Skip to main content
Learn more
GolangTechnical

Implicit Interfaces: An introduction to one of Golang's superpowers

Everyone talks about goroutines. But Go's interfaces are quietly doing some of the heaviest lifting in the language, and they rarely get the spotlight they deserve.

Diabene Yaw Addo

Chief Technology Officer

Implicit Interfaces: An introduction to one of Golang's superpowers

If you have spent any time reading about Go, you have almost certainly heard about goroutines. People love talking about goroutines. They are lightweight, they are cheap to spin up, and they make writing concurrent programs feel surprisingly natural. That reputation is well-earned, and Go deserves the credit it gets for it.

But here is the thing. In all that excitement about concurrency, another feature quietly sits in the background doing some of the most important work in the language. That feature is interfaces, and it does not get nearly enough love.

Interfaces are the reason Go codebases stay manageable as they grow. They are the reason you can write a function once and have it work with a file, a network connection, a test buffer, or any other data source you throw at it, without touching the function at all. So let us talk about them properly. What they are, how they work inside Go, where you already see them in action in the standard library, and finally how to use them to build something real.

The Analogy: Sockets and Plugs

Before we write any code, think about electrical sockets for a moment.

A wall socket does not care what you plug into it. It does not know whether it is charging a phone, powering a fan, or running a kettle. All it cares about is one thing: does the plug fit? If the shape matches, electricity flows. The socket defines a contract about shape, and any device that meets that contract gets to use it.

Go interfaces work exactly this way. An interface defines a set of method signatures, which is basically the shape you need to have. Any type in Go that has those methods automatically satisfies the interface. If the shape matches, the type can be used wherever that interface is expected. This sounds small, but the consequences are genuinely large.

What really sets Go apart is that interfaces are implemented implicitly. A concrete type does not need to announce that it satisfies an interface. If the type has all the methods in the interface's method set, the interface is satisfied automatically. No keyword, no declaration, no registration. This implicit behaviour is what makes Go interfaces the most interesting thing about types in the language, because they enable both type-safety and decoupling at the same time.

Go, Python, and Java Walk Into a Bar

Languages like Python, Ruby, and JavaScript use what is called duck typing. If a value has the method your function expects, you can pass it. No formal contract needed. It is quick to write and very flexible, but as a codebase grows, it gets genuinely hard to know what a function depends on without reading every line of it. New developers joining the project have to trace through the code just to understand the actual requirements.

Java went the other direction. You declare explicitly that your class implements an interface, and the compiler enforces it. Everything is traceable, but it also means that every time you want to satisfy a new interface, or use a type from a third-party package, you have to go back and change your class declaration. The interface relationship is locked in at the point of definition, not at the point of use.

Go decided both groups had a point. You get compile-time safety and explicit contracts, but the relationship between a type and an interface is defined by the caller, not the implementor. Here is what that looks like in practice. Say you are building a checkout flow and you want to charge a customer through Hubtel:

// the contract: any provider that can charge a phone number must satisfy this
type PaymentProcessor interface {
    Charge(phone string, amount float64) error
}

// Hubtel is just a provider we want to use in our application, it knows nothing about how and where it's used.
type HubtelProvider struct{}

// having this method is all it takes to satisfy the PaymentProcessor interface
func (h HubtelProvider) Charge(phone string, amount float64) error {
    // call Hubtel API
    return nil
}

// Checkout depends on the interface, not on any specific provider
type Checkout struct {
    Payment PaymentProcessor
}

// this method works with Hubtel, Paystack, or anything else that fits
func (c Checkout) Process(phone string, amount float64) error {
    // call the registered provider to handle the charge, business logic would live here too
    return c.Payment.Charge(phone, amount)
}

func main() {
    // swap HubtelProvider for any other provider here without changing the business logic inside the Checkout's Process method
    c := Checkout{Payment: HubtelProvider{}}
    c.Process("+233244000000", 50.00)
}

HubtelProvider never says it implements PaymentProcessor. Checkout defines the interface it needs, and HubtelProvider satisfies it by simply having the right method. Tomorrow you decide to add Paystack as an option. You write a new provider, give it a Charge method with the same signature, and pass it in. Checkout never changes. That is the Go way: interfaces specify what callers need, and any type that fits gets to participate.

How the Standard Library Uses Interfaces

Before we build anything ourselves, it is worth looking at how Go's own standard library uses interfaces, because it does so brilliantly and the examples are everywhere. Every interface has what Go calls a method set: the exact list of methods a concrete type must have to satisfy it. You will also notice that Go interfaces tend to end in "er," and that is not accidental. io.Reader reads. io.Writer writes. io.Closer closes. fmt.Stringer converts a value to a string. json.Marshaler converts it to JSON. http.Handler handles an HTTP request. The naming convention makes the capability obvious just from the name, which is exactly what you want when you are scanning unfamiliar code.

