Mert Özen Further With Every Line
Backend Backend with Go — #04

Structs, Methods and Interfaces in Go: OOP Without Classes, Embedding and Implicit Interfaces

Mert Özen Nov 15, 2026 15 min 4 views
Structs, Methods and Interfaces in Go: OOP Without Classes, Embedding and Implicit Interfaces

Go has no classes: data lives in structs and behaviour in methods; a method that changes state needs a pointer receiver, and interfaces are implicit and declared small on the side that uses them. Bookstore's order model (NewOrder, embedding Timestamps, MarkPaid), memstore and sqlstore behind orders.Repository, accept interfaces return structs, Stringer, type switches, the method set error and the nil interface trap caught by staticcheck.

Version note: The code in this post was built and run with Go 1.27.4; the module's go line is 1.26.0 and the code also compiles with Go 1.26.11. staticcheck 2026.2.2 was used. The sqlstore package compiles, but in this post it was not run against a database; we will run it with SQL Server in 11.

Guess First

In this post we build the order model of Bookstore, the online bookstore used as the shared example across this site's series, in Go. Go has no classes; data lives in structs and behaviour in methods attached to them. When you declare a method, you write its receiver either as a value or as a pointer. That is the only difference between the two methods below:

// Lab 04: value receivers work on a copy, pointer receivers on the original.
package main

import "fmt"

type Order struct {
    ID     string
    Status string
}

func (o Order) PayOnCopy() {
    //lint:ignore SA4005 this lab shows the trap on purpose
    o.Status = "Paid"
}

func (o *Order) Pay() {
    o.Status = "Paid"
}

func main() {
    o := Order{ID: "ORD-1001", Status: "Pending"}
    o.PayOnCopy()
    fmt.Println(o.Status)
    o.Pay() // Go takes &o for us
    fmt.Println(o.Status)
}

In C#, when a class method changes a field of this, the change sticks. Here o is a struct variable. What do the two lines print? Does the line o.Pay() compile, or do you have to write (&o).Pay()?

go run ./labs/04-receivers
Pending
Paid

PayOnCopy's receiver is a value: when the method was called, the struct was copied, the copy's status changed, and the copy disappeared when the method ended. Pay's receiver is a pointer: it wrote to the actual variable. o.Pay() compiled too, because o is an addressable variable and Go takes &o itself. The compiler said nothing about the first method, but staticcheck has a check for this trap. After deleting the //lint:ignore line in the lab:

labs/04-receivers/main.go:12:2: ineffective assignment to field Order.Status (SA4005)

In this post we look at structs, the receiver rule, embedding instead of inheritance and Go's implicit interfaces through Bookstore's order model.

Structs and Constructor Functions

The order model lives in the internal/orders package; it is the counterpart of the Bookstore.Ordering module on the C# side. The types first:

// Package orders models customer orders.
package orders

import (
    "fmt"
    "time"

    "bookstore/internal/catalog"
)

// Status is where an order is in its life cycle.
type Status int

const (
    StatusPending Status = iota
    StatusPaid
    StatusShipped
    StatusCancelled
)

func (s Status) String() string {
    switch s {
    case StatusPending:
        return "Pending"
    case StatusPaid:
        return "Paid"
    case StatusShipped:
        return "Shipped"
    case StatusCancelled:
        return "Cancelled"
    default:
        return fmt.Sprintf("Status(%d)", int(s))
    }
}

// Item is one line of an order.
type Item struct {
    ISBN      string
    Title     string
    UnitPrice catalog.Money
    Quantity  int
}

// Total is the price of the line.
func (i Item) Total() catalog.Money {
    return i.UnitPrice * catalog.Money(i.Quantity)
}

// Timestamps is embedded into types that track when they changed.
type Timestamps struct {
    CreatedAt time.Time
    UpdatedAt time.Time
}

// Touch records a change.
func (t *Timestamps) Touch(now time.Time) {
    t.UpdatedAt = now
}

// Order is a customer's order.
type Order struct {
    Timestamps
    ID         string
    CustomerID string
    Items      []Item
    Status     Status
}

// NewOrder returns a pending order with no items.
func NewOrder(id, customerID string, now time.Time) *Order {
    return &Order{
        Timestamps: Timestamps{CreatedAt: now, UpdatedAt: now},
        ID:         id,
        CustomerID: customerID,
        Status:     StatusPending,
    }
}

