惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

小众软件
小众软件
B
Blog RSS Feed
美团技术团队
博客园 - 【当耐特】
C
Check Point Blog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
M
MIT News - Artificial intelligence
aimingoo的专栏
aimingoo的专栏
J
Java Code Geeks
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Microsoft Azure Blog
Microsoft Azure Blog
博客园 - 司徒正美
T
Tailwind CSS Blog
Last Week in AI
Last Week in AI
Google DeepMind News
Google DeepMind News
D
DataBreaches.Net
人人都是产品经理
人人都是产品经理
N
Netflix TechBlog - Medium
Vercel News
Vercel News
P
Proofpoint News Feed
IT之家
IT之家
I
InfoQ
腾讯CDC
H
Hackread – Cybersecurity News, Data Breaches, AI and More

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Designing SaaS Database Architecture: Users, Organization...
sweet · 2026-06-23 · via DEV Community

SaaS database architecture decisions made in the first week will haunt you for years. This guide walks through a battle-tested multi-tenant schema design covering users, organizations, role-based memberships, subscription state machines, and billing records. Each section includes production-ready SQL, indexing strategies, and migration patterns you can adapt directly to PostgreSQL, D1, or any relational database.


Introduction

Every SaaS founder starts with a simple vision: sign up, pay, use the product. But under the hood, that "simple" flow requires a database schema that balances multi-tenancy isolation, billing accuracy, and future flexibility. Get it wrong, and you'll be rewriting migrations six months in — while your customers are waiting for invoices.

The schema you design today determines how easily you can:

  • Add organization-level billing without breaking per-user features
  • Implement role-based access control (RBAC) across teams
  • Run trial-to-paid conversions without manual data fixes
  • Generate accurate invoices and revenue reports

Multi-Tenancy Strategy

Before we write a single CREATE TABLE, we need to decide how to isolate data between tenants:

Strategy Isolation Complexity Best For
Row-level (shared DB) Low Low Early-stage, B2C
Schema-level (separate schemas) Medium Medium Mid-stage, regulatory needs
Database-level (separate DBs) High High Enterprise, PCI/HIPAA

For 90% of SaaS products starting today, row-level multi-tenancy with a shared database is the correct choice. It keeps operational complexity low and allows cross-tenant analytics with a simple WHERE organization_id = ?.

The key is to enforce tenant isolation at the application layer — every query must filter by organization_id. Never trust client-provided IDs.


Core Entities and Relationships

User ── MemberOf ── Organization
                         │
                    Subscription
                         │
                    Invoice
                         │
                   InvoiceLineItem

1. Users

Users are individuals who authenticate. Keep this table lean — identity data only.

CREATE TABLE users (
    id            TEXT PRIMARY KEY,
    email         TEXT NOT NULL UNIQUE,
    name          TEXT NOT NULL,
    avatar_url    TEXT,
    created_at    INTEGER NOT NULL DEFAULT (unixepoch()),
    updated_at    INTEGER NOT NULL DEFAULT (unixepoch())
);

CREATE INDEX idx_users_email ON users(email);

Design notes: Use TEXT IDs (UUIDs) to avoid enumeration attacks. Store timestamps as Unix integers for timezone-agnostic comparisons.

2. Organizations

Organizations are the tenant boundary. Every subscription belongs to an org, not a user.

CREATE TABLE organizations (
    id          TEXT PRIMARY KEY,
    name        TEXT NOT NULL,
    slug        TEXT NOT NULL UNIQUE,
    logo_url    TEXT,
    created_at  INTEGER NOT NULL DEFAULT (unixepoch()),
    updated_at  INTEGER NOT NULL DEFAULT (unixepoch())
);

CREATE UNIQUE INDEX idx_orgs_slug ON organizations(slug);

Why orgs matter: Billing to users directly breaks when a team of five wants one shared subscription. Always bill to organizations. Users come and go; organizations persist.

3. Memberships (Users ↔ Organizations)

The join table with roles. Never store the role directly on the users table.

CREATE TABLE memberships (
    id              TEXT PRIMARY KEY,
    user_id         TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    role            TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member', 'billing')),
    joined_at       INTEGER NOT NULL DEFAULT (unixepoch()),
    UNIQUE (user_id, organization_id)
);

CREATE INDEX idx_memberships_user ON memberships(user_id);
CREATE INDEX idx_memberships_org  ON memberships(organization_id);

Role hierarchy: owner (full control) -> admin (manage members) -> member (standard access) -> billing (view invoices only).

4. Subscriptions

This is the heart of your billing system. A subscription links an organization to a plan and tracks its lifecycle through a state machine.

