Elite Varan
Solo-built multi-tenant matrimony SaaS — schema-per-tenant Postgres, 100+ API routes, in production.
May 5, 2026
Problem
Constraints
What I built
Key decision
Outcome
Hindsight
Architecture notes
Three pieces carry most of the weight.
Schema-per-tenant multi-tenancy
The shape that makes the rest of the system simple. Each tenant lives in its
own PostgreSQL schema (tenant_{slug}), and a public schema holds
genuinely shared data (users, packages, master data). The connection is
opened with options=-csearch_path=tenant_{slug},public, so Drizzle generates
unqualified SQL like SELECT * FROM profiles and Postgres resolves it to
the right schema automatically.
Tenant tables have no tenantId column. The schema is the isolation.
That's the property worth paying for: there is no application-layer query
that can accidentally join across tenants, because the only data the
connection can see is the data its search_path includes.
Composable middleware chain
Every protected route runs through a composed pipeline:
withAuth → withTenant → withApproval → [withAdmin] → withRateLimit → handler
Each layer narrows the type of the context it passes:
AuthContext → { user: { id, email, profileId, tenantSlug } }
TenantContext → AuthContext + { tenantSlug, tenantId, db, services }
ApprovedContext → TenantContext + { user.profileId guaranteed non-null }
AdminContext → ApprovedContext + admin permissions
Route handlers receive the context type they actually need, and the
type system rejects code paths that use, say, services before the
tenant middleware has run. The factory pattern below makes this
ergonomic.
Repository + service factory pattern
Data access (repositories) and business logic (services) are both factory functions, wired together once per tenant DB:
export function createTenantServices(db: TenantDb) {
const profileRepo = createProfileRepository(db);
const kundaliRepo = createKundaliMatchRepository(db);
const profileService = createProfileService({ profileRepo });
const kundaliService = createKundaliService({ kundaliRepo, profileRepo });
return {
profile: profileService,
kundali: kundaliService,
repos: { profile: profileRepo, kundali: kundaliRepo },
};
}
Route handlers reach for context.services.kundali.compute(...) rather
than constructing services themselves. Tests inject mock repos through
the same factory shape. No DI container, no decorators — just functions
that return functions.
The kundali engine
The actual differentiator. It takes two sets of birth details, computes
both horoscope charts on top of the astronomy-engine library, runs
domain-specific compatibility scoring (guna milan, dosha matching), and
returns a categorised match result. The service is lazy and cached —
results are stored in a kundali_match_cache keyed by the pair of
profile IDs, and only recomputed when the underlying horoscope data
changes. That keeps Smart Match and the per-pair compatibility view fast
even at scale.
Why this composition
The schema-per-tenant boundary, the typed middleware chain, and the factory wiring mean that adding a new feature usually involves a new repository, a new service, and one route — and the type system tells me when I'm wiring something incorrectly. Solo shipping with this much surface area only works if the framework around the features is doing its job. This one did.