Module boundaries
Playbook for enforcing the modular-monolith architecture — kernel<modules<app layering, one-public-API-per-module, ports/adapters with a shared contract-test-suite-per-port, tenant-first repositories, publish gates, and a CI-enforced dependency-cruiser config. Use when adding/moving a module, wiring cross-module calls, defining a port or adapter, writing a multi-tenant repository, or reviewing boundary violations (reviewer axis-2).From its SKILL.md
npx -y skills add MrEyratnz/software-factory --skill module-boundariesAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
5.7 KB, as published. Nobody here has run it
Module boundaries (modular monolith)
The system is ONE deployable, internally partitioned so boundaries are structural, not
conventional. A violation must be impossible to write, not merely discouraged. Confirm
layering and port contracts with roadmap_check / gate_evaluate — do not eyeball.
Invariants (non-negotiable)
- Layering is a DAG:
kernel < modules < app. Kernel depends on nothing internal. Modules depend only on kernel + their own port interfaces. App wires modules together. No upward, no sideways-by-reach imports. Ever. - One public API per module. Each module exposes exactly ONE entry file (
index). Cross-module access goes through that surface only — never a deep import into another module's internals (moduleA/src/internal/...is forbidden from outsidemoduleA). - Ports own the seams; adapters are swappable. A module talks to the outside world (db, http, clock, email, LLM) through a port INTERFACE it defines. Concrete adapters live at the app edge and are injected. No module imports a vendor SDK directly.
- Every adapter passes the port's shared contract-test-suite. One suite per port, run against every adapter (real + in-memory fake). An adapter that skips the suite does not ship.
- Multi-tenant repositories take
tenantIdFIRST on every call. The unscoped query does not exist, so you cannot forget the filter.find(tenantId, id), neverfind(id). - Accessibility / validation is a publish GATE, not a lint warning. Contrast, schema,
and required-field checks run as unit tests /
gate_evaluate, and a failure blocks merge and release — it is never a suppressible warning.
Procedure — adding or changing a module
- Place it. Decide kernel vs module vs app. Shared pure types/utilities → kernel. Feature domain → module. Composition/wiring/adapters → app.
- Define the public API. Create/extend the single
indexsurface. Nothing outside the module may import anything else from it. - Name the seams as ports. For each external dependency, declare a port interface in the module. Write (or reuse) the port's contract-test-suite describing required behavior.
- Write adapters at the app edge. Implement the real adapter + an in-memory fake. Run the SAME contract suite against both; both must be green before wiring.
- Repositories are tenant-first. Signatures lead with
tenantId. There is no method that queries across tenants without an explicit, reviewed, separately-named escape hatch. - Gate a11y/validation as tests. Express contrast/schema rules as unit tests wired into the publish gate, not lint config.
- Regenerate & enforce the dep graph (below). Then run TDD ladder:
typecheck -> module-boundary -> unit -> BDD -> build -> generated-artifact drift. - Verify green, commit.
guard-commitblocks red;guard-scopeblocks out-of-path edits. An unfixed boundary finding becomes atech-debtissue, never silence.
dependency-cruiser: generate for the detected stack, let CI enforce
Detect the stack (package.json/tsconfig, pyproject, go.mod...) and emit a config whose rules encode the invariants above, then wire it into CI as an authoritative gate identical to the local hook.
# .dependency-cruiser.js — rules mirror the layering DAG + one-public-API law
module.exports = {
forbidden: [
{ name: 'no-upward-kernel', severity: 'error',
from: { path: '^src/kernel' }, to: { path: '^src/(modules|app)' } },
{ name: 'modules-no-app', severity: 'error',
from: { path: '^src/modules' }, to: { path: '^src/app' } },
{ name: 'no-cross-module-deep', severity: 'error',
from: { path: '^src/modules/([^/]+)' },
to: { path: '^src/modules/(?!\\1/)[^/]+/(?!index)' } }, // only sibling index
{ name: 'no-vendor-in-module', severity: 'error', comment: 'use a port',
from: { path: '^src/modules' }, to: { path: 'node_modules/(pg|axios|nodemailer|openai)' } },
{ name: 'no-orphans', severity: 'warn',
from: { orphan: true }, to: {} },
],
options: { doNotFollow: { path: 'node_modules' }, tsConfig: { fileName: 'tsconfig.json' } },
};
CI step (same command the local guard-scope/module-boundary hook runs — one authority):
depcruise src --config .dependency-cruiser.js --output-type err # nonzero => gate fails
For non-JS stacks emit the equivalent (import-linter contracts for Python layers/forbidden;
go-arch-lint / depguard for Go). The tool differs; the encoded rules are identical.
Reviewer axis-2 — boundary check
The adversarial reviewer's second axis inspects structure and names each violation as a fatal flaw before synthesis:
- No import crosses the DAG upward or sideways-by-reach (
depcruiseclean, not just green tests). - Every cross-module reference resolves to the target's single
index. - Each new external dependency is a port; a real adapter AND a fake both pass the port's contract suite.
- Every repository method leads with
tenantId; no unscoped query exists. - a11y/validation checks are gate tests (block merge), not lint warnings.
Any unfixed axis-2 finding → GitHub issue labeled tech-debt with file:line, concrete
impact, provenance (pre-existing vs introduced), and a suggested fix.