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.

after commitonline ridersofferfanoutacceptmerchantweb · whatsapp · telegramapi · gochi · sqlc · pgxpostgresdeliveries + historyrabbitmqdispatch · rejectionredispresence · locksstomp serverany instancerider appwebsocket
The dispatch path. A rider's socket lives on one instance, so offers fan out through Redis and reach the rider from whichever instance holds the connection.

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.

  • PENDING becomes ASSIGNED when a rider accepts, or UNASSIGNED when 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.

GoJava 21Spring BootRabbitMQRedisPostgreSQL