If every change to your SaaS codebase takes longer than the last one, and there is one file you avoid opening, you probably need a modular monolith architecture rather than microservices. Your first action takes ten minutes: pick the module you are most afraid to touch and list every other part of the code that imports it. That list is the boundary you don't have yet. The rest of this post is about drawing it inside the one deployable you already run.
A modular monolith keeps one build and one deploy, but enforces real boundaries between modules. Martin Fowler's MonolithFirst note from 2015 argues that you shouldn't start a new project with microservices, and that any refactoring of functionality between services is much harder than in a monolith. Fowler also says he didn't have enough anecdotes at the time to be firm about the rule, so treat it as a strong heuristic, not a law. The SaaS MVP stack I recommend for a greenfield build starts as one deployable for the same reason.
When does a SaaS codebase actually need module boundaries?

A SaaS codebase needs module boundaries once two unrelated features can no longer change independently without both breaking. Team size, line count and deploy frequency are proxies for that one symptom.
Milan Jovanović lists warning signs in his guide to overgrown bounded contexts from April 2025: fear of modifying code because everything is interconnected, single entities that serve several unrelated purposes, classes over 1,000 lines, and business logic from different subdomains bleeding into each other. The 1,000-line figure is his rule of thumb, not a measured threshold. If three of those four describe your code, boundaries are the next piece of work.
Callidus, the multi-tenant clinic SaaS for UK aesthetic clinics that I built on React, Firebase and Stripe Connect, shows the smallest version of a boundary. The platform has six roles: super admin, owner, admin, manager, practitioner and receptionist, each with different access to clinical data, financial data, settings and billing. The case study explains that inline role checks rot the moment the matrix changes, so the codebase is built around role helpers (hasClinicalAccess, hasManagerAccess, hasAdminAccess) and every access gate stays consistent. That is a module boundary in the sense that matters here: one place owns the rule, and the rest of the code asks it. It has nothing to do with how many servers the app runs on.
How do you find the bounded contexts hiding in a monolith?

Find bounded contexts by grouping the code that changes together, then test each group with vocabulary: if different teams use different words for what looks like the same entity, you have found a seam.
Jovanović's vocabulary test asks whether teams use different vocabulary for each area and whether you would give each domain to a different team. If billing calls something a subscription and support calls it an account, treat that as a boundary and not as a naming inconsistency. The bounded-context idea comes from domain-driven design, and the search term most people use for it is bounded context in microservices, but the same test works before any service exists. When the boundary is unclear, keep the module coarser and split later. A wrong split is expensive to undo.
Which module do you extract first?
Start with modules that are pure side effects. Jovanović's example is notifications: sending one is a pure side effect, it doesn't change business state, and so it is easier to decouple safely. Replace each direct call into the module with a domain event, so the calling code only announces that something happened. Core business logic, which every other module depends on, comes last, after you have practised extraction on the cheap cases. His steps, in order, are to identify subdomains by what changes together, extract one context at a time starting with low risk, replace direct calls with events, migrate data gradually, and repeat. His summary line is that boundaries should be enforced by code, not just folder structure.
Why must data boundaries follow code boundaries?
A module that owns its code but shares tables with everyone is only half separated. In his data boundaries article from August 2025, Jovanović recommends a schema per module and a dedicated database role whose privileges cover only that schema. Cross-module reads go through read-only views or read models built from events, not through joins across schemas. His code samples use EF Core, but the schema and role mechanism is plain PostgreSQL, so the idea carries to other stacks.

This is also why the modular monolith vs microservices debate often misses the point. If you split code into services that still read and write the same tables, you pay for network calls and partial failures but still can't deploy one piece alone, because a schema change touches all of them. That is my reasoning from how shared data works, consistent with Fowler's point that refactoring across services is harder than inside a monolith. Fix the data boundary first and the deployment question gets smaller.
What should the public surface of a module look like?
Jovanović's definition of a modular monolith from March 2024 is a single application organized into independent modules, each grouping related functionality behind a clear boundary, loosely coupled and communicating through public APIs, and still deployed as a single unit. In practice that means each module exposes a small entry point and keeps everything else private. In a TypeScript codebase, the entry point can be one index file per module, and the lint rule from the next section can forbid imports that reach past it:
// modules/notifications/index.ts (the only file other modules may import)
export { sendNotification } from "./public/sendNotification";
export type { NotificationRequested } from "./public/events";
Keep that surface small on purpose, and review it like any public API, because a change to it affects every caller in the repository. Every function you export is a promise to the rest of the codebase, and a short list of promises is easier to keep.
Do you need Nx or Turborepo for a modular monolith?
No. Plain workspaces plus an import rule in CI are enough to start, and a build tool becomes worth adding when build times or dependency-graph complexity actually hurt.
The tools do different jobs, and one claim in older advice no longer holds. Turborepo's caching docs describe caching so you never do the same work twice, and remote caching that shares results across machines and CI. Its boundaries feature checks two violations: importing a file outside a package's directory and importing a package that isn't declared as a dependency. It also supports tags, and the docs mark it experimental. Nx's module boundaries feature works through project tags and declared constraints, enforced by the @nx/enforce-module-boundaries ESLint rule or by a conformance plugin.
| Option | What it gives you | Boundary enforcement | Watch out for |
|---|---|---|---|
| npm or pnpm workspaces plus a lint rule | No extra tooling, one install | Whatever rule you write, for example a cross-module import check in CI | No task caching |
| Turborepo | Task caching, remote caching | Experimental boundaries check, with tags | Feature status: confirm it is still experimental before relying on it |
| Nx | Project graph, generators, caching | Tag constraints through @nx/enforce-module-boundaries | More concepts to learn than a small team may need |
Buying the tool before the discipline gets the order wrong. Boundaries come from deciding what each module owns and then failing the build when someone breaks that. The tool only makes the check cheaper to run.
How do you extract a module without a rewrite?
Extract one module at a time behind events, and let each step ship on its own. No step needs a feature freeze.
- Pick the lowest-risk module, usually a pure side effect such as notifications or audit logging.
- Give it its own schema or table prefix, with a database role that only it can write through.
- Replace every direct call into it with a domain event.
- Add a CI check, either a lint rule or an architecture test, that fails the build on a cross-module import. A boundary without a failing check is a convention.
- Repeat with the next-lowest-risk module. Leave the modules everything else depends on, often billing or the core entity model, for last.
- Consider a build tool or a service split only when build time or ownership friction is the bottleneck, not code tidiness.
Consider a team three sprints into this work that removes a direct import between two modules and watches an unrelated build break. That is a hypothetical, but a plausible shape: a third file depended on the internals of both. Such breaks are the boundary work surfacing coupling nobody had written down, and they are the reason to go module by module. Order matters too: pick modules that can fail loudly during the transition without touching a paying customer's data. A failed notification is an annoyance. A failed multi-tenant billing module mid-extraction is a support queue.
If you are still choosing between a no-code stack and writing the code yourself, or you are signing your first B2B SaaS customers, this work is premature. You earn the problem by having enough customers that the code tangled.
Open your repository now, run a search for imports of the module you fear most, and write down how many distinct callers it has. Which one of them could switch to an event first?
