Skip to content
GoBeginner5 min read

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.

GoBackend.dev
GoStructsMethodsComposition

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 UserUser 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 empty

Without 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 compared

Slices, 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 Base cannot accept a User by 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