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.

  • namemodular-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

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.

~/.claude/skills/modular-monolith/SKILL.md

--- name: modular-monolith description: Structures a single deployable application into business modules with explicit public APIs, private internals, owned database schemas, and… license: MIT ---

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:

StackToolRule
TS/JSdependency-cruiserForbid imports into another module except its index.ts
TS/JS (Nx monorepo)@nx/enforce-module-boundariesTags per project, depConstraints between scopes
TS/JS (simple)ESLint no-restricted-imports or eslint-plugin-boundariesBlock **/modules/*/internal/** from outside
Pythonimport-linterindependence / forbidden contracts
RubyPackwerkpackage.yml with enforce_dependencies: true
Java/Kotlin (Spring)Spring ModulithApplicationModules.of(App.class).verify() in a test
JVM (any)ArchUnitSlice rules: modules only access each other’s API packages
Gointernal/ + depguardCompiler 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/shared module 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

  1. The boundary check runs in CI and fails on a deliberately added import of another module’s internals.
  2. Baseline violation count is recorded and trending down.
  3. No SQL in one module references another module’s tables (grep schema names or use DB roles).
  4. 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.

esc