// AddItem appends a line to the order.
func (o *Order) AddItem(item Item, now time.Time) {
    o.Items = append(o.Items, item)
    o.Touch(now)
}

// MarkPaid moves a pending order to Paid and reports whether it did.
func (o *Order) MarkPaid(now time.Time) bool {
    if o.Status != StatusPending {
        return false
    }
    o.Status = StatusPaid
    o.Touch(now)
    return true
}

// Total is the sum of all lines.
func (o *Order) Total() catalog.Money {
    var sum catalog.Money
    for _, it := range o.Items {
        sum += it.Total()
    }
    return sum
}

// String implements fmt.Stringer.
func (o *Order) String() string {
    return fmt.Sprintf("order %s (%v): %d items, %v", o.ID, o.Status, len(o.Items), o.Total())
}

Go has no constructors; instead you write an ordinary function whose name starts with New, such as NewOrder. Not every type needs one; as we saw in 03, the zero value is often enough. I wrote NewOrder because an order only makes sense with an ID, a customer and a creation time. But Go does not stop you from writing orders.Order{} and skipping this function; the invariants that C# protects with a private constructor are protected in Go only by convention. Design with that in mind: for a type whose zero value is invalid, either do not export its fields or check them in its methods.

NewOrder returns a pointer. The state of an order will change and we will keep it in a repository; for everyone to see the same order, a pointer is the right choice. The time is not taken inside with time.Now(); it comes in as a parameter. This is a testing habit: code that receives the time from outside can be run with a fixed time in tests. The Status constants follow the iota pattern from 03; thanks to the String() method, logs say Paid instead of 1. The state transition check I promised in 03 is in MarkPaid: only a Pending order can be paid. For now it reports the result as a bool; in 05 we will turn that into a meaningful error.

Value or Pointer? The Receiver Rule

A practical rule follows from the behaviour we saw in Guess First. If the method changes the receiver, use a pointer receiver. If the struct is large, use a pointer as well, so that it is not copied on every call. If it contains a field that must not be copied, such as sync.Mutex, use a pointer. For small values that do not change (like Money, Status and Item), a value receiver is natural. If even one of a type's methods has a pointer receiver, make them all pointers for consistency; that is why Total and String on Order have pointer receivers.

The receiver choice also affects which interfaces a type satisfies. A method with a pointer receiver only belongs to the pointer's method set. I tried to give an Order value to an interface that wants MarkPaid:

type Payer interface {
    MarkPaid(now time.Time) bool
}

o := orders.NewOrder("ORD-1001", "ada", time.Now())
var p Payer = *o
cannot use *o (variable of struct type orders.Order) as Payer value in variable declaration: orders.Order does not implement Payer (method MarkPaid has pointer receiver)

The compiler states the reason plainly. Writing var p Payer = o, that is, passing the pointer, compiles. The rule is not arbitrary: if the interface held a copy of the value, MarkPaid would change the copy's state and we would make the mistake from Guess First again, this time through an interface.

Embedding: Composition, Not Inheritance

The first field of Order is written without a name: Timestamps. This is called embedding. The fields and methods of the embedded struct are promoted to the outer struct: you can write o.CreatedAt and o.Touch(now) without writing o.Timestamps.CreatedAt. That is how the call to o.Touch(now) inside AddItem works. In C# you would do this with an AuditableEntity base class. Go has no base classes, and embedding is not inheritance:

var t *orders.Timestamps = o
cannot use o (variable of type *orders.Order) as *orders.Timestamps value in variable declaration

An *Order cannot stand in for a *Timestamps; there is no "is-a" relationship between them. Order contains a Timestamps and offers its methods as shortcuts. If you need the inner value, you write &o.Timestamps explicitly. The embedded type's methods are not virtual either: inside Touch, t is always a Timestamps and knows nothing about the outer struct. Go's counterpart of C#'s virtual dispatch is interfaces. The "composition over inheritance" advice from the C# Object-Oriented Programming series post "Composition in C# and an Introduction to SOLID" is not advice in Go; it is the language's only way.

Implicit Interfaces, Declared by the Consumer

Let's write a service to store orders and the interface it needs:

package orders

import (
    "context"
    "fmt"
    "time"
)

