Skip to main content

Go Structs: Composition and Embedding

Published: December 17, 2025 Updated: August 29, 2026 Larry Qu 6 min read

Go has no classes and no inheritance. Instead, it has structs — named collections of fields — and two mechanisms for reuse: composition (a struct field of another struct type) and embedding (an anonymous field that promotes the embedded type’s fields and methods). These are simpler and more explicit than class hierarchies, and they cover the same use cases without the fragility of deep inheritance.

For methods on structs see Go function receivers and methods. For interface usage with structs see Go interfaces.

Defining and Initializing Structs

A struct groups related fields under a single name. Field order matters for memory alignment but not for readability — use named field initialization:

type User struct {
    ID        int
    Name      string
    Email     string
    CreatedAt time.Time
    Active    bool
}

// Named field initialization — clear and order-independent
u := User{
    ID:        1,
    Name:      "Alice",
    Email:     "[email protected]",
    CreatedAt: time.Now(),
    Active:    true,
}

// Zero value — all fields get their type's zero value
var empty User  // ID=0, Name="", Active=false, etc.

// Pointer to struct — common when passing to functions that modify the struct
up := &User{ID: 2, Name: "Bob"}

Positional initialization (User{1, "Alice", ...}) works but is brittle — adding a field breaks every call site. Always use named fields for structs with more than two or three fields.

Methods

Structs gain behavior through methods. The receiver type determines whether the method can modify the struct:

type Rectangle struct {
    Width  float64
    Height float64
}

// Value receiver: gets a copy — safe for reads, can't modify the original
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

func (r Rectangle) String() string {
    return fmt.Sprintf("%.1f × %.1f", r.Width, r.Height)
}

// Pointer receiver: gets a pointer — required for mutations
func (r *Rectangle) Scale(factor float64) {
    r.Width *= factor
    r.Height *= factor
}

Go automatically takes the address when needed — r.Scale(2) works even if r is a value, not a pointer. The convention: if any method on a type uses a pointer receiver, use pointer receivers for all methods on that type for consistency.

Composition: Structs as Fields

Composition means a struct contains another struct as a named field. Access is explicit through the field name:

type Address struct {
    Street string
    City   string
    State  string
}

type Employee struct {
    Name    string
    Email   string
    Address Address  // named field — access as e.Address.City
    Manager *Employee
}

e := Employee{
    Name:  "Alice",
    Email: "[email protected]",
    Address: Address{
        Street: "123 Main St",
        City:   "Springfield",
        State:  "IL",
    },
}

fmt.Println(e.Address.City)  // Springfield

Composition is explicit. You always know where a field comes from. This is the right choice when the relationship is “has a” — an Employee has an Address.

Embedding: Promoting Fields and Methods

Anonymous embedding places a struct (or any named type) inside another without a field name. The embedded type’s fields and methods are promoted — accessible directly on the outer struct:

type Timestamps struct {
    CreatedAt time.Time
    UpdatedAt time.Time
}

func (t *Timestamps) Touch() {
    t.UpdatedAt = time.Now()
}

type Post struct {
    Title   string
    Content string
    Timestamps  // anonymous — fields and methods promoted
}

p := Post{Title: "Hello", Content: "World"}
p.CreatedAt = time.Now()  // directly accessible
p.Touch()                 // Timestamps.Touch() promoted to Post

// Still accessible via the type name when needed
fmt.Println(p.Timestamps.CreatedAt)

Embedding is the right choice when the relationship is “is implemented in terms of” — a Post uses timestamp tracking functionality. It’s how Go achieves code reuse without inheritance.

A struct can embed multiple types. If two embedded types have the same field or method name, accessing it directly is ambiguous — you must qualify with the type name (p.TypeA.Field vs p.TypeB.Field).

Embedding Interfaces

You can embed an interface in a struct. This is less common but useful for two patterns:

Test doubles: embed the interface and override only the methods you care about:

