Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 005 / 100

Structs and methods in Go: practical tips and trade-offs

Go's way to model data and behavior.

Expona Admin8 min read

Introduction

When building production APIs, how you model and organize your domain logic directly affects how maintainable and performant your code becomes. Structs and methods in Go: practical tips and trade-offs explores one of Go's most powerful design patterns—bundling data with behavior in a way that's explicit, efficient, and easy to reason about. Unlike object-oriented languages where inheritance and polymorphism run deep, Go keeps things lean: structs hold data, methods attach to types, and you decide whether to mutate or copy based on real constraints, not frameworks.

In this guide, we'll walk through how to structure your domain models, when to use pointer receivers, and how embedding sidesteps verbose composition. By the end, you'll have patterns ready for shipping production services that scale from monoliths to microservices without surprises.

Structs group fields

A struct in Go is simply a collection of named fields with their types. It's the foundation for modeling your domain—orders, users, payments, sessions. Unlike maps or slices, a struct's shape is known at compile time, which gives the compiler room to optimize and type-check your data before it ships.

Consider a typical e-commerce scenario: you're building a checkout service and need to represent an order.

go
package checkout

type Order struct {
	ID        string
	UserID    string
	Items     []OrderItem
	Total     float64
	CreatedAt time.Time
	Status    OrderStatus
}

type OrderItem struct {
	ProductID string
	Quantity  int
	Price     float64
}

type OrderStatus string

const (
	StatusPending   OrderStatus = "pending"
	StatusConfirmed OrderStatus = "confirmed"
	StatusShipped   OrderStatus = "shipped"
)

Each field is explicit: no hidden fields, no optional keys that might vanish. When a service receives an Order, it knows exactly what to expect. This clarity pays dividends in a team environment—new engineers see the contract instantly.

Structs also support tags, which are metadata attached to fields. You'll use tags constantly in production APIs for JSON marshaling, database mapping, and validation.

go
type Order struct {
	ID        string    `json:"id" db:"id"`
	UserID    string    `json:"user_id" db:"user_id"`
	Total     float64   `json:"total" db:"total"`
	CreatedAt time.Time `json:"created_at" db:"created_at"`
	Status    OrderStatus `json:"status" db:"status"`
}

When you marshal this Order to JSON, the encoder respects the tags. The same struct works for database writes via an ORM or custom queries. No separate DTOs just for serialization—one clear shape.

Methods attach to types

A method in Go is a function that has a receiver. It's not a function living inside a struct; it's a function associated with a type. This distinction matters: you can define methods on any named type you control, including custom types built on primitives.

go
// Value receiver: operates on a copy
func (o Order) Total() float64 {
	var sum float64
	for _, item := range o.Items {
		sum += item.Price * float64(item.Quantity)
	}
	return sum
}

// Another method on Order
func (o Order) IsValid() bool {
	return o.ID != "" && o.UserID != "" && len(o.Items) > 0
}

Now any Order value can call these methods:

go
order := Order{
	ID:     "ord-123",
	UserID: "user-456",
	Items: []OrderItem{
		{ProductID: "prod-1", Quantity: 2, Price: 19.99},
	},
}

if order.IsValid() {
	fmt.Println("Order total:", order.Total())
}

Methods cluster behavior near your data. In production services, you'll often see methods that validate, compute derived values, or format for output. Grouping these near the struct definition keeps the codebase navigable.

One subtle but important detail: when you write a method, you're not modifying a class definition elsewhere. Go's approach is compositional from the start. If you want to add behavior to Order, define a method on it right in the checkout package or in a service package that wraps it. No monkey-patching, no decorator soup.

Pointer receivers to modify or avoid copies

Here's where many Go developers stumble: deciding between a value receiver and a pointer receiver. The choice affects both correctness and performance.

A value receiver operates on a copy of the struct. If the method modifies fields, those changes don't affect the original:

go
// Value receiver
func (o Order) UpdateStatus(status OrderStatus) {
	o.Status = status
}

order := Order{ID: "ord-123", Status: StatusPending}
order.UpdateStatus(StatusConfirmed)
fmt.Println(order.Status) // Still "pending" — copy was modified, not original

A pointer receiver operates on the original. Changes persist:

go
// Pointer receiver
func (o *Order) UpdateStatus(status OrderStatus) {
	o.Status = status
}

order := Order{ID: "ord-123", Status: StatusPending}
order.UpdateStatus(StatusConfirmed)
fmt.Println(order.Status) // Now "confirmed"

In production APIs, you'll use pointer receivers for methods that mutate state. Checkpoint: if a method changes a field, use *T. If it only reads, you can use either—but consistency matters.