// Repository is what Service needs from storage. It is declared here, next
// to its consumer, and implemented elsewhere without naming it.
type Repository interface {
    Save(ctx context.Context, o *Order) error
    ByID(ctx context.Context, id string) (*Order, error)
}

// Service places orders.
type Service struct {
    repo   Repository
    now    func() time.Time
    nextID int
}

// NewService accepts any Repository and returns a concrete *Service.
func NewService(repo Repository) *Service {
    return &Service{repo: repo, now: time.Now, nextID: 1000}
}

// Place creates an order with the given items and saves it.
func (s *Service) Place(ctx context.Context, customerID string, items []Item) (*Order, error) {
    s.nextID++ // not safe for concurrent use yet; fixed in 08
    o := NewOrder(fmt.Sprintf("ORD-%d", s.nextID), customerID, s.now())
    for _, it := range items {
        o.AddItem(it, s.now())
    }
    if err := s.repo.Save(ctx, o); err != nil {
        return nil, err
    }
    return o, nil
}

In Go a type does not implement an interface through a declaration like : IRepository. If the type has the methods the interface asks for, it satisfies the interface; that is all. This has an important consequence: you declare the interface not next to the implementation but next to the code that uses it. In C# projects, IOrderRepository usually lives in a separate abstractions project, and the implementation references it. In Go, Repository sits in the package of Service, which uses it. The implementations never have to mention the interface's name.

This approach keeps interfaces small. Service only wants Save and ByID; even if the storage layer offers ten methods, the interface has two. The most widely used interfaces in Go's standard library are like this: io.Reader has one method, fmt.Stringer one, error one. The role interface idea from the SOLID series post "Interface Segregation Principle" is the default behaviour in Go. Go also has a naming habit here: since the package name appears in every call, you write orders.Repository rather than orders.OrderRepository; there is no need to repeat the name.

A saying in the Go community sums up this section: "accept interfaces, return structs". NewService accepts an interface, so we can pass a fake repository in tests; but it returns a concrete *Service, because the caller should hold the real type with all its methods. Do not worry about the ctx context.Context parameter for now; we will cover this type, which carries cancellation and timeouts, in 08. Making it the first parameter of every method that does I/O is a Go convention, like C#'s CancellationToken.

Two Implementations: memstore and sqlstore

The first implementation keeps orders in memory, in a map:

// Package memstore keeps orders in memory.
package memstore

import (
    "context"
    "fmt"

    "bookstore/internal/orders"
)

// Store is an in-memory order store. Not safe for concurrent use yet.
type Store struct {
    byID map[string]*orders.Order
}

// Compile-time check that *Store satisfies orders.Repository.
var _ orders.Repository = (*Store)(nil)

// New returns an empty store.
func New() *Store {
    return &Store{byID: make(map[string]*orders.Order)}
}

// Save stores the order, replacing any order with the same ID.
func (s *Store) Save(_ context.Context, o *orders.Order) error {
    s.byID[o.ID] = o
    return nil
}

// ByID returns the order with the given ID.
func (s *Store) ByID(_ context.Context, id string) (*orders.Order, error) {
    o, ok := s.byID[id]
    if !ok {
        return nil, fmt.Errorf("order %s not found", id)
    }
    return o, nil
}

The line var _ orders.Repository = (*Store)(nil) is a Go idiom. It is an assignment that does nothing, but if *Store does not satisfy the interface, compilation stops right here. The one drawback of implicit interfaces is that when you get a method signature wrong, the error shows up where you use the type; this line pulls the error next to the implementation. I did not forget to create the map with make; remember the nil map lesson from 03. We will discuss why the store is not safe for concurrent use and how to fix it in 08.

The second implementation is for SQL Server. We do not connect to a database in this post, so I did not run this code; it only compiles, and the compiler confirms that it satisfies the same interface. We will add the driver, the tables and transactions in 11:

// Package sqlstore keeps orders in SQL Server through database/sql.
// The driver and the real queries arrive in post 11.
package sqlstore

import (
    "context"
    "database/sql"

    "bookstore/internal/orders"
)

// Store reads and writes the Orders table.
type Store struct {
    db *sql.DB
}

var _ orders.Repository = (*Store)(nil)

// New wraps an open *sql.DB.
func New(db *sql.DB) *Store {
    return &Store{db: db}
}

