November 4, 2026
App Development

Modular Monolith Migration: Boundaries Before Microservices

A modular monolith keeps one codebase and one deploy unit, but enforces strict internal boundaries between domains so teams get microservice-style ownership without distributed-systems overhead. It beats jumping straight to microservices whenever a team hasn’t yet proven where its real domain seams are, since fixing a bad boundary in-process is a refactor — fixing one across a network is a rewrite.
DimensionTangled monolithModular monolithMicroservices
Deploy unitsOne (accidental)One (deliberate)Many (per service)
Domain boundariesNone enforced, implicitEnforced by tooling (Packwerk, Spring Modulith, ArchUnit)Enforced by network/process boundary
Cross-boundary callsDirect object/database access, anywherePublic module API only, checked in CINetwork calls (REST/gRPC/events)
Refactor cost of a wrong boundaryLow friction to violate, high cost laterModerate — move code, still one deployHigh — data migration, contract versioning, dual-write windows
Operational overheadLow, but reliability risk rises with sizeLow — one thing to deploy, monitor, scaleHigh — service mesh, distributed tracing, per-service on-call
Best forNobody, deliberatelyTeams that haven’t proven their domain seams yetTeams with proven seams and independent scaling/ownership needs

What “modular monolith” means, precisely

A modular monolith is a single deployable application, internally partitioned into modules with explicit public APIs and enforced dependency rules, where crossing a module boundary without going through its published interface fails a build or a test — not just a code review comment. The distinguishing feature isn’t the folder structure (a tangled monolith can have neat-looking folders while every module quietly imports every other module’s internals); it’s that the boundary is mechanically enforced, so architectural drift shows up as a failing CI check rather than as an undiscovered problem six months later.

Shopify’s Packwerk, released in 2020 and still running across six Rails applications including the core monolith with 48 packages and roughly 30 boundary enforcements, is the reference implementation most engineers point to: it’s a static analysis tool that inspects constant references and flags any file that reaches into another package’s private internals instead of its declared public interface. On the JVM side, Spring Modulith (built on ArchUnit) does the equivalent job via an @ApplicationModule annotation, rejecting cyclic dependencies between application modules and blocking references to types not explicitly allow-listed as public — checked automatically alongside your regular test suite, the same “fail the build, not the retro” discipline covered in our guide to Git workflows for collaborative app projects.

Figure 1: Where the enforcement actually lives

In a tangled monolith, “boundaries” exist only as folder names and a wiki page nobody reads. In a modular monolith, boundaries are enforced by a dependency-graph checker (Packwerk’s package.yml files, Spring Modulith’s ArchUnit rules, or a custom ESLint/import-boundary rule in a JS/TS monorepo) that runs in CI and fails the build on violation. In microservices, the boundary is enforced by the network itself — you literally cannot call another service’s private database table, which is the most rigid enforcement possible, at the highest operational cost.

Designing module boundaries that actually hold

Boundary design is domain design, not folder design. The practice that transfers most directly from the microservices world is domain-driven design’s bounded context: identify the areas of the business where the same word means different things (a “customer” in billing has different attributes and lifecycle than a “customer” in support), and draw module lines along those seams rather than along technical layers (controllers/services/models). A module should own its data access completely — no other module queries its tables directly, ever, even for a “quick join” — and expose only a narrow public API of functions, events, or DTOs that encode intent, not raw schema shape. This is the same discipline that determines whether a cross-platform app codebase stays maintainable as it grows: the boundary has to follow the domain, not the convenience of the moment.

Two structural tests separate a real boundary from a cosmetic one. First, the dependency direction test: can you draw a directed acyclic graph of module dependencies with no cycles? If module A depends on module B which depends back on module A through some indirect path, you don’t have two modules — you have one module with extra ceremony. Second, the extraction test: could this module theoretically become its own service, with its own database, without touching any other module’s code beyond its declared public interface? If answering that question requires you to first untangle a dozen direct table joins, the boundary isn’t real yet, whatever the folder structure suggests.

  • Step 1 — Map bounded contexts: Interview each team about where their domain language diverges from a neighboring team’s; draw the seams there, not at arbitrary technical layers.
  • Step 2 — Assign data ownership: Every table belongs to exactly one module. Cross-module reads go through a published query API or an event, never a direct join.
  • Step 3 — Publish narrow public APIs: Expose functions, commands, and domain events — not ORM models or raw schema — so internal refactors inside a module never break its consumers.
  • Step 4 — Enforce boundaries in CI: Wire up Packwerk, Spring Modulith/ArchUnit, or an equivalent import-boundary linter so a violation fails the pull request, not a future architecture review.
  • Step 5 — Add module-level tests and dashboards: Run each module’s tests in isolation and track a per-module dependency count over time so drift is visible before it becomes a rewrite.

