Go Structs: Simple Data, Real Design Decisions

One of the easiest ways to misunderstand Go is to treat structs as a weaker replacement for classes.

That framing is convenient, but it is not very accurate.

A struct in Go is not basically a class without inheritance. It is a simpler way to model data, and the interesting part is not what it is missing. The interesting part is what the language chooses not to build on top of it.

That choice affects how Go code models payloads, request data, domain records, background jobs, configuration, and behavior that lives close to data without turning everything into a mini object hierarchy.

This is not a beginner tutorial on what a struct is. This is a closer look at how structs work in Go, why they matter so much in real code, and which details become more interesting once you stop treating them as just a bunch of fields.

A struct is usually just a shape of data

At the most basic level, a struct is a named grouping of fields.

That sounds almost too simple to mention, but it matters because Go stays surprisingly disciplined here. A struct is usually just describing the shape of data you want to keep together.

A realistic example:

type WebhookEvent struct {
    ID        string
    StoreID   string
    Topic     string
    CreatedAt time.Time
}

That is not trying to be clever. It is just saying: these values belong together.

You see structs like this everywhere in real Go code:

  • incoming API payloads
  • database rows mapped into app-level data
  • internal job messages
  • service configuration
  • domain entities like orders, users, invoices, and payouts

That is one reason structs matter so much in Go. They are not some secondary feature sitting behind a class system. They are one of the main ways the language models real data.

Zero values matter here too

One of the more important things about structs in Go is that all their fields start with zero values unless you set them explicitly.

That means:

  • strings start as ""
  • integers start as 0
  • booleans start as false
  • pointers start as nil
  • nested structs get their own zero values too

For example:

type DeliveryJob struct {
    OrderID   string
    Retry     int
    Immediate bool
}

func main() {
    var job DeliveryJob

    fmt.Println(job.OrderID)   // ""
    fmt.Println(job.Retry)     // 0
    fmt.Println(job.Immediate) // false
}

This is one of those things that sounds basic until it starts shaping how your code is written.

In Go, it is common to design types so that their zero value is at least safe, and sometimes directly usable. That is not always possible, but when it is, it usually makes code simpler.

A simple example:

type RetryPolicy struct {
    MaxAttempts int
}

func (p RetryPolicy) CanRetry(current int) bool {
    return current < p.MaxAttempts
}

This one may not be fully useful at zero value, but it is at least predictable. In other cases, you can design structs where the zero value works cleanly without extra setup.

That bias shows up often in Go: if something can start safely from zero, it usually should.

Nested structs are common because real data is layered

Real systems rarely deal with flat data forever.

An order has a customer. A webhook has metadata. A shipment has an address. A billing record has money, currency, and timing.

That is where nested structs become natural.

type Address struct {
    City    string
    Country string
}

type Warehouse struct {
    Code    string
    Address Address
}

And then:

warehouse := Warehouse{
    Code: "TR-IZM-01",
    Address: Address{
        City:    "Izmir",
        Country: "Turkey",
    },
}

This is a normal pattern in Go. You define one shape, then use it inside another shape.

That part is not complicated, but it is important because nested structs let you keep related data grouped without flattening everything into one oversized type. Once a system grows, that matters more than it first appears.

Field names matter because package boundaries matter

In Go, a field name starting with an uppercase letter is exported. A field starting with a lowercase letter is not.

That rule is simple, but it carries real design weight.

type CheckoutRequest struct {
    OrderID string
    amount  int
}

From outside the package, OrderID is visible. amount is not.

This is one of those language choices that feels tiny until you start organizing code across packages. Then it becomes one of the main ways you control what a type exposes.

This also means structs are not just dumb containers. The field list is part of your package boundary. Exposing a field is a design choice. Hiding a field is also a design choice.

That is worth being deliberate about, especially in shared internal packages and public libraries.

Anonymous structs are useful, but usually not as a default habit

Go allows anonymous structs too.

That means you can define a one-off shape inline without giving the type a name:

payload := struct {
    EventID string `json:"event_id"`
    Status  string `json:"status"`
}{
    EventID: "evt_123",
    Status:  "processed",
}

This can be useful when:

  • the shape is truly local
  • you only need it once
  • naming the type would not really improve clarity
  • you are building a short JSON response or test case

For example, anonymous structs are pretty handy in tests and small HTTP handlers.

But in ordinary application code, they are easy to overuse. If the shape matters enough to exist in more than one place, or if it represents a concept in your system, naming it usually improves the code.

That is the trade-off: anonymous structs are convenient, but named structs usually age better.

Struct literals are clearer when they use field names

Go allows composite literals, and for structs that usually means one of two styles:

type Money struct {
    Amount   int
    Currency string
}

You can write:

m := Money{1000, "USD"}

or:

m := Money{
    Amount:   1000,
    Currency: "USD",
}

The first works. The second is usually better.

Field names make the code more readable and more stable against future changes. If someone reorders the fields in the type definition, positional literals can quietly become misleading or even break in awkward ways.

In small private code, positional literals may be tolerable. In most real-world code, named fields are easier to trust.

Embedded structs are useful, but they are easy to misread

This is one of the most commonly misunderstood parts of Go structs.

You can embed one struct inside another without giving it a field name:

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

type Invoice struct {
    ID string
    AuditFields
}

Now Invoice gets promoted access to CreatedAt and UpdatedAt:

invoice := Invoice{}
invoice.CreatedAt = time.Now()

That is useful. But it is also where people start reaching too quickly for the word inheritance.

That mental model usually causes more confusion than help.

