Full Scale named to the Inc. 5000 for the 5th time

Modular Monolith vs Microservices: Start Boring, Split When It Hurts

Matt Watson
Matt Watson
October 27, 202613 min read
Modular monolith vs microservices: microservices from day one versus a modular monolith first
In this article

Quick Answer: For the modular monolith vs microservices question, most teams should build a modular monolith: one deployable application split into modules that each own their code and their data. Microservices make sense when one module has a concrete reason to run on its own. Think scaling needs that look nothing like the rest of the system, or a team blocked by everyone else's releases. At Full Scale, a modular monolith is our default until a real wall shows up.

I once defined microservices on LinkedIn as "creating enough small problems to avoid one big one." I still think that's about right.

The modular monolith is how you deal with the one big problem without signing up for a hundred small ones. You keep one application and one deploy, and you build real walls inside it. I've run a company on one giant database with no walls at all, and later a product where a single ingestion API needed its own fleet of servers. The first taught me that the walls that matter most live in the database. The second showed me what a real reason to split looks like.

At Full Scale, we place backend developers into client teams, and how a codebase is split decides what each of them can own. If you're still deciding whether to break up a monolith at all, I covered that in monolith vs microservices.

What a modular monolith actually is

A modular monolith is a single deployable application divided into modules with hard boundaries. Each module owns one business area, like billing or user accounts, along with that area's code and data. Other modules can only use it through a small public interface, never by reaching into its internals or its tables. You still build and ship one thing.

That's the whole idea behind modular monolith architecture: the separation you'd get from services, without the network in between.

The difference from a regular monolith isn't the folder structure. Plenty of messy monoliths have a folder called Billing. The difference is whether anything stops the order code from querying the billing tables directly at 4:45 on a Friday. In a plain monolith, nothing does.

A group of Google engineers made the cleanest version of this argument in a 2023 paper. They wrote that microservices "conflate logical boundaries (how code is written) with physical boundaries (how code is deployed)." A modular monolith keeps the logical boundaries and skips the physical ones until you actually need them.

Plain monolithModular monolithMicroservices
How it deploysOne applicationOne applicationMany services, each on its own schedule
How the parts talkAnything can call anythingOnly through each module's public interfaceOver the network (HTTP, gRPC, queues)
Where the data livesOne shared schema, no rulesOne database, each module owns its own tables or schemaEach service owns its own database
Best fitOne small team, early productOne to a few teams, growing productMany teams blocking each other
What it costs youTangles as it growsThe discipline to keep the walls upOps work, network failures, and distributed debugging from day one

A modular monolith has real costs of its own. Every module runs in the same process, so a memory leak in the reporting code can take billing down with it. Everyone shares one build pipeline too, so one team's broken build holds up everybody's release. The test suite also gets slower as the app grows. I'll still take those problems over running a fleet of services for a team that doesn't need one.

Modular monolith vs microservices: how to choose for your situation

For most teams, the modular monolith vs microservices choice starts with one question: do you have more teams than one codebase can handle? If you don't, build the modular monolith, and pull out only the specific module that has its own reason to run separately.

You don't have to take my word for it. Ashley Davis wrote Bootstrapping Microservices, which is literally the how-to manual. I asked him on my podcast who should actually use them. His answer: microservices are "not recommended for startups at all," because small teams "don't have the problem that microservices is going to solve." He's used them on startups himself, but he knows when they'll help more than they cost. Most small teams don't have that judgment on staff yet.

Segment found that out the expensive way. By 2018 they had split their data integrations into more than 140 separate services, with "3 full-time engineers spending most of their time just keeping the system alive." Then they consolidated all of those services back into one.

Three engineers babysitting plumbing is three engineers not building anything a customer pays for. That cost never shows up on the architecture diagram.

This is how I'd make the call at Full Scale, situation by situation:

Where you areWhat I'd build
One team, still figuring out what customers will pay forOne monolith, organized into modules from the start, even if they're rough
Two or three teams in one codebase, merges getting painfulA modular monolith with enforced boundaries and a clear owner per module
One module's load looks nothing like the rest of the appExtract that one module into a service and keep the rest together
Teams waiting on each other's releases every weekMicroservices, split along team lines
Compliance requires isolating one area, like payment dataA separate service for that area only
The main reason is it'll look good on someone's résuméA modular monolith

