Skip to content
← Review options

Sample Deliverable · Premium Tier

Acme HR SaaS

DynamoDB Schema Review · Premium Tier

Reviewer · Tejovanth N · singletable.dev Delivered · May 2026

Tejovanth has shipped production DynamoDB systems for rasika.life, rekha.app, and several B2B SaaS apps since 2022. Writes single-table design patterns at singletable.dev.

At a glance

5 entities · 12 access patterns · 1 overloaded GSI · ~$2/month at launch · ~$21/month at 10×. Greenfield design optimised for tenant isolation and time-ordered queries.

  • · ULID over UUID for composite sort keys: preserves time-ordered query semantics
  • · Relationship item (DeptEmployee) for M:N instead of a GSI: cheaper, no extra index
  • · Sparse GSI for open jobs: closed jobs auto-excluded, no FilterExpression
  • · Denormalised headcount via Transactions: consistent without scanning
  • · GSI1 overloaded across 3 entity types: 1 index covers AP4, AP8, and AP11

Project brief

Company: Acme HR SaaS
Use case: Multi-tenant HR platform for small and mid-size companies (10–500 employees per org)
Core features: Employee directory, department management, internal job board, applicant tracking
Stack: SST v3 · Node.js / TypeScript · ElectroDB · DynamoDB on-demand
Scale targets: 500 organisations at launch · up to 1,000 employees per org · 50 concurrent writes/second at peak
Key constraint: Employee email is the SSO identifier. Lookup by email must be sub-millisecond.

Acme HR SaaS is a multi-tenant B2B product. Each paying customer is an Organisation. Employees within an Organisation can browse open job postings, apply internally, and track their application status. HR admins manage departments, headcount, and the hiring pipeline. The auth flow authenticates employees by email address (Google/Okta SSO), so email lookup must be a primary key operation, not a scan.

Entities

Five entity types. All IDs are ULIDs (lexicographically sortable, time-ordered, URL-safe). Choosing ULID over UUID here is intentional: composite sort keys like JOB#<postedAt>#<jobId> depend on the ID sorting by creation time. A UUID in that position produces random ordering and breaks time-sorted queries.

EntityKey Attributes
OrganisationorgId, name, plan (free / pro / enterprise), status, createdAt
EmployeeempId, orgId, email, firstName, lastName, departmentId, role (admin / manager / employee), status (active / inactive), hiredAt, terminatedAt?
DepartmentdeptId, orgId, name, managerId (empId), headcount (denormalised counter)
JobPostingjobId, orgId, departmentId, title, status (draft / open / closed), postedAt, closedAt?
ApplicationappId, jobId, empId, status (applied / reviewing / rejected / accepted), submittedAt

Access patterns

Twelve access patterns.

#DescriptionWho triggers it
AP1Get organisation by IDSession bootstrap on every login
AP2Get employee by ID (within org context)Profile page, permission checks
AP3List all employees in an org, sorted by hire dateEmployee directory
AP4Get employee by emailSSO authentication, invite deduplication
AP5List all departments in an orgOrg chart, department picker
AP6Get department by IDDepartment detail page
AP7List all employees in a departmentTeam roster, manager view
AP8List all open job postings for an org, newest firstInternal job board
AP9Get job posting by IDJob detail page
AP10List all applications for a job posting, newest firstApplicant review pipeline
AP11List all applications submitted by an employeeEmployee’s application history
AP12List all job postings for an org (all statuses), newest firstHR admin dashboard

Schema diagram

Acme HR SaaS DynamoDB schema: entity diagram showing Organisation, Employee, Department, JobPosting, DeptEmployee, and Application with PK/SK structure and relationships
PK/SK structure — 5 entities plus the DeptEmployee relationship item. GSI1 is overloaded across AP4 (email lookup), AP8 (open jobs), and AP11 (applications by employee).

Table design

Main table: acme-hr-{stage}

One table. On-demand billing. One overloaded GSI (GSI1).

PK/SK structure

