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
- The key is the address - GetItem needs the whole primary key: partition and sort.
- One query, a whole collection - Items that share a partition key are read together in one Query.
- Model a one-to-many with one partition - A parent and its children share a partition key, so one Query fetches them together.
- Absence as a filter - An item appears in an index only if it has the index's key, so leaving the key off filters it out.
List every member of an organisation.
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.