type UserStore interface {
    GetUser(id int) (*User, error)
    CreateUser(u *User) error
    DeleteUser(id int) error
}

// Partial mock — embed interface, only define methods the test uses
type mockStore struct {
    UserStore  // satisfies interface; panics if unimplemented methods are called
    getUser func(id int) (*User, error)
}

func (m *mockStore) GetUser(id int) (*User, error) {
    return m.getUser(id)
}

Wrapper/middleware: embed an interface in a struct that wraps its behavior:

type loggingStore struct {
    UserStore  // delegate to the real store
    logger     *slog.Logger
}

func (l *loggingStore) GetUser(id int) (*User, error) {
    l.logger.Info("GetUser", slog.Int("id", id))
    u, err := l.UserStore.GetUser(id)  // delegate to embedded
    if err != nil {
        l.logger.Error("GetUser failed", slog.Any("error", err))
    }
    return u, err
}

Only GetUser is overridden — all other UserStore methods are forwarded to the embedded real store automatically.

Struct Tags

Struct tags are string metadata attached to fields, read at runtime via reflection. They control serialization, validation, and ORM behavior:

type Product struct {
    ID          int       `json:"id"          db:"id"`
    Name        string    `json:"name"         db:"name"         validate:"required,min=2"`
    Price       float64   `json:"price"        db:"price"        validate:"min=0"`
    Description string    `json:"description"  db:"description"  validate:"max=500"`
    Internal    string    `json:"-"            db:"-"`           // never serialized
    CreatedAt   time.Time `json:"created_at"   db:"created_at"`
}

The convention is key:"value" pairs. Multiple tags for different packages are separated by spaces. Common tag keys:

  • json: controls encoding/json marshaling (field name, omitempty, - to omit)
  • db: used by sqlx, GORM, and other database libraries for column mapping
  • validate: used by github.com/go-playground/validator for struct validation
  • yaml: controls YAML marshaling
  • form: used by frameworks for form parsing

Tags are accessed at runtime via reflect.TypeOf(v).Field(i).Tag.Get("json"). They have no compile-time checking — typos silently go unnoticed, which is why IDE support matters.

Copying and Comparing Structs

Structs are value types — assigning copies all fields:

a := User{ID: 1, Name: "Alice"}
b := a          // b is a copy — changing b doesn't affect a
b.Name = "Bob"
fmt.Println(a.Name)  // Alice
fmt.Println(b.Name)  // Bob

Two structs are comparable (with ==) if all their fields are comparable. Structs with slice, map, or function fields are not comparable:

type Point struct{ X, Y int }
p1 := Point{1, 2}
p2 := Point{1, 2}
fmt.Println(p1 == p2)  // true

type Bag struct{ Items []string }
b1 := Bag{Items: []string{"a"}}
b2 := Bag{Items: []string{"a"}}
// b1 == b2 — compile error: Bag contains []string which is not comparable

For structs containing slices or maps, use reflect.DeepEqual or write a custom equality method.

Why Composition Over Inheritance

Inheritance creates implicit coupling — changing a base class affects all derived classes in ways that can be hard to predict. Composition and embedding are explicit: you can see exactly what fields and methods come from where, and changing an embedded type only affects the outer type where you embed it.

Go’s approach also avoids the “fragile base class” problem and diamond inheritance. The tradeoff is more verbosity in some cases — you sometimes need to write a forwarding method explicitly. In practice, this is rarely a burden and often clarifies intent.

Summary

  • Use named field initialization — positional initialization breaks when fields are added
  • Pointer receivers for mutations; value receivers for reads — be consistent across a type
  • Composition (named field): use when the relationship is “has a” — always explicit with outer.Field.SubField
  • Embedding (anonymous field): use when you want field/method promotion — “implemented in terms of” relationship
  • Embedding interfaces enables partial mocks and transparent middleware wrappers
  • Struct tags control serialization and validation — spell carefully, there’s no compile-time check

Resources

Comments

👍 Was this article helpful?