Case study·Closed source·Live
Kova Delivery Platform
Multi-sided logistics backend
- Routes ported
- 195 of 196
- Runtime
- Go, from Spring Boot
- Realtime
- Hand-written STOMP 1.2
- Schema
- Goose, held to Flyway
Kova Delivery connects stores, riders, and customers. A store creates a delivery on the web, on WhatsApp, or on Telegram; Kova prices it and offers it to nearby online riders; the first rider to accept carries it, and everyone tracks it live. I built the backend behind it: the merchant API, the rider mobile API, and the admin operations the Kova team runs the service from.
It started as a Spring Boot 3.5 service on Java 21. In September 2026 I ported it to Go and retired the Java service, without changing a single path, request shape, or response shape the apps depend on.
A constrained lifecycle
A delivery is a small state machine, enforced in one place, and every transition is written to a history table with who caused it: the rider, an admin, the merchant, or the system.
PENDINGbecomesASSIGNEDwhen a rider accepts, orUNASSIGNEDwhen nobody does.ASSIGNED→PICKED_UP→IN_TRANSIT→DELIVERED→CONFIRMED.- A merchant can cancel until pickup. After pickup the only way out is
FAILED, which brings in the team.
Keeping the transitions in a table rather than spread across handlers means the dashboard, the rider app, and the background jobs can't disagree about what's allowed.
Dispatch
Creating a delivery publishes to a dispatch queue. The dispatcher picks the online riders, opens an acceptance window, and pushes each one an offer over their WebSocket with the full delivery and the time they have to answer. A rider who declines sends the job to a rejection queue and it goes out again.
Accepting is a race, and it's settled in Redis with a single SET NX that carries its own expiry. Two separate commands would leave the lock permanent if the process died between them.
Porting it to Go
The reason was hosting cost: a static binary in a scratch image instead of a JVM. The constraint was that three client apps and two dashboards were already in production, and none of them could be asked to change.
So the two services ran side by side against the same database, and traffic moved over domain by domain. Every place the Go service behaves differently from the Java one is written down in a parity ledger, on the rule that an unrecorded difference is a bug. Before the Java service was removed, a mechanical sweep compared the two: 195 of 196 routes, every environment variable one-for-one, all ten RabbitMQ exchanges, queues and routing keys identical, and every Redis key pattern present.
Replacing Spring's WebSocket layer
Java used Spring's in-memory STOMP broker. Go has nothing equivalent, so I wrote a STOMP 1.2 server and reproduced the behaviour the apps relied on, down to the destination string echoed back in each message. Two things it does that the original didn't:
- It works across instances. Messages are published through Redis pub/sub, and each instance delivers to the sessions it owns. The publisher doesn't also deliver locally, so there's one delivery path and no double-send.
- A stuck client can't stall dispatch. Each session's outbound queue is bounded. When it fills, the session is closed and the app reconnects, so one slow connection never holds up offers to everyone else.
Keeping the schema honest
Spring Boot ran Flyway at startup, so an instance could never serve traffic against a schema it hadn't migrated. The Go service keeps that guarantee with goose migrations embedded in the binary and applied before it listens, behind a Postgres advisory lock so two instances booting together don't race.
While both services existed, a test held the goose files to the Flyway files byte for byte. On first boot against production, goose baselined from Flyway's history table, reading it and never writing it, so the Java image stayed a valid rollback target until the day it was retired. Migrations stay additive for the same reason: an old instance has to keep working while a new one migrates.