Inventory systems fail in predictable ways. They either lose writes under high concurrent load, produce stale stock counts that cause overselling, or generate reports that do not reflect the current state. Event-driven inventory architecture solves all three problems with a design that scales from a single restaurant to a chain across Lebanon and the MENA region.
Inventory systems fail in predictable ways. They either lose writes under high concurrent load, produce stale stock counts that cause overselling, or generate reports that do not reflect the current state. Event-driven inventory architecture solves all three problems with a design that scales from a single restaurant location to a chain with branches across Lebanon and the MENA region.
Why inventory management is harder than it looks
Inventory appears straightforward: track how much of each item you have, subtract when it is used, add when it is restocked. The complexity emerges from the operational realities of running restaurants and retail businesses.
Concurrent writes. A busy restaurant kitchen is processing dozens of orders simultaneously. Each order deducts ingredients from inventory in real time. Without correct concurrent write handling, the system double-counts deductions and produces negative inventory numbers, or loses deductions entirely.
Multi-location stock. A restaurant group with three branches and a central warehouse has stock at four locations. A transfer between locations must atomically reduce stock at the source and increase it at the destination. Partial transfers (one side succeeds, one fails) create phantom inventory.
Real-time reporting with historical accuracy. Operations managers need current stock levels now. Accountants need to know what stock levels were at the end of last month. These are different queries with different consistency requirements.
Audit requirements. In Lebanon's operational environment, where supplier disputes and inventory theft are operational concerns, every inventory change needs a clear audit trail: who made the change, when, what system triggered it, and whether it was reviewed.
The event log as the source of truth
In event-driven inventory architecture, the database never stores current stock levels directly. Instead, it stores an append-only log of inventory events:
PURCHASE_RECEIVED: stock added from a supplier deliveryORDER_DEDUCTION: stock consumed by a customer orderTRANSFER_OUT: stock sent to another locationTRANSFER_IN: stock received from another locationWASTE_RECORDED: stock discarded due to spoilage or damageADJUSTMENT: manual correction by a managerSTOCKTAKE: physical count that overrides calculated balance
Current stock level for any item at any location is the sum of all events for that item and location:
SELECT
location_id,
item_id,
SUM(quantity_delta) AS current_stock
FROM inventory_events
WHERE location_id = $1 AND item_id = $2
GROUP BY location_id, item_id;
Historical stock levels at any point in time are the same query with a WHERE event_time <= $3 clause added. The same event log answers both the real-time and the historical question.
Why append-only beats direct updates
The alternative is a current_stock table with a numeric quantity column. Every event updates this column: UPDATE stock SET quantity = quantity - 2 WHERE item_id = $1 AND location_id = $2.
This design has a critical problem: concurrent updates on the same row race. Two orders processed simultaneously both read the same stock quantity, both subtract their deduction, and both write the result. One deduction is lost. The final quantity is wrong.
PostgreSQL serializable isolation or SELECT FOR UPDATE can prevent this, but at the cost of serializing all writes to the same row. In a busy restaurant that processes 500 orders per hour, each involving 10 to 15 ingredient deductions, this creates a serialization bottleneck that slows order processing.
The append-only event log avoids row-level contention. Each event is an INSERT, not an UPDATE. INSERTs on the same table do not contend with each other in PostgreSQL. The system can process 10,000 concurrent INSERTs without any serialization overhead.
The tradeoff is query complexity: reading current stock requires a SUM aggregation instead of a simple SELECT. This is managed with a materialized view or a projection table that is refreshed on a schedule.
Implementing the projection layer in Go
The projection layer maintains a stock_projections table with pre-computed current stock levels. A Go service subscribes to inventory events and updates projections as events arrive.
In a simple deployment, the projection update runs within the same database transaction as the event insert:
func (s *InventoryService) RecordEvent(ctx context.Context, event InventoryEvent) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Insert the event
if err := s.insertEvent(ctx, tx, event); err != nil {
return err
}
// Update the projection
if err := s.updateProjection(ctx, tx, event); err != nil {
return err
}
return tx.Commit()
}
This ensures that the event and its projection are always consistent: if the event insert fails, the projection is not updated, and vice versa.
For high-volume deployments where the projection update latency is a concern, the projection can be updated asynchronously. A PostgreSQL LISTEN/NOTIFY channel signals projection workers when new events arrive. Workers consume events and update projections. The tradeoff is a brief period where the projection lags behind the event log.
Handling stocktake events correctly
A stocktake is a physical count that overrides the calculated balance. It presents a special challenge in the event log model because it invalidates prior event history.
The correct approach: a stocktake event includes the counted quantity as a field, not as a delta. When computing current stock, the aggregation starts from the most recent stocktake event and only sums events that occurred after it:
WITH last_stocktake AS (
SELECT event_time, quantity_absolute
FROM inventory_events
WHERE event_type = 'STOCKTAKE'
AND location_id = $1
AND item_id = $2
ORDER BY event_time DESC
LIMIT 1
)
SELECT
COALESCE((SELECT quantity_absolute FROM last_stocktake), 0)
+ COALESCE(
(SELECT SUM(quantity_delta)
FROM inventory_events
WHERE location_id = $1
AND item_id = $2
AND event_time > (SELECT event_time FROM last_stocktake)),
0
) AS current_stock;
If no stocktake exists, the system uses zero as the starting balance and accumulates all events. This is correct for items tracked from first receipt.
Low stock alerting and threshold management
Restaurant operations require low stock alerts to trigger purchasing before items run out. The projection table is the right place to trigger these alerts.
When a projection update reduces stock below a threshold, the update function can emit a notification. In Go, this can be a goroutine-safe channel, a Redis pub/sub message, or a PostgreSQL NOTIFY call.
Thresholds are stored per item per location and can be set by the operations team. A restaurant that serves 200 covers per night and uses 40 portions of chicken breast per service should set a low stock alert at 80 portions - two services worth.
The alert system should be pull-based, not push-based. An alert that sends a WhatsApp message every time stock drops below threshold will send hundreds of messages during service. A daily purchasing report that shows all items below threshold - generated each morning before the purchasing team makes calls - is more operationally useful.
Scaling across multiple locations in Lebanon
For restaurant groups with multiple branches in Lebanon, the inventory system needs to handle inter-location transfers and give head office visibility across all branches.
The recommended architecture: each location has its own inventory event log in its local database. The head office reporting database receives replicated events from all branches via PostgreSQL logical replication. The head office system can then compute consolidated stock positions across all branches.
This architecture means a branch can continue recording inventory events (order deductions, waste, deliveries) even when the head office connection is down. Events are replicated when connectivity returns.
Transfers between locations generate two events: a TRANSFER_OUT at the source branch and a TRANSFER_IN at the destination branch. These are created by a central transfer service that holds a distributed lock during the operation to ensure atomicity. If the destination TRANSFER_IN fails, the source TRANSFER_OUT is reversed.
Key lessons from production
The event log model is more complex to build initially but significantly more correct and maintainable than direct quantity updates. The audit trail, historical querying, and freedom from concurrent update races make the investment worthwhile.
Stocktake events are the only event type that requires careful special handling. Every other event is a simple delta. Design stocktake handling correctly at the start.
Projection tables are essential for read performance. Summing 50,000 events per item per location query is too slow for real-time dashboard use. Project into a fast-read table and keep it updated.
Low stock alerts should generate reports, not real-time notifications. Operational teams in MENA restaurant environments are busiest during service when stock is actually running low. Interrupt-driven notifications during service create noise. Morning purchasing reports create action.
Enjoying this article?
Enter your email and get a clean, formatted PDF of this article - free, no spam.
Not sure where to start?
Voxire builds inventory management systems for restaurants and retail businesses in Lebanon and the MENA region, including integration with POS systems and supplier management workflows. If you are running stock on spreadsheets or a system that cannot handle your branch count, we can help design and build the right architecture.
https://voxire.com/get-a-quote/



