Go From Zero, Part 4: Methods, Interfaces, and Composition

This is Part 4 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 3: Arrays, Slices, Maps, and Structs is the place to catch up.

In Part 3 we built structs to hold data. Now we’ll give them behaviour. If you’re coming from Java, C# or Python, you’re probably expecting classes, constructors and inheritance. Go has none of those.

What it has instead is three simple ideas: methods, interfaces and embedding. Together they cover everything you’d use classes for, with less ceremony and fewer tangled hierarchies.

Methods: functions attached to a type

A method is a function with a receiver, the value it’s called on. The receiver goes in parentheses before the method name.

package main

import (
    "fmt"
    "math"
)

type Circle struct {
    Radius float64
}

// Area has a value receiver: it gets a copy of the Circle.
func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

// Scale has a pointer receiver: it can modify the original.
func (c *Circle) Scale(factor float64) {
    c.Radius *= factor
}

func main() {
    c := Circle{Radius: 2}
    fmt.Printf("%.2f\n", c.Area()) // 12.57
    c.Scale(2)                     // Go automatically takes &c for you
    fmt.Printf("%.2f\n", c.Area()) // 50.27
}

You can add methods to any type you define in your package, not only structs. For example, type Celsius float64 can have a ToFahrenheit() method.

Value receiver or pointer receiver?

This is the question every Go beginner asks. Here’s the rule of thumb I use:

  • Use a pointer receiver if the method modifies the receiver, if the struct is large, or if the struct contains something that must not be copied (like a sync.Mutex, which we’ll meet in Part 8).
  • Use a value receiver for small, immutable-feeling types like Point, Money or time.Time.
  • Be consistent. If any method on a type needs a pointer receiver, give all of them pointer receivers. Mixing is legal but confusing.

When in doubt, use a pointer receiver.

“Constructors” are just functions

Go has no special constructor syntax. By convention you write a function called NewSomething that returns a ready-to-use value.

package main

import (
    "errors"
    "fmt"
)

type Account struct {
    owner   string
    balance float64
}

func NewAccount(owner string) (*Account, error) {
    if owner == "" {
        return nil, errors.New("owner is required")
    }
    return &Account{owner: owner}, nil
}

func (a *Account) Deposit(amount float64) {
    a.balance += amount
}

func (a *Account) Balance() float64 {
    return a.balance
}

func main() {
    acc, err := NewAccount("Asha")
    if err != nil {
        fmt.Println(err)
        return
    }
    acc.Deposit(500)
    fmt.Println(acc.Balance()) // 500
}

Notice that owner and balance are lowercase, so code outside this package can’t touch them directly. That’s how you get encapsulation in Go: through package-level visibility, not private keywords. Also notice the getter is called Balance(), not GetBalance(). Go style drops the Get prefix.

Interfaces: describe what something can do

An interface is a set of method signatures. Any type that has those methods automatically satisfies the interface. There’s no implements keyword.

package main

import (
    "fmt"
    "math"
)

type Shape interface {
    Area() float64
    Perimeter() float64
}

type Rect struct{ W, H float64 }
type Circle struct{ R float64 }

func (r Rect) Area() float64        { return r.W * r.H }
func (r Rect) Perimeter() float64   { return 2 * (r.W + r.H) }
func (c Circle) Area() float64      { return math.Pi * c.R * c.R }
func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.R }

// describe works with ANY Shape, now or in the future.
func describe(s Shape) {
    fmt.Printf("%T area=%.1f perimeter=%.1f\n", s, s.Area(), s.Perimeter())
}

func main() {
    shapes := []Shape{Rect{W: 3, H: 4}, Circle{R: 1}}
    for _, s := range shapes {
        describe(s)
    }
}

This is called structural typing, or informally “duck typing with a compiler”. If it walks like a Shape and quacks like a Shape, it is a Shape, and the compiler checks it for you.

Types satisfying the Shape interface implicitly