When a modular monolith beats going straight to microservices

The industry’s 2023-2026 correction is well documented at this point: a widely cited 2026 industry survey found 42% of organizations that initially adopted microservices had consolidated some services back into larger deployable units. The reason isn’t that microservices are wrong — it’s that most teams adopted them before they’d proven where their actual domain seams were, and paid for that mistake in network calls, distributed transactions, and duplicate infrastructure for services that never needed independent scaling in the first place.

A modular monolith wins specifically when: the team is small-to-mid-sized (roughly under 40-50 engineers) and doesn’t yet have the platform/SRE investment to run a service mesh, distributed tracing, and per-service on-call without that overhead swamping feature work; the domain boundaries are still being discovered, so a wrong boundary needs to be cheap to fix (an in-process move, not a data migration across a network); and there’s no proven need for independent scaling or independent deploy cadence per component — the reasons that would justify true microservices or, on the frontend, an approach like micro-frontend architecture for genuinely independent teams. If none of those forcing functions exist yet, a modular monolith gets you almost all the organizational clarity of microservices — clear ownership, enforced boundaries, independent testing — while keeping the “one thing to deploy, one thing to monitor” simplicity that lets a smaller team move fast.

Common mistakes

  • Drawing module boundaries around technical layers (controllers, services, repositories) instead of business domains — this produces modules that all still need to change together for any real feature, defeating the purpose.
  • Adding the boundary-enforcement tool after the codebase is already tangled and expecting it to “just work” — most teams need a multi-month untangling phase before CI enforcement can be turned on without blocking every PR.
  • Allowing a “shared” or “common” module to become a dumping ground that every other module depends on — this recreates a single point of coupling with an innocent-sounding name.
  • Treating module boundaries as a one-time exercise instead of an ongoing discipline — without CI enforcement and periodic dependency-graph review, boundaries decay back into a tangled monolith within a year.
  • Jumping to microservices to “solve” a modular monolith’s growing pains without first confirming the pain is a scaling problem and not just an unenforced boundary problem.

Testing and team ownership inside a single deploy unit

One underappreciated benefit of enforced module boundaries is that testing gets faster and more precise, not slower. Because a module’s public API is the only legitimate entry point, its test suite can run in isolation — mocking or stubbing the public APIs of the modules it depends on — instead of spinning up the entire application for every test. Spring Modulith takes this further with module-isolated integration tests that verify a module’s behavior in the context of the whole application without requiring every other module’s full dependency chain to be live, giving teams a middle ground between slow full-application integration tests and brittle unit tests that mock too much.

Team ownership follows the same logic as team-owned microservices, just without the network boundary. Each module gets a named owning team responsible for its public API’s stability, its own on-call rotation for its slice of the shared deploy (using structured logging and per-module metrics tags so an incident can be attributed to the right module even though everything runs in one process), and its own backlog. The practical difference from microservices is that a cross-module bug fix still requires exactly one deploy, not a coordinated multi-service rollout — which is precisely the tradeoff that makes a modular monolith attractive for teams that haven’t yet earned the operational complexity of independent deploys.

Figure 3: Ports-and-adapters inside a single module

Well-built modules internally follow a hexagonal (ports-and-adapters) shape: a core domain layer with business logic and no framework dependencies, surrounded by adapters for persistence, HTTP, and messaging. This internal structure is what makes eventual extraction cheap — swapping an in-process adapter (a direct function call to another module) for a network adapter (an HTTP client or event publisher) touches only the adapter layer, not the domain logic, because the module was never coupled to how its neighbors happened to be reached.

A practical migration playbook