io.Writer and io.Reader

Two of the most widely used interfaces in all of Go are io.Writer and io.Reader. They are defined like this:

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

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

That is it. One method each. And because they are so minimal, an enormous number of types in Go satisfy them.

os.Stdout is a writer. A file opened for writing is a writer. A bytes.Buffer is a writer. A network connection is both a reader and a writer. A gzip compressor is a writer that wraps another writer underneath. They all have that one Write method, and so they are all interchangeable wherever a Writer is expected.

This means you can write a function that accepts an io.Writer, and it will work with all of them without you changing a line.

writer

A Practical Example: Logging

Think about a simple logger. You want it to write log messages somewhere, but you do not want to hardcode where. In development, you probably want to write to the terminal. In production, maybe you write to a file or a log aggregator. Here is how interfaces make this trivial:

type Logger struct {
    out io.Writer
}

func (l *Logger) Log(message string) {
    fmt.Fprintf(l.out, "[LOG] %s\n", message)
}

Now you can create the same logger pointed at different destinations:

// Write to terminal
logger := &Logger{out: os.Stdout}
logger.Log("server started")

// Write to a file
file, _ := os.Create("app.log")
fileLogger := &Logger{out: file}
fileLogger.Log("server started")

The Logger itself never changed. You just swapped the destination. os.Stdout and *os.File both satisfy io.Writer, so both work.

Network Streams

The same idea applies beautifully to network connections. A net.Conn in Go satisfies both io.Reader and io.Writer, because reading from a network socket and reading from a file are structurally the same operation as far as Go is concerned.

func readGreeting(r io.Reader) (string, error) {
    buf := make([]byte, 1024)
    n, err := r.Read(buf)
    if err != nil {
        return "", err
    }
    return string(buf[:n]), nil
}

You can pass a file, a network connection, a bytes.Buffer in your tests, or a strings.Reader anywhere this function is used. The function does not know or care. It just knows it has something it can read from.

This is why Go programs tend to be so composable. Small interfaces let different pieces of the standard library work together naturally, without tight coupling between them.

Building a Simple Load Balancer

Now let us apply this to something we build ourselves. We are going to write a simple HTTP load balancer. The goal is to have a load balancer that can distribute incoming requests across multiple backend servers, and where you can swap the distribution strategy without changing the balancer itself.

We will keep this minimal. No health checks, no dynamic backend registration, no complex configuration. Just the core idea expressed cleanly through interfaces.

The Backend Interface

First, we define what the load balancer needs to know about a backend. It needs an address it can log, and a way to forward a request. That is all.

type Backend interface {
    Address() string
    Forward(w http.ResponseWriter, r *http.Request)
}

Now we implement a concrete backend. This one is a simple reverse proxy to an HTTP server.

type Server struct {
    address string
    proxy   *httputil.ReverseProxy
}

func NewServer(address string) *Server {
    target, _ := url.Parse(address)
    return &Server{
        address: target.Host,
        proxy:   httputil.NewSingleHostReverseProxy(target),
    }
}

func (s *Server) Address() string { return s.address }

func (s *Server) Forward(w http.ResponseWriter, r *http.Request) {
    s.proxy.ServeHTTP(w, r)
}

Server satisfies Backend because it has Address and Forward. Nothing else needed.

The Balancer Interface

Next, we define the balancing strategy. Given a list of backends, pick one.

type Balancer interface {
    Pick(backends []Backend) Backend
}

One method. Now we implement three strategies.

Round Robin

Round robin is the simplest fair distribution strategy. Imagine three people at a ticket booth (Joshua, Evans, Topboy). The first request goes to Joshua, the second to Evans, the third to Topboy, and then you start again from Joshua. Every backend gets an equal share of traffic, and you cycle through them in order.

type RoundRobin struct {
    current uint64
}

func (rr *RoundRobin) Pick(backends []Backend) Backend {
    index := atomic.AddUint64(&rr.current, 1) - 1
    return backends[index%uint64(len(backends))]
}

The atomic.AddUint64 keeps the counter safe when multiple requests come in at the same time. Other than that, it is just dividing the counter by the number of backends and taking the remainder, which cycles through 0, 1, 2, 0, 1, 2 and so on.

Random

Random selection does exactly what it sounds like. For each incoming request, pick a backend at random. There is no memory of what was picked before. Over a large enough number of requests, each backend will receive roughly equal traffic, but in the short term the distribution can be uneven.

