The most complex interface point in Netorigo is the shared boundary between Finance, Logistics, and Travelium. After an order is placed, three modules have to work in coordination: it must be invoiced, it must be shipped, and if there's a travel product it must also be booked. Below we present the shared state machine that 18 months of production experience has shaped.
The five order states
The sales_orders.status enum used to have 14 states. After a retrospective we cut it down to five domain-level states, because the rest can be handled at module level:
pending— the cart has gone through checkout, awaiting payment confirmation.confirmed— payment arrived, the order is "live".fulfilling— at least one module is actively working on it (logistics picking, finance invoicing, travelium booking).completed— every module is done, the customer received the product / service.cancelled— some module vetoed (refund initiated, or never fulfilled).
The pending → confirmed transition is a payment callback. confirmed → fulfilling is automatic, once the Finance module has issued an invoice. fulfilling → completed is an aggregate: every module-specific sub-state must be done.
The module-specific sub-states
Every module stores an order_module_state record in its own table, referencing the shared order_id. So:
- Finance:
invoice_state = (pending | issued | paid | refunded) - Logistics:
shipment_state = (waiting | picked | packed | shipped | delivered | returned) - Travelium:
booking_state = (pending | reserved | confirmed | travelled | cancelled)
An order is completed when every linked module-state is in a "terminal happy" state: invoice = paid, shipment = delivered (if there's a physical product), booking = travelled (if there's a travel product). A missing module row doesn't block — a purely digital order has no Logistics row at all.
The event bus as communication
Communication between modules is NOT direct API calls but an event bus (order_events Postgres outbox + NATS dispatch). An OrderConfirmedEvent, for example, is consumed by every module's listener; each decides whether to react:
- Finance listener: issues an invoice, writes
invoice_state = issued. - Logistics listener: if there's a physical SKU, creates a
pick_listrow,shipment_state = waiting. - Travelium listener: if there's a travel SKU, places an allotment reservation,
booking_state = reserved.
Other modules (Catalog, Inventory) also hear the event but don't react (or only update metrics).
What a developer building an interface point needs to know
If you build a new integration with a 7th module (say "Subscription"), follow these rules:
- You also subscribe to the
order_eventsbus. Never call the Sales module directly. - You keep your own
order_module_staterow. If your module touches the order, you write a row, you update it on state change. - You register with the
fulfilling → completedaggregator. A config entry (modules_required_for_completion) adds your module — the order won't go to completed until you're done. - You handle cancel cases. If Sales moves an order to
cancelled, you must call your rollback logic so your module cleans up (e.g. terminate the subscription). - You use idempotency keys on every mutation. The event bus is at-least-once, so if you receive the same event twice, react only once. The event
idmakes a fine idempotency key.
A concrete problem we solved
At the start, the trouble was that if Finance issued an invoice but Logistics hadn't shipped yet, the customer received two emails: one invoice email and one "shipment soon" email — at two different times. The current solution: notifications are not module-level but state-machine-level. OrderStateChangedEvent sends an email only on confirmed ("Thanks for the order, invoice attached, shipment soon") and on completed ("Arrived, want to review?"). Module-internal state changes don't trigger emails.
Takeaway
Wiring three modules (Finance + Logistics + Travelium) is not a "REST API in Finance, REST API in Logistics, integration glue in the middle" problem. It's a shared state machine + event bus problem. The shared state machine has 5 states, the sub-states are module-level, the event bus is the connector. If you understand this, you understand 80% of the Netorigo backend.