Migrating a tangled monolith to a modular one follows a strangler-adjacent pattern, but internal to the codebase rather than external. Start with a full dependency-graph audit — most teams are surprised to discover their “clean” folder structure has dozens of undocumented cross-module database joins. Pick the module with the clearest, most self-contained domain boundary first (often billing, notifications, or auth — areas with naturally distinct data and few incidental dependencies) and prove the pattern there before rolling it out everywhere. Introduce the boundary-enforcement tool in “warn, don’t fail” mode initially, so the team can see the size of the violation backlog without blocking shipping, then flip it to hard-fail once the backlog is triaged. Convert direct cross-module database access to either synchronous calls through a published module API or asynchronous domain events for anything that doesn’t need an immediate response — the event approach, in particular, is what later makes true service extraction painless if you ever do need it, since the module already communicates the way a separate service would. This mirrors the staging-parity discipline covered in our guide to rehearsing major software deployments: prove the change in a low-risk module before touching the ones the whole business depends on.

Figure 2: The migration sequence in practice

1) Dependency-graph audit reveals actual (not assumed) coupling. 2) Pick one low-risk module and extract its public API + enforce boundary in warn-only mode. 3) Convert its cross-module calls to the public API or domain events. 4) Flip enforcement to hard-fail for that module once violations are resolved. 5) Repeat module by module, prioritizing by business criticality last. Teams that try to do all modules simultaneously in step 2 consistently stall — Shopify’s own Packwerk retrospective describes this as a multi-year, module-by-module effort, not a single migration sprint.

In practice

A mid-market logistics SaaS with roughly 30 backend engineers spent 2025 planning a full microservices rewrite after their five-year-old Rails monolith became painful to onboard new engineers into. Instead of the rewrite, they ran a two-week dependency audit and found that 80% of their perceived “tangled mess” traced back to just four modules (routing, billing, fleet, and a shared “utils” dumping ground) with unrestricted database access to each other’s tables. They adopted Packwerk in warn-only mode, spent one quarter converting the worst offenders to published APIs and domain events, retired the “utils” module entirely by redistributing its contents to the modules that actually owned that logic, then flipped enforcement to hard-fail module by module over the following two quarters. They kept a single Rails deploy the entire time. Eighteen months later, onboarding time for new engineers dropped because each module’s public API was small and documented, and when the fleet-tracking module did eventually need independent scaling due to a genuine load spike from a new GPS-ping feature, extracting it into its own service took three weeks — because its boundary, database ownership, and event-based communication were already in place.

What you give up by not going full microservices

It’s worth being honest about the tradeoff rather than presenting the modular monolith as a free lunch. You give up true independent scaling — if the billing module needs ten times the CPU of everything else during month-end invoicing, it scales along with the whole application, not on its own. You give up independent deploy cadence — even with perfect module boundaries, every deploy ships the whole application, so a team wanting to ship five times a day is still coupled to the CI pipeline and release process of every other module, the same coordination cost that pushes some organizations toward container orchestration platforms and per-service deployment pipelines like those covered in our overview of modern orchestration and deployment platforms. And you give up technology diversity — every module runs in the same language and runtime, which is a real constraint if one domain would genuinely benefit from a different tool (a recommendation engine that wants Python’s ML ecosystem next to a core app in Ruby or Java).

None of these are dealbreakers for most organizations, which is exactly why the correction toward modular monoliths happened: the majority of teams that adopted microservices never actually needed independent scaling, deploy cadence, or technology diversity for most of their services — they needed clear ownership boundaries, which a modular monolith provides at a fraction of the operational cost. The right read of the case studies collected in our piece on companies scaling engineering organizations with open source is that Shopify didn’t reject microservices out of principle — it extracted the handful of components (payments processing, some high-scale storefront rendering paths) that had actually earned independent deployment, while keeping the rest of the platform as one well-modularized Rails application.

Key takeaways

  • A modular monolith is one deploy unit with mechanically enforced internal boundaries — the enforcement, not the folder layout, is what makes it real.
  • Draw module boundaries around business domains and bounded contexts, never around technical layers like controllers or services.
  • Tools like Shopify’s Packwerk and Spring Modulith (built on ArchUnit) enforce boundaries in CI, turning architectural drift into a failing build instead of a future rewrite.
  • A 2026 industry survey found 42% of organizations that adopted microservices have since consolidated some services back into modular monoliths — proof this isn’t a downgrade, it’s a correction.
  • Migrate module by module, starting with the most self-contained domain, using warn-only enforcement before hard-fail.
  • Converting cross-module calls to published APIs or domain events early makes later service extraction — if you ever need it — a matter of weeks, not months.
  • Choose modular monolith over microservices whenever your domain boundaries aren’t proven yet or your team lacks the platform investment to run distributed systems well.

