Agentic AI with Java: Live Cohort
GoInterfaces and Generics

Composition over Inheritance

Every Go developer arriving from Java, C#, Python, or C++ asks the same question in their first week: how do I do inheritance? The honest answer is that you do not, and the more useful answer is that the things you were using inheritance for split into three separate tools in Go.

This page maps each one.

What inheritance was doing

Class inheritance bundles three unrelated jobs into one mechanism:

   class Dog extends Animal
         │
         ├─ 1. code reuse        Dog gets Animal's implemented methods
         ├─ 2. substitutability  a Dog can be used wherever an Animal is expected
         └─ 3. shared state      Dog has Animal's fields

Go separates them:

JobGo's tool
Code reuseembedding, or plain functions
Substitutabilityinterfaces
Shared statestruct fields, embedded or named

Once you see that split, the design questions get easier, because you pick the tool that matches the job instead of getting all three whether you wanted them or not.

Substitutability is an interface

The classic inheritance example translates directly:

type Animal interface {
    Speak() string
    Name() string
}

type Dog struct{ name string }
func (d Dog) Speak() string { return d.name + " says woof" }
func (d Dog) Name() string  { return d.name }

type Cat struct{ name string }
func (c Cat) Speak() string { return c.name + " says meow" }
func (c Cat) Name() string  { return c.name }

func describe(animals []Animal) {
    for _, a := range animals {
        fmt.Println(a.Speak())
    }
}

describe([]Animal{Dog{"Bruno"}, Cat{"Mittens"}})

There is no base class, no hierarchy, and Dog and Cat do not know Animal exists. Adding a Bird requires touching nothing that already works.

Code reuse is embedding or a function

When several types need the same implemented behaviour:

type Base struct {
    ID        string
    CreatedAt time.Time
}

func (b *Base) Age() time.Duration {
    return time.Since(b.CreatedAt)
}

type Article struct {
    Base
    Title string
}

type Comment struct {
    Base
    Body string
}

Both get Age() and both get the fields. What they do not get is any relationship to each other, which is usually correct: an Article is not a kind of Comment, they merely share some mechanics.

Often a plain function is even better:

func Age(createdAt time.Time) time.Duration {
    return time.Since(createdAt)
}

No embedding, no coupling, testable on its own. Reach for embedding when the behaviour genuinely belongs to the type, and a function when it merely operates on data the type holds.

The template method problem

Here is where people get stuck. In Java you write a base class with an algorithm that calls abstract steps, and subclasses fill in the steps.

abstract class Report {
    final void generate() {
        loadData();          // subclass provides
        render();            // subclass provides
        save();              // shared
    }
}

Embedding cannot do this, because a promoted method has no idea it was promoted:

type Report struct{}

func (r Report) Generate() {
    r.LoadData()      // there is no r.LoadData, and even if there were,
    r.Render()        // it would be Report's, not the outer type's
}

Go's answer is to pass the varying steps in, either as an interface or as functions.

type DataSource interface {
    Load(ctx context.Context) ([]Row, error)
}

type Renderer interface {
    Render(rows []Row) ([]byte, error)
}

type Report struct {
    source   DataSource
    renderer Renderer
}

func (r *Report) Generate(ctx context.Context) ([]byte, error) {
    rows, err := r.source.Load(ctx)
    if err != nil {
        return nil, fmt.Errorf("loading data: %w", err)
    }

    out, err := r.renderer.Render(rows)
    if err != nil {
        return nil, fmt.Errorf("rendering: %w", err)
    }

    return out, nil
}

The algorithm is fixed, the steps are injected, and each step can be tested and swapped independently. This is strictly more flexible than the inheritance version, because a data source and a renderer can be combined freely rather than being locked together by a class hierarchy.

Notice what happened to testing. With the template method, testing the algorithm means subclassing. Here it means passing two fakes. That difference compounds across a codebase.

Wrapping instead of overriding

Where inheritance would override a method to add behaviour, Go wraps.

type Store interface {
    Get(ctx context.Context, key string) ([]byte, error)
    Put(ctx context.Context, key string, value []byte) error
}

type cachingStore struct {
    Store                          // embedded interface, Get and Put pass through
    cache map[string][]byte
    mu    sync.RWMutex
}

