← Back to Learning Hub

Designing a Self-Serve Platform: One Model for Individuals and Organizations

Multi-TenancyArchitectureIntermediate15 min

By: Anacodic Team

TL;DR — A platform that supports both "one person working alone" and "an organization with many members" is tempted to build two things: a personal-account system and a separate organization system. The cheaper, more robust design treats an individual account as nothing more than a workspace with exactly one member, so organizations and individuals run through the identical membership, permission, and resource-ownership code — the member count is data, not a fork in the logic. In the harness below, one can_access function, containing zero conditionals on account type, correctly handles both a one-member workspace and a three-member organization. This design earns its keep specifically because self-serve growth (one user signs up alone, later invites colleagues) is a first-class path, not an edge case — for a product where individual and organizational accounts are genuinely, permanently different products, the unification isn't worth forcing.


1. Simple explanation

A product that lets anyone sign up and start working alone, and separately lets an organization onboard many people at once, faces a modeling choice on day one: are "personal account" and "organization account" two different kinds of thing, each with their own membership and permission logic, or are they the same kind of thing, one of which just happens to have one member? Building two separate systems means every feature — invites, roles, billing, resource ownership — gets implemented twice, once per account type, and the two implementations drift apart the moment either one changes without the other keeping up.

Analogy — a shared apartment lease that also works for a single tenant. A well-designed lease template doesn't have one legal structure for "one person renting alone" and a completely different structure for "three roommates splitting a lease" — it's one lease, with a list of tenants, and the list happens to have one name on it or several. Adding a roommate to a one-person lease is adding a name to the existing list, not switching to a different kind of contract. A platform's account model can work the same way: the entity that owns resources and has members is one thing, and "personal" is just the case where that thing's member list has exactly one name on it.


2. Diagram

TWO SEPARATE SYSTEMS                      ONE UNIFIED MODEL

  PersonalAccount                           Workspace
    - owner: user                             - members: {user_id: role}
    - resources: [...]                        - resources: [...]
    - can_access(user, action) -----+          - kind: "personal" | "org"
        [personal-specific logic]   |            (a LABEL, never branched on)
                                    |
  OrgAccount                       |          can_access(workspace, user, action)
    - members: {user: role}        |            [ONE implementation, used by
    - resources: [...]             |             every workspace regardless
    - can_access(user, action) ----+             of member count]
        [org-specific logic,
         DUPLICATING most of
         the personal logic,
         plus role handling]

  Every feature (invites, roles,          Every feature is implemented
  billing) is written TWICE and           ONCE against "a workspace with
  can drift out of sync.                  N members," N >= 1.

  MEASURED (harness in §5):
    can_access() called against a 1-member workspace and a 3-member
    workspace: SAME function, correct result both times, zero branches
    on workspace kind.

3. How it works

3.1 An individual is a workspace of one

The core modeling move is refusing to treat "how many members does this have" as a fact that changes what kind of entity something is. A workspace is: an identifier, a set of members each with a role, and a set of resources it owns. A personal account satisfies that definition with a member set of size one, where that one member holds the most senior role. An organization satisfies the identical definition with a larger member set. Nothing about the definition, or about any function that operates on it, needs to know or care which case it's looking at.

3.2 Permission logic reads roles and membership, never account type

Every permission check — can this user view this resource, manage members, delete the workspace — is answerable purely from "is this user a member, and what's their role," regardless of how many other members exist. A function written this way handles a one-member workspace correctly for the same reason it handles a fifty-member workspace correctly: it never asked "is this personal or organizational" in the first place, so there's no code path that could diverge between the two.

3.3 Growth from individual to organization becomes an operation, not a migration

Because both cases are the same underlying entity, "a solo user invites colleagues" is just calling the same invite function that already exists for adding the second, tenth, or hundredth member to any workspace — there is no separate "convert my personal account into an organization" migration step, no data model change, and no feature that has to be re-implemented once a workspace crosses from one member to two. This is the direct payoff of unification: self-serve growth from individual usage to team or institutional usage is free, because it was never a different code path to begin with.

Where this stops working: the unification assumes that everything meaningfully differing between an individual and an organization can be expressed as data (member count, role assignments, billing plan) rather than as fundamentally different behavior. A product where organizational accounts genuinely need different resource types, different workflows, or different underlying architecture — not just more members — is better served by two real systems; forcing that difference into one workspace model just relocates the special-casing into conditionals scattered through otherwise-unified code, which is worse than having two honestly separate systems.


4. The math

There's no formula to derive here — the measurable claim is structural, not numerical: the same function, unmodified, produces correct results for a member count of 1 and a member count of 3 (or any N), because its logic depends only on (workspace, user, action), never on how many entries exist in workspace.members. The demonstration in §5 is a correctness proof by exhaustive check across both cases, not a statistic — either the one function handles both cases correctly, or it doesn't, and there's no partial credit or approximate answer in between.


