09
Chapter 09

Database Design
for Solo Founders

Your database is where all your data lives — every user, every purchase, every conversation. It is the one thing that is hardest to change later, so getting the basics right early matters more than getting them perfect.

12 min read | Technical | Shaen Hawkins
USERS id uuid PK email text name text created_at timestamp target_language text SUBSCRIPTIONS id uuid PK user_id uuid FK plan text started_at timestamp status text PAYMENTS id uuid PK subscription_id uuid FK amount decimal status text created_at timestamp user_id subscription_id ROW-LEVEL SECURITY User A only sees User A's rows | Enforced at the database level | Even buggy code cannot bypass

Think in Tables

If you can use a spreadsheet, you can understand a database. A database is just a collection of spreadsheets (called "tables") that can reference each other.

Your Users table has a row for every person who signed up — their name, email, when they joined. Your Subscriptions table has a row for every subscription — which user it belongs to, which plan they are on, when it started. The two tables are connected: every subscription row points to a user row through a shared ID number.

That connection is the superpower. "Show me all the subscriptions for this user" is one simple question to the database. "How many users are on the Pro plan?" is another. "Which users signed up this week but have not subscribed yet?" is a third. The database answers all of these instantly because the relationships between tables are built in.

Table

A structured collection of data with rows and columns — exactly like a spreadsheet. Each table holds one type of information: Users, Subscriptions, Payments, Messages. Each row is one entry. Each column is one piece of information about that entry.

Foreign Key

A column in one table that points to a row in another table. If your Subscriptions table has a "user_id" column, that is a foreign key — it tells you which user this subscription belongs to. It is the thread that connects your tables together.

Users
id, name, email, created_at
Subscriptions
id, user_id, plan, started_at
Payments
id, subscription_id, amount, status

Each table connects to the next through foreign keys — user_id links subscriptions to users, subscription_id links payments to subscriptions.

Restaurant Analogy

Your database is the filing system in the back office. One cabinet for Customers, one for Reservations, one for Suppliers. Each file in the cabinet has the same fields filled out. The customer's reservation card has their customer number on it — pull that number, find their full file. That is a foreign key.

Security from Day One

By default, if your app can read from your database, it can read everything. Every user's data. Every other user's data too. That is fine during development when it is just you. It is a serious problem when real people are using your product.

Row-level security (RLS) is a feature in modern databases like Supabase and PostgreSQL that enforces a simple rule: User A can only see and change User A's data. User B can only see and change User B's data. This is enforced at the database level — meaning even if there is a bug in your app code, the database itself blocks unauthorized access.

Set this up on day one, not after your first data privacy incident. It takes an hour and it is one of those things where doing it early is trivially easy and doing it later is surprisingly painful.

Row-Level Security (RLS)

A database feature that controls who can see which rows. Think of it as individual locks on every filing cabinet drawer. The receptionist can open the Reservations drawer. The accountant can open the Finances drawer. A customer can only see their own file. Even if someone picks the wrong door, the drawer will not open.

Without RLS

Any user can query any other user's data. A single API bug exposes your entire database. You discover this when a user sees someone else's orders — or worse, when a security researcher publishes it.

With RLS

The database itself enforces boundaries. Even a buggy query can only return the current user's rows. The lock is on the data, not just the app code that accesses it.

WITH ROW-LEVEL SECURITY User A User B CONVERSATIONS TABLE user_a | "Hola, como estas?" | 2 min VISIBLE user_a | "Necesito ayuda..." | 5 min VISIBLE user_b | ████████████████ | ██ BLOCKED

The Traps Nobody Warns You About

01

The Silent 1,000-Row Limit

Many database platforms (including Supabase) return a maximum of 1,000 rows by default. It does not tell you it stopped. Ask for "all users" with 5,000 in the table and you get 1,000 back — your dashboard, your reports, your investor conversations are all based on bad data. Nothing looks broken.

The Fix

Never pull rows and count them in your app. Use server-side aggregation — ask the database to count for you directly. Always returns the correct answer.

02

Check Your Column Names

Is it "user_id" or "userId"? Is it "created_at" or "createdAt"? AI guesses — and guesses wrong about 30% of the time. A wrong column name does not crash your app. It returns empty results, which your app interprets as "no data" instead of "that column does not exist."

The Fix

Always verify actual column names in your database before writing queries. Copy-paste, do not type from memory or let AI guess.

Server-Side Aggregation

Asking the database to do the math instead of doing it yourself. Instead of "give me all 5,000 rows and I will count them" (which silently caps at 1,000), you say "count the rows and tell me the number." The database does the work and gives you the correct answer: 5,000.

// BAD — silently capped at 1,000 rows
const { data } = await supabase
  .from('users')
  .select('*')
// data.length === 1000 (you have 5,000 users)

// GOOD — always correct
const { count } = await supabase
  .from('users')
  .select('*', { count: 'exact', head: true })
// count === 5000 ✓

If your aggregate numbers come from SELECT queries without server-side COUNT, your data is probably wrong and you do not know it yet.

Your First Design Will Be Wrong

This is not a failure. It is expected. You will add columns, rename things, and restructure tables as you learn what your product actually needs. The first version of your database is a hypothesis, not a final answer.

What matters is knowing how to make changes safely.

Adding a Column

Safe. Existing rows get a blank value in the new column. No existing code breaks.

Renaming a Column

Risky. Any code that references the old name will break. Check all references first.

Deleting a Column

Permanent. The data is gone. Make sure nothing needs it before you remove it.

The Rule

Additions are safe. Renames require checking what references the old name first. Deletions are permanent. And always, always test changes in sandbox first (Chapter 7).

Backups

Most managed database platforms include automatic daily backups on paid plans. This means if something goes catastrophically wrong — a bad migration wipes a table, a rogue query deletes rows — you can restore to yesterday's state.

But daily backups have a gap: anything that happened today is not backed up yet. For critical operations (major schema changes, bulk data modifications), make a manual backup first. Export the affected table to a CSV, or use Supabase's point-in-time recovery if your plan supports it.

The rule of thumb: if a mistake would take more than an hour to fix manually, back up before you start.

WEEK 1 users subscriptions conversations 3 tables MONTH 2 users subscriptions conversations payments characters messages assessments progress 8 tables PRODUCTION users subs convos pay chars msgs assess prog drill render ladder audio episo study goals logs hooks plans cron rls 20+ tables

Your first design will be wrong. That is expected. The database grows with your product.

Topics Covered
Tables & Rows Foreign Keys Row-Level Security 1,000-Row Limit Server-Side Aggregation Column Names Schema Evolution Backups Supabase