How can a domain (business) layer trigger database writes, emails, or messages without having any compile-time dependency on the database, mail, or broker technology?
answer
- consumer owns the interface, provider implements
- ports (domain vocabulary) + adapters (tech)
- runtime call outward, compile-time arrow inward
- composition root wires the graph
- domain events + outbox for side effects
basics
~20 sThe domain declares interfaces describing what it needs ("save this order", "send this notification") in its own vocabulary. Infrastructure classes implement those interfaces, and something at startup wires the implementations in. Calls still go outward; the code dependency points inward.
solid answer
~50 sUse the **Dependency Inversion Principle**: both sides depend on an abstraction, and the abstraction is *owned by the domain*, not by infrastructure. The domain defines a **port** — for example `OrderRepository.save(order)` or `PaymentGateway.charge(...)` — expressed in domain types with no SQL, HTTP, or vendor concepts. Infrastructure provides **adapters** implementing those ports using JDBC, an ORM, an SDK, or a broker client. A composition root (DI container, main function, module wiring) injects the adapter at startup. The result: at runtime the call still travels domain → infrastructure, but at compile time infrastructure → domain. The domain module can build and be unit-tested with in-memory fakes and no container. This is the core of hexagonal/ports-and-adapters and onion/clean architecture, and it turns classic top-down layering into an inward-pointing one. Two cautions: the port must be defined in the *domain's* language — a port leaking `ResultSet`, HTTP status codes, or ORM entities inverts nothing. And async concerns (transactions, ordering, retries) belong to the adapter and application layer, not the entity.
code
pseudocode · 17 lines// domain module — owns the contract, knows no technology
interface OrderRepository { fun save(order: Order) }
class PlaceOrder(private val orders: OrderRepository) {
fun handle(cmd: Command) {
val order = Order.place(cmd.items) // invariants live here
orders.save(order)
}
}
// infrastructure module — depends on domain, not the reverse
class SqlOrderRepository(private val db: Database) : OrderRepository {
override fun save(order: Order) { db.execute("insert into orders ...") }
}
// composition root — the only place that knows both
val useCase = PlaceOrder(SqlOrderRepository(db))go deeper
Say the domain defines an interface for what it needs and infrastructure implements it; the wiring happens at startup.
Name the Dependency Inversion Principle, describe ports/adapters and the composition root, and note that the domain becomes unit-testable with fakes.
Stress interface ownership, keeping ports in domain vocabulary, transaction placement, and domain events for side effects; acknowledge mapping cost and single-implementation ceremony.
Discuss enforcement via module boundaries and build-level dependency edges, dual-write/outbox concerns, when the abstraction is not worth it, and how the rule survives team growth.
## The problem Classic layering says the domain sits above persistence and therefore *depends on* it. That is enough to compile, but it means the layer holding your most valuable and most stable logic has a compile-time reference to your least stable, most vendor-specific code. Upgrade the ORM, change databases, or add a message broker, and the domain is dragged along. It also means the domain cannot be unit-tested without at least the persistence types on the classpath. ## Dependency Inversion Principle (DIP) DIP states: high-level modules should not depend on low-level modules; both should depend on abstractions. And: abstractions should not depend on details; details should depend on abstractions. The part people miss is **ownership**. Simply extracting an interface does not invert anything if the interface lives in, and is shaped by, the infrastructure module. Inversion happens when the *consumer* declares the contract it needs and the *provider* conforms to it. Practically: the interface file lives in the domain module; the implementation lives in the infrastructure module; the infrastructure module's build file lists the domain module as a dependency, and the domain module lists neither. ## Ports and adapters - **Port** — an interface owned by the inside (domain/application), stated purely in domain vocabulary. `OrderRepository.findById(OrderId): Order?`, `PaymentGateway.charge(Money, CardToken): ChargeResult`, `Clock.now(): Instant`. - **Adapter** — an implementation of a port using a concrete technology. `PostgresOrderRepository`, `StripePaymentGateway`, `SystemClock`. - **Driving vs. driven ports.** *Driven* (outbound) ports are what the application calls: storage, mail, brokers. *Driving* (inbound) ports are the use-case interfaces the application exposes and the presentation layer calls. Both are owned by the inside; only the direction of initiative differs. - **Composition root** — one place (main, a DI configuration, a module wiring file) that knows every concrete type and assembles the object graph. It is the *only* place allowed to depend on everything. ## Domain events as the other inversion tool Some effects should not be triggered synchronously by the domain at all. Instead the domain records a **domain event** ("OrderPlaced") as a plain value; the application layer collects and publishes it after the transaction commits, and infrastructure handlers react (send email, push to broker). The domain then depends on nothing except the event type it defined. This also gives you a natural place to solve dual-write problems using the **transactional outbox** pattern: persist the event in the same transaction as the state change, and a separate process relays it to the broker, giving at-least-once delivery without distributed transactions. ## Keeping the port honest A port is only useful if it does not leak the technology: - Bad: `findAll(sql: String)`, `save(entity: JpaOrderEntity)`, `charge(...): HttpResponse`, returning a lazily-loaded ORM proxy or a cursor that must be closed by the caller. - Good: domain types in, domain types or domain-level results out; failures expressed as domain outcomes or a small set of declared errors, not vendor exceptions. - Watch for **semantic leakage**: a port with `beginTransaction()`/`commit()` forces the domain to understand transactional mechanics; a port with `findByCriteria(spec)` may be fine, but one requiring paging tokens shaped by a specific store is not. - Persistence-ignorant entities: annotations from the ORM, serializer, or validation framework on domain classes reintroduce the dependency you removed. Either accept the pragmatic trade-off explicitly, or keep separate persistence models with a mapping layer (which costs mapping code — a real trade-off, not a free win). ## What inversion does *not* buy you - It does not make the database swappable for free. Query semantics, transaction isolation, and consistency guarantees leak through behaviour even when they do not leak through types. - It does not eliminate mapping cost; it relocates it. - It is not free of ceremony: an interface with exactly one implementation, forever, is sometimes just indirection. Justify it by testability, by an actual second implementation (in-memory/test double counts), or by protecting a boundary you expect to churn. ## Enforcement Declare the rule in the build: separate modules/projects for domain and infrastructure, with the dependency edge pointing infrastructure → domain, so a violation fails compilation. Where modules are impractical, use architecture tests (dependency rules over packages) or package-private visibility. A rule that only lives in a wiki is not a rule.
- If there will only ever be one implementation of a repository interface, is the interface still worth it?Sometimes. The justification is not future vendor swaps but (a) the compile-time dependency direction, which keeps the domain module buildable and testable alone, and (b) test doubles, which are a genuine second implementation. If the domain and infrastructure live in the same module with no enforced boundary and tests already use a real database, the interface may be pure ceremony — say so rather than defending it reflexively.
- Where do transactions belong once you have inverted the dependency?In the application/use-case layer, which defines the unit of work, with the adapter supplying the mechanism. Entities should not open transactions, and repositories should not each commit independently, or you lose atomicity across a use case. For cross-system effects, prefer a transactional outbox over writing to the database and the broker in one 'transaction' that does not exist.
A restaurant kitchen posts its own order ticket format. Any supplier who wants to serve the kitchen must fill in that ticket. The kitchen never learns each supplier's paperwork — the suppliers conform to the kitchen's form.
saying these in an interview costs you the question
- Extracting an interface but keeping it in the infrastructure module — that is not inversion, just indirection.
- Ports that expose SQL strings, ORM entities, HTTP responses, or vendor exceptions.
- Claiming inversion makes swapping databases trivial, ignoring behavioural and consistency differences.
- Putting ORM/serialization annotations on domain entities and still calling the domain framework-free.
- Injecting the DI container itself into domain classes (service locator), which restores the hidden dependency.