Skip to content
← All patterns

saas

SaaS Multi-Tenant

What belongs to this tenant?

Organisations and their members, teams, projects and API keys in one table, isolated by partition.

Everything that belongs to a customer organisation sits under its own partition key ORG#<orgId>, so a query for one organisation can't return another's data, and members live beside the org record so the whole tenant reads in one Query. Users can belong to several organisations through membership items, and usage is counted per month with atomic updates, ready for billing.

The model

Organisation

A tenant organisation (customer account).

pk
ORG#<orgId>
sk
ORG#<orgId>

Attributes: PK (S), SK (S), orgId (S), name (S), slug (S), plan (S), status (S), billingEmail (S), stripeCustomerId (S), settings (M), limits (M), createdAt (S)

OrganisationSlug

Uniqueness tracker for organisation slugs.

pk
SLUG#<slug>
sk
SLUG#<slug>

Attributes: PK (S), SK (S), slug (S), orgId (S), createdAt (S)

User

A user account that can belong to several organisations.

pk
USER#<userId>
sk
USER#<userId>

Attributes: PK (S), SK (S), userId (S), email (S), firstName (S), lastName (S), avatarUrl (S), defaultOrgId (S), createdAt (S), lastLoginAt (S)

Membership

A user's membership of an organisation, with a role (the adjacency item).

pk
ORG#<orgId>
sk
MEMBER#<userId>

Attributes: PK (S), SK (S), GSI1PK (S), GSI1SK (S), orgId (S), userId (S), role (S), invitedBy (S), invitedAt (S), acceptedAt (S)

Team

A team within an organisation, grouping projects and members.

pk
ORG#<orgId>
sk
TEAM#<teamId>

Attributes: PK (S), SK (S), orgId (S), teamId (S), name (S), description (S), memberIds (SS), createdAt (S)

Project

A project belonging to an organisation, and optionally to a team.

pk
ORG#<orgId>
sk
PROJECT#<projectId>

Attributes: PK (S), SK (S), GSI2PK (S), GSI2SK (S), orgId (S), projectId (S), teamId (S), name (S), description (S), status (S), visibility (S), createdBy (S), createdAt (S)

ApiKey

An API key for programmatic access to a project.

pk
ORG#<orgId>
sk
APIKEY#<keyId>

Attributes: PK (S), SK (S), GSI3PK (S), GSI3SK (S), orgId (S), keyId (S), hashedKey (S), name (S), projectId (S), permissions (SS), createdBy (S), lastUsedAt (S), expiresAt (S), createdAt (S)

UsageRecord

Monthly usage aggregation for billing and analytics.

pk
ORG#<orgId>
sk
USAGE#<yearMonth>#<metric>

Attributes: PK (S), SK (S), orgId (S), yearMonth (S), metric (S), value (N), limit (N), lastUpdatedAt (S)

Invitation

A pending invitation to join an organisation.

pk
ORG#<orgId>
sk
INVITE#<email>

Attributes: PK (S), SK (S), GSI4PK (S), GSI4SK (S), orgId (S), email (S), token (S), role (S), invitedBy (S), createdAt (S), ttl (N)

Access patterns

  • QueryGet organisation members

    List every member of an organisation.

  • GetItemGet an organisation

    Fetch a single organisation record.

  • QueryGet organisation with all data

    Get org details, members, teams, and projects in one query

  • Query · GSI1Get user's organisations

    List all organisations a user belongs to

  • Query · GSI2Get team projects

    List projects assigned to a specific team

  • Query · GSI3Authenticate API key

    Lookup API key by its hashed value

  • Query · GSI4Accept invitation by token

    Find invitation by token to accept it

  • TransactWriteItemsTrack usage increment

    Atomically increment usage counter for billing

  • QueryValidate slug uniqueness

    Check if organisation slug is available

  • QueryGet user profile

    Fetch one user's account, whichever organisations they belong to.

Design notes

Isolation by partition keywhy

Every item for a tenant shares ORG#<orgId>, so a query for one organisation cannot return another's data, and the whole tenant is one Query away. That isolation is what multi-tenant security rests on.

Org record beside its memberstrade-off

Storing the organisation under SK = ORG#<orgId> keeps it in the same partition as its members, so one Query can fetch the org and its people together.

OrganisationSlug for uniquenesswhy

Separate item enforces slug uniqueness via conditional write. Alternative would be a GSI on slug, but conditional write is simpler and doesn't require a GSI scan for uniqueness check.

Hashed API keys in GSI3gotcha

Never store API keys in plaintext. GSI3 uses the SHA-256 hash for lookup. The original key is only shown once at creation. If you need to rotate, create a new key.

Usage tracking with atomic counterswhy

UsageRecord uses ADD operation for atomic increment. This handles concurrent API calls safely. Monthly aggregation simplifies billing queries.

Single-query dashboard loadtrade-off

Query on PK=ORG#<orgId> returns org, members, teams, projects, keys, usage, and invitations. One round trip for entire dashboard. Trade-off: large response, but client can filter.

Team assignment is optionalalternative

Projects may or may not belong to a team. GSI2 only has projects with teamId set (sparse). Org-wide projects queried directly from main table.

