What a modular monolith folder structure is, and why package-by-layer breaks down
A modular monolith is a single deployable unit, built and released as one artifact, that is split internally into business modules such as orders, billing and catalog. Each module owns its data and exposes a small public surface. Other modules may call that surface and nothing else.
Most codebases start out organized by technical layer, with controllers/, services/ and repositories/ at the top. That works at ten files. At several hundred, adding one field to "refund an order" means editing a controller in one folder, a service in another, a repository in a third, and a DTO somewhere else. The tree tells you what kind of class a file is, which you rarely need to know. It hides what the file is for, which you need to know all the time. Since every service can reach every repository, coupling between features grows without anyone deciding it should.
Package by feature turns this around. Code that changes together sits together, so one change touches one folder.
| Package by layer | Package by feature |
|---|---|
controllers/OrderController |
orders/OrderController |
controllers/InvoiceController |
orders/OrderService |
services/OrderService |
orders/OrderRepository |
services/InvoiceService |
billing/InvoiceController |
repositories/OrderRepository |
billing/InvoiceService |
repositories/InvoiceRepository |
billing/InvoiceRepository |
The rest of this article rests on one point. A folder is only a naming convention. If nothing stops billing from importing orders/OrderRepository, the feature layout is a package-by-layer codebase with its files in a different order. You get a modular monolith only when the boundaries are enforced.
# Change: add a refundReason field to "refund an order"
# Files marked * must be edited.
# Package by layer: one change spreads across four folders
src/
controllers/
OrderController.java * accept refundReason in the request
InvoiceController.java
services/
OrderService.java * validate and pass refundReason on
InvoiceService.java can call OrderRepository directly
repositories/
OrderRepository.java * persist the new column
InvoiceRepository.java
dto/
RefundRequest.java * add the field
# Package by feature: the same change stays in one folder
src/
orders/
OrderController.java *
OrderService.java *
OrderRepository.java * internal: only orders/ may use it
RefundRequest.java *
OrdersApi.java public surface for other modules
billing/
InvoiceController.java
InvoiceService.java should depend on orders/OrdersApi only
InvoiceRepository.java
# Nothing in this tree stops billing/InvoiceService from importing
# orders/OrderRepository. Only an enforced boundary does that.
The same refund change touches four folders in a package-by-layer tree and one folder in a package-by-feature tree, and the folder layout alone does not stop billing from reaching into orders internals.
Top-level module layout and naming conventions
Start with two top-level areas under the source root: modules/ and platform/. Inside modules/, create one folder per business capability, such as billing/, catalog/, identity/. Each folder is a unit a team can own, reason about and eventually extract. platform/ holds only infrastructure that every module needs and no module owns: logging setup, configuration loading, database connection pooling, and maybe HTTP server bootstrap.
Naming rules
- Name modules after capabilities, not technical roles.
billingtells you what a module is responsible for.servicesorpersistencereintroduces layering at the top level. - Use one fixed name for the public surface in every module. Pick a folder like
api/for contracts and DTOs, or a suffix likeBillingApiandInvoiceDto, and apply it everywhere. Other modules may depend on that surface and nothing else. - Ban catch-all buckets.
utils/,common/andhelpers/attract code that has no clear owner. Over time they become an unreviewed dependency that every module shares. If a function belongs to a capability, put it in that module. If it is genuinely infrastructural, put it inplatform/under a specific name likeplatform/clock. - Keep names predictable enough for a tool to check. A rule such as "only
modules/*/api/**may be imported across modules" works only if every module follows the same layout.
The rule for shared code
platform/ holds no business logic. No pricing rules, no permission checks, no domain types. The trade-off is that this area still tends to become a hidden coupling hub, because adding something there is always the easy option. Review its contents periodically and move anything that has grown domain knowledge back into the module that owns it.
src/
├── modules/ # one folder per business capability
│ ├── billing/
│ │ ├── api/ # public surface: the only thing other modules may import
│ │ │ ├── BillingApi.ts
│ │ │ └── InvoiceDto.ts
│ │ ├── domain/ # pricing rules, invoice lifecycle (private)
│ │ └── infrastructure/ # billing's own tables and adapters (private)
│ ├── catalog/
│ │ ├── api/
│ │ │ ├── CatalogApi.ts
│ │ │ └── ProductDto.ts
│ │ ├── domain/
│ │ └── infrastructure/
│ └── identity/
│ ├── api/
│ │ ├── IdentityApi.ts
│ │ └── UserDto.ts
│ ├── domain/ # permission checks live here, not in platform/
│ └── infrastructure/
└── platform/ # infrastructure only: no business logic, no domain types
├── config/ # configuration loading
├── db/ # connection pool setup
├── logging/ # logger setup
├── clock/ # specific name instead of utils/
└── http/ # server bootstrap
# Not allowed anywhere: utils/, common/, helpers/, services/, persistence/
# Boundary rule a tool can check: imports from another module must match modules/*/api/**
A modular monolith source tree with one folder per capability, a fixed api/ folder and Api/Dto suffixes for each module's public surface, and a platform/ area limited to infrastructure.
Inside a module: public API versus internals, events and data ownership
Every module repeats the same internal shape. Take billing: billing/api/ holds the facade (an exported interface or index file) and the contract and event types other modules are allowed to see. Behind it sit domain/, application/ and infrastructure/ (repositories, persistence adapters), with migrations/, config/ and tests/ colocated so the whole module can be moved, deleted or extracted as one folder. Other modules import only from api/. Everything else is an implementation detail, so you can refactor it without coordinating with another team.
Modules talk to each other in two ways. Queries are synchronous calls through the facade: orders asks billing for an invoice's status. Side effects go through in-process domain events: billing publishes InvoicePaid and shipping subscribes. The publisher owns the event type in its own api/, so subscribers depend on billing and never the reverse.
orders ──query──▶ billing/api ─┐ shipping/api
│ InvoicePaid ▲
└──(in-process)──┘
[orders schema] [billing schema] [shipping schema]
In-process events are not a message broker. Handlers run in the same process and usually inside the publisher's transaction. A failing handler can roll back the payment, and there is no durability or retry unless you build it. Events also hide control flow: reading markPaid() does not tell you that shipping logic runs next.
Each module owns its data. Do not join across modules; fetch other modules' data through their facades. Use either a schema per module (billing.invoices) or a table prefix (billing_invoices) that matches the folder name, so ownership is visible in every query and migration.
// billing/api/index.ts: the only path other modules may import
export type InvoiceStatus = "open" | "paid" | "void";
export interface BillingFacade {
// Synchronous query: callers use this instead of joining billing.invoices
getInvoiceStatus(invoiceId: string): Promise<InvoiceStatus>;
}
// The event type is owned by the publisher, so subscribers depend on billing
export interface InvoicePaid {
readonly type: "billing.InvoicePaid";
readonly invoiceId: string;
readonly orderId: string;
readonly paidAt: Date;
}
// shared/events.ts: a minimal in-process bus, with no broker involved
type Handler<E> = (event: E) => Promise<void>;
export class EventBus {
private handlers = new Map<string, Handler<any>[]>();
subscribe<E extends { type: string }>(type: E["type"], h: Handler<E>): void {
this.handlers.set(type, [...(this.handlers.get(type) ?? []), h]);
}
async publish<E extends { type: string }>(event: E): Promise<void> {
// Handlers run in the publisher's call stack. An error thrown here
// propagates back and can roll back the caller's transaction.
// Nothing is persisted, and failed handlers are not retried.
for (const h of this.handlers.get(event.type) ?? []) await h(event);
}
}
// shipping/application/handlers.ts: imports billing/api only, never internals
import type { InvoicePaid } from "../../billing/api";
import type { EventBus } from "../../shared/events";
export function registerShippingHandlers(bus: EventBus, createShipment: (orderId: string) => Promise<void>): void {
bus.subscribe<InvoicePaid>("billing.InvoicePaid", (e) => createShipment(e.orderId));
}
The billing module exposes a query facade and owns its InvoicePaid event type in billing/api. Shipping subscribes through a minimal in-process bus, where a handler's errors propagate back into the publisher's call and transaction.
Enforcing boundaries with tooling instead of discipline
A folder layout only describes intent. It does not stop anyone from importing billing/internal/InvoiceRepository from shipping when a deadline is close, and every such shortcut makes the next one look acceptable. Without automated checks, these convenience imports pile up quietly, and within a few months the modules are coupled again. The cure is to make violations fail the build.
There are three broad ways to enforce boundaries.
Language-level visibility. Some languages can hide a module's internals from the rest of the codebase: package-private in Java, or internal in languages that scope it to an assembly or compilation unit. It costs nothing to adopt, but it only works if the language's visibility scope lines up with your module boundary.
Separate build modules or packages per module. Each module becomes its own build unit and declares its dependencies explicitly. An undeclared import then fails to compile.
Architecture or import-lint tests in CI. A test states the rule, for example: "code under modules/*/internal may only be imported by code in the same module; other modules may import only modules/<name>/api." It also states which module-to-module dependencies are allowed, such as "shipping may depend on billing.api, never the reverse." The test scans imports and fails the pipeline on any violation.
| Approach | Strength | Setup cost | Build impact | Refactoring friction |
|---|---|---|---|---|
| Language visibility | Medium, limited to what the language can express | Very low | None | Low |
| Separate build units | High, enforced by the compiler | High | Can slow builds as unit count grows | High: cross-cutting changes touch many build files |
| Import-lint / architecture tests | High for the rules you write | Low to medium | Small extra CI step | Low; rules change in one place |
Many teams combine language visibility with architecture tests, and move to separate build units only for modules that have stabilized.
# check_boundaries.py: run in CI; exits non-zero if a module bypasses another module's public API.
# Assumed layout: modules/<name>/api/... (public) and modules/<name>/internal/... (private).
import ast, pathlib, sys
ROOT = pathlib.Path("modules")
# Allowed module-to-module dependencies: importer -> modules whose api it may use.
# shipping may depend on billing.api; billing must never depend on shipping.
ALLOWED = {"shipping": {"billing"}, "billing": set()}
def imports(path):
tree = ast.parse(path.read_text(), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
yield from (alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
yield node.module
violations = []
for path in ROOT.rglob("*.py"):
owner = path.relative_to(ROOT).parts[0]
for name in imports(path):
parts = name.split(".")
if parts[0] != "modules" or len(parts) < 2:
continue # stdlib or third-party code: outside this rule
target = parts[1]
if target == owner:
continue # a module may use its own internals freely
layer = parts[2] if len(parts) > 2 else ""
if layer != "api":
violations.append(f"{path}: imports {name}; only modules.{target}.api is public")
elif target not in ALLOWED.get(owner, set()):
violations.append(f"{path}: {owner} is not allowed to depend on {target}")
for v in violations:
print(v)
sys.exit(1 if violations else 0)
A minimal import-lint test that parses every source file and fails the CI build when one module imports another module's internals or depends on a module it is not allowed to use.
Migrating incrementally from a layered codebase
Start with one capability the team understands well and that changes often enough for the effort to pay off. Notifications or invoicing are common first picks. Avoid the most tangled area: a first migration that stalls teaches people the approach doesn't work.
- Move the code. Gather the capability's controllers, services and repositories into one module folder. Change no behavior yet, so the diff is easy to review.
- Define the public API. Expose a small facade or interface. Mark everything else internal, using language visibility where possible.
- Redirect callers. Update each external caller to use the API. Each remaining direct import marks coupling you still have to remove.
- Take ownership of tables. Only this module reads and writes its tables. Other modules ask through the API or react to its events.
- Add the boundary check to CI. Lock in what you've gained so new violations fail the build.
Then pick the next capability. Each round is small enough to ship alongside feature work, and the codebase stays releasable throughout.
Existing joins and reporting
Cross-module SQL joins are usually the hardest part to unwind. You have three options:
- Read models. A module builds its own projection from other modules' events. This keeps ownership clean, but you must accept eventual consistency and maintain the projection code.
- Dedicated query paths. A separate reporting component may read several modules' tables, read-only. This is cheap to build, but it couples reporting to internal schemas, so schema changes need coordination.
- Documented exceptions. Allow the join, record the reason and an owner in the boundary rules, and revisit it later. This costs nothing upfront. Without a review date, exceptions tend to accumulate.
Who should use it, when to avoid it, and the alternatives
The structure pays off once a codebase has grown past the point where one person can hold it in their head. Typical signs are several engineers or teams changing the same areas in parallel, merge conflicts piling up in shared service classes, and a realistic chance that one area will later need to become its own service. Module boundaries give each group a clear place to work. They also make a future extraction a matter of replacing in-process calls rather than untangling shared tables.
Skip it for small services and small teams. With three entities and two developers, facades, DTOs and mapping code mostly add indirection with nothing to protect, and a plain layered structure is easier to read and change. Also hold off when the domain is still being discovered. A folder structure enforces whatever boundaries you draw, including wrong ones. Re-splitting modules means moving data ownership, rewriting contracts and changing events, which costs far more than reorganising layers.
| Aspect | Layered monolith | Modular monolith | Microservices |
|---|---|---|---|
| Deployment | One unit | One unit | Independent per service |
| Scaling | Whole application | Whole application | Per service |
| Failure isolation | None | None | Per process, if designed for it |
| Boundary enforcement | Convention only | Build-time rules and tests | Network and API contracts |
| Operational overhead | Low | Low | High |
A modular monolith still shares one deployment, one runtime and one failure domain. A memory leak in one module takes down all of them, and the whole application scales as a single unit.
Impact on development cost and engineering time
The structure costs time before it saves any. Someone has to draw the first boundaries, which means arguing about where "billing" ends and "subscriptions" begins. Every cross-module call needs a public interface instead of a direct import. Boundary checks have to be wired into CI and kept current. Builds can slow down if each module becomes its own compilation unit. Reporting queries that once joined five tables now need read models or API composition, because no module may read another's tables.
The return comes from smaller blast radius. Most changes stay inside one module, so reviewers read less and tests run narrower. New engineers find code by business capability instead of tracing a request through every layer. Boundary violations fail the pipeline instead of slipping past a tired reviewer. Teams working in separate modules rarely touch the same files, so merge conflicts drop. If a module later needs to become a service, its code, tests, migrations and data are already in one place.
Illustrative estimate (assumed numbers, not a benchmark). Payback weeks = upfront hours ÷ (engineers × hours saved per engineer per week). Assume 160 hours of setup, 8 engineers, and 1 hour saved each per week: 160 ÷ (8 × 1) = 20 weeks.
That estimate assumes the boundaries are right. Wrong or premature boundaries reverse the math. Modules that constantly call each other, share events for every operation, or need coordinated releases add interface overhead without adding isolation. Draw boundaries late, after real change patterns show where the seams are, and merge modules that keep changing together.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to modular monolith folder structure, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.