Aggregates & Relational Patterns
This guide covers three related features for modeling rich domain relationships in DynamoDB:
- Entity References — Denormalized copies of related entities, with automatic ID-based inputs and hydration on write
- Aggregates — Graph-based composite domain objects assembled from multiple entity types sharing a partition key
- Cascade Updates — Propagating source entity changes to all items that embed it via refs
Entity References
Section titled “Entity References”The Problem
Section titled “The Problem”DynamoDB doesn’t have JOINs. When your domain model needs data from a related entity, you denormalize — store a copy of the related data alongside the item that references it. This creates two problems:
- Write complexity — Instead of accepting an ID, you must fetch the full entity by ID and embed its data
- Read/write type mismatch — Create/update inputs should accept IDs; read outputs should return full entity data
DynamoModel.identifier
Section titled “DynamoModel.identifier”Mark the primary business identifier field on any entity that can be referenced:
class Team extends Schema.Class<Team>("Team")({ id: Schema.String.pipe(DynamoModel.identifier), name: Schema.String, country: Schema.String, ranking: Schema.Number,}) {}Rules:
- Exactly one field per model may be annotated with
identifier - The field should be a string or branded string type
- Only entities with an
identifierfield can be referenced viaDynamoModel.ref
DynamoModel.ref
Section titled “DynamoModel.ref”Mark a field as a denormalized reference to another entity:
class SquadSelection extends Schema.Class<SquadSelection>("SquadSelection")({ squadId: Schema.String, selectionNumber: Schema.Number, team: Team.pipe(DynamoModel.ref), player: Player.pipe(DynamoModel.ref), squadRole: Schema.Literals(["batter", "bowler", "all-rounder"]), isCaptain: Schema.Boolean,}) {}DynamoModel.ref is a schema annotation. It tells Entity how to handle the field across different type contexts.
Entity Ref Integration
Section titled “Entity Ref Integration”When creating an entity with ref fields, provide a refs config mapping field names to the entities they reference:
const SquadSelections = Entity.make({ model: SquadSelection, entityType: "SquadSelection", primaryKey: { pk: { field: "pk", composite: ["squadId"] }, sk: { field: "sk", composite: ["selectionNumber"] }, }, indexes: { byPlayer: { name: "gsi1", pk: { field: "gsi1pk", composite: ["playerId"] }, sk: { field: "gsi1sk", composite: ["squadId", "selectionNumber"] }, }, }, refs: { team: { entity: Teams }, player: { entity: Players }, },})Type Transformation
Section titled “Type Transformation”Ref fields transform across Entity’s derived types:
| Type | Ref field shape | Example |
|---|---|---|
Entity.Input<E> | ID field (derived from identifier name + “Id” suffix) | teamId: string, playerId: string |
Entity.Record<E> | Full domain object | team: Team, player: Player |
Entity.Update<E> | Optional ID field | teamId?: string, playerId?: string |
// Input accepts IDs — not full objectsyield* db.entities.SquadSelections.put({ squadId: "aus#2024-25#BGT", selectionNumber: 1, teamId: "aus", playerId: "cummins-01", squadRole: "bowler", isCaptain: true,})
// Record returns full domain objectsconst selection = yield* db.entities.SquadSelections.get({ squadId: "aus#2024-25#BGT", selectionNumber: 1,})Ref Hydration
Section titled “Ref Hydration”On put and update, Entity automatically:
- Extracts the ID from each ref field in the input
- Fetches the referenced entity by ID (parallel fetches for multiple refs)
- Embeds the entity’s core domain data (Schema.Type — no system fields) in the DynamoDB item
- Returns
RefNotFoundif any referenced entity doesn’t exist
// #region ref-not-found // RefNotFound when a ref doesn't resolve const refError = yield* db.entities.SquadSelections.put({ squadId: "aus#2024-25#BGT", selectionNumber: 99, teamId: "aus", playerId: "nonexistent", squadRole: "batter", isCaptain: false, }) .asEffect() .pipe(Effect.flip)
if (refError._tag === "RefNotFound") { // refError.field → "player" // refError.refId → "nonexistent" // refError.refEntity → "Player" }DynamoDB Storage
Section titled “DynamoDB Storage”In DynamoDB, a ref field is stored as an embedded map containing the referenced entity’s domain fields:
{ "pk": { "S": "$cricket#v1#squad_selection#aus#2024-25#BGT" }, "sk": { "S": "$cricket#v1#squad_selection#1" }, "team": { "M": { "id": { "S": "aus" }, "name": { "S": "Australia" }, "country": { "S": "Australia" }, "ranking": { "N": "1" } } }, "player": { "M": { "id": { "S": "cummins-01" }, "firstName": { "S": "Pat" }, "lastName": { "S": "Cummins" }, "role": { "S": "bowler" } } }, "squadRole": { "S": "bowler" }, "isCaptain": { "BOOL": true }}Aggregates
Section titled “Aggregates”The Problem
Section titled “The Problem”Real-world DynamoDB applications model rich domain objects as multiple denormalized items sharing a partition key. A cricket match, for example, might store the match root, venue, two team sheets, coaches, and player selections as separate items in the same partition. Building and maintaining these structures requires:
- Manual collection queries with discrimination by entity type
- Complex assembly logic (reduce + merge items into domain shape)
- Deep destructuring for mutations
- Transactional writes for consistency
The Aggregate module automates all of this.
Core Concepts
Section titled “Core Concepts”An Aggregate is a domain object composed of multiple DynamoDB entity types sharing a partition key. The structure is a directed acyclic graph (DAG):
- Root node — The aggregate identity
- Edges — Relationships with cardinality (
oneormany) - Edge attributes — Data owned by the relationship, not the referenced entity (e.g.,
isCaptain,battingPosition) - Sub-aggregates — Reusable child aggregates that form transaction boundaries
Defining an Aggregate
Section titled “Defining an Aggregate”Step 1: Domain Schemas
Section titled “Step 1: Domain Schemas”Define the composed domain shape using Schema.Class. Ref fields reference other entities; edge fields reference aggregate members.
class PlayerSheet extends Schema.Class<PlayerSheet>("PlayerSheet")({ player: Player.pipe(DynamoModel.ref), battingPosition: Schema.Number, isCaptain: Schema.Boolean,}) {}
class TeamSheet extends Schema.Class<TeamSheet>("TeamSheet")({ team: Team.pipe(DynamoModel.ref), coach: Coach.pipe(DynamoModel.ref), homeTeam: Schema.Boolean, players: Schema.Array(PlayerSheet),}) {}
class Match extends Schema.Class<Match>("Match")({ id: Schema.String, name: Schema.String, venue: Venue.pipe(DynamoModel.ref), team1: TeamSheet, team2: TeamSheet,}) {}Step 2: Sub-Aggregates
Section titled “Step 2: Sub-Aggregates”Define reusable graph shapes for repeated structures. A sub-aggregate specifies a root entity type and edges to member entity types.
const TeamSheetAggregate = Aggregate.make(TeamSheet, { root: { entityType: "MatchTeam" }, edges: { team: Aggregate.ref(Teams), coach: Aggregate.one("coach", { entityType: "MatchCoach", entity: Coaches }), players: Aggregate.many("players", { entityType: "MatchPlayer", entity: Players }), },})Aggregate.one(name, config)— One member item per aggregate instance (decomposes to separate DynamoDB item)Aggregate.many(name, config)— Multiple member items per aggregate instance (one DynamoDB item per element)Aggregate.ref(entity)— Inline ref edge (stays in the parent item, no separate DynamoDB item; hydrates on create)
Edge keys (coach, players) correspond to fields on the schema.
Sort keys for many edges
Section titled “Sort keys for many edges”Each element of a many edge becomes its own DynamoDB item, and by default its sort key is composed from the referenced entity’s identifier. That is enough while an entity appears at most once in an aggregate. When the same entity legitimately appears twice — distinguished by a field on the edge element rather than by the ref — both elements compose the same sort key, and DynamoDB rejects the whole write.
Declare sk.composite to say what separates them:
// A panel seats two on-field umpires, plus a third umpire and a referee — and// the same person routinely holds more than one appointment.class MatchOfficial extends Schema.Class<MatchOfficial>("MatchOfficial")({ umpire: Umpire.pipe(DynamoModel.ref), role: Schema.Literals(["onfield", "third", "referee"]),}) {}
class OfficiatedMatch extends Schema.Class<OfficiatedMatch>("OfficiatedMatch")({ matchId: Schema.String, name: Schema.String, officials: Schema.Array(MatchOfficial),}) {}const OfficiatedMatchAggregate = Aggregate.make(OfficiatedMatch, { table: MainTable, schema: CricketSchema, pk: { field: "pk", composite: ["matchId"] }, collection: { name: "officiated" }, root: { entityType: "OfficiatedMatchItem" }, edges: { officials: Aggregate.many("officials", { entityType: "MatchOfficial", entity: Umpires, sk: { composite: ["role", "umpire.id"] }, }), },})const officiated = yield* OfficiatedMatchAggregate.create({ matchId: "bgt-2025-test-1", name: "AUS vs IND", officials: [ // A role seating two umpires. { umpireId: "bowen-01", role: "onfield" }, { umpireId: "kumar-01", role: "onfield" }, // ...and an umpire holding two appointments. { umpireId: "bowen-01", role: "third" }, { umpireId: "erasmus-01", role: "referee" }, ],})Pick the composite that is unique across the whole edge, not just the case in front of you. Here neither half would do alone — ["role"] collides on the two on-field umpires, and the ref-identifier default collides on the umpire holding two appointments. ["role", "umpire.id"] separates both. ["role"] alone is right only when every role seats exactly one official.
Each entry must resolve to a scalar — string, number, bigint, boolean or date. Naming the hydrated ref itself ("umpire") rather than a scalar path ("umpire.id") fails with AggregateDecompositionError rather than serialising the object into the key.
A declared sk.composite is authoritative: it replaces the ref-identifier default rather than extending it, so it decides both uniqueness and the order elements sort in. Note umpire.id is a dotted path — hydration replaces the input’s umpireId with the hydrated umpire object, so the bare name no longer exists at the top level.
An alternative modelling, when the roles are fixed and few, is an edge per role — onfield: Aggregate.many(...) over Schema.Array(Umpire) alongside third: Aggregate.one(...). Elements there are the referenced entity itself, so each row is keyed by that entity’s DynamoModel.identifier and no declaration is needed. It costs the ability to add a role without changing the schema.
Two shapes need a declaration, because nothing about the element identifies it on its own:
- an edge with no
entity, whose elements are plain structs - an edge whose elements share a referenced entity
Without one, decomposition fails with AggregateDecompositionError naming the edge and the colliding sort key — rather than the DynamoDB ValidationException (“Transaction request cannot include multiple operations on one item”) that the collision would otherwise produce.
Step 3: Top-Level Aggregate
Section titled “Step 3: Top-Level Aggregate”Bind the full domain schema to a table, schema namespace, and partition key:
const MatchAggregate = Aggregate.make(Match, { table: MainTable, schema: CricketSchema, pk: { field: "pk", composite: ["id"] }, collection: { name: "match" }, consistentRead: true, root: { entityType: "MatchItem" }, edges: { venue: Aggregate.one("venue", { entityType: "MatchVenue", entity: Venues }), team1: TeamSheetAggregate.with({ discriminator: { teamNumber: 1 } }), team2: TeamSheetAggregate.with({ discriminator: { teamNumber: 2 } }), },})Config properties:
| Property | Description |
|---|---|
table | The DynamoDB table |
schema | DynamoSchema namespace for key prefixing |
pk | Partition key definition (field + composite attributes from the schema) |
collection | Collection naming for the read path. name is required; index + sk only when assembling through a secondary index (see below) |
consistentRead | Use strongly consistent reads when assembling. Defaults to false |
root | Entity type for the root item |
context | Optional array of schema field names to propagate to all member items |
timestamps | Optional auto-managed created / updated attributes on every row the aggregate writes (see below) |
edges | Map of schema fields to edge descriptors, ref edges, or bound sub-aggregates |
System Timestamps
Section titled “System Timestamps”An aggregate composes its DynamoDB rows directly rather than routing them through
Entity write ops, so entity-level timestamps never reach them. Configure them on the
aggregate instead — it takes the same config, so field names and DynamoModel storage
annotations behave exactly as they do on Entity.make:
const AuditedMatchAggregate = Aggregate.make(Match, { table: MainTable, schema: CricketSchema, pk: { field: "pk", composite: ["id"] }, collection: { name: "audited-match" }, root: { entityType: "AuditedMatchItem" }, edges: { venue: Aggregate.one("venue", { entityType: "AuditedMatchVenue", entity: Venues }), }, timestamps: { created: { field: "created", schema: DynamoModel.DateEpochMs }, updated: { field: "updated", schema: DynamoModel.DateEpochMs }, },})Every row the aggregate writes is stamped — the root item, one and many edges, and
every row inside a sub-aggregate’s transaction group.
updated is per row. It records when that row last changed. update diffs the
decomposition and rewrites only the sub-aggregate groups whose content actually changed,
so rows a mutation leaves alone keep their stored value. That is what a downstream sync
guarding its writes with attribute_exists(updated) and updated < :updated wants: rows
that did not change do not need a new stamp.
created survives rewrites. Aggregate writes are Put, not UpdateItem, so a
rewritten row would otherwise lose its original create instant. The stored value is read
back off the partition the update already fetched and re-emitted unchanged.
Timestamp attributes are stripped on the read path, so they never appear on the assembled domain object. If you want them in the domain model, declare the field on the root schema under the same name — the library then manages it using the model’s own encoding and the value is decoded normally.
Do You Need a Secondary Index?
Section titled “Do You Need a Secondary Index?”Usually not. Assembly queries the whole partition with a bare pk = :pk condition and
discriminates items by entity type in memory — it issues no sort-key condition and depends
on no ordering. When the aggregate is keyed on the table’s primary partition key, that
query runs against the base table, so no index is provisioned and no mirror sort-key
attribute is written on every item.
Supply collection.index and collection.sk together only when the aggregate’s pk.field
is not the table’s primary partition key — then a GSI is genuinely load-bearing, because
you are querying a partition key the base table does not have.
Prefer the base table where you can. An LSI’s only capability a GSI cannot match is strongly
consistent reads, and it costs a 10 GB cap on the item collection plus a permanent commitment
at CreateTable — LSIs cannot be added or removed later. An aggregate is exactly the shape
that grows (root + N edges), so that cap is a poor trade for an index the read path never uses.
Consistent Reads
Section titled “Consistent Reads”Aggregate writes are transactional, so root and edges land atomically. Reads are eventually
consistent by default, which means a read taken shortly after a write can observe a torn
collection — the root may be missing (raising AggregateAssemblyError), or an edge may be
missing while the root is visible, which for optional or empty-able edges assembles into an
aggregate that is quietly incomplete.
Set consistentRead: true to close that window. It doubles the RCU cost of every assembly
read, so it is opt-in. It requires a query that can be strongly consistent — the base table
or an LSI; DynamoDB rejects consistent reads against a GSI, so pairing it with a GSI-shaped
collection index throws at make() time.
Sub-Aggregate Binding with Discriminators
Section titled “Sub-Aggregate Binding with Discriminators”When the same sub-aggregate shape appears multiple times (e.g., team1 and team2), use .with() to bind discriminator values:
team1: TeamSheetAggregate.with({ discriminator: { teamNumber: 1 } }),team2: TeamSheetAggregate.with({ discriminator: { teamNumber: 2 } }),Discriminator values are stored on each member item in DynamoDB and used during assembly to route items to the correct sub-aggregate instance.
Aggregate Operations
Section titled “Aggregate Operations”get — Read and Assemble
Section titled “get — Read and Assemble”Fetch all items in the partition, then assemble them into the domain object:
const fetched = yield* MatchAggregate.get({ id: "bgt-2025-test-1" })Assembly works by:
- Querying all items in the partition
- Discriminating items by
__edd_e__entity type + discriminator values - Traversing the graph leaves-to-root (topological sort)
- Building the Schema.Class instance at each node
Returns AggregateAssemblyError if the collection query returns incomplete or unexpected items.
create — Decompose and Write
Section titled “create — Decompose and Write”Create a new aggregate from input data. Ref fields accept IDs (not full objects):
const match = yield* MatchAggregate.create({ id: "bgt-2025-test-1", name: "AUS vs IND, 1st Test", venueId: "mcg", team1: { teamId: "aus", coachId: "mcdonald", homeTeam: true, players: [ { playerId: "cummins-01", battingPosition: 8, isCaptain: true }, { playerId: "smith-01", battingPosition: 4, isCaptain: false }, ], }, team2: { teamId: "ind", coachId: "gambhir", homeTeam: false, players: [{ playerId: "kohli-01", battingPosition: 4, isCaptain: true }], },})The create operation:
- Validates the input against the schema
- Hydrates all ref fields (fetches entities by ID, embeds domain data)
- Decomposes the domain object into individual DynamoDB items following the graph structure
- Adds partition keys, sort keys, entity type discriminators, and context fields
- Writes items in sub-aggregate transaction groups (each sub-aggregate = one
transactWriteItems)
Returns RefNotFound if a ref ID doesn’t resolve. Returns AggregateTransactionOverflow if a sub-aggregate group exceeds 100 items (DynamoDB transaction limit).
update — Diff and Write
Section titled “update — Diff and Write”Fetch the current state, apply a mutation function, diff, and write only changed sub-aggregate groups:
const updated = yield* MatchAggregate.update({ id: "bgt-2025-test-1" }, (current) => ({ ...current.state, team1: { ...current.state.team1, players: current.state.team1.players.map((ps) => ({ ...ps, isCaptain: ps.player.lastName === "Smith", })), },}))The update operation:
- Fetches the current aggregate via
get - Applies the mutation function to produce the updated domain object
- Decomposes both old and new into DynamoDB items
- Diffs at sub-aggregate group boundaries
- Writes only the groups that changed
Context field propagation: If the mutation changes a context field (a root-level field propagated to all members), all sub-aggregate groups are rewritten with the new context values.
UpdateContext
Section titled “UpdateContext”The mutation function receives a single UpdateContext object with tools for composable, type-safe immutable updates:
const cursorUpdated = yield* MatchAggregate.update({ id: "bgt-2025-test-1" }, ({ cursor }) => cursor .key("team1") .key("players") .modify((players) => players.map((ps) => ({ ...ps, isCaptain: ps.player.lastName === "Smith" })), ),)The UpdateContext provides:
| Property | Type | Description |
|---|---|---|
state | TIso | The current aggregate as a plain object (for spreads) |
cursor | Cursor<TIso> | Pre-bound optic for navigating and transforming — no need to pass state explicitly |
optic | Optic.Iso<TIso, TIso> | Composable optic for external lenses — pass state explicitly |
current | TClass | Schema.Class instance (rarely needed) |
Cursor operations (pre-bound to current state):
| Operation | Description |
|---|---|
cursor.key("field") | Focus on a required field |
cursor.optionalKey("field") | Focus on an optional field |
cursor.at(index) | Focus on an array element |
cursor.get() | Get the focused value |
cursor.replace(value) | Replace the focused value, return updated root |
cursor.modify(fn) | Apply a function to the focused value, return updated root |
Simple field update:
yield* MatchAggregate.update({ id: "bgt-2025-test-1" }, ({ cursor }) => cursor.key("name").replace("AUS vs IND, Boxing Day Test"),)Using optic + state (for external lenses):
yield* MatchAggregate.update({ id: "bgt-2025-test-1" }, ({ state, optic }) => optic.key("name").replace("AUS vs IND, 1st Test", state),)The mutation function can return either a class instance (from spreading current) or a plain object (from cursor/optic operations) — the schema decode handles both.
delete — Remove All Items
Section titled “delete — Remove All Items”Remove all items in the aggregate’s partition:
yield* MatchAggregate.delete({ id: "bgt-2025-test-1" })Queries all items in the partition and batch-deletes them.
list — Page Through Aggregates
Section titled “list — Page Through Aggregates”list needs a list index: a GSI the root item of every aggregate is written to. list reads root items from one partition of it, then assembles each one.
class TournamentMatch extends Schema.Class<TournamentMatch>("TournamentMatch")({ matchId: Schema.String, tournament: Schema.String, name: Schema.String, status: Schema.Literals(["scheduled", "completed"]),}) {}const TournamentMatchAggregate = Aggregate.make(TournamentMatch, { table: MainTable, schema: CricketSchema, pk: { field: "pk", composite: ["matchId"] }, collection: { name: "tmatch" }, list: { index: "gsi1", name: "tmatchlist", pk: { field: "gsi1pk", composite: ["tournament"] }, sk: { field: "gsi1sk", composite: ["matchId"] }, }, root: { entityType: "TournamentMatchItem" }, edges: {},})The first argument selects the partition (and, with contiguous SK composites, a prefix within it). The second carries the paging options:
| Option | Meaning |
|---|---|
limit | At most n aggregates. Accumulates across as many requests as it takes — not DynamoDB’s Limit, which bounds rows examined |
pageSize | DynamoDB’s Limit — rows examined per request. A contract on round trips, not on what comes back |
filter | FilterExpression on the root-item query. Same callback and shorthand forms as BoundQuery.filter() |
reverse | Walk the list index descending |
cursor | Resume after the last aggregate the previous call returned |
const completed = yield* TournamentMatchAggregate.list( { tournament: "bgt-2025" }, { filter: { status: "completed" }, limit: 2 },)filter is worth reaching for beyond ergonomics. list assembles each surviving root item with its own partition read, so filtering the result afterwards pays a full assembly for every aggregate it then discards. Pushing the predicate into the query means those rows are never assembled.
Because a FilterExpression runs after DynamoDB’s Limit, a request can come back empty. limit still means “this many matching aggregates”: the query keeps asking until the page fills or the key range ends. cursor is null only when genuinely exhausted.
if (completed.cursor !== null) { const nextPage = yield* TournamentMatchAggregate.list( { tournament: "bgt-2025" }, { cursor: completed.cursor, filter: { status: "completed" }, limit: 2 }, )}const latest = yield* TournamentMatchAggregate.list( { tournament: "bgt-2025" }, { filter: (t, { eq }) => eq(t.status, "completed"), limit: 1, reverse: true },)Type Extractors
Section titled “Type Extractors”| Type | Description |
|---|---|
Aggregate.Type<A> | The assembled domain type (e.g., Match) |
Aggregate.Key<A> | The partition key type |
type MatchDomain = Aggregate.Type<typeof MatchAggregate> // Matchtype MatchKey = Aggregate.Key<typeof MatchAggregate> // { id: string }Error Handling
Section titled “Error Handling”| Error | When | Properties |
|---|---|---|
AggregateAssemblyError | Read path: missing items, structural violations, or decode errors | aggregate, reason, key |
AggregateDecompositionError | Write path: schema validation or structural error during decomposition | aggregate, member, reason |
AggregateTransactionOverflow | Write path: sub-aggregate exceeds 100-item transaction limit | aggregate, subgraph, itemCount, limit |
RefNotFound | Ref hydration: referenced entity not found | entity, field, refEntity, refId |
const result = yield* MatchAggregate.get({ id: "nonexistent" }).pipe( Effect.catchTag("AggregateAssemblyError", (e) => Effect.succeed(`Assembly failed for ${e.aggregate}: ${e.reason}`), ),)Cascade Updates
Section titled “Cascade Updates”The Problem
Section titled “The Problem”When denormalized data changes at the source (e.g., a player’s name is corrected), every item that embeds that entity via DynamoModel.ref must be found and updated. Without automation, this requires per-entity-type GSIs and manual batch update logic.
Entity.cascade
Section titled “Entity.cascade”Attach a cascade to an EntityUpdate operation. After the source entity update completes, the cascade finds and updates all target items:
// #region cascade-basic const updatedPlayer = yield* db.entities.Players.update({ id: "smith-01" }) .set({ firstName: "Steven" }) .cascade({ targets: [SquadSelections] })After updating the player, the cascade:
- Identifies all ref fields on
SquadSelectionsthat referencePlayers - Queries the target entity’s GSI (whose PK composite includes the player’s ID field) to find all items embedding this player
- Updates each matching item with the new denormalized player data
Configuration
Section titled “Configuration”Entity.cascade({ targets: ReadonlyArray<Entity>, // Required: entities that embed this source via ref filter?: Record<string, unknown>, // Optional: additional filter on target items mode?: "eventual" | "transactional", // Optional: default "eventual"})Cascade Modes
Section titled “Cascade Modes”Eventual (default): Batch-updates target items. No item limit. Partial failures are possible.
// #region cascade-eventual yield* db.entities.Players.update({ id: "smith-01" }) .set({ firstName: "Steven" }) .cascade({ targets: [SquadSelections], mode: "eventual" })Transactional: Atomic update via transactWriteItems. All target items updated in a single transaction. Maximum 100 items.
// #region cascade-transactional yield* db.entities.Players.update({ id: "smith-01" }) .set({ firstName: "Steven" }) .cascade({ targets: [SquadSelections], mode: "transactional" })Cascade Prerequisites
Section titled “Cascade Prerequisites”For cascade to work, the target entity must:
- Have at least one
DynamoModel.reffield pointing to the source entity type - Have a GSI whose PK composite includes the ref’s ID field (e.g.,
byPlayerwithcomposite: ["playerId"]) - Be configured with
refsmapping the ref field to the source entity
Error Handling
Section titled “Error Handling”In eventual mode, partial failures return CascadePartialFailure:
yield* db.entities.Players.update( { id: "smith-01" }, Entity.set({ firstName: "Steven" }), Entity.cascade({ targets: [SquadSelections] }),).pipe( Effect.catchTag("CascadePartialFailure", (e) => { console.log(`${e.succeeded} updated, ${e.failed} failed`) // Retry or handle partial failure }),)| Property | Description |
|---|---|
sourceEntity | Entity type of the source (e.g., "Player") |
sourceId | ID of the source entity |
succeeded | Number of successfully updated target items |
failed | Number of failed updates |
errors | Array of individual error details |
In transactional mode, a failure rolls back the entire cascade (but not the source update — the source entity is already updated). You get TransactionCancelled instead of CascadePartialFailure.
Multiple Targets
Section titled “Multiple Targets”Cascade to multiple entity types in a single operation:
yield* db.entities.Players.update( { id: "smith-01" }, Entity.set({ firstName: "Steven" }), Entity.cascade({ targets: [SquadSelections, MatchPlayers, ContractRecords] }),)Each target is queried independently. In eventual mode, each target’s updates are batched separately.
Full Example
Section titled “Full Example”The code blocks on this page are backed by a runnable example that demonstrates all three features in a cricket match domain:
- Part 1: Entity refs — teams, players, coaches, venues as referenced entities; squad selections as the ref consumer
- Part 2: Aggregate CRUD — creating, reading, updating (spread + cursor + optic), and deleting a full match aggregate with sub-aggregates for team sheets
- Part 3: Cascade updates — updating a player name propagates to all squad selections
Run it:
# Start DynamoDB Localdocker run -p 8000:8000 amazon/dynamodb-local
# Run the guide examplenpx tsx examples/guide-aggregates.tsFor a more comprehensive example including print output and all lifecycle operations, see:
npx tsx examples/cricket.tsSee also: API Reference for complete module-by-module documentation.