0006. Per-aggregate domain layout

Status: Accepted Date: 2026-04-28

#Context

The project's Domain Layer convention is that every Bounded Context arranges its domain/ directory as a list of aggregate sub-directories. Each aggregate sub-directory contains:

Account is the first BC in the project with a real Domain Layer (Broker has none, shared/ has none). Until this ADR the Account domain was a single 252-line monolithic file account/lib/domain/portfolio.ml that bundled the aggregate root Portfolio.t, the Entity reservation, the VO position, two DomainEvents (amount_reserved, reservation_released), and two business-rule error types — exactly the kind of mixing the above schema is designed to forbid.

The reorganisation of Account establishes the canonical pattern that the next BC with a domain layer must follow.

#Decision

Adopt per-aggregate sub-directories with the recursive shape described above, dune (include_subdirs qualified) namespacing, and one consistent file-naming rule applied at every level: the "main module" of a directory is a file whose name matches the directory. The same rule applies to:

account/lib/domain/
├── dune                                   # (include_subdirs qualified)
└── portfolio/
    ├── portfolio.ml/.mli/.mlw             # aggregate root + re-exports
    ├── reservation/
    │   └── reservation.ml/.mli/.mlw       # Entity (id + lifecycle)
    │   (if Reservation grows VOs/events of its own, they go into
    │    reservation/values/ and reservation/events/ — the Entity
    │    directory recursively repeats the aggregate shape)
    ├── values/
    │   └── position.ml/.mli/.mlw          # VO
    └── events/
        ├── amount_reserved.ml/.mli/.mlw      # DomainEvent
        └── reservation_released.ml/.mli/.mlw # DomainEvent

Module paths after compilation (one dune-library account covers all of account/lib/domain/, no nested libraries):

Concept OCaml module Why3 module path
Aggregate root Account.Portfolio portfolio.portfolio.Portfolio
Entity reservation Account.Portfolio.Reservation portfolio.reservation.reservation.Reservation
VO position Account.Portfolio.Values.Position portfolio.values.position.Position
DomainEvent (success) Account.Portfolio.Events.Amount_reserved portfolio.events.amount_reserved.Amount_reserved
DomainEvent (release) Account.Portfolio.Events.Reservation_released portfolio.events.reservation_released.Reservation_released

Callers reach the aggregate-root API at Account.Portfolio.X directly — no .Aggregate (or similar) suffix, no top-of-file alias required:

let p = Account.Portfolio.empty ~cash:(Decimal.of_int 1_000_000) in
match Account.Portfolio.try_reserve p ~id ... with
| Ok (p', ev) -> (* ev : Account.Portfolio.Events.Amount_reserved.t *)

#How the layout works: collapse + explicit re-export

dune (include_subdirs qualified) has a sharp behavioural rule: when a sub-directory contains a file whose name matches the directory name (foo/foo.ml), dune treats that file as the directory's main module and collapses the sub-directory's qualified namespace into it. Peer sub-directories (foo/bar/, foo/baz/) become nested submodules inside the collapsed main module — visible from outside only if the main module explicitly re-exports them.

We make this rule load-bearing in both directions:

The .ml/.mli re-export idiom (no signature duplication):

(* portfolio/portfolio.mli *)
module Values      : module type of Values
module Events      : module type of Events
module Reservation : module type of Reservation

(* + the aggregate-root API *)
type t = private { ... }
val empty : cash:Core.Decimal.t -> t
...
(* portfolio/portfolio.ml *)
module Values      = Values
module Events      = Events
module Reservation = Reservation

(* + the aggregate-root implementation *)
type t = { ... }
let empty ~cash = { ... }
...

module type of M copies the full signature of M (including type equalities) without manual repetition; the .ml-side alias module M = M connects the published name to the actual peer sub-directory module.

This is exactly the OCaml/dune analogue of Python's __init__.py or the old Rust mod.rs: the file with the directory-matching name is the namespace's controlled surface, and the author explicitly chooses what passes through.

#Alternatives considered

#aggregate.ml (no name collision, automatic peer-subdir publication)

An earlier draft of this ADR named the aggregate-root file aggregate.ml to avoid dune's collapse rule. That made all peer sub-directories (values/, events/, reservation/) automatically exposed under Account.Portfolio.Values.X, Account.Portfolio.Events.X, Account.Portfolio.Reservation.

Rejected because it produces an asymmetric naming convention (aggregate.ml for roots, <entity>.ml for Entities), strips the aggregate-root file of explicit control over its public surface, and forces callers to write Account.Portfolio.Aggregate.X or carry a top-of-file alias module Portfolio = Account.Portfolio.Aggregate. The __init__.py-style explicit re-export is more canonical and gives encapsulation by default.

#Flat layout, all files at account/lib/domain/ root

What we had before. Conflicts with the per-aggregate convention, mixes VOs / Entities / Events / aggregate root in one namespace, doesn't scale to a second aggregate.

#entities/ umbrella sub-directory

Earlier draft placed all Entities under a single entities/ sub-directory next to values/ and events/. Rejected because: the aggregate root is itself an Entity (chosen as the transactional consistency boundary), so splitting "the root Entity" from "the other Entities" via different directory placement is semantically incoherent; Vernon (IDDD) and Evans (DDD blue book) keep the aggregate root and its supporting Entities at the same level; the project convention is therefore a separate directory per Entity, with the aggregate root sitting alongside the Entities it composes.

#One dune-library per sub-directory

Would give names like Account_portfolio_values.Position.t. Plus: no namespace-collapse mechanics to learn. Minus: snake-case prefixes, every new sub-directory adds a dune file, the account umbrella library no longer covers the full BC. Rejected — qualified mode achieves the same hierarchy with one library and lets the collapse rule do useful work.

#Consequences

Easier:

Harder:

To watch for:

#References