5. Real code

from dataclasses import dataclass


@dataclass
class Workspace:
    id: str
    kind: str  # "personal" or "org" -- a label only, never branched on
    members: dict  # user_id -> role ("owner" | "admin" | "member")


def create_personal_workspace(user_id):
    """A personal account is just a workspace with exactly one member,
    who owns it. No separate code path from an org workspace below."""
    return Workspace(id=f"ws-{user_id}", kind="personal",
                      members={user_id: "owner"})


def create_org_workspace(org_id, owner_id):
    """An org is the SAME entity with more than one member over time."""
    return Workspace(id=f"ws-{org_id}", kind="org",
                      members={owner_id: "owner"})


def invite_member(workspace, user_id, role="member"):
    workspace.members[user_id] = role


def can_access(workspace, user_id, action):
    """ONE function, used for personal and org workspaces alike. It never
    checks workspace.kind -- membership and role are the only inputs that
    matter, regardless of how many members exist."""
    role = workspace.members.get(user_id)
    if role is None:
        return False
    if action == "view":
        return True  # any member can view
    if action == "manage_members":
        return role in ("owner", "admin")
    if action == "delete_workspace":
        return role == "owner"
    raise ValueError(f"unknown action: {action}")


personal = create_personal_workspace("alice")
org = create_org_workspace("acme", owner_id="bob")
invite_member(org, "carol", role="admin")
invite_member(org, "dave", role="member")

checks = [
    (personal, "alice", "view"),
    (personal, "alice", "delete_workspace"),
    (personal, "mallory", "view"),           # not a member at all
    (org, "bob", "delete_workspace"),
    (org, "carol", "manage_members"),
    (org, "dave", "manage_members"),
    (org, "dave", "view"),
]

print(f"{'workspace':10}{'kind':10}{'user':10}{'action':18}{'result'}")
for ws, user, action in checks:
    result = can_access(ws, user, action)
    print(f"{ws.id:10}{ws.kind:10}{user:10}{action:18}{result}")

assert can_access(personal, "alice", "delete_workspace") is True
assert can_access(personal, "mallory", "view") is False
assert can_access(org, "dave", "manage_members") is False
assert can_access(org, "carol", "manage_members") is True
print("\nasserts passed: same can_access() function correctly handles "
      "a 1-member workspace and a 3-member workspace with zero "
      "special-casing on workspace.kind")

# Output:
# workspace kind      user      action            result
# ws-alice  personal  alice     view              True
# ws-alice  personal  alice     delete_workspace  True
# ws-alice  personal  mallory   view              False
# ws-acme   org       bob       delete_workspace  True
# ws-acme   org       carol     manage_members    True
# ws-acme   org       dave      manage_members    False
# ws-acme   org       dave      view              True
#
# asserts passed: same can_access() function correctly handles a 1-member workspace and a 3-member workspace with zero special-casing on workspace.kind

The four asserts cover both the one-member and three-member cases, an owner action, a member-role rejection, and a non-member rejection — all passing against a single implementation of can_access that never inspects workspace.kind.


6. Real-world example

A product initially shipped with two separate account types: an individual plan with its own signup flow, resource model, and settings pages, and a team plan added later with its own membership table, its own invite flow, and its own permission checks written independently because the team feature was built by a different engineer months after the individual flow shipped. Both worked correctly at launch.

The trouble surfaced when the product added a feature — shared resource templates — that needed to work for both individual and team users. Because the two account types had separate data models, the feature had to be implemented twice: once against the individual schema, once against the team schema, with two separate sets of tests. A bug fix in the individual version's permission check three months later didn't get ported to the team version, because the two code paths had diverged enough that the engineer fixing the individual bug didn't recognize the team code as needing the identical fix — it lived in a different file, with different variable names, structured around a different (but equivalent) set of assumptions.

The eventual fix was a migration to a single workspace model, where an individual account became, structurally, a workspace with one member — an involved, multi-month data migration that would not have been necessary had the two account types been unified from the start. The team's retrospective conclusion was that the two-system design had felt like the simpler choice early on, specifically because it deferred the harder modeling question (what do these two things actually have in common?) rather than answering it up front.


7. Interview questions companies actually ask

Q1. Why model an individual account as "a workspace with one member" instead of a separate, simpler entity? Because every feature that needs to exist for a multi-member organization — resource ownership, permission checks, invites — also needs to exist, in some form, for an individual account, and building it twice means it can drift out of sync between the two whenever one is updated without the other. Treating both as the same entity with a variable member count means every feature is implemented once and correctly serves both cases by construction.

