Go Interfaces: Small Contracts, Real Trade-offs
One of the easiest ways to get interfaces wrong in Go is to import mental models from other languages too early.
If you come from Java, C#, or TypeScript, it is very tempting to read interface and immediately think in terms of abstract types, formal declarations, and inheritance-heavy design. That instinct is understandable. It is also where a lot of confusion starts.
Interfaces in Go are usually much smaller than that.
In practice, a Go interface is often just a small behavioral contract. Not a family tree. Not a framework feature. Not a declaration that a type must loudly opt into. Just a way to say: anything that can do this can be treated this way.
That sounds modest, but it turns out to be one of the more useful design tools in the language.
This is not a beginner tutorial on what an interface is. This is a practical look at how interfaces work in Go, why small interfaces matter, where type assertions and type switches actually fit, and which parts of the model become more interesting once the code stops being toy-sized.
An interface is usually about behavior, not identity
In Go, an interface describes behavior through method signatures.
A small example:
type Notifier interface {
Send(ctx context.Context, recipient string, body string) error
}
This does not say anything about what the underlying type is. It only says what the caller needs: something that knows how to send a message.
That might be an SMS provider. It might be an email provider. It might be a push notification client. It might be a fake sender in tests.
The point is not identity. The point is capability.
That distinction matters more than it first appears. A lot of bad interface design comes from starting with what kinds of things do I have instead of what behavior does this code actually depend on.
In Go, the second question is usually the better one.
Real interfaces tend to be small
This is one of the most important interface habits in Go.
Most good interfaces are small. Often very small. Sometimes they have one method. Sometimes two. Once they start growing, they often become harder to reuse and easier to regret.
A realistic example:
type TokenVerifier interface {
Verify(token string) (Claims, error)
}
That is already enough to support a lot of real code.
Your HTTP middleware does not need to know whether the token comes from JWT, an internal HMAC scheme, or a mocked verifier used in tests. It just needs something that can verify a token and return claims.
This is one of the big differences between interface usage in Go and in more inheritance-heavy ecosystems. In Go, the useful interface often appears close to the code that consumes it, and it often stays deliberately small.
That is not a style preference. It has consequences.
- small interfaces are easier to satisfy
- small interfaces are easier to fake in tests
- small interfaces are easier to understand
- small interfaces are less likely to drag unrelated behavior into places it does not belong
Types do not declare that they implement an interface
This is one of the most distinctive parts of Go’s interface model.
Implementation is implicit.
If a type has the required methods, it satisfies the interface. There is no implements keyword. No extra declaration. No ceremony.
For example:
type SMTPMailer struct{}
func (m SMTPMailer) Send(ctx context.Context, recipient string, body string) error {
// send email
return nil
}
type TwilioSMS struct{}
func (s TwilioSMS) Send(ctx context.Context, recipient string, body string) error {
// send sms
return nil
}
Both of these satisfy Notifier:
type Notifier interface {
Send(ctx context.Context, recipient string, body string) error
}
That design has a subtle but important effect: it decouples the interface from the type definition.
The mailer does not need to know the interface exists. The SMS client does not need to opt in. The consuming code defines the contract it needs, and types satisfy it by behavior.
That is one of the reasons Go interfaces can feel lightweight when used well. They do not force the whole codebase into a declaration-first architecture.
Accept interfaces, return structs
This is one of those Go sayings that gets repeated so often that it risks sounding like a slogan. But there is a real idea behind it.
Accepting an interface often makes sense because the caller may want flexibility at the boundary:
type ReceiptSender interface {
SendReceipt(ctx context.Context, order Order) error
}
type CheckoutService struct {
sender ReceiptSender
}
func NewCheckoutService(sender ReceiptSender) *CheckoutService {
return &CheckoutService{sender: sender}
}
Here the service depends on a behavior. That is a reasonable use of an interface.
Returning an interface too early is often less helpful. In many cases, returning a concrete type is clearer:
func NewStripeClient(apiKey string) *StripeClient {
return &StripeClient{apiKey: apiKey}
}
The caller now knows exactly what it received. Nothing is hidden behind abstraction unless abstraction is actually needed.
That is the useful reading of the rule: accepting an interface can keep a dependency flexible; returning one too early can hide more than it helps.
A switch is not automatically worse than an interface
This is worth saying clearly, because interface discussions often get too idealistic.
Not every repeated branch is a missing interface. Not every switch is a design failure.
Sometimes a local switch is simply the more honest solution.
For example:
func deliveryCost(channel any) int {
switch v := channel.(type) {
case SMSNotification:
return v.Segments * 2
case EmailNotification:
return 1
case PushNotification:
return 0
default:
return 0
}
}
This may be completely fine if:
- the branching is local
- the decision really is based on concrete type
- the difference is part of the domain
- there is no meaningful dependency boundary yet
Trying to force an interface here can actually add more boilerplate than value.
And if you really do need to branch on concrete types, a type switch is usually the cleanest way to do it. That is a much narrower claim than saying switches are always better. It just means that once you are already in concrete-type territory, stacking repeated assertions is usually harder to read than expressing the same decision as one switch.
A realistic example:
func expenseReport(exp Expense) (string, float64) {
switch v := exp.(type) {
case SMSExpense:
return v.PhoneNumber, v.Cost()
case EmailExpense:
return v.ToAddress, v.Cost()
default:
return "", 0
}
}
That still comes with the same caveat. If code like this becomes central to the design, it is usually worth stepping back and asking whether the boundary is too generic for its own good.
Where interfaces become more useful is when you are sitting at a real boundary, especially a third-party service, a side-effect-heavy adapter, or a dependency your service genuinely should not care about in concrete terms.
Interfaces can create a useful test seam
One place interfaces can help is at a test seam: a boundary where production code can use a real dependency, while tests can substitute a fake one without changing the calling code.
A practical example:
type Sender interface {
Send(ctx context.Context, recipient string, body string) error
}
type CheckoutService struct {
sender Sender
}
func NewCheckoutService(sender Sender) *CheckoutService {
return &CheckoutService{sender: sender}
}
func (s *CheckoutService) CompleteOrder(ctx context.Context, recipient string, orderID string) error {
body := "Your order " + orderID + " is confirmed"
return s.sender.Send(ctx, recipient, body)
}
In production, you can wire a real sender:
type EmailSender struct{}
func (e EmailSender) Send(ctx context.Context, recipient string, body string) error {
fmt.Println("sending email to", recipient)
return nil
}
And in tests, you can substitute a fake:
type FakeSender struct {
Calls []SendCall
}
type SendCall struct {
Recipient string
Body string
}
func (f *FakeSender) Send(ctx context.Context, recipient string, body string) error {
f.Calls = append(f.Calls, SendCall{
Recipient: recipient,
Body: body,
})
return nil
}
That does not mean every dependency deserves an interface. A test seam is useful when the boundary is real, not when abstraction is added just in case.
One type can satisfy multiple interfaces naturally
Because implementation is implicit, a single type can satisfy multiple interfaces without doing anything special.
That is not a trick. It just falls out of the model.
For example:
type AuditLogger struct{}
func (l AuditLogger) Println(v ...any) {
fmt.Println(v...)
}
func (l AuditLogger) Write(p []byte) (int, error) {
fmt.Print(string(p))
return len(p), nil
}
This type can be used where something print-like is needed and where something write-like is needed.
That flexibility is useful, but it is also one place where people sometimes start over-abstracting. The fact that a type can satisfy multiple interfaces does not mean it should be used through interfaces everywhere.
Sometimes the concrete type is still the clearest choice.
Pointer receivers affect interface satisfaction
This is one of the details that looks small until it causes a compile error you did not expect.
Suppose you have this interface:
type HealthChecker interface {
Check() error
}
And this type:
type RedisClient struct{}
func (r *RedisClient) Check() error {
return nil
}
Here, *RedisClient satisfies HealthChecker, but RedisClient does not.
That is because the method was defined on the pointer receiver, not the value receiver.
So this works:
var h HealthChecker = &RedisClient{}
But this does not:
var h HealthChecker = RedisClient{} // compile error
This is one of those Go details that is easy to misread if you think has the method is the whole story. Receiver choice affects method sets, and method sets affect whether a type satisfies an interface.
That matters in API design, not just in trivia.
The empty interface exists, but any should not become a reflex
Modern Go gives you any, which is just an alias for interface{}.
That means it can hold a value of any type.
func debugField(name string, value any) {
fmt.Printf("%s=%v\n", name, value)
}
This can be useful. Logging, debugging, generic containers, framework boundaries, and a few helper APIs sometimes need that flexibility.
But any is also one of those things that should make you slow down for a second.
When a function accepts any, it is giving up type information at the boundary. Sometimes that is the right move. Sometimes it is just postponing the moment when the code should become more explicit.
A realistic example where it can make sense:
func logFields(event string, fields ...any) {
fmt.Println(event, fields...)
}
That is fine for a logging helper.
A less convincing example is using any in a service API because the input shape has not been thought through yet. That usually ages badly. The flexibility is real, but so is the cost.
Type assertions are useful, but they are usually a smell if overused
Sometimes you have an interface value and want to check whether its underlying concrete type is something more specific.
That is where type assertions come in.
For example:
mailer, ok := sender.(*SMTPMailer)
if ok {
fmt.Println("smtp-specific behavior")
}
This has legitimate uses, especially when you are dealing with generic inputs or framework-style APIs.
But it is worth saying out loud: if a design constantly forces you to assert back to concrete types, the interface may be in the wrong place.
An interface is most useful when callers can stay at the interface level. Once every other call site starts asking yes, but what are you really, the abstraction may not be helping much anymore.
That does not mean assertions are wrong. It means they should usually be the exception, not the center of the design.
Nil is where interfaces stop feeling trivial
This is one of the classic places where interfaces stop feeling obvious.
Consider this:
type Sender interface {
Send(ctx context.Context, recipient string, body string) error
}
type EmailSender struct{}
func (e *EmailSender) Send(ctx context.Context, recipient string, body string) error {
return nil
}
Now:
var email *EmailSender = nil
var sender Sender = email
fmt.Println(sender == nil) // false
This surprises people the first time they see it.
The reason is that an interface value is only nil when both its dynamic type and dynamic value are nil. A typed nil pointer stored inside an interface is not the same thing as a nil interface.
This is not just an academic corner case. It creates real bugs in error handling, optional dependencies, and interface fields that look nil but are not.
It is one of the places where understanding that an interface value carries both a dynamic type and a dynamic value starts to matter.
Interface values carry dynamic type at runtime
That point is worth saying directly.
An interface value is not just abstract. At runtime, it carries a concrete dynamic type and a value.
That is why these things exist:
- type assertions
- type switches
- typed nil surprises
%Toutput being meaningful on an interface value
A small example:
func describe(v any) {
fmt.Printf("%T %v\n", v, v)
}
If you pass different concrete values into v, the interface is still abstract at the type level, but it still carries a runtime identity.
That is one of the reasons interfaces in Go feel lightweight but not magical. They are abstractions with concrete runtime behavior underneath.
Interface fields affect zero-value usability
This is a subtle but important design point.
Suppose you have this:
type Logger interface {
Printf(format string, args ...any)
}
type Service struct {
logger Logger
}
Now ask a very practical question: is the zero value of Service usable?
Probably not.
If logger is nil and the code does this:
func (s *Service) Process() {
s.logger.Printf("processing...")
}
You have a problem.
This is one of the places where interface design connects back to zero-value design. An interface field often means the struct’s zero value is no longer naturally safe unless you handle that case.
A common fix is a no-op implementation:
type NopLogger struct{}
func (NopLogger) Printf(format string, args ...any) {}
Then:
func NewService(logger Logger) *Service {
if logger == nil {
logger = NopLogger{}
}
return &Service{logger: logger}
}
This kind of detail matters because interfaces do not just affect abstraction. They affect how safe and predictable your types are by default.
Interface embedding can be useful when it reuses meaningful contracts
Go also allows interfaces to embed other interfaces.
For example:
type CacheReader interface {
Get(ctx context.Context, key string) ([]byte, error)
}
type CacheWriter interface {
Set(ctx context.Context, key string, value []byte) error
}
type CacheStore interface {
CacheReader
CacheWriter
}
This is useful when it composes existing behavioral contracts in a way that actually matches the domain.
What it should not become is a more elegant way to grow oversized interfaces.
Embedding helps when it reuses meaningful smaller contracts. It helps less when it just makes a large abstraction look tidier on paper.
Performance matters, but it usually should not lead the design
There are performance details around interfaces.
In hot paths, interface-heavy designs can have real costs. Conversions, dynamic dispatch, escape behavior, and allocations can all matter depending on the code.
But most interface decisions should still be made for clarity and boundaries first, not micro-benchmark wins.
If a path is performance-sensitive, it should be measured. Otherwise, interface design is usually better guided by coupling, readability, and correctness than by imagined cost.
Real-world interface examples tend to look narrower than people expect
A few examples that tend to make sense:
type PasswordHasher interface {
Hash(password string) (string, error)
}
type SessionStore interface {
Save(ctx context.Context, session Session) error
}
type ReceiptSender interface {
SendReceipt(ctx context.Context, order Order) error
}
type InventoryReader interface {
GetAvailable(ctx context.Context, sku string) (int, error)
}
None of these are dramatic. That is part of the point.
The best Go interfaces often describe one thing the caller needs to do right now, not an entire role in the system.
So what makes Go interfaces feel different?
If you zoom out, a few patterns show up again and again:
- interfaces describe behavior, not identity
- implementation is implicit
- small interfaces tend to work best
- consumer-defined interfaces often lead to better boundaries
- accepting interfaces often helps more than returning them too early
- a switch is not automatically worse than an interface
- interfaces can create useful test seams at real boundaries
anyis useful, but should be used carefully- method sets and pointer receivers affect interface satisfaction
- nil interfaces and typed nils are not the same thing
- type assertions and switches are tools, not a design goal
- interface fields can change zero-value safety
- embedded interfaces are useful when they compose meaningful contracts
That is why Go interfaces can feel lightweight when used well and frustrating when used badly.
Used well, they make dependencies smaller and code easier to adapt. Used badly, they become another layer of abstract naming around things that were already understandable.
Final thoughts
Interfaces in Go are easy to overcomplicate because the keyword looks familiar.
But the useful mental model is usually much simpler than people expect.
A Go interface is often just a small contract around behavior. Not a hierarchy tool. Not a declaration ceremony. Not a reason to abstract everything preemptively.
That is what makes them valuable.
They let code depend on what matters, stay smaller at boundaries, and remain flexible without forcing the whole design into a louder abstraction style than it needs.
And in real systems, that is often enough.