Skip to content

codebybilal18/grubpool-backend

Repository files navigation

GrubPool — Group Order Coordinator (backend)

CI License: MIT Node

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.


Architecture at a glance

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
Loading
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

The happy path (what the events do)

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.

Tech stack

  • 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-emitter behind an EventBus port — 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

Quick start

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 /docs

Already running Postgres/Redis on 5432/6379? Set POSTGRES_PORT / REDIS_PORT (and the matching URLs) in .env — the compose ports are overridable.

Try the whole lifecycle with curl

# 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/bill

The interactive Swagger UI at /docs documents every endpoint.


API

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.


Testing

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: Money arithmetic, session/cart/order/bill invariants, the order state machine, and the split strategy's exact integer money math.
  • E2Eorder-lifecycle.e2e-spec.ts drives 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.ts asserts a joined client receives the events live.
  • CI runs lint + build + unit + e2e on every push (Postgres & Redis service containers).

Design decisions worth calling out

  • Money value 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 eventId via a processed_events ledger, 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 EventBus port; swapping the in-process adapter for Redis Streams / Pub-Sub extracts a context to its own service with zero domain change.

Project structure

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.


Deploy (free)

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.


What I'd do next

  • 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 synchronize for production.

License

MIT

About

Turn group food orders into one shared live cart with automatic per-person bill splitting. Event-driven DDD backend - NestJS, Postgres, Redis, WebSockets, full E2E tests.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors