AlgoMaster Logo

Interface Best Practices

Last Updated: May 22, 2026

Medium Priority
12 min read

Go's interfaces are easy to declare and easy to misuse. The mechanics fit on one page, but the design choices around them, where to put them, how big they should be, when to write one at all, take longer to understand. This lesson is the practical guide: a set of rules Go developers follow to keep interfaces useful instead of decorative.

Accept Interfaces, Return Concrete Types

This rule shows up in almost every Go style guide, and it's worth understanding why before applying it. The idea is that a function should take the smallest interface it needs as input, but return the actual struct (or concrete type) it produces.

Here's a function that does both wrong:

The function takes *EmailNotifier directly, which means it can't be used with an SMSNotifier or a PushNotifier without changing the signature. And it returns Notifier, which hides the concrete type from the caller. The caller now has to type-assert before doing anything email-specific.

Flip both decisions:

sendOrderUpdate now works with anything that has a Send method. The constructor newEmailNotifier returns *EmailNotifier, so callers can still reach email-specific fields like Address without a type assertion.

The rule bends in two real situations. One is when returning the concrete type would leak too much of the implementation, for example a database driver returning its internal connection pool. The other is when the function needs to return different concrete types depending on input, in which case the interface return is the only honest signature. Both cases are real but rare. Default to returning the concrete type and switch only when there's a clear reason.

Keep Interfaces Small

The smaller the interface, the easier it is to satisfy, the easier it is to mock, and the easier it is to compose. Go's standard library leans hard on single-method interfaces, and the convention is so strong it has its own naming pattern.

A method named Read produces an interface named Reader. Write produces Writer. Close produces Closer. The "-er" suffix attaches to the verb and names the role. The pattern appears throughout the standard library:

Each of these is one method. They compose into larger interfaces when needed (io.ReadWriter, io.ReadCloser), but the building blocks stay tiny.

Compare a fat interface that tries to capture everything an order processor might do:

Any type that wants to be an OrderProcessor has to implement six methods. A test double has to stub all six. If one new method is added, every existing implementation breaks until it adopts the new method. The interface is also lying about the real shape of the code, since validation, payment, and logging are almost never the same component.

The same logic split into small interfaces:

Each interface stands on its own, can be implemented and mocked independently, and reflects a real responsibility. When the checkout code needs to validate and charge, it accepts both interfaces as parameters and lets the caller pass whatever concrete types make sense.

The checkout function depends on two abstractions, each of which is one method. Swapping in a test double for either is trivial because there's only one method to stub.

A rough rule of thumb: prefer one to three methods per interface. Anything larger is a hint that the interface is grouping unrelated capabilities and should be split.

Define Interfaces Where They Are Used

This is the rule that surprises developers coming from Java or C#. In those languages, the producer of a type often declares an interface alongside the class so the world can depend on the interface. Go does the opposite. Interfaces should be declared in the package that consumes them, not the package that implements them.

The reason is structural typing. A Go interface is satisfied implicitly. The type doesn't have to know the interface exists, doesn't have to import its package, and doesn't have to write implements Foo. So the producer of a struct can just publish the struct. Any consumer that needs to abstract over it declares its own small interface, exactly fitting what it needs, and the struct satisfies that interface automatically.

Here's the layout. Suppose package pdf produces a renderer and package report consumes one:

First, the interface lives in the report package, next to the function that uses it. Second, the interface is named in lowercase, so it's unexported. The report package doesn't expose it because no one outside report needs to know that this abstraction exists. Third, *pdf.Renderer satisfies report.renderer without either package importing the other for interface purposes.

The benefits show up later. If a second consumer, say package invoice, also needs a rendering abstraction but with a slightly different method set (maybe it also needs PageCount() int), it declares its own interface with both methods. The pdf.Renderer satisfies both without changing. If the interfaces lived in package pdf, every consumer would either share a single fat interface or import pdf just to name the abstraction, which defeats the point.

The producer-side approach also creates a real problem: cyclic imports. If pdf declared an interface, and pdf also wanted to use types from report somewhere, you'd have a cycle. Consumer-side interfaces sidestep this entirely.

There's one common exception. The standard library's io.Reader and io.Writer live in package io because they're shared by hundreds of consumers and the abstraction belongs to no single one of them. When an interface is truly central and reused across many packages, hoisting it into a shared package makes sense. That's a rare situation. Default to consumer-side and migrate to a shared package only if the interface ends up needed in many places.

