Skip to main content
Engineering

Why we build ERPs as modular monoliths first

18.09.2026 Koddata Admin 4 min read
Why we build ERPs as modular monoliths first

The split usually happens too early, for the wrong reason

The first architecture meeting on an ERP project tends to go the same way: inventory, orders, invoicing and customer management get drawn as separate services, because that is what the diagrams in conference talks look like. Six months later the same team is debugging a transaction that spans three services just to decrement stock, create an order and post an invoice line. That debugging session takes longer than building the feature as a single application would have. Most ERP projects struggle for architectural reasons, not technical ones, and the trouble usually starts in that first week.

We build ERPs as modular monoliths first. We have not regretted that decision on a single project. The point is not avoiding complexity. It is matching the architecture to what actually needs to change together and what actually needs to stay consistent.

Where the module boundary goes

A modular monolith is not one undifferentiated codebase. On a typical Java/Spring Boot ERP, we draw the boundary by business capability: inventory, sales orders, purchasing, invoicing, customer records. Each module owns its own package and its own set of tables, often its own schema inside the same PostgreSQL database. A module reaches another only through an explicit interface, never by querying another module's tables directly.

  • Inventory owns stock levels, reservations and movements.
  • Sales owns orders, pricing and fulfilment status.
  • Invoicing owns billing documents and payment status.
  • Customer records own contact data, payment terms and correspondence history.

Code review enforces one rule: a module can call another module's service interface, but it cannot query its tables. That single rule is what makes a later extraction possible, because the boundary is already shaped like a network call from day one, even before any network call exists.

What has to stay in one transaction

The operations that must be atomic in an ERP are exactly the ones that are hard to make atomic across services: reserving stock and confirming an order, posting an invoice and updating order status, applying a payment and releasing a credit hold. In a modular monolith these run inside one database transaction with ordinary ACID guarantees. In a system split too early, the same operation needs a saga, compensating steps and a window where the system sits inconsistent. Most support tickets come out of exactly that window.

We do use an event mechanism between modules, written to an outbox table in the same transaction and processed asynchronously, but only for effects that can tolerate delay: sending a notification, updating a reporting projection, triggering document generation. Anything the user needs to see on the next screen stays inside the transaction boundary.

When we do split

Two things justify pulling a module into its own service: a genuinely different scaling profile, or a genuinely different team owning it. A reporting module that needs to run heavy aggregation against a read replica instead of the transactional core scales differently, so it is usually the first piece we extract. A customer-facing storefront built on the same ERP catalogue is another natural split, because it needs its own release cadence and its own uptime target, and neither should depend on a batch import job running in the back office.

Because the boundaries were already clean, the extraction becomes mechanical: the internal interface turns into an HTTP or messaging contract, the module gets its own deployment pipeline, and the rest of the system keeps calling the same interface it always did. We have done this on live client systems without a rewrite.

Our recommendation

Start every ERP as a modular monolith with strict internal boundaries and one database. Keep the transactional core, the part that genuinely needs to stay consistent right now, inside that boundary for as long as it needs to be there. Pull a module into its own service only when you have a concrete scaling or ownership reason, not because a diagram says so. The boundary work done on day one is what turns that later split into a cheap step instead of a rewrite.

Share
Message us on WhatsApp