Modular Monolith skill for Codex
Service-grade boundaries in one deployable. Codex splits a single deployable into capability modules, each with a small public API, private internals, its own schema and in-process events, then wires a boundary checker for the stack (Spring Modulith, Packwerk, Nx, import-linter, .NET projects or Go internal packages) into CI with a shrinking baseline. It gets most of what teams want from microservices without network calls, and leaves modules ready to extract later.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- modular-monolith Installs to ~/.agents/skills/modular-monolith/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install modular-monolith The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project modular-monolith Installs to .agents/skills/modular-monolith/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/modular-monolith.zipunzip -o modular-monolith.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) modular-monolith PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $modular-monolith, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Structures a single deployable application into enforced modules - each with a public API, private internals, its own tables, and in-process events - and makes the boundaries fail the build when crossed, using Spring Modulith, Packwerk, Nx module boundaries, import-linter, ArchUnit or .NET project references. Use when a monolith has turned into a big ball of mud, as the default alternative to premature microservices, when preparing a module for later extraction, or when reviewing cross-module coupling in one codebase.
Use it when
- A single codebase has tangled dependencies between features
- The team is considering microservices without a scaling or isolation driver
- You want to prepare a module for later extraction into a service
- Cross-module joins or imports keep slipping through review
Use something else when
- A part genuinely needs separate deployment or scaling now: use microservice-boundaries
- You need layering inside one module rather than boundaries between modules: use clean-architecture
How to check the result
A throwaway import of another module's internals makes CI fail, module tests pass with neighbours faked at their APIs, and no module's SQL references another module's schema.
The mistake it prevents
Defining modules by technical layer, or exposing ORM entities through module APIs, so every caller can still lazy-load another module's tables.
A modular monolith is working when each module can be understood, tested and changed through its public API alone, no module reads another module’s tables, and a boundary violation fails CI rather than waiting for a code review to notice. It keeps one deploy, one process and local calls, and buys most of the team-independence benefits of services.
When to choose it
| Situation | Choose |
|---|---|
| One to a few teams, product still finding its shape | Modular monolith |
| Monolith with tangled code but no scaling or isolation driver | Modularise in place |
| Planning microservices “eventually” | Modular monolith first; extract modules that prove they need it |
| A part needs separate scaling, runtime, security isolation or release cadence now | Extract that module ($microservice-boundaries) |
What makes a module
| Property | Rule |
|---|---|
| Capability | Named for a business capability (orders, billing, catalog), not a layer |
| Public API | A small facade, service interface or package other modules may call; everything else is private |
| Data | Owns its tables (own schema or table prefix); no other module reads or writes them |
| Events | Publishes domain events other modules subscribe to, in process |
| Tests | Has tests that run against its API with other modules stubbed |
| Owner | One team in CODEOWNERS |
Layout
src/
modules/
orders/
api/ # public: OrdersApi, DTOs, published events
internal/ # private: domain, persistence, handlers
migrations/ # tables owned by orders (schema "orders")
billing/
api/
internal/
catalog/
shared/ # tiny: Money, ids, clock, logging - no business logic
app/ # composition root, HTTP routing, wiring
Cross-module calls go only to modules/<other>/api. Shared code stays small and boring;
a growing shared/ is a module that has not been named yet.
Enforce the boundaries
Pick the tool for the stack; run it in CI on every PR.
Java / Kotlin: Spring Modulith
Modules are the direct sub-packages of the main application package; types in nested packages are internal unless exposed.
class ModularityTests {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
@Test
void verifiesModuleStructure() {
modules.verify(); // fails on cycles and on access to another module's internals
}
@Test
void writesDocumentation() {
new Documenter(modules).writeDocumentation(); // C4-style component diagrams per module
}
}
Use @ApplicationModuleListener for cross-module event handlers (async, transactional),
and the event publication registry (spring-modulith-starter-jpa or -jdbc) so events
survive crashes. @ApplicationModuleTest boots one module in isolation.
Ruby on Rails: Packwerk
# packs/orders/package.yml
enforce_dependencies: true
enforce_privacy: true # privacy checker comes from the packwerk-extensions gem in Packwerk 3
dependencies:
- packs/catalog
bin/packwerk check # fails on undeclared dependencies and private constant access
bin/packwerk update-todo # records existing violations in package_todo.yml as a baseline
TypeScript monorepo: Nx
"@nx/enforce-module-boundaries": ["error", {
"depConstraints": [
{ "sourceTag": "scope:orders", "onlyDependOnLibsWithTags": ["scope:orders", "scope:catalog-api", "scope:shared"] },
{ "sourceTag": "scope:billing", "onlyDependOnLibsWithTags": ["scope:billing", "scope:orders-api", "scope:shared"] }
]
}]
Without Nx, use dependency-cruiser rules that forbid ^src/modules/([^/]+)/internal/ from
any path outside that same module.
Python: import-linter
[importlinter:contract:orders-private]
name = Only orders may import orders internals
type = forbidden
source_modules =
app.modules.billing
app.modules.catalog
forbidden_modules =
app.modules.orders.internal
Add one contract per module (or generate them), plus an acyclic check of module
dependencies if your import-linter version provides it; otherwise check cycles in review.
.NET and Go
- .NET: one project per module plus a
.Contractsproject as its public API; other modules reference only.Contracts. NetArchTest for rules inside a project. - Go: put module internals under
internal/inside the module directory; the compiler forbids imports from outside the parent.
Data ownership
- Give each module its own schema (
orders.*,billing.*) or a strict table prefix. - Use a database role per module where practical, so a stray query fails at runtime.
- Remove cross-module foreign keys over time; keep IDs, validate through the API.
- Replace cross-module joins with: an API call returning what is needed, a read model the consuming module maintains from events, or a dedicated reporting query module that is explicitly allowed to read everything (and never writes).
Find cross-module SQL quickly:
rg -n "FROM\s+(billing|catalog)\.|JOIN\s+(billing|catalog)\." src/modules/orders
Communication between modules
| Need | Use |
|---|---|
| Query another module’s data now | Call its public API (synchronous, in process) |
| React to something that happened | In-process domain event, handled after commit |
| Several modules must change together atomically | Single transaction is allowed in a monolith, but treat it as a smell; prefer one module owning the invariant |
| Prepare for later extraction | Events via an outbox table so moving to a broker is a transport change |
Keep event handlers idempotent from day one; extraction then needs no rework.
Workflow (modularising an existing monolith)
- [ ] 1. Read AGENTS.md; map current packages and the worst coupling hotspots
- [ ] 2. Name 4-8 candidate modules by capability; agree with the user
- [ ] 3. Move code into module folders without behaviour change (one PR per module)
- [ ] 4. Add the boundary tool with a baseline of existing violations
- [ ] 5. Define each module's public API; route callers through it; shrink the baseline
- [ ] 6. Separate data ownership: schemas, then remove cross-module joins
- [ ] 7. Turn on "no new violations" in CI, then "zero violations" per module
- [ ] 8. Document modules and owners (CODEOWNERS, AGENTS.md)
Gotchas
- Modules by technical layer (
controllers,services,models) are not modules; every feature crosses all of them. - A public API that returns internal entities (ORM objects) leaks the internals and lets callers lazy-load another module’s tables. Return DTOs or read-only views.
- The baseline file must only shrink. Review any PR that adds to
package_todo.ymlor an equivalent ignore list. - Circular module dependencies hide in events too: if A listens to B and B calls A’s API on that event, document it or invert one direction.
- A single shared transaction across modules makes later extraction a redesign. Mark each such place with a comment and track it.
Verification
- The boundary check fails when you add an import of another module’s internals (prove once with a throwaway change, then revert).
- Each module’s tests run with other modules stubbed or faked at their API.
- No SQL in a module references another module’s schema (grep above returns nothing).
- The violation baseline is empty, or shrinking with a dated plan.
Output contract
**Modules:** orders, billing, catalog, identity (owners in CODEOWNERS)
**Enforcement:** <tool>, config <path>, CI step <name>; baseline violations: <n> (was <m>)
**Data:** schemas per module: yes/partial; cross-module joins remaining: <list>
**Integration:** public APIs at modules/*/api; events: <list>, outbox: yes/no
**Next extraction candidate (if any):** <module> because <driver> What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Modular Monolith"
short_description: "Service-grade boundaries in one deployable"
default_prompt: "$modular-monolith Our monolith is a big ball of mud - split it into enforced modules without going to microservices" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.