Modular monolith vs microservices should follow the evidence
Use an evidence-based modular monolith vs microservices decision across team ownership, deployments, tenant isolation, operations, and migration cost.

An AI-enabled SaaS product expecting rapid growth should usually begin as a modular monolith, with one deliberate exception: isolate workloads that have a proven need for a different scaling, security, or failure model. Expected growth is not evidence that every business capability needs its own process. It is evidence that the code needs boundaries you can measure and later move.
I have seen teams split an unfinished domain into services because a forecast looked steep. Six months later, every feature crossed four APIs, releases still moved together, and one engineer understood the message choreography. I have also seen a single deployable become a brake because one inference worker consumed all available compute and its release cadence had nothing to do with billing. The architecture label did not decide either outcome. Ownership, runtime behavior, and operational readiness did.
A useful decision compares the cost of coordination inside one process with the cost of coordination across a network. The first cost appears in code reviews, shared releases, and database contention. The second appears in contracts, queues, retries, traces, incident response, and duplicated infrastructure. Choose services only when an identified boundary earns back that second bill.
Rapid growth is not one workload
Rapid customer growth does not tell you which part of the system will strain first. A SaaS product can add tenants quickly while its API remains ordinary and its background AI jobs consume most of the compute. It can have modest traffic while enterprise onboarding creates sharp data-isolation requirements. It can acquire users quickly but change one pricing workflow ten times more often than everything else. Those are different pressures, and each points to a different boundary.
Start with a load model, even when the numbers are estimates. Separate interactive requests, scheduled jobs, inference calls, ingestion, search indexing, notifications, and reporting. For each workload, record its unit of demand, acceptable latency, concurrency limit, retry behavior, data touched, and failure effect. A request measured in page views behaves differently from a document-processing job measured in tokens and minutes. Scaling both behind the same process may waste capacity, but that still does not require splitting the whole domain.
The distinction teams often blur is logical modularity versus physical distribution. A module owns a business vocabulary and an internal API. A service adds a network and an independent runtime to that boundary. You need the first before the second. Without it, microservices reproduce the same coupling across slower calls and harder deployments.
Microsoft's guidance on common web application architectures makes a sensible qualification that gets lost in architecture debates: if the whole application can scale by cloning an instance, separate services may add little benefit. The same guidance says natural functional boundaries may not be clear during an early product phase. I agree, but I would add a test. Uncertainty should change your code structure now, not force a distributed topology now. Put uncertain capabilities behind module interfaces, keep their data access explicit, and collect the evidence that could justify extraction.
A modular monolith is not a folder named modules around a shared ball of code. It needs enforceable import rules, ownership of tables or schemas, and no backdoor calls into another module's internals. If any controller can query any table, the deployment is monolithic and the design is unstructured. That design is expensive to split because dependencies remain invisible until migration.
Team ownership sets the practical limit
Team structure usually sets the first useful limit on service count. A small product group cannot give ten services ten independent owners. It gives one group ten repositories, ten pipelines, and ten alert sources. The same people still coordinate every change, so the supposed autonomy exists only in diagrams.
For one product team, a modular monolith keeps the feedback loop short. A developer can change a domain rule, its transaction, and the user-facing behavior in one branch and one test run. Code ownership can still be strict. The billing module can reject imports from workspace or AI orchestration, and reviewers for billing can approve changes to its public interface. Independence begins with decision rights and boundaries, not repositories.
Several stable teams change the equation. A service becomes plausible when one team owns a business capability, can operate it, and rarely needs synchronized edits in another team's code. The service should have one clear on-call owner, its own release decision, and a contract other teams consume. Shared ownership is not ownership. If every incident opens a group chat with representatives from five teams, the boundary has not reduced coordination.
Use three questions during planning. Who approves a contract change? Who receives the alert at night? Who can deploy a fix without waiting for another group? If the answers name different committees or nobody, keep the boundary in process until the organization can support it. This is not a ban on early extraction. A two-person team may isolate an inference worker because its runtime is radically different, but they should admit that they still operate one product system.
Distributed development adds another wrinkle. Time zones can make module ownership useful because teams need fewer conflicting edits, yet network boundaries do not automatically solve communication. SaaS Production oversees engineers in Kazakhstan and Eastern Europe as well as California, so we treat written contracts, clear ownership, and review rules as engineering work. That discipline benefits a modular monolith immediately and makes later service extraction much less dramatic.
The popular recommendation I argue against is one service per small team. It sounds tidy because the architecture chart mirrors the org chart. It fails when teams change, when a capability needs several skills, or when a thin service exists only to justify a box. Let stable business boundaries influence team ownership, and let team ownership support independent operation. Do not turn a temporary staffing plan into a permanent network.
Deployment independence has to pay for itself
Independent deployment matters only when teams actually release independently. If a change to service A requires service B to deploy first, a coordinated database migration, and a shared acceptance window, you have a distributed monolith. It carries the failure modes of a network without buying release autonomy.
Measure the need before extracting anything. Review the last twenty production changes for the candidate module. Count how many changed only that module, how many required coordinated edits elsewhere, how often the whole application release delayed them, and how often a module-specific rollback would have reduced harm. A boundary earns a service when independent changes occur often enough that shared deployment is a recurring constraint, not an imagined future inconvenience.
Compatibility is the hard part. An independently deployed service must tolerate callers on older and newer contract versions during rollout. Add fields instead of changing their meaning. Accept both forms while consumers migrate. Keep database changes compatible with the previous application version until rollback is no longer needed. A pipeline per service does not create deployment independence if contracts demand lockstep.
Kubernetes documentation shows how a Deployment can perform a rolling update, but rolling containers solve only the runtime replacement. They do not make an incompatible API safe. They do not coordinate an event producer with old consumers, and they do not reverse a destructive schema change. Teams sometimes buy an orchestrator and mistake its rollout controller for an architecture.
A monolith can also improve release independence without becoming distributed. Feature flags can separate release from exposure. Module-specific test suites can shorten feedback. A clear internal interface limits the blast radius of change. You can package one application image, run multiple instances, and use a worker entry point from the same codebase. Those moves are cheap and reversible.
Extract when the deployment record shows a repeated conflict: one capability changes frequently, carries a distinct rollback risk, or must run a version that the rest of the application cannot take. Keep it internal when releases remain coordinated for good business reasons. One pipeline is often an advantage while a team is still discovering how the product works.
Tenant isolation starts with data access
Tenant isolation is primarily a data-access and authorization property, not a service-count property. A hundred services can all leak tenant data if each trusts an unvalidated tenant identifier. One process can provide strong logical isolation if every access path enforces tenant context and tests cross-tenant denial.
Decide which isolation level the product and contracts require. Shared tables with a tenant column give efficient density but demand consistent filtering. Separate schemas reduce accidental joins and simplify some exports, while increasing migration work. Separate databases create a stronger operational boundary and let teams restore or relocate one tenant, but they raise connection, upgrade, and fleet-management costs. Dedicated deployments go further when a tenant needs isolated compute or release timing. None of these choices requires every business capability to become a microservice.
PostgreSQL's row security documentation says policies can restrict which rows normal queries return or modify. When row security is enabled and no applicable policy exists, PostgreSQL applies default deny. That is useful defense in depth, but there is a sharp caveat: table owners normally bypass row security. An application that connects as the owner may believe a policy protects it while every query still sees every row.
This fragment shows the shape of an enforceable shared-table setup. The application sets tenant context at the transaction boundary, and the policy checks both reads and writes:
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_documents ON documents
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
BEGIN;
SET LOCAL app.tenant_id = '8bd3659e-64f6-4f0d-92c7-49e79ad86a2b';
SELECT id, status FROM documents;
COMMIT;
Run a negative integration test with two tenants. Insert one document for each, set the first tenant's context, and assert that selecting the second document returns zero rows. Then try an update that changes the first document's tenant ID and assert that the database rejects it. Run the test through the same database role used in production. A policy tested as an administrator proves very little.
Service extraction becomes justified when an isolation boundary needs separate credentials, data stores, encryption administration, residency, restore procedures, or operator access. For example, a healthcare-related subsystem may warrant its own operational boundary because access and change controls differ from a public marketing workflow. Splitting tenant service, user service, and preferences service does not by itself isolate tenants. It merely gives a tenant-context bug more places to hide.
AI execution often deserves the first split
AI execution is often the first useful runtime boundary because its resource and failure behavior differs from the transactional SaaS core. Model calls can take much longer than normal database requests, providers enforce concurrency and rate limits, inputs vary greatly in size, and customers can abandon a job while compute continues. Keeping that work inside a web request ties user latency to an external dependency and makes capacity planning murky.
The first split does not need to be a fleet of business microservices. A web application can validate access, create a job record, and commit an outbox event in one transaction. A worker process can claim the job, call the model provider, store progress, and publish completion. The worker may use the same repository and release artifact at first. The queue creates an execution boundary, while modules preserve domain ownership.
Design the job protocol before choosing deployment topology. Each job needs an immutable tenant ID, actor ID, input reference, model policy, attempt number, and idempotency key. Store large prompts or documents in controlled storage rather than stuffing them into queue messages. A worker must verify tenant scope again; it should not assume that a message is authorized because it came from an internal queue.
Retries deserve explicit rules. Retry timeouts, temporary provider errors, and rate limits with bounded backoff. Do not blindly retry invalid prompts, authorization failures, or jobs that exceed a defined limit. Mark every attempt so an operator can distinguish a slow first run from the fifth execution of the same expensive request. If a provider times out after accepting work, an idempotency strategy must decide whether a retry can duplicate effects.
Human review is a workflow state, not a comment attached to model output. Store the generated proposal, review decision, reviewer identity, timestamps, and the version that was approved. A reviewer should approve a stable artifact, not a value that the system can silently regenerate after approval. This makes Human-in-a-Loop behavior testable and gives product teams a clear seam around high-impact actions.
Extract the AI worker into its own service when it needs separate scaling, a different language or dependency stack, stricter network egress, provider-specific credentials, or an independent release cadence. Keep orchestration rules in the product domain unless another team truly owns them. Otherwise the worker becomes a remote utility that knows too much about every feature and turns into a new monolith across the network.
Observability grows at every boundary
Microservices do not create observability. They create more events that must be correlated. In a single process, a stack trace and request log may show the complete path. Across an API, queue, worker, and callback, the same failure can appear as four partial successes unless the system carries context through every hop.
OpenTelemetry defines traces, metrics, logs, and baggage as distinct signals. That distinction matters. Metrics can show that job latency rose. Traces can show where sampled requests spent time. Logs can record a specific provider response or state transition. None substitutes for the others, and collecting all of them without a question in mind produces an expensive pile.
Instrument the modular monolith before extracting services. Give each module a stable name in spans and logs. Record request IDs, tenant-safe identifiers, job IDs, deployment versions, result states, and durations. Never put prompts, health data, access tokens, or unrestricted model output into telemetry. The W3C Trace Context specification standardizes the traceparent and tracestate headers for carrying trace identity between systems, and it explicitly warns against placing personally identifiable or sensitive information in them.
A practical readiness test is to trace one user action through its full lifecycle. Start with an HTTP request that creates an AI job, follow the database commit and outbox publication, continue through the worker attempt, and finish at the stored result. An operator should answer four questions without searching by hand across unrelated consoles: which tenant action started the work, which version handled each stage, where time accumulated, and which state can safely resume.
Queues add a common trap. A producer span ending successfully means only that the message was accepted. It does not mean the job succeeded. Carry trace context in message metadata, create a consumer span for each attempt, and link retries without pretending they are one continuous network call. Record queue age separately from execution time. Otherwise a dashboard can blame the model for ten minutes that a job spent waiting for capacity.
The operational cost of a new service includes alerts, dashboards, service-level objectives, runbooks, log retention rules, sampling policy, and an owner who can interpret them. If the candidate boundary cannot state its user-visible success measure, it is not ready to become a service. Distribution magnifies ambiguity.
Data ownership exposes the hidden bill
A genuine microservice owns its data and does not invite other services to query its tables. Microsoft's microservices guidance calls this data sovereignty and notes the consequence: a business process spanning services cannot rely on one database transaction, so teams must handle eventual consistency. That is the part many architecture diagrams omit.
Consider subscription activation. In one database transaction, the application might create the subscription, assign entitlements, write an invoice record, and enqueue onboarding work through an outbox. Split those capabilities into services and a timeout can occur after billing accepts the request but before entitlement confirms it. The caller cannot tell whether to retry, compensate, or wait unless the protocol defines idempotency and status lookup.
A queue does not settle the business semantics. The team must decide which state is authoritative, how duplicates behave, how long intermediate states may remain, which failures trigger compensation, and what an operator can repair. Every event schema becomes a compatibility promise. Every replicated field can become stale. This work can be correct and worthwhile, but it is not free scale.
Shared databases between services are a transitional choice, not full independence. They can reduce migration risk, yet a schema change can still coordinate releases and one service can bypass another's rules. If two components require transactional consistency on most writes, that is evidence that they may belong in one boundary. Do not replace a reliable local transaction with a distributed workflow merely to satisfy a diagram.
The outbox pattern is a useful bridge. Write the domain change and an event record in the same local transaction, then let a publisher deliver the event with retries. Consumers still need idempotency because delivery can repeat. The pattern closes the gap between commit and publish, but it does not guarantee that every downstream action finishes or that events arrive only once.
Before extraction, write the failure table. For every remote step, record what happens when the request times out before acceptance, after acceptance, during response, and during retry. Name the reconciliation action and the person or automated process that performs it. If the table feels disproportionate to the business capability, the network boundary probably is too.
Migration cost depends on the seams you build now
The cheapest migration starts before any service extraction. A modular monolith can make dependencies visible, assign data ownership, and define contracts while calls are still local. Those seams let a team test the domain boundary without also debugging networks, deployment, and consistency on the same day.
Begin with dependency enforcement. Each module exposes a small public interface, and build rules reject imports into its internal packages. Give each table one owning module. Other modules request behavior through the interface instead of joining owned tables. Publish domain events inside the process when several modules need a fact, but keep the event contract explicit and versioned. These rules create a map of coupling.
Then collect boundary evidence. A candidate becomes stronger when it has high change frequency, distinct scaling, a different security posture, a separate owner, or repeated release conflicts. It becomes weaker when most features require synchronized edits across it, its data participates in constant cross-module transactions, or its interface mostly mirrors database CRUD. A CRUD-shaped service often moves records without owning a business decision.
AWS Prescriptive Guidance describes the strangler fig pattern as incremental replacement through routing and an anti-corruption layer. That approach is safer than a rewrite, but the proxy is not the hard part. Data authority is. During extraction, choose one writer for each record type, prevent dual writes where possible, and make the old module call an adapter that can switch between local and remote implementations.
A controlled extraction follows this sequence:
- Freeze the module's public contract and add consumer tests around actual behavior.
- Move its data access behind the module interface and remove direct cross-module queries.
- Run the new implementation in shadow mode for read-only comparisons, with sensitive fields excluded from comparison logs.
- Route a small, reversible slice through the adapter and keep one system authoritative for writes.
- Remove the local implementation only after rollback, reconciliation, and on-call procedures have worked in production.
Do not start with identity, shared authorization, or a workflow that touches every module. Choose a boundary with clear inputs, observable outcomes, and tolerable temporary inconsistency. AI document processing, media conversion, notification delivery, and search indexing often fit. Billing might fit only after the product model stabilizes. The best first extraction teaches the team how to operate a service without putting the whole company behind the lesson.
Migration cost includes more than engineering time. Count the period of duplicate infrastructure, contract support, data reconciliation, extra testing, training, and slower feature work. Also count the cost of not migrating: delayed releases, wasteful scaling, recurring contention, and incident blast radius. A credible decision compares both totals over a stated period instead of treating microservices as the inevitable destination.
A service boundary needs five proofs
Choose the modular monolith by default, then require evidence for each physical split. This is not conservative architecture. It is a way to spend complexity where it changes an outcome.
Score a candidate boundary against five proofs. First, one team can own its contract, deployment, alerts, and incidents. Second, it needs releases that are often independent in practice. Third, its load or runtime differs enough that separate scaling saves capacity or protects latency. Fourth, its data and failure semantics can survive a network boundary without constant distributed transactions. Fifth, the organization can observe and recover the resulting workflow.
Treat tenant isolation as a separate decision axis. A service may deserve its own database or deployment because a contract requires isolation, even when its traffic is low. Another high-traffic component may stay inside the monolith because stateless horizontal scaling works and its data belongs with the core transaction. Growth does not make every answer the same.
Set review triggers instead of predicting a final architecture. Revisit a module when release coordination repeatedly delays it, when its resource curve diverges, when another team assumes stable ownership, when tenant obligations change, or when incidents show that a shared process expands the blast radius. Bring deployment history, traces, capacity data, and failure reports to that review.
The decision can also go backward. If two services always deploy together, share one owner, and spend most requests calling each other, merging them may remove failure modes without losing autonomy. Architecture should respond to evidence in both directions.
For a young AI SaaS product, I would ship a strongly modular core, a durable job queue, and separate worker execution where AI workloads demand it. I would invest early in tenant-context enforcement, outbox delivery, trace propagation, and module dependency tests. I would not create a service for every noun in the product vocabulary.
Rapid growth rewards a system that can change its shape. Clean in-process boundaries preserve that option at low cost. When one boundary can prove distinct ownership, deployment, scaling, data semantics, and recovery, move it across the network with confidence. Until then, the network is overhead you have not earned.
Frequently Asked Questions
Is a modular monolith suitable for a fast-growing SaaS product?
Yes, if the application can scale horizontally and its modules have enforced boundaries. Growth alone does not require microservices; a measured difference in ownership, deployment, runtime, or isolation does.
How large should a team be before adopting microservices?
There is no magic headcount. Adopt a service when a stable team can own its contract, releases, alerts, and incidents without routine coordination with the rest of the organization.
Can a modular monolith deploy different modules independently?
Not as separate runtime units, but feature flags and module-specific tests can separate release timing from customer exposure. If one module repeatedly needs its own rollback and release cadence, that history supports extraction.
Do microservices provide better tenant isolation?
Only when the service boundary includes separate credentials, data, compute, or operator access. Splitting code into services does not fix an application that accepts untrusted tenant IDs or skips authorization checks.
Should AI inference run inside the main SaaS application?
Short, predictable calls can start there, but long or variable work should move behind a durable job boundary. A separate worker protects web latency and lets the team scale compute without decomposing the entire product.
What observability is needed before splitting a service?
Trace a user action across requests, queues, workers, and stored outcomes. Operators need deployment versions, safe tenant context, job state, timing, and retry information before a network boundary multiplies failure points.
Is sharing one database between microservices acceptable?
It can be a temporary migration step, but it limits independent schema changes and lets services bypass each other's rules. Long-term autonomy requires explicit data ownership, even if several stores use the same database server.
What is the safest first service to extract?
Choose a capability with clear inputs, observable outputs, distinct scaling, and tolerable temporary inconsistency. Background AI processing, media conversion, notifications, or indexing often teach useful operational lessons with limited transaction risk.
How can a team avoid building a distributed monolith?
Require backward-compatible contracts, independent release decisions, and one owner per service. If routine changes require synchronized deployments or cross-service database access, fix the boundary or merge it.
Can a company move from microservices back to a monolith?
Yes, and sometimes it should. Services that always deploy together, share an owner, and make constant calls between them may become simpler and safer as one well-structured deployable.