Single-Table Design: Entity, Access Pattern, Composite Key, Item Collection
Learn AWS Application and Database - Part 073
Single-table design DynamoDB untuk production system: access pattern, entity modeling, composite key, item collection, sparse index, denormalization, versioning, dan operational safety.
Part 073 — Single-Table Design: Entity, Access Pattern, Composite Key, Item Collection
Single-table design bukan aturan agama. Ia adalah teknik untuk menyusun banyak entity dalam satu table DynamoDB agar operasi aplikasi bisa dijawab oleh GetItem, Query, PutItem, UpdateItem, DeleteItem, TransactWriteItems, dan index yang sengaja dirancang.
Target part ini: kamu bisa membaca requirement bisnis, menurunkannya menjadi access pattern, lalu mendesain key schema yang membuat DynamoDB bekerja seperti engine query yang deterministik, bukan seperti database document yang dipaksa melakukan scan.
AWS sendiri menjelaskan single-table design sebagai pola untuk menyimpan banyak tipe entity dalam satu DynamoDB table demi mengoptimalkan access pattern, performa, dan cost. Namun AWS juga tidak mengatakan bahwa semua workload wajib single-table. Keputusan tetap harus berdasarkan access pattern, ownership, operability, dan evolusi data model.
1. Core Principle
Relational modeling dimulai dari entity dan relationship.
DynamoDB modeling dimulai dari pertanyaan:
Query apa yang harus dijawab oleh sistem, dengan latency berapa, pada skala berapa, dengan consistency apa, dan menggunakan key apa?
Dalam RDBMS, kamu sering membuat model normalisasi dulu, lalu query menyusul.
Dalam DynamoDB, kamu membalik proses:
- daftar semua access pattern;
- kelompokkan access pattern berdasarkan aggregate dan ownership;
- desain primary key dan sort key agar access pattern penting bisa dijawab dengan
QueryatauGetItem; - tambahkan GSI hanya jika ada access pattern alternatif yang tidak bisa dijawab oleh primary key;
- denormalisasi secara eksplisit;
- uji dengan data volume, skew, hot key, dan growth scenario.
Mermaid mental model:
Single-table design bukan tentang “satu table supaya keren”. Single-table design berguna jika beberapa entity sering dibaca bersama dalam satu aggregate/query boundary.
2. DynamoDB Table as Access-Pattern Index
Anggap DynamoDB table sebagai kumpulan index yang sengaja kamu bentuk.
Satu table minimal punya:
| Component | Fungsi |
|---|---|
| Partition key | Menentukan item collection dan distribusi workload |
| Sort key | Menentukan urutan, range query, hierarchy, dan overloaded relationship |
| Attributes | Data entity, metadata, state, audit, dan projection fields |
| GSI | Alternate access pattern dengan partition/sort key berbeda |
| LSI | Alternate sort key dalam partition key yang sama, jika perlu dan didesain sejak table dibuat |
Istilah penting:
| Istilah | Makna operasional |
|---|---|
| Item | Satu record DynamoDB, bukan row SQL yang harus punya kolom sama |
| Entity type | Jenis item: Case, Party, Task, Event, Assignment, Comment |
| Item collection | Semua item dengan partition key sama |
| Composite key | Key yang dibentuk dari beberapa komponen domain |
| Overloaded key | Key attribute yang dipakai banyak entity type dengan format konsisten |
| Sparse index | Index yang hanya berisi item yang memiliki attribute index tertentu |
| Adjacency list | Teknik menyimpan relationship graph-ish dalam item collection/index |
3. Kapan Single-Table Design Cocok
Single-table design cocok ketika:
- access pattern sudah cukup jelas;
- entity saling dibaca dalam aggregate yang sama;
- latency query harus predictable;
- transaksi lokal kecil dan terbatas;
- aplikasi lebih sering melakukan point lookup/range query daripada ad-hoc query;
- read model bisa didesain sejak awal;
- team disiplin menjaga key grammar dan schema contract;
- migration/backfill bisa dikontrol lewat versioned item.
Contoh cocok:
| Domain | Mengapa cocok |
|---|---|
| Order management | Order, order line, payment attempt, shipment, status history sering dibaca bersama |
| Regulatory case management | Case, parties, tasks, deadlines, enforcement actions, decision log bisa dikelompokkan per case |
| IoT device registry | Device, config, latest telemetry pointer, alert state, owner mapping punya access pattern jelas |
| Multi-tenant issue tracker | Tenant, ticket, comments, assignments, SLA state, status index |
| Game leaderboard/session | Session, participant, score, rank projection |
4. Kapan Jangan Memaksakan Single-Table Design
Single-table design bisa menjadi beban kalau:
- access pattern belum stabil dan masih sangat eksploratif;
- workload butuh ad-hoc analytics;
- query membutuhkan banyak filter bebas, join bebas, aggregate bebas;
- team belum punya discipline key schema;
- banyak tim menulis ke table yang sama tanpa ownership jelas;
- compliance/audit memerlukan physical separation yang sulit disimulasikan dengan item type;
- kebutuhan data export/reporting lebih dominan daripada low-latency transactional access;
- data model berubah cepat tetapi tidak ada migration framework.
Dalam kondisi ini, pilihan lebih baik bisa berupa:
| Problem | Alternatif |
|---|---|
| Ad-hoc query kompleks | Aurora PostgreSQL, OpenSearch projection, Athena/S3 analytics |
| Transactional consistency lintas banyak entity | Aurora/RDS atau DynamoDB transaction terbatas dengan desain ketat |
| Reporting-heavy | OLTP store + stream/export ke analytical store |
| Full-text search | OpenSearch sebagai projection, bukan source of truth |
| Graph traversal | Neptune atau explicit precomputed relationship index |
5. Running Example: Enforcement Case Platform
Kita pakai contoh regulatory enforcement lifecycle.
Entity utama:
| Entity | Description |
|---|---|
| Tenant | Regulator/business unit/account boundary |
| Case | Enforcement case/investigation |
| Party | Person/company terkait case |
| Task | Action item untuk officer/reviewer |
| Deadline | SLA/legal deadline |
| Action | Enforcement action: warning, notice, penalty, escalation |
| Evidence | Metadata evidence, bukan file binary langsung |
| Event | Domain event/audit event |
| Assignment | Mapping case/task ke user/team |
Access pattern awal:
| ID | Access pattern | Consistency | Expected operation |
|---|---|---|---|
| AP1 | Get case summary by caseId | Strong or eventually, depends UI | GetItem |
| AP2 | List all items in a case timeline | Usually eventual acceptable | Query PK = CASE#id |
| AP3 | List open tasks for officer | Eventually acceptable | GSI query |
| AP4 | List overdue deadlines by tenant/date | Eventually acceptable | Sparse GSI query |
| AP5 | Add event to case timeline | Atomic with event item | PutItem conditionally |
| AP6 | Change case status with optimistic concurrency | Strong write invariant | UpdateItem condition expression |
| AP7 | Find case by external reference | Strong uniqueness preferred | GSI or uniqueness sentinel |
| AP8 | List parties in a case | Same case item collection | Query PK = CASE#id AND begins_with(SK,'PARTY#') |
| AP9 | Get all cases for party | Alternate relationship view | GSI adjacency query |
| AP10 | List cases by tenant and status | Eventually acceptable | GSI query/status projection |
Jangan mulai dari ERD. Mulai dari tabel access pattern.
6. Base Key Schema
Satu bentuk umum:
PK string
SK string
GSI1PK string optional
GSI1SK string optional
GSI2PK string optional
GSI2SK string optional
entityType string
version number
createdAt string ISO-8601
updatedAt string ISO-8601
...
Base table:
Table: EnforcementCore
Primary key:
PK = partition key
SK = sort key
Contoh key grammar:
| Item | PK | SK |
|---|---|---|
| Tenant metadata | TENANT#t1 | META |
| Case metadata | CASE#c123 | META |
| Case status snapshot | CASE#c123 | STATE |
| Case party | CASE#c123 | PARTY#p789 |
| Case task | CASE#c123 | TASK#2026-07-07#task456 |
| Deadline | CASE#c123 | DEADLINE#2026-07-30#deadline9 |
| Evidence metadata | CASE#c123 | EVIDENCE#evd55 |
| Domain event | CASE#c123 | EVENT#2026-07-07T10:05:00Z#evt999 |
| Party case edge | PARTY#p789 | CASE#c123 |
| External reference sentinel | EXTREF#ABC-2026-0001 | CASE#c123 |
Item collection for case:
PK = CASE#c123
SK = META
SK = STATE
SK = PARTY#p789
SK = TASK#2026-07-07#task456
SK = DEADLINE#2026-07-30#deadline9
SK = EVIDENCE#evd55
SK = EVENT#2026-07-07T10:05:00Z#evt999
Query timeline:
PK = CASE#c123
SK begins_with EVENT#
Query deadlines:
PK = CASE#c123
SK begins_with DEADLINE#
Query all case operational view:
PK = CASE#c123
SK between META and TASK...?
Lebih aman: gunakan prefix yang eksplisit daripada range ambigu.
7. Item Collection Design
Item collection adalah semua item dengan PK sama.
Dalam single-table design, item collection sering mewakili aggregate read boundary.
Untuk CASE#c123, item collection bisa memuat:
Keuntungan:
- satu
Querybisa mengambil banyak komponen case; - timeline bisa sorted by SK;
- entity berbeda tetap ada di physical table yang sama;
- relationship lokal case mudah diakses;
- UI case detail bisa mengambil subset dengan prefix.
Risiko:
- item collection terlalu besar;
- hot case menjadi hot partition key;
- semua entity ikut berbagi throughput boundary logical key;
- schema governance makin penting;
- LSI punya batas item collection size yang perlu diperhatikan jika digunakan.
Rule praktis:
Letakkan entity dalam item collection yang sama hanya jika access pattern membacanya bersama atau membutuhkan locality yang nyata.
8. Sort Key Grammar
Sort key bukan string bebas. Sort key adalah bahasa kecil untuk query.
Contoh grammar:
META
STATE
PARTY#<partyId>
TASK#<dueDate>#<taskId>
TASK_STATUS#<status>#<dueDate>#<taskId>
DEADLINE#<dueDate>#<deadlineId>
EVIDENCE#<evidenceId>
EVENT#<occurredAt>#<eventId>
Sort key yang baik:
- sortable secara leksikografis;
- punya prefix entity type;
- komponen waktu memakai ISO-8601 atau epoch-padded;
- ID ditempatkan setelah field query utama;
- mendukung
begins_with,between, forward/reverse scan; - stabil walaupun attribute lain berubah;
- tidak menyimpan data sensitive kalau key masuk log/metrics.
Buruk:
TASK#<uuid>#<dueDate>
Kenapa buruk? Karena query TASK due before date tidak bisa range by due date.
Lebih baik:
TASK#<dueDate>#<taskId>
Kalau query utama adalah due date.
9. Entity Modeling Pattern
Setiap item sebaiknya menyimpan entityType.
Contoh Case metadata:
{
"PK": "CASE#c123",
"SK": "META",
"entityType": "Case",
"tenantId": "t1",
"caseId": "c123",
"externalRef": "ABC-2026-0001",
"status": "UNDER_REVIEW",
"riskScore": 82,
"openedAt": "2026-07-07T02:10:00Z",
"version": 7
}
Task item:
{
"PK": "CASE#c123",
"SK": "TASK#2026-07-12#task456",
"entityType": "Task",
"tenantId": "t1",
"caseId": "c123",
"taskId": "task456",
"assigneeId": "u77",
"status": "OPEN",
"dueAt": "2026-07-12T17:00:00Z",
"version": 1,
"GSI1PK": "ASSIGNEE#u77#TASK_STATUS#OPEN",
"GSI1SK": "2026-07-12T17:00:00Z#CASE#c123#TASK#task456"
}
Deadline item:
{
"PK": "CASE#c123",
"SK": "DEADLINE#2026-07-30#deadline9",
"entityType": "Deadline",
"tenantId": "t1",
"caseId": "c123",
"deadlineId": "deadline9",
"deadlineType": "LEGAL_RESPONSE",
"status": "OPEN",
"dueAt": "2026-07-30T00:00:00Z",
"GSI2PK": "TENANT#t1#DEADLINE_STATUS#OPEN",
"GSI2SK": "2026-07-30T00:00:00Z#CASE#c123#DEADLINE#deadline9"
}
The table is schemaless, but your application must not be schema-less.
10. Access Pattern Matrix
Sebelum membuat table, buat matrix.
| Access Pattern | PK | SK condition | Index | Operation |
|---|---|---|---|---|
| Get case metadata | CASE#<caseId> | SK = META | Base | GetItem |
| Get case state | CASE#<caseId> | SK = STATE | Base | GetItem |
| List case parties | CASE#<caseId> | begins_with(PARTY#) | Base | Query |
| List case tasks by due date | CASE#<caseId> | begins_with(TASK#) | Base | Query |
| List case events | CASE#<caseId> | begins_with(EVENT#) | Base | Query |
| List open tasks by assignee | ASSIGNEE#<id>#TASK_STATUS#OPEN | due-date range | GSI1 | Query |
| List tenant overdue deadlines | TENANT#<id>#DEADLINE_STATUS#OPEN | due-date range | GSI2 | Query |
| Find case by external ref | EXTREF#<ref> | CASE#<caseId> | Base or GSI | GetItem/Query |
| List cases for party | PARTY#<partyId> | begins_with(CASE#) | Base or GSI | Query |
If an access pattern has no key plan, it is not designed yet.
Jangan menjawab “nanti pakai filter expression”. Filter expression tidak mengurangi read capacity untuk item yang sudah dibaca dari partition/query result. Filter hanya menyaring setelah read candidate ditemukan.
11. Composite Key Design
Composite key harus menjawab tiga hal:
- locality: data apa yang harus berdekatan?
- ordering: data harus sorted by apa?
- selectivity: query harus memotong data dari mana?
Contoh SK = TASK#2026-07-12#task456:
| Segment | Fungsi |
|---|---|
TASK | Prefix entity/query type |
2026-07-12 | Sort/range by due date |
task456 | Tie-breaker uniqueness |
Contoh GSI untuk assignee task:
GSI1PK = ASSIGNEE#u77#TASK_STATUS#OPEN
GSI1SK = 2026-07-12T17:00:00Z#CASE#c123#TASK#task456
Ini menjawab:
List open tasks for assignee u77 sorted by due date.
Kalau status berubah dari OPEN ke DONE, update item harus menghapus/mengubah index attribute.
12. Denormalization Is a Contract
Di DynamoDB, denormalization bukan dosa. Ia adalah kontrak.
Contoh Task item menyimpan:
{
"caseTitle": "Investigation ABC",
"partyName": "PT Example",
"assigneeName": "Nadia"
}
Ini boleh jika:
- field memang read-optimized;
- staleness acceptable;
- source of truth tetap jelas;
- ada propagation rule ketika source berubah;
- ada reconciliation job untuk memperbaiki drift;
- UI tahu field mana yang authoritative dan mana yang snapshot.
Classification:
| Field type | Example | Safe? | Rule |
|---|---|---|---|
| Immutable copy | caseId, createdAt | Safe | Copy freely |
| Slowly changing label | caseTitle | Usually safe | Accept staleness/reconcile |
| Current state | caseStatus | Risky | Prefer projection with version |
| Money/legal amount | penaltyAmount | Dangerous | Use source-of-truth or audited copy |
| Authorization field | tenantId, ownerOrg | Dangerous | Must be strongly governed |
13. Relationship Modeling
DynamoDB tidak punya join. Kamu memilih relationship representation.
13.1 One-to-Many Inside Item Collection
Case to tasks:
PK = CASE#c123
SK = TASK#2026-07-12#task456
Bagus untuk:
List tasks for case.
13.2 Many-to-Many with Edge Items
Party to cases:
PK = CASE#c123
SK = PARTY#p789
PK = PARTY#p789
SK = CASE#c123
Atau gunakan GSI:
CaseParty item:
PK = CASE#c123
SK = PARTY#p789
GSI1PK = PARTY#p789
GSI1SK = CASE#c123
Pilihan tergantung update atomicity dan query volume.
13.3 Adjacency List
Untuk relationship heavy tetapi traversal terbatas:
PK = ENTITY#A
SK = EDGE#RELATION#B
Jangan menjadikan DynamoDB sebagai graph database umum jika query butuh traversal multi-hop dinamis. Untuk graph-heavy workload, evaluasi Neptune atau precomputed projection.
14. GSI as Alternate Access Pattern
GSI bukan index untuk “nanti siapa tahu perlu”. GSI adalah access pattern yang dibayar dengan write amplification, storage, eventual consistency, dan operational surface.
Contoh GSI1: open tasks by assignee.
GSI1PK = ASSIGNEE#<assigneeId>#TASK_STATUS#OPEN
GSI1SK = <dueAt>#CASE#<caseId>#TASK#<taskId>
Contoh GSI2: open deadlines by tenant.
GSI2PK = TENANT#<tenantId>#DEADLINE_STATUS#OPEN
GSI2SK = <dueAt>#CASE#<caseId>#DEADLINE#<deadlineId>
Sparse GSI:
- hanya item dengan
GSI2PKdanGSI2SKyang masuk index; - cocok untuk open tasks, open deadlines, active escalations;
- jangan memasukkan closed/completed item jika query hanya active item.
GSI design checklist:
- Apa exact query yang dijawab?
- Seberapa sering item berubah status sehingga keluar/masuk index?
- Apakah GSI partition key bisa hot?
- Apakah query butuh strong consistency? Jika iya, GSI bukan jawabannya karena GSI read eventually consistent.
- Berapa projected attributes yang dibutuhkan?
- Apakah index bisa sparse?
- Bagaimana backfill ketika menambah GSI baru?
15. LSI: Use Rarely and Deliberately
Local Secondary Index memakai partition key yang sama dengan base table tetapi sort key berbeda. Ia harus dibuat saat table creation.
LSI berguna jika:
- kamu butuh alternate sort order dalam item collection yang sama;
- partition key tetap sama;
- strong consistency pada index read penting;
- item collection size tetap aman.
Contoh:
Base:
PK = CASE#c123
SK = EVENT#2026-07-07T10:00:00Z#evt1
LSI1SK = SEVERITY#HIGH#2026-07-07T10:00:00Z#evt1
Namun banyak desain modern lebih memilih GSI atau duplicated item karena LSI constraint lebih kaku.
16. Uniqueness Pattern
DynamoDB tidak punya unique constraint global selain primary key.
Untuk memastikan externalRef unik:
PK = EXTREF#ABC-2026-0001
SK = UNIQUE
entityType = UniqueExternalRef
caseId = c123
Ketika membuat case, gunakan TransactWriteItems:
Putunique sentinel with conditionattribute_not_exists(PK);Putcase metadata;Putcase state;- optional outbox/event item.
Pseudo transaction:
{
"TransactItems": [
{
"Put": {
"TableName": "EnforcementCore",
"Item": {
"PK": {"S": "EXTREF#ABC-2026-0001"},
"SK": {"S": "UNIQUE"},
"entityType": {"S": "UniqueExternalRef"},
"caseId": {"S": "c123"}
},
"ConditionExpression": "attribute_not_exists(PK)"
}
},
{
"Put": {
"TableName": "EnforcementCore",
"Item": {
"PK": {"S": "CASE#c123"},
"SK": {"S": "META"},
"entityType": {"S": "Case"}
},
"ConditionExpression": "attribute_not_exists(PK) AND attribute_not_exists(SK)"
}
}
]
}
Failure behavior:
| Failure | Meaning | Response |
|---|---|---|
| Conditional check failed on sentinel | externalRef exists | return conflict/duplicate |
| Conditional check failed on case item | caseId collision | generate new ID or conflict |
| Transaction canceled unknown to client | ambiguous | retry with idempotency key/read sentinel |
17. Optimistic Concurrency Pattern
Case status transition:
UNDER_REVIEW -> NOTICE_ISSUED -> RESPONSE_WAITING -> DECISION_PENDING -> CLOSED
Use versioned item:
{
"PK": "CASE#c123",
"SK": "STATE",
"status": "UNDER_REVIEW",
"version": 7
}
Update:
SET status = :next,
version = version + :one,
updatedAt = :now
WHERE PK = :pk
AND SK = :sk
AND version = :expected
AND status = :expectedStatus
DynamoDB expression:
UpdateExpression: SET #status = :next, #version = #version + :one, updatedAt = :now
ConditionExpression: #version = :expectedVersion AND #status = :expectedStatus
This is not just concurrency control. It is state-machine enforcement.
18. Transaction Boundary
DynamoDB transactions are useful, but not a license to model relational joins.
Use transaction when:
- several items must change atomically;
- uniqueness sentinel and entity must be created together;
- state transition and domain event must be committed together;
- counter/summary must remain consistent with mutation;
- multiple conditional writes form one invariant.
Avoid transaction when:
- you are compensating for wrong aggregate boundary;
- transaction touches many dynamic items;
- business process is long-running;
- workflow needs human/external callback;
- cross-aggregate consistency can be eventual via event/projection.
Pattern:
19. Outbox in Single-Table Design
Outbox item in same case item collection:
{
"PK": "CASE#c123",
"SK": "OUTBOX#2026-07-07T10:05:00Z#evt999",
"entityType": "OutboxEvent",
"eventId": "evt999",
"eventType": "CaseStatusChanged",
"published": false,
"payload": {
"caseId": "c123",
"from": "UNDER_REVIEW",
"to": "NOTICE_ISSUED"
},
"GSI3PK": "OUTBOX#PENDING",
"GSI3SK": "2026-07-07T10:05:00Z#evt999"
}
Publisher query:
GSI3PK = OUTBOX#PENDING
GSI3SK <= now
After publish:
SET published = true
REMOVE GSI3PK, GSI3SK
Important:
- publisher must be idempotent;
- event ID must be stable;
- publish may succeed but update may fail;
- duplicate publish must be accepted by consumers;
- outbox retention must be defined.
20. Versioned Item Pattern
Single-table design evolves. Add explicit schema version.
{
"entityType": "Task",
"schemaVersion": 3,
"PK": "CASE#c123",
"SK": "TASK#2026-07-12#task456"
}
Versioning strategies:
| Strategy | When |
|---|---|
| Read-time upcast | Low write volume, compatibility needed |
| Background backfill | High read volume, new field required |
| Dual write new attributes | Transition period |
| New item type | Semantic change large enough |
| New GSI attributes | New access pattern requires index |
Never assume schemaless means migrationless.
21. Projection Attribute Strategy
GSI projection controls what attributes are copied into index.
Options:
| Projection | Use case |
|---|---|
KEYS_ONLY | Query index, then BatchGetItem base item if needed |
INCLUDE | UI list needs small summary fields |
ALL | Avoid extra read but increases write/storage cost |
For open tasks by assignee, projection might include:
caseId
taskId
dueAt
priority
caseTitleSnapshot
status
Not include:
largeDescription
evidenceMetadataBlob
internalAuditTrail
22. Query Examples
22.1 Get Case Metadata
GetItemRequest request = GetItemRequest.builder()
.tableName("EnforcementCore")
.key(Map.of(
"PK", AttributeValue.fromS("CASE#c123"),
"SK", AttributeValue.fromS("META")
))
.consistentRead(true)
.build();
22.2 List Case Timeline Events
QueryRequest request = QueryRequest.builder()
.tableName("EnforcementCore")
.keyConditionExpression("PK = :pk AND begins_with(SK, :prefix)")
.expressionAttributeValues(Map.of(
":pk", AttributeValue.fromS("CASE#c123"),
":prefix", AttributeValue.fromS("EVENT#")
))
.scanIndexForward(false)
.limit(50)
.build();
22.3 List Open Tasks for Assignee
QueryRequest request = QueryRequest.builder()
.tableName("EnforcementCore")
.indexName("GSI1")
.keyConditionExpression("GSI1PK = :pk AND GSI1SK BETWEEN :from AND :to")
.expressionAttributeValues(Map.of(
":pk", AttributeValue.fromS("ASSIGNEE#u77#TASK_STATUS#OPEN"),
":from", AttributeValue.fromS("2026-07-01T00:00:00Z"),
":to", AttributeValue.fromS("2026-07-31T23:59:59Z")
))
.build();
22.4 Conditional Status Transition
UpdateItemRequest request = UpdateItemRequest.builder()
.tableName("EnforcementCore")
.key(Map.of(
"PK", AttributeValue.fromS("CASE#c123"),
"SK", AttributeValue.fromS("STATE")
))
.updateExpression("SET #status = :next, #version = #version + :one, updatedAt = :now")
.conditionExpression("#status = :current AND #version = :expected")
.expressionAttributeNames(Map.of(
"#status", "status",
"#version", "version"
))
.expressionAttributeValues(Map.of(
":current", AttributeValue.fromS("UNDER_REVIEW"),
":next", AttributeValue.fromS("NOTICE_ISSUED"),
":expected", AttributeValue.fromN("7"),
":one", AttributeValue.fromN("1"),
":now", AttributeValue.fromS("2026-07-07T10:15:00Z")
))
.build();
23. Scan Is Usually a Design Smell
Scan reads across table/index. It is useful for:
- backfill job;
- export/maintenance;
- admin operation with bounded table;
- rare repair workflow;
- parallel scan with strict throttling.
It is not a normal application query path.
Bad pattern:
Scan all tasks where assigneeId = u77 and status = OPEN
Good pattern:
GSI1PK = ASSIGNEE#u77#TASK_STATUS#OPEN
24. Write Amplification
Single-table design often creates additional writes:
- base item;
- relationship edge item;
- GSI entry;
- outbox item;
- summary/counter item;
- idempotency record;
- audit event.
For every command, compute write amplification:
| Command | Writes |
|---|---|
| Create case | case meta + state + external ref sentinel + audit event + outbox = 5 |
| Add task | task item + audit event + outbox = 3, plus GSI entries |
| Complete task | update task + remove GSI open attributes + audit event + outbox = 4 |
| Link party | party edge + reverse edge + audit event = 3 |
Write amplification is not automatically bad. Hidden write amplification is bad.
25. Tenant Modeling
Do not blindly put tenant ID as only partition key.
Bad:
PK = TENANT#t1
SK = CASE#c123
If tenant is large, every operation for tenant hits same logical partition key.
Better:
PK = CASE#c123
SK = META
And use tenant-specific GSI for tenant views:
GSI_TENANT_PK = TENANT#t1#CASE_STATUS#OPEN
GSI_TENANT_SK = 2026-07-07T10:00:00Z#CASE#c123
For multi-tenant systems, tenant ID still belongs in every item for authorization and audit, but it does not always belong as the base partition key.
26. Authorization Boundary
DynamoDB key design can help authorization, but must not be the only enforcement.
Every item should include:
tenantId
ownerOrgId
classification
createdBy
updatedBy
API/service layer must verify authorization before query and before mutation.
Risky design:
Client supplies PK directly.
Safer design:
API receives domain ID -> service resolves tenant/authorization -> service constructs PK/SK.
Never expose raw internal key grammar as public contract unless you are prepared to support it forever.
27. Evolution Strategy
New access pattern appears:
“List high-risk open cases per tenant by risk score.”
Decision process:
- Is this production transactional path or reporting path?
- Is consistency requirement strong or eventual?
- Is query bounded by tenant/status/risk?
- Can existing GSI answer it?
- Would new sparse GSI be hot?
- Can we maintain projection attributes on state change?
- Is OpenSearch/reporting store better?
- Can we backfill safely?
Potential GSI:
GSI4PK = TENANT#t1#CASE_STATUS#OPEN#RISK_BUCKET#HIGH
GSI4SK = 000082#CASE#c123
But if risk changes frequently, index churn increases. Maybe compute daily queue/report projection instead.
28. Testing the Data Model
Test data model before production.
28.1 Access Pattern Test
For each access pattern:
Given realistic item volume
When query is executed
Then it uses GetItem/Query not Scan
And page size is bounded
And p95 latency target is met
And consumed capacity is within budget
28.2 Hot Key Test
Simulate:
- 10 officers updating same hot case;
- 1 tenant with 10x traffic;
- status dashboard refreshing every few seconds;
- bulk import creating events for same case;
- GSI partition for
OPENtasks getting skewed.
28.3 Evolution Test
- add new field;
- add new item type;
- add new sparse GSI attribute;
- backfill old items;
- deploy reader before writer;
- deploy writer before reader;
- rollback.
28.4 Replay Test
If events/outbox are used:
- publish duplicate event;
- replay old event;
- process out-of-order event;
- rebuild projection;
- validate idempotency.
29. Anti-Patterns
29.1 Table-per-Entity Without Reason
CasesTable
TasksTable
PartiesTable
DeadlinesTable
EventsTable
This recreates relational layout without joins. It often leads to many network calls and application-side joins.
Multiple tables can be correct if ownership/capacity/security/lifecycle differ. But if the reason is “because SQL had tables,” it is not enough.
29.2 Single Table with No Access Pattern Discipline
PK = uuid
SK = uuid
This is not single-table design. It is an object dump.
29.3 FilterExpression as Query Strategy
Filter expression after reading many items is not a scalable query plan.
29.4 GSI Explosion
Adding GSI for every UI filter makes write path expensive and hard to operate.
29.5 Overloaded Key Without Grammar
PK = random business string
SK = sometimes date, sometimes name, sometimes status
Overloading requires grammar. Without grammar, it is entropy.
29.6 Mutable Sort Key as Identity
If sort key contains mutable field, updates require delete+put or careful rewrite. Put mutable fields in index keys only if movement across index is intentional.
29.7 Authorization by Partition Key Alone
Do not assume because PK includes tenant, authorization is solved. Validate every command/query.
30. Production Checklist
Before approving a single-table design:
- Every access pattern has exact key condition.
- No normal application path depends on table scan.
- Every entity has
entityTypeandschemaVersion. - Sort key grammar is documented.
- GSI purpose is documented per access pattern.
- Sparse indexes are used where appropriate.
- Hot partition analysis exists.
- Tenant skew analysis exists.
- Write amplification is calculated.
- Conditional write invariants are defined.
- Transaction boundaries are explicit.
- Idempotency strategy exists for commands.
- Outbox/replay behavior is safe.
- Backfill/migration strategy exists.
- Projection staleness is documented.
- Dashboard and alarms include throttling, latency, errors, consumed capacity, and GSI health.
31. Minimal ADR Template
# ADR: DynamoDB Single-Table Design for EnforcementCore
## Status
Accepted / Proposed / Deprecated
## Context
The workload requires predictable low-latency access for enforcement case operational screens.
## Access Patterns
- AP1: Get case metadata by caseId
- AP2: List case timeline
- AP3: List open tasks by assignee
- AP4: List overdue deadlines by tenant
...
## Decision
Use one DynamoDB table with PK/SK and sparse GSIs for assignee task and tenant deadline views.
## Key Schema
Base:
- PK
- SK
GSI1:
- GSI1PK
- GSI1SK
GSI2:
- GSI2PK
- GSI2SK
## Invariants
- externalRef uniqueness enforced by sentinel item
- case status transition enforced by conditional update
- outbox item written in same transaction as state change
## Trade-offs
- no ad-hoc joins
- GSI eventual consistency
- write amplification accepted
- schema grammar must be governed
## Reversibility
Export to S3 + DMS/custom migration to Aurora/OpenSearch projection if access pattern changes.
## Validation
Load test with tenant skew and hot case scenario.
32. Key Takeaways
- Single-table design is access-pattern design, not table-count minimalism.
- Partition key creates locality and distribution; sort key creates query language.
- Item collection is the central modeling primitive.
- GSI is an alternate access pattern, not a casual secondary index.
- Denormalization is acceptable only with source-of-truth and reconciliation discipline.
- Conditional writes and transactions encode business invariants.
- Schema-less storage still requires schema governance.
- The most dangerous DynamoDB model is one that works in dev because the dataset is small.
References
- AWS DynamoDB Developer Guide — Data modeling foundations in DynamoDB: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/data-modeling-foundations.html
- AWS DynamoDB Developer Guide — NoSQL design for DynamoDB: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-general-nosql-design.html
- AWS DynamoDB Developer Guide — Best practices for DynamoDB table design: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-table-design.html
- AWS Prescriptive Guidance — Identify DynamoDB data access patterns: https://docs.aws.amazon.com/prescriptive-guidance/latest/dynamodb-data-modeling/step3.html
- AWS Database Blog — Single-table vs. multi-table design in Amazon DynamoDB: https://aws.amazon.com/blogs/database/single-table-vs-multi-table-design-in-amazon-dynamodb/
- AWS Compute Blog — Creating a single-table design with Amazon DynamoDB: https://aws.amazon.com/blogs/compute/creating-a-single-table-design-with-amazon-dynamodb/
You just completed lesson 73 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.