Glossary

Modular monolith
A single deployable application internally divided into modules with enforced boundaries and explicit public APIs, as opposed to an unstructured “tangled” monolith.
Bounded context
A domain-driven design concept marking a clear business area where specific terms, rules, and data models are consistent — the recommended basis for drawing module lines.
Fitness function
An automated, repeatable check (like an ArchUnit rule or a Packwerk boundary check) that verifies an architectural property continuously, failing the build on violation.
Strangler pattern
An incremental migration approach where new functionality is built around and gradually replaces old functionality, rather than a single big-bang rewrite.
Domain event
A message published by one module describing something that happened, consumed asynchronously by other modules without direct coupling to the publisher’s internals.
Dependency cycle
A situation where two or more modules depend on each other directly or transitively, indicating the boundary between them isn’t a real seam.
Service extraction
The process of pulling a well-bounded module out of a modular monolith into its own independently deployed service, made easier when the module’s boundary was already enforced.

FAQs

Is a modular monolith just a monolith with better naming?

No. The distinguishing feature is mechanical enforcement — a tool like Packwerk or Spring Modulith fails the build when code crosses a module boundary without going through its declared public API, which a naming convention alone can never guarantee.

Do I need Domain-Driven Design to build a modular monolith?

Not formally, but the underlying practice — identifying bounded contexts and drawing boundaries around business domains rather than technical layers — is the same skill DDD teaches, and skipping it is the most common reason module boundaries don’t hold.

Can a modular monolith scale as well as microservices?

For most workloads, yes, since you can scale the whole application horizontally behind a load balancer. It cannot scale one module independently of the others without extraction — that’s the specific capability you trade away for simplicity.

How long does migrating a tangled monolith to a modular one take?

Real-world case studies suggest a multi-quarter, module-by-module effort rather than a single project — Shopify’s own migration with Packwerk has been an ongoing, years-long process, not a one-time cutover.

What tool should I use to enforce module boundaries?

It depends on your stack: Packwerk for Ruby/Rails, Spring Modulith (with ArchUnit) for Java/Kotlin/Spring, or a custom import-boundary ESLint rule combined with a monorepo tool for JavaScript/TypeScript codebases.

Should every module eventually become a microservice?

No. Extraction should be driven by a proven need — independent scaling, independent deploy cadence, or a genuinely separate team — not treated as the automatic next step after modularization.

What’s the biggest risk during migration?

Trying to modularize the entire codebase at once instead of module by module. Teams that attempt a full rewrite in one pass consistently stall; incremental, warn-then-enforce rollout succeeds far more often.

Does a modular monolith help with onboarding new engineers?

Yes, often significantly. A new engineer only needs to understand one module’s public API and internals to contribute safely, rather than the entire tangled codebase’s implicit coupling.

References

  • Shopify Engineering, “Enforcing Modularity in Rails Apps with Packwerk” — https://shopify.engineering/enforcing-modularity-rails-apps-packwerk
  • Shopify Engineering, “A Packwerk Retrospective” — https://shopify.engineering/a-packwerk-retrospective
  • Spring, “Introducing Spring Modulith” — https://spring.io/blog/2022/10/21/introducing-spring-modulith/
  • Martin Fowler, “MonolithFirst” — https://martinfowler.com/bliki/MonolithFirst.html
  • Martin Fowler, “Microservices” — https://martinfowler.com/articles/microservices.html
    Rafael Ortega
    Rafael holds a B.Eng. in Mechatronics from Tecnológico de Monterrey and an M.S. in Robotics from Carnegie Mellon. He cut his teeth building perception pipelines for mobile robots in cluttered warehouses, tuning sensor fusion and debugging time-sync issues the hard way. Later, as an edge-AI consultant, he helped factories deploy real-time models on modest hardware, balancing accuracy with latency and power budgets. His writing brings that shop-floor pragmatism to topics like robotics safety, MLOps for embedded devices, and responsible automation. Expect diagrams, honest trade-offs, and “we tried this and it failed—here’s why” energy. Rafael mentors robotics clubs, contributes to open-source tooling for dataset versioning, and speaks about the human implications of automation for line operators. When he’s offline, he roasts coffee, calibrates a temperamental 3D printer, and logs trail-running miles with friends who tolerate his sensor jokes.

      Leave a Reply

      Your email address will not be published. Required fields are marked *