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. business, 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.*, business, 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/service, business
Serviceservice/business, persistence, servers/*/shared, common.lib.util.*
Persistencepersistence/business, common.lib.util.*
Business / domainbusiness/common.lib.util.*, servers/*/shared
Servers (external systems)servers/<vendor>/shared/, servers/<vendor>/<proxy>/common.lib.util.* only
Common-module stagingcommon.lib.util.*, common.lib.util.<concept>.*Nothing module-internal

The two layers most likely to invite cycles are business and servers: it is tempting for a vendor-shared package to import a business type, then for a business type to reference a vendor-shared shape. Both directions are wrong. Vendor packages depend only on common.lib.util.*; business types reference vendor packages by name when the field is genuinely vendor-shaped (e.g. a Postmark server ID), but the concept sits in business and the shape sits in servers.

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 business/ (which would couple business 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.

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/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.
  • cards.arda.operations.shopaccess.email.* — leaf-level acyclic and aggregate-level acyclic across business, persistence, service, servers.postmark.{shared,resources,signature,server,webhook}, common.lib.util.{,email}, and api/rest. Phase 5b S01+S02 refactor.
  • cards.arda.operations.reference.item.* — established layering pattern for a Data Authority module; the canonical reference for business/persistence/service split.