Order Pipeline

An event-driven order processing system in Go: HTTP ingress → Kafka → consumer-group workers → PostgreSQL, with a separate gRPC read path.

Built to be measured, broken and recovered, not just to run. Every number below is from a recorded experiment, and the raw reports are in the repository.

The console drives the live system, so it answers only while the stack is running on the author's machine — during a demo. The measurements below stand on their own.

Architecture

Writes are acknowledged as soon as Kafka has them (202 Accepted) and persisted asynchronously. Reads go through a separate service over gRPC. That asymmetry is the architecture: the two paths saturate, fail and get tuned independently.

Write and read paths of the order pipeline POST /orders enters the API, which produces to a Kafka topic with three partitions. A consumer group of N workers persists to PostgreSQL, dead-lettering failures to a separate topic. Separately, GET /orders/id reaches a query service over gRPC, which reads the same PostgreSQL. WRITE PATH — asynchronous READ PATH — synchronous POST /orders api produce Kafka: orders 3 partitions worker 1 worker N PostgreSQL DLQ failed GET /orders/{id} gRPC query

Results

Scaling

Each configuration is deliberately overloaded and the drain measured, because workers only run flat out while a backlog exists.

Throughput by worker count at 3 and 12 partitions
Workers3 partitions12 partitions
11828 ev/s1922 ev/s
22333 ev/s
43348 ev/s1932 ev/s
83807 ev/s2410 ev/s

Quadrupling the partition count made the system slower, not faster — the opposite of the textbook expectation. Partition count caps how many consumers can participate, but only binds if nothing else saturates first, and here something else did.

Batching

The fix the scaling table implied, measured. One worker, draining a 48,001-record backlog, batch size the only variable.

1
1908 ev/s1.0×
10
13796 ev/s7.2×
50
22203 ev/s11.6×
200
41082 ev/s21.5×

The ceiling was the worker's per-record round trip — not partition count, and not Postgres. The same database absorbed 21× the write rate once the round trips were amortised. It ships defaulted to 1 so every measurement here stays reproducible; the trade batching buys is per-record latency, since a record waits for its whole batch.

Overload

4 workers, 3 partitions, 15 seconds at each offered rate.

Behaviour under increasing offered load
Offered503sLost Accept p99End-to-end p99Peak lag
1,000001 ms1 ms2
5,000003 ms5.4 s26,908
20,0000015 ms71 s261,292

At 20,000/s — over 5× the sustainable write rate — all 300,001 orders were accepted and persisted. The entire overload became latency, not errors. Overload is not a failure mode here.

Failure

Every mode induced deliberately and measured, not asserted.

Outcome of each induced failure mode
Component lostResult CorrectnessAvailability
Worker (graceful stop)rebalance in 1.35 s intactintact
Worker (killed)rebalance in 43.2 s, lag spike intactintact
Database, outage < retry budgetlatency only intactintact
Database, outage > retry budget 6 dead letters per 150,001, tagged intactintact
Broker384 refusals per 60,001 intactdegraded

Correctness survived every experiment. Nothing was lost, duplicated in the database, or silently dropped. The broker is the only hard dependency, because it is the one component with no queue in front of it.

What the measurements overturned

More partitions made it slower

Going from 3 partitions to 12 cut throughput at every worker count — 3807 ev/s down to 2410 ev/s at 8 workers. Partition count caps how many consumers can participate; it only binds when nothing else saturates first. Here something else already had.

The database was never the ceiling

A single worker's 1828 ev/s matched one serial round trip per record almost exactly. Batching those round trips took the same PostgreSQL to 41,082 ev/s, which settled what the bottleneck had actually been.

Overload never became failure

Offered 5× the sustainable rate, the system dropped nothing. The backlog converted to 71 s of end-to-end latency and 261,292 records of consumer lag, and then drained. The only genuine loss of availability came from removing the broker.

Deliberately out of scope

Excluded to keep the system small enough to understand completely: Kubernetes, cloud deployment, multi-broker clusters, database replicas, CI/CD, Kafka transactions, auth, and a schema registry. Each is a project in its own right, and none would have made the measurements above more informative.