Modular Monolith: A Practical Architecture for Web-Based ERP

Every team building a web-based ERP eventually faces the same question: microservices or monolith? After delivering several mid-size ERP systems, our default answer is a modular monolith — one deployable unit, strictly separated internal modules. This article explains why, and how to keep the monolith from collapsing into a big ball of mud.

Why not microservices

Microservices solve organizational problems: independent deployment by teams of ten-plus engineers, independent scaling of hot paths, polyglot persistence justified by specialized workloads. A typical web ERP project has a five-to-twenty-person team, one deployment window, and a shared relational database where finance, inventory and production data must be consistent anyway.

In that setting microservices mostly buy cost: distributed transactions for document posting, eventual consistency visible to users, duplicated boilerplate per service, and an operations burden (service discovery, tracing, versioning) that small teams cannot carry. The failure mode is familiar: "microservices" that all read and write the same database — the worst of both worlds.

What a modular monolith actually means

A modular monolith is a single process and a single deployment, but internally it is organized as a set of modules with hard boundaries. Each module owns:

  • Its tables — no foreign keys across module boundaries, no JOIN into another module's tables.
  • Its public API — other modules call in-memory interfaces or application services, never repositories of another module.
  • Its domain events — state changes are announced through an internal event bus so dependent modules react without tight coupling.

The discipline is what separates this from a layered monolith. The deployment is unified; the codebase is not a free-for-all.

Drawing the module boundaries

We slice by business capability, mirroring how the customer organization thinks:

  • Base data — items, customers, suppliers, warehouses, units, currencies.
  • Inventory — stock levels, inbound, outbound, transfers, counting.
  • Purchasing and sales — orders, receipts, deliveries, pricing.
  • Finance — chart of accounts, vouchers, AR/AP, period closing. See Integrating General Ledger with Business Documents.
  • Production — BOM, MRP, work orders. See Implementing BOM Explosion and MRP in a Web ERP.
  • Workflow — approval processes attached to documents from any module.

Two rules of thumb: a module should map to one bounded context in the domain, and cross-module calls should feel slightly painful. If calling another module is as easy as calling your own code, the boundary does not exist.

Enforcing the boundaries

Conventions alone rot under deadline pressure. Enforce boundaries mechanically:

  1. Package by module, not by layer. Each module contains its own controllers, services, repositories and entities. A global service or dao package is the first step toward mud.
  2. Static analysis gate. Tools such as ArchUnit (Java) or dependency-cruiser (TypeScript) fail the build when module A imports module B's internals. Run it in CI on every commit.
  3. Module-owned migrations. Each module applies its own schema migrations. Shared migration folders hide cross-module table coupling.
  4. Event bus for reactions. When a sales delivery is confirmed, inventory reacts to DeliveryConfirmed rather than the sales module calling inventory directly. This keeps the dependency graph acyclic.

When a module genuinely deserves to split out

A module earns its own service when one of these is measurably true:

  • It has a different scaling profile — e.g., report generation saturating CPU while transactions wait.
  • It has different availability requirements — e.g., a notification service that must survive while the core is down for period closing.
  • A separate team owns it and ships on a different cadence.

Because the boundary was already enforced in-process, extracting a module into a service is mostly packaging: replace the in-memory interface with an HTTP or message client. Teams that start with microservices pay this cost for every module from day one; the modular monolith pays it only when the data justifies it.

The frontend side

Modularity does not stop at the API. The web frontend should mirror the module structure: one route subtree per module, feature-based code organization, and a permission system that decides which menu trees a user sees at all — the pattern described in Enterprise Web UI Patterns.

Conclusion

For most web ERP projects, the debate is not monolith versus microservices — it is disciplined monolith versus accidental monolith. Choose the modular monolith, enforce boundaries with tools, and let real operational data tell you if and when a module deserves to leave the process. You will ship faster, debug easier, and still keep the escape hatch open.