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/, api/grpc/ | service, domain, servers/<capability>/definition, common.lib.util.* |
| Service | service/ | domain, persistence, servers/<capability>/definition, common.lib.util.* |
| Persistence | persistence/ | domain, servers/<capability>/definition, common.lib.util.* |
| Domain (information model) | domain/ | servers/<capability>/definition, common.lib.util.* |
| Servers — capability definition | servers/<capability>/definition/ | common.lib.util.* only |
| Servers — vendor implementation | servers/<capability>/<vendor>/ | its own definition, common.lib.util.*. Only wiring may name one. |
| Common-module staging | common.lib.util.*, common.lib.util.<concept>.* | Nothing module-internal |
Every layer above servers may name a capability’s definition, and no layer but wiring may name a vendor implementation of it. Both halves of that matter. Permitting the definition everywhere is what lets the port sit at the bottom of the graph, so the consumer and the implementor both depend downward onto it and neither depends on the other. Forbidding the vendor everywhere is what keeps which vendor is behind the port a fact only the composition root knows — and it has to be everywhere to mean anything, because a domain type naming a vendor puts that vendor within reach of every layer above domain, which is the same edge by a longer path.
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. As of 2026-09-16, eightoperationsmodules still use it, and one —reference/businessaffiliates/— has both directories: a singleBusinessAffiliate.ktbeside two files underdomain/. Those counts move as modules converge; the rule does not. 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 the capability’s definition 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.
The shape of a servers subtree
Section titled “The shape of a servers subtree”A servers package holds the access mechanism for an external system. Some of those generalise and some do not, and the structure is the same either way:
servers/ <capability>/ definition/ the port interface, its types, whatever the vendors share <vendor>/ one implementation of it <vendor>/ …and any othersUniform even when there is only one vendor, which is the part that pays for itself twice.
It is what makes the two levels distinguishable at all. A lone vendor at servers/documint/ and an inlined definition at servers/pdfrendering/ are the same shape one segment down with opposite meanings; neither depth nor counting children separates them. Without a level that says which package is the definition, “every layer may import the definition and only wiring may import a vendor” is a rule nothing can check.
And it is what keeps a second vendor from being a refactor. The structure that admits one already admits three, so a successor arrives as a new sibling rather than as a rearrangement of every consumer — which is the reason the port exists.
The definition level may be spelled definition or shared; both are in use and both are recognised. shared is the older spelling, from when these packages were nested under a vendor rather than under a capability.
A capability whose port is consumed by the Service is constructed in Module.kt and injected, so the Service names servers/<capability>/definition and never the vendor beside it.
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.
They are also mechanized, so the recipe above is for investigating a finding rather than for routine verification. The Kotlin Standards plugin evaluates these as structural rules over the compiled output:
| Rule | Enforces |
|---|---|
PackageCycleLeaf | The leaf check |
PackageCycleAggregate | The aggregate check |
LayerDirection | The layering table in Layering inside a module |
ServersCapabilityShape | The shape of a servers subtree |
ProtocolBoundary | Protocol technologies confined to the packages that implement an Endpoint |
All five ship at tree — they block anywhere in the repository, not only in files a branch
changed. ServersCapabilityShape reports SKIPPED rather than clean where a repository has not
declared which segments under servers name a definition, because a rule with nothing to look at
has not looked.
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/<capability>/postmark/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. Note that this predates the capability level described above — it nests directly under the vendor — and is recorded as it stands rather than as it would be written today.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