Skip to content
chiltepin

Generated from: “Document which backend modules are allowed to depend on which, so reviewers can enforce it.

Backend module dependency rules

Written by an agent from the skill, validated by chiltepin check, rendered by the renderer — shown as generated, 13 September 2026.

DOCUMENTSTANDARD

Backend module dependency rules

Which module may import which, and how a reviewer enforces it.

The backend is one Go monorepo under internal/. Every import between two top-level modules is either allowed by this document or rejected in review. Dependencies point inward: handlers depend on use cases, use cases depend on the domain, the domain depends on nothing but shared.

SECTION 01 · Note

Assumptions

Note
The request names no modules. This document assumes six top-level modules under internal/: api, workers, app, domain, infra, shared. It adds the cmd/ composition root. The layout is hexagonal: app declares ports and infra implements them. Rename the packages to match the repo; the rules stay the same.

Module layout and allowed imports

Every arrow is a permitted import. An import that has no arrow here is forbidden. infra points at app, not the reverse: app declares the interfaces (ports) and infra supplies the Postgres, Kafka, Stripe, and Redis implementations. cmd is the only module that may import everything, because it wires the adapters into the use cases at startup.

SECTION 02 · Packages

Allowed imports between backend modules

PACKAGES
Package diagram: 7 packages, 16 dependenciescmd«composition root»serverworkerapihandlersmiddlewaredtoworkersconsumerscronappusecasesportstxndomainorderspaymentsinventoryeventsinfrapostgreskafkastriperedissharedidsmoneyclockerrorslog12345678910111213141516
1«import» mounts routes2«import» starts consumers3«import» builds adapters4«import» injects ports5«use» calls use cases6«import» reads value types only7«use» calls use cases8«import» decodes domain events9«import» runs entities and rules10«import» implements ports11«import» maps rows to entities12«import»13«import»14«import»15«import»16«import»
Legendpackagedependency«import»import«use»use

The rule table reviewers apply

Read a row as the importing module and a column as the imported module. A reviewer needs one lookup per new import line. Yes is allowed without comment; Ports means the import may touch only the app/ports package; No blocks the merge.

SECTION 03 · Capability matrix

May row import column

Importer / Importedcmdapiworkersappdomaininfrashared
cmdYesYesYesYesYesYes
apiNoNoYesYesNoYes
workersNoNoYesYesNoYes
appNoNoNoYesNoYes
domainNoNoNoNoNoYes
infraNoNoNoPortsYesYes
sharedNoNoNoNoNoNo
Legendfullpartialnone

Invariants behind the table

The table is derived from five rules. A new module is not in the table. Apply the rules to it, then extend the table in the same pull request.

SECTION 04 · Spec

Dependency invariants

Direction
Imports point inward: cmd → api/workers → app → domain → shared. No module imports a module that is above it.
Domain purity
domain imports only shared and the standard library. No database, HTTP, queue, or clock access; the clock comes in as a value.
Ports only
infra may import app/ports and nothing else in app. Use cases never import infra; cmd injects the adapter at startup.
No cycles
The module graph is acyclic. api and workers do not import each other; they share code by moving it to app or shared.
shared is leaf
shared imports nothing from internal/. A type that needs domain knowledge belongs in domain, not shared.

How to review a dependency change

Run these steps on every pull request that adds or moves an import between internal/ modules. Steps 2 and 3 are also enforced by CI; steps 4 and 5 need a person.

SECTION 05 · Steps

Review an import change

  1. List the new cross-module imports

    Diff only the import blocks; ignore imports inside the same module.

    bash
    git diff origin/main -- 'internal/**/*.go' | grep '^+' | grep '"example.com/backend/internal/'
  2. Check each import against the rule table

    Find the importer row and the imported column in the rule table. Yes passes, Ports passes only if the path is app/ports, No blocks.

  3. Run the dependency linter

    The linter encodes the same table. A red result is a blocking review comment, not a warning.

    bash
    go run ./tools/depcheck ./internal/...

    depcheck reads the table from tools/depcheck/rules.yaml; change the table and the file together.

  4. Reject workarounds

    These are the same violation: an interface declared in the wrong module, a copied file, or a go:linkname directive.

  5. Record an approved exception

    When no rule fits, ask the author to add an exception with an expiry date to tools/depcheck/exceptions.yaml. Approve only with the expiry present.

SECTION 06 · Note

Exceptions expire

Warning
Every entry in exceptions.yaml carries an expiry date. CI fails when the date passes, so an exception is a loan, not a rule change.
View the Markdown
```meta
title: Backend module dependency rules
subtitle: Which module may import which, and how a reviewer enforces it.
tag: STANDARD
```

The backend is one Go monorepo under `internal/`. Every import between two top-level modules is either allowed by this document or rejected in review. Dependencies point inward: handlers depend on use cases, use cases depend on the domain, the domain depends on nothing but `shared`.

```callout
tone: note
title: Assumptions
body: "The request names no modules. This document assumes six top-level modules under internal/: api, workers, app, domain, infra, shared. It adds the cmd/ composition root. The layout is hexagonal: app declares ports and infra implements them. Rename the packages to match the repo; the rules stay the same."
```

## Module layout and allowed imports

