The notification trap
You are building a notification service. It starts simple: send an email. You write a function, test it, and ship it. A week later, the product manager asks for SMS support. You copy the email function, rename it, and tweak the logic. Two weeks later, Slack joins the party. Now you have three nearly identical functions scattered across your codebase. Changing the error handling logic means hunting down three copies. Refactoring feels risky. You need a way to group related code without locking yourself into a single implementation.
Packages and interfaces: drawers and sockets
Go solves this with two tools that work together. Packages group code by namespace and compilation unit. Interfaces define behavior without specifying implementation.
Think of packages as labeled drawers in a workshop. You put all your screwdrivers in one drawer, all your hammers in another. The drawer keeps things organized and prevents you from accidentally grabbing a hammer when you need a screwdriver. Packages enforce this boundary. Code inside a package can access private names starting with a lowercase letter. Code outside cannot. This encapsulation protects your internal state.
Interfaces are like the universal socket on a power strip. It does not care if you plug in a laptop, a lamp, or a charger. It only cares that the plug fits the shape. Your code can talk to the socket without knowing what device is on the other end. Interfaces allow you to write functions that work with any type satisfying the contract.
Packages organize code. Interfaces organize behavior.
Minimal example: implicit contracts
Here is the simplest pattern: define an interface in one place, implement it in another, and pass the interface around.
// notifier.go
package main
// Notifier defines the behavior contract.
// Types implement this implicitly by having a Send method.
type Notifier interface {
Send(msg string) error
}
// EmailNotifier is a concrete implementation.
type EmailNotifier struct{}
// Send implements the Notifier interface.
func (e *EmailNotifier) Send(msg string) error {
// Logic to send email lives here.
return nil
}
// processNotification depends on the interface.
// It works with any type that has a Send method.
func processNotification(n Notifier, msg string) error {
return n.Send(msg)
}
func main() {
// Pass concrete types where the interface is expected.
n := &EmailNotifier{}
processNotification(n, "Test message")
}
Go interfaces are implicit. You do not write implements. If a type has the methods, it satisfies the interface. The compiler checks this at compile time. If you pass a type that lacks a method, the compiler rejects it with cannot use type as Notifier value in argument: type missing Send method. This keeps your contracts safe without boilerplate.
Interfaces are satisfied by structure, not declaration.
Under the hood: what the compiler sees
Go interfaces are not zero-cost abstractions in all cases. Under the hood, an interface value is a struct with two pointers. One points to the type information, the other points to the data. When you pass a large struct to an interface, the compiler copies the struct onto the heap and passes a pointer. This allocation can hurt performance in tight loops.
If you are passing large structs, consider passing a pointer to the struct instead. The interface will then hold a pointer to the pointer, avoiding the heap copy. This is a common optimization. The receiver name convention helps here too. Use short names matching the type, like (e *EmailNotifier). Avoid this or self. It keeps the code idiomatic and readable.
Interface indirection costs memory. Measure before optimizing.
Realistic split: handler and storage
In real code, you often split packages and interfaces. The interface lives in the package that uses it, or in a shared package. The implementation lives in a separate package. This prevents circular dependencies and keeps boundaries clear.
Here is a handler package that depends on a storage interface.
// handler/handler.go
package handler
import "myapp/storage"
// Handler processes HTTP requests.
// It takes a storage.Store to decouple from the database.
type Handler struct {
db storage.Store
}
// NewHandler creates a handler with the given store.
func NewHandler(db storage.Store) *Handler {
return &Handler{db: db}
}
// GetItem retrieves an item using the store.
// It propagates errors to the caller.
func (h *Handler) GetItem(id string) (string, error) {
val, err := h.db.Get(id)
if err != nil {
return "", err
}
return val, nil
}
Notice the Handler accepts a storage.Store interface. This follows the Go mantra: accept interfaces, return structs. The handler does not need to know if the store is a database, a cache, or a mock. It just needs something that satisfies Store. This makes testing easy. You can inject a fake store that returns predictable data without touching a real database.
The if err != nil check is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Do not hide errors behind silent returns.
Accept interfaces, return structs. Keep dependencies flowing one way.
Refactoring and the implicit tax
Implicit interfaces make refactoring tricky. If you add a method to an interface, every implementation must update. The compiler catches missing methods, but you might have implementations in other packages that you forgot to check. Run go build ./... to scan the whole module. The compiler will scream with missing method in implementation errors.
This is why small interfaces are safer. If you change a one-method interface, fewer things break. Interface bloat happens when you define interfaces too early or too broadly. If you define an interface with five methods when you only need one, you force every implementation to carry dead weight. Keep interfaces small. One or two methods is ideal. If you find yourself adding methods to an existing interface, consider splitting it.
Small interfaces break less often. Keep them focused.
Built-in interfaces: error and context
Go uses interfaces for core features. The error type is an interface with a single Error() string method. This allows any package to define custom errors. You can wrap errors, add context, or create sentinel errors. This flexibility is why Go error handling feels verbose but powerful. You can check types with type assertions or errors.As.
context.Context is another ubiquitous interface. It carries deadlines, cancellation signals, and request-scoped values. Functions that perform I/O should accept a context as the first parameter. This convention allows callers to control the lifetime of operations. If you are designing an interface for a long-running task, include context support. The receiver name for context is conventionally ctx.
Context is plumbing. Run it through every long-lived call site.
Pitfalls and compiler errors
Interfaces are powerful, but they can create new problems. The biggest risk is circular dependencies. Package A imports Package B, and Package B imports Package A. The compiler rejects this with import cycle not allowed. To break cycles, move the interface to a third package, or define the interface in the package that uses it.
Another pitfall is nil interfaces. A common bug is returning a nil interface when you mean to return a nil concrete type. If a function returns an interface and you return nil, the interface value is non-nil because it contains type information. This causes panics when the caller checks for nil. Always return a typed nil or check the concrete type.
Do not wrap strings in pointers. Strings are already cheap to pass by value. Passing *string adds indirection without benefit. Use _ to discard values intentionally. result, _ := ... says "I considered the second return value and chose to drop it". Use it sparingly with errors.
Nil interfaces are not nil values. Check the type, not just the value.
Decision matrix
Use a package when you need to group related types and functions under a single namespace. Use a package when code must be compiled together and shared across multiple files. Use an interface when you want to decouple a consumer from a specific implementation. Use an interface when you need to swap behaviors at runtime or inject mocks for testing. Use an interface when multiple packages need to provide the same behavior without importing each other. Use a concrete type when there is only one implementation and no reason to abstract. Use a concrete type when performance matters and interface indirection adds measurable overhead. Use plain functions when the logic is simple and does not require state or polymorphism.
Modularity is a balance. Over-abstracting creates confusion. Under-abstracting creates coupling. Find the middle ground.