Interfaces, Abstract Types & Contracts
What an interface promises beyond its signatures, who should own it — the implementer or the caller — the real differences between interfaces, abstract classes and traits, and why an interface with exactly one implementation and one mock is usually a mistake.
An interface is a contract, and the part a language can check — method names and types — is the smaller part. The rest is everything a caller must be able to assume without reading an implementation: whether calls may block, whether the object is safe to share across threads, what exceptions mean, whether ordering is stable, whether an operation is idempotent, roughly what it costs. List.get(int) is a signature; "constant time" is the reason ArrayList and LinkedList are not interchangeable in a hot loop even though both satisfy the compiler.
Who should own the interface #
The default habit is to define an interface next to its implementation — UserRepository shipped by the persistence package. The more useful arrangement is often the inverse: the consumer declares the interface it needs, in its own module, and the implementer satisfies it.
// package billing — declares only what billing actually uses
type RateSource interface {
Rate(ctx context.Context, pair string) (decimal.Decimal, error)
}Nothing in the pricing package mentions billing. Go makes this idiomatic because interface satisfaction is structural and implicit, but the pattern is available in any language, and it is what makes dependency inversion more than a slogan: the dependency arrow between modules now points at the consumer's abstraction rather than at the provider's concrete type.
Fowler's distinction is the useful vocabulary:
- A header interface mirrors the whole public surface of one class, one-to-one. It is a mechanical extraction, usually done for mocking, and it buys almost nothing — it changes whenever the class changes.
- A role interface describes one capability that a caller needs, named for the role.
Comparable,AutoCloseable,io.Writer,RateSource. It is small, it is stable, and unrelated types can play the role.
Interface segregation is the same observation stated as a rule: a client should not be forced to depend on methods it does not use. The practical smell is an interface whose implementers throw UnsupportedOperationException — that is the type system reporting that two roles were merged into one.
Interface, abstract class, trait #
| Interface (Java/C#) | Abstract class | Trait (Rust) | |
|---|---|---|---|
| Instance state | no | yes | no (associated consts/types yes) |
| Constructor | no | yes | no |
| Multiple per type | yes | no — uses the single inheritance slot | yes |
| Default implementations | yes (since Java 8 / C# 8) | yes | yes |
| Can be added to a type you don't own | no | no | yes, if you own the trait |
| Dispatch | virtual (itable) | virtual (vtable) | static by default, dyn opt-in |
Two of those rows carry most of the design weight.
The single inheritance slot. An abstract class spends the implementer's one extends. That is a real cost imposed on everyone downstream, and it is the main reason to prefer an interface for the abstraction plus an optional abstract skeletal class for convenience — the AbstractList/AbstractMap pattern. Implementers who need the shortcut extend the skeleton; implementers who cannot still implement the interface.
Retroactive implementation. Rust's traits can be implemented for foreign types — impl Display for MyType, or impl MyTrait for u32 — bounded by the orphan rule: the impl is allowed only if either the trait or the type is local to your crate. That rule exists to guarantee coherence, that there is never more than one impl of a trait for a type anywhere in a program, which is what makes global reasoning about dispatch sound. Java and C# have no equivalent; the workaround is an adapter object, or C#'s extension methods, which are static and therefore not polymorphic.
Abstract types that are not interfaces #
Not every abstraction is a set of methods. When a type has a fixed, known set of variants, an interface is the wrong tool — it is open by construction, so no code can ever be sure it has handled every case. Sealed hierarchies and enums close the set and make exhaustiveness checkable (inheritance vs composition).
sealed interface Payment permits Card, Transfer, Cash {}
// every switch over Payment is checked for completeness at compile timeThe rule of thumb: open set → interface; closed set → sealed type. Guessing wrong shows up as either a switch on instanceof over an interface (should have been sealed) or a sealed hierarchy that gets a new permit every sprint (should have been an interface).