The diagram contrasts the two placements. In Java or C#, the producer publishes both the implementation and the contract, and consumers import the contract. In Go, the producer publishes only the concrete type, and each consumer declares whatever contract it needs. The dotted line shows implicit satisfaction: the struct never refers to the interface, but the compiler verifies the match wherever the value is used.

Don't Predict the Future

A common mistake is writing an interface "in case we need to swap implementations later". You usually don't. The right time to introduce an interface is when you actually have two concrete types that need to be treated the same, or when you have a clear testing need that a real implementation can't meet.

Until then, use the concrete type:

There's exactly one catalog implementation. Adding type Catalog interface { PriceOf(code string) (float64, bool) } and changing displayPrice to take Catalog would add a layer without benefit. The interface would never have a second implementer, and the test for displayPrice can use the real ProductCatalog because constructing one is trivial.

The refactor is cheap when the need actually appears. Say the team later adds a RemoteCatalog that fetches prices over the network. At that point you introduce a small interface in the consumer package, both types satisfy it, and displayPrice switches to taking the interface. Until that day, the concrete type is correct.

The same logic applies to testing. If a real implementation is fast, deterministic, and free of external dependencies, use it directly in tests. An in-memory ProductCatalog is a perfectly good test target. The interface only is useful when the real implementation hits something you can't (or shouldn't) hit in a test: a network, a clock, a random source, a real database.

An interface in front of a concrete type adds an indirect call at runtime and prevents some compiler optimizations (inlining, devirtualization). Modern Go is good enough that this rarely matters at the application level, but in tight loops or hot paths it shows up.

Interface Pollution

The failure mode of "predict the future" at scale is interface pollution: every struct has a matching one-method interface, every constructor returns the interface, and reading the code means tracing through layers of abstraction to find what actually runs.

The smell looks like this:

There's one struct, one interface, one constructor, and the constructor returns the interface so the concrete type is hidden. The next package that consumes user.Service now has to deal with a six-method interface even if it only uses one method. And the test for user.service can't easily mock individual methods because the contract is all-or-nothing.

The fix is to publish the concrete type and let consumers declare their own slim interfaces:

A consumer that only reads users declares its own one-method interface:

*user.Service satisfies profile.userReader without either package coupling to a giant interface. Tests for RenderProfile can stub a single method. The user package can grow new methods without forcing every consumer to update.

The principle: an interface should describe what the consumer needs, not what the producer offers.

Composition Over Inheritance

Go doesn't have inheritance. There's no class extends keyword and no way for one type to automatically get another type's methods through a parent-child relationship. What Go offers instead is composition: building larger types out of smaller pieces and embedding interfaces into other interfaces.

The standard library's io.ReadWriter is io.Reader and io.Writer glued together:

The same pattern applies to your own code. When two small interfaces are commonly needed together, compose them:

memoryStore has two methods. They satisfy Saver on their own, Loader on their own, and SaveLoader together. Functions that need only saving accept Saver; functions that need only loading accept Loader; functions that need both accept SaveLoader. No type hierarchy, no virtual methods, no parent classes. The pieces snap together because the interfaces are small and the composition is explicit.

The inheritance equivalent in another language would put Save and Load on a base Store class and ship both methods to every subclass. That's fine until a subclass legitimately only needs one of them, and now it has to either throw "not implemented" exceptions or break the inheritance chain. Composition avoids that whole category of bug by never bundling unrelated capabilities in the first place.

Compile-Time Satisfaction Checks

Because Go interfaces are satisfied implicitly, there's no compiler error when a type drops below an interface's requirements. If you write a type that's supposed to satisfy io.Reader and you misspell the method as Reed, the file compiles fine. The mismatch only surfaces when something tries to use your type as an io.Reader, which might happen at the top of main or might happen at runtime in a different package.

The compile-time check pattern fixes this. Place a line like this near the type definition:

The line var _ io.Reader = (*EventLog)(nil) says: declare a variable named _ of type io.Reader, and assign it a nil *EventLog. The blank identifier _ means the variable is thrown away immediately, so this costs nothing at runtime. The compiler still verifies the assignment, which means it verifies that *EventLog satisfies io.Reader. If you misspell Read as Reed, the line refuses to compile, and the error message tells you exactly which method is missing or has the wrong signature.

Place the check just below the method definitions. It serves as documentation for the next reader ("this type is meant to satisfy io.Reader") and as a guard against silent breakage if someone later edits a method signature.

A few notes on the form:

  • Use (*EventLog)(nil) for pointer receivers, since the method set lives on the pointer type.
  • Use EventLog{} (or a zero value) for value receivers.
  • The variable name _ is the convention. Naming it anything else creates an unused-variable warning unless the package actually uses it.