Counting usage is a transaction the page cannot runtrade-off

Track usage increment is a TransactWriteItems: a condition check that the organisation exists, and the ADD on its counter for the month, which succeed together or not at all. The engine in this page does not support TransactWriteItems, so it is not offered as a run.

Taught in the course

saas-multi-tenantDynamoDB workbench
Ready to run
Explore an access patternSelect to load & run

List every member of an organisation.

Request
Execute against the local Dynoxide engine
ReturnedFiltered outChanged
PK(pk)
SK(sk)
name
plan
billingEmail
createdAt
limits
orgId
settings
slug
status
stripeCustomerId
avatarUrl
defaultOrgId
email
firstName
lastLoginAt
lastName
userId
GSI1PK
GSI1SK
role
acceptedAt
invitedAt
invitedBy
description
memberIds
teamId
GSI2PK
GSI2SK
createdBy
projectId
visibility
GSI3PK
GSI3SK
hashedKey
keyId
lastUsedAt
permissions
lastUpdatedAt
limit
metric
value
yearMonth
GSI4PK
GSI4SK
token
ttl
ORG#acme
MEMBER#u-adaS
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
USER#u-adaS
ORG#acmeS
ownerS
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
ORG#acme
MEMBER#u-bobS
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
USER#u-bobS
ORG#acmeS
adminS
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
ORG#acme
MEMBER#u-cleoS
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
USER#u-cleoS
ORG#acmeS
memberS
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
ORG#acme
ORG#acmeS
Acme IncS
enterpriseS
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
ORG#globex
ORG#globexS
GlobexS
teamS
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
ORG#org-acme
APIKEY#key-001S
Production API KeyS
—
—
2026-03-31T00:00:00ZS
—
org-acmeS
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
u-001S
proj-apiS
—
KEY#a1b2c3d4e5f6...S
KEY#a1b2c3d4e5f6...S
a1b2c3d4e5f6...S
key-001S
2026-10-13T10:00:00ZS
read:data, write:dataSS
—
—
—
—
—
—
—
—
—
ORG#org-acme
INVITE#charlie@example.comS
—
—
—
2026-10-14T00:00:00ZS
—
org-acmeS
—
—
—
—
—
—
charlie@example.comS
—
—
—
—
—
—
memberS
—
—
u-001S
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
INVITE#xyz789tokenS
INVITE#xyz789tokenS
xyz789tokenS
1792540800N
ORG#org-acme
MEMBER#u-001S
—
—
—
—
—
org-acmeS
—
—
—
—
—
—
—
—
—
—
u-001S
USER#u-001S
ORG#org-acmeS
ownerS
2026-02-12T00:00:00ZS
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
ORG#org-acme
MEMBER#u-002S
—
—
—
—
—
org-acmeS
—
—
—
—
—
—
—
—
—
—
u-002S
USER#u-002S
ORG#org-acmeS
adminS
2026-02-18T00:00:00ZS
2026-02-17T00:00:00ZS
u-001S
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
ORG#org-acme
ORG#org-acmeS
Acme CorporationS
proS
billing@acme.example.comS
2026-02-12T00:00:00ZS
{3}M
org-acmeS
{2}M
acmeS
activeS
cus_abc123S
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
ORG#org-acme
PROJECT#proj-apiS
API GatewayS
—
—
2026-02-28T00:00:00ZS
—
org-acmeS
—
—
activeS
—
—
—
—
—
—
—
—
—
—
—
—
—
—
Main API serviceS
—
team-engS
TEAM#team-engS
PROJECT#proj-apiS
u-001S
proj-apiS
organisationS
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
ORG#org-acme
TEAM#team-engS
EngineeringS
—
—
2026-02-12T00:00:00ZS
—
org-acmeS
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
Software engineering teamS
u-001, u-002SS
team-engS
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
ORG#org-acme
USAGE#2026-10#api_callsS
—
—
—
—
—
org-acmeS
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
2026-10-13T10:30:00ZS
1000000N
api_callsS
547832N
2026-10S
—
—
—
—
ORG#org-acme
USAGE#2026-10#storage_bytesS
—
—
—
—
—
org-acmeS
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
2026-10-13T08:00:00ZS
10737418240N
storage_bytesS
5368709120N
2026-10S
—
—
—
—
SLUG#acme
SLUG#acmeS
—
—
—
2026-02-12T00:00:00ZS
—
org-acmeS
—
acmeS
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
USER#u-001
USER#u-001S
—
—
—
2025-09-09T00:00:00ZS
—
—
—
—
—
—
https://cdn.example.com/avatars/alice.pngS
org-acmeS
alice@example.comS
AliceS
2026-10-13T09:30:00ZS
JohnsonS
u-001S
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
USER#u-002
USER#u-002S
—
—
—
2025-11-17T00:00:00ZS
—
—
—
—
—
—
—
org-acmeS
bob@example.comS
BobS
2026-10-12T16:00:00ZS
SmithS
u-002S
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
Awaiting request
Your next query starts here.

Choose an access pattern above, or build your own request. See what comes back and what it costs.

Write transactions, streams, tags and TTL are among the operations this browser build leaves out. dynoxide's native build has them.