Skip to content

OOP patterns in Go

Go has no class, no inheritance, and no constructors. Yet it covers everything object-oriented code reaches for — it just assembles the results from four smaller pieces you've already met: structs, methods, interfaces, and embedding. This article maps the familiar OOP ideas onto the Go way of doing them.

OOP idea Go mechanism
class struct + methods
constructor a NewT(...) T function (plain convention)
instance method method with a receiver
encapsulation exported vs unexported names (per package)
inheritance — none; use embedding for reuse
polymorphism interfaces
abstract base class an interface

"Objects" are structs with methods

A struct bundles the data; methods give it behaviour. The idiomatic "constructor" is just a function named New... that returns a ready value:

type Counter struct{ n int }

func NewCounter(start int) *Counter { return &Counter{n: start} }

func (c *Counter) Inc()       { c.n++ }
func (c *Counter) Value() int { return c.n }

c := NewCounter(10)
c.Inc()
fmt.Println(c.Value())   // output: 11

Encapsulation is per-package, not per-class

Go's access control is capitalisation: an identifier starting with an uppercase letter is exported (visible to other packages); lowercase is unexported (visible only inside its own package). The privacy boundary is the package, not the type.

type Account struct {
    owner   string   // unexported: other packages can't touch it
    balance int      // unexported
}

func NewAccount(owner string) *Account { return &Account{owner: owner} }

func (a *Account) Deposit(amount int) { a.balance += amount }
func (a *Account) Balance() int       { return a.balance }

a := NewAccount("Ada")
a.Deposit(100)
fmt.Println(a.Balance())   // output: 100

Code in another package can call Deposit and Balance but cannot read or write balance directly — that's encapsulation. (Within the same package everything is visible, so the barrier is about package boundaries.)

Composition over inheritance: embedding

Instead of subclassing, you embed one struct in another. The inner type's fields and methods are promoted, so the outer type appears to "have" them — reuse without an inheritance hierarchy.

type Logger struct{ prefix string }

func (l Logger) Log(msg string) string { return l.prefix + ": " + msg }

type Server struct {
    Logger          // embedded — Server gains Log()
    addr string
}

s := Server{Logger: Logger{prefix: "srv"}, addr: ":8080"}
fmt.Println(s.Log("up"))   // output: srv: up  — promoted from Logger

You can override a promoted method by defining one with the same name on the outer type; the outer one shadows the inner, which is still reachable via the field name:

func (s Server) Log(msg string) string {
    return "[" + s.addr + "] " + s.Logger.Log(msg)   // call the embedded one explicitly
}

s := Server{Logger: Logger{prefix: "srv"}, addr: ":8080"}
fmt.Println(s.Log("up"))   // output: [:8080] srv: up

From Python: embedding looks like inheritance but isn't — there's no base class and no super(). It's composition with automatic forwarding; you reach the inner value explicitly as s.Logger.

Polymorphism through interfaces

Different "subclasses" become different types satisfying one interface — no shared base required:

type Speaker interface{ Speak() string }

type Dog struct{}
func (Dog) Speak() string { return "woof" }

type Cat struct{}
func (Cat) Speak() string { return "meow" }

func chorus(speakers []Speaker) string {
    out := ""
    for _, s := range speakers {
        out += s.Speak() + " "
    }
    return out
}

fmt.Print(chorus([]Speaker{Dog{}, Cat{}}))   // output: woof meow 

Interface embedding: building bigger from smaller

Interfaces embed interfaces, composing capabilities. This is exactly how the standard library builds io.ReadWriter from io.Reader + io.Writer:

type Reader interface{ Read() string }
type Writer interface{ Write(s string) }

type ReadWriter interface {
    Reader          // embedded interfaces
    Writer
}

A type satisfies ReadWriter automatically once it has both Read and Write — no declaration needed:

type Buf struct{ data string }

func (b *Buf) Read() string   { return b.data }
func (b *Buf) Write(s string) { b.data = s }

var rw ReadWriter = &Buf{}    // *Buf has Read + Write, so it qualifies
rw.Write("hi")
fmt.Println(rw.Read())        // output: hi

*Buf never mentions ReadWriter, Reader, or Writer — having the two methods is enough.

Why no inheritance?

Go leaves out inheritance on purpose. Deep class hierarchies couple a subclass to its ancestors and grow brittle; Go pushes you toward small interfaces (for polymorphism) plus embedding (for reuse), which compose more flexibly. The practical guidance: model "is-a" with interfaces and "has-a" with embedding — and you'll rarely miss classes.

Quick reference

Want… Do this
an "object" struct + methods
a constructor func NewT(...) *T
private state lowercase (unexported) fields + exported methods
reuse another type's behaviour embed it
override a promoted method define same-named method on the outer type
polymorphism define an interface; many types satisfy it
combine capabilities embed interfaces

Sources