Modular Monolith skill for Claude Code
Service-grade boundaries inside one deployable. Claude organizes a monolith into business modules that each expose one public API, keep internals and tables private, and talk through calls or in-process events. Boundaries are enforced in CI with the stack's native checker, existing violations are baselined so only new ones fail, and modules become extraction-ready without anyone rewriting the system.
- name
modular-monolith - size214 lines · ≈2,100 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Structures a single deployable application into business modules with explicit public APIs, private internals, owned database schemas, and in-process events, and enforces the boundaries in CI with dependency-cruiser, ESLint, Nx module boundaries, import-linter, Packwerk, Spring Modulith, or ArchUnit. Covers baselining existing violations and ratcheting them down, cross-module data access, shared kernel rules, and preparing a module for later extraction. Use when a monolith has become a big ball of mud, when teams step on each other in one codebase, when enforcing module boundaries, or as the alternative to splitting into microservices. Try asking
- Our Django monolith is a big ball of mud. Split it into enforced modules
- Add CI checks so nobody imports another module's internal folder
- How should modules in our monolith share data without joining each other's tables?
Use it when
- A monolith has become tangled and hard to change
- Several teams work in one codebase and keep colliding
- You want module boundaries enforced by tooling
- Preparing parts of a monolith for possible future extraction
Skip it when
- You are still deciding whether to split into services: use service-boundaries
- You need the internal layering of a single module: use hexagonal-architecture
How to check the result
The boundary check fails in CI on a deliberately added import of another module's internals, and the recorded baseline of violations shrinks over time.
The common mistake it prevents
Turning on boundary enforcement without a baseline, which floods the build with hundreds of errors until someone disables the check.
A modular monolith is one deployable with boundaries as strict as service boundaries: each module exposes a small public API, hides everything else, owns its tables, and the build fails when someone reaches into another module’s internals. Folders named after domains without enforcement are just folders.
Ground rules
- Modules follow business capabilities (billing, catalog, fulfillment), not technical layers (controllers, services, repositories).
- Each module has one public entry point. Everything else is private by convention and by tooling.
- A module owns its tables. Other modules never query or join them directly.
- Enforcement runs in CI from day one, with a baseline of existing violations that can only shrink.
- The shared kernel (code every module may use) stays tiny: primitives, IDs, money, time, logging. No business logic.
Workflow
- [ ] 1. Identify modules from business capabilities and change coupling
- [ ] 2. Define each module's public API and internal folder
- [ ] 3. Add a boundary checker; record the current violations as a baseline
- [ ] 4. Fix violations module by module; ratchet the baseline down
- [ ] 5. Separate data: schema per module, no cross-module joins
- [ ] 6. Replace synchronous cross-module coupling with events where it fits
- [ ] 7. Track module health; extract a module only when a force demands it
1. Find the modules
Start from bounded contexts (see domain-driven-design) and validate with git co-change data (see service-boundaries, which ships a co-change script). A good first cut has 5–15 modules for a mid-size product. Too many tiny modules create more boundary friction than value.
2. Layout and public API
TypeScript:
src/modules/
billing/
index.ts # public API: functions, types, events — the only importable file
internal/ # domain, persistence, handlers; private
billing.test.ts
catalog/
index.ts
internal/
src/shared/ # shared kernel: ids, money, clock, logger
src/app/ # composition: HTTP server, routing, wiring modules together
// src/modules/billing/index.ts
export type { InvoiceId, InvoiceSummary } from './internal/types';
export { createInvoiceForOrder, getInvoiceSummary } from './internal/api';
export { InvoicePaid } from './internal/events';
The public API speaks in DTOs and IDs, never in ORM entities, so internals can change
freely. Other languages: Java/Kotlin top-level packages with internal subpackages
(Spring Modulith conventions), Go internal/ directories (compiler-enforced), .NET
separate projects with internal visibility, Ruby Packwerk packages, Python packages
with an api.py facade.
3. Enforce boundaries
Pick the tool native to the stack:
| Stack | Tool | Rule |
|---|---|---|
| TS/JS | dependency-cruiser | Forbid imports into another module except its index.ts |
| TS/JS (Nx monorepo) | @nx/enforce-module-boundaries | Tags per project, depConstraints between scopes |
| TS/JS (simple) | ESLint no-restricted-imports or eslint-plugin-boundaries | Block **/modules/*/internal/** from outside |
| Python | import-linter | independence / forbidden contracts |
| Ruby | Packwerk | package.yml with enforce_dependencies: true |
| Java/Kotlin (Spring) | Spring Modulith | ApplicationModules.of(App.class).verify() in a test |
| JVM (any) | ArchUnit | Slice rules: modules only access each other’s API packages |
| Go | internal/ + depguard | Compiler blocks internal imports; depguard for extra rules |
dependency-cruiser rule with group matching:
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: 'module-internals-are-private',
severity: 'error',
comment: 'Import other modules only through their index.ts',
from: { path: '^src/modules/([^/]+)/' },
to: { path: '^src/modules/([^/]+)/.+', pathNot: ['^src/modules/$1/', '^src/modules/[^/]+/index\\.ts$'] },
},
{
name: 'shared-kernel-is-leaf',
severity: 'error',
from: { path: '^src/shared/' },
to: { path: '^src/modules/' },
},
{ name: 'no-circular', severity: 'error', from: {}, to: { circular: true } },
],
};
npx depcruise src --config .dependency-cruiser.cjs
# baseline existing violations, then fail only on new ones:
npx depcruise src --config .dependency-cruiser.cjs --output-type baseline > .dependency-cruiser-known-violations.json
npx depcruise src --config .dependency-cruiser.cjs --ignore-known
Python (import-linter):
[tool.importlinter]
root_package = "shop"
[[tool.importlinter.contracts]]
name = "Modules are independent"
type = "independence"
modules = ["shop.billing", "shop.catalog", "shop.fulfillment"]
ignore_imports = [
"shop.fulfillment.handlers -> shop.billing.api", # allowed: public API only
]
Spring Modulith:
class ModularityTests {
@Test
void verifiesModuleBoundaries() {
ApplicationModules.of(ShopApplication.class).verify();
}
}
Packwerk: bin/packwerk check in CI (or bundle exec packwerk check), with existing
violations recorded in package_todo.yml files via update-todo.
4. Ratchet
- Commit the baseline. CI fails on any new violation.
- Each sprint, fix a slice: move a function into the owning module’s API, or move the code to the module that owns the concept.
- Track baseline size as a metric; it only goes down.
5. Data ownership
- One schema per module (
billing.invoices,catalog.products) or a table prefix if schemas are impractical. Optionally a DB role per module to make violations fail. - Cross-module reads go through the owning module’s API, or through a read model the consumer maintains from the owner’s events.
- Cross-module joins for reporting move to a reporting module that reads replicas or a warehouse; it does not write.
- Foreign keys across module schemas couple deployments of data changes; prefer storing the ID without a cross-schema foreign key, and handle referential checks in code.
- Transactions spanning modules are a smell; use one module’s transaction plus an in-process event (with an outbox if the handler’s effect must not be lost).
6. In-process events
// billing publishes
await events.publish(new InvoicePaid({ invoiceId, orderId, amountCents }));
// fulfillment subscribes in its own module wiring
events.subscribe(InvoicePaid, (e) => fulfillment.releaseOrder(e.orderId));
- Use events when the publisher should not know or wait for the consumer.
- Use direct API calls when the caller needs an answer now.
- For reliability across crashes, store events in an outbox in the publisher’s transaction and dispatch after commit (Spring Modulith’s event publication registry does this for Spring apps). This also makes later extraction to a broker a transport change, not a redesign.
7. Health and extraction
Track per module: boundary violations, fan-in/fan-out to other modules, co-change with other modules, test time. A module is ready to extract when it has zero violations, owns its schema, talks to others only through its API and events, and a real force (scaling, isolation, team autonomy) exists. Extraction then mostly swaps in-process calls for network calls (see service-boundaries for the strangler steps).
Gotchas
- A
common/utils/sharedmodule that grows business logic becomes the new ball of mud; review every addition. - Public APIs that return ORM entities leak internals; return DTOs.
- Circular dependencies between modules mean the boundary is wrong or a concept belongs in a third module; fix, do not suppress.
- One huge module (“core”) containing 70% of the code is not modularization; split it.
- Tests that reach into another module’s internals break encapsulation as surely as production code; apply the same rules to tests.
- Turning on enforcement without a baseline produces hundreds of errors and gets disabled the same week.
Verification
- The boundary check runs in CI and fails on a deliberately added import of another module’s internals.
- Baseline violation count is recorded and trending down.
- No SQL in one module references another module’s tables (grep schema names or use DB roles).
- Each module’s tests run in isolation with other modules stubbed at their public APIs.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.