~/hassan
← all posts

"post": "ninety-prisma-models"

What a 90-model Prisma schema taught me about data design

2026-08-03 · 3 min read · Hassan Naveed

I worked on a professional-network-and-jobs platform for the Greek market — think a social feed, formal job applications, recruiting agencies, a local gig-work marketplace, and bookable freelance services, all in one product. The Prisma schema grew past ninety models. Here's what that scale taught me, none of which I knew when the schema had ten.

1. Model the sides of a market separately

The platform serves three kinds of actors: job seekers, companies, and recruiting agencies. The tempting shortcut is one User model with a type field and thirty nullable columns. It's a trap — every query grows conditionals, and every new feature for one side risks the others.

What works: a thin shared identity (auth, sessions, notifications) with separate profile models per side, joined through the objects they genuinely share. A job application is the meeting point of a seeker, a company, and sometimes an agency — so it carries the relationships, not a bloated user record.

2. Payment providers are isolation problems

The product bills through four providers — Revolut Pay, RevenueCat, Apple In-App Purchases, and Google Play Billing — because users pay on whatever platform they're on. Four providers means four webhook contracts, four retry behaviors, and four vocabularies for "this subscription is active."

The design that stays sane: one canonical subscription state, many translators. Each provider gets its own service and webhook handler whose only job is to translate provider events into canonical transitions. No feature code ever asks "what does RevenueCat think?" — it asks "is this subscription active?" When a fifth provider shows up, it's a new translator, not a schema migration.

3. Reference data is not application data

Structured profiles and job matching run on ESCO — the EU's taxonomy of occupations and skills. It's thousands of entries, read constantly, written never. Treating it like application data would mean hammering the same rows on every autocomplete keystroke.

Reference datasets deserve their own rules: cache aggressively (Redis in front of Postgres), tune for reads, and let them power features — matching, autocomplete, standardized profiles — without ever appearing in a write path. Same story for the Greek business-registry lookup that auto-fills company onboarding: external truth, cached locally, never mutated.

4. Policy belongs in data, not in if-statements

Multi-device sessions, per-role permissions, notification preferences, blocked users — the schema holds all of it as rows, not as scattered conditionals. When "can this agency see this candidate?" is answered by a query instead of a code path, the rule is auditable, testable, and changeable without a deploy.

5. Model count isn't a smell — dead models are

Ninety models sounds like over-engineering until you list what the product actually does: feed, articles, comments, follows, jobs, applications, matching, agencies, teams, gig work with check-ins and reputation, services, inquiries, chat with read receipts, subscriptions, boosts, payment history, translations, settings. Every model maps to a feature someone uses.

The real smell isn't the count. It's models nothing queries — schema written for a future that never arrived. Grow the schema with the product, not ahead of it.

The meta-lesson

A big schema is a big vocabulary. If each concept in the product has exactly one name in the database, size stays navigable. The schemas that hurt aren't the large ones — they're the ones where the same idea lives in three places under three names.