DEV Community

puffball1567
puffball1567

Posted on

How to Write Object-Oriented Code in Go: Structs, Methods, Interfaces, and Composition

Go does not have classes or inheritance, but it does not prevent object-oriented design. It simply moves the building blocks into different language features: structs hold state, methods attach behavior, interfaces describe capabilities, and composition assembles collaborating parts.

The productive question is not “how can I recreate a class hierarchy in Go?” It is “which data owns this behavior, and which dependency should this component require?” This article builds a small application service around that question.

Start with data and behavior

Suppose an application lets a user change their display name. The domain value is a struct, while the operation belongs on a service that has access to user storage.

package users

import (
    "context"
    "errors"
)

type User struct {
    ID          int64
    DisplayName string
}

type Repository interface {
    FindByID(ctx context.Context, id int64) (User, error)
    Save(ctx context.Context, user User) error
}

type Service struct {
    repository Repository
}

func NewService(repository Repository) *Service {
    return &Service{repository: repository}
}

func (s *Service) Rename(ctx context.Context, id int64, name string) (User, error) {
    if name == "" {
        return User{}, errors.New("display name is required")
    }

    user, err := s.repository.FindByID(ctx, id)
    if err != nil {
        return User{}, err
    }

    user.DisplayName = name
    if err := s.repository.Save(ctx, user); err != nil {
        return User{}, err
    }
    return user, nil
}
Enter fullscreen mode Exit fullscreen mode

User is a value with data. Service is a struct with a dependency and a cohesive operation. The constructor function makes the dependency explicit at creation time. None of these types needs a base class to have a clear responsibility.

Define interfaces where they are consumed

The Repository interface above belongs next to Service, not necessarily next to a database implementation. The service states the smallest capability it needs: load a user and save a user. Any type with those methods satisfies the interface automatically.

That has two useful consequences. Production code can use a PostgreSQL-backed repository, while a test can use a small in-memory fake. Neither implementation needs to import a shared “repository base class” or declare that it implements the interface.

type MemoryRepository struct {
    users map[int64]User
}

func (r *MemoryRepository) FindByID(_ context.Context, id int64) (User, error) {
    user, ok := r.users[id]
    if !ok {
        return User{}, errors.New("user not found")
    }
    return user, nil
}

func (r *MemoryRepository) Save(_ context.Context, user User) error {
    r.users[user.ID] = user
    return nil
}
Enter fullscreen mode Exit fullscreen mode

This is dependency inversion without a framework. The application service depends on a capability, rather than on a particular storage driver.

Prefer composition to inheritance

Go supports struct embedding, but embedding is not a substitute for a deep inheritance tree. It promotes fields and methods from one struct into another; it does not give Go classes, virtual methods, or a general subtype model.

For backend code, explicit fields are usually clearer than embedding. If a service needs a clock, a logger, and an email sender, name them as dependencies:

type EmailSender interface {
    Send(ctx context.Context, to string, subject string, body string) error
}

type NotificationService struct {
    users UserLookup
    email EmailSender
}
Enter fullscreen mode Exit fullscreen mode

This makes the object graph visible, keeps testing straightforward, and avoids coupling unrelated behavior through a shared parent type. Use embedding when the promoted behavior genuinely reads as part of the receiving type, not merely to reduce typing.

Keep interfaces narrow and delay abstraction

Not every struct needs an interface. A useful default is to create a concrete type first, then introduce an interface at the consumer boundary when a second implementation or a test seam actually exists.

Large interfaces tend to become accidental framework contracts. Small interfaces are easier to implement, easier to test, and clearer about what an operation needs. Go’s standard library follows this style: a type only needs to provide the methods required by the consumer.

A practical Go OOP checklist

  • Use structs to group state.
  • Attach behavior with methods when that behavior naturally belongs to the state or service.
  • Use constructor functions to make required dependencies explicit.
  • Define small interfaces at the point where they are consumed.
  • Prefer explicit composition to inheritance-shaped abstractions.
  • Pass context.Context through request-scoped backend operations.
  • Keep transport concerns such as HTTP request parsing outside domain services.

This approach is object-oriented in the useful sense: state, behavior, dependencies, and boundaries have clear ownership. It just does not require a class hierarchy to achieve it. The official Go documentation on methods and interfaces is a good companion when learning the language mechanics.

Kinmokusei

Kinmokusei is a programming language with TypeScript-inspired syntax that compiles to readable Go. It is intended for writing web backends and Go libraries while using the normal Go toolchain and package ecosystem directly.

Top comments (0)