Generated from: “Document which backend modules are allowed to depend on which, so reviewers can enforce it.”
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.
```