Random is useful when you have stateless backends and you want the simplicity of zero coordination between requests.

type Random struct{}

func (rn *Random) Pick(backends []Backend) Backend {
    return backends[rand.Intn(len(backends))]
}

Weighted Round Robin

Sometimes your backends are not equal. One server might be running on a machine with twice the CPU and memory of the others. Sending it the same amount of traffic as a weaker machine means you are leaving capacity on the table.

Weighted round robin solves this by assigning each backend a weight. A backend with a weight of three receives three times as many requests as a backend with a weight of one. You are still cycling through backends in order, but the more powerful ones appear in the rotation more often.

type WeightedBackend struct {
    backend Backend
    weight  int
}

type WeightedRoundRobin struct {
    pool    []WeightedBackend
    current uint64
}

func NewWeightedRoundRobin(pool ...WeightedBackend) *WeightedRoundRobin {
    return &WeightedRoundRobin{pool: pool}
}

func (w *WeightedRoundRobin) Pick(_ []Backend) Backend {
    expanded := make([]Backend, 0)
    for _, entry := range w.pool {
        for i := 0; i < entry.weight; i++ {
            expanded = append(expanded, entry.backend)
        }
    }
    index := atomic.AddUint64(&w.current, 1) - 1
    return expanded[index%uint64(len(expanded))]
}

If you have server A with weight 3 and server B with weight 1, the expanded slice looks like [A, A, A, B]. Round robin over that and server A gets traffic 75% of the time, server B gets 25%. The ratio reflects the weights you set.

Putting It Together

Now the load balancer itself. It holds a list of backends and a balancer strategy, and it implements http.Handler so you can use it directly with Go's standard net/http package.

type LoadBalancer struct {
    backends []Backend
    balancer Balancer
}

func New(balancer Balancer, backends ...Backend) *LoadBalancer {
    return &LoadBalancer{
        backends: backends,
        balancer: balancer,
    }
}

func (lb *LoadBalancer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    backend := lb.balancer.Pick(lb.backends)
    log.Printf("routing to %s", backend.Address())
    backend.Forward(w, r)
}

And here is how you wire it up:

func main() {
    b1 := NewServer("http://localhost:8081")
    b2 := NewServer("http://localhost:8082")
    b3 := NewServer("http://localhost:8083")

    lb := New(&RoundRobin{}, b1, b2, b3)

    log.Println("load balancer running on :8080")
    log.Fatal(http.ListenAndServe(":8080", lb))
}

To switch to random selection:

lb := New(&Random{}, b1, b2, b3)

To use weighted round robin where the first server is twice as powerful:

lb := New(
    NewWeightedRoundRobin(
        WeightedBackend{backend: b1, weight: 2},
        WeightedBackend{backend: b2, weight: 1},
        WeightedBackend{backend: b3, weight: 1},
    ),
    b1, b2, b3,
)

That single line is the only thing you change. LoadBalancer, Server, ServeHTTP -- none of that moves. The balancing logic is completely separated from the routing logic, and you can swap strategies at will.

How Interfaces Work Inside Go

It helps to have a rough mental model of what Go is actually doing at runtime when you use an interface.

An interface value is essentially two pointers sitting side by side. One pointer knows the concrete type behind the interface, including a table of the actual functions to call for each method. The other pointer points at the data itself, the actual value you stored in the interface.

When you call a method on an interface, Go looks at that first pointer to find the right function for the concrete type you have, then calls it. This is called dynamic dispatch, because the exact function to call is decided at runtime based on what type is actually there, not at compile time.

The other important thing to understand is that Go checks interface satisfaction at compile time. If your type is missing a method that the interface requires, your code will not compile. You get a clear error message right there before the program ever runs, which is exactly what you want.

One small gotcha worth knowing: a nil interface and an interface holding a nil pointer are not the same thing. A nil interface has no type information at all. An interface holding a nil pointer still has type information, so it is not nil as far as Go is concerned, even though the underlying value is nil. This trips people up sometimes, but once you know it, you will not be surprised by it.

What This Is Really About

The load balancer example is simple, but the principle behind it scales to much larger systems. Any time you find yourself writing code that depends on a specific implementation of something, think about what you actually need that thing to do. Express that as an interface. Now your code is open to any future implementation you have not thought of yet.

Go's standard library applies this thinking everywhere, and the result is a standard library where components fit together naturally without being tightly coupled. fmt.Fprintf writes to any writer. io.Copy copies from any reader to any writer. The HTTP server accepts any handler. Once you start seeing the interfaces, you see them everywhere.

The language gives you goroutines for concurrency and interfaces for flexibility. Both are worth mastering.

A practical Introduction to Interfaces in Golang | JED