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.

Bind the full domain schema to a table, schema namespace, partition key, and collection index:

const MatchAggregate = Aggregate.make(Match, {
table: MainTable,
schema: CricketSchema,
pk: { field: "pk", composite: ["id"] },
collection: {
index: "lsi1",
name: "match",
sk: { field: "lsi1sk", composite: ["name"] },
},
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 index for the read path query (index name, collection name, SK definition)
rootEntity type for the root item
contextOptional array of schema field names to propagate to all member items
edgesMap of schema fields to edge descriptors, ref edges, or bound sub-aggregates

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 via a collection query, then assemble them into the domain object:

const fetched = yield* MatchAggregate.get({ id: "bgt-2025-test-1" })

Assembly works by:

  1. Querying the collection index for 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.

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.