This application is not multi-tenant

Free preview 4 min to read

Multi-tenancy is easiest to understand from an application that doesn't have it, because the gap is measurable.

The measurement

tables: 33

with user_id (13):
  bookmarks, community_comments, enrollments, experience_points,
  identity_authentications, lesson_completions, lesson_notes,
  partner_referrals, quiz_attempts, solution_reveals, submissions,
  user_badges, user_preferences

without user_id (20):
  action_text_rich_texts, active_storage_attachments,
  active_storage_blobs, active_storage_variant_records, badges,
  categories, challenges, courses, lessons, levels,
  partner_api_tokens, partner_course_sponsorships,
  partner_handoff_nonces, partner_inquiries, partner_roles,
  partners, quiz_options, quiz_questions, quizzes, users

That's db/schema.rb on 2026-10-05. Four of the twenty are Rails infrastructure (Action Text and Active Storage), so the real shape is twenty-nine domain tables: thirteen carrying a user_id, sixteen not.

Why user_id isn't a tenant key

The whole subject rests on this distinction, and the two look identical in a schema.

user_id answers "who owns this row?" A lesson completion belongs to a learner. Another learner's completion is a different row, and both are ordinary data in one shared table.

account_id answers "whose data partition is this?" Two accounts must never see each other's rows. A query that returns one account's data to another is a data breach, not a bug in a feature.

The difference is in what happens when the scope is forgotten.

Forget user_id on a lesson-completion query and a learner sees the wrong progress number, which is embarrassing. Forget account_id on the same query in a multi-tenant application and one customer sees another customer's data, which is an incident with a legal side.

flowchart TB
  U["user_id: who owns this row"] --> U1["forgotten: a wrong number on a page"]
  A["account_id: whose partition this is"] --> A1["forgotten: one customer sees another's data"]
  U1 --> D["same column shape, different consequence"]
  A1 --> D

What's missing

Identity::Current has one attribute:

module Identity
  class Current < ActiveSupport::CurrentAttributes
    attribute :user
  end
end

In a multi-tenant application this is where the tenant lives, because it's the one place every layer can read without passing it through every method signature. There's no attribute :account, and nothing to add one for.

The sixteen domain tables without a user_id fall into groups. Nine are the shared catalog (courses, lessons, challenges, quizzes, quiz_questions, quiz_options, categories, levels, badges), and they're correct as they stand: every learner sees the same courses, by design. Four belong to a partner through partner_id (partner_api_tokens, partner_course_sponsorships, partner_handoff_nonces, partner_roles), which is the nearest thing this application has to a tenant key: one partner's API tokens must never be visible to another. The rest are the owners themselves (partners, users) and inbound partner inquiries.

That's the significant fact. A multi-tenant version of this application wouldn't add a tenant key to twenty-nine tables, because the catalog is shared. Lesson 3 is about how a table earns the key or doesn't.

When it would arrive

One sentence changes everything: "a company wants to buy fifty seats and see their team's progress."

That isn't a request for a report. It introduces an entity above the user. With it come an admin who sees fifty people's completions and nobody else's, an invitation flow and a billing relationship. And a rule: one company's data never appears in another's.

The learners still share the catalog. So the answer isn't "make everything tenant-scoped". It's a per-table decision, which is why the measurement above is the right starting point.

Why learn this before it arrives

Because the choice is expensive to change and cheap to prepare for.

The patterns in lesson 2 differ in migration cost, isolation and operational overhead, and switching between them later means a data migration across every tenant. The preparation, on the other hand, is nearly free. Know which tables would be shared, keep ownership scoping explicit instead of implicit, and don't add a globally unique index that would have to become per-tenant.

Lesson 6 is that preparation list, and none of it requires deciding anything today.

Key takeaways

  • Measured on 2026-10-05: 33 tables, 13 with user_id, and 16 domain tables without one.
  • user_id says who owns a row; a tenant key says whose partition it is. Same shape, different consequence when forgotten.
  • Identity::Current declares one attribute, and a tenant would live there.
  • The catalog tables are shared on purpose, and the partner tables already carry a partner key; a tenant key is a per-table decision.
  • The choice is expensive to change and nearly free to prepare for.
Lesson 1 of 7 · free

Keep going with lesson 2

Three patterns

Compares row-level, schema-level and database-level tenancy by isolation, migration cost, connections and how each one leaks.

A free account also gives you the quizzes, your progress and notes, and the discussion.

Type to start searching...