rgoussu@goussu: ~/library/java/hexagonal-reference-implementation
~/library/java/hexagonal-reference-implementation cat domain-layer.md

Domain layer — contract & implementation

# Each module's hexagon is two modules — domain/contract with the surface API (ports, commands, events) and domain/core with the implementation — over a platform/kernel that supplies the dispatch vocabulary and depends on nothing.

Conceptsaved 2026-08-09updated 2026-08-14 #hexagonal-architecture#architecture#java#domain#ports#modulith

Overview

Inside every module of the modulith, domain/ is the hexagon itself, split in two: domain/contract, the surface API — ports in both directions, command and query records, domain events, result and error types — and domain/core, the implementation — entities, value objects, handlers, domain services. Below both sits platform/kernel, the repository-wide dispatch vocabulary (Command, Query, Handler, Mediator) that depends on nothing. The split is what lets adapters and infrastructure compile against a module's contract while only the assembly ever touches its implementation.

Key points

  • platform/kernel depends on nothing — JDK only — and holds no business vocabulary: the Command/Query marker types, the Handler interface, the Mediator port and its registry implementation. Every module's contract depends on it; it depends on no module.
  • domain/contract depends only on platform/kernel. No framework, no annotation, no logging facade, and — the modulith's addition — no other module. It is the most stable artifact in its context.
  • Both port directions live in domain/contract: driving ports (what adapters call) and driven ports (what infrastructure implements). Driven ports must sit in the contract module, otherwise infrastructure would need domain/core to compile and the layering collapses.
  • A driven port is named after the need, never the provider. This is what keeps a cross-module dependency out of the domain: billing declares OrderLookup, not OrderingService, and never learns which module — or which process — answers it. See module composition.
  • Dispatch is Command/Query + Mediator, scoped per module (settled 2026-08-09, re-scoped 2026-08-14). Each module has its own mediator over its own handlers; sealed Command/Query records in domain/contract name the operations that module supports, handlers in domain/core self-declare via supports(), and the registry mediator is built from a Collection<Handler> — never an injected map, no reflection. There is deliberately no repository-wide command bus: one would make every module's vocabulary visible to every adapter and quietly re-merge the contexts.
  • Boundary types, not entities, cross the line. Commands, queries, results and events in domain/contract are dedicated types (records, ideally). Entities and value objects stay internal to domain/core. When a driven port genuinely needs a rich domain type in its signature, that type moves to domain/contract deliberately — a design decision, not a default.
  • domain/core depends only on its own domain/contract and implements its driving ports. Framework-free: constructor injection of driven ports, plain Java, exhaustively unit tested without any container.
  • Transaction boundaries are a domain decision expressed as a port. The settled shape is a dedicated UnitOfWork driven port in domain/contract<T> T inTransaction(Supplier<T> work) — rather than unit-of-work semantics smeared over the repository port: a handler wraps the state-changing work of one command in it and every port touched inside commits or rolls back together, while how atomicity is achieved (JTA, a TransactionTemplate, the framework's transaction operations) stays an adapter in infra/unit-of-work-<tech>. Keeping it a port of its own is what lets the handler be unit-tested against a counting fake. A transaction never spans two modules — treat the service seam as if it were already remote.
  • One domain per bounded context, several contexts per repository. A module that wants a slice of another module's use cases is asking a context-mapping question, and the answer is a port at the seam — not a wider contract module.

Details

What goes where

Artifact Module Notes
Command / Query / Handler / Mediator types platform/kernel the dispatch vocabulary, shared by every context, business-free
The registry Mediator implementation platform/kernel generic; instantiated per module by the assembly
Sealed command/query records modules/x/domain/contract name every operation this module supports
Driving ports modules/x/domain/contract usually just the module's Mediator binding
Driven ports modules/x/domain/contract OrderRepository, PaymentGateway, and peer-facing ports like OrderLookup
Command / query / result DTOs modules/x/domain/contract immutable records; validated on construction
Domain events modules/x/domain/contract published through a driven port
Domain errors modules/x/domain/contract part of the contract — adapters must map them
Entities, value objects modules/x/domain/core never leak; mapped at the boundary
Handlers modules/x/domain/core self-declare via supports()
Domain services modules/x/domain/core plain classes, constructor-injected driven ports

Both port directions in the contract module, with their callers and implementors:

flowchart LR
    CALLERS["user-side adapters (api, consumers, service)"]
    KERNEL["platform/kernel<br/>Command · Handler · Mediator"]
    subgraph contract["modules/x/domain/contract"]
        DRIVING["Driving seam (the module's Mediator)"]
        COMMANDS["Sealed commands & queries"]
        DRIVEN["Driven ports (incl. peer-facing ones)"]
    end
    subgraph core["modules/x/domain/core"]
        HANDLERS["Handlers (entities and value objects stay inside)"]
    end
    IMPLEMENTORS["modules/x/infra"]

    CALLERS -->|"dispatch commands"| DRIVING
    COMMANDS --> KERNEL
    HANDLERS -->|"handle"| COMMANDS
    HANDLERS -->|"depend on"| DRIVEN
    IMPLEMENTORS -->|"implement"| DRIVEN

Dispatch — Command/Query + Mediator, per module

Java is the pattern's home turf, and the ruling makes it the default:

  • Why it fits: sealed interfaces + records give genuinely sealed command hierarchies; CDI/Spring collect the Collection<Handler> for free; one dispatch seam gives uniform cross-cutting (transactions, validation, audit) by decorating the mediator — no AOP; and the module graph stays honest, since adapters depend on the command vocabulary in domain/contract and never on domain/core.
  • Why per module (2026-08-14): the mediator's registry is exactly as wide as the set of commands it can dispatch, so a global one would hand every adapter every context's vocabulary — the opposite of what modules/ is for. Per-module mediators also make extraction free: the module leaves with its own seam intact. The assembly distinguishes them with a qualifier (@Ordering Mediator) or by constructing them explicitly.
  • Cost accepted: one unchecked cast inside the dispatcher; a missing handler is a runtime wiring error surfaced at first dispatch (the Rust realization does better — its compiler is the registry; see the Rust reference).
  • Rejected as default — per-use-case driving interfaces (PlaceOrder, CancelOrder): finer-grained dependency truth per adapter, but every cross-cutting concern is re-plumbed per interface and each new use case touches every wiring site. They remain the right shape in Go and the frontend, where the mediator object fights the platform — the stances are recorded per reference.
  • Rejected — one bus for the whole modulith: it reads as convenience and behaves as a shared domain. Modules that need each other use the service seam, where the dependency is visible in the build graph.

Testing

  • domain/core: plain JUnit against the module's commands, driven ports faked in-memory. This is where the bulk of the service's tests live, and they run in milliseconds. A context's tests never mention another context.
  • domain/contract: nothing to test beyond invariants encoded in command constructors. Deliberately no contract-test kit binding infrastructure implementations to the ports (2026-08-09 ruling): adapters are integration-tested against their real technology on their own terms (see infrastructure layer); the in-memory fakes encode only what the domain relies on, and drift between a fake and a real adapter is handled when observed, not with standing machinery. The one near-exception is the peer gateway test described in module composition.
  • platform/kernel: the registry mediator's own dispatch behavior — handler selection, the missing-handler failure — tested once for the whole repository.

Related