Q2. What's the concrete cost of building two separate systems (personal accounts and organization accounts) instead of one unified model? Every shared feature — permissions, invites, resource ownership, billing — has to be implemented and tested twice, and the two implementations can silently diverge over time as one gets bug-fixed or extended without a matching change to the other. The real-world example in §6 shows the failure mode directly: a permission bug fixed in one system's code simply wasn't recognized as the same bug in the other system's independently-written equivalent.

Q3. How does a unified workspace model make "a solo user invites colleagues" simpler? It isn't a special operation at all — it's the ordinary invite function, already used for adding any member to any workspace, called on a workspace that happens to currently have one member. There's no account-type conversion, no data migration, and no separate code path to invoke; the workspace simply now has two members instead of one, and every function that already worked for one member continues to work unmodified.

Q4. When would forcing individual and organizational accounts into one model be the wrong call? When the two genuinely need different resource types, different workflows, or fundamentally different behavior — not just a different number of members. Forcing that real difference into one unified model just relocates the special-casing into conditionals scattered through what's supposed to be shared code, which is worse than two honestly separate systems, because the special-casing is now hidden inside logic that looks unified but isn't.

Q5. How would you test that a permission function genuinely doesn't special-case on account type, rather than trusting the code review caught it? Run the identical set of permission checks against both a one-member and a multi-member workspace and confirm the results match what each case's actual membership and roles predict — if the function secretly branches on account kind, a carefully chosen test case (for instance, an action that should be denied only for personal accounts, if such special-casing exists) will expose a result that doesn't follow purely from membership and role.

Q6. Your product needs org-only features — SSO configuration, billing seats — that genuinely don't apply to a one-member workspace. Does that break the unified model? No — those features are naturally guarded by a check like "does this workspace have more than one member" or "is a billing plan configured," which is a data condition on the workspace, not a fork into a separate account-type system. The unified model doesn't mean every feature applies identically to every workspace; it means the underlying entity and its core operations (membership, permissions, resource ownership) are shared, while specific features can still be conditional on the workspace's actual state.


8. When to use / tradeoffs

Reach for a unified workspace model when:

  • self-serve growth from individual use to team or organizational use is a real, expected path, not a hypothetical
  • most features (permissions, resource ownership, invites) need to work identically regardless of member count
  • you're designing the account model from scratch, before two separate systems have already been built and diverged
SituationWhy it breaksUse instead
Individual and organizational accounts are genuinely different products with different resource types or workflowsForcing them into one model relocates the real difference into scattered conditionals instead of eliminating itTwo honestly separate systems, sharing only what's genuinely common (e.g., authentication)
Two separate account systems already exist and have diverged significantlyUnifying them is now a real data migration, not a modeling choice made earlyWeigh migration cost against the ongoing cost of maintaining drifted duplicate logic
The product will never support more than one member per accountThe membership/role machinery is complexity with no case that ever uses more than one entryA simpler single-owner model without a member list at all
Org-specific features (SSO, seat-based billing) are the majority of the product's actual complexityThe workspace-of-one framing undersells how different org accounts really areModel the org-specific concerns as their own explicit subsystem, layered on top of a shared core

Honest limits. Unifying individual and organizational accounts into one workspace model doesn't eliminate the need for account-type-specific features — it only means the underlying entity and its core operations don't need to be duplicated. A real product will still have things that only apply above a certain member count or plan tier, and those still need explicit handling; the unification just keeps that handling as conditions on workspace state rather than as an entirely separate account-type system. And the migration cost of unifying two systems that already exist and have already diverged is real and can be substantial — the design is cheapest when chosen from the start, as shown by the real-world example's multi-month migration cost for doing it after the fact.


  • Modeling an individual account as "a workspace with exactly one member" lets organizations and individuals share the identical membership, permission, and resource-ownership logic instead of duplicating it across two systems.
  • Measured: one can_access function, containing no conditional on account type, correctly handled every permission check across both a one-member and a three-member workspace.
  • Growth from individual to team usage becomes an ordinary invite operation rather than an account-type migration, because both were the same kind of entity all along.
  • Boundary: this only pays off when individual and organizational accounts genuinely differ only in scale (member count, plan tier) — where they differ in kind (different resource types, different workflows), unifying them just hides the real difference inside conditionals.

Related:

  • Prompt Management Architecture: Prompts as Files, Not Strings — the same underlying principle (one shared mechanism handling what looks like two cases, via a lookup/data difference rather than a code fork) applied to prompt selection instead of account modeling
  • RAG at Scale — the workspace model's natural complement: once workspaces exist, their data still needs to be isolated from each other in any shared retrieval index

Runnable notebook

Run it end to end — the mock model needs no API key; add your own key for the real Claude section.

Open In Colab