Every arrow is a permitted import. An import that has no arrow here is forbidden. `infra` points at `app`, not the reverse: `app` declares the interfaces (`ports`) and `infra` supplies the Postgres, Kafka, Stripe, and Redis implementations. `cmd` is the only module that may import everything, because it wires the adapters into the use cases at startup.

```pkg
id: layout
title: Allowed imports between backend modules
dir: TB
packages:
  - { id: cmd, col: 2, row: 1, name: cmd, contains: [server, worker], stereotype: composition root }
  - { id: api, col: 1, row: 2, name: api, contains: [handlers, middleware, dto] }
  - { id: workers, col: 3, row: 2, name: workers, contains: [consumers, cron] }
  - { id: app, col: 2, row: 3, name: app, contains: [usecases, ports, txn] }
  - { id: domain, col: 2, row: 4, name: domain, contains: [orders, payments, inventory, events] }
  - { id: infra, col: 3, row: 4, name: infra, contains: [postgres, kafka, stripe, redis] }
  - { id: shared, col: 1, row: 5, name: shared, contains: [ids, money, clock, errors, log] }
deps:
  - { from: cmd, to: api, kind: import, label: mounts routes }
  - { from: cmd, to: workers, kind: import, label: starts consumers }
  - { from: cmd, to: infra, kind: import, label: builds adapters }
  - { from: cmd, to: app, kind: import, label: injects ports }
  - { from: api, to: app, kind: use, label: calls use cases }
  - { from: api, to: domain, kind: import, label: reads value types only }
  - { from: workers, to: app, kind: use, label: calls use cases }
  - { from: workers, to: domain, kind: import, label: decodes domain events }
  - { from: app, to: domain, kind: import, label: runs entities and rules }
  - { from: infra, to: app, kind: import, label: implements ports }
  - { from: infra, to: domain, kind: import, label: maps rows to entities }
  - { from: app, to: shared, kind: import }
  - { from: domain, to: shared, kind: import }
  - { from: api, to: shared, kind: import }
  - { from: workers, to: shared, kind: import }
  - { from: infra, to: shared, kind: import }
```

## The rule table reviewers apply

Read a row as the importing module and a column as the imported module. A reviewer needs one lookup per new import line. `Yes` is allowed without comment; `Ports` means the import may touch only the `app/ports` package; `No` blocks the merge.

```matrix
id: rules
title: May row import column
corner: Importer / Imported
cols: [cmd, api, workers, app, domain, infra, shared]
rows:
  - { label: cmd, cells: ["—", Yes, Yes, Yes, Yes, Yes, Yes] }
  - { label: api, cells: [No, "—", No, Yes, Yes, No, Yes] }
  - { label: workers, cells: [No, No, "—", Yes, Yes, No, Yes] }
  - { label: app, cells: [No, No, No, "—", Yes, No, Yes] }
  - { label: domain, cells: [No, No, No, No, "—", No, Yes] }
  - { label: infra, cells: [No, No, No, Ports, Yes, "—", Yes] }
  - { label: shared, cells: [No, No, No, No, No, No, "—"] }
```

## Invariants behind the table

The table is derived from five rules. A new module is not in the table. Apply the rules to it, then extend the table in the same pull request.

```spec
id: invariants
title: Dependency invariants
accent: navy
rows:
  - { label: Direction, value: "Imports point inward: cmd → api/workers → app → domain → shared. No module imports a module that is above it." }
  - { label: Domain purity, value: "domain imports only shared and the standard library. No database, HTTP, queue, or clock access; the clock comes in as a value." }
  - { label: Ports only, value: "infra may import app/ports and nothing else in app. Use cases never import infra; cmd injects the adapter at startup." }
  - { label: No cycles, value: "The module graph is acyclic. api and workers do not import each other; they share code by moving it to app or shared." }
  - { label: shared is leaf, value: "shared imports nothing from internal/. A type that needs domain knowledge belongs in domain, not shared." }
```

## How to review a dependency change

Run these steps on every pull request that adds or moves an import between `internal/` modules. Steps 2 and 3 are also enforced by CI; steps 4 and 5 need a person.

```steps
id: review
title: Review an import change
items:
  - title: List the new cross-module imports
    body: Diff only the import blocks; ignore imports inside the same module.
    code: git diff origin/main -- 'internal/**/*.go' | grep '^+' | grep '"example.com/backend/internal/'
    lang: bash
  - title: Check each import against the rule table
    body: "Find the importer row and the imported column in the rule table. Yes passes, Ports passes only if the path is app/ports, No blocks."
  - title: Run the dependency linter
    body: The linter encodes the same table. A red result is a blocking review comment, not a warning.
    code: go run ./tools/depcheck ./internal/...
    lang: bash
    note: "depcheck reads the table from tools/depcheck/rules.yaml; change the table and the file together."
  - title: Reject workarounds
    body: "These are the same violation: an interface declared in the wrong module, a copied file, or a go:linkname directive."
  - title: Record an approved exception
    body: "When no rule fits, ask the author to add an exception with an expiry date to tools/depcheck/exceptions.yaml. Approve only with the expiry present."
```

```callout
tone: warn
title: Exceptions expire
body: Every entry in exceptions.yaml carries an expiry date. CI fails when the date passes, so an exception is a loan, not a rule change.
```