Skip to content

Aggregates & Relational Patterns

This guide covers three related features for modeling rich domain relationships in DynamoDB:

  1. Entity References — Denormalized copies of related entities, with automatic ID-based inputs and hydration on write
  2. Aggregates — Graph-based composite domain objects assembled from multiple entity types sharing a partition key
  3. Cascade Updates — Propagating source entity changes to all items that embed it via refs

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

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 identifier field can be referenced via 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.

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 },
},
})

Ref fields transform across Entity’s derived types:

TypeRef field shapeExample
Entity.Input<E>ID field (derived from identifier name + “Id” suffix)teamId: string, playerId: string
Entity.Record<E>Full domain objectteam: Team, player: Player
Entity.Update<E>Optional ID fieldteamId?: string, playerId?: string
// Input accepts IDs — not full objects
yield* db.entities.SquadSelections.put({
squadId: "aus#2024-25#BGT",
selectionNumber: 1,
teamId: "aus",
playerId: "cummins-01",
squadRole: "bowler",
isCaptain: true,
})
// Record returns full domain objects
const selection = yield* db.entities.SquadSelections.get({
squadId: "aus#2024-25#BGT",
selectionNumber: 1,
})

On put and update, Entity automatically:

  1. Extracts the ID from each ref field in the input
  2. Fetches the referenced entity by ID (parallel fetches for multiple refs)
  3. Embeds the entity’s core domain data (Schema.Type — no system fields) in the DynamoDB item
  4. Returns RefNotFound if 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"
}

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 }
}

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.

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 (one or many)
  • Edge attributes — Data owned by the relationship, not the referenced entity (e.g., isCaptain, battingPosition)
  • Sub-aggregates — Reusable child aggregates that form transaction boundaries

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,
}) {}

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.

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.

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:

PropertyDescription
tableThe DynamoDB table
schemaDynamoSchema namespace for key prefixing
pkPartition key definition (field + composite attributes from the schema)
collectionCollection naming for the read path. name is required; index + sk only when assembling through a secondary index (see below)
consistentReadUse strongly consistent reads when assembling. Defaults to false
rootEntity type for the root item
contextOptional array of schema field names to propagate to all member items
timestampsOptional auto-managed created / updated attributes on every row the aggregate writes (see below)
edgesMap of schema fields to edge descriptors, ref edges, or bound sub-aggregates

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.

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.

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.

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.

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:

  1. Querying all items in the partition
  2. Discriminating items by __edd_e__ entity type + discriminator values
  3. Traversing the graph leaves-to-root (topological sort)
  4. Building the Schema.Class instance at each node

Returns AggregateAssemblyError if the collection query returns incomplete or unexpected items.

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:

  1. Validates the input against the schema
  2. Hydrates all ref fields (fetches entities by ID, embeds domain data)
  3. Decomposes the domain object into individual DynamoDB items following the graph structure
  4. Adds partition keys, sort keys, entity type discriminators, and context fields
  5. 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).

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:

  1. Fetches the current aggregate via get
  2. Applies the mutation function to produce the updated domain object
  3. Decomposes both old and new into DynamoDB items
  4. Diffs at sub-aggregate group boundaries
  5. 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.

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:

PropertyTypeDescription
stateTIsoThe current aggregate as a plain object (for spreads)
cursorCursor<TIso>Pre-bound optic for navigating and transforming — no need to pass state explicitly
opticOptic.Iso<TIso, TIso>Composable optic for external lenses — pass state explicitly
currentTClassSchema.Class instance (rarely needed)

Cursor operations (pre-bound to current state):

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

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 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:

OptionMeaning
limitAt most n aggregates. Accumulates across as many requests as it takes — not DynamoDB’s Limit, which bounds rows examined
pageSizeDynamoDB’s Limit — rows examined per request. A contract on round trips, not on what comes back
filterFilterExpression on the root-item query. Same callback and shorthand forms as BoundQuery.filter()
reverseWalk the list index descending
cursorResume 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 },
)
TypeDescription
Aggregate.Type<A>The assembled domain type (e.g., Match)
Aggregate.Key<A>The partition key type
type MatchDomain = Aggregate.Type<typeof MatchAggregate> // Match
type MatchKey = Aggregate.Key<typeof MatchAggregate> // { id: string }
ErrorWhenProperties
AggregateAssemblyErrorRead path: missing items, structural violations, or decode errorsaggregate, reason, key
AggregateDecompositionErrorWrite path: schema validation or structural error during decompositionaggregate, member, reason
AggregateTransactionOverflowWrite path: sub-aggregate exceeds 100-item transaction limitaggregate, subgraph, itemCount, limit
RefNotFoundRef hydration: referenced entity not foundentity, field, refEntity, refId
const result = yield* MatchAggregate.get({ id: "nonexistent" }).pipe(
Effect.catchTag("AggregateAssemblyError", (e) =>
Effect.succeed(`Assembly failed for ${e.aggregate}: ${e.reason}`),
),
)

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.

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:

  1. Identifies all ref fields on SquadSelections that reference Players
  2. Queries the target entity’s GSI (whose PK composite includes the player’s ID field) to find all items embedding this player
  3. Updates each matching item with the new denormalized player data
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"
})

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" })

For cascade to work, the target entity must:

  1. Have at least one DynamoModel.ref field pointing to the source entity type
  2. Have a GSI whose PK composite includes the ref’s ID field (e.g., byPlayer with composite: ["playerId"])
  3. Be configured with refs mapping the ref field to the source entity

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
}),
)
PropertyDescription
sourceEntityEntity type of the source (e.g., "Player")
sourceIdID of the source entity
succeededNumber of successfully updated target items
failedNumber of failed updates
errorsArray 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.

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.


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:

Terminal window
# Start DynamoDB Local
docker run -p 8000:8000 amazon/dynamodb-local
# Run the guide example
npx tsx examples/guide-aggregates.ts

For a more comprehensive example including print output and all lifecycle operations, see:

Terminal window
npx tsx examples/cricket.ts

See also: API Reference for complete module-by-module documentation.