CQRS — separating the model that handles writes from the model that serves reads — is a natural fit for ERP integrations, even though the ERP itself (Acumatica, in the customisations I've shipped) is not built as a CQRS system internally. The pattern shows up at the integration boundary: writes go through the ERP's business logic layer where validation and side effects belong, while reads for reporting and dashboards hit a denormalised projection that never touches the ERP's live database directly.
Why ERP reads and writes have different needs
A write against an ERP — posting an AR invoice, releasing a sales order — needs to go through the full business logic: tax calculation, GL distribution, credit limit checks, workflow approval. That logic is expensive and stateful, and it's exactly what the ERP vendor built and maintains. A read for a dashboard — "show total open AR by customer class this quarter" — needs none of that. It needs a fast, denormalised, indexed view of data that's already been through the write path. Forcing both through the same model means either the writes get simplified past what compliance requires, or the reads get slow because they're competing with the OLTP write path for the same tables.
The integration shape
In practice this means: writes call the ERP's REST contract-based API or business logic layer directly, synchronously, so validation errors surface immediately to the user. Reads are served from a separate store — a reporting database, a data warehouse table, or even just a well-indexed replica — populated by events or a scheduled sync from the ERP rather than queried live. The read side is eventually consistent with the write side, and that lag (seconds to minutes, typically) needs to be an explicit, communicated design decision, not a surprise someone discovers when a just-posted invoice doesn't show up on a dashboard yet.
public class ARInvoiceCommandHandler
{
private readonly IErpClient _erp;
public async Task Handle(CreateInvoiceCommand cmd)
{
// Goes through the ERP's own business logic — tax, GL, credit checks
var response = await _erp.PostAsync("/entity/Default/22.200.001/Invoice", new
{
CustomerID = new { value = cmd.CustomerId },
Details = cmd.Lines.Select(l => new { InventoryID = new { value = l.Sku }, Qty = new { value = l.Qty } })
});
await _eventBus.Publish(new InvoiceCreated(response.RefNbr, cmd.CustomerId));
return InvoiceResult.FromErpResponse(response);
}
}
Keeping the read model in sync
Acumatica's business events (or a scheduled export/webhook for other ERPs) publish a notification when a document changes state. A consumer picks that up and updates the denormalised read model — usually a much flatter table shape than the ERP's normalized schema, pre-joined and pre-aggregated for the queries the reporting layer actually runs. This consumer is the piece that needs the most defensive engineering: it must be idempotent (the same event arriving twice shouldn't double-count), and it needs a reconciliation job that periodically compares read-model totals against the ERP's own totals to catch drift from missed or failed events.
If a user posts an invoice and immediately checks a dashboard built on the read model, and the number hasn't updated yet, that's confusing at best and a trust problem at worst. Either show a "as of" timestamp on reporting views so staleness is visible, or route the specific just-created-record lookups back to the write side rather than the lagging read model.
When this is overkill
CQRS adds real complexity — two data paths, an event pipeline, a reconciliation job — and it is not the right default for every ERP integration. If your reporting needs are met by the ERP's built-in generic inquiries or a handful of SQL views against replicated tables, and query volume is low, a single synchronous read/write path is simpler and has fewer moving parts to keep consistent. Reach for CQRS when reporting query load is genuinely competing with transactional load, or when the read shape (deeply aggregated, cross-entity) is fundamentally different from anything the ERP's normalized schema serves efficiently.
A read replica of the ERP's database, queried directly for reporting, gets you most of the isolation benefit of CQRS — reporting load doesn't compete with OLTP writes — without needing an event bus or a reconciliation job. Move to a true event-driven projection only once the replica's schema shape genuinely can't serve the query patterns you need.
| Approach | Consistency | Complexity |
|---|---|---|
| Single model, direct queries | Always consistent | Lowest — no extra infrastructure |
| Read replica for reporting | Near real-time (replication lag) | Low — DB-native, no custom pipeline |
| Event-driven CQRS projection | Eventually consistent (seconds-minutes) | Higher — event bus, idempotent consumer, reconciliation |
Wrapping up
CQRS for ERP integration is really a statement about where validation belongs (the write side, inside the ERP's own business logic) versus where query performance matters (the read side, denormalised and decoupled). Start with the simplest thing that serves your actual read patterns — often just a replica — and only build the full event-driven projection once reporting load or read shape genuinely demands it.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.