How to Build an Extensible Application with Go

Build extensible Go apps by defining interfaces for core behaviors, allowing new implementations to be added without modifying existing client code.

The coupling trap

You are building a notification service. It starts simple. You write a function sendEmail that takes a user address and a message. It works. Then the product manager asks for SMS support. You write sendSMS. Then Slack. Now you have three functions. They all do similar things but with different details. sendEmail needs a subject. sendSMS validates phone numbers. sendSlack picks a channel.

You try to unify them. You write a switch statement that checks a provider string and calls the right function. Every time you add a provider, you touch the switch. Every time you add a new field, you update the switch. The switch becomes a bottleneck. Your code depends on the list of providers. You are writing code that talks to specific implementations instead of a behavior. This is the coupling trap. You are hard-coding dependencies, and the code becomes rigid.

Go solves this with interfaces, but the trick isn't just writing the interface. It's designing the boundary so the rest of your code never knows which implementation is plugged in. You define the shape of the behavior once. You let implementations vary. The code that uses the behavior just calls the method. It doesn't care about the details. This keeps your application extensible. You can add new providers without touching the core logic. You can swap implementations for testing. You can even let third-party packages provide implementations without depending on your internal types.

Interfaces as contracts

Think of a USB port. Your computer doesn't care if you plug in a mouse, a keyboard, or a hard drive. It just knows the port accepts a device that speaks USB. The operating system provides a driver for each device, but the code that handles input or storage works against the USB abstraction. In Go, interfaces are that USB port. You define the shape of the connection, and any type that fits that shape can plug in.

Go interfaces are structural. This is different from languages like Java or C#. In those languages, you write a declaration saying a class implements an interface. Go doesn't do that. If a type has the methods, it implements the interface. This has a huge consequence. You can define an interface in one package. Another package can implement it without importing the first package. The implementation doesn't need to know the interface exists. This enables deep decoupling. You can write a library that defines an interface. Users of the library can implement it in their own code. They get integration with the ecosystem for free.

The convention is to name interfaces based on the behavior. Single-method interfaces get an -er suffix. A type with a Read method is a Reader. A type with a Write method is a Writer. Multi-method interfaces drop the suffix. Notifier or Storer works. Don't prefix with I. INotifier is not Go style. Just Notifier.

Minimal example

Here's the smallest extensible pattern: define an interface, implement it, and pass the interface to the function that needs the behavior.

package main

// Notifier defines the contract for sending messages.
// Any type with these methods satisfies this interface.
type Notifier interface {
	Send(to string, msg string) error
}

// EmailNotifier sends messages via email.
type EmailNotifier struct{}

// Send fulfills the Notifier interface for email.
func (e EmailNotifier) Send(to string, msg string) error {
	// Simulate sending email.
	// In real code, this would connect to an SMTP server.
	return nil
}

// processRequest handles a user action and notifies them.
// It accepts a Notifier, so it doesn't care about the transport.
func processRequest(n Notifier, user string) error {
	return n.Send(user, "Your request is complete")
}

func main() {
	var n Notifier = EmailNotifier{}
	// processRequest works with EmailNotifier because it has Send.
	_ = processRequest(n, "alice@example.com")
}

Small interfaces are easy to satisfy. Big interfaces are hard to test.

How implicit implementation works

In the example, processRequest takes a Notifier. It calls n.Send. The compiler generates a call to the method. Since EmailNotifier has Send, it works. The key is processRequest doesn't know about EmailNotifier. It only knows Notifier. You can swap EmailNotifier for SlackNotifier without changing processRequest. This is polymorphism.

The compiler enforces the contract. If you rename a method in the interface but forget to update the implementation, you get cannot use LocalStorer as type Storer: missing method Save. This error stops you from shipping broken code. It forces you to update all implementations. This is a safety net. It makes refactoring safe. You can change the interface, and the compiler tells you exactly where to fix the code.

Receiver naming follows a convention. Use short names that match the type. (e EmailNotifier), (n Notifier), (s Storer). One or two letters. Don't use this or self. Go doesn't use those keywords. The receiver is just another parameter. This keeps method signatures clean and readable.

Realistic storage backend

Here's a realistic pattern: define a storage interface, implement a local backend, and inject it into the handler so you can swap to cloud storage later without touching the handler logic.

package main

import "io"

// Storer abstracts the persistence layer.
// Any backend implementing these methods can be swapped in.
type Storer interface {
	Save(key string, data io.Reader) error
}

// LocalStorer implements Storer using the local filesystem.
type LocalStorer struct{}

// Save writes data to disk.
// This method satisfies the Storer interface.
func (l LocalStorer) Save(key string, data io.Reader) error {
	// Real code would create a file and copy the reader.
	return nil
}

// processUpload handles the business logic.
// It accepts Storer, so it remains agnostic to the storage backend.
func processUpload(s Storer, key string, data io.Reader) error {
	return s.Save(key, data)
}

Testing is where extensibility pays off. With the Storer interface, you can write a TestStorer. It stores data in a map. It has no side effects. You inject TestStorer into processUpload. You run the test. You verify the behavior. You don't need a real database or file system. You don't need to clean up files. The test is fast and deterministic. This is the practical benefit. Extensibility isn't just about swapping S3 for Local. It's about swapping Real for Fake during testing.

The mantra is "accept interfaces, return structs." Functions should accept interfaces so callers can pass any implementation. Functions should return structs so callers get a concrete value they can use. Returning an interface leaks implementation details and makes the code harder to reason about. Keep the interface at the boundary. Return the struct from the inside.

Inject the interface. Return the struct. Keep the dependency flowing one way.

Pitfalls and compiler errors

Interface bloat is the enemy. If your interface has ten methods, every implementation needs ten methods. If you add an eleventh method, you break every implementation. The compiler will scream. You have to update everything. Keep interfaces small. One or two methods is the sweet spot. io.Reader has one method. io.Writer has one method. io.Closer has one method. You can combine them. io.ReadWriteCloser embeds the three. This is composition. You get flexibility without bloat. If you find yourself writing an interface with many methods, ask if it should be split. Maybe one part is for reading, one for writing. Split them. Accept the smaller interfaces. Return the struct that implements both.

Another trap is the nil interface. If you have a function that returns a Storer, and you return nil, the caller gets a nil interface. But if you return a *LocalStorer that is nil, the interface is not nil. It holds a type and a nil pointer. Calling a method on that interface panics with runtime error: invalid memory address or nil pointer dereference. Always return a nil interface when the value is nil, not a nil concrete type wrapped in an interface. This is a subtle bug that shows up in production. Write a test that checks for nil returns. Verify the interface is nil, not just the pointer.

Error handling is part of the contract. Methods that can fail return an error. Send returns error. Save returns error. The caller checks if err != nil. This is verbose. The community accepts it. It makes the failure path visible. Don't hide errors. Return them. Let the caller decide. The boilerplate is a feature. It forces you to handle the unhappy path.

The compiler catches missing methods. Your tests catch nil panics. Trust both.

When to use interfaces

Extensibility costs complexity. Every interface adds an abstraction layer. You pay the cost only when you need the flexibility. Use the simplest thing that works. If you have one implementation and don't expect more, use a concrete type. If you need to swap implementations or inject mocks, use an interface.

Use an interface when you need to swap implementations at runtime or inject a mock for testing.

Use a concrete type when the behavior is stable and you don't expect multiple implementations.

Use a function type when the operation is stateless and fits in a single callback.

Use composition when you need to layer behaviors, like adding logging or retries to an existing implementation.

Extensibility costs complexity. Pay the cost only when you need the flexibility.

Where to go next