Structs, Methods, and Composition in Go
How to define your own types with structs, attach behavior with methods, and build up functionality through embedding instead of inheritance — with diagrams and real examples.
TL;DR
A struct groups related fields into one type; a method is a function with a receiver that attaches behavior to that type; and Go builds up functionality by embedding one struct inside another (composition) instead of class inheritance.
What You'll Learn
- How to define a struct and construct values of it
- How methods attach to a type through a receiver, and how that differs from a plain function
- How struct embedding works, and how method promotion makes an embedded type's methods appear on the outer type
- Why Go uses composition instead of inheritance, and what that means in practice
- How struct equality and comparison actually work
- How to use struct tags (like json:"name") to control encoding
Prerequisites
The problem
Every real program needs to model something with more than one piece of data — a user has an ID, a name, and an email; an order has a total, a status, and a list of line items. A struct is Go's answer to "group these related fields into one type," and methods are how you attach behavior to that type without reaching for a class hierarchy Go doesn't have.
Defining and constructing a struct
type User struct {
ID string
Name string
Email string
}Three equivalent ways to construct one:
u1 := User{ID: "1", Name: "Ada", Email: "ada@example.com"} // named fields — the one to prefer
u2 := User{"1", "Ada", "ada@example.com"} // positional — brittle, avoid
u3 := User{} // zero value: "", "", ""Named fields are worth defaulting to — adding a field later, or reordering existing ones, doesn't silently break every positional construction elsewhere in the codebase.
Methods: functions with a receiver
A method is a function declared with an extra parameter — the receiver — written before the function name:
func (u User) Greeting() string {
return "Hello, " + u.Name
}
u := User{Name: "Ada"}
fmt.Println(u.Greeting()) // "Hello, Ada"(u User) is the receiver — it means Greeting is a method on User,
and inside the method body, u is that specific User value. Pointers in
Go already covered the choice between a value receiver
(u User) and a pointer receiver (u *User): use a pointer receiver when
the method needs to mutate the struct, a value receiver otherwise.
func (u *User) UpdateEmail(email string) {
u.Email = email // mutates the real User through the pointer
}Composition: embedding instead of inheritance
Go has no class, no extends, and no inheritance. Instead, one struct can
embed another by declaring a field with only a type name, no field name:
type Base struct {
CreatedAt time.Time
UpdatedAt time.Time
}
func (b Base) Age() time.Duration {
return time.Since(b.CreatedAt)
}
type User struct {
Base // embedded — no field name, just the type
Name string
}Rendering diagram…
The embedded type's fields and methods are promoted onto the outer
struct — you can call them directly, as if they belonged to User itself:
u := User{Base: Base{CreatedAt: time.Now()}, Name: "Ada"}
fmt.Println(u.CreatedAt) // promoted field — no need for u.Base.CreatedAt
fmt.Println(u.Age()) // promoted method — actually Base.Age()This is composition, not inheritance: User doesn't become a Base and
there's no polymorphism where a Base-typed variable can secretly hold a
User — User simply has a Base, and Go promotes its members as a
convenience. If User declares its own Age() method, that one wins;
promoted methods are shadowed exactly the way a locally-declared field or
method always takes precedence.
This is also how the site's own article pages compose behavior — a
Base-style embedded type for shared fields (timestamps, IDs) is a common
pattern across real Go codebases, not just a toy example, precisely because
it avoids repeating the same three fields and one method on every struct
that needs them.
Struct tags
A struct tag is a string literal attached to a field, read by reflection
at runtime — most commonly by encoding/json:
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"` // omit this field if it's empty
}json.NewEncoder(os.Stdout).Encode(User{ID: "1", Name: "Ada"})
// {"id":"1","name":"Ada"} — Email omitted because it was emptyWithout the tag, encoding/json falls back to the field's exact Go name
(ID, Name, Email) — which is rarely the JSON casing an API is
expected to use, so tagging every exported field of an API-facing struct is
standard practice.
Struct equality
Two struct values are comparable with == if — and only if — every field is
itself comparable:
type Point struct{ X, Y int }
p1 := Point{1, 2}
p2 := Point{1, 2}
fmt.Println(p1 == p2) // true — compares field by field
type Bag struct{ Items []string } // slice field
b1 := Bag{[]string{"a"}}
// b1 == b1 // compile error: struct containing []string cannot be comparedSlices, maps, and functions are never comparable, so any struct containing
one of them as a field loses == entirely — reach for
reflect.DeepEqual or a hand-written comparison method when that happens
and you genuinely need equality.
Common mistakes
- Positional struct literals (
User{"1", "Ada", "..."}) — a field reorder elsewhere breaks every one of these silently, with no compiler error pointing at the mismatch. - Expecting embedding to behave like class inheritance — there's no
dynamic dispatch; a function expecting a
Basecannot accept aUserby substitution the way a subclass could. - Forgetting struct tags on API-facing types, then being surprised the JSON output uses Go's exact field names instead of the expected casing.
- Trying to
==compare a struct with a slice or map field — it's a compile error, not a runtime surprise, but confusing the first time.
Summary
A struct groups related fields into one named type; a method is a function tied to that type through a receiver, following the same pointer-vs-value rule already covered for pointers. Go replaces inheritance with embedding: a struct can embed another, and Go promotes the embedded type's fields and methods onto the outer one — composition, not a class hierarchy. Struct tags control how a type is encoded, and equality only works when every field is itself comparable. That's the entire model Go uses in place of the class systems most other languages reach for.
Key Takeaways
- A struct is a value type grouping named fields; a method is a function whose receiver ties it to a specific type
- Embedding one struct inside another promotes the embedded type's fields and methods onto the outer type, without any inheritance mechanism involved
- Composition ('has-a') replaces inheritance ('is-a') in Go — you build behavior by embedding small, focused types
- Struct tags like `json:"name"` are how encoding/json (and similar packages) know what field name to use
- Two structs are comparable with == only if every field is itself comparable — slices and maps inside a struct make it incomparable
Go Fundamentals Series
Article 4 of 13
- 1.Go Modules and Packages: Setting Up and Structuring a Go Project
- 2.Slices, Arrays, and Maps in Go: How They Actually Work
- 3.Pointers in Go: When to Use *T and Why
- 4.Structs, Methods, and Composition in Go
- 5.Error Handling in Go: Custom Errors, Wrapping, and errors.Is/As
- 6.Defer, Panic, and Recover in Go
- 7.Go Interfaces: Design Small, Testable Components
- 8.Goroutines and Channels in Go: A Fresher-Friendly Guide
- 9.Understanding HTTP Servers in Go: From TCP Listen to Your First Handler
- 10.sync.WaitGroup, sync.Mutex, and the Race Detector in Go
- 11.Context in Go: Cancellation, Deadlines and Request Propagation
- 12.Building Background Workers in Go with Goroutines and Channels
- 13.Graceful Shutdown in Go HTTP Servers
funcRelated()[]Article
Pointers in Go: When to Use *T and Why
What a pointer actually is, why Go passes everything by value by default, and the concrete rule for when a function or method should take a pointer instead of a copy — with diagrams.
Go Interfaces: Design Small, Testable Components
Design small, focused Go interfaces for dependency injection, mocking, and testable backend code, with realistic service and repository examples.
Defer, Panic, and Recover in Go
How defer actually schedules a call, what panic does to the call stack, why recover only works inside a deferred function, and the real-world pattern of recovering from panics in HTTP middleware — with diagrams.