When you split an ERP into Admin + Finance + Logistics + Storefront, the hardest question is not how they talk to each other but who owns what. Each entity needs exactly ONE source of truth, or the accounting topples weekly. Here's the ownership map and the eventual-consistency windows that go with it.
The ownership map
| Entity | Owner module | Reader modules | Consistency window |
|---|---|---|---|
| Customer master | Admin | Finance, Logistics, Storefront | Instant (sync write-through) |
| Product master | Admin | Finance, Logistics, Storefront | Instant |
| Stock | Logistics | Storefront, Admin | 15 seconds |
| General ledger | Finance | Admin (reports only) | 60 seconds |
| VAT-code definitions | Finance | Admin, Storefront | Instant |
| Sales order | Storefront → Logistics | Finance | 15 seconds |
| Invoice | Finance | Admin, Storefront | Instant |
The owner is the module that may write. Everyone else may only read — and only through the public API.
Why not a shared DB
The classic monolithic ERP lives on a single PostgreSQL where every module directly JOINs every other module's tables. Fast, simple — and the worst enemy of a modular system. Why:
- Implicit coupling. If Logistics directly
JOINs theaccounting_entrytable, Finance cannot evolve its schema without redeploying Logistics in lockstep. - Transactional coupling. A big SQL transaction with Finance + Logistics writes means a lock or deadlock in one drops both.
- Schema-versioning mess. Coordinated migrations across six places, deployed in lockstep? The modules lose their independent release cycle.
So even on one physical PostgreSQL cluster the modules get logically separate schemas, and they talk only through APIs.
The eventual-consistency windows
Not every entity needs instant consistency. The windows were chosen deliberately:
Customer master, product master, VAT codes, invoice: instant. These either change rarely (new SKU on product master) or are critically time-sensitive (an invoice cannot half-exist). Sync write-through: until the source module has confirmed the update on every read-only replica, the API call doesn't return 200.
Stock: 15 seconds. Logistics owns it. On the Storefront UI the customer sees "4 in stock" — this number can be at most 15s stale. Black Friday included. Experience: in the 15s window there's a sliver of overlap (two customers ordering the same 4 units), caught at checkout by a reservation lock. The API is optimistic, the cart-finalisation is pessimistic.
General ledger: 60 seconds. The Admin reporting surface accepts data being up to 60s behind the actual Finance state. Reporting-style queries are never run against a real-time replica; a 60s-lagged replica is much cheaper and scales more easily. A bookkeeper who has just posted an entry and immediately runs a report sees it appear within a minute.
The nightly scrubber
Every night at 03:00 UTC a data-integrity scrubber runs. 11 independent cross-checks:
- Customer master consistent across Admin / Finance / Logistics replicas.
- Product master likewise.
- Total stock (Logistics) = available + reserved (warehouse-level breakdown).
- GL balance sum = 0 (double-entry invariant).
- VAT-code → VAT liability totals match the NAV submission.
- Etc.
If any check finds drift over 0.1%, an immediate alert fires (PagerDuty → Slack + email). In the past 18 months it has gone off live three times, all three were clock skew (NTP drift) causing updated_at collisions in a given module, not business-data corruption. But the scrubber's mere existence keeps auditors comfortable looking at the system.
What this means in a migration project
The biggest adaptation for a tenant coming from a monolithic ERP is accepting that certain reads come with a 15–60s lag. In practice nobody notices — a bookkeeper does not run a report within 60s of posting an entry. But we communicate it: the modular ERP is not a shared DB but four modules that talk to each other politely.