Why does this matter so much? Because the type doesn’t need to know the interface exists. You can define an interface in your own code that’s satisfied by a type from the standard library or a third-party package, without changing that package at all.

The most important interfaces in Go are tiny

Two of the most used interfaces in the whole standard library have one method each:

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

Files, network connections, HTTP bodies, buffers, gzip streams and hash functions all implement these. Because the interfaces are so small, everything fits together like Lego:

package main

import (
    "fmt"
    "io"
    "os"
    "strings"
)

// Any io.Writer works: a file, the terminal, a network connection, a buffer...
func greet(w io.Writer, name string) {
    fmt.Fprintf(w, "Hello, %s!\n", name)
}

func main() {
    greet(os.Stdout, "terminal")

    var sb strings.Builder
    greet(&sb, "buffer")
    fmt.Print(sb.String())

    // io.Copy connects any Reader to any Writer
    io.Copy(os.Stdout, strings.NewReader("streamed text\n"))
}

Here’s a guideline that experienced Go developers repeat often: “The bigger the interface, the weaker the abstraction.” Keep interfaces small, often one or two methods.

The Stringer interface

If your type has a String() string method, fmt will use it automatically when printing. It’s a nice touch for any type you’ll log or debug.

package main

import "fmt"

type Status int

const (
    Pending Status = iota
    Shipped
    Delivered
)

func (s Status) String() string {
    switch s {
    case Pending:
        return "pending"
    case Shipped:
        return "shipped"
    case Delivered:
        return "delivered"
    }
    return fmt.Sprintf("Status(%d)", int(s))
}

func main() {
    fmt.Println(Shipped) // shipped
    fmt.Printf("order is %v\n", Delivered)
}

Type assertions and type switches

Sometimes you have an interface value and need to know the concrete type underneath. Use a type assertion or a type switch.

package main

import "fmt"

func describe(v any) {
    switch x := v.(type) {
    case int:
        fmt.Println("int, doubled:", x*2)
    case string:
        fmt.Println("string of length", len(x))
    case fmt.Stringer:
        fmt.Println("something printable:", x.String())
    default:
        fmt.Printf("no idea what %T is\n", x)
    }
}

func main() {
    describe(21)
    describe("gopher")
    describe(3.14)

    var v any = "hello"
    s, ok := v.(string) // comma-ok form never panics
    fmt.Println(s, ok)
}

any is an alias for interface{}, the empty interface. Every type satisfies it, so it can hold anything. Use it sparingly, though. Every time you use any, you give up help from the compiler. Part 9 on generics shows a better tool for many of these cases.

Embedding: composition instead of inheritance

Go doesn’t have inheritance, but it has embedding. You put one type inside another without a field name, and the outer type gets the inner type’s fields and methods “promoted” to it.

package main

import (
    "fmt"
    "time"
)

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

func (t *Timestamps) Touch() {
    t.UpdatedAt = time.Now()
}

type Logger struct {
    Prefix string
}

func (l Logger) Log(msg string) {
    fmt.Println(l.Prefix + msg)
}

// Order is composed of smaller pieces.
type Order struct {
    Timestamps // embedded: Order gets CreatedAt, UpdatedAt and Touch()
    Logger     // embedded: Order gets Log()
    ID         int
    Total      float64
}

func main() {
    o := Order{ID: 42, Total: 999, Logger: Logger{Prefix: "[order] "}}
    o.Touch()
    o.Log(fmt.Sprintf("order %d updated", o.ID))
    fmt.Println(!o.UpdatedAt.IsZero()) // true
}

The difference from inheritance is subtle but important. An Order is not a Logger. It has a logger, and Go just saves you from typing o.Logger.Log(...). There’s no hidden super, no overriding chains and no fragile base classes.

You can also embed interfaces inside interfaces to build bigger ones from small ones. That’s exactly how the standard library defines io.ReadWriter:

type ReadWriter interface {
    Reader
    Writer
}

Best practices for interfaces

