@kensio/dynamodb-single-table 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
|
+
"name": "dynamodb-single-table",
|
|
4
|
+
"version": "1.13.1",
|
|
5
|
+
"description": "Data modelling for Amazon DynamoDB, starting from one table.",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Kensio Software",
|
|
8
|
+
"email": "hugh@kensiosoftware.co.uk"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://kensio.ai",
|
|
11
|
+
"repository": "https://github.com/KensioSoftware/kensio.ai",
|
|
12
|
+
"license": "Apache-2.0",
|
|
13
|
+
"keywords": ["aws", "dynamodb", "nosql", "data-modelling", "single-table"]
|
|
14
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# @kensio/dynamodb-single-table
|
|
2
|
+
|
|
3
|
+
An agent skill for modelling data in Amazon DynamoDB, where the default is one table per service
|
|
4
|
+
holding every entity type.
|
|
5
|
+
|
|
6
|
+
Nearly all of the data modelling advice an LLM has read is about SQL, and it carries over badly. The
|
|
7
|
+
result is a table per entity, a join in the application layer, and a `Scan` wherever the keys fall
|
|
8
|
+
short. This skill interrupts that reflex and gives the modelling technique that replaces it.
|
|
9
|
+
|
|
10
|
+
It follows the AWS guidance, which says the same thing. "You should maintain as few tables as
|
|
11
|
+
possible in a DynamoDB application." The sources are two AWS posts,
|
|
12
|
+
[Creating a single-table design](https://aws.amazon.com/blogs/compute/creating-a-single-table-design-with-amazon-dynamodb/)
|
|
13
|
+
and
|
|
14
|
+
[Single-table vs multi-table design](https://aws.amazon.com/blogs/database/single-table-vs-multi-table-design-in-amazon-dynamodb/),
|
|
15
|
+
together with the
|
|
16
|
+
[NoSQL design](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-general-nosql-design.html),
|
|
17
|
+
[data modeling](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/data-modeling-foundations.html),
|
|
18
|
+
[partitioning](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.Partitions.html)
|
|
19
|
+
and
|
|
20
|
+
[best practices](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/best-practices.html)
|
|
21
|
+
pages of the developer guide.
|
|
22
|
+
|
|
23
|
+
The skill carries the links and tells the agent to fetch them when designing or reviewing a real
|
|
24
|
+
schema. A summary goes stale, quotas move, and the worked examples hold detail no summary keeps.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
Into any agent that reads `SKILL.md`:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx @kensio/skills add dynamodb-single-table
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
That copies the skill directory into `.agents/skills/`, where Codex, Cursor, Copilot, Gemini CLI and
|
|
35
|
+
the other implementations of the specification look for one. Pass `--agent claude` for
|
|
36
|
+
`.claude/skills/`, `--agent copilot` for `.github/skills/`, and `--user` to install it for every
|
|
37
|
+
project at once.
|
|
38
|
+
|
|
39
|
+
Claude Code also takes it as a plugin:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
claude plugin marketplace add KensioSoftware/kensio.ai
|
|
43
|
+
claude plugin install dynamodb-single-table@kensio
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or pin it in a repository as a dependency:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm install @kensio/dynamodb-single-table
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Every skill is also published as a zip on each
|
|
53
|
+
[release](https://github.com/KensioSoftware/kensio.ai/releases), for a machine with no npm reach.
|
|
54
|
+
Unzip it into `.agents/skills/` and it is installed.
|
|
55
|
+
|
|
56
|
+
## What it covers
|
|
57
|
+
|
|
58
|
+
**Write the access patterns down first.** AWS states this as a rule. Do not start designing the
|
|
59
|
+
schema until the questions it answers are known. Each entry records what the caller holds, what
|
|
60
|
+
comes back, the ordering and the cardinality. Data size, data shape and data velocity are what
|
|
61
|
+
decide the keys. A pattern that arrives later costs a secondary index or a backfill, and never a new
|
|
62
|
+
table.
|
|
63
|
+
|
|
64
|
+
**One table, every entity type.** Items sharing a partition key form an item collection, held
|
|
65
|
+
together and sorted by sort key. That is the whole mechanism. An order and its lines come back from
|
|
66
|
+
one `Query`, where the multi-table version pays two round trips. One query for two items under 4 KB
|
|
67
|
+
costs 0.5 read units eventually consistent, and two queries for the same items cost 1. One table
|
|
68
|
+
also means one set of alarms, one backup policy, one stream and one key to rotate. Name it after the
|
|
69
|
+
service, never after an entity, because a table called `Users` has already lost the argument.
|
|
70
|
+
|
|
71
|
+
**Generic key names and entity prefixes.** `PK`, `SK`, `GSI1PK` and `GSI1SK`, with every value
|
|
72
|
+
prefixed by its entity type and every item carrying a `type` attribute. A key named `customerId` can
|
|
73
|
+
only ever hold a customer.
|
|
74
|
+
|
|
75
|
+
**Overload the secondary indexes, and make them sparse.** Keep the number of indexes small. Every
|
|
76
|
+
GSI is a full copy of the attributes it projects, rewritten whenever one of them changes, and a
|
|
77
|
+
design that adds an index per access pattern feels the write cost long before it reaches the AWS
|
|
78
|
+
ceiling of 20. The same index attributes carry different meanings for different entity types. An
|
|
79
|
+
index keyed on an attribute only some items hold contains only those items, so setting `GSI2PK` to
|
|
80
|
+
`STATUS#OPEN` while an order is open builds an index of open orders that reads no closed ones.
|
|
81
|
+
|
|
82
|
+
**Split items by write rate.** A write is charged on the whole item rounded up to the kilobyte, so a
|
|
83
|
+
view counter sharing an item with 2 KB of video metadata pays for the metadata on every increment.
|
|
84
|
+
Keep the counter on its own item in the same partition. This split cuts across entity boundaries. A
|
|
85
|
+
table-per-entity layout has no way to express it.
|
|
86
|
+
|
|
87
|
+
**Uniqueness is a second item.** There is no unique index. The rule is an item keyed on the unique
|
|
88
|
+
value, written in the same `TransactWriteItems` as the entity, both conditional on the key being
|
|
89
|
+
free.
|
|
90
|
+
|
|
91
|
+
**Many-to-many is an adjacency list.** Entities are partition keys and a relationship is an item in
|
|
92
|
+
the partition keyed on the id at the other end. One inverted index, a GSI whose partition key is the
|
|
93
|
+
table's sort key, buys the reverse of every relationship in the table.
|
|
94
|
+
|
|
95
|
+
**Query, never Scan, and never select with a filter.** A `FilterExpression` runs after the read and
|
|
96
|
+
is charged on everything read. Filtering 10,000 items down to 3 costs 10,000 items of read capacity.
|
|
97
|
+
A single 1 MB scan page of 4 KB items costs 128 eventually consistent read units in one burst, taken
|
|
98
|
+
from one partition, which throttles everything else sharing it.
|
|
99
|
+
|
|
100
|
+
**Know the reasons for a second table.** Whole-table settings that need to differ (backup,
|
|
101
|
+
encryption, table class), stream pressure past two consumers per shard, analytics exports,
|
|
102
|
+
high-volume time series data, a different owning service, or a framework that fights the design.
|
|
103
|
+
Each produces a second table holding several entity types. None of them produces a table per entity.
|
|
104
|
+
|
|
105
|
+
## The reference file
|
|
106
|
+
|
|
107
|
+
[`reference/aws-guidance.md`](skills/dynamodb-single-table/reference/aws-guidance.md) holds the
|
|
108
|
+
mechanics the rules rest on, loaded only when a decision turns on them. Throughput per partition
|
|
109
|
+
(3,000 read units and 1,000 write units), the AWS table of good and bad partition keys, write
|
|
110
|
+
sharding with random and calculated suffixes, sort key hierarchy and version history patterns, index
|
|
111
|
+
projections and LSI fetches, the 10 GB item collection limit, the three routes past 400 KB, what a
|
|
112
|
+
`Scan` costs, the materialized graph pattern, and AWS's own list of what single-table design costs
|
|
113
|
+
you.
|
|
114
|
+
|
|
115
|
+
## Related skills
|
|
116
|
+
|
|
117
|
+
- [`yulin-aws-simulation`](https://github.com/KensioSoftware/kensio.ai/tree/main/plugins/yulin-aws-simulation)
|
|
118
|
+
runs the tests for each access pattern against a simulated DynamoDB, using the real CDK table
|
|
119
|
+
definition.
|
|
120
|
+
|
|
121
|
+
Part of [kensio.ai](https://github.com/KensioSoftware/kensio.ai). Licensed under the Apache License
|
|
122
|
+
2.0. See the [LICENSE](https://github.com/KensioSoftware/kensio.ai/blob/main/LICENSE) in the
|
|
123
|
+
repository root.
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kensio/dynamodb-single-table",
|
|
3
|
+
"version": "1.13.1",
|
|
4
|
+
"description": "Data modelling for Amazon DynamoDB, starting from one table.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agent-skills",
|
|
7
|
+
"aws",
|
|
8
|
+
"claude",
|
|
9
|
+
"claude-code",
|
|
10
|
+
"claude-code-plugin",
|
|
11
|
+
"codex",
|
|
12
|
+
"copilot",
|
|
13
|
+
"cursor",
|
|
14
|
+
"data-modelling",
|
|
15
|
+
"dynamodb",
|
|
16
|
+
"kensio",
|
|
17
|
+
"nosql",
|
|
18
|
+
"single-table",
|
|
19
|
+
"skill",
|
|
20
|
+
"skill-md"
|
|
21
|
+
],
|
|
22
|
+
"homepage": "https://kensio.ai",
|
|
23
|
+
"license": "Apache-2.0",
|
|
24
|
+
"author": "Kensio Software <hugh@kensiosoftware.co.uk>",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/KensioSoftware/kensio.ai.git",
|
|
28
|
+
"directory": "plugins/dynamodb-single-table"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
".claude-plugin",
|
|
32
|
+
"skills",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dynamodb-single-table
|
|
3
|
+
description: Model data in Amazon DynamoDB as one table by default, reading the current AWS guidance before committing to a schema, writing the access patterns down before any keys exist, holding every entity type in one table, overloading generic partition and sort keys across those types, overloading and sparsifying secondary indexes, and splitting items by write rate. Use when designing or reviewing a DynamoDB schema, when a CDK stack is about to gain a second table, when an entity needs a query it has no key for, when application code fetches from two tables to assemble one response, when a Scan or a filter expression appears, when a partition runs hot or a write throttles, and when asked how to model users, orders, events or tenants in DynamoDB.
|
|
4
|
+
license: Apache-2.0
|
|
5
|
+
metadata:
|
|
6
|
+
version: "1.13.1"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Single-table design in DynamoDB
|
|
10
|
+
|
|
11
|
+
DynamoDB is a key-value store with a query language shaped like an index lookup. Almost all of the
|
|
12
|
+
data modelling advice in the training data is about SQL, and carrying it over produces a table per
|
|
13
|
+
entity, a join in the application layer, and a `Scan` wherever the keys fall short. That is the
|
|
14
|
+
reflex this skill exists to interrupt.
|
|
15
|
+
|
|
16
|
+
The default is **one table per service**, holding every entity type, with keys derived from the
|
|
17
|
+
queries the application makes. AWS puts it plainly in the NoSQL design best practices. "You should
|
|
18
|
+
maintain as few tables as possible in a DynamoDB application." A second table needs a reason from
|
|
19
|
+
[When a second table earns its place](#when-a-second-table-earns-its-place). "One entity, one table"
|
|
20
|
+
is never one of them.
|
|
21
|
+
|
|
22
|
+
## Read the current guidance before designing a schema
|
|
23
|
+
|
|
24
|
+
This file is a summary, and a summary goes stale. Quotas change, the worked examples carry detail no
|
|
25
|
+
summary keeps, and a schema outlives the session that produced it. **Fetch the sources below when
|
|
26
|
+
designing or reviewing a real schema**, and treat them as the authority wherever they disagree with
|
|
27
|
+
what follows.
|
|
28
|
+
|
|
29
|
+
The two posts, both worth reading end to end for the worked example:
|
|
30
|
+
|
|
31
|
+
- [Creating a single-table design with Amazon DynamoDB](https://aws.amazon.com/blogs/compute/creating-a-single-table-design-with-amazon-dynamodb/)
|
|
32
|
+
- [Single-table vs multi-table design in Amazon DynamoDB](https://aws.amazon.com/blogs/database/single-table-vs-multi-table-design-in-amazon-dynamodb/)
|
|
33
|
+
|
|
34
|
+
The developer guide, starting with these four:
|
|
35
|
+
|
|
36
|
+
- [NoSQL design for DynamoDB](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-general-nosql-design.html),
|
|
37
|
+
which is where the "as few tables as possible" rule lives.
|
|
38
|
+
- [Data modeling foundations](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/data-modeling-foundations.html),
|
|
39
|
+
which sets out AWS's own advantages and disadvantages for both foundations.
|
|
40
|
+
- [Partitions and data distribution](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.Partitions.html),
|
|
41
|
+
the mechanism everything else follows from.
|
|
42
|
+
- [Best practices for designing and architecting with DynamoDB](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/best-practices.html),
|
|
43
|
+
an index whose sub-pages cover partition keys, sort keys, secondary indexes, large items, time
|
|
44
|
+
series data, many-to-many relationships and querying.
|
|
45
|
+
|
|
46
|
+
[references/aws-guidance.md](references/aws-guidance.md) collects the mechanics from those sub-pages
|
|
47
|
+
in one place, including throughput per partition, write sharding, index projections, sort key
|
|
48
|
+
patterns and the read cost of a `Scan`. Read it when the question turns on performance or cost. The
|
|
49
|
+
questions about shape are answered here.
|
|
50
|
+
|
|
51
|
+
[NoSQL Workbench](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/workbench.html)
|
|
52
|
+
is the AWS tool for building and visualising a model before writing any code, and it is worth
|
|
53
|
+
suggesting to a user who is modelling something substantial.
|
|
54
|
+
|
|
55
|
+
## Write the access patterns down first
|
|
56
|
+
|
|
57
|
+
AWS states the ordering as a rule. Do not start designing the schema until the questions it answers
|
|
58
|
+
are known. The schema is a consequence of the queries.
|
|
59
|
+
|
|
60
|
+
Three properties matter before the first attribute is named.
|
|
61
|
+
|
|
62
|
+
- **Data size**, meaning how much is stored and how much comes back in one request.
|
|
63
|
+
- **Data shape**, meaning the shape in the table matches the shape the query wants, with no
|
|
64
|
+
reshaping at read time.
|
|
65
|
+
- **Data velocity**, meaning where the peak load lands, which decides how the keys distribute.
|
|
66
|
+
|
|
67
|
+
Enumerate the patterns and write them where the reviewer of the pull request can see them (the CDK
|
|
68
|
+
stack, an ADR, a comment above the key builders). Each entry records what the caller holds, what
|
|
69
|
+
comes back, the ordering and the expected cardinality. For a small orders service:
|
|
70
|
+
|
|
71
|
+
| Access pattern | Caller holds | Returns | Order |
|
|
72
|
+
| --------------------------------- | ------------ | --------------------- | ------------ |
|
|
73
|
+
| Get a customer profile | customer id | one item | |
|
|
74
|
+
| Get an order with its lines | order id | one order, 1-50 lines | line sku |
|
|
75
|
+
| List a customer's orders | customer id | 0-500 orders | newest first |
|
|
76
|
+
| Find a customer by email | email | one item | |
|
|
77
|
+
| List open orders across customers | nothing | 0-2000 orders | oldest first |
|
|
78
|
+
|
|
79
|
+
A pattern that arrives later usually costs a new secondary index or a backfill. It never costs a new
|
|
80
|
+
table.
|
|
81
|
+
|
|
82
|
+
## One table, every entity type
|
|
83
|
+
|
|
84
|
+
Items sharing a partition key form an **item collection**, stored together and sorted by sort key.
|
|
85
|
+
That is the whole mechanism behind single-table design. Related items of different types land in one
|
|
86
|
+
collection and one `Query` returns them.
|
|
87
|
+
|
|
88
|
+
The arguments for it, in the order AWS makes them:
|
|
89
|
+
|
|
90
|
+
- **Locality of reference.** Keeping related data together is the first general principle in the
|
|
91
|
+
best practices, ahead of every key-design detail. An order and its lines share a partition, and
|
|
92
|
+
one `Query` returns both. The multi-table version issues a `GetItem` for the order and a `Query`
|
|
93
|
+
for the lines, and pays two round trips to assemble one response.
|
|
94
|
+
- **Reads cost less.** One query for two items totalling under 4 KB is 0.5 read units eventually
|
|
95
|
+
consistent. Two queries for the same two items cost 1 read unit, because each is billed at 0.5.
|
|
96
|
+
Latency follows the same shape, and two calls average worse than one.
|
|
97
|
+
- **Traffic smooths out.** Aggregating several usage patterns onto one table produces a steadier
|
|
98
|
+
overall curve than any single pattern has on its own, in the way an index moves more smoothly than
|
|
99
|
+
a share in it. Provisioned mode reaches a higher utilisation as a result.
|
|
100
|
+
- **Cost tracks the item.** A write is charged per kilobyte of the whole item, so a view counter
|
|
101
|
+
living on the same item as the video metadata charges for the metadata on every increment.
|
|
102
|
+
Splitting by write rate is a modelling decision the table boundary cannot make.
|
|
103
|
+
- **Operational surface stays flat.** One table means one set of alarms, one backup policy, one
|
|
104
|
+
capacity mode, one stream, one set of IAM statements, and one customer managed key to rotate.
|
|
105
|
+
- **It keeps the modelling honest.** A table per entity looks like a relational schema and invites
|
|
106
|
+
relational habits. One table has no shape to fall back on except the access patterns.
|
|
107
|
+
|
|
108
|
+
Name the table after the service (`orders-service-data`), never after an entity. A table called
|
|
109
|
+
`Users` has already lost the argument.
|
|
110
|
+
|
|
111
|
+
Symptoms of the relational reflex, all of which mean the keys are wrong:
|
|
112
|
+
|
|
113
|
+
- Two or more `GetItem` calls in sequence to build one response.
|
|
114
|
+
- A `Scan` with a `FilterExpression` doing the selection.
|
|
115
|
+
- A secondary index added for each new query.
|
|
116
|
+
- An `orderId` attribute on the customer item, used as a foreign key by the application.
|
|
117
|
+
|
|
118
|
+
## Generic key names and entity prefixes
|
|
119
|
+
|
|
120
|
+
Partition and sort key attributes are named `PK` and `SK`, and secondary index keys `GSI1PK`,
|
|
121
|
+
`GSI1SK` and so on. A key named `customerId` can only ever hold a customer.
|
|
122
|
+
|
|
123
|
+
Every value carries a prefix naming its entity type, and every item carries a `type` attribute for
|
|
124
|
+
the deserialiser, for stream consumers and for exports.
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
PK SK type Attributes
|
|
128
|
+
CUSTOMER#c-42 #PROFILE customer email, name, createdAt
|
|
129
|
+
ORDER#o-981 #ORDER order customerId, status, total, placedAt
|
|
130
|
+
ORDER#o-981 LINE#sku-0007 orderLine qty, price
|
|
131
|
+
ORDER#o-981 LINE#sku-0031 orderLine qty, price
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`Query` on `PK = "ORDER#o-981"` returns the order and every line in one request, in sort key order.
|
|
135
|
+
`#ORDER` comes back first because `#` sorts below the letters. A `#` prefix is the usual trick for
|
|
136
|
+
pinning a parent item to the top of its item collection.
|
|
137
|
+
|
|
138
|
+
Sort keys are byte-ordered strings, and hierarchy in them is free. AWS gives
|
|
139
|
+
`[country]#[region]#[state]#[county]#[city]#[neighborhood]` as the shape, queryable at every level
|
|
140
|
+
with `begins_with` and `between`. A timestamp in ISO 8601 sorts chronologically without parsing. Pad
|
|
141
|
+
numbers so `sku-10` sorts after `sku-9`.
|
|
142
|
+
|
|
143
|
+
Choosing the partition key is also a throughput decision. A key with many distinct values used
|
|
144
|
+
evenly (a customer id) distributes well, and one with few values (a status code) or one that
|
|
145
|
+
concentrates writes on the current period (a date rounded to the day) does not. Every partition
|
|
146
|
+
serves 3,000 read units and 1,000 write units per second.
|
|
147
|
+
[references/aws-guidance.md](references/aws-guidance.md) covers the arithmetic, the AWS table of
|
|
148
|
+
good and bad keys, and write sharding for the cases that need it.
|
|
149
|
+
|
|
150
|
+
## Overload the secondary indexes
|
|
151
|
+
|
|
152
|
+
Keep the number of indexes small. Every global secondary index is a full copy of the attributes it
|
|
153
|
+
projects, rewritten whenever a projected attribute changes, and it consumes its own write capacity.
|
|
154
|
+
AWS states the rule as "keep the number of indexes to a minimum" and warns that a seldom-used index
|
|
155
|
+
costs storage and I/O without buying performance. The default quota caps a table at 20 of them, and
|
|
156
|
+
a design that adds one index per access pattern will feel the write cost long before it reaches that
|
|
157
|
+
ceiling.
|
|
158
|
+
|
|
159
|
+
Index overloading is what keeps the count down. The same index attributes carry different meanings
|
|
160
|
+
for different entity types:
|
|
161
|
+
|
|
162
|
+
| Item | GSI1PK | GSI1SK | Serves |
|
|
163
|
+
| --------- | ----------------------- | -------------------------- | ------------------------ |
|
|
164
|
+
| customer | `EMAIL#ada@example.com` | `EMAIL` | find a customer by email |
|
|
165
|
+
| order | `CUSTOMER#c-42` | `PLACED#2026-03-01T09:14Z` | a customer's orders |
|
|
166
|
+
| orderLine | (absent) | (absent) | |
|
|
167
|
+
|
|
168
|
+
Two access patterns, one index, and the line items stay out of it because they carry no `GSI1PK`.
|
|
169
|
+
That last part is the **sparse index**. An index keyed on an attribute only some items hold contains
|
|
170
|
+
only those items. Set `GSI2PK` to `STATUS#OPEN` while an order is open and remove the attribute when
|
|
171
|
+
it closes. The index then holds the open orders and nothing else, and the query for them reads no
|
|
172
|
+
closed orders at all.
|
|
173
|
+
|
|
174
|
+
Prefer a GSI to an LSI. An LSI has to exist when the table is created, cannot be deleted afterwards,
|
|
175
|
+
shares the table's throughput, and caps every item collection at 10 GB. A GSI can be added later and
|
|
176
|
+
carries its own capacity. The case for an LSI is a strongly consistent read on an alternate sort
|
|
177
|
+
key, which a GSI cannot give (GSI reads are eventually consistent, always).
|
|
178
|
+
|
|
179
|
+
Decide the projection on every index. `ALL` removes fetches and roughly doubles storage and write
|
|
180
|
+
cost. See [references/aws-guidance.md](references/aws-guidance.md) for the trade.
|
|
181
|
+
|
|
182
|
+
## Split items by write rate
|
|
183
|
+
|
|
184
|
+
The write cost of an item is its whole size rounded up to the next kilobyte. An attribute that
|
|
185
|
+
changes constantly, sitting on an item that is mostly static, charges for the static part every
|
|
186
|
+
time.
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
PK SK type Attributes
|
|
190
|
+
VIDEO#v-3 #METADATA video title, description, tags, uploadedAt (2 KB, rarely written)
|
|
191
|
+
VIDEO#v-3 #VIEWS viewCount count (1 WCU per increment)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Same partition, so one `Query` still returns both. The counter now costs one write unit, down from
|
|
195
|
+
two. This split cuts across entity boundaries. A table-per-entity layout has no way to express it.
|
|
196
|
+
|
|
197
|
+
The same move handles items that outgrow 400 KB. Break the item into chunks under one partition key
|
|
198
|
+
and order them by sort key, which AWS calls vertical partitioning.
|
|
199
|
+
|
|
200
|
+
## Many-to-many is an adjacency list
|
|
201
|
+
|
|
202
|
+
Model both sides in one table. Top-level entities are partition keys, and a relationship is an item
|
|
203
|
+
inside the partition whose sort key is the id of the thing on the other end.
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
PK SK type
|
|
207
|
+
INVOICE#i-3 #INVOICE invoice
|
|
208
|
+
INVOICE#i-3 BILL#b-7 invoiceBill
|
|
209
|
+
INVOICE#i-3 BILL#b-9 invoiceBill
|
|
210
|
+
BILL#b-7 #BILL bill
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
A `Query` on `INVOICE#i-3` gives every bill on the invoice. The other direction comes from an
|
|
214
|
+
**inverted index**, a GSI whose partition key is the table's sort key, so a query on `BILL#b-7`
|
|
215
|
+
returns every invoice carrying that bill. One extra index buys the reverse of every relationship in
|
|
216
|
+
the table.
|
|
217
|
+
|
|
218
|
+
Where the traversal is graph-shaped and needs several hops at low latency, Amazon Neptune is the
|
|
219
|
+
tool AWS points to.
|
|
220
|
+
|
|
221
|
+
## Denormalise what is stable, and write the copies together
|
|
222
|
+
|
|
223
|
+
A join is replaced either by co-location or by duplication. Copy the customer name onto the order
|
|
224
|
+
item when the order view needs it, and keep the copies consistent in one `TransactWriteItems` (up to
|
|
225
|
+
100 items in one call). Where the fan-out is too wide for a transaction, repair from a DynamoDB
|
|
226
|
+
stream.
|
|
227
|
+
|
|
228
|
+
Duplicate attributes that rarely change. An attribute that changes often turns every update into a
|
|
229
|
+
fan-out write across every copy, and at that point a second `Query` for the current value is
|
|
230
|
+
cheaper.
|
|
231
|
+
|
|
232
|
+
## Uniqueness is a second item
|
|
233
|
+
|
|
234
|
+
There is no unique index. A uniqueness rule is an item whose key is the unique value, written in the
|
|
235
|
+
same transaction as the entity, both writes conditional on the key being free:
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
await documents.send(new TransactWriteCommand({
|
|
239
|
+
TransactItems: [
|
|
240
|
+
{
|
|
241
|
+
Put: {
|
|
242
|
+
TableName: table,
|
|
243
|
+
Item: { PK: `CUSTOMER#${id}`, SK: "#PROFILE", type: "customer", email },
|
|
244
|
+
ConditionExpression: "attribute_not_exists(PK)",
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
Put: {
|
|
249
|
+
TableName: table,
|
|
250
|
+
Item: { PK: `EMAIL#${email}`, SK: "#EMAIL", type: "emailClaim", customerId: id },
|
|
251
|
+
ConditionExpression: "attribute_not_exists(PK)",
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
}));
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Either both land or neither does. The claim item is another entity type in the same table, and it
|
|
259
|
+
needs releasing when the email changes.
|
|
260
|
+
|
|
261
|
+
## Query, never Scan, and never select with a filter
|
|
262
|
+
|
|
263
|
+
A `FilterExpression` is applied after the read and charged on everything read. Filtering 10,000
|
|
264
|
+
items down to 3 costs 10,000 items of read capacity and returns pages that look half-empty. Filters
|
|
265
|
+
are for trimming a result set the key condition has already narrowed, and never for choosing which
|
|
266
|
+
items to fetch.
|
|
267
|
+
|
|
268
|
+
A `Scan` in a request path is a modelling failure. It reads the whole table, slows as the table
|
|
269
|
+
grows, and takes its capacity from one partition at a time, which throttles the requests that share
|
|
270
|
+
that partition. A single 1 MB page of 4 KB items costs 128 eventually consistent read units in one
|
|
271
|
+
burst.
|
|
272
|
+
|
|
273
|
+
In a batch job over the whole table a `Scan` is legitimate. Set `Limit` to cap the page size and use
|
|
274
|
+
parallel segments once the table passes about 20 GB.
|
|
275
|
+
|
|
276
|
+
Watch the 1 MB page limit on `Query` too. Code that reads `Items` without following
|
|
277
|
+
`LastEvaluatedKey` silently truncates.
|
|
278
|
+
|
|
279
|
+
## When a second table earns its place
|
|
280
|
+
|
|
281
|
+
Each of these is a real reason, and each produces a second table holding several entity types. None
|
|
282
|
+
of them produces a table per entity.
|
|
283
|
+
|
|
284
|
+
- **Whole-table settings that need to differ.** Backups and point-in-time recovery are per table,
|
|
285
|
+
and so are encryption keys and the table class. Mission-critical data mixed with disposable data
|
|
286
|
+
gets backed up as one unit. A multi-tenant application needing a key per tenant needs a table per
|
|
287
|
+
tenant or client-side encryption. Historical data mixed with operational data loses most of the
|
|
288
|
+
Infrequent Access saving.
|
|
289
|
+
- **Stream pressure.** One stream carries every change to every entity type, and a shard supports
|
|
290
|
+
about two concurrent readers before throttling. Entities needing separate downstream pipelines
|
|
291
|
+
(orders into Step Functions, registrations into EventBridge) can exhaust that. Lambda event
|
|
292
|
+
filters keep the irrelevant records off the bill, and the Kinesis Client Library does not.
|
|
293
|
+
- **Analytics exports.** An immutable event log and a mutable entity set want different export
|
|
294
|
+
strategies. Full exports suit mutable data and streaming suits the log.
|
|
295
|
+
- **High-volume time series data.** AWS names this as an explicit exception, along with datasets
|
|
296
|
+
whose access patterns have nothing in common. A table per time period is the usual shape.
|
|
297
|
+
- **A different owner.** Another service owning the data means a shared table couples two
|
|
298
|
+
deployments and two IAM policies. The table boundary follows the service boundary.
|
|
299
|
+
- **A framework that fights it.** GraphQL resolvers map cleanly onto one entity per table, and
|
|
300
|
+
higher-level SDK mappers struggle when one response holds several classes. AWS lists both as
|
|
301
|
+
disadvantages of single-table design.
|
|
302
|
+
|
|
303
|
+
Absent one of these, the second table is the SQL habit reasserting itself.
|
|
304
|
+
|
|
305
|
+
## Keep key construction in one place
|
|
306
|
+
|
|
307
|
+
Single-table keys are strings with structure, and structure spread across handlers as template
|
|
308
|
+
literals drifts. One module owns building and parsing them, and nothing else concatenates a `#`.
|
|
309
|
+
|
|
310
|
+
```typescript
|
|
311
|
+
export const keys = {
|
|
312
|
+
customer: (id: string) => ({ PK: `CUSTOMER#${id}`, SK: "#PROFILE" }),
|
|
313
|
+
order: (orderId: string) => ({ PK: `ORDER#${orderId}`, SK: "#ORDER" }),
|
|
314
|
+
orderLine: (orderId: string, sku: string) => ({ PK: `ORDER#${orderId}`, SK: `LINE#${sku}` }),
|
|
315
|
+
ordersForCustomer: (id: string) => ({ GSI1PK: `CUSTOMER#${id}` }),
|
|
316
|
+
};
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
A `Query` over one partition returns several entity types, so the item type is a discriminated union
|
|
320
|
+
keyed on the `type` attribute. Parse into that union at the edge of the data access layer and let
|
|
321
|
+
the rest of the code hold real types.
|
|
322
|
+
|
|
323
|
+
## Testing the model
|
|
324
|
+
|
|
325
|
+
Access patterns are testable, and each one deserves a test that seeds a few items and asserts what
|
|
326
|
+
the query returns. The
|
|
327
|
+
[`yulin-aws-simulation`](https://github.com/KensioSoftware/kensio.ai/tree/main/plugins/yulin-aws-simulation)
|
|
328
|
+
skill covers running those tests against a simulated DynamoDB with the real CDK table definition,
|
|
329
|
+
including key schema and index projections.
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Mechanics behind the modelling rules
|
|
2
|
+
|
|
3
|
+
The numbers and patterns the [SKILL.md](../SKILL.md) rules rest on, collected from the DynamoDB
|
|
4
|
+
developer guide. Read this when the question turns on cost, throughput or size. Fetch the linked
|
|
5
|
+
pages when a decision rests on an exact figure, because quotas move.
|
|
6
|
+
|
|
7
|
+
## Partitions and throughput
|
|
8
|
+
|
|
9
|
+
A table is stored in partitions, each backed by SSD and replicated across Availability Zones. AWS
|
|
10
|
+
manages them and never exposes them directly. More partitions appear when provisioned throughput
|
|
11
|
+
rises past what the current ones serve, and when an existing partition fills.
|
|
12
|
+
|
|
13
|
+
DynamoDB hashes the partition key to choose the partition. Items sharing a partition key value form
|
|
14
|
+
an **item collection**, held together and sorted by sort key, which is what makes a range query over
|
|
15
|
+
one collection cheap. Where the table carries no local secondary index, DynamoDB splits an item
|
|
16
|
+
collection across as many partitions as it needs, and there is no ceiling on the number of distinct
|
|
17
|
+
sort key values under one partition key.
|
|
18
|
+
|
|
19
|
+
**Every partition serves 3,000 read units and 1,000 write units per second.** One read unit is one
|
|
20
|
+
strongly consistent read of an item up to 4 KB, or two eventually consistent reads. One write unit
|
|
21
|
+
is one write of an item up to 1 KB. Item size multiplies this. A 20 KB item costs 5 read units per
|
|
22
|
+
consistent read, which puts the ceiling at 600 reads per second against that one item.
|
|
23
|
+
|
|
24
|
+
Source
|
|
25
|
+
[Partitions and data distribution](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.Partitions.html)
|
|
26
|
+
and
|
|
27
|
+
[Best practices for partition keys](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-partition-key-design.html).
|
|
28
|
+
|
|
29
|
+
## Choosing a partition key that spreads
|
|
30
|
+
|
|
31
|
+
Throughput efficiency rises with the ratio of partition key values accessed to partition key values
|
|
32
|
+
that exist. The AWS comparison:
|
|
33
|
+
|
|
34
|
+
| Partition key value | Uniformity |
|
|
35
|
+
| --------------------------------------------------------------- | ---------- |
|
|
36
|
+
| User id, in an application with many users | Good |
|
|
37
|
+
| Status code, where few codes exist | Bad |
|
|
38
|
+
| Creation date rounded to a day, hour or minute | Bad |
|
|
39
|
+
| Device id, where devices are accessed at similar intervals | Good |
|
|
40
|
+
| Device id, where one device is far more popular than the others | Bad |
|
|
41
|
+
|
|
42
|
+
The date case is the one that catches people. Every item created today lands on one partition key
|
|
43
|
+
value and therefore one physical partition.
|
|
44
|
+
|
|
45
|
+
A table small enough to fit in a single partition, allowing for growth, and whose throughput stays
|
|
46
|
+
inside one partition's limits, will not throttle whatever the key looks like.
|
|
47
|
+
|
|
48
|
+
Source
|
|
49
|
+
[Designing partition keys to distribute your workload](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-partition-key-uniform-load.html).
|
|
50
|
+
|
|
51
|
+
## Write sharding
|
|
52
|
+
|
|
53
|
+
Where the natural key concentrates writes, widen the key space by appending a suffix.
|
|
54
|
+
|
|
55
|
+
**Random suffix.** Append a random number in a fixed range, giving `2026-07-09.1` through
|
|
56
|
+
`2026-07-09.200`. Writes spread evenly. Reading one item back becomes impossible without knowing
|
|
57
|
+
which suffix it took, and reading the whole day means one `Query` per suffix followed by a merge.
|
|
58
|
+
|
|
59
|
+
**Calculated suffix.** Derive the suffix from an attribute the reader already holds, such as the sum
|
|
60
|
+
of the UTF-8 code points of an order id modulo 200 plus 1. Writes spread the same way, and a
|
|
61
|
+
`GetItem` for a known order still works because the suffix is recomputable. Reading the whole day
|
|
62
|
+
still costs one `Query` per suffix.
|
|
63
|
+
|
|
64
|
+
A GSI can be sharded the same way to make selective queries parallel.
|
|
65
|
+
|
|
66
|
+
Source
|
|
67
|
+
[Using write sharding](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-partition-key-sharding.html).
|
|
68
|
+
|
|
69
|
+
## Sort key patterns
|
|
70
|
+
|
|
71
|
+
**Hierarchy.** A composite sort key defines one-to-many relationships queryable at any level, using
|
|
72
|
+
`begins_with`, `between`, `>` and `<`. The AWS example is
|
|
73
|
+
`[country]#[region]#[state]#[county]#[city]#[neighborhood]`.
|
|
74
|
+
|
|
75
|
+
**Version history.** Keep two copies of every item. One carries a `v0_` sort key prefix and holds
|
|
76
|
+
the current version, and each revision is written under the next number up (`v1_`, `v2_` and so on)
|
|
77
|
+
with its contents also copied over `v0_`. The current version is then a query on the `v0_` prefix,
|
|
78
|
+
and the history is the rest of the partition.
|
|
79
|
+
|
|
80
|
+
Source
|
|
81
|
+
[Best practices for sort keys](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-sort-keys.html).
|
|
82
|
+
|
|
83
|
+
## Secondary indexes
|
|
84
|
+
|
|
85
|
+
A table gets 20 global secondary indexes (default quota) and 5 local secondary indexes. AWS says
|
|
86
|
+
global indexes are usually the more useful of the two.
|
|
87
|
+
|
|
88
|
+
**Keep the number to a minimum.** An index that is seldom queried adds storage and I/O cost and buys
|
|
89
|
+
no performance.
|
|
90
|
+
|
|
91
|
+
**Choose projections deliberately.** A smaller index costs less and outperforms the base table by
|
|
92
|
+
more. Project the attributes the queries actually return. `ALL` removes every fetch and in most
|
|
93
|
+
cases doubles storage and write cost. Where an index entry is under 1 KB the projection is free up
|
|
94
|
+
to that point, because writes round up.
|
|
95
|
+
|
|
96
|
+
**Avoid fetches on the read path.** Querying an LSI for an attribute it does not project makes
|
|
97
|
+
DynamoDB read the whole item from the table, adding latency and I/O. Attributes queried occasionally
|
|
98
|
+
have a habit of becoming attributes queried always.
|
|
99
|
+
|
|
100
|
+
**Watch LSI item collections.** An item collection covers the table items and every LSI item sharing
|
|
101
|
+
a partition key, and it cannot exceed 10 GB. Writes fail once it does. Pass
|
|
102
|
+
`ReturnItemCollectionMetrics` on writes and alarm before the limit. An LSI cannot be deleted after
|
|
103
|
+
creation, which makes the decision to add one permanent.
|
|
104
|
+
|
|
105
|
+
Source
|
|
106
|
+
[General guidelines for secondary indexes](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-indexes-general.html).
|
|
107
|
+
|
|
108
|
+
## Large items
|
|
109
|
+
|
|
110
|
+
The item size limit is 400 KB, and exceeding it fails the write with a `ValidationException`. Pass
|
|
111
|
+
`ReturnConsumedCapacity` on writes and alarm on items approaching the limit.
|
|
112
|
+
|
|
113
|
+
Three ways out, in the order worth trying:
|
|
114
|
+
|
|
115
|
+
- **Vertical partitioning.** Break the item into several items under one partition key, ordered by
|
|
116
|
+
sort key. This is the single-table answer and keeps everything queryable.
|
|
117
|
+
- **Compression.** GZIP or LZO into a `Binary` attribute. A compressed attribute cannot be filtered
|
|
118
|
+
or queried on.
|
|
119
|
+
- **S3.** Store the payload as an object and the object key in the item, with the item's primary key
|
|
120
|
+
in the S3 object metadata pointing back. No transaction spans the two, so the application owns the
|
|
121
|
+
cleanup of orphaned objects.
|
|
122
|
+
|
|
123
|
+
Source
|
|
124
|
+
[Best practices for storing large items](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-use-s3-too.html).
|
|
125
|
+
|
|
126
|
+
## What a Scan actually costs
|
|
127
|
+
|
|
128
|
+
A `Scan` reads the whole table or index and then discards what the filter rejects. It slows as the
|
|
129
|
+
table grows.
|
|
130
|
+
|
|
131
|
+
A single 1 MB page of 4 KB items costs 128 eventually consistent read units, or 256 strongly
|
|
132
|
+
consistent. That arrives as one spike, and it lands on one partition, because the items a scan reads
|
|
133
|
+
sit next to each other. Requests sharing that partition throttle.
|
|
134
|
+
|
|
135
|
+
Where a scan is needed:
|
|
136
|
+
|
|
137
|
+
- Set `Limit` to shrink the page, which spreads the cost and leaves gaps for other traffic.
|
|
138
|
+
- Use parallel segments once the table passes about 20 GB, starting at roughly one segment per 2 GB,
|
|
139
|
+
and only where the provisioned read capacity is not already busy.
|
|
140
|
+
- Retry throttled requests with exponential backoff.
|
|
141
|
+
|
|
142
|
+
Source
|
|
143
|
+
[Best practices for querying and scanning](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-query-scan.html).
|
|
144
|
+
|
|
145
|
+
## Adjacency lists and the materialized graph
|
|
146
|
+
|
|
147
|
+
Top-level entities become partition keys, and each relationship becomes an item in the partition
|
|
148
|
+
whose sort key is the id at the other end. Data duplication stays minimal and the forward query is a
|
|
149
|
+
plain `Query`.
|
|
150
|
+
|
|
151
|
+
The reverse direction comes from an **inverted index**, a global secondary index whose partition key
|
|
152
|
+
is the table's sort key.
|
|
153
|
+
|
|
154
|
+
The materialized graph pattern extends this. Edge items carry `Type` and `Target` attributes
|
|
155
|
+
composed into a `TypeTarget` key, one overloaded GSI indexes a `Data` attribute holding dates,
|
|
156
|
+
names, places and skills, and a second GSI on `TypeTarget` answers reverse lookups. Aggregations
|
|
157
|
+
large enough to run hot (everyone born on one date, everyone with one skill) want sharding across
|
|
158
|
+
logical partitions.
|
|
159
|
+
|
|
160
|
+
Multi-hop traversal at millisecond latency is Amazon Neptune's job. AWS says so on the many-to-many
|
|
161
|
+
page itself.
|
|
162
|
+
|
|
163
|
+
Source
|
|
164
|
+
[Best practices for many-to-many relationships](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-adjacency-graphs.html).
|
|
165
|
+
|
|
166
|
+
## AWS's own trade-off lists
|
|
167
|
+
|
|
168
|
+
Worth reading in full at
|
|
169
|
+
[Data modeling foundations](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/data-modeling-foundations.html),
|
|
170
|
+
because they are the most honest account of the cost of single-table design.
|
|
171
|
+
|
|
172
|
+
What AWS counts against single-table design. The learning curve is steep because the design runs
|
|
173
|
+
opposite to relational instinct, whole-table settings (backup, encryption, table class) apply to
|
|
174
|
+
every entity at once, streams carry every change whether or not a consumer wants it, GraphQL is
|
|
175
|
+
harder to implement, and higher-level SDK mappers struggle with one response holding several
|
|
176
|
+
classes.
|
|
177
|
+
|
|
178
|
+
What AWS counts for it. Data locality, fewer read units and fewer round trips, one set of
|
|
179
|
+
permissions and alarms, one key to rotate, capacity averaged across entities, and traffic that
|
|
180
|
+
smooths as patterns aggregate.
|
|
181
|
+
|
|
182
|
+
AWS's summary of when multiple tables are the right answer is short. Where the access patterns never
|
|
183
|
+
query several entities together, multiple tables are good and sufficient.
|