EntityPKSKNotes
OrganisationORG#<orgId>#METADATA# sorts before all letters, so it’s always first in any ORG# partition
DepartmentORG#<orgId>DEPT#<deptId>Co-located with Org; AP5 is a single Query with begins_with DEPT#
EmployeeORG#<orgId>EMP#<empId>Co-located with Org; AP3 is a single Query with begins_with EMP#
DeptEmployee (relationship item)DEPT#<deptId>EMP#<empId>Written atomically alongside the Employee item (same Transaction). Required for AP7.
JobPostingORG#<orgId>JOB#<postedAt>#<jobId>Composite SK enables AP12 (all jobs, newest first) without a GSI
ApplicationJOB#<jobId>APP#<submittedAt>#<appId>Composite SK enables AP10 (applications per job, newest first) without a GSI

The composite sort keys on JobPosting and Application are the load-bearing decision in this schema. Because postedAt and submittedAt are ULIDs, ScanIndexForward=false returns results newest-first with zero client-side sort. See ULIDs vs UUIDs vs Timestamps for Sort Keys for why this breaks with UUID.

Why DeptEmployee is a separate write

AP7 (list employees in a department) requires querying by DEPT#<deptId>. Employee items live at ORG#<orgId>, the wrong partition for that query. Rather than a GSI, we write a lightweight relationship item at DEPT#<deptId> / EMP#<empId> alongside every employee create/update. This item holds only orgId, enough to hydrate the full Employee record via BatchGetItem if needed.

This is the typical approach for M:N in DynamoDB without the GSI cost. See The 5 Most Common Single-Table Design Mistakes. Mistake #2 is reaching for a GSI when a relationship item solves the problem cheaper.


GSI1: overloaded across three entity types

Rather than three separate GSIs (one for email lookup, one for applications-by-employee, one for open-jobs-by-org), this schema uses one GSI with disjoint PK shapes per entity type. DynamoDB charges per GB of projected data in a GSI, not per access pattern. Overloading keeps storage cost flat as the schema grows.

EntityGSI1PKGSI1SKEnables
EmployeeEMAIL#<email>EMP#<empId>AP4: employee by email
ApplicationEMP#<empId>APP#<submittedAt>#<appId>AP11: applications by employee, time-ordered
JobPosting (open only — sparse)ORG#<orgId>#OPENJOB#<postedAt>#<jobId>AP8: open jobs by org, newest-first

Why the sparse index on JobPosting

When a job is closed, its GSI1PK attribute is deleted (set to undefined). DynamoDB only writes a record into a GSI when the projected attributes are present, so closed jobs are never indexed in GSI1. The AP8 result set is clean without a FilterExpression, and no cleanup Lambda is required. This pattern is covered in Sparse Indexes: Queues, Soft Deletes, Active-Only Views.

Why the key shapes don’t collide

The SK begins_with filter in each query provides a second discriminator. The full rationale is in When to Add a GSI vs Reshape Your Sort Key.


Sample data

Ten representative items with realistic attribute values. ULIDs truncated for readability.

PKSKtypeKey attributes
ORG#01HXAA#METADATAorgname=“Acme Corp”, plan=“pro”, status=“active”
ORG#01HXAADEPT#01HXABdeptname=“Engineering”, managerId=“01HXAD”, headcount=12
ORG#01HXAADEPT#01HXACdeptname=“People Ops”, managerId=“01HXAE”, headcount=4
ORG#01HXAAEMP#01HXADempemail=“alice@acme.co”, firstName=“Alice”, role=“admin”, GSI1PK=“EMAIL#alice@acme.co
ORG#01HXAAEMP#01HXAEempemail=“bob@acme.co”, firstName=“Bob”, role=“manager”, GSI1PK=“EMAIL#bob@acme.co
DEPT#01HXABEMP#01HXADdept_emporgId=“01HXAA”
DEPT#01HXABEMP#01HXAEdept_emporgId=“01HXAA”
ORG#01HXAAJOB#01HXZZ1#01HXAFjobtitle=“Senior Engineer”, status=“open”, GSI1PK=“ORG#01HXAA#OPEN”
ORG#01HXAAJOB#01HXZZ0#01HXAGjobtitle=“HR Coordinator”, status=“closed”, (no GSI1PK)
JOB#01HXAFAPP#01HXZZ9#01HXAHappempId=“01HXAD”, status=“reviewing”, GSI1PK=“EMP#01HXAD”

ElectroDB entity definitions

Production-ready TypeScript. Set TABLE_NAME in your environment. All five entities share one table and one ElectroDB Service.

