// Stay in touch?
Products
Encore CloudEncore Cloud
Encore.tsEncore.ts
Encore.goEncore.go
PricingPricing
Book a DemoBook a Demo
Use Cases
AI-Powered DevelopmentAI-Powered Development
Event-Driven SystemsEvent-Driven Systems
Distributed SystemsDistributed Systems
Case StudiesCase Studies
ShowcaseShowcase
Resources
DocsDocs
InstallInstall
Example AppsExample Apps
Demo videoDemo video
ArticlesArticles
GitHub ReleasesGitHub Releases
Systems Operational
Company
About UsAbout Us
Swag ShopSwag Shop
ContactContact
JobsJobs
PressPress
SecuritySecurity
TermsTerms
Privacy PolicyPrivacy Policy
Data Processing AgreementData Processing Agreement
Enterprise SLAEnterprise SLA
Encore
© 2026 EncoreAll rights reserved
© 2026 Encore All Rights Reserved
GitHubDiscordYouTube

Microservices vs Modular Monolith: How to Choose

What real module boundaries look like, how to keep them from rotting, and exactly what you trade against microservices

07/20/26
8 Min Read
Ivan Cernja
07/20/26

Microservices vs Modular Monolith: How to Choose

What real module boundaries look like, how to keep them from rotting, and exactly what you trade against microservices

Ivan Cernja
8 Min Read

Shopify runs one of the largest Ruby on Rails codebases in the world as a single deployable app, serving millions of merchants without splitting into hundreds of microservices. Around 2019 the code had drifted toward a big ball of mud, so instead of breaking it apart the team drew firm boundaries inside it, grouped the code into components with defined interfaces, and built tooling that fails the build when one component reaches into another's internals. The monolith stayed one thing to deploy, and it got the structure people usually go to microservices for.

That middle path is the modular monolith, and it is where a lot of teams now land when the honest answer to "microservices or not" is "we want the boundaries, not the operational bill." The two share the same idea about structure and disagree only on whether a network sits between the modules, so the real decision is about what that network buys you and what it costs.

The same set of modules drawn two ways: as internal boundaries inside one deployable app, and as separate services with a network between them.

Same domain, same boundariesorders · billing · catalog
modular monolith
module
Orders
module
Billing
module
Catalog
the difference
Split over the network
microservices
service
Orders
service
Billing
service
Catalog
The same boundaries, with and without a network between them.

What a real module boundary looks like

A module boundary is more than a folder. Grouping files into orders/, billing/, and catalog/ directories does nothing on its own, because any file in billing/ can still import any file in orders/ and read its database tables directly. That arrangement looks modular in the file tree and behaves like a tangle at runtime.

A boundary that does work has three properties. Each module exposes a small public interface, a handful of functions or types that are the only supported way in, and keeps the rest internal. Other modules depend on that interface and never on the internals behind it, so billing calls orders.getOrder(id) and never touches the orders tables or helper functions. And the dependencies point in one direction that matches how the domain flows, so you do not get orders and billing importing each other in a cycle that makes both impossible to change alone.

Data is where boundaries most often leak. If billing reads the orders tables directly, the two modules are welded together no matter how clean the code looks, because you cannot change the orders schema without breaking billing. A module owns its data and lets other modules reach it only through its interface, which is the same rule microservices enforce with a database per service, applied inside one process instead.

How to keep it from rotting into a big ball of mud

The reason people distrust modular monoliths is that boundaries kept by convention decay. Someone under deadline imports another module's internal helper because it is right there and the build stays green, the next person copies the pattern, and after a year the boundaries live only on a diagram. Microservices avoid this by accident, since the network makes reaching into another service's internals hard enough that people define a real interface. A modular monolith has to make the boundary fail the build on its own, because nothing physical stops a bad import.

The mechanisms that do this, roughly in order of strength:

  • Language visibility. Package-private or module-private declarations that keep internals unreachable from outside the module. This is the strongest check because the compiler enforces it, though most languages only give you package-level, not module-level, visibility.
  • Architecture tests. A test that reads the dependency graph and fails when an import crosses a boundary the wrong way. ArchUnit for the JVM, import-linter for Python, and dependency-cruiser or ESLint boundary rules for TypeScript all do this, and they run in CI like any other test.
  • One database schema per module. Give each module its own schema or table prefix and forbid cross-module table access in the same way you forbid cross-module code imports, so the data boundary is as real as the code boundary.

The common thread is that a bad import produces a red build, not a code-review comment someone might miss. A boundary nobody can violate silently is the difference between a modular monolith that holds its shape for years and one that is a monolith with extra folders.

What you keep and what you give up versus microservices

Against microservices, the modular monolith keeps the boundaries and gives up the independence between them. That trade is worth being precise about, because most of the pain teams blame on "the monolith" comes from missing boundaries, and most of the pain they blame on "microservices" comes from the network, and the modular monolith sits exactly between the two.

