Skip to content

Service-Layer Architecture

Service-layer architecture (module layering)

Section titled “Service-layer architecture (module layering)”

Formalized from the operations PR #220 review rulings (2026-07-22); the demand and inventory modules are the exemplar implementation.

  • Universe methods are declared only inside the Universe class, return DBIO<*>, and never manage transactions — that is what makes them composable. Never define extension functions on a Universe outside it: an extension declared in the service layer reads as a Universe method but hides service-layer behavior (transactions, business policy) — deceptive by construction.
  • The service impl owns every persistence operation of its module — reads, commits, and the refusal-row (F1) append — and owns all transaction boundaries. Mapping files hold pure row→domain projections only: no Universe access, no transactions.
  • The F1 refusal append runs its own deliberate transaction — it must survive the failing operation’s rollback. A separate transaction is legal exactly when the semantics require it and the KDoc at the site says so.
  • Saga/orchestrator classes operate at the service level exclusively — no Database, no Universe, no inTransaction anywhere in a saga class. The owning service impl exposes an internal interface <X>SagaOps (head reads, genesis, successor commits, the F1 failure path, module-specific reads) and passes itself (this) when constructing its saga collaborators; cross-module legs go through the injected peer services. See DemandSagaOps / DemandSagas / DemandServiceImpl in operations.
  • Boundary of the saga rule. It binds orchestrator classes — collaborators whose job is cross-module choreography, handed the service from outside. It does not bind the service impl’s own internally-constructed cohesion splits (Reads / Genesis / Gates classes extracted for file-size cohesion), which share the impl’s persistence ownership legitimately. Litmus test: constructed by the impl from the impl’s own primitives and meaningless outside it → it is the impl; handed the service (or should be) → orchestrator, ops interface required. Exemplar of the allowed shape: OrderLineGenesis in operations (atomic own-module gate + write in one transaction, with its cross-module demand calls remaining independent transactions — cross-module is never atomic).
  • Sanctioned exception — partition-wide invariant probes. A read-only check that a system-wide invariant still holds needs a cross-tenant, partition-wide scan, and the tenant-scoped public service surface deliberately offers no such read — so these may read the Universe directly. They must stay read-only, work at a single abstraction level, and carry the KDoc rationale for why the scoped surface was insufficient.
  • Reads and mutation responses carry the full EntityRecord<Payload, Metadata> — metadata, bitemporal coordinates, and provenance come for free. Never hand-roll a view DTO that flattens them away. Commit types are occurrence + record; typeOf<EntityRecord<…>>() works with the OpenAPI generator (the kanban precedent), so no reified wrapper is needed.
  • The commit author is the row’s own btsAuthor, never a threaded parameter — the threaded form stamps the redelivering caller onto duplicate-notification no-ops.
  • State-engine hosts wrap the whole source entity (data class <X>Host(val source: BitemporalEntity<P, M>, val pending: P = source.payload) with current/metadata/rId/at as accessors) so a commit or refusal snapshots exactly what the guard evaluated, provenance included.
  • Soft references cross the wire as full references (with rId), never bare UUIDs, whenever the domain has the reference in hand — version pinning and retired-entity retrieval depend on it.
  • Wire fields name their target concept (holdingPool, deliveryPool), not their role alone; and APIs address the business-significant concept, not its mechanism (the demand membership surface takes the KanbanCard eId — the module resolves the card’s at-most-one active cycle; callers never reason about cycles).
  • No hand-rolled copies of shared constants. Reuse common-module; if the value is missing there, promote it to the repo’s local common.lib package (marked for later promotion to common-module) — never a private per-module copy. Example: full-log-window reads use TimeCoordinates.ZERO + the shared TIME_HORIZON.
  • A read whose signature carries no page parameter must page the Universe query surface to a short page — never a single default-paged query (the silent 100-row cap), and never a raw SQL/JSONB predicate (it bypasses the latest-version / retired / tenant semantics the Universe owns).