Turn the messy "everyone drop your order in the group chat" ritual into one shared, live cart with an automatic per-person bill split.
The organizer starts a session and shares a link; everyone adds items to a shared live cart; the organizer locks it; the app produces one consolidated order and an automatic per-person bill with settlement tracking — all driven by domain events across cleanly separated bounded contexts.
This is a portfolio-grade backend built to demonstrate Domain-Driven Design, event-driven microservice-ready architecture, relational + NoSQL data, real-time, and end-to-end testing on a production-shaped stack.
GrubPool is a modular monolith with hexagonal (ports & adapters) structure, split into five
bounded contexts that never touch each other's tables — they communicate through domain
events on an EventBus port and read each other through published query ports. Swap the
in-process event bus for Redis Streams / Pub-Sub and any context extracts into its own service with
no domain changes.
flowchart LR
SESSIONS -- SessionLocked --> ORDERS
ORDERS -- OrderConsolidated --> SETTLEMENT
ORDERS -- OrderConsolidated --> SESSIONS
CARTS -. ItemAddedToCart .-> NOTIFICATIONS
SESSIONS --> NOTIFICATIONS
ORDERS --> NOTIFICATIONS
SETTLEMENT --> NOTIFICATIONS
SESSIONS --> REALTIME[Realtime WS]
CARTS --> REALTIME
ORDERS --> REALTIME
SETTLEMENT --> REALTIME
| Context | Aggregate | Responsibility |
|---|---|---|
| Sessions (core) | OrderingSession |
Create/join a round, unique display names, organizer-only lock, Open → Locked → Ordered |
| Carts | Cart |
Per-participant line items with a Money value object; live consolidated cart mirrored in Redis |
| Orders | ConsolidatedOrder |
Consolidate all carts on lock; lifecycle state machine Draft → Placed → Confirmed → OutForDelivery → Delivered (+ Cancelled) |
| Settlement | Bill |
Per-person shares via a split strategy (equal / proportional, exact integer money); settle to fully paid |
| Notifications | — | Idempotent event consumers (dedupe on eventId via a processed_events ledger); swappable notifier |
| Realtime | — | WebSocket gateway broadcasting events to a session:{id} room |
create session → join → add items → LOCK
└─(SessionLocked)→ Orders consolidates every cart into one placed order
└─(OrderConsolidated)→ Settlement issues a bill (one share per person)
└─(OrderConsolidated)→ Sessions moves the session to "Ordered"
settle each share → (last one) BillFullySettled
Every step is broadcast live over WebSockets and logged by the notifier.
- NestJS + TypeScript — modules, DI,
controllers → services → repositories(the same structured-backend patterns as ASP.NET Core / Spring) - PostgreSQL (TypeORM) — transactional source of truth
- Redis (ioredis) — live consolidated-cart read model + real-time feed
@nestjs/event-emitterbehind anEventBusport — in-process now, broker later- socket.io (
@nestjs/websockets) — real-time - Swagger / OpenAPI at
/docs - Jest + Supertest — domain unit tests + full-lifecycle E2E
- Docker + docker-compose, GitHub Actions CI, Render free-tier deploy
Requires Node ≥ 20 and Docker.
cp .env.example .env
docker compose up -d # Postgres + Redis
npm install
npm run start:dev # http://localhost:3000 · Swagger at /docsAlready running Postgres/Redis on 5432/6379? Set POSTGRES_PORT / REDIS_PORT (and the matching
URLs) in .env — the compose ports are overridable.
# create a session (note the returned id + organizerId)
curl -s -X POST localhost:3000/sessions -H 'content-type: application/json' \
-d '{"organizerName":"Bilal","currency":"AED"}'
# add an item, then lock (organizer-only) — use the ids from above
curl -X POST localhost:3000/sessions/$SID/participants/$OID/items \
-H 'content-type: application/json' -d '{"name":"Shawarma","unitPriceMinor":1500,"qty":2}'
curl -X POST localhost:3000/sessions/$SID/lock \
-H 'content-type: application/json' -d "{\"requestedBy\":\"$OID\"}"
# the order and bill are produced automatically by the event pipeline
curl localhost:3000/sessions/$SID/order
curl localhost:3000/orders/$ORDER_ID/billThe interactive Swagger UI at /docs documents every endpoint.
| Method | Path | Purpose |
|---|---|---|
POST |
/sessions |
Create a session |
GET |
/sessions/:id |
Get a session + participants |
POST |
/sessions/:id/participants |
Join with a display name |
POST |
/sessions/:id/lock |
Lock (organizer-only) → consolidation |
GET |
/sessions/:id/cart |
Live consolidated cart (Redis-backed) |
POST |
/sessions/:id/participants/:pid/items |
Add a cart item |
PATCH |
/sessions/:id/participants/:pid/items/:itemId |
Change quantity |
DELETE |
/sessions/:id/participants/:pid/items/:itemId |
Remove an item |
GET |
/sessions/:id/order · /orders/:id |
Get the consolidated order |
POST |
/orders/:id/advance · /orders/:id/cancel |
Move the order lifecycle |
GET |
/orders/:id/bill |
Get the per-person bill |
POST |
/bills/:id/shares/:pid/settle |
Mark a share paid |
GET |
/health |
Liveness |
Real-time: connect a socket.io client, emit('join', { sessionId }), and receive every
session_event for that session.
Money is always integer minor units (unitPriceMinor: 1500 = 15.00) — never floats.
npm test # domain unit tests (Money, aggregates, split strategy, state machine)
npm run test:e2e # full lifecycle + realtime over HTTP (needs docker compose up)
npm run lint # check-only (npm run lint:fix to autofix)- Unit — pure domain:
Moneyarithmetic, session/cart/order/bill invariants, the order state machine, and the split strategy's exact integer money math. - E2E —
order-lifecycle.e2e-spec.tsdrives the entire happy path over HTTP and asserts the effect of every cross-context event, plus the guard rails (duplicate name, non-organizer lock, frozen carts after lock, illegal state jumps, double-settle).realtime.e2e-spec.tsasserts a joined client receives the events live. - CI runs lint + build + unit + e2e on every push (Postgres & Redis service containers).
Moneyvalue object — integer minor units + currency; rejects float amounts, non-integer multipliers, and cross-currency math. The bill split uses the largest-remainder method so shares always sum to the exact total — no rounding leaks.- Ports & adapters — every context depends on interfaces (
SessionRepository,EventBus,Notifier,ProcessedEventStore); Postgres/Redis/console are swappable adapters. - Events for change, query ports for reads — state changes propagate as integration events; when one context needs another's data synchronously (lock's item check, order consolidation) it calls a published query port, never the other's tables.
- Idempotent consumers — at-least-once delivery is deduped on
eventIdvia aprocessed_eventsledger, and the cross-context reactions (consolidate, issue bill) are guarded so a re-delivered event can't create duplicates. - Postgres + Redis, each for what it's good at — Postgres is the transactional source of truth; Redis serves the hot consolidated-cart read and powers real-time.
- Monolith → microservices — contexts sit behind an
EventBusport; swapping the in-process adapter for Redis Streams / Pub-Sub extracts a context to its own service with zero domain change.
src/
shared/ Money, AggregateRoot, DomainEvent, EventBus port + adapter, config, filters
contexts/
sessions/ domain · application · infrastructure · api (vertical slice)
carts/
orders/
settlement/
notifications/
realtime/
test/ order-lifecycle.e2e-spec.ts · realtime.e2e-spec.ts
Each context is a vertical slice: domain → application → infrastructure → api.
A render.yaml blueprint provisions the Docker web service plus a free Postgres
and Redis on Render: New + → Blueprint → point at this repo. All infra is
env-driven (DATABASE_URL, REDIS_URL), so any host works. DB_SYNCHRONIZE=true auto-creates the
schema for the demo; use migrations for a long-lived database.
- Extract Notifications into its own service over Redis Streams (the payoff of the EventBus port).
- Magic-link / QR join, and a WhatsApp notifier (Twilio) to close the loop on the original pain point.
- Auth (the organizer id is currently passed explicitly) and rate limiting.
- TypeORM migrations in place of
synchronizefor production.