DAG Package Discipline
Arda backend modules organize Kotlin source into packages that form a directed acyclic graph (DAG). The rule applies at every aggregation level: no leaf package may import a package that (directly or transitively) imports it back, and no aggregate (a package and everything beneath it) may form a cycle with another aggregate.
A leaf-level DAG is necessary but not sufficient. Two aggregates can each be internally acyclic while their union contains a cycle that crosses the boundary. Reviewing one level at a time misses this.
The two checks
Section titled “The two checks”For a module whose top-level package is cards.arda.<component>.<area>.<module>:
-
Leaf check. Build the directed graph where nodes are individual packages (e.g.
domain,persistence,service,servers.postmark.shared,common.lib.util.email) and edges areimportstatements that cross a leaf-package boundary. The graph must have no cycles. -
Aggregate check. Group leaves by every prefix. For each grouping (e.g.
servers.*,domain,common.lib.util.*), collapse all leaves under that prefix into a single node and re-check. Every collapsed graph must also be acyclic.
The two checks are independent. A module that passes (1) can still fail (2) when an aggregate boundary “wraps” a backward edge that was legal at the leaf level.
Layering inside a module
Section titled “Layering inside a module”Most module-internal aggregates fit one of these roles. They sort top-down — packages at the top may depend on those below them, never the reverse.
| Layer | Typical packages | Imports |
|---|---|---|
| Wiring / entry point | Module.kt at module root | All layers below |
| Protocol / API | api/rest/, api/proto/ | service, domain |
| Service | service/ | domain, persistence, servers/*/shared, common.lib.util.* |
| Persistence | persistence/ | domain, common.lib.util.* |
| Domain (information model) | domain/ | common.lib.util.*, servers/*/shared |
| Servers (external systems) | servers/<vendor>/shared/, servers/<vendor>/<proxy>/ | common.lib.util.* only |
| Common-module staging | common.lib.util.*, common.lib.util.<concept>.* | Nothing module-internal |
domain/ holds the information model — the entities, value objects, and supporting types named in the design’s information-model section. That is the whole rule; there is no finer distinction to adjudicate.
business/is the legacy name for this layer. Tenoperationsmodules still use it, and two (reference/item/,reference/businessaffiliates/) have both directories. That split is a remnant of early undecisions, not a convention: it is not a second layer with a distinct meaning, and reverse-engineering a rule from it — “business/holds persisted entities,domain/holds value objects” — describes what the code happens to do today while describing no decision anyone made. New code usesdomain/. Existing modules converge opportunistically (see below); nobody schedules a rename.
The two layers most likely to invite cycles are domain and servers: it is tempting for a vendor-shared package to import a domain type, then for a domain type to reference a vendor-shared shape. Both directions are wrong. Vendor packages depend only on common.lib.util.*; domain types reference vendor packages by name when the field is genuinely vendor-shaped (e.g. a Postmark server ID), but the concept sits in domain and the shape sits in servers.
Opportunistic compliance
Section titled “Opportunistic compliance”Conventions on this page are stated forward-only. New code conforms; existing code converges as a side effect of being worked on.
When a project touches a module, it brings that module to current conventions as part of the work. Do not raise backward-correction tickets and do not schedule migrations. The cost of renaming a package you are already editing is close to zero, and the touching project already owns those files — which is exactly what a scheduled migration does not, and why scheduled migrations collide with every branch in flight.
This is a standing disposition, not a rule about package names. It applies equally to formatting, to route declaration, to return types, and to anything else on the pattern index that a module predates.
The common.lib.util.* staging convention
Section titled “The common.lib.util.* staging convention”cards.arda.<component>.common.lib.util.* is a staging area for types that will likely be promoted to cards.arda.common.lib.* in the central common-module at a later date. Place a type here when:
- It has no module-internal dependencies (only stdlib + serialization +
cards.arda.common.lib.*). - Its shape is generic — not tied to one module’s vocabulary (
DnsRecord,TokenCipherEnvelope,EmailAddress). - It is referenced by more than one leaf inside the module, or is on a clear path to being shared across modules.
This keeps such types out of domain/ (which would couple the information model to infrastructure types) and out of servers/<vendor>/ (which would couple all vendors to one vendor’s path). Sub-packages cluster related types: common.lib.util.email.LocalPart, common.lib.util.email.EmailAddress.
When the type is later promoted to common-module, every import inside the module changes prefix in one mechanical rename; no source restructuring is required.
Detecting cycles
Section titled “Detecting cycles”A quick check at the leaf level on a Kotlin source tree:
# Build a flat list of package → package edges from import statementsfind src/main/kotlin/cards/arda/<component>/<area>/<module> -name '*.kt' \ -exec awk ' /^package / { pkg = $2 } /^import / && $2 ~ /^cards\.arda\./ { sub(/\.[^.]+$/, "", $2) # drop class name if ($2 != pkg) print pkg " -> " $2 } ' {} \;Pipe the output into any graph tool (or a 20-line Python script using networkx) and ask for the strongly-connected components — anything larger than one node is a cycle.
For the aggregate check, repeat with every prefix folded:
# pseudoedges_leaf = parse_edges("imports.txt")for depth in range(1, max_depth(edges_leaf)): edges_at_depth = {(fold(a, depth), fold(b, depth)) for a, b in edges_leaf} edges_at_depth = {(a, b) for a, b in edges_at_depth if a != b} assert is_acyclic(edges_at_depth), f"cycle at depth {depth}"Run both checks before every PR; landing a cycle makes future refactoring exponentially harder.
When the rule pushes back on a design
Section titled “When the rule pushes back on a design”If you cannot place a type without creating a cycle, the design is wrong — not the rule. Common signals:
- A type with both business meaning and vendor shape (e.g. “the encrypted Postmark token”). Split: the concept (encrypted token envelope) lives in
common.lib.util/; the vendor wrapper lives inservers/postmark/shared/and composes the envelope. - A “helper” file that pulls together pieces from multiple layers. The helper itself wants to live above all of them — promote to
service/or the wiring layer; do not let it sit beside one of its sources. - A persistence-side type that “needs” to know a business-state enum. The business enum is upstream of persistence; passing the value into persistence at write time keeps the edge in the right direction.
Worked references
Section titled “Worked references”cards.arda.operations.shopaccess.email.*— leaf-level acyclic and aggregate-level acyclic acrossbusiness(pre-domainnaming),persistence,service,servers.postmark.{shared,resources,signature,server,webhook},common.lib.util.{,email}, andapi/rest. Phase 5b S01+S02 refactor.cards.arda.operations.reference.item.*— established layering pattern for a Data Authority module; the canonical reference for the domain/persistence/service split, though it predates thedomain/naming and still carries both directories.
Related pages
Section titled “Related pages”- Module Concept — the architectural definition of a Module.
- Naming Conventions — canonical names and forms.
- Information Model Design — entity package layout per Data Authority module.
Copyright: © Arda Systems 2025-2026, All rights reserved