SS1WORK

Delivery platform

A delivery platform in Go and Flutter. Customers order, merchants accept, the nearest free driver gets the job, and every step that can fail has a way back.

CLIENT
OWN PROJECT
PROBLEM
ORDERS THAT FAIL HALFWAY
ROUTE
GO MODULES, EVENTS, POSTGIS
RESULT
END TO END IN 126 MIB IDLE
Role
Design and build
Year
2026
Client
Own project
Stack
Go, PostgreSQL, PostGIS, Kafka, Flutter, Docker

Problem

A delivery order looks simple until something goes wrong in the middle of it. The card is declined after the restaurant has started cooking. The restaurant says no after the customer has paid. Every driver is busy. A message about the order arrives twice, or not at all.

Most delivery demos only show the happy path. I wanted one that handles the unhappy ones properly, so that an order can never end up charged but forgotten, or assigned to two drivers at once.

Constraints

  • Cheap to run for one business. A small operator should be able to run it on one small VPS, not a cluster. I set a target of under 1 GB of RAM idle for everything.
  • Correct with real money. Messages between parts of the system are delivered at least once, so every handler has to cope with seeing the same event twice.
  • One codebase, more than food. The same core had to handle parcels and freight as well as restaurant orders, without if food branches through the code.

What I built

Eight modules, each owning its own data: identity, merchants, jobs, payments, dispatch, notifications, reporting, and a gateway that handles auth and live tracking over WebSockets. A Flutter app (web, Android, iOS) talks to the gateway only. The demo is set up as a Windhoek service in Namibian dollars.

The modules talk through events, not each other's databases. When a customer places an order:

  1. Jobs prices it on the server from the merchant's catalogue (the client never sends a price) and publishes jobs.created.
  2. Payments charges it, keyed by the job id so a repeated event can never charge twice.
  3. Merchants re-checks the items and prices, then accepts or rejects.
  4. Only when the job has both facts, paid and accepted, does it move to confirmed and into the kitchen.
  5. When it is ready, dispatch finds the nearest free driver and the customer can watch them move on a map.

Each module writes its change and an outbox row in one database transaction, and a relay publishes the outbox. Each consumer records the event id in the same transaction as its side effect, so a duplicate is ignored rather than applied again.

When something fails, the system undoes what it has done so far:

  • The merchant rejects: the job is cancelled, the payment refunded and any reserved stock released.
  • The payment fails: the job is cancelled and stock released.
  • No driver is free: the job waits in a queue and is retried every 5 seconds instead of being dropped.
  • Two jobs want the same driver: the nearest-driver query uses PostGIS's <-> operator on a spatial index with FOR UPDATE SKIP LOCKED, so the second job simply gets the next driver.
  • A handler keeps failing: three attempts, then the event goes to a dead-letter table for a person to look at.

See one order move through it, and break it yourself, in SS2 / Systems.

Decisions and trade-offs

One binary by default, microservices as an option. It started as eight services and Kafka, which wanted well over 1 GB of RAM before doing anything. Most of that cost buys scale a single business doesn't need. Now every module runs in one process with one Postgres database, a schema per module, and the event bus behind an interface. The default bus is a small log table in Postgres with partitions, per-group offsets and a dead-letter table: a deliberately tiny Kafka look-alike. Setting EVENTS_BUS=kafka runs the same modules as separate services on Kafka. The cost is two deployment shapes that can drift apart, and a Postgres log that tops out around low thousands of events a minute.

Every service checks the login token itself. The alternative was for the gateway to check it once and pass signed identity headers inward. That is a second token format to get right, and one mistake in stripping client headers lets anyone claim to be anyone. With every module verifying the JWT, reaching a service directly still requires a valid login. The weakness is that every service holds the signing secret.

A fixed set of milestones under a configurable lifecycle. Food, parcels and freight each define their own states in YAML. Other modules can't react to state names that change per business, so every state maps onto a small fixed set of milestones (created, confirmed, ready for dispatch, in transit, completed, cancelled), and dispatch listens for ready for dispatch, whatever that state is called. The lifecycle is validated at boot: unreachable states, dead ends and unknown events stop it from starting.

Calls between modules stay HTTP, even inside one process. It looks odd, but the in-process calls go through an in-memory transport into the app's own router, with no socket involved. Every module keeps enforcing its own permissions, and the same code works whether it runs as one binary or eight services.

Results

It runs end to end. One script builds the stack, seeds demo data, places an order through the gateway and waits for it to be delivered, for food, courier and freight jobs.

  • About 126 MiB of RAM idle for the whole default stack, measured after a completed order and five minutes idle: the app itself 11 MiB, Postgres 104 MiB, Caddy 11 MiB. The target was under 1 GB.
  • 168 test functions across 58 test files, including one contract suite that both event buses must pass: per-job ordering, redelivery after a crash, dedupe, and dead-lettering.
  • Around 22,000 lines of Go and 3,400 of Dart.

It has not handled real orders yet. Payments are simulated and drivers' locations can be simulated for demos.

What I would do differently

  • Measure under load sooner. The idle number is real, but I don't yet know where the Postgres-backed bus starts to struggle. A load test on a real VPS should have come before some of the features.
  • Asymmetric token signing from the start. With a shared secret, any one compromised service could issue tokens. Signing at the gateway only, with services holding a public key, is a small change now and would have been free on day one.
  • Decide earlier how far "generic" goes. Jobs with several legs, such as laundry going to the shop and back days later, need their own dispatch trigger per leg. I designed for them and then left them out, because the milestone set has one ready for dispatch. That should have been settled before the data model, not after.

All work