The argument against monoliths assumes they're unstructured. A single codebase where everything touches everything else. No boundaries, no discipline, just accumulating mess.
That's just bad engineering. A modular monolith gives you the same structural clarity as microservices—bounded contexts, explicit interfaces, domain separation—without the operational cost of network boundaries.
What Modularity Actually Means
Modularity isn't about separate processes. It's about separate concerns. In a well-structured monolith, your billing logic doesn't reach into your notification logic. Each module has a clear boundary and a public interface.
The difference from microservices is enforcement. Microservices enforce boundaries at runtime through the network. Modular monoliths enforce them at dev time through linting, architecture tests, and code review. When you violate a boundary in a monolith, your CI build breaks. When you violate one in microservices, your service degrades in production.
Start With Domain Boundaries
The first step isn't code. It's understanding what your system does.
Use domain-driven design to identify core domains. Not CRUD entities—actual business capabilities. Billing is the capability to charge customers, track payments, and handle refunds. Projects is the capability to organize work, assign tasks, and track progress.
List your domains. For a project management tool: projects, users, notifications, billing, analytics. For e-commerce: catalog, cart, orders, payments, fulfillment. Keep the list small. If you have more than eight domains, you're probably slicing too thin.
These domains become your modules. Each gets a folder and a clear responsibility.
Structure Your Codebase
Organize code by business capability, not technical layer. Instead of controllers/, services/, repositories/, you have billing/, projects/, notifications/.
src/
modules/
billing/
domain/ # Business logic
handlers/ # HTTP/CLI entry points
repository/ # Data access
index.ts # Public API
projects/
domain/
handlers/
repository/
index.ts
shared/
db/
types/
The index.ts file is your public API. It exports only what other modules need. Everything else stays internal.
// modules/billing/index.ts
export async function createInvoice(
userId: string,
amount: number,
description: string
): Promise<Invoice> {
// implementation
}
export async function getBalance(userId: string): Promise<number> {
// implementation
}
// Everything else is internal
Other modules import from here:
// modules/projects/handlers/createProject.ts
import { createInvoice } from '../../billing';
export async function createProject(req, reply) {
const project = await projectRepo.create(req.body);
await createInvoice(project.userId, project.plan.price, 'Project subscription');
return project;
}
The projects module doesn't know how billing works internally. It just calls the public function.
Enforce With Tooling
You need tooling to keep boundaries clear as the codebase grows. ESLint can enforce import restrictions:
// .eslintrc.js
module.exports = {
rules: {
'no-restricted-imports': ['error', {
patterns: [{
group: ['**/modules/*/domain/*', '**/modules/*/repository/*', '**/modules/*/handlers/*'],
message: 'Import from module index only'
}]
}]
}
};
Write architecture tests that verify boundaries:
// tests/architecture.test.ts
describe('Module boundaries', () => {
it('should not import across module internals', () => {
const projectsFiles = glob.sync('src/modules/projects/**/*.ts');
projectsFiles.forEach(file => {
const content = fs.readFileSync(file, 'utf-8');
const illegalImports = content.match(/from ['"].*\/modules\/billing\/(domain|repository|handlers)/);
expect(illegalImports).toBeNull();
});
});
});
Shopify built an internal tool called Wedge to track component isolation across their massive Rails codebase. It hooks into Ruby's tracepoints during CI to build a call graph and flags violations.
Database Strategy
Start with a single database but enforce logical separation. Use PostgreSQL schemas to group tables by module:
CREATE SCHEMA billing;
CREATE SCHEMA projects;
CREATE TABLE billing.invoices (...);
CREATE TABLE projects.projects (...);
Modules only access their own schema. If you need data from another module, call its public API. This keeps boundaries clean. If you later split databases, the code doesn't change.
Using Platformatic
Platformatic is a Node.js framework built for this model. It lets you structure applications as composable services that run in a single process. You get the modularity of microservices with the deployment simplicity of a monolith.
With Platformatic, you define each module as a service with its own routes and schema. The framework handles inter-service communication in-process. No network latency. No distributed tracing. Just function calls with clean boundaries. You get type safety, OpenAPI docs, and comprehensive NFR management without building infrastructure yourself.
Migration Strategy
If you're starting fresh, build modules from day one. If you're refactoring an existing monolith, go incrementally.
Pick one feature. Extract it into a module. Draw the boundary, define the public API, move the code. Test that everything works. Then pick the next feature.
Don't try to modularize everything at once. That's a rewrite, and rewrites fail. Carve out modules one at a time. Focus on high-churn areas first—the code that changes most frequently benefits most from clear boundaries.
What Success Looks Like
You know it's working when:
- New engineers contribute to a module without understanding the entire system
- Features touch one module 80% of the time
- Build times stay reasonable as the codebase grows
- Refactoring within a module doesn't break other modules
- Extracting a service later would be straightforward
A modular monolith isn't about perfection. It's about making your system easier to change over time.
Why This Matters
A modular monolith delivers the primary benefit of good architecture: the ability to understand and change systems without understanding everything. When you work on billing, you don't need to know how notifications work. You just need to know the interface.
You also gain migration optionality. Because boundaries are explicit, extracting a service later doesn't require a rewrite. You've done the hard work of defining interfaces and eliminating coupling. Moving to a separate process is a deployment change, not an architectural one.
The modular monolith isn't a stepping stone to microservices. It's a legitimate endpoint. Many systems will never need to split into services, and that's alignment between architecture and actual requirements, not failure.
Try This
Pick one feature in your current codebase. Draw a box around all the code it needs: routes, business logic, data access. What does it depend on from outside that box? Could those dependencies be expressed as a clean interface with a handful of functions?
That's your first module boundary. Create a folder, move the code, write an index.ts that exports only what other modules need. Run your tests. If they pass, you've created your first module.
Next
3/4: When to Stay, When to Split