// Save inserts the order header. Items and transactions come in post 11.
func (s *Store) Save(ctx context.Context, o *orders.Order) error {
    _, err := s.db.ExecContext(ctx,
        `INSERT INTO Orders (Id, CustomerId, Status, CreatedAt) VALUES (@p1, @p2, @p3, @p4)`,
        o.ID, o.CustomerID, int(o.Status), o.CreatedAt)
    return err
}

// ByID loads the order header.
func (s *Store) ByID(ctx context.Context, id string) (*orders.Order, error) {
    o := &orders.Order{}
    err := s.db.QueryRowContext(ctx,
        `SELECT Id, CustomerId, Status, CreatedAt FROM Orders WHERE Id = @p1`, id).
        Scan(&o.ID, &o.CustomerID, &o.Status, &o.CreatedAt)
    if err != nil {
        return nil, err
    }
    return o, nil
}

Notice that both packages import orders, while orders imports neither. The dependency points one way: storage knows the domain, the domain does not know storage. This is exactly what I meant in 02 by "define a small interface on one side" as the fix for the import cycle trap. In the Dapper series we built repositories with hand-written SQL; sqlstore will be the Go counterpart of that mindset.

Stringer, Type Assertions and Type Switches

I combined the pieces in a lab program. It places an order, pays it, reads it back from the store and runs values of different types through a type switch:

// Lab 04: orders, Stringer, type switches and the nil interface trap.
package main

import (
    "context"
    "fmt"
    "time"

    "bookstore/internal/catalog"
    "bookstore/internal/orders"
    "bookstore/internal/orders/memstore"
    "bookstore/internal/orders/sqlstore"
)

func main() {
    ctx := context.Background()
    repo := memstore.New()
    svc := orders.NewService(repo)

    books := catalog.Store{}.All()
    dune, emma := books[0], books[3]
    o, err := svc.Place(ctx, "ada", []orders.Item{
        {ISBN: dune.ISBN, Title: dune.Title, UnitPrice: dune.Price, Quantity: 2},
        {ISBN: emma.ISBN, Title: emma.Title, UnitPrice: emma.Price, Quantity: 1},
    })
    if err != nil {
        fmt.Println("place failed:", err)
        return
    }
    fmt.Println(o)
    fmt.Println(o.MarkPaid(time.Now()), o.MarkPaid(time.Now()), o.Status)

    found, err := repo.ByID(ctx, o.ID)
    fmt.Println(found == o, err)
    _, err = repo.ByID(ctx, "ORD-9999")
    fmt.Println(err)

    for _, v := range []any{o.Total(), o.Status, err, 42, nil} {
        fmt.Println(describe(v))
    }

    var r orders.Repository = repo
    if ms, ok := r.(*memstore.Store); ok {
        fmt.Printf("type assertion: %T\n", ms)
    }

    r = pickRepository(false)
    //lint:ignore SA4023 this lab shows the trap on purpose
    fmt.Printf("r == nil: %v, dynamic type: %T\n", r == nil, r)
}

func describe(v any) string {
    switch x := v.(type) {
    case nil:
        return "nil"
    case catalog.Money:
        return "money: " + x.String()
    case fmt.Stringer:
        return "stringer: " + x.String()
    case error:
        return "error: " + x.Error()
    default:
        return fmt.Sprintf("other: %T", x)
    }
}

// pickRepository has a bug: it returns a typed nil pointer inside an interface.
func pickRepository(useSQL bool) orders.Repository {
    var s *sqlstore.Store
    if useSQL {
        s = sqlstore.New(nil)
    }
    return s
}
go run ./labs/04-orders
order ORD-1001 (Pending): 2 items, 898.80 TRY
true false Paid
true <nil>
order ORD-9999 not found
money: 898.80 TRY
stringer: Paid
error: order ORD-9999 not found
other: int
nil
type assertion: *memstore.Store
r == nil: false, dynamic type: *sqlstore.Store

On the first line, fmt.Println(o) printed not the struct's fields but the output of String(). The fmt package checks whether a value satisfies the fmt.Stringer interface (String() string); it is the counterpart of overriding ToString() in C#, except that there is no override here either, the method merely has to exist. 898.80 TRY is two copies of Dune and one of Emma. I also added a String() to the Money type from 03, which is why amounts print in TRY instead of kuruş. On the second line, MarkPaid returned true on the first call and false on the second; a paid order cannot be paid again.

