الدرس 1 من 3
What Makes a Monolith Modular
One deployable, several modules, and boundaries that something in the build actually refuses to let you cross. Without the third part it is just a large application.
A modular monolith is a single deployable unit divided into modules with explicit boundaries, where each module owns a business capability and exposes a deliberate public surface. It is not a compromise or a stepping stone — for a large share of systems it is the correct end state, because it gives most of the organisational benefits of services without the operational cost of distribution.
Three arrangements of the same code
The difference is not the number of deployables. It is whether a boundary exists and what it costs to cross.
A big ball of mud where every module imports every other module freely. A modular monolith where four modules communicate only through published surfaces inside one deployable. A microservice system where the same four modules are separate deployables communicating over a network.
Big ball of mud
One deployable
any file may import any file
No enforced edges
coupling grows silently
Modular monolith
One deployable
one process, one release
Enforced module surfaces
build fails on a breach
In-process calls
no network, no partial failure
Microservices
Four deployables
independent releases
Network boundaries
partial failure is now normal
Notice what the middle column keeps and what it avoids. It keeps a single transaction across modules, one deployment, one place to look during an incident, one set of credentials and one runtime to upgrade. It avoids the properties that a network forces on you: partial failure, retries, idempotency, distributed tracing as a prerequisite rather than a nicety, and the coordination cost of releasing several units.
// features/assessment/index.ts — the entire public surface of the module.
export { quizService } from './application/quiz-service';
export type { GradedAttempt, QuizSummary } from './domain/quiz';
// Allowed from another module:
import { quizService } from '@/features/assessment';
// Refused by the build, even though the file exists and the import compiles
// in an editor:
// import { PrismaQuizRepository } from '@/features/assessment/infrastructure/prisma-quiz-repository';
//
// The rule in .dependency-cruiser.cjs is what converts a naming convention
// into a boundary. Without it, the first deadline puts that import in place
// and nobody notices for a year.How modules talk
| Style | What it looks like | Use it when |
|---|---|---|
| Direct call through the public surface | Module A calls `billingService().charge(...)` synchronously | A needs the result now, and a failure should fail the whole operation |
| In-process domain event | A publishes `OrderPlaced`; B and C subscribe, in the same transaction or just after | Several modules react to something, and A should not know who they are |
| Shared read model owned by one module | B maintains a projection it owns, updated from A's events | B queries A's data constantly and the query shapes differ from A's model |
In practice
Choosing a modular monolith with services on the table
A health-tech company of eighteen engineers was rebuilding a patient-scheduling platform. Leadership expected microservices, having been told that was what scaling required. The team had four capability areas: scheduling, clinical records, billing and notifications.
Constraints
- Eighteen engineers in three teams
- Peak load of 300 requests per second, growing 40% per year
- Two engineers with production Kubernetes experience
- Regulatory requirement for a complete audit trail across all four areas
Decision
One deployable with four enforced modules, each owning its own database schema, communicating through published surfaces and in-process events. Notifications was designed with an outbox so that it could be extracted later without changing its callers.
Why
None of the usual justifications for separate services applied: no module needed independent scaling at 300 requests per second, no team was blocked by another's release cadence at three teams, and there was no differing availability requirement. Meanwhile the audit requirement was much easier to satisfy with one transaction spanning all four modules, and the team had very little operational capacity for a distributed system.
What it cost
The whole application must be redeployed for any change, so a risky change to billing carries a small risk to scheduling — mitigated by trunk-based development, feature flags and a fast pipeline. They also accepted that if the company reaches a size where three teams become eight, the extraction work will be real, and they reduced its cost in advance by enforcing module surfaces and separate schemas from day one.
A stakeholder asks why the system is "still a monolith"
As a developer
Explains that microservices would be over-engineering at this size and that the team lacks the operational experience to run them. True, and it sounds like a limitation being defended.
As an architect
Reframes the question around what the business is buying. Independent deployment is worth paying for when teams are blocked by each other's release cadence; independent scaling is worth paying for when one component's load differs by an order of magnitude. Neither is true today, and here is the specific measurement — release blocking incidents per quarter, and the load profile — that will tell us when it becomes true. The point is not that services are premature; it is that the trigger is written down and being watched.
A team says they have a modular monolith. Each capability has its own directory, code review checks that modules only use each other's public interfaces, and the team is disciplined. What is the risk?RevealHide
The boundary depends entirely on human attention, which is exactly the resource that disappears near a deadline, during an incident, and whenever a new joiner does not yet know the rule. The characteristic pattern is that nothing breaks for a year and then someone attempts to extract a module and finds forty imports reaching into its internals, each added by a reasonable person under pressure. The fix is inexpensive: one dependency rule in the build that fails on a forbidden import. It costs an afternoon and converts a convention into a property of the system. Note also the secondary risk — even with import rules, two modules may still be reading each other's tables, which the import graph cannot see.
Key takeaways
- A modular monolith is one deployable with modules that map to capabilities and have enforced public surfaces.
- The enforcement is the architecture; the folder structure without it is a big ball of mud with better naming.
- It keeps single transactions, one deployment and one place to debug, avoiding the costs a network imposes.
- Modules communicate by direct calls through the surface, by in-process events, or through owned read models.
- Shared tables defeat modularity even when the import graph looks clean.