skip to content

How can a layered architecture (presentation, application, domain, persistence) coexist with hexagonal architecture (ports and adapters) inside a single service, and which dependency rule must hold in the combined design?

level: middleimportance: should knowfreq 56%

answer

  1. layering = grouping; hexagonal = dependency direction
  2. port declared inside, adapter implements outside
  3. driving/primary in, driven/secondary out
  4. dependency rule: arrows point inward only
  5. enforce with architecture tests, or it decays

basics

~20 s

Keep the layering inside the core (application layer calls domain layer), but let the domain declare interfaces (ports) for anything outside it. Database, HTTP and messaging code become adapters implementing those ports. All dependencies point inward, toward the domain.

solid answer

~50 s

Classic layering stacks presentation on application on domain on persistence, with every dependency pointing down - which makes the domain depend on the database. Hexagonal keeps the same horizontal separation but inverts the outermost arrow using dependency inversion: the domain or application layer declares the interface (a port such as OrderRepository or PaymentGateway) and the infrastructure adapter implements it, so the compile-time arrow points inward. Driving (primary) adapters - controllers, CLI, message listeners - call inbound ports; driven (secondary) adapters - repositories, HTTP clients, publishers - are called through outbound ports. The invariant is: the domain has zero imports of frameworks, ORMs, HTTP types or broker clients, and no arrow points from an inner ring to an outer one. Practical benefits are testability (substitute in-memory adapters) and replaceability. Practical costs are extra interfaces and mapping between domain objects and persistence or transport models, which is why anaemic ports that just mirror ORM methods are a common failure.

code

pseudocode · 17 lines
pseudocode
// domain (inner): declares what it needs, knows no technology
interface OrderRepository {
    fun ordersAwaitingPaymentFor(customer: CustomerId): List<Order>
    fun save(order: Order)
}

// application (inner): the use case - reached through a driving port
class PlaceOrder(private val orders: OrderRepository, private val payments: PaymentGateway) {
    fun handle(cmd: PlaceOrderCommand): OrderId { /* orchestration + rules only */ }
}

// infrastructure (outer): adapters implement the ports
class SqlOrderRepository(private val db: Database) : OrderRepository { /* SQL here */ }
class HttpPaymentGateway(private val http: HttpClient) : PaymentGateway { /* HTTP here */ }

// composition root wires outer into inner at runtime; compile-time arrows point inward
// tests substitute InMemoryOrderRepository - no database required

go deeper

for a junior

Say the domain defines interfaces and the database code implements them, so business rules can be tested without a database.

for a middle

Explain dependency inversion explicitly, distinguish driving from driven adapters and inbound from outbound ports, and state the inward dependency rule.

for a senior

Discuss what must not leak through ports (ORM types, query-shaped methods, transport DTOs), the mapping cost, when the indirection is not worth it, and automated enforcement.

for a principal

Position it as a policy decision per module rather than per system: rich-domain modules get full ports and adapters, CRUD modules stay layered, the boundary is documented in a decision record and enforced by build-time fitness functions.