This section is where a lot of Go code goes right or wrong, so I want to spend a moment here.

1. Accept interfaces, return concrete types. Functions should ask for the smallest interface they need as input (so callers have flexibility), but return real structs (so callers get all the methods and the docs are clear).

2. Define interfaces where they’re used, not where they’re implemented. In Java you’d write the interface next to the class. In Go, the code that consumes a behaviour defines the small interface it needs. For example, your billing package might define:

type PaymentCharger interface {
    Charge(customerID string, amount int64) error
}

Your real Stripe client satisfies it automatically, and so does a tiny fake in your tests. No mocking framework required. We’ll use this trick heavily in Part 10.

3. Don’t create an interface until you need one. If there’s only one implementation and no test needs a fake, a plain struct is fine. Interfaces are cheap to add later, precisely because of structural typing.

4. Keep them small. One to three methods is the sweet spot. Name single-method interfaces with an -er suffix: Reader, Writer, Notifier, Validator.

5. Avoid any when you know the type. It moves errors from compile time to run time.

A gotcha: nil interfaces

An interface value holds two things: a type and a value. It’s only nil when both are nil. This can bite you:

package main

import "fmt"

type MyErr struct{}

func (*MyErr) Error() string { return "boom" }

func mightFail(fail bool) error {
    var e *MyErr // a nil pointer of type *MyErr
    if fail {
        e = &MyErr{}
    }
    return e // returns an interface holding (type=*MyErr, value=nil)
}

func main() {
    err := mightFail(false)
    fmt.Println(err == nil) // false! The interface has a type, so it isn't nil.
}

The fix is simple: when there’s no error, return nil directly, never a typed nil pointer. Keep this in the back of your mind for Part 5.

Recap

  • Methods are functions with a receiver. Use pointer receivers when you modify state or the struct is large.
  • Interfaces are satisfied implicitly. No implements keyword.
  • Small interfaces like io.Reader and io.Writer make Go code composable.
  • Embedding gives you composition, not inheritance.
  • Accept interfaces, return structs, and define interfaces where they’re consumed.

Try this: create a Notifier interface with a single Notify(msg string) error method. Write two implementations, EmailNotifier and SMSNotifier, that just print what they’d send. Then write a function that takes a []Notifier and notifies all of them.

In Part 5, we’ll look at error handling, the topic people complain about most when they start Go and appreciate most once they’ve run something in production.


Where to go from here

⬅️ Previous: Part 3: Arrays, Slices, Maps, and Structs

➡️ Next: Part 5: Error Handling the Go Way

The full Go From Zero series

  1. Part 1: Why Go, Setting It Up, and Your First Program
  2. Part 2: Variables, Types, Control Flow, and Functions
  3. Part 3: Arrays, Slices, Maps, and Structs
  4. Part 4: Methods, Interfaces, and Composition (you are here)
  5. Part 5: Error Handling the Go Way
  6. Part 6: Packages, Modules, and Project Layout
  7. Part 7: Goroutines, Channels, and select
  8. Part 8: sync, context, and Concurrency Patterns
  9. Part 9: Generics Without the Headache
  10. Part 10: Testing, Benchmarks, and Fuzzing
  11. Part 11: Best Practices for Production-Ready Go
  12. Part 12: Building a REST API with the Standard Library

If this helped you, share it with a friend who is learning Go, or subscribe to get the next posts by email. Questions are welcome in the comments.

comments powered by Disqus

Releted Posts

Go From Zero, Part 3: Arrays, Slices, Maps, and Structs

This is Part 3 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 2: Variables, Types, Control Flow, and Functions is the place to catch up.

Read more

Go From Zero, Part 2: Variables, Types, Control Flow, and Functions

This is Part 2 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 1: Why Go, Setting It Up, and Your First Program is the place to catch up.

Read more

Go From Zero, Part 1: Why Go, Setting It Up, and Your First Program

This is Part 1 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. You are at the very beginning, which is a great place to be.

Read more