A property maintenance management platform built as a NestJS monorepo with a microservices, event-driven architecture — RabbitMQ for work distribution, Kafka for the audit stream, LLM-powered triage, a transactional outbox, JWT auth, and full test coverage from unit to full-stack e2e.
This repository doubles as a living study project: every architectural decision is documented as an ADR with its trade-offs, and each phase of the roadmap focuses on one production concern (messaging, observability, resilience, AI integration).
Property managers handle a constant stream of maintenance requests: a tenant reports a leaking tap, the request must be triaged, assigned to a contractor, tracked to completion, and everyone involved must be notified along the way. PropFlow models that flow:
- Work Orders — the core aggregate: maintenance requests and their lifecycle (
open → assigned → in_progress → completed), with async LLM triage (category + urgency). - Properties — buildings and units that work orders belong to.
- Notifications — reacts to domain events (e.g.
work-order.created) asynchronously. - Audit — projects the Kafka event stream into a queryable activity feed.
flowchart LR
Client([Client]) --> GW[API Gateway]
GW --> WO[Work Orders Service]
GW --> PR[Properties Service]
GW --> AU[Audit Service]
WO -->|domain events| MQ[(RabbitMQ)]
WO -->|audit stream| KF[(Kafka)]
MQ -->|created events| WO
WO -.->|triage| AI[Anthropic API]
MQ --> NT[Notifications Service]
KF --> AU
WO --> DB1[(PostgreSQL)]
PR --> DB2[(PostgreSQL)]
AU --> DB3[(PostgreSQL)]
Each service owns its data (database-per-service). Services communicate synchronously through the gateway for queries and asynchronously through domain events for side effects — no service calls another service's database directly.
| Layer | Stack | Why / decisions |
|---|---|---|
| Runtime & language | Strict mode, monorepo path aliases | |
| Framework | Monorepo mode, one app per service — ADR-0001 | |
| Data | Database-per-service, reviewed migrations — ADR-0003 | |
| Messaging | Queue for fan-out, log for replayable history — ADR-0002, ADR-0004 | |
| Reliability | Dual-write closed, at-least-once end to end — ADR-0007 | |
| AI | Async LLM triage, structured outputs, best-effort — ADR-0006 | |
| Auth | Verified once at the edge, roles per route, actor in every event — ADR-0008 | |
| Observability | RED metrics, JSON logs, correlation ids via ALS — ADR-0005 | |
| Testing | 70+ unit tests, e2e per service, full-stack composition suite — coverage report | |
| Infra & CI | Compose for dev, manifests + NetworkPolicies in k8s/ | |
| API & docs | Swagger at /api/docs, AsyncAPI rendered, hosted docs site |
# infrastructure (PostgreSQL + RabbitMQ + Kafka)
docker compose up -d
# install & run (one terminal per service)
npm install
npm run start:dev work-orders-service # :3001
npm run start:dev notifications-service # :3002
npm run start:dev properties-service # :3003
npm run start:dev audit-service # :3004
npm run start:dev api-gateway # :3000 — public entry point (/api/*)
# tests
npm test # unit
npm run test:e2e # end-to-endRabbitMQ management UI: http://localhost:15672 (propflow / propflow).
Every business route requires a JWT. Log in with a demo user (see .env.example to customize):
curl -s -X POST localhost:3000/api/auth/login \
-H 'content-type: application/json' \
-d '{"email":"manager@propflow.dev","password":"propflow"}'
# -> { "accessToken": "...", "role": "manager" } — send as Authorization: Bearer <token>With the stack up (and ANTHROPIC_API_KEY set for the work-orders service), one command drives the whole platform through the gateway and narrates each step:
npm run demoIt authenticates, opens a work order whose tenant understates the priority as medium, and shows the LLM re-evaluating it to emergency — then runs the state machine and prints the audit feed with every event attributed (human vs. system (AI)). It's the fastest way to see auth, the state machine, the transactional outbox, both brokers, the audit projection and AI triage working end to end.
Recorded with VHS from a real run — regenerate with
vhs scripts/demo.tape.
Sample run (text)
PropFlow — live demo (http://localhost:3000/api)
OK gateway healthy
==> 1. Authenticate (role: manager)
OK JWT issued (eyJhbGciOiJIUzI1NiIs...)
OK request without a token -> 401 (auth is enforced)
==> 2. Register a property
OK propertyId c6e716a1-425e-4e8f-ac01-683e5a0418db
==> 3. Open a work order — tenant reports priority: medium
"strong smell of gas in the kitchen and the boiler is dead in winter"
OK workOrderId df0a1cd0-5255-4efb-836e-897fd02b0d15 (returns immediately — triage is async, off the write path)
==> 4. AI triage (LLM -> outbox -> Kafka), waiting...
OK classified in ~4s
category hvac
urgency emergency (the LLM raised it from the tenant's "medium")
reasoning A strong gas smell poses an active danger of fire or explosion, and combined with no heat in winter this is a life-safety emergency regardless of the tenant's stated priority.
==> 5. Lifecycle transitions (guarded by the state machine)
OK assign -> 200
OK in_progress -> 200
OK invalid in_progress->open -> 409 (rejected, as it should be)
==> 6. Activity feed — audit projection of the Kafka log
work-order.started by manager@propflow.dev
work-order.assigned by manager@propflow.dev
work-order.triaged by system (AI)
work-order.created by manager@propflow.dev
==> What just happened
1 request -> state + event committed atomically (outbox), never lost
outbox relay -> RabbitMQ (notifications) + Kafka (audit), at-least-once
AI triage ran async and re-evaluated the priority, attributed as 'system'
every action is auditable end to end, with who did it
done.
Each phase is a self-contained increment with tests and documentation.
- Phase 0 — Foundations: monorepo scaffold, Docker Compose (PostgreSQL + RabbitMQ), CI, ADR structure
- Phase 1 — Work Orders service: REST API, PostgreSQL + data modelling, validation, unit + e2e tests
- Phase 2 — Event-driven core: domain events over RabbitMQ, Notifications consumer, retries + dead-letter queue
- Phase 3 — Properties service + API Gateway: service composition, inter-service communication patterns
- Phase 4 — Observability: structured logging, correlation ids, Prometheus metrics, liveness/readiness probes
- Phase 5 — Kafka: event streaming for an audit/activity feed; RabbitMQ vs Kafka in practice
- Phase 6 — AI integration: LLM-powered triage of maintenance requests (urgency + category classification)
- Phase 7 — Production hardening: outbox pattern, idempotent consumers, Kubernetes manifests
- Phase 8 — Authentication & authorization: JWT at the edge, role-based routes, identity propagated into the audit trail
Hosted docs: mhayk.github.io/propflow — same content, with rendered diagrams, navigation and search.
- Services map — each service's mission, its world in a diagram, and its explicit non-responsibilities
- Design patterns — catalog of the patterns used (architectural, messaging, application) and the ones deliberately not used
- Running on GCP — a deployment study mapping the stack onto Google Cloud (GKE, Cloud SQL, Pub/Sub, Managed Kafka, Claude on Vertex AI) and how little code moves
- API reference — every endpoint with its required role; interactive OpenAPI (Scalar) at
/api/docswhen running, rendered statically here - Postman collection — import and call every endpoint; login auto-saves the token, create-requests chain the ids
- Event catalog — the async contract: envelope, every event with producers/consumers, broker topology, delivery guarantees; formal AsyncAPI spec validated in CI and rendered on the docs site
- Architecture Decision Records — every significant decision and its trade-offs
- Sequence flows — how actors and services interact, flow by flow (auth, write path, outbox relay, event fan-out, AI triage, retries/DLQ, composition, activity feed)
- Study notes — deep dives on the concepts each phase exercises
- Known limitations & planned work — the deliberate gaps and what would close each (technician/assignee registry, refresh tokens, tracing, …)
- Unit coverage report — line-by-line, republished on every push; the badge above tracks it. Unit scope only: the gateway's HTTP surface is exercised by the e2e suites, which don't count here
