Structs in Go: how to model data without traditional classes

Structs in Go explained: methods, composition, JSON tags, visibility, and differences from classical object orientation.

Cover for Structs in Go: how to model data without traditional classes

Go has no classes. No inheritance. No constructors in the traditional sense. And yet, you can model complex domains without any trouble. It’s not that Go eliminates data modeling: it eliminates some of the theater that sometimes surrounds object orientation.

If you come from Java or Kotlin, this feels uncomfortable at first. You’re used to class hierarchies, extends, abstract, inheritance patterns you’ve been practicing for years. Go tells you: you don’t need any of that. What you need is a struct, some methods, and composition. And it turns out that with those tools you build software that’s just as expressive and considerably easier to read.

Structs are the fundamental modeling mechanism in Go. Everything goes through them: your domain entities, your DTOs, your configurations, your API responses. Understanding them well isn’t optional. It’s the foundation on which any Go program beyond a script is built.


Defining a struct: fields, types, and zero values

A struct in Go is a collection of named, typed fields. Nothing more. It has no associated methods in its definition (that comes later). It has no inheritance. It has no per-field visibility via keywords like private or protected. It is, by design, the simplest data structure you can have.

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

Each field has an explicit type. No magic inference, no native optional types (though you can use pointers for that). And something that surprises many people: all fields have a default zero value. Not null, not nil (except for pointers, slices, maps, and channels). A concrete and deterministic zero value.

TypeZero value
int, float640
string""
boolfalse
*T (pointer)nil
[]T (slice)nil
map[K]Vnil
time.TimeZero date (year 1)

This means an empty User{} is perfectly valid: ID is 0, Name is "", Active is false, CreatedAt is the zero date. It doesn’t throw exceptions, it’s not null. It has a defined, predictable state.

var u User
fmt.Println(u.Name)   // "" (empty string)
fmt.Println(u.Active) // false
fmt.Println(u.ID)     // 0

If you come from Java, think about this: you don’t need to initialize fields. You don’t need constructors that assign default values. The language already guarantees that every type has a safe initial state. This eliminates an entire category of NullPointerException-related bugs.


Creating instances: literal syntax and constructor functions

Go doesn’t have a new keyword in the Java sense. There are two main ways to create structs, and each has its use.

Literal syntax

The most direct. You create the struct and assign the fields you need:

user := User{
    ID:    1,
    Name:  "Roger",
    Email: "roger@oshy.tech",
    Active: true,
}

Fields you don’t specify take their zero value. This is intentional and useful: if a boolean should default to false, simply don’t include it.

You can also create structs without naming the fields, but don’t do it:

// This compiles, but it's brittle and unreadable
user := User{1, "Roger", "roger@oshy.tech", true, time.Now()}

If someone adds a field to the struct tomorrow, this code breaks. Always use the syntax with field names.

Constructor functions

Go has no constructors as a special method of the type. Instead, the convention is to create a function starting with New:

func NewUser(name, email string) *User {
    return &User{
        Name:      name,
        Email:     email,
        Active:    true,
        CreatedAt: time.Now(),
    }
}

Notice several details:

  • It returns a pointer (*User), not a value. This is the usual convention when the struct will be modified or shared.
  • It sets default values (Active: true, CreatedAt: time.Now()).
  • It doesn’t need a special keyword. It’s a normal function. No magic.

This convention is so strong in Go that when you see NewX you immediately know it’s a constructor. No annotations, no reflection, no framework registering anything.

The & operator

You can create a pointer to a struct directly with &:

user := &User{Name: "Roger"}

This is equivalent to creating the struct and then taking its address. It’s idiomatic and you’ll see it everywhere. If you’re not comfortable with pointers in Go, I recommend reading Pointers in Go first before continuing.


Methods: value receivers vs pointer receivers

This is where Go starts to differentiate itself from mere data structures. Structs can have associated methods, but they’re not defined inside the struct (as you would in a class). They’re defined outside, with a receiver.

