A prototype delivery/logistics tracking platform built as a set of independent microservices coordinated through a message broker, rather than one monolithic app. It models a real-world scenario: a client submits a delivery order, which then has to clear three separate backend systems — a Cargo Management System (CMS), a Warehouse Management System (WMS), and a Route Optimisation System (ROS) — each speaking its own protocol, without ever making the client wait for that to finish.
The core idea is decoupling via a message queue, combined with an orchestrated Saga for the multi-step backend transaction:
- The client should never block on CMS/WMS/ROS — it gets a
202 Acceptedimmediately, and the real work happens asynchronously. - If one of the three backend steps fails partway through, whatever already succeeded needs to be undone (compensation), not left in a half-finished state.
- Delivery-status changes (a driver marking a package delivered) need to reach the client in real time, not on the next page refresh.
flowchart LR
CP[Client Portal<br/>client-portal/] -- "POST /login, /orders" --> OS[Order Service<br/>:5000]
DA[Driver App<br/>driver-app/] -- "POST /login,<br/>/deliveries/:id/status" --> OS
OS -- "INSERT order PENDING" --> PG[(Postgres)]
OS -- "publish order.created" --> MQ{{RabbitMQ<br/>swifttrack exchange}}
MQ -- "order.created" --> SW[Saga Worker]
SW -- "XML/HTTP" --> CMS[mock-cms :5001]
SW -- "JSON/HTTP" --> ROS[mock-ros :5002]
SW -- "TCP/JSON" --> WMS[mock-wms :6000]
SW -- "UPDATE order CONFIRMED/FAILED" --> PG
SW -- "publish order.completed / order.failed" --> MQ
OS -- "publish delivery.updated" --> MQ
MQ -- "order.#, delivery.#" --> NS[Notification Service<br/>:5003]
NS -- "WebSocket push" --> CP
NS -- "WebSocket push" --> DA
| Component | Role | Protocol it speaks |
|---|---|---|
Order Service (order-service/) |
Thin API: validates, persists PENDING, publishes an event, returns 202 immediately. Also handles JWT login and delivery-status updates. |
HTTP/JSON |
Saga Worker (saga-worker/) |
Separate consumer process. Picks up order.created, drives CMS → WMS → ROS in sequence, compensates on failure, updates Postgres. |
AMQP consumer |
| mock-cms | Stands in for a legacy on-prem CMS. | XML over HTTP |
| mock-ros | Stands in for a modern cloud route optimiser. | JSON/REST |
| mock-wms | Stands in for a proprietary warehouse protocol. | Newline-delimited JSON over raw TCP |
Notification Service (notification-service/) |
Subscribes to all order.*/delivery.* events, rebroadcasts over WebSocket. |
AMQP consumer + Socket.IO |
Client Portal (client-portal/index.html) |
Log in, submit orders, watch them go live PENDING → CONFIRMED. | Static HTML/JS |
Driver App (driver-app/index.html) |
Log in, see confirmed orders as a manifest, mark delivered/failed. | Static HTML/JS |
| RabbitMQ | Topic exchange (swifttrack) decoupling every producer from every consumer. |
AMQP |
| Postgres | Source of truth for order/delivery state and idempotency keys. | — |
- Orchestrated Saga + compensation —
saga-worker/worker.py'srun_saga(). If ROS fails after CMS/WMS already succeeded, it calls WMS's and CMS's cancel operations in reverse order. - Circuit breaker —
pybreakerwraps the ROS call; 3 consecutive failures trips it, 20s cooldown before retrying. - Competing consumers — the Saga Worker's queue is
durablewith manual ack, so redelivery on a crash doesn't lose an order. - Idempotent consumer —
process_order()checks the order's current status before reprocessing, guarding against RabbitMQ redelivery. - Idempotency on the API — a repeated
Idempotency-Keyheader onPOST /ordersreturns the original order instead of creating a duplicate, backed by a Postgres table so it survives a service restart. - Publish–subscribe — the
swifttracktopic exchange withorder.#anddelivery.#routing keys, so the Saga Worker and Notification Service each get only what they care about. - Protocol/data-translation adapters — real XML built for CMS, JSON for ROS, newline-delimited TCP for WMS.
- Docker Desktop (running)
curl(or any HTTP client) for the demo commands below
First create your local configuration file and replace its demo values:
cp .env.example .envdocker compose up --build -dThis starts every component in its own container:
- RabbitMQ:
localhost:5672(management UI atlocalhost:15672; use the credentials in.env) - PostgreSQL:
localhost:5432(credentials and database name from.env) —orders,idempotency_keys, andusersare created automatically frominit.sqlon first start only (see Troubleshooting if you change the schema later). - CMS / ROS / WMS mocks:
localhost:5001,localhost:5002, andlocalhost:6000 - Order Service / Notification Service:
localhost:5000andlocalhost:5003 - Client Portal:
http://localhost:8090(not:8080— pick whatever's free on your machine and adjust the port mapping indocker-compose.ymlandALLOWED_ORIGINSin.envif8090is already taken) - Driver App:
http://localhost:8081
Follow the services with:
docker compose ps
docker compose logs -f saga-workerdocker compose up waits for RabbitMQ and PostgreSQL health checks before
starting dependent services. No local Python installation or separate service
terminals are required.
Scaling the Saga Worker — since it's a competing consumer on a durable queue, you can run several instances with no code change:
docker compose up -d --scale saga-worker=3Submit a few orders and each lands on whichever replica RabbitMQ hands it to
next (prefetch_count=1 on the queue). Scale back down with
docker compose up -d --scale saga-worker=1 when you're done.
Log in and submit an order, either through the Client Portal at
http://localhost:8090,
or directly:
CLIENT_TOKEN=$(curl -s -X POST http://localhost:5000/login \
-H "Content-Type: application/json" \
-d '{"username":"client-demo","password":"change-this-client-password"}' | python -c "import sys,json;print(json.load(sys.stdin)['token'])")
curl -i -X POST http://localhost:5000/orders \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $CLIENT_TOKEN" \
-H "Idempotency-Key: demo-key-1" \
-d '{"clientName": "Kandy Traders", "addresses": ["123 Galle Rd", "45 Duplication Rd"]}'For the driver-only delivery endpoint, obtain a separate driver token:
DRIVER_TOKEN=$(curl -s -X POST http://localhost:5000/login \
-H "Content-Type: application/json" \
-d '{"username":"driver-demo","password":"change-this-driver-password"}' | python -c "import sys,json;print(json.load(sys.stdin)['token'])")The response comes back 202 immediately with "status": "PENDING" —
before CMS/WMS/ROS have even been called. A moment later:
- The Saga Worker's terminal logs the CMS/WMS/ROS calls happening.
client-portal/index.htmlflips that order's row toCONFIRMEDlive.- Or query it directly:
curl http://localhost:5000/orders/<orderId>
Mark it delivered, either via "Mark delivered" on the order's card in
driver-app/index.html, or:
curl -X POST http://localhost:5000/deliveries/<orderId>/status \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $DRIVER_TOKEN" \
-d '{"status": "DELIVERED"}'Watch client-portal/index.html pick up the delivery status live in the
same row.
Demo idempotency — repeat the exact same curl command with the same
Idempotency-Key. You get the same orderId back and no second
order.created event fires (nothing new appears in the Saga Worker's
terminal).
Demo saga compensation + the circuit breaker:
curl -X POST http://localhost:5002/routes/toggle-failure -H "X-API-Key: change-this-ros-api-key"
curl -i -X POST http://localhost:5000/orders \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $CLIENT_TOKEN" \
-H "Idempotency-Key: demo-key-2" \
-d '{"clientName": "Colombo Mart", "addresses": ["10 Marine Drive"]}'Watch the Saga Worker's terminal: CMS and WMS both succeed, ROS fails, and
you'll see the compensating calls fire against CMS's /cancel and WMS's
cancel handling. The order ends up FAILED with failedStep: "ros". Submit
two or three more orders (new Idempotency-Key each time) while failure
mode is still on — a later attempt should trip the circuit breaker, and the
failure reason will say "circuit breaker open" instead of a raw connection
error. Toggle failure mode off again afterwards
(curl -X POST http://localhost:5002/routes/toggle-failure -H "X-API-Key: change-this-ros-api-key").
A pytest suite covers order-service (auth/role enforcement, request
validation, idempotent order submission, order history/listing and
ownership scoping on GET, delivery status updates) and saga-worker
(the saga's success path, all three compensation paths, and
process_order's claim/update/publish control flow). Postgres and
RabbitMQ are mocked out, so it runs with nothing started — no Docker
required.
pip install -r requirements-test.txt
pytest| Method | Path | Auth | Notes |
|---|---|---|---|
POST |
/login |
Username + password | Issues a JWT with the configured client or driver role |
POST |
/orders |
Client JWT + Idempotency-Key header |
Returns 202 immediately |
GET |
/orders |
JWT | History/list: clients get their own orders, drivers get every order (operational manifest). Used to seed the UI on login. |
GET |
/orders/<id> |
JWT | Clients can view only their own orders; drivers can view operational orders |
POST |
/deliveries/<id>/status |
Driver JWT | status must be DELIVERED or FAILED |
GET |
/health |
— | On every service |
POST |
/routes/toggle-failure |
ROS API key | mock-ros only, flips simulated ROS outage on/off |
order-service/ Order API: validation, 202-immediately, auth, idempotency
saga-worker/ Saga orchestration, compensation, circuit breaker
mock-cms/, mock-ros/, mock-wms/ Simulated backend systems, one protocol each
notification-service/ RabbitMQ → WebSocket bridge
client-portal/ Client-facing static UI (+ Dockerfile, nginx)
driver-app/ Driver-facing static UI (+ Dockerfile, nginx)
shared/ CSS shared by both UIs
tests/ pytest suite — see "Running the tests" above
Dockerfile Shared image for the five Python services
docker-compose.yml Every service containerized: RabbitMQ, Postgres,
the three mocks, order-service, saga-worker,
notification-service, and both UIs
init.sql Schema: orders, idempotency_keys, users
This is a prototype built incrementally across a multi-week assignment.
Current gaps (tracked in more detail in ROADMAP.md):
- Browser connections use JWTs and client events are scoped to the owner; drivers receive the operational manifest. Driver assignment per individual driver is not yet implemented.
.envis deliberately excluded from Git..env.examplecontains only replaceable local demo values.- CMS uses HTTP Basic Authentication, ROS uses an API key, and the WMS TCP protocol uses a shared service token. These are demonstration controls; production deployment still requires TLS or mTLS/VPN protection.
- No TLS anywhere (acceptable for local dev; a real deployment would terminate TLS at a gateway/load balancer).
- No retry/backoff on the WMS TCP call specifically (RabbitMQ connections do retry with backoff — see Troubleshooting below).
RabbitMQ's healthcheck can pass slightly before its AMQP listener is
actually ready for connections. rabbitmq-diagnostics -q ping (used in
docker-compose.yml's healthcheck) confirms the node is up, not that the
AMQP port is accepting new client connections yet — so saga-worker and
notification-service can still hit pika.exceptions.AMQPConnectionError
in the few seconds after RabbitMQ reports healthy. Both now retry that
connection with backoff (up to 10 attempts, 3s apart) instead of crashing,
so this resolves itself on its own within docker compose up; you'll see
RabbitMQ not ready yet (attempt N/10) in docker compose logs saga-worker
if it happens. order-service does the same (shorter backoff) around
publish_event on the request path. restart: unless-stopped on every
app service is a second line of defense if a container exits for any other
reason.
Changed init.sql but Postgres doesn't have the new table/column.
Docker only runs docker-entrypoint-initdb.d scripts the first time a
volume is created. If you add a table to init.sql after Postgres has
already been running, apply it manually instead of recreating the volume
(which would drop existing data):
docker exec swifttrack-postgres psql -U swift -d swifttrack -c "<your new CREATE TABLE statement>"The client portal / driver app table was empty after a refresh — fixed.
Both pages used to be pure live views with no history: they only rendered
events received over the WebSocket after the page connected, so logging
out and back in showed nothing until you submitted a fresh order. Both UIs
now call GET /orders once on login to seed the table with real history
before the WebSocket takes over for live updates — see the API reference
below. The data was always in Postgres regardless; you can still check it
directly with GET /orders/<id> or the query below.
Inspecting Postgres directly:
docker exec -it swifttrack-postgres psql -U swift -d swifttrack -c "SELECT * FROM orders;"Or connect a GUI (DBeaver, pgAdmin, the PostgreSQL VS Code extension) using
the PostgreSQL host, user, password, and database values in .env.