CREATE TABLE subscriptions (
    id                TEXT PRIMARY KEY,
    organization_id   TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    plan_id           TEXT NOT NULL,
    status            TEXT NOT NULL DEFAULT 'trialing'
                      CHECK (status IN ('trialing', 'active', 'past_due', 'canceled', 'expired', 'incomplete')),
    current_period_start INTEGER NOT NULL,
    current_period_end   INTEGER NOT NULL,
    trial_start       INTEGER,
    trial_end         INTEGER,
    canceled_at       INTEGER,
    ended_at          INTEGER,
    created_at        INTEGER NOT NULL DEFAULT (unixepoch()),
    updated_at        INTEGER NOT NULL DEFAULT (unixepoch())
);

CREATE INDEX idx_subs_org    ON subscriptions(organization_id);
CREATE INDEX idx_subs_status ON subscriptions(status);
CREATE INDEX idx_subs_period ON subscriptions(current_period_end);

Subscription State Machine

  trialing ──→ active ──→ past_due ──→ canceled/expired
                  ↑
                  └─── (renewal fails)

  • trialing → automatically converts to active when trial ends and payment succeeds
  • active → happy path. Dunning starts if payment fails
  • past_due → payment failed. Grace period (3-14 days), then canceled or expired
  • canceled → user or system canceled. Access until period end
  • expired → final state. No access
  • incomplete → initial payment failed (card declined on first attempt)

Never use a boolean is_active column. A state machine with explicit transitions prevents ambiguous states.

5. Invoices

CREATE TABLE invoices (
    id                TEXT PRIMARY KEY,
    organization_id   TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    subscription_id   TEXT REFERENCES subscriptions(id),
    stripe_invoice_id TEXT,
    status            TEXT NOT NULL DEFAULT 'draft'
                      CHECK (status IN ('draft', 'open', 'paid', 'void', 'uncollectible')),
    amount_due        INTEGER NOT NULL,  -- in cents
    amount_paid       INTEGER NOT NULL DEFAULT 0,
    currency          TEXT NOT NULL DEFAULT 'usd',
    due_date          INTEGER,
    paid_at           INTEGER,
    created_at        INTEGER NOT NULL DEFAULT (unixepoch())
);

CREATE INDEX idx_invoices_org  ON invoices(organization_id);
CREATE INDEX idx_invoices_sub  ON invoices(subscription_id);

6. Invoice Line Items

CREATE TABLE invoice_line_items (
    id           TEXT PRIMARY KEY,
    invoice_id   TEXT NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
    description  TEXT NOT NULL,
    quantity     INTEGER NOT NULL DEFAULT 1,
    unit_amount  INTEGER NOT NULL,  -- in cents
    total_amount INTEGER NOT NULL,
    type         TEXT NOT NULL CHECK (type IN ('subscription', 'addon', 'credit', 'tax', 'adjustment')),
    created_at   INTEGER NOT NULL DEFAULT (unixepoch())
);

CREATE INDEX idx_ili_invoice ON invoice_line_items(invoice_id);

Storing individual line items is critical for prorated refunds, tax reporting, and usage-based billing breakdowns.


Billing and Invoicing Design

Flat-Rate Subscription

Charge a fixed amount per period. Simple, predictable.

Per-Seat (Per-User)

Multiply the unit price by the number of active members:

SELECT m.organization_id, COUNT(*) AS member_count
FROM memberships m
WHERE m.organization_id = ?
  AND m.role != 'billing'
GROUP BY m.organization_id;

Usage-Based

Store usage events in a separate table:

CREATE TABLE usage_events (
    id              TEXT PRIMARY KEY,
    organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    metric_name     TEXT NOT NULL,
    quantity        INTEGER NOT NULL,
    recorded_at     INTEGER NOT NULL DEFAULT (unixepoch())
);

CREATE INDEX idx_usage_event ON usage_events(organization_id, metric_name, recorded_at);


Common Pitfalls

N+1 Queries in Billing

-- BAD: N+1 — one query for the org, then N per subscription
SELECT * FROM organizations;

-- GOOD: single join
SELECT o.name, s.status, s.current_period_end
FROM organizations o
LEFT JOIN subscriptions s ON s.organization_id = o.id;

Missing Index on current_period_end

If you run a daily cron to expire subscriptions, this query runs a full table scan without an index:

SELECT id FROM subscriptions
WHERE status = 'active' AND current_period_end < unixepoch();

Cascading Deletes That Destroy Billing Records

If you accidentally delete an org, all invoices vanish. Consider ON DELETE RESTRICT or a soft-delete (deleted_at column) for financial records.

Storing Monetary Values as Floats

Never use FLOAT or REAL for money. Always use integer cents:

amount_due INTEGER NOT NULL  -- $29.99 -> 2999


Conclusion

A well-designed SaaS database schema is invisible when it works and catastrophic when it doesn't. The entities and patterns in this guide — users, organizations, memberships, subscriptions with state machines, and normalized invoicing — have been refined across production systems handling thousands of tenants.

Start with row-level multi-tenancy. Bill organizations, not users. Model subscription status as an explicit state machine. Store money as integers. Add indexes for your query patterns. And always, always write reversible migrations.


Related Resources