type Rectangle struct {
    Width  float64
    Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

func (r Rectangle) Perimeter() float64 {
    return 2 * (r.Width + r.Height)
}

The (r Rectangle) before the method name is the receiver. It tells Go that this method belongs to the Rectangle type. To use it:

rect := Rectangle{Width: 10, Height: 5}
fmt.Println(rect.Area())      // 50
fmt.Println(rect.Perimeter()) // 30

So far everything seems cosmetic. The real difference shows up with pointer receivers.

Value receiver vs pointer receiver

A value receiver works on a copy of the struct. A pointer receiver works on the original.

// Value receiver: does NOT modify the original
func (r Rectangle) Scale(factor float64) Rectangle {
    return Rectangle{
        Width:  r.Width * factor,
        Height: r.Height * factor,
    }
}

// Pointer receiver: DOES modify the original
func (r *Rectangle) ScaleInPlace(factor float64) {
    r.Width *= factor
    r.Height *= factor
}
rect := Rectangle{Width: 10, Height: 5}

scaled := rect.Scale(2)
fmt.Println(rect.Width)   // 10 (unchanged)
fmt.Println(scaled.Width) // 20

rect.ScaleInPlace(2)
fmt.Println(rect.Width)   // 20 (changed)

Practical rules for choosing:

  • Use pointer receiver if the method modifies the struct.
  • Use pointer receiver if the struct is large (avoid copying a lot of memory).
  • Use value receiver if the struct is small and immutable (like a coordinate or a time range).
  • Be consistent: if one method of the type uses a pointer receiver, all of them should. Mixing is confusing and can cause subtle bugs with interfaces.

In practice, most methods in backend code use pointer receivers. Structs typically represent services, repositories, or entities that are modified or too large to copy on every call.


Composition over inheritance: embedded structs

Go has no inheritance. The word extends doesn’t exist. Instead, Go offers composition through embedded structs. The difference isn’t just syntactic: it fundamentally changes how you think about the relationship between types.

type Address struct {
    Street  string
    City    string
    Country string
}

type Person struct {
    Name    string
    Age     int
    Address // embedded field (no explicit name)
}

The Person struct doesn’t “inherit from” Address. It contains an Address. But since it’s an embedded field (no explicit name), its fields are promoted: you can access them directly.

p := Person{
    Name: "Roger",
    Age:  32,
    Address: Address{
        Street:  "Main Street 1",
        City:    "Barcelona",
        Country: "Spain",
    },
}

fmt.Println(p.City)         // "Barcelona" (promoted)
fmt.Println(p.Address.City) // "Barcelona" (explicit access, also valid)

Both forms work. And if Address has methods, those methods are promoted too:

func (a Address) FullAddress() string {
    return fmt.Sprintf("%s, %s, %s", a.Street, a.City, a.Country)
}

fmt.Println(p.FullAddress()) // "Main Street 1, Barcelona, Spain"

Why composition and not inheritance

Inheritance creates vertical coupling. If you change the parent class, you affect all children. If you have multiple levels of inheritance, tracing where a behavior comes from becomes an archaeological exercise.

Composition is horizontal. A Person contains an Address. If tomorrow you need it to also contain a ContactInfo, you add it. You don’t need to restructure a hierarchy. You don’t break implicit inheritance contracts.

type ContactInfo struct {
    Phone string
    Email string
}

type Person struct {
    Name string
    Age  int
    Address
    ContactInfo
}

p := Person{
    Name:        "Roger",
    Age:         32,
    Address:     Address{City: "Barcelona"},
    ContactInfo: ContactInfo{Email: "roger@oshy.tech"},
}

fmt.Println(p.Email) // "roger@oshy.tech"
fmt.Println(p.City)  // "Barcelona"

If two embedded structs have a field with the same name, Go doesn’t resolve it automatically. It forces you to be explicit. This is a design decision: ambiguity is resolved at compile time, not at runtime.

type A struct { Name string }
type B struct { Name string }
type C struct {
    A
    B
}

var c C
// c.Name  <- compile error: ambiguous
c.A.Name = "from A" // this works

This eliminates an entire category of bugs that in languages with multiple inheritance (C++, Python) are resolved with precedence rules nobody remembers.


JSON tags: marshaling and unmarshaling

Struct tags are one of Go’s most useful and least glamorous features. They let you add metadata to fields that libraries can read via reflection. The most common case: controlling how a struct is serialized to JSON.

type Product struct {
    ID          int       `json:"id"`
    Name        string    `json:"name"`
    Price       float64   `json:"price"`
    InStock     bool      `json:"in_stock"`
    Description string    `json:"description,omitempty"`
    InternalSKU string    `json:"-"`
    CreatedAt   time.Time `json:"created_at"`
}

Each tag is a string between backticks following the key:"value" convention. For JSON:

  • json:"name" defines the field name in JSON.
  • json:"description,omitempty" omits the field if it has its zero value (empty string, 0, false, nil).
  • json:"-" excludes the field from serialization. Useful for internal fields that shouldn’t appear in a REST API response.

Marshaling: struct to JSON

p := Product{
    ID:      1,
    Name:    "Mechanical Keyboard",
    Price:   89.99,
    InStock: true,
    InternalSKU: "KB-001-MX",
}

data, err := json.Marshal(p)
if err != nil {
    log.Fatal(err)
}
fmt.Println(string(data))

Result:

{"id":1,"name":"Mechanical Keyboard","price":89.99,"in_stock":true,"created_at":"0001-01-01T00:00:00Z"}

Notice: description doesn’t appear because we used omitempty and it was empty. InternalSKU doesn’t appear because we used "-". Field names follow the tag convention, not Go’s.

Unmarshaling: JSON to struct

raw := `{"id":2,"name":"Ergonomic Mouse","price":45.50,"in_stock":false}`

var p Product
if err := json.Unmarshal([]byte(raw), &p); err != nil {
    log.Fatal(err)
}
fmt.Println(p.Name)  // "Ergonomic Mouse"
fmt.Println(p.Price) // 45.5

Unmarshaling ignores JSON fields with no corresponding struct field, and leaves struct fields not in the JSON at their zero value. No exceptions, no errors. Pure pragmatism.

Tags beyond JSON

Tags aren’t exclusive to JSON. Libraries like pgx (PostgreSQL), yaml, xml, validate, and many others use them:

type Config struct {
    Port     int    `yaml:"port" env:"APP_PORT" validate:"required,min=1024"`
    Host     string `yaml:"host" env:"APP_HOST" validate:"required"`
    LogLevel string `yaml:"log_level" env:"LOG_LEVEL"`
}

The key is that tags are just metadata. The compiler doesn’t validate them. If you write json:"naem" instead of json:"name", it will compile fine and you’ll have a silent bug. Tools like go vet and linters help catch these errors.


Visibility: exported vs unexported

Go has no public, private, or protected. Visibility is controlled by a rule that’s brutal in its simplicity: if it starts with an uppercase letter, it’s exported (public). If it starts with a lowercase letter, it’s unexported (private to the package).

type User struct {
    ID       int    // exported: visible outside the package
    Name     string // exported
    email    string // NOT exported: only visible within the package
    password string // NOT exported
}

This applies to everything: structs, fields, functions, methods, constants, variables. It’s a convention enforced by the compiler, not by a keyword. You can’t accidentally “work around it” with reflection.

Practical implications

When you design structs that are part of a public API (a package others will import), the visibility of fields matters a lot:

// package auth

type Credentials struct {
    Username string // other packages can read and write it
    password string // only the auth package can access this
}

func NewCredentials(username, password string) Credentials {
    return Credentials{
        Username: username,
        password: hashPassword(password),
    }
}

func (c Credentials) ValidatePassword(input string) bool {
    return checkHash(c.password, input)
}

From outside the package:

creds := auth.NewCredentials("roger", "secret123")
fmt.Println(creds.Username)  // works
// fmt.Println(creds.password) // compile error
creds.ValidatePassword("secret123") // works

The password field is inaccessible from outside. You don’t need getters/setters like in Java. Encapsulation is real and the compiler guarantees it.

Visibility and JSON

A detail that catches many people: unexported fields are not serialized to JSON. If a field starts with a lowercase letter, encoding/json ignores it completely:

type Response struct {
    Status  string `json:"status"`
    message string `json:"message"` // will NEVER appear in JSON
}

This is by design. If you need a field to appear in JSON, it has to be exported. There’s no way around it with tags. It’s a simple rule that prevents accidentally exposing internal data.


Struct comparison and equality

Structs in Go are comparable if all their fields are comparable. You can use == directly:

type Point struct {
    X, Y int
}

a := Point{1, 2}
b := Point{1, 2}
c := Point{3, 4}

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

This works because int is comparable. But not all structs are comparable:

type Data struct {
    Values []int // slices are NOT comparable
}

a := Data{Values: []int{1, 2}}
b := Data{Values: []int{1, 2}}
// fmt.Println(a == b) // compile error

Slices, maps, and functions can’t be compared with ==. If your struct contains any of these types, you need to write your own comparison function or use reflect.DeepEqual (which is slow and should be reserved for tests).

func (d Data) Equal(other Data) bool {
    if len(d.Values) != len(other.Values) {
        return false
    }
    for i, v := range d.Values {
        if v != other.Values[i] {
            return false
        }
    }
    return true
}

Practical rule: if you need to compare complex structs in production code, implement an Equal method. If it’s just for tests, reflect.DeepEqual or libraries like go-cmp are acceptable.

Comparable structs can also be used as map keys, which is useful for caches and lookups:

type Coordinate struct {
    Lat, Lng float64
}

visited := map[Coordinate]bool{
    {40.4168, -3.7038}: true, // Madrid
    {41.3874, 2.1686}:  true, // Barcelona
}

When to use structs vs maps

This question comes up a lot, especially if you come from Python or JavaScript, where dictionaries/objects are the default tool for everything.

In Go, the answer is almost always “use a struct”. But there are exceptions.

Use structs when:

  • You know the shape of the data at compile time.
  • The fields have different types.
  • You need associated methods.
  • You want compiler validation.
  • The data is serialized/deserialized with a known structure.
// This is a struct. Always.
type Order struct {
    ID        string
    Customer  string
    Items     []OrderItem
    Total     float64
    Status    string
    CreatedAt time.Time
}

Use maps when:

  • The shape of the data is dynamic or unknown at compile time.
  • The keys are dynamic (configuration, HTTP headers, metadata).
  • You’re working with generic data that has no fixed structure.
// Dynamic metadata: this is a map
metadata := map[string]string{
    "source":  "api",
    "version": "2.1",
    "region":  "eu-west",
}

// HTTP headers: also a map
headers := map[string][]string{
    "Content-Type":  {"application/json"},
    "Authorization": {"Bearer token123"},
}

The anti-pattern: map[string]interface

If you find yourself writing map[string]interface{} (or map[string]any since Go 1.18) everywhere, you probably need a struct. Untyped maps throw away all compiler guarantees and turn your Go code into JavaScript with extra steps.

// DON'T do this for data with a known structure
data := map[string]any{
    "name":  "Roger",
    "age":   32,
    "email": "roger@oshy.tech",
}
name := data["name"].(string) // type assertion, can panic at runtime

// Do this instead
user := User{
    Name:  "Roger",
    Age:   32,
    Email: "roger@oshy.tech",
}
// user.Name is string. Always. No discussion.

Practical example: modeling an API response

Let’s put everything together with a real example: modeling the response of an API that returns a paginated list of articles. This is the kind of code you write constantly in a Go backend.

package api

import (
    "encoding/json"
    "time"
)

// Article represents a blog article.
type Article struct {
    ID          string    `json:"id"`
    Title       string    `json:"title"`
    Slug        string    `json:"slug"`
    Content     string    `json:"content,omitempty"`
    Author      Author    `json:"author"`
    Tags        []string  `json:"tags"`
    PublishedAt time.Time `json:"published_at"`
    Draft       bool      `json:"draft,omitempty"`
    viewCount   int       // unexported: doesn't appear in JSON, not accessible outside the package
}

// Author uses composition instead of duplicating fields.
type Author struct {
    Name  string `json:"name"`
    Email string `json:"email"`
    Bio   string `json:"bio,omitempty"`
}

// Pagination encapsulates pagination data.
type Pagination struct {
    Page       int  `json:"page"`
    PerPage    int  `json:"per_page"`
    Total      int  `json:"total"`
    TotalPages int  `json:"total_pages"`
    HasNext    bool `json:"has_next"`
}

// ArticleListResponse is the complete API response.
type ArticleListResponse struct {
    Data       []Article  `json:"data"`
    Pagination Pagination `json:"pagination"`
}

// NewPagination automatically calculates derived fields.
func NewPagination(page, perPage, total int) Pagination {
    totalPages := total / perPage
    if total%perPage != 0 {
        totalPages++
    }
    return Pagination{
        Page:       page,
        PerPage:    perPage,
        Total:      total,
        TotalPages: totalPages,
        HasNext:    page < totalPages,
    }
}

// ToJSON serializes the response. Method with value receiver
// because ArticleListResponse doesn't need to be modified.
func (r ArticleListResponse) ToJSON() ([]byte, error) {
    return json.MarshalIndent(r, "", "  ")
}

Using it:

response := api.ArticleListResponse{
    Data: []api.Article{
        {
            ID:    "1",
            Title: "Structs in Go",
            Slug:  "go-structs",
            Author: api.Author{
                Name:  "Roger Bosch",
                Email: "roger@oshy.tech",
            },
            Tags:        []string{"Go", "structs", "backend"},
            PublishedAt: time.Now(),
        },
    },
    Pagination: api.NewPagination(1, 10, 1),
}

data, err := response.ToJSON()
if err != nil {
    log.Fatal(err)
}
fmt.Println(string(data))

Result:

{
  "data": [
    {
      "id": "1",
      "title": "Structs in Go",
      "slug": "go-structs",
      "author": {
        "name": "Roger Bosch",
        "email": "roger@oshy.tech"
      },
      "tags": ["Go", "structs", "backend"],
      "published_at": "2026-06-24T10:30:00Z"
    }
  ],
  "pagination": {
    "page": 1,
    "per_page": 10,
    "total": 1,
    "total_pages": 1,
    "has_next": false
  }
}

This example covers all the concepts: structs with typed fields, composition (Author inside Article), JSON tags with omitempty, unexported fields (viewCount), constructor functions (NewPagination), methods with value receivers (ToJSON), and clean serialization.


Common patterns in real backend code

To close, these are patterns you’ll see (and write) constantly if you do backend in Go. They’re not theoretical: they come directly from production code.

The Options pattern for complex constructors

When a constructor has too many parameters, the functional options pattern is idiomatic in Go:

type Server struct {
    host         string
    port         int
    readTimeout  time.Duration
    writeTimeout time.Duration
    logger       *slog.Logger
}

type Option func(*Server)

func WithPort(port int) Option {
    return func(s *Server) {
        s.port = port
    }
}

func WithTimeouts(read, write time.Duration) Option {
    return func(s *Server) {
        s.readTimeout = read
        s.writeTimeout = write
    }
}

func WithLogger(logger *slog.Logger) Option {
    return func(s *Server) {
        s.logger = logger
    }
}

func NewServer(host string, opts ...Option) *Server {
    s := &Server{
        host:         host,
        port:         8080,
        readTimeout:  5 * time.Second,
        writeTimeout: 10 * time.Second,
        logger:       slog.Default(),
    }
    for _, opt := range opts {
        opt(s)
    }
    return s
}
srv := NewServer("localhost",
    WithPort(9090),
    WithTimeouts(10*time.Second, 30*time.Second),
)

This pattern gives you sensible defaults, named optional parameters, and the ability to extend configuration without breaking the API. Libraries like google.golang.org/grpc and go.uber.org/zap use it.

Struct as service receiver

In Go backend, services and repositories are structs with dependencies injected via constructor. It’s the equivalent of a class with @Service in Spring, but without annotations or reflection:

type UserService struct {
    repo   UserRepository
    cache  Cache
    logger *slog.Logger
}

func NewUserService(repo UserRepository, cache Cache, logger *slog.Logger) *UserService {
    return &UserService{
        repo:   repo,
        cache:  cache,
        logger: logger,
    }
}

func (s *UserService) GetByID(ctx context.Context, id string) (*User, error) {
    if cached, ok := s.cache.Get(ctx, "user:"+id); ok {
        return cached.(*User), nil
    }

    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("finding user %s: %w", id, err)
    }

    s.cache.Set(ctx, "user:"+id, user, 5*time.Minute)
    return user, nil
}

