When Your Domain Imports the Database Driver: The Direction Problem
Open the Order domain module of a typical service and count the framework imports. In the unmanaged version, orders.py or Order.java imports the ORM session, the web framework's annotations, and the message-bus client directly: the class that encodes business rules has a compile-time dependency on three infrastructure packages. Every one of those arrows points the wrong way, and the costs arrive on a schedule.
# Before: policy depends on details
from sqlalchemy import Column, Integer # ORM in domain
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True)
def refund(self, amount): ... # business rule
from kafka import KafkaProducer # bus client in domain
class OrderEvents:
def publish(self, order): # policy calls infra directly
KafkaProducer(...).send("orders", order.to_json())
The rules for swapping cannot hold a dependency: migrating from SQLAlchemy to a hand-rolled repository means editing domain classes; testing refund() requires a database session; upgrading the Kafka client's major version ripples into business logic. Dependency inversion makes domain code depend on interfaces rather than frameworks — and the principle is specifically about the direction of the source-code dependency, not about a Repository interface merely existing somewhere in the project.
Invert each arrow so the policy declares the abstraction it needs, and the infrastructure implements it:
# The abstraction lives with the policy (ports)
class OrderRepository(Protocol):
def get(self, order_id: UUID) -> Order: ...
def save(self, order: Order) -> None: ...
class OrderEventPublisher(Protocol):
def order_placed(self, order: Order) -> None: ...
class PlaceOrder: # use case: pure policy
def __init__(self, repo: OrderRepository,
events: OrderEventPublisher): ...
def __call__(self, cmd: PlaceOrderCmd) -> Order:
order = Order.place(cmd)
self.repo.save(order) # depends on the port
self.events.order_placed(order)
return order
# Adapters at the edge implement the ports
class SqlAlchemyOrderRepository:
def __init__(self, session): self.session = session
def save(self, order): self.session.merge(to_row(order))
class KafkaOrderEventPublisher:
def order_placed(self, order): self.producer.send(...)
Read the imports now: PlaceOrder imports nothing from SQLAlchemy or Kafka; the adapters import both the ports and the frameworks. That is inversion — the low-level detail (the driver, the ORM, the SDK) now depends on the abstraction the policy defined, instead of the reverse. The rule of thumb that catches drift in review: if a package under domain/ or core/ imports anything named after a vendor or a framework, the arrow regressed.
Three consequences are worth naming because they are what you are buying. The policy compiles and loads without any infrastructure present — so the module can be unit-tested, reasoned about, and reused in a batch job with no web stack attached. Swapping a detail becomes an adapter exercise: replacing Kafka with SQS is a new adapter class plus one wiring change, and the use cases do not know. And the boundary makes performance decisions explicit, because mapping between the domain model and the persistence rows happens in one visible place (the adapter) instead of leaking ORM identity maps through business rules. The next chapter's Clean Architecture Layers generalizes this into a full ring structure; the same principle, one module at a time, already pays for itself. A useful sequencing detail for teams doing the inversion incrementally: do not attempt a big-bang re-plumb. Start with the port that hurts most — usually the repository behind the module with the worst test suite — extract the interface from actual call sites so it names the operations the policy truly uses, implement the adapter by wrapping the existing ORM code rather than rewriting it, and leave the next port for the following sprint. Three or four such inversions usually cover the module's real dependencies, and each one ships independently with the test-suite improvement visible immediately.
Constructor Injection Makes Every Dependency Visible in One Line
Once policy holds ports, something must connect ports to adapters at runtime. The choices differ more than they appear, and the differences are what interviews probe.
Constructor injection declares dependencies in the signature and freezes them at creation:
class PlaceOrderHandler {
private final OrderRepository repo; // immutable after ctor
private final OrderEventPublisher events;
PlaceOrderHandler(OrderRepository repo, OrderEventPublisher events) {
this.repo = Objects.requireNonNull(repo);
this.events = Objects.requireNonNull(events);
}
}
Everything the class needs is legible in one place: a reader sees the complete dependency set without scanning method bodies, the fields are final, and construction without all dependencies is impossible — the type system enforces the wiring. Compare the alternatives, because each hides something. Setter injection makes construction a multi-step protocol (new Handler(); h.setRepo(r); h.setEvents(e);) where a half-initialized object is representable and every field must survive being null. Field injection (the typical DI-annotation pattern) hides dependencies behind reflection entirely, and forces framework context to construct anything testable. And the service locator — a container object passed around and queried (locator.get(OrderRepository.class)) — inverts nothing: it replaces compile-time dependencies with runtime ones, so a missing binding fails in production instead of at construction, and the class's true dependency list becomes invisible again.
| Mechanism | Dependency visible? | Object usable half-built? | Fails fast? |
|---|---|---|---|
| Constructor injection | yes, in signature | no | yes, at construction |
| Setter injection | no | yes | no |
| Field injection | no (reflection) | yes | no |
| Service locator | no (queries at call sites) | yes | no, at first call |
The related decision is where the wiring happens. A composition root — one module (or a handful of entry points) where concrete adapters are chosen and passed into constructors — keeps all knowledge of the actual stack in one file. With three or five ports, hand-written wiring is shorter and more debuggable than any framework; the DI container earns its place when the graph has dozens of bindings, scoped lifetimes (a per-request session vs a singleton publisher), and conditional registration (a stub adapter in dev, a real one in prod). If you do adopt a container, treat it as construction-time infrastructure only: dependencies are resolved at the root and passed in, never queried from inside business logic — that keeps the policy portable between containers.
A practical caution about lifecycle, because it bites in production: injection wiring can hide singleton state. A KafkaOrderEventPublisher registered as a singleton must be safe for concurrent use across all handler instances; a per-request repository must not leak into a global scope. Writing the intended lifetime next to each binding (// singleton, thread-safe: no mutable fields) is cheap documentation that prevents the classic 'works in tests, races under load' failure. With the wiring settled, the payoff arrives in the next chapter's test suite: running the entire domain against in-memory fakes. A note on constructor arity, because reviewers reasonably object when signatures grow. If a use case needs eight constructor parameters, that is signal, not a reason to fall back to setters: the class usually bundles two or three responsibilities (the SRP smell arriving from the other direction) and wants splitting into two constructors' worth of collaborators. An optional knob like a clock or an id generator may legitimately default to the system implementation, but anything with observable behavior should stay explicit — a default that hides a real collaborator trades a long signature for a silent dependency.
Running the Domain Suite Without Docker: Fakes, Ports, and Contract Tests
The claim that dependency inversion improves testability deserves numbers, because the alternative is a test suite that teams learn to skip. In a typical unmanaged service, running the PlaceOrder scenarios means starting a Postgres container, a Kafka broker, and the migration tooling — 40 to 90 seconds per session, plus flaky-start failures a few times a week. Teams respond by running integration tests only before release. The inverted design removes that trade: the domain suite runs against fakes at the ports, in milliseconds, on every save.
The fake is not a mock. A mock verifies interactions; a fake implements the port's contract in memory so scenarios can be set up and asserted against real domain behavior:
class InMemoryOrderRepo:
def __init__(self): self.rows = {}
def get(self, oid):
if oid not in self.rows: raise OrderNotFound(oid)
return self.rows[oid].clone() # preserve identity semantics
def save(self, order): self.rows[order.id] = order.clone()
class RecordingEventPublisher:
def __init__(self): self.published = []
def order_placed(self, order): self.published.append(order.id)
def test_double_refund_rejected():
repo = InMemoryOrderRepo(); events = RecordingEventPublisher()
use_case = PlaceOrder(repo, events)
order = use_case(PlaceOrderCmd(cart=[line("sku-1", 2)]))
order.refund(order.total)
with raises(RefundNotAllowed):
order.refund(Money(1)) # domain rule, no infra in sight
The suite compiles and runs with no external process: a thousand tests in under a second, deterministic, debuggable with a plain stack trace. Two traps keep fakes honest, and experienced teams name them out loud. Semantic drift: the fake's save() overwrites while the real Postgres adapter upserts, or the fake returns the same mutable object the database would re-read fresh — so the fake must reproduce the contract's observable behavior (clone on read, raise the same not-found error), not merely its signature. The adapter gap: nothing in the domain suite proves the real SqlAlchemyOrderRepository actually persists — so each adapter keeps one thin contract test that runs the port's behavior checks against the real dependency in CI, while the fast suite stays pure.
| Suite | What it proves | Runtime | Runs when |
|---|---|---|---|
| Domain (fakes) | business rules, use-case flows | < 1 s | every save |
| Adapter contract (real infra, dockerized) | fakes match reality | minutes | CI, on adapter changes |
| End-to-end (staging stack) | wiring, config, ops paths | tens of minutes | pre-release |
The strategy question interviewers ask next is whether in-memory fakes 'really' test the system. The honest answer is layered: fakes give the domain suite its speed, contract tests keep the fakes truthful, and a small end-to-end tier proves the composition root wires the right adapters with the right configuration. Each tier exists because the one below it cannot see something — and the inversion is what made the tiers possible at all. One operational benefit teams discover late: the inverted domain suite changes how refactoring feels. With the suite against fakes, a developer can restructure the entire use-case layer and see deterministic pass or fail in a second, which encourages continuous structural improvement instead of frozen code. The unmanaged alternative trains the opposite habit — if validating a rename takes a coffee break while containers boot, developers batch changes and ship riskier diffs. Suite runtime is a process property, not just a testing detail: it shapes how aggressively the design is maintained.
Quiz
1. What does dependency inversion specifically require, as distinct from merely having interfaces in the project?
2. Why is constructor injection preferred over setter injection or a service locator for domain collaborators?
3. A domain suite runs against an in-memory fake whose save() always overwrites, while the real Postgres adapter upserts and re-reads fresh rows on get(). What is the risk, and the standard mitigation?
4. A team's handler classes call `locator.get(OrderRepository.class)` internally instead of receiving dependencies in constructors. What is the main architectural cost?
Flashcards
Question
State dependency inversion precisely: what direction do source-code dependencies point before and after?
Click to reveal answer
Answer
Before: high-level policy imports low-level details (domain classes import ORM/Kafka packages). After: policy defines the ports it needs (OrderRepository, OrderEventPublisher protocols) and the adapters implement them — so the infrastructure depends on the policy's abstractions, never the reverse. The review rule: any framework-named import under domain/ is a regression.
Question
What three properties does constructor injection provide that setter injection and field injection cannot?
Click to reveal answer
Answer
1) The complete dependency set is visible in the signature. 2) Dependencies are final — the object cannot exist half-wired or mutated after construction. 3) Wiring errors fail at construction time in the composition root rather than at first call or via reflection. This is also why a service locator is the anti-pattern: it re-hides the dependency set and defers missing bindings to runtime.
Question
Where should concrete adapters be chosen and bound, and when does a DI container earn its complexity?
Click to reveal answer
Answer
At a composition root — one module or entry point where adapters are constructed and passed into constructors. Hand-written wiring wins for small graphs; a container earns its place at dozens of bindings with scoped lifetimes (per-request session vs singleton publisher) and conditional registration (stub in dev, real in prod). Containers must resolve at construction only, never be queried from business logic.
Question
What is the difference between a fake and a mock at a port boundary, and which does a domain suite use?
Click to reveal answer
Answer
A mock verifies interactions (was save called?); a fake implements the port's contract in memory — clone-on-read, same error types, upsert semantics — so scenarios assert against real domain behavior. Domain suites use fakes: they run thousands of deterministic tests in under a second with no external process, then stay truthful via adapter contract tests against real infrastructure.
Question
Name the two traps of port fakes and the three-tier test strategy that answers them.
Click to reveal answer
Answer
Traps: semantic drift (fake behavior diverges from the real adapter) and the adapter gap (nothing proves the real adapter works). Strategy: 1) fast domain suite on fakes (< 1 s, every save), 2) one contract test per adapter against real infrastructure in CI, 3) a small end-to-end tier proving composition root wiring and configuration. Each tier exists because the one below cannot see something.
Revision Notes
Key Takeaways
- 1. Dependency inversion is about compile-time arrow direction: policy declares the ports, adapters implement them, and a framework import under domain/ is the measurable regression
- 2. Swapping Kafka for SQS becomes an adapter exercise only when the use case never named the broker — the abstraction must live with the policy, not merely exist somewhere
- 3. Constructor injection makes the dependency set legible, immutable, and fail-fast; locators and setters hide exactly what review and testing need to see
- 4. The composition root concentrates all stack knowledge; containers earn their complexity at dozens of bindings with scoped lifetimes, and must never be queried inside business logic
- 5. The domain suite's speed (< 1 s, deterministic, no Docker) is bought with fakes at the ports and kept honest with one contract test per real adapter plus a thin end-to-end tier
Interview Tips
- • 'What is dependency inversion?' — arrow direction, not 'interfaces exist': before, the use case imports the ORM; after, the port lives with policy and the adapter depends on it; finish with the review rule about framework imports in domain code
- • 'How do you test business logic without a database?' — port fakes with contract semantics (clone-on-read, same errors), one contract test per real adapter, and honest tiering; naming the semantic-drift trap shows real experience
- • 'Constructor or setter injection?' — constructor: visible, immutable, fail-fast; then say when you'd adopt a DI container (binding count, lifetimes, conditional registration) and the rule that it constructs but is never queried from logic
- • 'Where do you draw the boundary between domain and infrastructure?' — the port: everything the policy names is inside (repository interfaces, event publisher), everything that names a vendor is outside; give the example mapping rows to entities in one visible place
Cheat Sheet
Dependency Inversion and Decoupling
Before → after arrow direction:
Before: PlaceOrder use case → SQLAlchemy / KafkaProducer (policy imports details)
After: PlaceOrder → OrderRepository (port) ← SqlAlchemyOrderRepository (adapter)
PlaceOrder → OrderEventPublisher (port) ← KafkaOrderEventPublisher
Review rule: any framework-named import under domain//core/ is a dependency-arrow regression.
Injection mechanisms:
| Mechanism | Visible? | Half-built? | Fails fast? |
|---|---|---|---|
| Constructor | yes (signature) | no | yes |
| Setter | no | yes | no |
| Field (annotation) | no (reflection) | yes | no |
| Service locator | no (runtime queries) | yes | no (first call) |
Wiring happens at a composition root: hand-written for small graphs; DI container when dozens of bindings, scoped lifetimes, and conditional registration appear — resolve at construction only, never query from logic. Document singleton/thread-safety next to each binding.
Test tiers:
| Tier | Proves | Runtime |
|---|---|---|
| Domain on fakes (clone-on-read, same errors) | rules + flows | < 1 s, every save |
| Adapter contract vs real infra | fakes match reality | CI, adapter changes |
| End-to-end staging | wiring + config | pre-release |
Fakes ≠ mocks: fakes implement the contract in memory; mocks verify calls.