ToolPopToolPop
Go · Lesson 7 of 22

Structs and methods, Go OOP without classes

6 min readUpdated 25 Jun 2026

Go does not have classes. It has structs and methods. After a week, you stop missing classes. After a month, you start preferring this. After a year, you wonder why the rest of the industry made everything so complicated.

Structs, fields, and embedding

A struct is a typed bag of named fields. Always name your fields when constructing one. Positional struct literals break the moment someone adds a field in the middle.

go
type Order struct {
    ID     string // capital = exported, visible outside the package
    Amount int
    paid   bool   // lowercase = package-private
}
 
o := Order{ID: "ord_001", Amount: 49900}
o2 := Order{}  // zero value, all fields zeroed

Capital first letter means exported. Lowercase means package-private. Same rule as functions. This matters for JSON marshalling, where only exported fields appear in the output.

Anonymous (embedded) fields are how Go does composition. The embedded struct's fields and methods get promoted to the outer struct. No extends, no super calls.

go
type Audit struct {
    CreatedAt time.Time
    CreatedBy string
}
 
type Payment struct {
    Audit         // embedded, no field name
    Amount int
    Method string
}
 
p := Payment{}
p.CreatedAt = time.Now()    // works, promoted from Audit
p.Audit.CreatedBy = "kiran" // also works, explicit

Methods, value vs pointer receivers

A method is a function with a receiver, declared before the name. The receiver is what you would call this in JavaScript or self in Python, except you choose the name and you choose value or pointer.

go
type Wallet struct {
    Balance int
}
 
func (w Wallet) Display() string {
    return fmt.Sprintf("Rs.%d", w.Balance/100)
}
 
func (w *Wallet) Credit(n int) {
    w.Balance += n
}
 
w := Wallet{Balance: 100}
w.Credit(50)       // Go auto-takes &w, Balance is now 150
w.Display()        // "Rs.1"

Go auto-takes the address for you when calling a pointer-receiver method on an addressable value. Convenient, but you still pick which kind of receiver each method gets.

Senior rule: use pointer receivers when (a) the method mutates the struct, OR (b) the struct is large enough that copying it on every call hurts. Use value receivers when neither. Pick one style per type, do not mix.

Mixing receivers on the same type causes subtle bugs around interface satisfaction. We will see why in the interfaces lesson.

Struct tags, the JSON edition

Struct tags are string metadata on fields, read by libraries via reflection.

go
type User struct {
    ID    string `json:"id"`
    Email string `json:"email,omitempty"`
    Pwd   string `json:"-"`  // never marshal
}

When you json.Marshal a User, you get the lowercase JSON your API consumers expect, omitempty drops empty fields, and - skips fields entirely. Other tags exist for db:, validate:, yaml:, all read by their respective libraries.

The zero-value struct should be usable

Idiomatic Go designs structs so the zero value is immediately useful. sync.Mutex{} is the canonical example. You never write NewMutex(). You just declare a sync.Mutex and use it.

go
// good: zero value works
type Limiter struct {
    PerSecond int // 0 means unlimited
}
 
// when you genuinely need a constructor
type Cache struct {
    items map[string]string
    ttl   time.Duration
}
 
func NewCache(ttl time.Duration) *Cache {
    return &Cache{items: make(map[string]string), ttl: ttl}
}

Provide a NewX constructor only when the zero value cannot be useful, like when you need a non-nil map, an external connection, or a started goroutine. Return a pointer when the type is meant to be mutated or shared. Return a value when it is small and immutable.

Composition over inheritance, in practice

Embedding lets you build big types out of small ones. The standard library is full of it. bufio.Reader embeds an io.Reader. http.Request embeds url.URL. sync.RWMutex is built from sync.Mutex. You will reach for this whenever you find yourself wanting "this type but with one more capability". No frameworks, no decorators, just a field with no name.

The tradeoff: embedding is not inheritance, and you should not pretend it is. If you embed a Logger, you do not get to override its Log method polymorphically. You can shadow it by defining Log on the outer struct, but the embedded one is still callable directly. This is on purpose. Composition is supposed to be additive, not substitutive. Once you internalise this, you stop trying to model "is-a" hierarchies and start modelling "has-a" capabilities, which is what most domain models actually are.

Diagram
rendering diagram...

Quick reference

Struct
A typed collection of named fields. Go's primary way to group related data.
Method
A function with a receiver that ties it to a type.
Value receiver
func (x T) M(). The method gets a copy of the struct.
Pointer receiver
func (x *T) M(). The method gets a pointer and can mutate the original.
Embedding
Putting one struct inside another anonymously. Promotes fields and methods to the outer type.
Struct tag
Backtick string metadata on a field, read by libraries like encoding/json via reflection.

Next, interfaces. Where Go's implicit satisfaction will rewire how you think about polymorphism.

Chai0/1 done

Watching quietly. Tap me if you want a tip.

Go Playground

Go cannot run natively in a browser. Run copies your code and opens go.dev/play ; paste and click Run there.

Try this (0 of 1 done)

  1. 1

    Predict the output.

    show answer
    // already in the editor