any is another name for interface{}; every type satisfies it, like C#'s object. There are two ways to reach the concrete type inside an interface value. A type assertion, r.(*memstore.Store), asks about a single type; written in the comma-ok form, it returns false instead of panicking when the type does not match. A type switch tries several types in order; it is the counterpart of C# pattern matching such as switch (v) { case Money m: ... }. The order of the cases matters: catalog.Money is also a Stringer, so had the fmt.Stringer case come first, the amount would have printed as "stringer:" instead of "money:". err is an error without a String() method, so it fell into the third case.

The nil Interface Trap

The last line of the output is one of Go's best-known traps. pickRepository(false) created no store, and s stayed nil. Yet the returned interface is not nil. An interface value has two parts: the concrete type inside and that type's value. For the interface to be nil, both must be empty. The line return s returned an interface whose type is *sqlstore.Store and whose value is a nil pointer; %T shows the type plainly. Had you called r.ByID(...) on this interface, the method would run and you would get a nil pointer panic when it touched s.db.

The compiler does not catch this, but staticcheck does. After deleting the //lint:ignore line in the lab:

labs/04-orders/main.go:48:49: this comparison is never true (SA4023)
    labs/04-orders/main.go:47:6: the lhs of the comparison is the 1st return value of this function call
    labs/04-orders/main.go:67:6: bookstore/labs/04-orders.pickRepository never returns a nil interface value

The fix is to write return nil explicitly where you mean "nothing": if !useSQL { return nil }. The rule can be summed up like this: in a function that returns an interface, do not return a variable of a concrete type; return either a real value or a plain nil. The same trap shows up more often with errors, because error is an interface too; in 05 we will see a validation function that makes exactly this mistake.

In Production

Open interfaces when the need arises. In C# projects, writing an interface for every service is a habit, because DI containers and mock frameworks expect it. In Go, writing an interface for a type with a single implementation is usually an unnecessary layer. Declare the interface when a second implementation (a fake in tests or a second storage) really needs it, and on the side that uses it.

Watch out for shared pointers. memstore keeps the order's pointer and ByID returns the same pointer; the lab's found == o line printed true. That means everyone who reads from the store can change the same order. A real database gives a fresh copy on every read; if you use an in-memory store outside tests, consider returning a copy.

Supply time and ID generation from outside. That is why Service has the now func() time.Time field: time.Now in production, a fixed clock in tests. The ID counter is a plain int for now; if several requests place orders at the same time, they will collide. We will fix this in 08.

Common Pitfalls

Changing state in a method with a value receiver. The code compiles and the change is lost. If even one of a type's methods has a pointer receiver, make them all pointers.

Using embedding like inheritance. The embedded type's methods do not know the outer type and cannot be overridden. If you need polymorphism, use an interface.

Declaring the interface next to the implementation, large and up front. Writing IOrderRepository with ten methods and passing it everywhere makes it harder in Go to write tests and to add a new implementation. The interface is as big as the consumer's needs.

Returning a typed nil as an interface. var s *Store; return s produces a non-nil interface. Especially in functions that return error, this mistake quietly leads to a "there is an error" result.

Putting a broad case first in a type switch. Interface cases such as fmt.Stringer or error must come after the cases of the concrete types that satisfy them.

A Small Experiment

★☆☆ Add MarkShipped(now time.Time) bool to orders: only a Paid order may become Shipped. In labs/04-orders, try to ship an unpaid order.

★★☆ Fix pickRepository and show in the lab that it really returns nil when useSQL is false. Then delete the //lint:ignore line and check that staticcheck no longer reports anything.

★★★ Write a Discounted struct that adds a Discount field to an order and embed it in Order; give Discounted a Total() method as well. Which one does o.Total() call? Can Discounted's Total reach the Order's lines from inside? This experiment shows why embedding is not inheritance.

The order model is ready, but for now we report failures with a bool and plain text. In the next post we move on to Go's approach to error handling: error values, sentinel errors like ErrOutOfStock, custom types like ValidationError, wrapping with %w, errors.Is, errors.As and Go 1.26's errors.AsType, and finally panic and recover.