At Full Scale, the default is a modular monolith until a module hits a wall you can name.

Segment folded more than 140 services back into one, with 3 full-time engineers just keeping them alive

The modules only stay separate if each one owns its data

A modular monolith only works if each module owns its data, which means no other module reads or writes its tables directly. Code boundaries are easy to draw and easy to sneak across. The database is where modules quietly fuse back together.

I say this a lot: "If you don't get the database design right, you never get anything right with an app." I learned it the hard way at VinSolutions. We ran that company on one giant SQL Server database that held everything. It carried us to $35 million in recurring revenue before the acquisition. None of us imagined it would get that big, so nobody designed it to come apart. Nobody ever accused us of overplanning.

Once we outgrew that one box, there was no clean seam to cut along. We bought time with read replicas and by shoving the hottest parts onto separate hardware, and every move was a slog. I wrote about the scale-in-place side of that story in refactor vs rewrite.

The lesson for this decision is a different one. Everything we couldn't pull apart lived in that one database.

If two modules share tables, you have one module with two folders.

In practice, owning the data comes down to four habits:

  • Each module gets its own schema, or at least its own tables that only that module touches. Kamil Grzybek's widely used reference project on GitHub gives every module a separate schema and allows no shared data at all.
  • Other modules ask, they don't query. They call the owning module's interface or listen for its events, and nobody joins across another module's tables. Because everything still sits in one database, a change that touches two modules can run in one transaction. You give that up the day you split them into services.
  • Reporting gets its own path. A report that needs data from five modules reads from a reporting model built for that job, so nobody's dashboard query ends up holding five modules hostage.
  • The schema moves with the module. When you extract a module later, its data goes with it. Moving the data is what made VinSolutions painful, and with this setup it's boring.

Boundaries rot unless a tool enforces them

Module boundaries that only live in a wiki page will be gone within a year. You need a check that fails the build when someone crosses one.

Shopify is the best public example. Its core app had been worked on "for over a decade by more than a thousand developers." In 2019 the team said they wanted "a solution that increased modularity without increasing the number of deployment units." A year later they were refreshingly honest about the starting point: the code was so dense that "every component depended on over half of all the other components."

Their fix was Packwerk, a tool they built and open sourced that blocks changes that break the dependency rules between components. Every major stack has something similar now:

  • Ruby on Rails: Packwerk, which is how our Ruby on Rails team keeps big Rails apps from turning into one tangled package.
  • Java and Spring Boot: Spring Modulith, which verifies the module structure and lets you test each module on its own.
  • .NET and most other stacks: architecture tests, using a library like NetArchTest or ArchUnitNET, that run in your build pipeline and fail when one module references another module's internals.

If you're starting from a tangled monolith, you don't have to fix every crossing on day one. Packwerk, for example, records the violations you already have in a to-do file and fails the build only on new ones. You stop the bleeding first, then pay the old ones down one module at a time.

Building a development team?

See how Full Scale can help you hire senior engineers in days, not months.

This matters more now that AI writes so much of the code. Ask a coding agent to add a feature and it takes the shortest path, which is usually straight into another module's tables. AI does a fine job writing the code inside a module. I trust it a lot less with backend architecture, and the boundaries are architecture. An automated check doesn't care who or what wrote the pull request.

A distributed monolith is the worst of both

A distributed monolith is a system split into services that still can't change or deploy on their own. You pay for the network and the on-call rotation, and you still have to coordinate every release.

I've said microservices are like hiding all your toys in different rooms. A distributed monolith is hiding them in different rooms with every toy still tied to the others by a string. You get all the walking and none of the tidiness.

You probably have one if:

  • Two or more services read and write the same database tables.
  • You can't deploy one service without deploying another one at the same time.
  • A normal feature regularly needs changes in three services and a coordinated release.
  • One service going down reliably takes others down with it.

The usual cause is splitting a plain monolith along lines nobody tested first. That's the real value of the modular monolith step. It's where you find out whether your boundaries are real while moving one is still a refactor instead of a migration project.

Signs you built a distributed monolith: shared tables, lockstep deploys, features spanning three services, cascading outages

