We wrote one rule down before we wrote the product: everything outside the core domain must be replaceable. No cloud, database, storage, payment, email, or model vendor may appear in the core domain’s types or logic. Each of those categories sits behind an interface we own.
Rules adopted after the fact are not rules. They are regrets with better formatting. So this one went into the repository before there was anything to apply it to.
Most writing on this subject stops at “use dependency injection” and leaves you to discover the hard parts alone. The hard parts are knowing when an abstraction is real, knowing what shape it should be, and knowing what it costs. This note is about those three.
What the rule actually forbids
It is stricter than dependency injection. The domain may not import a vendor SDK, may not name a vendor in a type, and may not branch on which vendor is configured. Three separate prohibitions, and the third is the one people miss.
The third is what stops the most common failure: an interface that is technically vendor-neutral wrapped around code that says if (provider == "s3") somewhere in the domain. That is not an abstraction. That is a switch statement wearing a costume.
Concretely, here is the selection mechanism. Every provider implementation in the Java core is chosen by a single configuration key, never by a code path:
@Component
@ConditionalOnProperty(
name = "provider.queue",
havingValue = "postgres",
matchIfMissing = true)
public class PostgresQueueProvider implements QueueProvider { ... }The default is the free one, and it is the default by omission —matchIfMissing = true. A fresh clone with an empty configuration file runs the free path. That is deliberate: the cheap path has to be the path of least resistance, or it decays the first time someone is in a hurry.
The test that separates a seam from indirection
An interface alone proves nothing. Here is the test we apply, and it is the most portable thing in this note: delete the vendor implementation from the build and run the whole test suite. If the suite still passes, you have a seam. If it fails to compile, you had a naming convention.
An abstraction with exactly one implementation is not an abstraction. It is indirection with extra steps.
Our suite passes with no API keys, no network access, and no credentials, because every category ships a working free default that the tests genuinely run against. Not a stub that throws UnsupportedOperationException — a working implementation the system runs on every day:
| Category | Free default | What it proves |
|---|---|---|
| Queue | Postgres table, for update skip locked | Async work needs no broker to be correct |
| Cache | In-memory | Cache is never the source of truth |
| Storage | Local disk | The interface holds without object-store semantics |
| Logs instead of sending | Delivery is a side effect, not a domain concept | |
| Inference | Deterministic mock | Tests stay reproducible without a model vendor |
Two implementations, one of which the whole test suite exercises, is a seam you can trust — because it is load-bearing every day, not on the hypothetical day you migrate. That is what turns a design principle into an engineering practice. Interfaces do not stay honest because you wrote them down. They stay honest because something breaks loudly when they drift.
Two interface shapes we got wrong
Designing an abstraction before you have felt the problem is genuinely hard, and we did not get it right first time. Two specific failures, because generic advice without them is useless.
Failure one: modelling the vendor, not the need. Our first storage interface had a method taking a content type, an access-control setting, and a lifecycle rule. All three existed because one object-storage product has them. The local-disk implementation had to ignore two of the three arguments, which is the tell.
When a second implementation must ignore an argument, that argument belongs to the vendor, not to your domain.
The fix was to ask what the domain actually needs: store bytes under a key, get them back, know whether they exist. Vendor-specific behaviour that genuinely matters gets configured where the implementation is configured, not passed through the domain.
Failure two: leaking failure modes. An early interface returned a vendor’s error type on failure. That compiles, it passes review if you are not looking for it, and it quietly makes the domain vendor-specific — because now every caller handling errors is written against one vendor’s taxonomy. Swapping the implementation would compile and then misbehave at every catch site, which is the worst class of migration bug: silent, distributed, and only visible under failure. The interface owns its error model or it owns nothing.
The exception we have not taken
The tempting exception is always the same: one vendor has a feature no interface models cleanly, and threading it through properly costs a day.
It is worth being precise about why refusing is not stubbornness. A rule with one exception is not a rule with one exception. It is a rule that has been demonstrated to be negotiable, and every subsequent argument is now about whether this case deserves it more or less than the last one. That argument has no ending. The cost of holding the line is one day of work. The cost of losing it is that every future decision becomes a debate.
The ledger: cost against benefit
| What it cost | What it bought |
|---|---|
| A slower first month. Designing interfaces before feeling the problem is harder than the direct call, and revising one after callers exist costs more than writing it twice. | No mandatory monthly infrastructure cost at our current scale — an outcome of the rule, not a separate initiative. |
| A permanent readability tax. Every request trace passes a boundary that would not exist in a vendor-direct codebase. | Moving off a free tier is a configuration change, not a scheduled migration. |
| Lost vendor ergonomics. Managed services are pleasant precisely because they leak into your code; refusing that means writing the retry policy, the backoff curve, and the dead-letter handling ourselves. | No vendor’s pricing page, deprecation notice, or rate-limit change gets a vote in our roadmap. |
| Two implementations per category to maintain, and a free path that rots the moment it stops being exercised. | A test suite that runs with no keys, no network, and no credentials — fast, deterministic, and runnable by anyone who clones the repository. |
When we would break it
If a category ever has exactly one credible implementation for the foreseeable life of the system, and modelling it as an interface makes the domain harder to understand for no available second implementation, the abstraction is ceremony. We would rather delete it than defend it.
That has not happened yet. Every category we assumed was single-vendor turned out not to be, usually within a year.
Try this
- Take a project you already have. Pick the vendor you depend on most, delete its client from the build, and count the compile errors. That number measures how much of your codebase belongs to someone else.
- Write one free default, for one category. Not the whole system. One.
- While writing it, keep a list of every argument your second implementation has to ignore. That list is the part of your interface that belongs to a vendor.
Own every layer.