Overview
The sections below (including architecture) are written from the engagement — they are not tied to a public repository.
/ Scope
- Multi-tenant data model (shared schema, row-level isolation)
- Booking & scheduling engine with concurrency control
- Staff management, invitations & onboarding
- Subscription billing & Stripe Connect
- Loyalty & rewards system
- Geolocation & maps integration
- Background jobs & push notifications
- PWA + Capacitor mobile (iOS/Android)
- Auth, RBAC & role-based access control
- AWS deployment & CI/CD
Highlights
01
Solo full-stack: Next.js 16 + NestJS + AWS — shipped to production
02
Concurrency-safe booking engine (PostgreSQL advisory locks)
03
True multi-tenant isolation enforced at the framework layer
04
Stripe Connect with idempotent webhook processing
05
Live product serving real salons — donebyme.dk
/ Tracks
- Client
Media
The Problem
Salon owners juggle bookings across phone, WhatsApp, and paper diaries. Double-bookings, no-shows, and missed renewals cost real money. Building one backend per salon wasn't realistic — we needed a single platform that could host many salons without leaking data between them, and still let each salon operate as if they had a dedicated system.
Approach
Started from the data model: every domain table carries a tenant_id, and a request-scoped middleware enforces tenant context on every query — a guard throws if a query tries to run without a tenant in scope. From there I split the codebase into clear service and repository layers so booking logic stays independent of storage. Redis sits in front of hot reads (availability lookups) and drives background jobs for notifications and reminders. RabbitMQ handles durable domain events for cross-service fan-out.
Key Decisions
- 01
Shared database with row-level tenant_id
Chose shared DB over DB-per-tenant for cost and operational simplicity. A strict query builder wrapper auto-injects the tenant filter on every query — a missing tenant context throws at the framework layer, not silently leaks data.
- 02
Service-Repository separation
Business rules (overlap detection, cancellation windows, loyalty accrual) live in services. Persistence lives in repositories. Makes the booking engine testable without spinning up Postgres and lets me swap storage without touching domain logic.
- 03
PostgreSQL advisory locks for concurrent booking
Two clients hitting the same 3:00 PM slot at once could both pass an availability check if the read and write weren't atomic. Advisory locks keyed by staff + slot serialize writes per slot — double-booking is provably impossible.
- 04
RabbitMQ + BullMQ for async work
RabbitMQ for durable domain events and cross-service fan-out (booking created → notify → loyalty → audit log). BullMQ (Redis-backed) for high-turnover, short-lived jobs like reminder scheduling. Each channel has a clear, intentional job.
- 05
Idempotent webhook handlers with event ledger
Stripe webhooks arrive out-of-order and retry on failure. Instead of incremental state updates, each handler is idempotent: subscription state is a function of the latest known event, and an event_id ledger prevents double-processing.
Architecture
/ System proof
These are not tool badges. They describe the boundaries, consistency controls, async paths, and failure-mode decisions behind the build.
- 01Shared schema, row-level tenant isolation (tenant_id on every table)
- 02Service-Repository pattern — domain logic never touches SQL
- 03Event-driven side effects via RabbitMQ domain events
- 04PostgreSQL advisory locks for concurrent slot reservation
- 05Redis availability cache with tenant-scoped key namespacing
- 06RBAC with 5-tier role hierarchy (Platform Admin → Customer)
- 07Idempotent webhook processing with event_id deduplication ledger
- 08PWA + Capacitor for cross-platform mobile delivery
Challenges & How I Solved Them
Concurrent booking race conditions
/ Problem
Two clients hitting the same 3:00 PM slot at once could both succeed if the availability check and the insert weren't atomic. Optimistic locking wasn't sufficient — two concurrent reads would both see 'available'.
/ Solution
Moved slot reservation behind a PostgreSQL advisory lock keyed by (staff_id, slot_start). Reads stay fast through Redis cache. Writes serialize per slot — double-booking is eliminated at the database level, not the application level.
Tenant data isolation without developer discipline
/ Problem
Repeating WHERE tenant_id = ? on every query is error-prone at scale. One missed clause leaks data across salons silently — the kind of bug that only surfaces in production.
/ Solution
Built a request-scoped TenantContext and a Drizzle query builder wrapper that auto-injects the tenant filter. A guard throws at the framework layer if any query executes without a tenant in scope. Isolation is enforced structurally, not by convention.
Subscription state drift from out-of-order webhooks
/ Problem
Stripe webhooks can arrive out of order and retry on failure. Updating subscription state naively (increment/decrement) caused mismatched statuses after retries.
/ Solution
Made every webhook handler idempotent with an event_id deduplication ledger. Subscription state is computed from the latest known event, not accumulated from patches — so replays are always safe.
Staff onboarding across 3 user scenarios
/ Problem
When a salon owner invites staff, the invitee could be: a brand-new user, an existing customer, or already staff at another salon. Each case needs different data creation paths and different post-onboarding routing.
/ Solution
Built a StaffUserDetectionService that identifies the scenario before onboarding begins. The backend then executes only the necessary record creation (user + profile + employment, or just employment). Post-onboarding routing is deterministic based on the scenario result.
Outcomes
- Live at donebyme.dk — serving real salon businesses in production
- Booking engine handles overlapping, recurring, and concurrent schedules without double-booking
- Tenant isolation enforced by the framework — zero reliance on developer discipline
- Stripe Connect billing with idempotent webhook processing
- Background jobs (notifications, reminders, loyalty) fully decoupled from the request path
- PWA + Capacitor delivers a native-like experience on iOS and Android
What I Learned
- 01
Tenant isolation is a framework concern, not a feature. Build the guardrail into the query layer so every developer gets it for free.
- 02
Idempotency is not optional when real money is involved. Design every payment handler to be safe to replay from day one.
- 03
Service-Repository separation pays for itself the moment you add a second interface — the admin panel, webhook handler, and REST API all share the same domain logic without duplication.
- 04
Advisory locks at the database level are simpler and more reliable than optimistic locking for short-lived, high-contention resources like booking slots.
Tech Stack
Next Steps
- Per-tenant analytics on a read replica with materialized view refresh
- Multi-location support (one tenant operating across multiple physical salons)
- Self-serve onboarding for new salon owners