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.
Step 3: Top-Level Aggregate
Section titled “Step 3: Top-Level Aggregate”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:
| Property | Description |
|---|---|
table | The DynamoDB table |
schema | DynamoSchema namespace for key prefixing |
pk | Partition key definition (field + composite attributes from the schema) |
collection | Collection index for the read path query (index name, collection name, SK definition) |
root | Entity type for the root item |
context | Optional array of schema field names to propagate to all member items |
edges | Map of schema fields to edge descriptors, ref edges, or bound sub-aggregates |
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 via a collection query, then assemble them into the domain object:
const fetched = yield* MatchAggregate.get({ id: "bgt-2025-test-1" })Assembly works by:
- Querying the collection index for 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.
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.