Skip to content

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.

For a module whose top-level package is cards.arda.<component>.<area>.<module>:

  1. 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 are import statements that cross a leaf-package boundary. The graph must have no cycles.

  2. 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.

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.

LayerTypical packagesImports
Wiring / entry pointModule.kt at module rootAll layers below
Protocol / APIapi/rest/, api/proto/, api/grpc/service, domain, servers/<capability>/definition, common.lib.util.*
Serviceservice/domain, persistence, servers/<capability>/definition, common.lib.util.*
Persistencepersistence/domain, servers/<capability>/definition, common.lib.util.*
Domain (information model)domain/servers/<capability>/definition, common.lib.util.*
Servers — capability definitionservers/<capability>/definition/common.lib.util.* only
Servers — vendor implementationservers/<capability>/<vendor>/its own definition, common.lib.util.*. Only wiring may name one.
Common-module stagingcommon.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, eight operations modules still use it, and one — reference/businessaffiliates/ — has both directories: a single BusinessAffiliate.kt beside two files under domain/. 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 uses domain/. 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.

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 others

Uniform 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.

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.

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.

A quick check at the leaf level on a Kotlin source tree:

Terminal window
# Build a flat list of package → package edges from import statements
find 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:

# pseudo
edges_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:

RuleEnforces
PackageCycleLeafThe leaf check
PackageCycleAggregateThe aggregate check
LayerDirectionThe layering table in Layering inside a module
ServersCapabilityShapeThe shape of a servers subtree
ProtocolBoundaryProtocol 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.

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 in servers/<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.
  • cards.arda.operations.shopaccess.email.* — leaf-level acyclic and aggregate-level acyclic across business (pre-domain naming), persistence, service, servers.postmark.{shared,resources,signature,server,webhook}, common.lib.util.{,email}, and api/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 the domain/ naming and still carries both directories.