Beyond correctness, pointer receivers save memory. Structs with many fields or large slices are expensive to copy. In a request handler processing dozens of orders per second, copying entire Order structs kills performance. With pointer receivers, you pass an 8-byte pointer instead.

go
// In a gRPC or REST handler
func (h *CheckoutHandler) ProcessOrder(order *Order) error {
	if !order.IsValid() {
		return fmt.Errorf("invalid order")
	}
	order.UpdateStatus(StatusConfirmed)
	return h.repo.Save(order)
}

Notice the mix: IsValid() uses a value receiver (it only reads), and UpdateStatus() uses a pointer receiver (it mutates). Go style is pragmatic: use the receiver that makes the intent clear and performs well.

One common pattern in production services is the receiver-as-dependency pattern. A method on a struct can also be a receiver on a service that wraps it:

go
type OrderService struct {
	repo  Repository
	cache Cache
	log   Logger
}

func (s *OrderService) ProcessOrder(ctx context.Context, order *Order) error {
	order.UpdateStatus(StatusConfirmed)
	if err := s.repo.Save(ctx, order); err != nil {
		s.log.Error("failed to save", err)
		return err
	}
	s.cache.Invalidate(order.ID)
	return nil
}

The receiver is a pointer to OrderService, giving the method access to dependencies. This pattern scales from simple checkers to full domain orchestration.

Embedding for composition

Go doesn't have inheritance. Instead, it offers embedding: you can include one struct (or interface) inside another, and automatically inherit its fields and methods. This is composition made lightweight.

Imagine you're building a ride-hailing service and need to model trips and administrative auditing. Trips and other domain objects need timestamps and audit trails:

go
type AuditMeta struct {
	CreatedBy string
	CreatedAt time.Time
	UpdatedBy string
	UpdatedAt time.Time
}

type Trip struct {
	AuditMeta // Embedded
	ID        string
	DriverID  string
	PassengerID string
	Status    TripStatus
}

Now Trip automatically has all fields from AuditMeta. You can access them directly:

go
trip := Trip{
	AuditMeta: AuditMeta{
		CreatedBy: "admin-1",
		CreatedAt: time.Now(),
	},
	ID:          "trip-789",
	DriverID:    "driver-1",
	PassengerID: "passenger-1",
}

fmt.Println(trip.CreatedBy)  // Works directly
fmt.Println(trip.CreatedAt)  // Works directly

More importantly, methods on AuditMeta are promoted to Trip. If you define a method on AuditMeta, Trip can call it as its own:

go
func (a *AuditMeta) UpdatedNow(user string) {
	a.UpdatedBy = user
	a.UpdatedAt = time.Now()
}

trip.UpdatedNow("driver-1") // Promoted method
fmt.Println(trip.UpdatedAt)

Embedding shines when you have cross-cutting concerns—audit trails, soft deletes, timestamps. Instead of repeating these fields in every struct, you embed once and let Go handle the promotion.

A note of caution: embedding can cause name collisions if both the outer and embedded struct have the same field. Go's shadowing rules are simple: outer wins. But clarity is king. If your team finds embedding confusing, explicit composition (a named field) is often better. Readability beats cleverness in production code.

Common mistakes

We've all seen these in code review.

  • Mixing value and pointer receivers on the same type: Pick one pattern and stick with it. If most methods mutate, use pointers across the board. If most read, use values, and go pointer only where needed. Consistency helps teammates predict behavior.
  • Forgetting to copy mutable fields when using value receivers: If your Order struct contains a slice (like Items), and you modify that slice in a value receiver, you're still modifying the original because slices are reference types. The value receiver copies the slice header, not the underlying array.
  • Over-embedding: Three levels of embedding becomes unreadable. Stick to one or two levels, and favor explicit composition when it gets complex.
  • Missing JSON tags: Every struct field that leaves your service in JSON should have a tag. Omitting them leads to surprised field names and bugs in integrations.
  • Exposing internal detail through struct fields: If a field is only for internal book-keeping, consider lowercase (unexported). Structs can have private fields just like types can have private methods.

Key takeaways

  • Structs group related fields; use them as your primary data-modeling tool in production APIs.
  • Methods attach behavior to types; they keep code organized and intent clear near your data definitions.
  • Use pointer receivers for methods that mutate, value receivers for methods that only read—and be consistent across a type.
  • Embedding is a lightweight composition tool for sharing fields and methods across types; use it for cross-cutting concerns like audit metadata, but don't overuse it.
  • Tags on struct fields drive JSON marshaling, database mapping, and validation frameworks; always tag fields that cross service boundaries.
  • Go's approach favors simplicity and explicitness over inheritance and reflection; embrace it and your code will be easier for teams to maintain.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles