DBMS 📂 Real Use cases · 2 of 4 50 min read

Multi-Tenant SaaS for Online Q&A: Database Schema and UML Diagrams

Design the database for a multi-tenant SaaS online question-and-answer service in MySQL, where many universities share one system with their own admins, teachers and students. Includes full table schemas, a multi-tenancy concept diagram, a UML use-case diagram, two ER diagrams, and how tenant_id keeps every university's data apart.

Section 01

What We Are Building

One Building, Many Tenants
Picture one apartment building. It has one front door, one lift, one water supply, and one caretaker. But inside, each family lives in their own flat with their own key. No family can walk into another's flat. The building is shared, the flats are private.

A SaaS (Software as a Service) app works the same way. We run one codebase and one database for everyone. Each university is a tenant — it gets its own private space inside the shared system. This design is called multi-tenancy, and it is the whole idea behind this tutorial.

We are building an online question-and-answer (quiz) service. Many universities buy the same service. Each one brings its own admins, teachers, and students. University A must never see University B's data. We will design the tables and the UML diagrams that make this safe. The engine is MySQL / MariaDB.

🏢
The One Column That Runs Everything

Almost every table carries a tenant_id. It is the flat key. Every read and every write is filtered by it, so one university only ever touches its own rows. Get this column right and the whole system stays isolated.


Section 02

Three Ways to Do Multi-Tenancy

Before the tables, pick a tenancy model. There are three common ones. We compare cost, isolation, and effort, then choose.

📦
Database per tenant
Each university gets its own database. Strongest isolation. But backups, upgrades, and reports across tenants become slow and costly. Hard at 100+ tenants.
isolation: high · cost: high
📁
Schema per tenant
One database, one schema per university. Middle ground. Still gets heavy on migrations when tenant count grows, because every schema must change.
isolation: medium · cost: medium
🏠
Shared schema + tenant_id
One database, one set of tables, a tenant_id column on every tenant row. Cheapest to run and easiest to scale. Isolation is enforced by the app and keys.
isolation: good · cost: low
🎯
Our Choice: Shared Schema with tenant_id

This is what most SaaS products use. It scales to thousands of universities on one database and keeps operations simple. The trade-off is discipline: every query must filter by tenant_id. The rest of this tutorial shows how to make that safe.


Section 03

Diagram — How Many Universities Share One System

Three universities send requests to one platform. The platform stores every row in shared tables, each stamped with its tenant_id. Colour shows which tenant a row belongs to.

🏢 Concept — Multi-Tenant SaaS
tenant_id University A tenant_id = 1 University B tenant_id = 2 University C tenant_id = 3 Q&A SaaS Platform ● one codebase ● one shared database ● rows split by tenant_id ● per-tenant logins users (shared table) tenant name role 1Ashastudent 1R. Raoteacher 2Benstudent 2S. Iyeradmin 3Miastudent 3Omarteacher WHERE tenant_id = ? keeps each university apart

Section 04

Tenancy and Billing Tables

Start with the tables that define a university and its plan. plans is a global table — it has no tenant_id because plans are the same for everyone. tenants and subscriptions are per-university.

-- ============ PLANS (global lookup) ============
CREATE TABLE plans (
    id             INT UNSIGNED   NOT NULL AUTO_INCREMENT,
    name           VARCHAR(60)   NOT NULL,      -- Free, Standard, Campus
    max_teachers   INT UNSIGNED   NOT NULL,
    max_students   INT UNSIGNED   NOT NULL,
    price_monthly  DECIMAL(10,2) NOT NULL DEFAULT 0,
    PRIMARY KEY (id),
    UNIQUE KEY uq_plan_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ TENANTS (the universities) ============
CREATE TABLE tenants (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    name        VARCHAR(150)    NOT NULL,        -- "Delhi Tech University"
    subdomain   VARCHAR(63)     NOT NULL,        -- dtu.qaservice.com
    plan_id     INT UNSIGNED    NULL,            -- FK -> plans (current plan)
    status      ENUM('trial','active','suspended') NOT NULL DEFAULT 'trial',
    created_at  TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uq_tenant_subdomain (subdomain),
    CONSTRAINT fk_tenant_plan FOREIGN KEY (plan_id) REFERENCES plans(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ SUBSCRIPTIONS (billing history per tenant) ============
CREATE TABLE subscriptions (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    tenant_id   BIGINT UNSIGNED NOT NULL,        -- FK -> tenants
    plan_id     INT UNSIGNED    NOT NULL,        -- FK -> plans
    starts_on   DATE            NOT NULL,
    ends_on     DATE            NULL,
    status      ENUM('active','past_due','cancelled') NOT NULL DEFAULT 'active',
    PRIMARY KEY (id),
    KEY ix_sub_tenant (tenant_id),
    CONSTRAINT fk_sub_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
    CONSTRAINT fk_sub_plan   FOREIGN KEY (plan_id)   REFERENCES plans(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
🌐
The subdomain Is the Tenant Key at Login

When a user opens dtu.qaservice.com, the app reads the subdomain, looks up the tenant, and locks the whole session to that tenant_id. From that point every query is scoped. Section 11 shows this request flow.


Section 05

Users and Roles — Admin, Teacher, Student

Every user belongs to one tenant. A user can hold one or more roles. We keep roles in a small global lookup and link them with a join table. This lets a person be, say, both a teacher and an admin in the same university.

👑
Admin
role_key = admin
Manages the university account: invites teachers and students, sets the plan, and sees all courses and results inside their tenant.
🧠
Teacher
role_key = teacher
Creates courses and quizzes, writes questions and correct answers, and grades student attempts within their tenant.
🎓
Student
role_key = student
Enrols in courses, attempts published quizzes, and views their own scores. Cannot see other students' answers.
-- ============ USERS (per tenant) ============
CREATE TABLE users (
    id             BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    tenant_id      BIGINT UNSIGNED NOT NULL,       -- FK -> tenants
    name           VARCHAR(120)    NOT NULL,
    email          VARCHAR(190)    NOT NULL,
    password_hash  VARCHAR(255)    NOT NULL,
    status         ENUM('active','invited','disabled') NOT NULL DEFAULT 'active',
    created_at     TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    -- same email may exist in two different universities, so scope it by tenant
    UNIQUE KEY uq_user_email (tenant_id, email),
    KEY ix_users_tenant (tenant_id),
    CONSTRAINT fk_users_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ ROLES (global lookup) ============
CREATE TABLE roles (
    id        TINYINT UNSIGNED NOT NULL AUTO_INCREMENT,
    role_key  VARCHAR(20)      NOT NULL,   -- 'admin','teacher','student'
    label     VARCHAR(40)      NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uq_role_key (role_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ USER_ROLES (who has which role) ============
CREATE TABLE user_roles (
    user_id    BIGINT UNSIGNED  NOT NULL,      -- FK -> users
    role_id    TINYINT UNSIGNED NOT NULL,      -- FK -> roles
    tenant_id  BIGINT UNSIGNED  NOT NULL,      -- copied in for fast tenant checks
    PRIMARY KEY (user_id, role_id),
    KEY ix_ur_tenant (tenant_id),
    CONSTRAINT fk_ur_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    CONSTRAINT fk_ur_role FOREIGN KEY (role_id) REFERENCES roles(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
⚠️
Scope Unique Emails by Tenant

A plain UNIQUE(email) would stop the same person joining two universities. In multi-tenancy you almost always want UNIQUE(tenant_id, email). Many "unique" rules in a SaaS app must include tenant_id.


Section 06

UML Use-Case Diagram — Who Does What

The three actors and their actions inside one tenant. The dashed box is the system boundary: everything inside runs per university.

👤 UML Use-Case — Roles and Actions
Q&A SaaS (one tenant / university) Admin Teacher Student Manage users & roles Manage plan & billing Create courses Build quizzes Add questions Grade attempts Enrol in course Attempt quiz View own results

Section 07

Academic Structure — Courses and Enrollments

A teacher creates courses. Students enrol in them. Both tables carry tenant_id, and the course code is unique only inside one university.

-- ============ COURSES ============
CREATE TABLE courses (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    tenant_id   BIGINT UNSIGNED NOT NULL,       -- FK -> tenants
    name        VARCHAR(150)    NOT NULL,
    code        VARCHAR(30)     NOT NULL,       -- "CS101"
    created_by  BIGINT UNSIGNED NOT NULL,       -- FK -> users (a teacher)
    created_at  TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uq_course_code (tenant_id, code),
    KEY ix_courses_tenant (tenant_id),
    CONSTRAINT fk_courses_tenant  FOREIGN KEY (tenant_id)  REFERENCES tenants(id) ON DELETE CASCADE,
    CONSTRAINT fk_courses_teacher FOREIGN KEY (created_by) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ ENROLLMENTS (student in course) ============
CREATE TABLE enrollments (
    id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    tenant_id    BIGINT UNSIGNED NOT NULL,      -- FK -> tenants
    course_id    BIGINT UNSIGNED NOT NULL,      -- FK -> courses
    student_id   BIGINT UNSIGNED NOT NULL,      -- FK -> users (a student)
    enrolled_at  TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uq_enroll (course_id, student_id),  -- no double enrolment
    KEY ix_enroll_tenant (tenant_id),
    CONSTRAINT fk_enroll_course  FOREIGN KEY (course_id)  REFERENCES courses(id) ON DELETE CASCADE,
    CONSTRAINT fk_enroll_student FOREIGN KEY (student_id) REFERENCES users(id)   ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Section 08

The Q&A Content — Quizzes, Questions, Options

A quiz belongs to a course. It holds many questions. A choice question holds many options, and each option is marked right or wrong with is_correct. This is how the service knows how to grade.

-- ============ QUIZZES ============
CREATE TABLE quizzes (
    id               BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    tenant_id        BIGINT UNSIGNED NOT NULL,      -- FK -> tenants
    course_id        BIGINT UNSIGNED NOT NULL,      -- FK -> courses
    title            VARCHAR(200)    NOT NULL,
    created_by       BIGINT UNSIGNED NOT NULL,      -- FK -> users (a teacher)
    time_limit_mins  INT UNSIGNED    NULL,
    is_published     TINYINT(1)      NOT NULL DEFAULT 0,
    created_at       TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    KEY ix_quiz_tenant (tenant_id, course_id),
    CONSTRAINT fk_quiz_tenant  FOREIGN KEY (tenant_id)  REFERENCES tenants(id) ON DELETE CASCADE,
    CONSTRAINT fk_quiz_course  FOREIGN KEY (course_id)  REFERENCES courses(id) ON DELETE CASCADE,
    CONSTRAINT fk_quiz_teacher FOREIGN KEY (created_by) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ QUESTIONS ============
CREATE TABLE questions (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    tenant_id   BIGINT UNSIGNED NOT NULL,       -- FK -> tenants
    quiz_id     BIGINT UNSIGNED NOT NULL,       -- FK -> quizzes
    body        TEXT            NOT NULL,
    qtype       ENUM('mcq_single','mcq_multi','true_false','short_text') NOT NULL,
    marks       DECIMAL(6,2)   NOT NULL DEFAULT 1,
    position    INT UNSIGNED    NOT NULL DEFAULT 0,
    PRIMARY KEY (id),
    KEY ix_q_tenant (tenant_id),
    KEY ix_q_quiz (quiz_id, position),
    CONSTRAINT fk_q_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
    CONSTRAINT fk_q_quiz   FOREIGN KEY (quiz_id)   REFERENCES quizzes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ QUESTION_OPTIONS (choices; correct flag) ============
CREATE TABLE question_options (
    id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    tenant_id    BIGINT UNSIGNED NOT NULL,      -- FK -> tenants
    question_id  BIGINT UNSIGNED NOT NULL,      -- FK -> questions
    label        VARCHAR(500)    NOT NULL,
    is_correct   TINYINT(1)      NOT NULL DEFAULT 0,
    position     INT UNSIGNED    NOT NULL DEFAULT 0,
    PRIMARY KEY (id),
    KEY ix_opt_question (question_id),
    CONSTRAINT fk_opt_tenant   FOREIGN KEY (tenant_id)   REFERENCES tenants(id)   ON DELETE CASCADE,
    CONSTRAINT fk_opt_question FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Section 09

Attempts and Results

When a student starts a quiz, we create one attempt. Each answer they give is one row in attempt_answers. Choice answers point to the picked option; short answers store text. After grading, we save the score on the attempt.

-- ============ ATTEMPTS (one student sitting one quiz) ============
CREATE TABLE attempts (
    id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    tenant_id     BIGINT UNSIGNED NOT NULL,     -- FK -> tenants
    quiz_id       BIGINT UNSIGNED NOT NULL,     -- FK -> quizzes
    student_id    BIGINT UNSIGNED NOT NULL,     -- FK -> users
    started_at    TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    submitted_at  TIMESTAMP       NULL,
    score         DECIMAL(8,2)   NULL,
    status        ENUM('in_progress','submitted','graded') NOT NULL DEFAULT 'in_progress',
    PRIMARY KEY (id),
    KEY ix_att_tenant (tenant_id),
    KEY ix_att_quiz (quiz_id, student_id),
    CONSTRAINT fk_att_tenant  FOREIGN KEY (tenant_id)  REFERENCES tenants(id) ON DELETE CASCADE,
    CONSTRAINT fk_att_quiz    FOREIGN KEY (quiz_id)    REFERENCES quizzes(id) ON DELETE CASCADE,
    CONSTRAINT fk_att_student FOREIGN KEY (student_id) REFERENCES users(id)   ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ ATTEMPT_ANSWERS (one answer per question) ============
CREATE TABLE attempt_answers (
    id                  BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    tenant_id           BIGINT UNSIGNED NOT NULL,   -- FK -> tenants
    attempt_id          BIGINT UNSIGNED NOT NULL,   -- FK -> attempts
    question_id         BIGINT UNSIGNED NOT NULL,   -- FK -> questions
    selected_option_id  BIGINT UNSIGNED NULL,       -- FK -> question_options (choice)
    answer_text         TEXT            NULL,       -- for short_text
    awarded_marks       DECIMAL(6,2)   NULL,       -- filled at grading
    PRIMARY KEY (id),
    UNIQUE KEY uq_att_ans (attempt_id, question_id, selected_option_id),
    KEY ix_aa_tenant (tenant_id),
    CONSTRAINT fk_aa_tenant   FOREIGN KEY (tenant_id)          REFERENCES tenants(id)          ON DELETE CASCADE,
    CONSTRAINT fk_aa_attempt  FOREIGN KEY (attempt_id)         REFERENCES attempts(id)         ON DELETE CASCADE,
    CONSTRAINT fk_aa_question FOREIGN KEY (question_id)        REFERENCES questions(id)        ON DELETE CASCADE,
    CONSTRAINT fk_aa_option   FOREIGN KEY (selected_option_id) REFERENCES question_options(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
🎁
Grading Is Just a Join

To grade a choice question, join attempt_answers.selected_option_id to question_options and check is_correct. Because the correct flag lives with the option, the service grades most questions automatically.


Section 10

UML Class / ER Diagrams — The Full Schema

Part 1: tenancy and identity. Every arrow reads one-to-many (1 → N) from parent to child. plans and roles are global; the rest carry tenant_id.

📊 ER Diagram (1/2) — Tenancy & Identity
1→N 1→N 1→N 1→N 1→N plans PK id name max_students tenants PK id FK plan_id subdomain subscriptions PK id FK tenant_id FK plan_id users PK id FK tenant_id email roles PK id role_key user_roles FK user_id FK role_id FK tenant_id

Part 2: the academic and Q&A tables. Dashed boxes are tables defined earlier that these point back to.

📊 ER Diagram (2/2) — Courses, Quizzes & Attempts
1→N 1→N 1→N 1→N 1→N 1→N 1→N 1→N courses PK id FK tenant_id code enrollments FK course_id FK student_id quizzes PK id FK course_id is_published questions PK id FK quiz_id qtype question_options FK question_id is_correct attempts PK id FK quiz_id FK student_id attempt_answers FK attempt_id FK question_id FK selected_option_id

Section 11

How Every Request Is Scoped to One Tenant

Isolation is not magic. It is a habit applied on every request. Here is the flow from the moment a user opens the site to the moment a query runs.

01
Read the subdomain
The user opens dtu.qaservice.com. The app reads dtu and finds the matching tenant.
02
Lock the session to a tenant_id
After login, the current tenant_id is stored in the session. Nothing else can change it during the request.
03
Add tenant_id to every query
Each SELECT, UPDATE, and DELETE includes WHERE tenant_id = ?. Each INSERT sets it. Use one shared helper so no one forgets.
04
Check the role
Inside the tenant, the role decides the action. A student cannot open the grading screen; a teacher can.
🚨
The One Bug That Leaks Data

Forget WHERE tenant_id = ? on a single query and one university can read another's data. This is the most serious bug in any SaaS app. Put the filter in a shared data layer, and add automated tests that fail if a query is missing it.


Section 12

Example Queries

List published quizzes for a student's courses (tenant-scoped)

SELECT qz.id, qz.title, c.name AS course
FROM   quizzes qz
JOIN   courses c      ON c.id = qz.course_id
JOIN   enrollments e  ON e.course_id = c.id
WHERE  qz.tenant_id = 1            -- current tenant, always first
  AND  e.student_id = 57
  AND  qz.is_published = 1
ORDER BY qz.created_at DESC;

Auto-grade a submitted attempt

-- award marks: correct option -> question marks, else 0
UPDATE attempt_answers aa
JOIN   questions q         ON q.id = aa.question_id
LEFT JOIN question_options o ON o.id = aa.selected_option_id
SET    aa.awarded_marks = IF(o.is_correct = 1, q.marks, 0)
WHERE  aa.tenant_id  = 1
  AND  aa.attempt_id = 9001;

-- roll the total up onto the attempt
UPDATE attempts a
SET    a.score  = (SELECT COALESCE(SUM(awarded_marks), 0)
                   FROM attempt_answers
                   WHERE attempt_id = 9001 AND tenant_id = 1),
       a.status = 'graded'
WHERE  a.id = 9001 AND a.tenant_id = 1;

Admin dashboard — average score per quiz

SELECT qz.title, ROUND(AVG(a.score), 1) AS avg_score, COUNT(*) AS attempts
FROM   attempts a
JOIN   quizzes qz ON qz.id = a.quiz_id
WHERE  a.tenant_id = 1
  AND  a.status = 'graded'
GROUP BY qz.id, qz.title
ORDER BY avg_score DESC;
OUTPUT — tenant 1 only
title avg_score attempts Data Structures Mid-term 78.4 212 Intro to Databases Quiz 2 71.9 198 Operating Systems Test 1 66.2 176

Section 13

Global vs Tenant-Scoped Tables

Not every table needs a tenant_id. Lookups that are the same for everyone stay global. Everything that belongs to a university is scoped. Keep this split clear.

🌎 Global (no tenant_id)
plans
roles
question_types (if used)
🏢 Tenant-scoped (needs tenant_id)
tenants, subscriptions, users, user_roles
courses, enrollments
quizzes, questions, question_options
attempts, attempt_answers
⚖️ Quick Test: Does This Table Need tenant_id?
Ask 1
Would two universities ever want different rows here? If yes → add tenant_id.
Ask 2
Is this a fixed list the platform owns (plans, roles)? If yes → keep it global.
Ask 3
Does a query ever join it per university? If yes → index it as (tenant_id, …).

Section 14

Golden Rules

🏢 Multi-Tenant SaaS — Non-Negotiable Rules
1
Put tenant_id on every tenant table. It is the wall between universities. Make it the first column of most indexes, like (tenant_id, …).
2
Filter every query by tenant_id. Do it in one shared data layer, never by hand in each screen. One missed filter leaks data across tenants.
3
Scope your unique keys. Use UNIQUE(tenant_id, email) and UNIQUE(tenant_id, code). A bare unique key breaks across universities.
4
Keep global lookups global. Plans and roles are shared. Do not copy them per tenant — that just creates drift and extra work.
5
Separate tenancy from roles. tenant_id says which university. The role says what the user may do inside it. You need both checks.
6
Cascade deletes down the tenant. Deleting a tenant should remove its users, courses, quizzes, and attempts. Set ON DELETE CASCADE on those chains.
7
Test isolation on purpose. Write automated tests that log in as tenant A and prove tenant B's rows are never returned. Isolation you did not test is isolation you do not have.