When a module earns its own service

A module should become its own service when it has a concrete reason to run separately. Usually that means it scales very differently from the rest of the app, or a separate team needs to ship it on its own schedule. Even Shopify, with millions of lines of code in one application, says it's "very deliberate about when to split functionality out into separate services."

At Stackify, the part of the product that took in customers' logs and performance data was exactly that kind of module. Every customer's app was firing data at us around the clock. If ingestion fell behind, alerts went out late, and for a monitoring product, late alerts are a broken product. That piece got built to swallow data first and sort it out later, with Azure Event Hubs sitting between the firehose and everything else. It scaled nothing like the dashboards or the billing code.

That's a wall you can name. A growth chart in a pitch deck isn't one, and most products never hit the software scalability problems their teams plan for.

When you do pull a module out, keep the old version running until the new one has proven itself. We worked that way when Stackify got off WCF, and the details are in our .NET Framework migration guide. If the module already owns its data and talks through an interface, extraction is mostly two jobs: swap an in-process call for a network call, and move the schema.

The split is cheap when the module was already acting like a service.

Skip that groundwork and you end up where we were at VinSolutions, prying shared tables apart by hand.

Match the modules to the people who own them

The number of modules or services you run should follow the number of teams you have to own them. That's Conway's law in practice: systems end up mirroring the communication structure of the organization that builds them. A team of six can own a handful of modules in one codebase. Ask the same six people to run a dozen services, each with its own pipeline and its own pager, and they'll spend their week on plumbing.

Modules are also the cleanest way to add people. Say you grow the team with local hires or through staff augmentation. A module with a clear boundary is something a new engineer can own without first learning the entire system. Put them in your standups and code reviews and give them the notifications module, with a defined interface and tests at the edges. They can ship without stepping on anyone.

Drawing those boundaries takes senior judgment, though. The seams you draw in month one are the seams you'll split along in year three. Hire the cheapest developer you can find and you usually get one of two outcomes: a big ball of mud, or twelve microservices for an app with forty customers. I call that cheapshoring, and architecture is where it gets most expensive.

It's the same point that runs through my book, Product Driven: build the stuff customers notice and keep the rest as simple as you can get away with. No customer has ever renewed a contract because of your service mesh.

Key takeaways: default to a modular monolith, each module owns its data, enforce boundaries with a tool, split only at a real wall, match modules to teams

Frequently asked questions

What is a modular monolith?

A modular monolith is a single deployable application divided into modules with strict boundaries, where each module owns one business area's code and data and other modules can only use it through a public interface. It keeps the simple deploys of a monolith and if one module ever needs to leave, it can go without dragging the rest along.

Is a modular monolith better than microservices?

For most teams, a modular monolith is the better choice because it gives clean separation without the operational cost of running many services. Microservices win when many teams are blocked by one release schedule or when a specific component needs to scale or fail on its own. At Full Scale, the modular monolith is the default we recommend until one of those triggers is real.

Should each module in a modular monolith have its own database?

Each module in a modular monolith should own its data, usually as its own schema or set of tables inside one database server, and no other module should read or write those tables directly. A separate database server per module is rarely needed until that module gets extracted into a service.

What's the difference between a modular monolith and a distributed monolith?

A modular monolith is one deployable app with clean internal boundaries. A distributed monolith is deployed as separate services that still share data or have to be released together, so it carries the operational cost of microservices without the independence that was supposed to justify it.

Can a modular monolith scale?

A modular monolith scales the way any monolith does, by running more instances behind a load balancer and scaling the database, which covers far more traffic than most products ever see. When one module truly needs to scale differently, you extract just that module into a service and leave the rest of the application alone.

Start boring and split when it hurts

Pick a modular monolith vs microservices by counting your teams and checking who owns which tables. Build one application with real walls and make every module own its data. Put a tool in charge of the boundaries, because people forget. Then split a module out only when it hits a real wall. It's boring advice, and boring is what ships.

If you'd like engineers who will respect those walls and own what's behind them, let's talk.

Ready to add senior engineers to your team?

Book a 15-minute call. Tell us your stack and where the gaps are, and we'll show you the engineers we'd put on your team.