import { Entity } from 'electrodb'
import { dynamoClient } from './dynamo'

export const Organisation = new Entity(
  {
    model: { entity: 'org', version: '1', service: 'acme-hr' },
    attributes: {
      orgId:     { 
        type: 'string', 
        required: true 
      },
      name:      { 
        type: 'string', 
        required: true 
      },
      plan:      { 
        type: 'string', 
        enum: ['free', 'pro', 'enterprise'], required: true 
      },
      status:    { 
        type: 'string', 
        enum: ['active', 'suspended'], default: 'active' 
      },
      createdAt: { 
        type: 'string', 
        readOnly: true, default: () => new Date().toISOString() 
      },
    },
    indexes: {
      byOrg: {
        pk: { 
          field: 'PK', 
          composite: ['orgId'], 
          template: 'ORG#${orgId}' 
        },
        sk: { 
          field: 'SK', 
          composite: [],        
          template: '#METADATA' 
        },
      },
    },
  },
  { table: process.env.TABLE_NAME!, client: dynamoClient },
)

GSI1 overloading: rationale

GSI1 serves three different query shapes. Each uses a distinct PK prefix, so they can’t collide.

QueryGSI1PK patternGSI1SK prefixFilterExpression needed?
Employee by email (AP4)EMAIL#<email>EMP#No
Applications by employee (AP11)EMP#<empId>APP#No
Open jobs by org (AP8)ORG#<orgId>#OPENJOB#No — sparse index handles it

The obvious alternative is three separate GSIs, one per access pattern. It works, but costs more: GSIs are billed on the data they store, and each additional index multiplies storage cost proportionally. All three access patterns here are served from one GSI with disjoint key shapes, and there’s no latency difference between querying one GSI or three.

The gsi1pk computed attribute uses ElectroDB’s watch + get to set GSI1PK only when status === 'open'. When a job closes, GSI1PK becomes undefined and the item drops out of GSI1 automatically. No cleanup Lambda needed. See Sparse Indexes for the full pattern.

See When to Add a GSI vs Reshape Your Sort Key for a full treatment.


Access pattern → query mapping

#DescriptionOperationKey condition
AP1Get org by IDGetItemPK=ORG#<orgId>, SK=#METADATA
AP2Get employee by IDGetItemPK=ORG#<orgId>, SK=EMP#<empId>
AP3List employees in orgQueryPK=ORG#<orgId>, SK begins_with EMP#, ScanIndexForward=false
AP4Get employee by emailQuery on GSI1GSI1PK=EMAIL#<email>
AP5List departments in orgQueryPK=ORG#<orgId>, SK begins_with DEPT#
AP6Get department by IDGetItemPK=ORG#<orgId>, SK=DEPT#<deptId>
AP7List employees in deptQueryPK=DEPT#<deptId>, SK begins_with EMP#
AP8List open jobs (org)Query on GSI1GSI1PK=ORG#<orgId>#OPEN, ScanIndexForward=false
AP9Get job posting by IDGetItemPK=ORG#<orgId>, SK=JOB#<postedAt>#<jobId> (see note)
AP10List applications for jobQueryPK=JOB#<jobId>, SK begins_with APP#, ScanIndexForward=false
AP11List applications by employeeQuery on GSI1GSI1PK=EMP#<empId>, GSI1SK begins_with APP#, ScanIndexForward=false
AP12List all jobs for orgQueryPK=ORG#<orgId>, SK begins_with JOB#, ScanIndexForward=false