Notice that UserRepository and Cache are interfaces, not concrete structs. This allows injecting different implementations in tests (mocks) and in production. Go’s rule applies: accept interfaces, return structs.

DTOs separate from domain entities

Don’t mix your domain model with your API DTOs. They’re different responsibilities:

// Domain entity (internal layer)
type Task struct {
    ID          string
    Title       string
    Description string
    Status      TaskStatus
    AssigneeID  string
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

// Response DTO (API layer)
type TaskResponse struct {
    ID          string `json:"id"`
    Title       string `json:"title"`
    Description string `json:"description,omitempty"`
    Status      string `json:"status"`
    Assignee    string `json:"assignee,omitempty"`
    CreatedAt   string `json:"created_at"`
}

// Explicit conversion
func ToTaskResponse(t Task) TaskResponse {
    return TaskResponse{
        ID:          t.ID,
        Title:       t.Title,
        Description: t.Description,
        Status:      string(t.Status),
        Assignee:    t.AssigneeID,
        CreatedAt:   t.CreatedAt.Format(time.RFC3339),
    }
}

It’s more code than putting JSON tags directly on the entity. But when your API needs to return data differently from how you store it (and this always happens), you’ll have the separation ready. I explain this in more detail in project structure and clean architecture in Go.

Structs as typed configuration

Instead of reading environment variables as strings scattered throughout the code, centralize configuration in a struct:

type Config struct {
    Server   ServerConfig
    Database DatabaseConfig
    Redis    RedisConfig
}

type ServerConfig struct {
    Host string `env:"SERVER_HOST" envDefault:"0.0.0.0"`
    Port int    `env:"SERVER_PORT" envDefault:"8080"`
}

type DatabaseConfig struct {
    URL             string        `env:"DATABASE_URL,required"`
    MaxConns        int           `env:"DB_MAX_CONNS" envDefault:"25"`
    ConnMaxLifetime time.Duration `env:"DB_CONN_MAX_LIFETIME" envDefault:"5m"`
}

type RedisConfig struct {
    Addr     string `env:"REDIS_ADDR" envDefault:"localhost:6379"`
    Password string `env:"REDIS_PASSWORD"`
    DB       int    `env:"REDIS_DB" envDefault:"0"`
}

With a library like github.com/caarlos0/env you can populate this struct directly from environment variables. Result: typed configuration, with defaults, with validation, and without a single bare os.Getenv in your code.


Less ceremony, more clarity

Structs in Go are simple by design. No inheritance, no magic constructors, no granular visibility with five keywords. And that’s precisely what makes them powerful: they force you to model your data explicitly, to compose instead of inherit, to be clear instead of abstract.

If you come from languages with classical object orientation, the change can feel like a step backward. But after using Go in production for a while, you realize that most OOP abstractions you used weren’t necessary. They were ceremonial. Go eliminates the ceremony and leaves you with what matters: data, behavior, and composition.

Structs are the foundation. On them you build services, API handlers, repositories, configuration, DTOs. For the next step, understand how interfaces in Go complement structs, and how together they form the type system that makes Go what it is. And when you’re ready to build something real, a REST API with Go is the best proving ground.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved