Defining contracts with interfaces
Open in the interactive app: practice, quiz, mock interview →In one minute
- An interface is a named set of operations a caller may use without knowing which concrete type answers. It separates "what I need" from "who provides it".
- Nominal typing (Java, C#) requires a type to declare what it implements. Structural typing (Go, TypeScript, Python protocols) accepts any type whose methods fit.
- An interface only covers signatures. The behaviour behind them (ordering, errors, side effects, thread safety) is part of the contract too, and only documentation and tests carry it.
- Interfaces cost something at runtime: an indirect call through a method table, lost inlining, and in Go often a heap allocation. Usually noise; in a hot loop, not.
- Agents add interfaces eagerly, one per class, "for testability". Ask who calls it and how many implementations exist. An interface with one implementation and one caller is usually just indirection.
How it works
The problem: code that names a concrete type
A billing service exports invoices. The first version writes straight to a CSV file:
class InvoiceExporter:
def __init__(self, path):
self.out = CsvFile(path)
def export(self, invoices):
for inv in invoices:
self.out.write_row([inv.id, inv.customer, inv.total_cents])
self.out.close()Then finance wants the export pushed to a data warehouse, and tests want it in memory. Each needs a change inside InvoiceExporter, because it names CsvFile. The exporter does not care about files; it needs something to hand rows to and then close.
Name the need, not the provider
An interface states that need:
type RowSink interface {
WriteRow(cells []string) error
Close() error
}
func ExportInvoices(invs []Invoice, out RowSink) error {
for _, inv := range invs {
if err := out.WriteRow(inv.Cells()); err != nil {
return err
}
}
return out.Close()
}ExportInvoices now works with a CSV writer, a warehouse loader, a gzip wrapper or a slice in a test. The Dependency Inversion lesson covers why the exporter's package should own RowSink, and the Interface Segregation lesson covers how small it should be. This lesson is about the interface itself: what it promises, how languages check it, and what it costs.
Nominal and structural
In Java a class must say implements RowSink. This is nominal typing: the relationship is declared by name. Intent is explicit and tools can find every implementation, but the interface must exist first, and a library type you do not own can only satisfy it through a wrapper.
Go, TypeScript and Python protocols are structural. A Go type satisfies RowSink if it has the two methods, whether or not its author ever heard of RowSink, which is why *os.File, *bytes.Buffer and *gzip.Writer all fit interfaces declared long after them. The cost is that nothing records intent: a renamed method silently stops satisfying the interface. Go's idiom is a compile-time assertion, var _ RowSink = (*CsvWriter)(nil), which fails the build if the method set drifts. TypeScript is structural even for classes: implements checks the class, but any object literal of the right shape is equally acceptable.
Signatures are not the whole contract
Close() error says nothing about whether calling it twice is safe, whether rows are durable when WriteRow returns, or whether two goroutines may write at once. Callers will rely on whatever the first implementation did. Write those rules in the doc comment and enforce them with one contract test suite, as the Liskov Substitution lesson describes. The signature is the part the compiler checks, and usually the smaller half of the promise.
Optional capabilities
Sometimes an implementation can do more than the interface requires. io.Copy checks whether the source implements io.WriterTo or the destination io.ReaderFrom, and if so hands over the whole copy, which lets file-to-socket copies use sendfile. An HTTP handler checks whether its ResponseWriter is also an http.Flusher before streaming. The pattern: require a minimal interface, probe for richer ones at runtime, fall back otherwise.
The trap is wrappers. A logging wrapper that implements only the three required ResponseWriter methods hides Flusher, and streaming silently becomes buffering. A wrapper must expose the same optional capabilities as what it wraps, or provide Unwrap so callers can reach the original, which http.ResponseController (Go 1.20) uses. The practice task builds this.
When not to add one
- One implementation, one caller, no test need. The interface doubles the files a reader opens and hides the real type behind "go to definition".
- Data, not behaviour. A struct of fields does not need an interface of getters.
- Producer-side interfaces in Go. Returning
Storeinstead of*PostgresStorehides methods and forces an allocation. Return the concrete type; let consumers declare what they use.
Reviewing agent-written code
Asked to "make this testable", agents generate an IUserRepository for every class, mirroring every public method, placed next to the implementation. Asked to "add metrics", they wrap a writer and lose its optional capabilities. Review three things: the interface lives with its consumer and lists only what it calls; a compile-time assertion or test proves each implementation fits; every wrapper forwards optional interfaces or exposes Unwrap.
The same shape elsewhere
An interface is a contract between parties that change independently, like a wire protocol. The POSIX file API (read, write, close on disks, pipes and sockets) and HTTP share its failure modes: implicit behaviour callers rely on (Hyrum's law), optional extensions negotiated at runtime (Accept-Encoding), and proxies that strip them.
Down the stack
Layer 1The contract
A caller holding an interface value is promised that every listed method exists. Go and Java check this at compile time for static conversions and at runtime for assertions and casts. TypeScript checks only at compile time. Python checks nothing until a lookup raises AttributeError, except that abc.ABC refuses to instantiate a class with unimplemented abstract methods. No language checks behaviour.
Layer 2The mechanism
Go. A non-empty interface value is two words, (tab, data). tab points to an itab holding the interface type, the concrete type, a copy of the type's hash (for type switches) and an array of method pointers in the interface's sorted method order. The empty interface any stores the type descriptor directly instead. For a static conversion such as var s RowSink = w, the compiler emits the itab at build time. A dynamic assertion such as x.(Flusher) looks up (interface, type) in a global runtime hash table and, on a miss, builds the itab by merging the two sorted method lists, caching negative results too; recent releases put a small cache at each assertion site in front of that table. Because the type word is part of the value, an interface holding a nil *MyErr is not nil: the typed-nil bug.
Java (HotSpot). Each class has a vtable followed by an itable: a list of (interface, offset) pairs, then a block of method entries per interface. invokeinterface cannot use a fixed slot, because the same interface method sits at different positions in different classes, so the megamorphic stub scans the pairs to find the block. JIT-compiled code avoids the scan with inline caches: a monomorphic site compares the receiver's class and jumps directly, and class hierarchy analysis drops even that check when only one loaded class implements the interface.
TypeScript. tsc erases interfaces, so x instanceof RowSink does not compile and a runtime check must be a hand-written type guard (typeof x.flush === "function"). Dispatch is plain property lookup through hidden classes and the prototype chain.
Python. ABCMeta computes __abstractmethods__ at class creation; object.__new__ raises TypeError if it is non-empty. isinstance(x, SomeABC) consults a per-ABC cache, register() calls and __subclasshook__ (how collections.abc.Iterable accepts any class with __iter__). typing.Protocol is for type checkers; @runtime_checkable makes isinstance work but only checks that attributes exist, not their signatures.
Layer 3The machine
A Go interface call loads tab, loads tab.fun[i], and calls indirectly with data as the receiver: about 1 to 2 ns when the branch predictor knows the target. The bigger cost is lost inlining, which blocks bounds-check elimination and other loop optimisations. An interface needs a pointer, so a non-pointer struct stored in one usually escapes to the heap (20 to 50 ns plus GC work); zero-sized values and integers 0 to 255 avoid this by pointing into static data. go build -gcflags=-m shows the escapes, and profile-guided optimisation can devirtualise a hot site behind a type check. On the JVM a monomorphic inline cache costs one compare against the class pointer in the object header, and the itable scan is a short loop reached only from megamorphic sites. A Python method call costs tens of nanoseconds regardless; a runtime_checkable isinstance costs microseconds, so probe once, not per item.
Why it's built this way
- Callers and implementations change at different times, so they need a written meeting point.
- A class implementing many interfaces puts each method at a different position, so dispatch needs a table per (type, interface) pair rather than one fixed slot.
- Building every pair's table up front is wasteful, so Go builds itabs lazily and caches them, and the JVM caches the common case per call site.
- Signatures are cheap to check mechanically and behaviour is not, so behaviour is left to documentation and tests.
Diagram
flowchart LR
V["interface value (tab, data)"] --> T["itab"]
V --> D["*CsvWriter data"]
T --> IT["inter: RowSink"]
T --> CT["type: *CsvWriter"]
T --> F["fun[0] Close, fun[1] WriteRow"]
F --> M["(*CsvWriter).WriteRow"]
A["x.(Flusher)"] --> C{"call-site cache hit?"}
C -- "yes" --> R["reuse itab"]
C -- "no" --> H["global itab table"]
H -- "miss" --> B["build itab, cache result"]Worked example
A log shipper handles 2 million records per second per core and writes them to a Sink one call per record. The interface call itself costs about 2 ns, 0.4 percent of the core. The Kafka sink behind it takes a lock and updates batch bookkeeping on every call, about 180 ns, which is 36 percent of the core.
The team adds an optional WriteBatch. The shipper probes for it once at startup and hands over 500 records at a time, so the lock is taken once per batch and write cost falls to about 15 ns per record (3 percent of the core). The file sink, which lacks WriteBatch, keeps working unchanged.
Later a metrics wrapper is added around every sink, implementing only Write. Kafka throughput quietly drops back, because the wrapper hides WriteBatch, and nobody notices for two weeks until a capacity review compares CPU graphs. The fix is a wrapper that forwards each capability its inner sink has, plus a test asserting Counting(kafkaSink) still satisfies Batcher.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Concrete type | Fast, inlinable, obvious to read | Callers change when the type changes | One implementation, no test seam needed |
Nominal interface (implements) | Intent explicit, tooling finds implementations | Interface must exist first; foreign types need wrappers | Large Java or C# codebases, published SPIs |
| Structural interface | Defined at the consumer; foreign types fit | Accidental satisfaction; silent drift | Go, TypeScript, Python protocols; small role interfaces |
| Optional capability probe | Fast paths without widening the required interface | Wrappers hide capabilities; runtime checks | Stable minimal interface with known optimisations |
Pitfalls in production
- Returning a typed nil pointer as an
error, soerr != nilis true and callers log an empty error. Fix: return a literalnilon success. - Middleware that hides optional interfaces, turning streaming into buffering. Fix: forward each capability or implement
Unwrap, and test it. - Implementations that drift after a method rename in structural languages. Fix:
var _ I = (*T)(nil)in Go,implementsin TypeScript. - Callers relying on unwritten behaviour, such as
Closebeing idempotent in one implementation only. Fix: document it and enforce it with a shared contract test. - Interface values in hot Go loops forcing heap allocations. Fix: concrete types or generics in the hot path; check with
-gcflags=-m.
Leadership lens
- Treat a widely implemented interface as a public API: a new required method breaks every implementation, so add an optional interface first.
- In design review, ask for each new interface: who calls it, how many implementations exist or are planned, and where the behavioural contract is written down.
- Put "wrappers forward optional capabilities" on the middleware review checklist; it prevents regressions nobody files tickets for.
- Budget for contract tests; they keep implementations by different teams or agents behaving alike.
Check yourself
Why can a library type you did not write satisfy your Go interface but not your Java interface?
Show answer
Go checks method sets structurally, so any type with matching methods fits. Java requires the class to declare
implements, which you cannot add to a class you do not own, so you need an adapter.A Go function returns
error, and on success returns a variable of type*ValidationErrorthat is nil. Why doeserr != nilevaluate true?Show answer
The interface value holds a non-nil type word (
*ValidationError) and a nil data word. An interface is nil only when both words are nil.Why does a JVM call site that sees one implementation run almost as fast as a direct call, while one that sees five does not?
Show answer
A monomorphic site uses an inline cache: one class compare, then a direct, often inlined, call. A megamorphic site falls back to the itable stub, which scans the interface list and calls indirectly.
A wrapper around an HTTP response writer breaks server-sent events. What is the likely cause?
Show answer
The wrapper implements only the required methods, so the handler's check for
http.Flusherfails and output is buffered. ForwardFlushor implementUnwrap.
Watch
- Go Proverbs (Rob Pike, Gopherfest 2015): "the bigger the interface, the weaker the abstraction" and other short rules for interface design.
- Understanding nil (Francesc Campoy, GopherCon 2016): how interface values are laid out and why a typed nil is not nil.
Free resources
- Go Data Structures: Interfaces (Russ Cox): the original explanation of itabs, method tables and how Go builds them lazily.
- Why is my nil error value not equal to nil? (Go FAQ): the typed-nil trap stated precisely.
- InterfaceCalls (OpenJDK HotSpot wiki): how HotSpot dispatches
invokeinterfacethrough itables and inline caches. - PEP 544: Protocols (Python): structural subtyping for Python and what
runtime_checkabledoes and does not check. - net/http ResponseController (Go docs): how the standard library reaches optional capabilities through wrappers with
Unwrap.