AP9: GetItem on a JobPosting requires both orgId and the exact SK (JOB#<postedAt>#<jobId>). If job pages are accessed by jobId alone: (a) embed orgId and postedAt in the URL slug for a direct GetItem, or (b) store a lookup item at PK=JOB#<jobId>, SK=#META containing orgId and postedAt for a two-step fetch. Option (a) is simpler; (b) is better if the URL must be opaque.


Cost analysis

Estimates based on DynamoDB on-demand pricing ($0.25/million RCU, $1.25/million WCU). Baseline: 500 active orgs, 200 employees per org, 20 open jobs per org.

See On-Demand vs Provisioned: The Real Math for when provisioned capacity becomes worth evaluating.

#Access PatternRCU / callWCU / callEst. calls/dayDaily cost
AP1Get org0.550,000$0.006
AP2Get employee0.5200,000$0.025
AP3List employees in org45,000$0.005
AP4Get employee by email0.5100,000$0.013
AP5List departments0.510,000$0.001
AP6Get department0.520,000$0.003
AP7List employees in dept115,000$0.002
AP8List open jobs130,000$0.004
AP9Get job posting0.525,000$0.003
AP10List applications for job0.58,000$0.001
AP11List applications by employee0.55,000$0.001
AP12List all jobs for org1.52,000~$0.001
Employee writeCreate / update (main + DeptEmp items)22,000$0.005
Job status changeOpen → closed (removes from GSI1)1500$0.001
Application submitNew application1500$0.001
Total≈ $0.071/day

Monthly DynamoDB cost at baseline: ≈ $2.15.

Cost at 10× scale (5,000 orgs, 2,000 employees per org) scales roughly linearly to ≈ $21/month. The on-demand → provisioned crossover for this access pattern mix is around 50–100M requests/month. See Cost Optimization: The 5 Levers That Actually Matter for when that math starts to matter.


Trade-off analysis

What this schema supports

What this schema doesn’t handle well

1. Employee search by name AP3 lists employees sorted by hire date (ULID order). Alphabetical sort requires a client-side sort, which is negligible at 200 employees per org. Free-text search (e.g., name autocomplete) isn’t possible with DynamoDB queries. For admin-facing search, use OpenSearch Serverless or a DynamoDB export to S3 + Athena. This is a DynamoDB constraint, not a schema design problem. See What Queries Your DynamoDB Schema Explicitly Doesn’t Support.

2. Real-time headcount without counter drift The headcount attribute on Department is a denormalised counter maintained via atomic increment/decrement in Transactions. It stays accurate as long as every employee-department mutation goes through the transaction path. A direct Employee.upsert() will silently drift the count. Wrap all employee-department mutations in a single service function that enforces the Transaction. See DynamoDB Transactions vs Conditional Writes for when atomic counters are the right tool.

3. Org-wide analytics queries “All employees hired in Q1 across all orgs” or “job postings with more than 10 applications” require a full table scan or a secondary analytics store. Enable DynamoDB Streams and pipe events to S3 via Kinesis Firehose. Athena or DuckDB can answer arbitrary queries on the raw data at near-zero cost.

4. Employee lookup by empId alone (no orgId) AP2 requires orgId to construct the full primary key. This is correct for a multi-tenant SaaS: orgId comes from the session JWT. If a future external integration needs empId-only lookup, add a lookup item at PK=EMP#<empId>, SK=#META containing just orgId. Only add this if the use case materialises.

5. Sorting employees alphabetically across large orgs At 200 employees per org, client-side sort on AP3 is negligible. If orgs grow beyond 5,000 employees, consider adding lastName to the Employee SK: EMP#<lastName>#<empId>. That’s a schema migration; see Schema Migrations: The Guide Nobody Wrote for how to approach it.


  1. DeptEmployee writes must go through a Transaction. Every Employee.upsert() that sets or changes departmentId must atomically write the relationship item too. A missed write silently breaks AP7. It won’t surface as an error, just wrong data.

  2. Before you ship, write an integration test for the sparse GSI: create an open job, confirm AP8 returns it, close it, confirm AP8 excludes it. The sparse index is correct by construction, but that test will catch any regression if someone touches the job-close logic later.

  3. Set default: () => ulid() on every *Id attribute and validate ULID format at the service boundary. Swapping to UUID or nanoid silently breaks time-ordered queries. The symptom is random-ordered pagination, which is hard to trace back to an ID format change.

  4. Add optimistic locking to Employee. Two concurrent admin updates produce silent last-write-wins. Add a version counter and ConditionExpression: version = :expected. See DynamoDB Transactions vs Conditional Writes.

  5. Do the migration planning now, before you have production data. Greenfield schemas are cheap to change. Once you have millions of items, a sort key restructure requires a full live migration, and nobody wants to run that in production. See Schema Migrations: The Guide Nobody Wrote.

Tejovanth N · singletable.dev

This is a redacted sample — names and access patterns are fictional. See review tiers and pricing →