Production-style Event-Driven Architecture (EDA) demo for e-commerce workflows.
Implemented in this repository:
infra/docker-compose.yml: Kafka, Kafka UI, PostgreSQL, topic initialization, and all implemented servicescontracts/event-envelope.json: standard event envelope schemacontracts/events/*.json: per-event schemasservices/order-service-spring: Spring Boot order service with Outbox pattern (POST /orders)services/inventory-service-spring: Spring Boot inventory reservation consumer (orders.events->inventory.events)services/payment-service-laravel: Laravel payment processor + Kafka adapter (orders.events->payments.events)services/order-orchestrator-nest: event-driven order orchestrator consuming inventory/payment outcomesservices/shipping-service-express-ts: Express + TypeScript shipping service consuming lifecycle eventsservices/read-model-laravel: Laravel CQRS read projections + Kafka adapter (orders.events,order.lifecycle.events,shipping.events)services/notification-service-express-ts: Express + TypeScript notification mock consumer (order.lifecycle.events,shipping.events)apps/web-nextjs: Next.js TypeScript UI using read-model API (/orders,/orders/[id])
Planned next: polish and observability improvements.
- Event-first communication via Kafka topics
- No direct synchronous coupling in the order workflow
- Eventual consistency
- Outbox pattern to avoid dual writes
- Idempotent consumers (shipping service persists processed
eventIdvalues in Postgres)
All events follow:
eventId(uuid)eventType(string)occurredAt(ISO timestamp)correlationId(uuid)producer(service name)version(integer)payload(object)
Schema file: contracts/event-envelope.json
orders.eventsinventory.eventspayments.eventsorder.lifecycle.eventsshipping.eventspayments.dlqinventory.dlq
infra/
docker-compose.yml
contracts/
event-envelope.json
events/*.json
services/
order-service-spring/
inventory-service-spring/
payment-service-laravel/
order-orchestrator-nest/
shipping-service-express-ts/
read-model-laravel/
notification-service-express-ts/
apps/
web-nextjs/
- Docker + Docker Compose
- Java 17+ (project compiles with release 17)
- Maven
- Node.js 20+ and npm
Copy defaults from .env.example:
cp .env.example .envDefault values:
KAFKA_BROKERS=localhost:9092DB_HOST=localhostDB_PORT=5432DB_USER=appDB_PASS=appDB_NAME=eventifyORDER_SERVICE_PORT=8081SHIPPING_SERVICE_PORT=8084PAYMENT_SERVICE_PORT=8085READ_MODEL_SERVICE_PORT=8086NOTIFICATION_SERVICE_PORT=8087WEB_NEXTJS_PORT=3000NEXT_PUBLIC_READ_MODEL_URL=http://localhost:8086
Use the root helper script to run everything locally:
./dev-local.sh upThis script runs the full Docker stack (including topic initialization and all services).
Useful commands:
./dev-local.sh status
./dev-local.sh logs all
./dev-local.sh logs order
./dev-local.sh logs inventory
./dev-local.sh logs orchestrator
./dev-local.sh logs shipping
./dev-local.sh logs payment
./dev-local.sh logs read-model
./dev-local.sh logs notification
./dev-local.sh logs web
./dev-local.sh restart
./dev-local.sh downdocker compose -f infra/docker-compose.yml up -d --buildVerify:
docker compose -f infra/docker-compose.yml ps
curl -sS http://localhost:8082/health
curl -sS http://localhost:8084/health
curl -sS http://localhost:8086/api/health
curl -sS http://localhost:8087/health
curl -sS http://localhost:3000/orders
docker exec eventify-kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list | sortExpected:
- Kafka running on
localhost:9092 - Kafka UI on
http://localhost:8080 - Postgres on
localhost:5432 - Order service on
localhost:8081 - Inventory service running as Kafka consumer (no HTTP port exposed)
- Payment service API on
localhost:8085andpayment-adapterKafka consumer running in Docker - Order orchestrator on
localhost:8082 - Shipping service on
localhost:8084 - Read model API on
localhost:8086andread-model-adapterKafka consumer running in Docker - Notification service on
localhost:8087 - Web UI on
http://localhost:3000 - required topics are listed (
orders.events,inventory.events,payments.events,order.lifecycle.events,shipping.events,payments.dlq,inventory.dlq)
Stop everything:
docker compose -f infra/docker-compose.yml down -v- Create an order and confirm outbox event emission.
curl -i -X POST http://localhost:8081/orders \
-H 'X-Correlation-Id: 11111111-1111-1111-1111-111111111111' \
-H 'Content-Type: application/json' \
-d '{
"customerId":"c-1001",
"items":[
{"sku":"SKU-RED-TSHIRT","quantity":2},
{"sku":"SKU-BLUE-CAP","quantity":1}
]
}'Expected:
- HTTP
201from order service - response includes the same
correlationId(if header is provided and valid UUID) - Message appears in Kafka UI topic
orders.eventswitheventType=OrderPlaced
- Let orchestrator emit lifecycle events automatically (no manual
OrderConfirmed).
Publish matching inventory + payment outcomes for the same orderId:
docker exec -i eventify-kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server localhost:9092 \
--topic inventory.events <<'EOF'
{"eventId":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","eventType":"InventoryReserved","occurredAt":"2026-01-01T00:00:00Z","correlationId":"11111111-1111-1111-1111-111111111111","producer":"inventory-service","version":1,"payload":{"orderId":"33333333-3333-3333-3333-333333333333"}}
EOF
docker exec -i eventify-kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server localhost:9092 \
--topic payments.events <<'EOF'
{"eventId":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","eventType":"PaymentSucceeded","occurredAt":"2026-01-01T00:00:05Z","correlationId":"11111111-1111-1111-1111-111111111111","producer":"payment-service","version":1,"payload":{"orderId":"33333333-3333-3333-3333-333333333333"}}
EOFExpected:
- Orchestrator publishes
OrderConfirmedtoorder.lifecycle.events orders.statusfor thatorderIdbecomesCONFIRMEDin Postgres- Duplicate input
eventIdvalues are ignored by orchestrator idempotency (processed_events)
- Verify inventory outcomes and idempotency from
OrderPlaced.
Publish OrderPlaced with stock available:
docker exec -i eventify-kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server localhost:9092 \
--topic orders.events <<'EOF'
{"eventId":"44444444-4444-4444-4444-444444444444","eventType":"OrderPlaced","occurredAt":"2026-01-01T00:00:00Z","correlationId":"55555555-5555-5555-5555-555555555555","producer":"order-service","version":1,"payload":{"orderId":"66666666-6666-6666-6666-666666666666","items":[{"sku":"SKU-RED-TSHIRT","quantity":2}]}}
EOFExpected:
- Inventory service publishes
InventoryReservedoninventory.events
Publish OrderPlaced with insufficient stock:
docker exec -i eventify-kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server localhost:9092 \
--topic orders.events <<'EOF'
{"eventId":"77777777-7777-7777-7777-777777777777","eventType":"OrderPlaced","occurredAt":"2026-01-01T00:01:00Z","correlationId":"88888888-8888-8888-8888-888888888888","producer":"order-service","version":1,"payload":{"orderId":"99999999-9999-9999-9999-999999999999","items":[{"sku":"SKU-GREEN-HOODIE","quantity":1}]}}
EOFExpected:
- Inventory service publishes
OutOfStockoninventory.events
Re-send the first event with same eventId=44444444-4444-4444-4444-444444444444:
- Inventory service skips it and does not reserve stock twice.
- Verify payment service success/failure and DLQ behavior.
Normal payment (PaymentSucceeded) using default event:
- Publish
OrderPlacedwith no force flags. - Expect
PaymentSucceededinpayments.events.
Forced business failure (PaymentFailed):
docker exec -i eventify-kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server localhost:9092 \
--topic orders.events <<'EOF'
{"eventId":"aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb","eventType":"OrderPlaced","occurredAt":"2026-01-01T00:03:00Z","correlationId":"cccccccc-1111-2222-3333-dddddddddddd","producer":"order-service","version":1,"payload":{"orderId":"eeeeeeee-1111-2222-3333-ffffffffffff","forcePaymentFailed":true,"items":[{"sku":"SKU-RED-TSHIRT","quantity":1}]}}
EOFExpect PaymentFailed in payments.events.
Forced transient exception -> retries -> DLQ:
docker exec -i eventify-kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server localhost:9092 \
--topic orders.events <<'EOF'
{"eventId":"bbbbbbbb-1111-2222-3333-cccccccccccc","eventType":"OrderPlaced","occurredAt":"2026-01-01T00:04:00Z","correlationId":"dddddddd-1111-2222-3333-eeeeeeeeeeee","producer":"order-service","version":1,"payload":{"orderId":"ffffffff-1111-2222-3333-aaaaaaaaaaaa","forceException":true,"items":[{"sku":"SKU-RED-TSHIRT","quantity":1}]}}
EOFExpect message published to payments.dlq after retries.
- Verify cancellation path.
Publish either OutOfStock or PaymentFailed for another orderId.
Expected:
- Orchestrator publishes
OrderCancelledtoorder.lifecycle.events orders.statusfor thatorderIdbecomesCANCELLED
- Prove shipping idempotency survives restart.
Restart shipping service and publish the exact same OrderConfirmed event again (same eventId):
pkill -f "shipping-service-express-ts" || true
cd services/shipping-service-express-ts
npm run devThen publish the same OrderConfirmed JSON (same eventId) again.
Expected:
- No second
ShipmentCreatedevent is produced for thateventId shipping.eventsstill has exactly one shipment event for that replayedeventId
- Verify read model projections and API.
For an existing order lifecycle, query projections:
curl -sS http://localhost:8086/api/orders
curl -sS http://localhost:8086/api/orders/33333333-3333-3333-3333-333333333333Expected:
GET /api/ordersreturns entries fromorders_viewGET /api/orders/:idreturnsorderand associatedshipments- Re-sending an already processed event (
same eventId) does not duplicate projection updates
- Verify notification service consumes lifecycle and shipping events.
curl -sS http://localhost:8087/notificationsExpected:
- Contains notification records for
OrderConfirmed,OrderCancelled, andShipmentCreatedevents - Every record includes
eventType,correlationId, andorderId - Re-sending an already processed event (
same eventId) is skipped by idempotency tablenotification_processed_events
- Verify web UI reads projections.
Open:
http://localhost:3000/ordershttp://localhost:3000/orders/<orderId>
Expected:
/ordersshows the projected order list from read-model service/orders/<orderId>shows order details and shipment entries when available- After new events are processed, refresh the page to see updated state
The following were run for this state:
docker compose -f infra/docker-compose.yml configmvn -Dmaven.repo.local=/workspaces/Eventify-Shop/.m2/repository testinservices/order-service-springmvn -Dmaven.repo.local=/workspaces/Eventify-Shop/.m2/repository testinservices/inventory-service-springphp artisan testinservices/payment-service-laravelphp artisan testinservices/read-model-laravelnpm install --no-audit --no-fundnpm run typechecknpm run buildinservices/shipping-service-express-tsnpm run typechecknpm run buildinservices/notification-service-express-tsnpm run typechecknpm run buildinapps/web-nextjs
- Inventory service reads consumer group from
KAFKA_GROUP_ID(default:inventory-service) and publishes poison events toinventory.dlq. - Payment service uses an adapter pattern: Node
payment-adapterhandles Kafka I/O and calls Laravel endpoint/api/internal/payments/process-order-placedfor idempotent payment decisions. - Read model service uses an adapter pattern: Node
read-model-adapterconsumesorders.events,order.lifecycle.events, andshipping.eventsthen applies projections through/api/internal/projections/apply. - Notification service consumes
order.lifecycle.eventsandshipping.events, logs correlation data, and exposes in-memory recent notifications viaGET /notifications. - Web UI uses
NEXT_PUBLIC_READ_MODEL_URLand proxies data through Next.js API routes to avoid browser CORS issues in local development. - Shipping service reads consumer group from
SHIPPING_KAFKA_GROUP_ID(default:shipping-service-group). - Orchestrator reads consumer group from
KAFKA_GROUP_ID(default:order-orchestrator). - Order service currently uses JPA
ddl-auto=update; migrations can be added next.