Building a Modular Monolith for Future Scale
Often when building a greenfield product, especially in a startup environment, we need to move forward quickly and be prepared for pivots. That's why, for many teams, thinking about robust architecture and future scalability is often not the case, and sometimes it is not a problem if the business behind the project is not complex. But... what if the problem you solve is complex and spans multiple domains from day one, or you need to operate on large data volumes? In that case, good architecture plays a very important role. Without it, your team will quickly get lost and slow down, ending up debugging bugs caused by spaghetti code and rewriting parts of the application constantly on every pivot and every new major functionality, while struggling with a data model and skyrocketing technical debt from day one. On the other hand, in such an early-stage project, extracting microservices per domain would be costly and create operational overhead, so we need to have something in the middle. Fortunately, we have Modular Monolith Architecture.
What is Modular Monolith Architecture in a few words
If we want to summarize this in a few words, a Modular Monolith is a monolithic application comprised of loosely coupled modules/services that typically use a shared database while keeping their data models isolated and communicating with each other via APIs or events. You can think of it as microservices-like packed into one application that is a single deployable unit.
Node.js is a very flexible environment, great for implementing such an architecture, either by implementing it yourself or by using frameworks like Nest.js, which encourage a modular architecture and are a good fit for a Modular Monolith.
The additional advantage of the Modular Monolith is that when you make a mistake while extracting services, or you want to get rid of one of the existing ones, you often only need to adjust the project structure and module boundaries and move on.
Define Bounded Context first
If you made the decision to start your project with a Modular Monolith Architecture, do not jump directly into the code and start blindly creating modules. Bring stakeholders, domain experts, and team members together to define bounded contexts, which will help you structure the application by business domains.
Bounded Context is a crucial concept in DDD for clear strict domain boundaries and preventing overlapping logic between different parts of your system. This concept allows you to define independent domain models; for example, User can mean different things in the Identity(auth) context, where it holds information about application roles and connected identity providers, while in the Billing context it holds information about tax data and payment methods.
Data segregation
Data segregation is crucial when it comes to building a modular monolith, and if you've defined bounded contexts in your application, it should be much easier to separate models, but how do you technically do it?
Let's start from the beginning. If we think about a monolith, we think about an application that operates on a single data model. For example, we have a typical case of an e-commerce app where we have entities such as user, product, merchant, delivery, invoice; each of those entities has properties from every context. Let's zoom in on the user:
typescript
export interface GodUserProps {
// --- IDENTITY DOMAIN ---
id: string;
email: string;
passwordHash: string;
isMfaEnabled: boolean;
// --- ORDER DOMAIN ---
currentCartItems: { sku: string; qty: number }[];
activeDiscountCode: string | null;
lifetimeOrderCount: number;
// --- LOGISTICS DOMAIN ---
streetAddress: string;
postalCode: string;
countryCode: string;
shippingInstructions: string;
}
export class GodUser {
private props: GodUserProps;
constructor(props: GodUserProps) {
this.props = props;
}
// --- IDENTITY DOMAIN ---
public rotatePassword(newHash: string): void {
if (newHash === this.props.passwordHash) {
throw new Error("Cannot reuse old password");
}
this.props.passwordHash = newHash;
}
public enableMfa(): void {
this.props.isMfaEnabled = true;
}
// --- ORDER DOMAIN ---
public addItemToCart(sku: string, qty: number): void {
if (qty <= 0) throw new Error("Quantity must be positive");
this.props.currentCartItems.push({ sku, qty });
}
public incrementOrderHistory(): void {
this.props.lifetimeOrderCount += 1;
this.props.currentCartItems = [];
}
// --- LOGISTICS DOMAIN ---
public isPoBoxAddress(): boolean {
return this.props.streetAddress.toLowerCase().includes("p.o. box");
}
public validateDeliveryRoute(): void {
if (this.isPoBoxAddress() && this.props.countryCode === "US") {
throw new Error(
"Logistics failure: Ground couriers cannot deliver to US PO Boxes",
);
}
}
}I've intentionally added comments to emphasize the different domains packed into this single user model; when you have more entities like this and the application grows, it is so hard to keep up with adding new features without destroying the logic, and the same issues apply to reading this code and testing.
In many microservice architectures, we would have a separate database for each service or domain, and in those databases our User entity would mean something else depending on the business context.
One of the benefits of the modular monolith is low operational overhead, and using a separate database per domain significantly reduces this benefit. But there are solutions for achieving such isolation within a single database.
The Pattern of Naming convention
The first approach is to enforce a naming convention for entities. For example, our god user is split into 3 tables/collections in the database, such as: identity_users, order_users, logistics_users. As you can see, the context comes first in the table name, and that way your data is segregated. The hard rule here is that there should be no joins between those tables, and each module should only operate on its own data model. Things like JOIN identy_users.id = order_users.id are strictly forbidden.
The cons are, of course, weak isolation guarded only by convention, not enforced by the database engine, and the fact that it will be harder to extract microservices. But this approach is very fast and simple and does not require any special database features, which, depending on the database used in a system, can be an advantage. For example, there is no schema in MongoDB in the same sense as in PostgreSQL, and to have data really separated you would need to create a database per domain, which adds complexity around selecting the right database per domain and managing access patterns in the application code.
The Pattern of Schema per Module
In a database like PostgreSQL, we can use a schema per module and have clear logical isolation per module without polluting table names with a special context prefix. When the time comes to extract a microservice, you can move the schema to a separate database as part of extracting the microservice. The rule of no joins still applies, despite cross-schema joins are possible in databases like PostgreSQL.
The main con is that it is not supported in all databases, and the very fact that this is a database-specific feature can be a problem.
Communication between modules
In a modular monolith, we can mentally map a module to a future microservice boundary, so let's say that I would like to implement an event-driven architecture — is it possible in a modular monolith? Yes, it is, and with proper implementation it should make a future move toward microservices easier. At the same time, in Node.js we do not necessarily need to use additional infrastructure such as message brokers; we can start with very simple things like an in-memory event mechanism: EventEmitter, and swap it later on if there is a need to extract a microservice.
We need to remember that our messaging mechanism should be hidden behind an interface and should be a separate package that allows us to swap the queue implementation, e.g. replace an in-memory mechanism with RabbitMQ.
We can also implement synchronous communication, but at this point we don't want to utilise any network calls. In order to achieve this goal, we need to operate on abstractions that will encapsulate the cross-module communication boundary, so for example module A, which calls module B, will have an adapter that will call module B's port. The port is like an interface that exposes the API of the functions that module B provides. When we look at our e-commerce app and assume that the Order module needs to check whether MFA is enabled for the user in the Identity module, we should:
- Prepare DTOs and interfaces as a contract:
typescript
// shared/contracts/identity-port.interface.ts
export interface IdentityUserDTO {
id: string;
email: string;
isMfaEnabled: boolean;
}
// The Port: The public facade interface exposed by the Identity Module
export interface IdentityPort {
getUserProfile(userId: string): Promise<IdentityUserDTO>;
}- Implement the port in the Identity module:
typescript
// modules/identity/identity.facade.ts
import { IdentityPort, IdentityUserDTO } from "@shared/contracts";
import { UserService } from "./services/user.service"; // Internal import
export class IdentityFacade implements IdentityPort {
// Injects the actual internal service of the Identity module
constructor(private readonly userService: UserService) {}
async getUserProfile(userId: string): Promise<IdentityUserDTO> {
// 1. Delegate the work to the real internal domain service
const user = await this.userService.findById(userId);
// 2. Map the internal database entity to the clean public DTO
return {
id: user.id,
email: user.email,
isMfaEnabled: user.isMfaEnabled,
};
}
}- Implement the adapter in the Order module that will be used to call the facade of the Identity module:
typescript
// modules/ordering/adapters/local-identity.adapter.ts
import { IdentityPort, IdentityUserDTO } from "@shared/contracts";
import { IdentityFacade } from "@modules/identity";
// This adapter wraps the direct in-memory call to the Identity module
export class LocalIdentityAdapter implements IdentityPort {
constructor(private readonly identityFacade: IdentityFacade) {}
async getUserProfile(userId: string): Promise<IdentityUserDTO> {
// Direct in-memory execution. Safe, fast, and networkless.
return this.identityFacade.getUserProfile(userId);
}
}- Use the adapter where needed in the Order module, for example:
typescript
// modules/ordering/services/checkout.service.ts
import { IdentityPort } from "@shared/contracts";
export class CheckoutService {
// Injected via abstract Port contract, NOT the concrete Identity class
constructor(private readonly identityPort: IdentityPort) {}
async executeOrder(userId: string, cartItems: any[]): Promise<void> {
// Synchronously fetches profile data across module boundaries safely
const userProfile = await this.identityPort.getUserProfile(userId);
if (!userProfile.isMfaEnabled) {
throw new Error(
"Security policy violation: MFA must be enabled to check out.",
);
}
// Continue with ordering domain logic using 'order_users' collection...
console.log(`Processing order for user: ${userProfile.email}`);
}
}The core business logic remains fully decoupled, and when the time comes and we need to extract modules into microservices, all we need to refactor is:
- In the Identity module, we introduce a controller that can call the facade and expose it via HTTP or gRPC:
typescript
// apps/identity-service/src/controllers/user.controller.ts
import { Request, Response } from "express";
import { IdentityFacade } from "@lib/identity";
export class UserController {
constructor(private readonly identityFacade: IdentityFacade) {}
async handleGetUserProfile(req: Request, res: Response): Promise<Response> {
try {
const userId = req.params.id;
// The HTTP layer delegates directly to the existing, trusted facade
const userProfileDto = await this.identityFacade.getUserProfile(userId);
return res.status(200).json(userProfileDto);
} catch (error) {
const message =
error instanceof Error ? error.message : "Internal server error";
return res.status(500).json({ message });
}
}
}- In the Order module, we need to swap the local adapter with a remote adapter, for example an HTTP adapter:
typescript
// apps/ordering-service/src/adapters/http-identity.adapter.ts
import { IdentityPort, IdentityUserDTO } from "@shared/contracts";
import axios from "axios";
export class HttpIdentityAdapter implements IdentityPort {
// points to the newly created deployment endpoint of the identity app
private readonly identityServiceUrl = process.env.IDENTITY_SERVICE_URL;
async getUserProfile(userId: string): Promise<IdentityUserDTO> {
const response = await axios.get<IdentityUserDTO>(
`${this.identityServiceUrl}/api/v1/users/${userId}`,
);
return response.data;
}
}Using estabilished architecure patterns
The previous example of communication led us to this point, where we need to talk about how established patterns can help us build a future-proof modular monolith. To make our modules easy to convert into microservices, we need to have a way to completely isolate business logic from infrastructure, as we know that it should be the only concern in this case, because a modular monolith consists of logically structured modules within a single deployable application, whereas microservices are a system of independently deployable services - from this we know that at the stage of migration to microservices, infrastructure should be the only thing that changes.
Imagine we have a use case that manages orders and heavily modifies data in the database, and we need to move from the monolith to a separate microservice. In the best-case scenario, we would like to change only infrastructure-related code, with minimal effort and maximum safety for the business code, being confident that no critical bugs were introduced. When the team is confident, it will move faster when migrating to microservices.
Tests
I mentioned confidence in the previous paragraph - with tests, you would have another pillar of confidence when doing migration, but only if your tests are well structured and separated. In tests also, we should not mix business logic with infrastructure code, and we should ensure smoke tests that cover the enitre microservice components setup, so that after migration we can be confident that the new infrastructure works as expected and catch failures early.
When moving to microservices, the rules for tests are similar to those for production code. Tests for use cases and business logic should mostly pass unchanged; in most cases, the tests that need changes are the ones tied to infrastructure. If those two things are achieved after migration, you have a strong sign that the migration was successful.
Wrap up
These days, we talk a lot about building fast by throwing PRDs into prompts. But we talk less about scaling those applications. Remember: garbage in, garbage out. If you don't care about your app's architecture and you're thinking about a project that is built to scale, sooner or later poor architecture makes it much more likely that both AI tools and developers will struggle, producing more unusable slop, and your feature team will struggle to understand the business logic, as it will be blended with everything else in your codebase. Setting up architecture from day one costs very little compared with cleaning up architectural debt later; even if you don't decide to move to microservices in the future, you give yourself a much better chance of ending up with a technically reliable and scalable product that solves your business problem, or the business problem of the company you're working for, instead of introducing blockers and bugs.