DimensionModular monolithMicroservices
Module boundariesFirm, enforced in codeFirm, enforced by the network
DeploymentOne unit, one pipelineIndependent per service
ScalingWhole app togetherPer service
Cross-module callsIn-process function callsNetwork calls (HTTP, gRPC, queue)
Failure between modulesSame process, no partial failureNetwork can fail; needs retries, timeouts
DataSchema per module, one databaseDatabase per service
Team workflowShared deploy, clear ownershipTeams deploy independently
Operational loadOne app to run and observeDiscovery, per-service observability
Local developmentRun one appRun or mock several services
AI agent contextWhole system in one codebaseContext split across boundaries
Refactoring across boundariesOne atomic change, compiler-checkedCoordinated deploys, versioned contracts

Refactoring across a boundary is where the trade bites hardest. In a modular monolith, moving a responsibility from one module to another is a single change the compiler checks end to end, and it ships in one deploy. Once a network sits between those modules, the same move becomes a versioned contract change, a coordinated rollout, and a window where old and new callers both have to work. That is the cost of independent deploys, and you pay it on every cross-service change whether or not you needed the independence that day.

You give up independent scaling and independent failure domains, which matter once a real difference in scaling profile or reliability appears. Until then they are capabilities you are running infrastructure to have, not to use.

Where Encore fits

The usual objection to starting with one codebase is that extracting a service later means a rewrite, so teams reach for microservices early to avoid the migration. Encore removes that pressure by letting the boundaries be real from the start without the network. You write several services in one codebase, and calls between them are ordinary function calls that are type-checked at compile time, so a call that reaches past a service's interface fails the build the same way a violated module boundary should.

// billing/billing.ts import { api } from "encore.dev/api"; import { orders } from "~encore/clients"; export const charge = api( { method: "POST", path: "/billing/charge", expose: true }, async (p: ChargeParams): Promise<Receipt> => { // Crossing into another service is a type-checked call to its // interface, never a reach into its tables or internals. const order = await orders.get({ id: p.orderId }); return chargeCard(order.total, p.card); }, );

Because the call is a typed interface rather than a raw HTTP request, the codebase reads and refactors like a modular monolith, and an AI agent can see the whole system at once. When one service does earn its own scaling or isolation, the boundary is already there, and Encore provisions each service's infrastructure, its Postgres database, queues, and the rest, into your own AWS or GCP account and deploys the services together. You get the enforced boundaries of a modular monolith with a path to real service separation that does not start with drawing the seams from scratch. The broader tradeoff is covered in Monolith vs Microservices, and the monorepo piece goes into how the single codebase is laid out.

Related reading

  • Monolith vs Microservices: How to Choose in 2026
  • Microservices in a Monorepo
  • When to Break a Monolith into Microservices
  • Microservices Best Practices
  • How to Build Microservices with TypeScript

Frequently asked questions

What is the difference between a modular monolith and microservices?

A modular monolith is one deployable app split into modules with firm boundaries and clear interfaces, so the code is organized like separate services but runs and deploys as a single unit. Microservices take those same boundaries and put a network between them, so each service deploys, scales, and fails on its own. The modular monolith keeps the boundaries and drops the network calls and the separate deploy pipelines.

Is a modular monolith better than microservices?

For most teams it is the better starting point, because you get clear module boundaries without paying for service discovery, per-service deploys, and distributed debugging. Microservices are better once a part of the system needs to scale on its own, a team is blocked on a shared deploy, or a component needs isolation. The modular monolith is designed so you can extract exactly that piece later along a boundary that already exists.

How do you enforce module boundaries in a modular monolith?

You make crossing a boundary the wrong way fail the build rather than relying on discipline. Each module exposes a small public interface and keeps everything else internal, and a dependency check rejects any import that reaches past that interface or points the wrong direction. Language visibility, package structure, and an architecture-test tool like ArchUnit or import-linter all do this. Without an enforced rule the boundaries decay into a big ball of mud.

What is a big ball of mud?

A big ball of mud is a codebase with no real internal structure, where any part reaches into any other and boundaries exist only on a diagram. It is the failure mode a modular monolith is built to avoid. It happens when module boundaries are a convention nobody enforces, so over time modules import each other's internals directly and the system becomes one tangled unit that is hard to change safely and impossible to split.

Can you migrate from a modular monolith to microservices?

Yes, and that is much of the point of the pattern. When a module has a firm boundary and talks to the rest of the system only through its interface, extracting it into its own service is mostly a matter of turning those in-process calls into network calls. The hard part, deciding where the seams go, is already done. A tangled monolith with no boundaries is far harder to split, which is why the modular structure matters before you ever need to divide it.

Do modular monoliths scale?

They scale well for a large share of systems. Shopify runs an enormous business on a modular Rails codebase without splitting into hundreds of services. You scale a modular monolith by running more copies of the whole app behind a load balancer, which works until one module has a scaling profile so different from the rest that scaling everything to serve it is wasteful. At that point you extract that module, and the rest stays a modular monolith.

Contents
What a real module boundary looks like
How to keep it from rotting into a big ball of mud
What you keep and what you give up versus microservices
Where Encore fits
Related reading
Encore

The backend platform for humans and agents

Build backends in Go or TypeScript and run them on your own AWS or GCP, with the guardrails your team and AI agents need to ship safely.

Get started

Ready to build your next backend?

Encore is the Open Source framework for building robust type-safe distributed systems with declarative infrastructure.