-
Notifications
You must be signed in to change notification settings - Fork 0
Multi type tables
Storing several entity types in one DynamoDB table — the "single-table design" SQL developers are warned about — and how to know which type a record is once everything shares one keyspace. The toolkit gives you three type signals that compose: key presence (depth), a discriminator field, and the auto-stamped typeField. adapter.typeOf(item) reads them in a fixed priority order.
Why put types together at all: records that transact together should share a partition (single TransactWriteItems, no cross-table fan-out); hierarchies want one sorted keyspace (walkthrough); one table is one provisioning unit (workflow). If none of those apply to two types, give them separate tables and separate Adapters — single-table design is a tool, not a religion.
With composite keyFields, the number of contiguous-from-start key components a record carries is its type. No extra attribute, nothing to maintain, impossible to get out of sync:
import {Adapter} from 'dynamodb-toolkit';
const adapter = new Adapter({
client,
table: 'rentals',
technicalPrefix: '_',
keyFields: ['state', 'facility', 'vehicle'],
structuralKey: '_sk',
typeLabels: ['state', 'facility', 'vehicle'] // paired 1:1 with keyFields
});
adapter.typeOf({state: 'TX'}); // 'state'
adapter.typeOf({state: 'TX', facility: 'Dallas Rental'}); // 'facility'
adapter.typeOf({state: 'TX', facility: 'Dallas Rental', vehicle: 'V1'}); // 'vehicle'Without typeLabels, typeOf returns the raw depth (1, 2, 3) — the labels are naming, not mechanism. One constraint is enforced at construction: no label may be a prefix of another (needed so buildKey's {self: true} ranges stay unambiguous).
Where depth stops working: two types at the same depth. A facility that rents cars and boats has two leaf types, both at depth 3 — depth alone can't tell them apart.
An explicit attribute names the type; typeDiscriminator tells typeOf to read it, and its value overrides depth-based detection whenever the field is present on the item:
typeDiscriminator: 'kind' // string shorthand for {name: 'kind'}adapter.typeOf({state: 'TX', facility: 'D', vehicle: 'V1', kind: 'car'}); // 'car' (discriminator wins)
adapter.typeOf({state: 'TX', facility: 'D', vehicle: 'B7', kind: 'boat'}); // 'boat'
adapter.typeOf({state: 'TX'}); // 'state' (no field → depth fallback)This is the classic single-table type/entity column — except you only need to write it where depth is ambiguous. Records whose depth already says everything can skip it.
The write-side companion: declare typeField and the built-in prepare step stamps typeOf(item) into that attribute on every full write — unless the item already carries a value (so explicit kind: 'boat' survives):
typeField: 'kind',
typeDiscriminator: 'kind' // same field: write it, then trust it on readPointing typeField and typeDiscriminator at the same attribute is the recommended loop: the toolkit stamps the type on write, reads it back on classification, and explicit leaf values short-circuit both. What it buys beyond classification: the type becomes an indexable attribute — a sparse GSI keyed on kind turns "all facilities, across every state" into one Query. That pattern, with cost tables and sharding variants, is List records of a tier.
Patches skip the stamp (a partial update may not carry the key fields to derive it from); the value written at creation persists.
adapter.typeOf(item) resolves in order:
-
typeDiscriminatorvalue, when the field is present on the item (coerced to string); -
typeLabels[depth - 1], where depth = count of contiguous-from-startkeyFieldspresent; - the raw depth number, when no labels are declared;
-
undefined— nothing recognizable (empty object, no discriminator, no key fields).
Practical reading: declared beats derived, and derivation needs keys. A revived item that was projected without its key fields (narrow fields= projection) can only be classified by its discriminator — one more reason to point typeField at a real attribute rather than relying on depth at read time.
Classification exists to route logic. The common shape — one revive hook, per-type behavior:
hooks: {
// The user revive composes after the built-in step (prefix stripping,
// projection) — `item` arrives ready for per-type touch-ups.
revive(item) {
switch (adapter.typeOf(item)) {
case 'car': return {...item, dailyPriceCents: Number(item.dailyPriceCents)};
case 'boat': return {...item, mooring: item.mooring ?? 'trailer'};
default: return item;
}
}
}The same dispatch works in REST exampleFromContext (scope a route to one type), in mass-op mapFns (transform only one type during a resumable sweep), and in list post-processing.
One Adapter per table + key schema, not per type. The Adapter's declaration (keyFields, structural key, indices, hooks) describes the keyspace; types within it are data. Splitting car/boat into two Adapters against the same table buys nothing — they'd share every declaration — and costs you the unified hierarchy operations (getListUnder, cascades) that see both.
Reach for a second Adapter only when the key schema genuinely differs — e.g. a metadata singleton keyed flat next to a hierarchy — and then treat it as what it is: a second logical entity that happens to co-locate. Cross-Adapter writes still compose transactionally via the batch builders (makePut/makeDelete from either Adapter into one applyTransaction).
- Types never transact together and never share queries — separate tables are simpler, and per-table capacity isolates load.
- Wildly different item sizes — a 300 KB document type sharing a partition budget with a 200-byte event type makes throughput math miserable.
- Divergent lifecycle — different TTL policies, backup cadence, or GDPR erasure scope per type are table-level knobs; sharing the table means sharing the policy.
- Hierarchical data walkthrough — the keyspace these types live in.
-
List records of a tier — the
typeFieldsparse-GSI listing pattern. -
Adapter: Constructor options —
typeLabels/typeDiscriminator/typeFieldreference. - examples/car-rental — runnable car + boat discrimination.
Start here
- Getting started
- Concepts
- Key and field design
- Compatibility
- Migration: v2 to v3
- SDK v2 to v3 cheat sheet
Guides
- Hierarchical data walkthrough
- Key expression patterns
- Multi-type tables
- Pagination
- Mass operation semantics
- URL schema design
Adapter
- Adapter
- Constructor options
- CRUD methods
- Mass methods
- Batch builders
- Hooks
- Raw marker
- Indirect indices
- Transaction auto-upgrade
Expression builders
Batch / transactions / mass / paths
REST surface
Framework adapters
Recipes
- Recipes index
- List records of a tier
- Per-tier sparse GSI markers
- Tier within a partition
- Reservation with auto-release
- Keys-only GSI, runtime projection
- Cascade subtree operations
- Querying subtrees with buildKey
- Filter URL grammar
- Text search
- Provisioning workflow
- Resumable mass operations
History