Modules only talk through ports

If blogmanagement imports usermanagement directly, we've

ADR 0009▣ Module SystemAccepted

ADR 0009 — Modules only talk through ports

Status: Accepted (2024-06-10)

The problem

If blog_management imports user_management directly, we've already lost. The two modules become co-deployed by construction. We can't swap the user provider. We can't test blog_management in isolation. We can't split the deployment across services without unwinding the import graph. That's the distributed-monolith trap — all of microservices' operational cost and none of the decoupling benefit.

We've watched this happen elsewhere. The first direct import feels harmless; the second feels like precedent. By the time anyone notices, the graph is a knot.

The decision

A module cannot import another module's package directly. Anything under platformkit-business-modules/<other_module>/... is off-limits — with two carved-out exceptions:

  1. The other module's contracts/ subtree is public. That's what

contracts are for — see Convention C-04 — public contracts live away from their implementation.

  1. Catalog wiring in platformkit-business-modules/catalog/ and the

composition layer in platformkit-apps/modulecatalog/ can import any module's NewModule() — they exist precisely to compose the graph.

Everywhere else, cross-module calls go through an interface in platformkit-business-modules/ports/, declared via standard.WithCategorizedDep in the caller's dependencies.go. The consumer imports the port; the app wires the implementation.

We're strict about this. One exception breeds ten.

What we gave up

  • Ergonomics. Wiring a new cross-module call takes more keystrokes

than import "../user_management".

  • Short-term hacks. A bug that spans two modules can't be fixed

with a cross-import shortcut. You extend the port or live with the bug.

What we kept

  • The option to deploy any module as its own service — see

ADR 0019.

  • Independent testability. A module's tests stand up without

spinning up every module it talks to.

  • A legible graph. platformkit modules graph reflects the real

runtime dependency story, not just what happened to compile.

How we enforce it

  • check-pkvet — runs the module import-boundary analyzer in CI.
  • importboundary pkvet analyzer — refuses cross-module direct imports

and catches the subtler cases (type assertions on a cross-module concrete type, reflection-based access, etc.) that a plain import-grep would miss.

  • platformkit-backend-kit/cmd/repo-split-importcheck

validates boundaries at the repo-split layer to prevent backend-kit ↔ business-modules leaks.

References

  • CLAUDE.md — Invariant #1: "No cross-module direct imports."
  • platformkit-business-modules check-pkvet — the CI target.
  • platformkit-business-modules/ports/ — the canonical cross-module

interface surface.

  • Related:

Convention C-04 — public contracts live away from their implementation.

Welcome back

Sign in securely without losing your place.

Preparing secure sign-in…

Having trouble? Open the full sign-in page