OrderFlow is a small learning project for building an event-driven purchase workflow with NestJS, Postgres, and RabbitMQ.
It models a course purchase from order creation through asynchronous payment processing, using a transactional outbox so database changes and published events stay reliable. The goal is not to build a production shop, but to make the moving parts of event-driven systems visible and testable.
You can use this repo to explore:
- creating an order through an HTTP API
- storing domain events in an outbox table
- publishing events to RabbitMQ
- consuming events in another module
- updating order state from asynchronous payment results
- handling duplicate messages safely with processed-event tracking
backend/contains the NestJS API, domain modules, RabbitMQ consumers, and outbox publisher.frontend/contains the minimal React debug UI.specs/project.mdcontains the full project specifications, architecture notes, and implementation order.
docker compose up -d postgres rabbitmq
cd backend
cp .env.example .env
npm install
npm run start:devThe backend starts at http://localhost:3000.
Create an order:
curl -X POST http://localhost:3000/orders \
-H 'content-type: application/json' \
-d '{
"userId": "00000000-0000-0000-0000-000000000001",
"courseId": "00000000-0000-0000-0000-000000000002",
"amount": "49.99"
}'Install dependencies:
cd backend
npm installCreate a local environment file:
cd backend
cp .env.example .envThe backend uses backend/.env for local development. The test:e2e script
sets NODE_ENV=test and loads backend/.env.test, which points at the
separate orderflow_test database.
Start local infrastructure:
docker compose up -d postgres rabbitmqStart the backend in watch mode:
cd backend
npm run start:devWith TYPEORM_SYNCHRONIZE=true in .env, TypeORM automatically creates tables from entities at startup (development only).
RabbitMQ is available at:
- AMQP:
localhost:5672 - Management UI:
http://localhost:15672 - Username:
orderflow - Password:
orderflow
The backend listens on http://localhost:3000 by default.
Install dependencies:
cd frontend
npm installStart the frontend in watch mode:
cd frontend
npm run devThe frontend listens on http://localhost:5173 by default. Vite proxies /api
requests to the backend at http://localhost:3000, so keep the backend running
when testing UI calls.
Run the current checks:
cd backend
npm run build
npm run lint
npm run test
cd ../frontend
npm run build
npm run testThe e2e test imports the full Nest application and therefore expects the local database settings to be available. Start Postgres first if you run:
cd backend
npm run test:e2eThe e2e suite uses a separate Postgres database named orderflow_test by
default. It creates that database when needed, then truncates only test-looking
database names before each test. This keeps local development data in
orderflow away from the e2e cleanup step.
Implemented so far:
- TypeORM and Postgres wiring in the Nest app module.
- Domain module shells for
orders,payments,enrollments,events, andnotifications. - TypeORM entities for the core tables:
orderspaymentsenrollmentsoutbox_eventsprocessed_eventsnotifications
- Enums for order, payment, enrollment, and notification statuses/types.
- A local
docker-compose.ymlwith Postgres and RabbitMQ services. backend/.env.examplewith the database settings used by the local compose stack.- A simple root endpoint returning backend status text.
Implemented so far:
POST /ordersendpoint.CreateOrderServiceapplication use case.OrderRepositoryport with a TypeORM implementation.- Transaction runner abstraction backed by TypeORM transactions.
- Outbox event repository abstraction backed by the
outbox_eventstable. - Order creation with initial
PENDINGstatus. - Transactional
order.createdoutbox write in the same database transaction as the order row. - Unit tests for the create-order use case.
- E2E coverage proving
POST /orderscreates both the order and the outbox event.
Implemented so far:
- Polling outbox publisher registered in the backend process.
- RabbitMQ publishing through a durable topic exchange named
orderflow.eventsby default. - Event routing keys match
outbox_events.type, for exampleorder.created. - Successful publishes set
outbox_events.published_at. - Failed publishes keep the row unpublished, increment
retry_count, and storelast_errorfor later retry/inspection. - Publisher configuration through
.env:RABBITMQ_EXCHANGEOUTBOX_PUBLISHER_ENABLEDOUTBOX_PUBLISHER_POLL_INTERVAL_MSOUTBOX_PUBLISHER_BATCH_SIZE
- Unit tests for successful publish and failed publish handling.
Implemented so far:
- RabbitMQ
order.createdconsumer registered in the backend process. - Durable queue named
payments.order-created.v1by default. - Queue binding from
payments.order-created.v1to theorderflow.eventstopic exchange with routing keyorder.created. ProcessOrderCreatedServiceapplication use case.PaymentRepositoryport with a TypeORM implementation.- Fake payment provider behavior that succeeds by default.
- Payment creation with
SUCCEEDEDstatus and one attempt. - Transactional
payment.succeededoutbox write in the same database transaction as the payment row. - Duplicate
order.createdhandling at the payment table boundary: if a payment already exists for the order, no second payment or event is created. - Consumer configuration through
.env:RABBITMQ_CONSUMERS_ENABLEDRABBITMQ_PAYMENTS_ORDER_CREATED_QUEUE
- Unit tests for the payment use case.
Implemented so far:
- RabbitMQ payment-events consumer registered in the Orders module.
- Durable queue named
orders.payment-events.v1by default. - Queue bindings from
orders.payment-events.v1to theorderflow.eventstopic exchange with routing keys:payment.succeededpayment.failed
ProcessPaymentEventServiceapplication use case.- Order repository support for finding an order and updating its status.
payment.succeededupdates aPENDINGorder toPAID.payment.failedupdates aPENDINGorder toPAYMENT_FAILED.- Explicit transition validation: invalid order status transitions are rejected.
- Processed-event idempotency guard backed by the
processed_eventstable. - Duplicate payment events are skipped for the stable consumer name
orders.payment-events.v1. - Consumer configuration through
.env:RABBITMQ_ORDERS_PAYMENT_EVENTS_QUEUE
- Unit tests for paid, payment-failed, duplicate-event, and invalid-transition behavior.
Implemented so far:
- RabbitMQ
payment.succeededconsumer registered in the Enrollments module. - Durable queue named
enrollments.payment-succeeded.v1by default. - Queue binding from
enrollments.payment-succeeded.v1to theorderflow.eventstopic exchange with routing keypayment.succeeded. ProcessPaymentSucceededServiceapplication use case.EnrollmentRepositoryport with a TypeORM implementation.payment.succeedednow carriescourseIdso enrollment can grant access without reading Orders internals.- Enrollment creation with
GRANTEDstatus. - Transactional
enrollment.grantedoutbox write in the same database transaction as the enrollment row. - Processed-event idempotency guard backed by the
processed_eventstable. - Duplicate payment events are skipped for the stable consumer name
enrollments.payment-succeeded.v1. - Duplicate enrollment creation is also guarded at the enrollment table boundary: if an enrollment already exists for the order, no second enrollment or event is created.
- Consumer configuration through
.env:RABBITMQ_ENROLLMENTS_PAYMENT_SUCCEEDED_QUEUE
- Unit tests for enrollment creation, duplicate-event handling, and existing enrollment handling.
Implemented so far:
- RabbitMQ order lifecycle consumer registered in the Orders module.
- Durable queue named
orders.lifecycle-events.v1by default. - Queue bindings from
orders.lifecycle-events.v1to theorderflow.eventstopic exchange with routing keys:enrollment.grantedenrollment.failedrefund.succeeded
ProcessOrderLifecycleEventServiceapplication use case.enrollment.grantedupdates aPAIDorder toCOMPLETED.enrollment.failedupdates aPAIDorder toREFUND_IN_PROGRESSand writesrefund.requestedto the outbox in the same transaction.- Duplicate or repeated
enrollment.failedevents do not write anotherrefund.requestedonce the order is alreadyREFUND_IN_PROGRESS. refund.succeededupdates aREFUND_IN_PROGRESSorder toREFUNDED.- RabbitMQ
refund.requestedconsumer registered in the Payments module. - Durable queue named
payments.refund-requested.v1by default. ProcessRefundRequestedServiceapplication use case.refund.requestedupdates a succeeded payment toREFUND_SUCCEEDEDand writesrefund.succeededto the outbox in the same transaction.- Duplicate refund requests are skipped for the stable consumer name
payments.refund-requested.v1; already-refunded payments do not emit anotherrefund.succeeded. - Consumer configuration through
.env:RABBITMQ_ORDERS_LIFECYCLE_EVENTS_QUEUERABBITMQ_PAYMENTS_REFUND_REQUESTED_QUEUE
- Unit tests for order completion, refund request emission, refund completion, duplicate handling, and invalid transition behavior.
Implemented so far:
GET /ops/debugreturns:- last 10 orders
- timeline events for those orders
- last 10 outbox events
- Scenario endpoints used by the frontend:
POST /ops/scenarios/order-successPOST /ops/scenarios/payment-failurePOST /ops/scenarios/enrollment-failure
POST /ops/outbox/:id/republishpublishes an existing outbox event to RabbitMQ again so duplicate handling can be tested from the UI.- The frontend scenario buttons call the real
/opsendpoints through the Vite/apiproxy. - The frontend polls
/ops/debugperiodically so async state transitions become visible without manual refresh. - Payment and enrollment failure scenarios use reserved scenario course IDs, so
they still travel through the normal outbox, RabbitMQ, and consumer flow:
- payment failure emits
payment.failed - enrollment failure emits
enrollment.failed, then compensation emitsrefund.requestedandrefund.succeeded
- payment failure emits
Not implemented yet:
- Dedicated processed-events UI table.
- Advanced replay tooling beyond outbox re-publish.
The orders feature is split into small layers so the HTTP API, business flow, database persistence, and event contract stay separate.
backend/src/orders/
|-- orders.module.ts
| Registers the controller, create service, Order entity, and repository binding.
|
|-- orders.controller.ts
| POST /orders
| - accepts CreateOrderDto from the request body
| - reads optional x-correlation-id header
| - delegates to CreateOrderService
| - maps the saved Order entity to CreateOrderResponseDto
|
|-- dto/
| `-- create-order.dto.ts
| Request/response shapes for the HTTP boundary.
|
|-- application/
| |-- create-order.service.ts
| | Main use case:
| | - opens a transaction
| | - creates a pending order through OrderRepository
| | - creates an order.created event payload
| | - appends that event to the transactional outbox
| |
| `-- order-repository.ts
| Repository port/interface used by the application layer.
| The service depends on this abstraction, not on TypeORM directly.
|
|-- infrastructure/
| `-- typeorm-order.repository.ts
| Repository adapter that implements OrderRepository with TypeORM.
| It uses the transaction EntityManager when one is provided.
|
|-- entities/
| `-- order.entity.ts
| TypeORM mapping for the orders database table.
|
|-- contracts/
| `-- events.ts
| Domain event payload types published through the outbox.
|
`-- order-status.enum.ts
Shared order status values, for example pending.
POST /orders
|
v
OrdersController.create()
|
| CreateOrderDto + optional x-correlation-id
v
CreateOrderService.create()
|
| transaction.run(...)
v
+----------------------------- transaction -----------------------------+
| |
| OrderRepository.create(...) |
| | |
| v |
| TypeormOrderRepository.create(...) |
| | |
| v |
| orders table |
| |
| build OrderCreatedEventV1 |
| | |
| v |
| OutboxEventRepository.append(...) |
| | |
| v |
| outbox_events table |
| |
+-----------------------------------------------------------------------+
|
v
Order entity -> CreateOrderResponseDto -> HTTP 201
The key idea is that CreateOrderService owns the business workflow, while
TypeormOrderRepository owns the database details. The ORDER_REPOSITORY
symbol in order-repository.ts is the Nest injection token that connects those
two pieces in orders.module.ts.
Payment events are domain facts published by the payments module through the outbox. RabbitMQ routing sends those facts to the Orders module queue so orders can update their own lifecycle state.
payments emits payment.succeeded
|
v
outbox_events row with type = payment.succeeded
|
v
OutboxPublisherService
|
| publish to exchange orderflow.events
| routing key payment.succeeded
v
RabbitMQ topic exchange
|
| binding: payment.succeeded -> orders.payment-events.v1
v
orders.payment-events.v1 queue
|
v
AmqpPaymentEventsConsumer
|
v
ProcessPaymentEventService
|
| insert processed_events(event_id, consumer)
| find order
| validate transition
| update status
v
orders.status = PAID
payment.failed follows the same route, but updates a PENDING order to
PAYMENT_FAILED.
bindQueue(...) sets up the RabbitMQ routing. consume(...) is the part that
actually listens to messages from the queue.
payment.succeeded ----+
|
v
orders.payment-events.v1
^
|
payment.failed -------+
For this step, the workflow intentionally skips PAYMENT_IN_PROGRESS; newly
created orders remain PENDING until a final payment event changes them to
PAID or PAYMENT_FAILED.