Overview
A multi-tenant SaaS backend for salon businesses. Built on a shared-database tenant isolation model, it handles booking flows, time-slot scheduling, and subscription billing. The service layer follows a strict service-repository separation, and Redis handles caching and background jobs.
The sections below (including architecture) are written from the engagement — they are not tied to a public repository.
/ Scope
- Multi-tenant data model
- Booking & scheduling engine
- Subscription billing
- Auth & role-based access
- Background jobs & notifications
Highlights
01
Concurrency-aware booking logic
02
Time-slot scheduling engine
03
Push notifications (PWA)
04
Redis-based caching and queues
/ Tracks
- Client
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 tenant wasn't realistic — we needed a single platform that could host many salons without leaking data between them.
Approach
Started from the data model: every domain table carries a tenant_id, and a middleware enforces tenant context on every query. 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.
Key Decisions
- 01
Shared database with row-level tenant_id
Chose shared DB over DB-per-tenant for cost and operational simplicity, with a strict guardrail middleware that refuses any query without tenant scope.
- 02
Service-Repository separation
Business rules (overlap detection, cancellation windows) live in services; persistence lives in repositories. Makes the booking engine testable without spinning up Postgres.
- 03
Redis for availability and queues
Time-slot availability is read-heavy; Redis caches computed slots per day per staff. The same Redis instance powers BullMQ for reminder jobs.
- 04
Event-driven side effects
Booking created, payment received, subscription renewed — each becomes an event. Notifications, audit logs and reminders are listeners, not inline calls.
Architecture
- 01Shared DB with tenant_id
- 02Clean Architecture
- 03Service-Repository Pattern
- 04Event-driven components
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 check and the insert weren't atomic.
/ Solution
Moved slot reservation behind a Postgres advisory lock keyed by staff + slot. Reads stay fast through Redis; writes serialize per slot, so double-booking is provably impossible.
Tenant data isolation without noise
/ Problem
Repeating `WHERE tenant_id = ?` on every query is error-prone. One forgotten clause leaks data across salons.
/ Solution
Built a request-scoped tenant context plus a query builder wrapper that auto-injects the filter. A guard throws if a query tries to run without a tenant in scope.
Subscription state drift
/ Problem
Gateway webhooks can arrive out of order or be retried. Updating subscription state naively caused mismatched statuses.
/ Solution
Made webhook handlers idempotent with an event_id ledger, and treated subscription state as a function of the latest known event — not incremental updates.
Outcomes
- Booking engine handles overlapping and recurring schedules without double-booking
- Tenant isolation is enforced by the framework, not by developer discipline
- Reminder/notification jobs decoupled from the request path
What I Learned
- 01
Tenant isolation is a framework concern, not a feature
- 02
Putting business rules in services (not controllers) pays off the moment you add a second interface — like an admin panel or webhook
- 03
Idempotency isn't optional once real money is involved
Tech Stack
Next Steps
- Analytics layer (per-tenant BI on top of a read replica)
- Customer-facing PWA for clients to book and manage appointments
- Soft-delete + audit log baked into the repository layer