Embedding in Go is better understood as composition with promoted fields, not class inheritance in disguise. It is often a way to reuse a shared chunk of data or behavior without retyping fields everywhere.

You see this in things like:

  • audit fields
  • base config blocks
  • reusable metadata
  • shared response fragments
  • common model pieces

It is a practical feature. It is just better not to force an OO story onto it.

Struct tags matter because boundaries matter

If structs are one of the main ways Go models data, tags are one of the ways that data crosses boundaries.

A common example is JSON:

type CheckoutRequest struct {
    OrderID  string `json:"order_id"`
    StoreID  string `json:"store_id"`
    Currency string `json:"currency"`
    Amount   int    `json:"amount"`
}

Those tags are metadata. They tell external tooling or packages how to interpret the fields.

That matters in real systems because structs often sit right at the edges:

  • JSON request and response bodies
  • database mappers
  • config loaders
  • validation packages
  • serialization layers

One useful thing to keep in mind: tags are not behavior. They do not change what the field is. They change how some external process reads or writes it.

That distinction helps keep the model clean in your head.

Structs are values, and that matters more than people think

One of the most important details about structs is that they are values.

If you assign a struct to another variable, you copy it.

type InventoryItem struct {
    SKU       string
    Available int
}

func main() {
    original := InventoryItem{SKU: "ABC-123", Available: 20}
    copy := original

    copy.Available = 5

    fmt.Println(original.Available) // 20
    fmt.Println(copy.Available)     // 5
}

This is a very important part of Go’s mental model.

A struct is not automatically some invisible shared object reference just because it contains fields. If you pass it around, assign it, or return it, you are often dealing with copies unless you intentionally design otherwise.

That affects:

  • function design
  • method receivers
  • API expectations
  • performance for larger structs
  • whether updates are local or shared

This is also one reason people sometimes get surprised when they first move into bigger Go codebases. Structs look lightweight and simple, which they are, but value semantics still matter.

Equality is simple until it is not

Another useful struct detail is comparability.

Some structs can be compared with ==. Some cannot.

For example:

type FeatureFlag struct {
    Name    string
    Enabled bool
}

This is fine:

a := FeatureFlag{Name: "discounts", Enabled: true}
b := FeatureFlag{Name: "discounts", Enabled: true}

fmt.Println(a == b) // true

But once a struct contains fields like slices, maps, or functions, direct comparison stops being allowed.

That is one of those details that looks minor until you hit it in tests, caches, or logic that assumes two values can be compared directly.

The rule itself is not especially hard. The interesting part is that it reminds you Go structs are not magical objects. They are values with pretty concrete rules.

Methods on structs keep behavior close to data, but not too close

Once you have a struct, you can define methods on it.

For example:

type Cart struct {
    Subtotal int
    Tax      int
}

func (c Cart) Total() int {
    return c.Subtotal + c.Tax
}

It also helps to say out loud what this really is.

The method syntax is convenient, but it is still basically syntax sugar over a function that takes the receiver explicitly:

func CartTotal(c Cart) int {
    return c.Subtotal + c.Tax
}

That is not how you would usually write real Go code, but it is a useful mental model.

It keeps the feature grounded:

  • the receiver is still just an argument
  • the method is still just behavior attached to a type
  • nothing here turns the struct into a class hierarchy mechanism

This is often where people start mapping everything back to classes.

That instinct is understandable, but it is better to keep it under control.

A method on a struct in Go is often just a convenient way to attach behavior that clearly belongs near that data. It does not mean the type should now become the center of a deep OO universe.

This works well for things like:

  • computed totals
  • validation checks
  • normalization helpers
  • status checks
  • small formatting logic

It works less well when every piece of behavior starts getting stuffed into one type just because methods exist.

Go gives you methods. It does not force you into method-heavy design.

Real-world structs often sit at system boundaries

A lot of structs in Go live at boundaries.

They are request types, response types, event payloads, config types, job payloads, row-shaped records, or service-facing DTOs.

For example:

type RefundRequest struct {
    PaymentID string `json:"payment_id"`
    Reason    string `json:"reason"`
    Amount    int    `json:"amount"`
}

or:

type WorkerConfig struct {
    QueueName   string
    MaxWorkers  int
    BatchSize   int
    RetryWindow time.Duration
}

These are not glamorous types. That is fine. Most useful structs are not glamorous.

What matters is whether the shape is clear, whether the fields reflect an actual concept, and whether the type is honest about the boundary it represents.

That is one of the reasons structs matter so much in Go: they are often where the system says, this is the shape of the thing.

So what makes structs in Go feel different?

If you zoom out, a few patterns show up repeatedly:

  • structs are usually just shapes of data
  • zero values matter
  • nested structs are normal
  • field visibility is part of package design
  • anonymous structs are useful, but limited
  • embedding helps composition, but is not inheritance
  • tags matter at boundaries
  • structs are values
  • methods can live close to data without turning everything into classes

None of that is especially dramatic.

That is probably the point.

Go structs feel simple because the language keeps them close to the actual job they need to do. They model data, carry boundaries, and support just enough attached behavior to stay practical.

That does not make them shallow. It just makes them easier to misread if you expect them to behave like something else.

Final thoughts

It is easy to look at Go structs and think, that is just fields.

Sometimes that is true.

But once you start building actual systems, structs become one of the main places where Go’s design choices become visible: value semantics, package boundaries, zero values, composition over hierarchy, and very direct data modeling.

That is what makes them worth paying attention to.

Not because the syntax is complicated. But because the simplicity hides a lot of design decisions that affect how real Go code ends up being written.