## The two styles **Layered architecture** slices a system horizontally. A common four-layer stack: ``` presentation -> controllers, view models application -> use cases, transaction boundaries, orchestration domain -> entities, value objects, domain services, business rules persistence -> ORM entities, SQL, database access ``` The rule is that each layer may depend only on the layer(s) below. That is simple and familiar, but it has one damaging consequence: the domain sits *above* persistence and therefore **depends on it**. Business rules end up importing ORM annotations, database types and connection concerns, so you cannot compile or test the rules without the database. **Hexagonal architecture** (Alistair Cockburn), also called ports and adapters, and closely related to onion and clean architecture, puts the domain in the centre and everything technical on the outside: - A **port** is an interface defined by the inside. An **inbound (driving) port** is a use case the outside may invoke; an **outbound (driven) port** is a capability the inside needs, such as `OrderRepository` or `PaymentGateway`. - An **adapter** is technology-specific code at the boundary. **Driving adapters** (REST controller, CLI, scheduled job, message listener) call inbound ports. **Driven adapters** (JPA repository, HTTP client, Kafka publisher, file store) implement outbound ports. ## How they combine They are not alternatives - hexagonal is a *rule about dependency direction*, layering is a *rule about grouping*. You keep both: 1. Keep the inner layering: application (use cases, transactions) sits above domain (rules), and calls into it. 2. Delete the bottom layer as a *dependency target*. Persistence stops being a layer the domain sits on and becomes an adapter that plugs into a port. 3. Apply **dependency inversion**: the interface lives with its *consumer* (domain or application), the implementation lives outside. At compile time the arrow now points inward; at runtime a composition root (dependency-injection container, `main`) wires the concrete adapter in. The combined invariant, sometimes called the dependency rule: **source-code dependencies point only inward; nothing in an inner ring names anything in an outer ring.** Concretely, the domain package imports no framework, ORM, HTTP or broker types. ## What the boundary must not leak - **ORM types in ports.** A port returning a lazily-loaded ORM entity or exposing session semantics leaks persistence into the core; the domain becomes untestable and the port unswappable. - **Ports shaped like the database.** `findByCustomerIdAndStatusAndCreatedAfter` is a query API, not a domain capability. Ports should read like intentions: `ordersAwaitingPaymentFor(customer)`. - **Framework annotations on entities.** Tolerable as a pragmatic compromise in small systems, but it is a real, admitted violation; the strict version keeps separate domain models and persistence models with explicit mapping. - **Transport DTOs used as domain objects.** They change for API-versioning reasons and drag the domain along. ## Costs and when it is worth it The pattern buys: unit-testing the core with in-memory fakes and no database or HTTP; swapping infrastructure (SQL to document store, REST to gRPC) without touching rules; forcing an explicit vocabulary of what the domain needs from the world. It costs: more interfaces and files, mapping code between three representations (transport, domain, persistence), and indirection that is genuinely wasteful in a thin CRUD service where the 'domain' is the table. A common pragmatic hybrid within one codebase: full ports and adapters in the two or three modules with real business rules, plain layered CRUD in the rest, with the boundary documented and enforced. ## Enforcement Because the rule is a *direction*, it can be checked automatically: architecture-test tools (ArchUnit on the JVM, dependency-cruiser in JavaScript, import-linter in Python, or module systems that restrict allowed dependencies) can fail the build when a domain package imports infrastructure. Without such a fitness function the arrow silently flips back within a few sprints.

  • If the domain defines the repository interface, where should that interface physically live, and why does it matter?
    With its consumer, inside the domain or application module - not in the persistence module. That placement is what makes the compile-time dependency point inward: infrastructure depends on the core, never the reverse. If the interface lives in the persistence package, the core must import that package and the inversion is lost even though an interface exists.
  • Is putting ORM annotations directly on domain entities acceptable?
    It is a known trade-off. Strictly it violates the dependency rule and couples the model's shape to persistence concerns (identity, lazy loading, default constructors, mutability). It saves a mapping layer and is defensible in small or short-lived systems, but in a core with rich invariants the usual choice is separate domain and persistence models with explicit mapping.
  • How do you stop the dependency rule from eroding over time?
    Automate it. Architecture tests such as ArchUnit, dependency-cruiser or import-linter assert that the domain package imports nothing from infrastructure, and run in CI on every commit. Rules enforced only by review or documentation get violated within weeks, usually by a well-meaning shortcut under deadline.

A wall socket is a port: the room defines the shape, and any appliance that fits may plug in. The room's wiring does not import a specific brand of kettle. Rewiring the house because you bought a new kettle would be the layered version, where the domain depends on the infrastructure.

saying these in an interview costs you the question

  • Thinking layered and hexagonal are competing options rather than rules about different things
  • Declaring the repository interface inside the persistence module and calling that dependency inversion
  • Ports shaped as database queries or returning ORM entities
  • Applying full ports and adapters to a thin CRUD service where the indirection buys nothing
  • Relying on documentation and code review instead of automated architecture tests to keep arrows pointing inward

context