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 boundary
Section titled “Universe boundary”- 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.
Sagas and orchestrators
Section titled “Sagas and orchestrators”- Saga/orchestrator classes operate at the service level exclusively — no
Database, no Universe, noinTransactionanywhere in a saga class. The owning service impl exposes aninternal 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. SeeDemandSagaOps/DemandSagas/DemandServiceImplin 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:
OrderLineGenesisin 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.
Wire and read surfaces
Section titled “Wire and read surfaces”- 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 areoccurrence + 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)withcurrent/metadata/rId/atas 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).
Shared vocabulary
Section titled “Shared vocabulary”- No hand-rolled copies of shared constants. Reuse common-module; if the value is
missing there, promote it to the repo’s local
common.libpackage (marked for later promotion to common-module) — never a private per-module copy. Example: full-log-window reads useTimeCoordinates.ZERO+ the sharedTIME_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).
Related
Section titled “Related”- Data Authority Module Pattern — the module shape these rules layer.
- DAG Package Discipline — which package each layer lives in.
- Persistence and Transactions — the
DBIOcontract the Universe boundary rests on. - Kotlin Coding Standards — index.
Copyright: © Arda Systems 2025-2026, All rights reserved