Skip to content

Multi type tables

Eugene Lazutkin edited this page Jul 17, 2026 · 1 revision

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.

Signal 1 — key presence (depth)

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.

Signal 2 — the discriminator field

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.

Signal 3 — typeField (auto-stamping on write)

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 read

Pointing 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.

The priority chain

adapter.typeOf(item) resolves in order:

  1. typeDiscriminator value, when the field is present on the item (coerced to string);
  2. typeLabels[depth - 1], where depth = count of contiguous-from-start keyFields present;
  3. the raw depth number, when no labels are declared;
  4. 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.

Dispatching on type

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 or several?

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).

When multi-type doesn't fit

  • 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.

Related

Clone this wiki locally