You know that moment when your app suddenly needs more kinds of users?
You start with a simple User model — just name, email, password.
Life is peaceful. Everything makes sense.
Then your PM drops the bomb:
“We need admins, vendors, and customers.
And some users can be all three.”
Now you’re staring at your schema thinking,
“Maybe it’s time to switch careers.” 😅
Don’t panic — we’ve all been there.
In this post, we’ll design a clean, scalable, multi-role user system — the kind that won’t collapse the moment your startup adds a new user type.
We’ll explore:
- The bad ideas (you’ve probably written before)
- The better ideas
- And finally, the best structure — one user table + separate profile tables.
🧱 1. The One-Table Disaster
Most apps start like this:
User { id: string name: string email: string password: string role: 'admin' | 'vendor' | 'customer' storeName?: string walletBalance?: number } It looks fine — until reality hits.
- Customers don’t have
storeName - Vendors don’t have
walletBalance - Admins don’t need half the columns
Now your table looks like a junk drawer full of nullable fields.
Welcome to the God Table Anti-Pattern — one table trying to be everything, and doing nothing well.
🪓 2. The “Split Everything” Overcorrection
Then comes the overcorrection phase:
Admin { id, name, email, password, permissions } Vendor { id, name, email, password, storeName } Customer { id, name, email, password, walletBalance } Looks cleaner, right?
Until you realize you just broke your system in three places.
Now you have:
- 3 login routes
- 3 sets of authentication logic
- 3 password reset flows
And worst of all:
A user can’t be both a vendor and a customer without two separate accounts. 🤦♂️
That’s not role-based — that’s multiple-account chaos.
💡 3. The Smarter Way — “User + Profiles”
Here’s the scalable approach:
- Keep shared user data (like email, password, name) in a single
Usertable. - Create separate tables for each role’s profile (
AdminProfile,VendorProfile,CustomerProfile). - Let one user have multiple profiles linked to them.
Think of it like this:
The
Useris the person.
TheProfilesare the hats they wear.
So, Sarah can:
- Log in once ✅
- Buy products as a customer 🛒
- Sell items as a vendor 🏪
- Manage users as an admin ⚙️
All through one account.
🧭 4. Visualizing It
+------------------+ | User | |------------------| | id | | name | | email | | passwordHash | +--------+---------+ | +--------+---------+ | | +----------------+ +----------------+ | VendorProfile | | CustomerProfile| |----------------| |----------------| | id | | id | | userId (FK) | | userId (FK) | | storeName | | walletBalance | | businessType | | preferences | +----------------+ +----------------+ +----------------+ | AdminProfile | |----------------| | id | | userId (FK) | | permissions[] | | accessLevel | +----------------+ ⚙️ 5. Schema Example (Prisma-style)
Here’s what that looks like in a clean ORM setup:
model User { id String @id @default(uuid()) name String email String @unique password String createdAt DateTime @default(now()) adminProfile AdminProfile? vendorProfile VendorProfile? customerProfile CustomerProfile? } model AdminProfile { id String @id @default(uuid()) userId String @unique user User @relation(fields: [userId], references: [id]) permissions String[] accessLevel String } model VendorProfile { id String @id @default(uuid()) userId String @unique user User @relation(fields: [userId], references: [id]) storeName String businessType String } model CustomerProfile { id String @id @default(uuid()) userId String @unique user User @relation(fields: [userId], references: [id]) walletBalance Float preferences String[] } It’s modular, readable, and future-proof.
Adding a new role later? Just create a new profile table. No migrations. No mess.
🧠 6. Real-World Example
Meet Sarah.
She signs up on your platform.
- ✅ Starts as a Customer → creates a
CustomerProfile - 🏪 Opens her store → creates a
VendorProfile - ⚙️ Joins your team → adds an
AdminProfile
Still one login, one token, one user record.
She just switches context — your backend handles the rest.
🔍 7. Why This Wins
✅ Separation of concerns — shared auth, separate business logic.
✅ Extensible — new roles = new tables, not new headaches.
✅ Clean authorization — role checks happen on profile level.
✅ Reusable auth — login once, access multiple contexts.
✅ No null fields — because every role owns its own schema.
🚀 8. Wrapping Up
Good system design is like clean UI — invisible when done right.
This User + Separate Profile Tables approach gives you:
- Flexibility
- Scalability
- And peace of mind 😌
As your app grows, you’ll thank yourself for choosing this pattern.
Because the next time someone says:
“We’re adding moderators now…”
You’ll just smile and create a ModeratorProfile table. 😎
💬 Coming Next
In the next article in this series:
Role-Based Access Control (RBAC) with User Profiles
— How to attach permissions dynamically and secure routes in your backend elegantly.
Top comments (0)