Designing to an interface
Put a seam where implementations plausibly vary, size it to the narrowest caller, and tell a real interface from one that only adds a file.
An interface names a capability. Notifier says something here can deliver a
message to a person; it does not say SMTP, and the caller holding one cannot
find out. That is the whole trade — the caller depends on the capability instead
of on the provider, and you pay for it with one more name and one more file.
What the name is doing
Without the interface, an order service that sends a confirmation constructs the sender itself, so it knows the host, the port, the credentials and the retry policy of a mail server it has no opinions about. With the interface, it knows one method:
from typing import Protocol
class Notifier(Protocol):
def send(self, to: str, text: str) -> None: ...
class OrderService:
def __init__(self, notifier: Notifier):
self.notifier = notifier
def place(self, order):
...
self.notifier.send(order.email, f"Order {order.id} placed")
interface Notifier {
void send(String to, String text);
}
final class OrderService {
private final Notifier notifier;
OrderService(Notifier notifier) { this.notifier = notifier; }
}
The dependency shrank from a class with a dozen methods and a configuration object to one signature. Retries, TLS and bounce handling now live on the far side of it, where the order service cannot be broken by them.
Choosing the seam
A seam is worth cutting where implementations plausibly vary. In practice that is a short list, the same one in most systems:
- Storage — Postgres in production, a list in tests, sometimes both during a migration.
- Notification — email today, SMS or push later, nothing at all in tests.
- Payment and other vendors — two providers, one of which will have an outage, and neither of which you want to call from a test.
- The clock and randomness — a test that needs "now" fixed is the second implementation, arriving on day one.
Where implementations do not vary, an interface is a rename. A price calculator that reads fields you own and returns a number has no second implementation and never will; wrapping it adds a file, an import and a jump in every editor without removing one dependency.
Two questions settle most cases. Can you name the second implementation? Does one of them exist today — where a test double counts as existing? If both answers are no, you are guessing about the future in a file everyone has to read in the present.
The seam belongs at the boundary between policy and mechanism, which is the same line the D in SOLID draws: what the business means on one side, how it is carried out on the other.
Test doubles are the payoff you get immediately
The second implementation almost always arrives as a fake, and that is where the arithmetic lands. Take a suite of 1,200 unit tests, each making 4 calls to the store.
Against a real database, every call is at minimum one datacenter round trip at 0.5 ms: 1,200 × 4 × 0.5 ms = 2.4 s per run of pure waiting, plus a server that has to exist, be migrated and be reset between tests. A developer who runs the suite 30 times a day waits 30 × 2.4 s ≈ 72 s a day for round trips alone, and cannot run the suite on a train.
Against an in-memory fake, the same 4,800 calls are main-memory references at roughly 100 ns: 4,800 × 100 ns ≈ 0.5 ms, about 5,000 times less, with no server and no reset step.
The fake also makes failure reachable: a store that raises on the third call is three lines, while making a real Postgres fail on exactly the third call is a small project.
The honest cost is drift. A fake that stops behaving like the real store lets tests pass where production fails — a unique-constraint violation it never raises, an ordering it invents. Keep one contract suite and run it against both implementations; that is the price of the seam.
How wide
An interface is as wide as its narrowest caller needs, and named for the role rather than the provider.
Width multiplies. Adding send_with_template, send_bulk and verify_address
to a Notifier with three implementations creates nine more method bodies to
write and keep correct, most of them in implementations no caller asks for. A
one-method notifier gives you a four-line fake; a six-method one gives you a
fake nobody wants to write, which is how doubles turn into mocks that assert on
calls instead of behaviour.
The name is the second half of the rule. If the method names read like a
vendor's SDK — charge_with_provider_token — the seam leaked, and the second
provider will not fit the signatures. Name the methods for what the policy
needs: authorise, capture, refund, in your own types.
Where C++ changes the answer
Python and Java put the seam at run time: a virtual call, a choice made when the object is constructed. C++ takes the same seam at compile time.
template <class N>
class OrderService {
public:
explicit OrderService(N n) : notifier_(std::move(n)) {}
void place(const Order& o) { notifier_.send(o.email, "Order placed"); }
private:
N notifier_;
};
There is no interface class, no vtable and no indirect call — the send inlines. The cost is that the choice is fixed at build time, errors move to instantiation and read badly, and every caller becomes a template living in a header. Use the template when the build chooses the implementation (production versus test); use an abstract base class with virtual methods when configuration chooses it at run time.
When it is premature
One implementation, no named second, and no test that needs a double: do not extract. The concrete class is already an interface — every public method is a promise — and extracting later is a mechanical refactor your editor performs.
The specific failure is the wrapper that mirrors its implementation
method-for-method: a repository exposing save, find, delete over an ORM
whose methods are save, find and delete. Nothing was decoupled, because the
shape of the interface is still the ORM's shape.
Twelve speculative interfaces cost twelve double jumps: every "go to definition" lands on a declaration, and a search for the behaviour finds the signature rather than the code. Extract at the second implementation or the first test that needs a double, whichever arrives first.
In an interview
Name one seam, and say where you would not put one. "I would put an interface between the booking policy and the payment provider — there are two providers and the tests need neither. I would not put one in front of the fare calculator; no second implementation, no I/O." That pair of sentences is the judgment the question tests, and the same call decides which classes are real in the design interview.
The mistake that loses points is defending an interface with "for flexibility" while being unable to name the second implementation. Interviewers hear that as a candidate who has learned a habit rather than a trade-off. If the second implementation is the test double, say exactly that — it is a good answer, and it is concrete.
Check yourself
A store has one implementation and no plan for a second, but the tests need a double. Extract an interface?
Yes. The double is the second implementation, and it exists today rather than in an imagined future. The reason to extract is the test in front of you, not the provider you might switch to in two years.
Your PaymentGateway interface has one implementation and its method names
match the vendor's SDK one for one. What has it bought?
A file and an extra jump. A second provider will not fit those signatures, and a test double has to reproduce vendor semantics to be useful. The seam is at the wrong level: define the methods the policy needs — authorise, capture, refund, in your own value types — and let the vendor adapter translate.
Estimate what the fake saves a developer per day for a suite of 1,200 tests making 4 store calls each, run 30 times a day.
Real store: 4,800 calls × 0.5 ms per datacenter round trip ≈ 2.4 s per run, so 30 runs ≈ 72 s a day of waiting, plus a live migrated database. Fake: 4,800 main-memory references at ~100 ns ≈ 0.5 ms per run, roughly 5,000 times less, and it runs with no network.