This check is especially valuable when you maintain a type that satisfies an interface in another package's tests. Without the check, breaking the interface only fails when someone runs the consuming tests, which might not happen for weeks.

Naming Conventions

Interface names in Go follow patterns that tell readers what kind of contract they describe.

Noun-er for single-method interfaces. Take the method name, append "-er", and you have the interface name. Read produces Reader. Write produces Writer. Close produces Closer. String produces Stringer. Sort produces Sorter. This works because most one-method interfaces describe a capability ("this thing can read") and English already has a way to name such capabilities.

When the method name doesn't end cleanly with "-er", adapt slightly. Marshal becomes Marshaler, not Marshaler (note the single l, matching the encoding/json convention). Lock becomes Locker. The convention is "thing that does X", and you bend the grammar enough to make it sound right.

-able for capability-style names. Less common in idiomatic Go but still seen, especially when "-er" doesn't read well. Comparable, Hashable. The standard library uses these sparingly; most one-method interfaces stick with "-er".

Descriptive role names for multi-method interfaces. When an interface has two or more methods and isn't a clean composition of single-method interfaces, name it after the role it plays. http.Handler is a multi-method-style name (it has one method, but the name describes what the type does in the system). sort.Interface describes the role of "thing that can be sorted" by sort.Sort. fs.FS names the filesystem abstraction.

Across all three:

  • Don't prefix with I (no IReader, no IDatabase). That's a C# habit and doesn't belong in Go.
  • Don't end with Interface unless it's truly a generic role name like sort.Interface. UserServiceInterface is noise; UserService is fine if it's the interface, and *userService is fine for the implementation.
  • Use lowercase (unexported) names when the interface is internal to one package. Most consumer-side interfaces are unexported.
  • Match the existing Go style around you. A package that already uses Reader-style names should keep using them.

When NOT to Use an Interface

The flip side of every rule above is knowing when to skip the interface entirely.

Single implementation, no testing need. If a struct has one and only one concrete form, and you can construct it directly in tests, an interface adds no value. Use the struct.

Internal helpers. A utility function inside one package, called from one other place in the same package, doesn't need to be hidden behind an interface. Pass the concrete type.

Premature abstraction. "We might swap implementations later" is almost never a good reason to add an interface now. Wait until you actually have two implementations. The refactor when that day arrives is mechanical and quick.

Data-holding types. Structs that mostly hold data (a User, an Order, a Product) generally shouldn't be hidden behind interfaces. The interface would just enumerate getters, which adds nothing the struct field access already provides.

Tight, performance-sensitive code. Interface calls in Go go through a method table lookup, which prevents inlining. In most application code this is invisible, but in hot loops it's measurable. If profiling shows the indirection matters, switch to a concrete type or generics.

The honest summary: interfaces are valuable when they decouple a consumer from a specific implementation that varies. They're overhead when they don't.

Mocking and Test Doubles

A common reason to introduce an interface is testing. When the real implementation does something that's slow, non-deterministic, or external (database, network, clock, random number generator), tests need a substitute. Small interfaces make those substitutes easy to write.

The principle is: don't use a mocking framework first. Use a small interface and a hand-written stub. Most Go developers prefer this over generated mocks because the stubs are short, readable, and obvious in what they do.

The stubCharger records every call and lets the test inject a return value. It's twenty lines of code, no library, and a future reader can see exactly what the test relies on.

When a real implementation is fine, use it. An in-memory map satisfies most "data store" interfaces in tests without any stub at all. The interface has a place only when the real implementation has a problem in the test context.

Common Mistakes

A few patterns show up often enough that they're worth calling out as antipatterns:

MistakeWhat's WrongFix
Returning an interface from a constructorHides the concrete type from callers, forces type assertionsReturn the concrete type
One-method interface per struct, alwaysInterface pollution, no real abstraction gainedAdd interfaces only when there's a second implementer or a clear testing need
Interface with seven methodsHard to satisfy, hard to mock, usually conflates responsibilitiesSplit into smaller interfaces; compose if needed
Interface declared in the producer packageCouples consumers to the producer's view of the abstractionDeclare in the consumer package
Naming with I prefix or Interface suffixNot idiomatic GoUse noun-er or descriptive role names
Forgetting compile-time satisfaction checkSilent breakage when method signatures changeAdd var _ Interface = (*Type)(nil)
Wrapping a struct in an interface "just in case"Adds indirection with no benefitUse the struct directly until a second implementer exists

The first six all share a root cause: treating interfaces as the default and concrete types as the exception. In Go, it's the other way around.