func (s *cachingStore) Get(ctx context.Context, key string) ([]byte, error) {
    s.mu.RLock()
    if v, ok := s.cache[key]; ok {
        s.mu.RUnlock()
        return v, nil
    }
    s.mu.RUnlock()

    v, err := s.Store.Get(ctx, key)
    if err != nil {
        return nil, err
    }

    s.mu.Lock()
    s.cache[key] = v
    s.mu.Unlock()
    return v, nil
}

func WithCache(s Store) Store {
    return &cachingStore{Store: s, cache: make(map[string][]byte)}
}

Put was not written, and it passes straight through to the embedded interface. Only Get is decorated.

The real advantage shows when you stack these:

store := WithCache(WithMetrics(WithRetry(postgresStore)))

Four behaviours, composed at runtime, in any order. An inheritance hierarchy would need a class for every combination.

The diamond problem does not arise

Multiple inheritance in C++ produces the diamond problem, where a class inherits the same base twice and the language has to decide what that means. Go sidesteps it because embedding is containment, not identity.

type A struct{}
func (A) Hello() string { return "A" }

type B struct{}
func (B) Hello() string { return "B" }

type C struct {
    A
    B
}

c := C{}
c.Hello()        // compile error: ambiguous selector c.Hello
c.A.Hello()      // fine

There is no ambiguity about which state exists, because C genuinely contains one A and one B. Only the method name collides, and the compiler makes you say which you meant.

A worked redesign

Take a typical inheritance hierarchy and see what it becomes.

   Object oriented                    Go
   ───────────────                    ──
   abstract class Notifier            type Notifier interface {
     abstract send(msg)                   Send(ctx, Message) error
     protected log(msg)               }
                                      
   class EmailNotifier extends        type EmailNotifier struct {
     Notifier                             smtp *smtp.Client
                                      }
   class SMSNotifier extends          
     Notifier                         type SMSNotifier struct {
                                          client *twilio.Client
   class RetryingEmailNotifier        }
     extends EmailNotifier            
                                      func WithRetry(n Notifier, attempts int) Notifier
type Notifier interface {
    Send(ctx context.Context, msg Message) error
}

type EmailNotifier struct {
    from   string
    client *smtp.Client
}

func (e *EmailNotifier) Send(ctx context.Context, msg Message) error {
    // ...
}

type SMSNotifier struct {
    client *twilio.Client
}

func (s *SMSNotifier) Send(ctx context.Context, msg Message) error {
    // ...
}

// Retry works with any notifier, not just email
type retrying struct {
    Notifier
    attempts int
    backoff  time.Duration
}

func (r *retrying) Send(ctx context.Context, msg Message) error {
    var err error
    delay := r.backoff

    for i := 0; i < r.attempts; i++ {
        if err = r.Notifier.Send(ctx, msg); err == nil {
            return nil
        }
        select {
        case <-time.After(delay):
            delay *= 2
        case <-ctx.Done():
            return ctx.Err()
        }
    }
    return fmt.Errorf("after %d attempts: %w", r.attempts, err)
}

func WithRetry(n Notifier, attempts int) Notifier {
    return &retrying{Notifier: n, attempts: attempts, backoff: 100 * time.Millisecond}
}

The retry logic is written once and applies to email, SMS, push, webhooks, and anything added later. In the hierarchy version, RetryingEmailNotifier and RetryingSMSNotifier would be separate classes with duplicated logic.

You can also fan out, which no hierarchy handles well:

type multi []Notifier

func (m multi) Send(ctx context.Context, msg Message) error {
    var errs []error
    for _, n := range m {
        if err := n.Send(ctx, msg); err != nil {
            errs = append(errs, err)
        }
    }
    return errors.Join(errs...)
}

all := multi{email, sms, WithRetry(push, 3)}

A slice with one method, satisfying the same interface as its elements.

The habits to build

   "I need several types used interchangeably"     →  interface
   "These types share some implementation"         →  embed, or a function
   "I need to add behaviour around an existing type" →  wrap it
   "I need a fixed algorithm with varying steps"   →  inject the steps
   "I need to share fields"                        →  embed a struct
   "Dog is a kind of Animal"                       →  stop and ask what you need

That last line is the important one. Inheritance encourages you to model taxonomies, and taxonomies are rarely what a program needs. Go pushes you toward asking what behaviour a piece of code requires, which usually produces a smaller and more flexible design.

Next, let's look at generics, which handle the one problem interfaces genuinely could not solve.

How is this guide?

Last updated on