Skip to content

Tutorial: Building a Cricket Match Manager with effect-dynamodb

Build a production-grade REST API for managing cricket matches, teams, players, and more — all backed by a single DynamoDB table. You’ll learn effect-dynamodb from the ground up: models, entities, queries, refs, cascades, and aggregates.

What you’ll build: A cricket match management system with:

  • 5 standalone entities (Team, Player, Coach, Umpire, Venue)
  • Entity references with cascade updates (SquadSelection)
  • A Match aggregate composed of two TeamSheet sub-aggregates
  • Full CRUD REST API with pagination and error handling

How this tutorial works: Rather than building all models, then all entities, then all services — we’ll build one entity end-to-end, get the server running, and validate with curl. Then we’ll add more entities iteratively, testing as we go.

Prerequisites:

  • Node.js 20+
  • pnpm 9+
  • Docker (for DynamoDB Local)
  • Basic familiarity with TypeScript and Effect

Terminal window
mkdir gamemanager && cd gamemanager
pnpm init
Terminal window
pnpm add effect@4.0.0-beta.43 @effect/platform-node@4.0.0-beta.43 effect-dynamodb ulid
pnpm add -D typescript@^5.9.0 tsx vitest@^4.1.0 @effect/vitest@4.0.0-beta.43 @biomejs/biome@^2.4.4

effect-dynamodb includes a TypeScript Language Service Plugin that enhances hover tooltips with DynamoDB operation details — showing you the underlying DynamoDB call, key structure, and index being used when you hover over operations like Teams.put(...) or Teams.query.byAll(...).

Install it as a dev dependency:

Terminal window
pnpm add -D @effect-dynamodb/language-service

The plugin is configured in tsconfig.json (next section) under compilerOptions.plugins.

VS Code setup: The plugin only activates when VS Code uses the workspace TypeScript version. Open any .ts file, click the TypeScript version in the bottom-right status bar, and select “Use Workspace Version”. Or add to .vscode/settings.json:

{
"typescript.tsdk": "node_modules/typescript/lib"
}

Create tsconfig.json:

{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"plugins": [{ "name": "@effect-dynamodb/language-service" }]
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

Update package.json (keep the dependencies from step 1.2):

{
"name": "@gamemanager/v2",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"build": "tsc",
"check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"db:create": "tsx src/create-table.ts",
"db:destroy": "tsx src/destroy-table.ts",
"dev": "tsx src/server.ts",
"start": "node dist/server.js"
}
}
Terminal window
mkdir -p src/models src/entities src/services src/api

You now have a working TypeScript project. We’ll verify compilation after adding our first source file in the next section.


Phase 2: Your First Entity End-to-End — Team

Section titled “Phase 2: Your First Entity End-to-End — Team”

Rather than defining all models up front, we’ll build one entity from model to running API, validating each step along the way.

Create branded types for entity IDs. We only need TeamId for now — we’ll add more as we build more entities:

src/models/Ids.ts
import { Schema } from "effect"
export const TeamId = Schema.String.pipe(Schema.brand("TeamId"))
export type TeamId = typeof TeamId.Type

Concept: Schema.brand — Creates a nominal type that is structurally a string but branded at the type level. When you add PlayerId later, TypeScript will treat TeamId and PlayerId as incompatible types even though both are strings at runtime. This catches mix-ups at compile time.

These branded types flow through the entire stack: Entity.Input<typeof Teams> has id: TeamId, Entity.Key<typeof Teams> has id: TeamId. Each branded ID also doubles as a Schema — use TeamId directly in HttpApiEndpoint params to decode URL strings into branded types at the API boundary.

src/models/Team.ts
import { Schema } from "effect"
class Team extends Schema.Class<Team>("Team")({
id: Schema.String,
name: Schema.String,
country: Schema.String,
ranking: Schema.Number,
}) {}

Note: Add export before class Team — the region-synced code above omits export because the backing example is a single file. In your multi-file project, all models, entities, and services need to be exported.

Two things to notice:

  1. Schema.Class — Defines a runtime-validated class. new Team({ id: "aus", name: "Australia", country: "Australia", ranking: 1 }) validates all fields.

  2. Pure domain model — Zero DynamoDB imports or annotations. The model is portable and testable, and the DynamoDB binding (key composition, identifier marking, indexes) happens at the entity level in the next section.

The entity binds your pure model to DynamoDB with indexes, key composition, and optional features:

src/entities/Teams.ts
import { DynamoModel, Entity } from "effect-dynamodb"
import { Team } from "../models/Team.js"
export const Teams = Entity.make({
model: DynamoModel.configure(Team, { id: { field: "teamId", identifier: true } }),
entityType: "Team",
primaryKey: {
pk: { field: "pk", composite: ["id"] },
sk: { field: "sk", composite: [] },
},
indexes: {
byAll: {
name: "gsi1",
pk: { field: "gsi1pk", composite: [] },
sk: { field: "gsi1sk", composite: ["country", "name"] },
},
},
})

Let’s break down what each piece does:

  • DynamoModel.configure(Team, { id: { field: "teamId", identifier: true } }) — Renames the id field to teamId in the DynamoDB item (avoiding collisions when multiple entity types share a table) and marks it as the entity’s primary business identifier so other entities can reference Teams by this field. Both the rename and the identifier marker live at the entity level so the underlying Team model stays free of DynamoDB concepts.

  • entityType: "Team" — A discriminator value stored as __edd_e__ on every item. Used to identify which entity type an item belongs to during queries.

  • primaryKey — The table’s primary key. pk is composed from the id field. sk has an empty composite (the library fills it with the entity type).

  • indexes.byAll — A GSI for listing/filtering teams. The PK composite is empty (query returns all teams), the SK composite ["country", "name"] enables hierarchical prefix filtering: filter by country alone, or country + name.

Key concept: Entity.make returns a pure entity definition — no table reference, no executable operations. The entity declares its model, primary key, indexes, and configuration. Table association happens when you register it on a Table.make({ entities: { Teams } }), which we’ll create next.

Now that we have an entity, we need a DynamoSchema (application namespace) and a Table (physical table binding) to assemble it:

src/entities/Schema.ts
import { DynamoSchema, Table } from "effect-dynamodb"
import { Teams } from "./Teams.js"
const CricketSchema = DynamoSchema.make({ name: "cricket", version: 1 })

Note: Add export before const CricketSchema — the region-synced code above omits export because the backing example is a single file. In your multi-file project, Schema.ts is imported by other modules (e.g., MatchAggregate.ts in Phase 5), so the schema must be exported.

src/entities/Schema.ts
export const MainTable = Table.make({
schema: CricketSchema,
entities: { Teams },
})

Concept: Single-table design — All entities share one DynamoDB table. The schema’s name and version become part of every key prefix ($cricket#v1#...), preventing collisions between entity types. You’ll add more entities to Table.make(...) as you build them throughout this tutorial.

Note: Entity files don’t import the table. Entities are pure definitions. The dependency flows the other way: Schema.ts imports entities and registers them on the table. In each service file, you call DynamoClient.make({ entities: { ... }, tables: { MainTable } }) listing the entities that service operates on, and the returned typed client has R = never for every operation.

Run pnpm check — the model, entity, and table should compile.

Before building the service, create shared pagination schemas that will be reused by every entity:

src/models/Pagination.ts
import { Query } from "effect-dynamodb"
import { Schema } from "effect"
import { identity } from "effect/Function"
// ---------------------------------------------------------------------------
// Query string schema — spread `.fields` into HttpApiEndpoint query definitions
// ---------------------------------------------------------------------------
export const PaginationOptions = Schema.Struct({
cursor: Schema.optionalKey(Schema.String),
count: Schema.optionalKey(Schema.NumberFromString),
reverse: Schema.optionalKey(Schema.String),
})
export type PaginationOptions = typeof PaginationOptions.Type
// ---------------------------------------------------------------------------
// Response envelope — schema factory for paginated list responses
// ---------------------------------------------------------------------------
/** Schema factory: creates a paginated response schema for any item type. */
export const PaginatedResponse = <A>(item: Schema.Schema<A>) =>
Schema.Struct({
data: Schema.Array(item),
count: Schema.Number,
cursor: Schema.NullOr(Schema.String),
})
// ---------------------------------------------------------------------------
// Query helper — apply decoded query params directly to a Query
// ---------------------------------------------------------------------------
/** Apply PaginationOptions to a Query: sets limit, order, and cursor. */
export const applyPagination = <A>(
query: Query.Query<A>,
options?: PaginationOptions,
): Query.Query<A> =>
query.pipe(
Query.limit(options?.count ?? 10),
options?.reverse === "true" ? Query.reverse : identity,
options?.cursor ? Query.startFrom(options.cursor) : identity,
)

Three things to notice:

  1. PaginationOptions — A Schema.Struct that doubles as both schema and type. Spread PaginationOptions.fields into endpoint query definitions. The HTTP framework decodes query strings (e.g., ?count=20&reverse=true) into typed values automatically.

  2. PaginatedResponse(item) — A schema factory for runtime use (e.g., in service implementations). Do not use in HttpApiEndpoint definitions — the generic function indirection exceeds TypeScript’s conditional type inference depth inside HttpApiBuilder.group, causing the layer’s R parameter to resolve to unknown instead of never. Use inline Schema.Struct({ data: Schema.Array(Entity), count: Schema.Number, cursor: Schema.NullOr(Schema.String) }) in endpoint success fields instead.

  3. applyPagination — Works directly with decoded PaginationOptions. No manual parsing — the Schema layer already decoded count from string to number via Schema.NumberFromString.

Concept: Query combinatorsQuery.limit, Query.reverse, and Query.startFrom are composable functions that modify a query description. Nothing executes until you pass the query to a bound entity terminal method like teams.fetch(query) or teams.collect(query).

Each entity’s list endpoint has its own filter fields. Define these as Schema.Struct values — the same schema drives the API endpoint query definition and the service’s filter type:

src/models/Filters.ts
import { Schema } from "effect"
export const TeamListFilter = Schema.Struct({
country: Schema.optionalKey(Schema.String),
})
export type TeamListFilter = typeof TeamListFilter.Type

Pattern: Schema as value + type — Exporting const Foo = Schema.Struct(...) and type Foo = typeof Foo.Type under the same name gives you a schema (for validation and .fields spreading) and a TypeScript type (for function signatures) from one definition.

We’ll add more filter schemas to this file as we add entities.

src/services/TeamService.ts
import { DynamoClient, Entity } from "effect-dynamodb"
import { type DateTime, Effect, Context } from "effect"
import { ulid } from "ulid"
import { Teams } from "../entities/Teams.js"
import { MainTable } from "../entities/Schema.js"
import type { TeamListFilter } from "../models/Filters.js"
import type { TeamId } from "../models/Ids.js"
import { applyPagination, type PaginationOptions } from "../models/Pagination.js"
export type { DateTime }
type CreateTeamInput = Entity.Create<typeof Teams>
type UpdateTeamInput = Entity.Update<typeof Teams>
export class TeamService extends Context.Service<TeamService>()("@gamemanager/TeamService", {
make: Effect.gen(function* () {
const db = yield* DynamoClient.make({ entities: { Teams }, tables: { MainTable } })
const teams = db.entities.Teams
return {
create: Effect.fn(function* (input: CreateTeamInput) {
const id = ulid() as TeamId
return yield* teams.put({ ...input, id })
}),
get: (id: TeamId) => teams.get({ id }),
update: (id: TeamId, updates: UpdateTeamInput) => teams.update({ id }, Entity.set(updates)),
delete: (id: TeamId) => teams.delete({ id }),
list: (filter: TeamListFilter = {}, pagination?: PaginationOptions) =>
teams.fetch(
applyPagination(Teams.query.byAll(filter), pagination),
).pipe(
Effect.map((page) => ({
data: page.items,
count: page.items.length,
cursor: page.cursor,
})),
),
}
}),
}) {}

Key patterns:

  • Context.Service — Declares a service with a tag and a make effect that produces the implementation. Consumers access it via yield* TeamService.

  • Entity.Create<typeof Teams> — Omits the identifier field (declared via DynamoModel.configure) from the input type — the common “create” payload where IDs are auto-generated.

  • Entity.Update<typeof Teams> — Partial type excluding primary key composites. Only the fields you want to change.

  • ulid() as TeamIdulid() returns string, so we cast it to the branded type. This is the one place you’ll need a cast — at the generation boundary where IDs are created.

  • DynamoClient.make({ entities, tables }) — The typed execution gateway. Resolves DynamoClient | TableConfig from context, binds the listed entities, and returns a typed client where every operation has R = never. Access bound entities via db.entities.Teams, db.entities.Players, etc., and table operations via db.tables.MainTable.create(). Each service typically only registers the entities it uses.

  • Bound entity methodsteams.get(), teams.update(), teams.delete() return Effect with R = never — no context requirements.

  • teams.fetch(query) — Executes a query descriptor and returns { items, cursor } for pagination. The entity definition (Teams) provides query descriptors via Teams.query.byAll(filter) and Teams.scan(). applyPagination applies limit, order, and cursor to the query descriptor before execution.

src/api/ApiErrors.ts
import { Schema } from "effect"
export class ApiNotFound extends Schema.ErrorClass<ApiNotFound>("ApiNotFound")({
error: Schema.Literal("NotFound"),
message: Schema.String,
}, { httpApiStatus: 404 }) {}
export class ApiBadRequest extends Schema.ErrorClass<ApiBadRequest>("ApiBadRequest")({
error: Schema.Literal("BadRequest"),
message: Schema.String,
}, { httpApiStatus: 400 }) {}
export class ApiConflict extends Schema.ErrorClass<ApiConflict>("ApiConflict")({
error: Schema.Literal("Conflict"),
message: Schema.String,
}, { httpApiStatus: 409 }) {}
export class ApiUnprocessableEntity extends Schema.ErrorClass<ApiUnprocessableEntity>("ApiUnprocessableEntity")({
error: Schema.Literal("UnprocessableEntity"),
message: Schema.String,
}, { httpApiStatus: 422 }) {}

Concept: Schema.ErrorClass for API errors — Each error uses Schema.ErrorClass — the idiomatic Effect v4 pattern for HTTP API errors. The second argument sets class-level annotations including httpApiStatus. This gives you proper Error instances with constructors (new ApiNotFound({ error: "NotFound", message: "..." })), clean JSON responses (no _tag in the wire format), and status-code-based discrimination — the HttpApi client dispatches by HTTP status code, not by _tag, so each error just needs a unique status.

src/api/TeamsApi.ts
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import { Teams } from "../entities/Teams.js"
import { TeamListFilter } from "../models/Filters.js"
import { TeamId } from "../models/Ids.js"
import { PaginationOptions } from "../models/Pagination.js"
import { Team } from "../models/Team.js"
import { ApiConflict, ApiNotFound } from "./ApiErrors.js"
export const TeamsApi = HttpApiGroup.make("teams").add(
HttpApiEndpoint.get("list", "/teams", {
query: { ...TeamListFilter.fields, ...PaginationOptions.fields },
success: Schema.Struct({
data: Schema.Array(Team),
count: Schema.Number,
cursor: Schema.NullOr(Schema.String),
}),
}),
HttpApiEndpoint.get("get", "/teams/:id", {
params: { id: TeamId },
success: Team,
error: ApiNotFound,
}),
HttpApiEndpoint.post("create", "/teams", {
payload: Teams.createSchema,
success: Team,
error: ApiConflict,
}),
HttpApiEndpoint.put("update", "/teams/:id", {
params: { id: TeamId },
payload: Teams.updateSchema,
success: Team,
error: ApiNotFound,
}),
HttpApiEndpoint.delete("remove", "/teams/:id", {
params: { id: TeamId },
error: ApiNotFound,
}),
)

Concept: HttpApiGroup / HttpApiEndpoint — Declarative API definition. Each endpoint specifies its method, path, query/params/payload schemas, success type, and error types. The framework validates inputs and serializes outputs automatically.

Teams.createSchema and Teams.updateSchema — Every entity exposes typed schemas derived from its model. createSchema is the input schema minus primary key composites (the common pattern where IDs are auto-generated). updateSchema makes all non-PK fields optional. Both preserve branded types — ref ID fields use PlayerId, TeamId, etc., not plain string. Use these directly as HttpApiEndpoint payloads to avoid manually re-specifying field schemas.

src/api/Api.ts
import type { Schema } from "effect"
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
import { TeamsApi } from "./TeamsApi.js"
export type { Schema }
export const GameManagerApi = HttpApi.make("gamemanager")
.add(TeamsApi)
.annotate(OpenApi.Title, "GameManager API")
.annotate(OpenApi.Version, "2.0.0")
src/api/App.ts
import type { ItemNotFound, UniqueConstraintViolation, Table } from "effect-dynamodb"
import { type DateTime, Effect, Layer } from "effect"
import { HttpServer } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Team } from "../models/Team.js"
import { TeamService } from "../services/TeamService.js"
import { GameManagerApi } from "./Api.js"
import { ApiConflict, ApiNotFound } from "./ApiErrors.js"
export type { Table, DateTime }
// ---------------------------------------------------------------------------
// Error helpers: catch domain errors, convert to HTTP errors
// ---------------------------------------------------------------------------
const entityNotFound =
(entity: string, id: string) =>
<A, E, R>(self: Effect.Effect<A, E | ItemNotFound, R>) =>
Effect.catchTag(
self,
"ItemNotFound",
() => Effect.fail(new ApiNotFound({ error: "NotFound", message: `${entity} not found: ${id}` })),
(e) => Effect.die(e),
)
const uniqueConflict =
(entity: string) =>
<A, E, R>(self: Effect.Effect<A, E | UniqueConstraintViolation, R>) =>
Effect.catchTag(
self,
"UniqueConstraintViolation",
() => Effect.fail(new ApiConflict({ error: "Conflict", message: `${entity} already exists (unique constraint violation)` })),
(e) => Effect.die(e),
)
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
const TeamsHandler = HttpApiBuilder.group(
GameManagerApi,
"teams",
Effect.fn(function* (handlers) {
const svc = yield* TeamService
return handlers
.handle("list", ({ query }) =>
Effect.gen(function* () {
const filter = query.country ? { country: query.country } : undefined
const result = yield* svc.list(filter, query)
return {
data: result.data.map((r) => new Team(r)),
count: result.count,
cursor: result.cursor,
}
}).pipe(Effect.orDie),
)
.handle("get", ({ params: { id } }) =>
svc.get(id).pipe(entityNotFound("Team", id)),
)
.handle("create", ({ payload }) =>
svc.create(payload).pipe(uniqueConflict("Team")),
)
.handle("update", ({ params: { id }, payload }) =>
svc.update(id, payload).pipe(entityNotFound("Team", id)),
)
.handle("remove", ({ params: { id } }) =>
svc.delete(id).pipe(entityNotFound("Team", id)),
)
}),
)
// ---------------------------------------------------------------------------
// Layer assembly
// ---------------------------------------------------------------------------
const HandlerLayers = Layer.mergeAll(
TeamsHandler,
)
const ServiceLayers = Layer.mergeAll(
Layer.effect(TeamService, TeamService.make),
)
export { GameManagerApi } from "./Api.js"
export const ApiLayer = HttpApiBuilder.layer(GameManagerApi).pipe(
Layer.provide(HandlerLayers),
Layer.provide(ServiceLayers),
Layer.provide(HttpServer.layerServices),
)

Concept: Layer assembly — Services are composed into layers. Layer.effect(TeamService, TeamService.make) creates a layer that runs TeamService.make and provides the result. Layer.mergeAll combines independent layers. Layer.provide feeds dependencies downward.

2.11 Table creation and server entry point

Section titled “2.11 Table creation and server entry point”
src/create-table.ts
import { DynamoClient } from "effect-dynamodb"
import { Console, Effect } from "effect"
import { Teams } from "./entities/Teams.js"
import { MainTable } from "./entities/Schema.js"
const program = Effect.gen(function* () {
const db = yield* DynamoClient.make({
entities: { Teams },
tables: { MainTable },
})
yield* Console.log("Creating table...")
yield* db.tables.MainTable.create()
yield* Console.log("Table created successfully")
})
const DynamoLayer = DynamoClient.layer({
region: "local",
endpoint: "http://localhost:8000",
credentials: { accessKeyId: "local", secretAccessKey: "local" },
})
const TableLayer = MainTable.layer({ name: "gamemanager" })
program.pipe(Effect.provide(DynamoLayer), Effect.provide(TableLayer), Effect.runPromise)

Note: As you add entities and aggregates throughout this tutorial, register them in the entities (and later aggregates) record passed to DynamoClient.make so db.tables.MainTable.create() derives the full GSI configuration.

src/destroy-table.ts
import { DynamoClient } from "effect-dynamodb"
import { Console, Effect } from "effect"
import { Teams } from "./entities/Teams.js"
import { MainTable } from "./entities/Schema.js"
const program = Effect.gen(function* () {
const db = yield* DynamoClient.make({
entities: { Teams },
tables: { MainTable },
})
yield* Console.log("Destroying table...")
yield* db.tables.MainTable.delete()
yield* Console.log("Table destroyed successfully")
})
const DynamoLayer = DynamoClient.layer({
region: "local",
endpoint: "http://localhost:8000",
credentials: { accessKeyId: "local", secretAccessKey: "local" },
})
const TableLayer = MainTable.layer({ name: "gamemanager" })
program.pipe(Effect.provide(DynamoLayer), Effect.provide(TableLayer), Effect.runPromise)

Concept: Layer-based DIDynamoClient.layer(...) creates a Layer that provides the DynamoDB client. MainTable.layer(...) provides the physical table name. DynamoClient.make({ entities, aggregates?, tables }) resolves both, binds the listed entities and aggregates, and returns a typed client with namespaced entities, aggregates, collections, and tables accessors — no need to inject DynamoClient directly.

src/server.ts
import { createServer } from "node:http"
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
import { DynamoClient } from "effect-dynamodb"
import { Effect, Layer } from "effect"
import { HttpMiddleware, HttpRouter, HttpServerRequest } from "effect/unstable/http"
import { ApiLayer } from "./api/App.js"
import { MainTable } from "./entities/Schema.js"
// Infrastructure layers
const DynamoLayer = DynamoClient.layer({
region: "local",
endpoint: "http://localhost:8000",
credentials: { accessKeyId: "local", secretAccessKey: "local" },
})
const TableLayer = MainTable.layer({ name: "gamemanager" })
const HttpLive = NodeHttpServer.layer(() => createServer(), { port: 3000 })
// Simple request logger
const requestLogger = HttpMiddleware.make((httpApp) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
yield* Effect.logInfo(`>>> ${request.method} ${request.url}`)
return yield* httpApp
}),
)
// Compose and launch
const AppWithInfra = Layer.provide(ApiLayer, Layer.mergeAll(DynamoLayer, TableLayer))
const Live = HttpRouter.serve(AppWithInfra, {
middleware: requestLogger,
}).pipe(Layer.provide(HttpLive))
Layer.launch(Live).pipe(NodeRuntime.runMain)

Run pnpm check to verify everything compiles. Then start DynamoDB Local, create the table, and launch the server:

Terminal window
# Start DynamoDB Local
docker run -d -p 8000:8000 amazon/dynamodb-local
# Create the table
pnpm db:create
# Start the dev server (tsx — no build needed)
pnpm dev

Scripts: pnpm db:create and pnpm db:destroy manage the table lifecycle. pnpm dev runs the server with tsx (TypeScript directly). pnpm start runs the built server (node dist/server.js) — run pnpm build first.

Test with curl:

Terminal window
# Create a team — capture the ID from the response
TEAM_ID=$(curl -s -X POST http://localhost:3000/teams \
-H "Content-Type: application/json" \
-d '{"name": "Australia", "country": "Australia", "ranking": 1}' \
| jq -r '.id')
echo "Created team: $TEAM_ID"
# List teams
curl -s http://localhost:3000/teams | jq
# Get a team by ID
curl -s http://localhost:3000/teams/$TEAM_ID | jq
# Update a team
curl -s -X PUT http://localhost:3000/teams/$TEAM_ID \
-H "Content-Type: application/json" \
-d '{"ranking": 2}' | jq
# Delete a team
curl -s -X DELETE http://localhost:3000/teams/$TEAM_ID

You have a running API with full CRUD for Teams — model validation, branded types, single-table DynamoDB, pagination, and error handling. Now let’s add more entities.


Each new entity follows the same pattern: model → entity → filter → service → API → handler → wire into App.ts. We’ll add Player (introducing shared PersonFields), Coach, Umpire, and Venue.

Several entities share enum-typed fields (gender, batting hand, player role). Centralize them in one file so every entity, filter, and curl payload uses the same set of values:

src/models/Enums.ts
import { Schema } from "effect"
// Gender — used by Player, Coach, Umpire models and their list filters.
export const GenderSchema = Schema.Literals(["Male", "Female"])
export type Gender = typeof GenderSchema.Type
// `GenderQuery` is the schema used in list filter query strings. It accepts the
// same values as `GenderSchema`; we expose it under a separate name so list
// filter schemas can evolve independently of the storage schema if needed.
export const GenderQuery = GenderSchema
export type GenderQuery = typeof GenderQuery.Type
// Batting hand — used by Player.
export const BattingHandSchema = Schema.Literals(["Left", "Right"])
export type BattingHand = typeof BattingHandSchema.Type
// Player role — used by SquadSelection (Phase 4) for the selection role.
// Values match the example file's `PlayerRole` enum.
export const PlayerRoleSchema = Schema.Literals([
"batter",
"bowler",
"all-rounder",
"wicket-keeper",
])
export type PlayerRole = typeof PlayerRoleSchema.Type

Pattern: Enums via Schema.LiteralsSchema.Literals(["Male", "Female"]) produces a runtime-validated literal union schema whose .Type is "Male" | "Female". Use the schema for runtime validation (in Schema.Class fields and Schema.Struct filters) and the type for TypeScript-only positions.

Why a separate GenderQuery? It’s an alias of GenderSchema today, but having a distinct name for the filter input lets you evolve the two independently — for example, accepting both "Male" and "male" as query input via a transformed schema, while keeping the storage representation strict.

Filters.ts (which we built in Phase 2.6 for TeamListFilter) imports GenderQuery for the new entity filters added in this phase. Update its imports to add the line below — every *ListFilter defined later in this phase relies on it:

// In src/models/Filters.ts — add this import alongside the existing `import { Schema } from "effect"`
import { GenderQuery } from "./Enums.js"

Players, coaches, and umpires share common fields. Rather than inheritance, use object spread:

src/models/Person.ts
import { Schema } from "effect"
import { GenderSchema } from "./Enums.js"
export const PersonFields = {
firstName: Schema.String,
lastName: Schema.String,
name: Schema.String,
dateOfBirth: Schema.optionalKey(Schema.Date),
gender: GenderSchema,
}

Why composition over inheritance? Each model gets its own copy of the fields. No base class to deal with — just plain objects spread into Schema.Class.

Schema.optionalKey vs Schema.optionaloptionalKey makes the property key itself optional ({ dateOfBirth?: Date }), meaning the key may be absent. optional makes the value optional ({ dateOfBirth: Date | undefined }), where the key is always present. With exactOptionalPropertyTypes: true, these are distinct types.

Add the branded ID to src/models/Ids.ts:

export const PlayerId = Schema.String.pipe(Schema.brand("PlayerId"))
export type PlayerId = typeof PlayerId.Type

Model:

src/models/Player.ts
import { Schema } from "effect"
import { BattingHandSchema } from "./Enums.js"
import { PlayerId } from "./Ids.js"
import { PersonFields } from "./Person.js"
export class Player extends Schema.Class<Player>("Player")({
id: PlayerId,
...PersonFields,
battingHand: BattingHandSchema,
bowlingStyle: Schema.optionalKey(Schema.String),
country: Schema.optionalKey(Schema.String),
}) {}

Entity:

src/entities/Players.ts
import { DynamoModel, Entity } from "effect-dynamodb"
import { Player } from "../models/Player.js"
export const Players = Entity.make({
model: DynamoModel.configure(Player, { id: { field: "playerId", identifier: true } }),
entityType: "Player",
timestamps: true,
primaryKey: {
pk: { field: "pk", composite: ["id"] },
sk: { field: "sk", composite: [] },
},
indexes: {
byAll: {
name: "gsi1",
pk: { field: "gsi1pk", composite: [] },
sk: { field: "gsi1sk", composite: ["gender", "name"] },
},
},
unique: {
player: ["gender", "name", "dateOfBirth"],
},
})

Filter — add to src/models/Filters.ts:

export const PlayerListFilter = Schema.Struct({
gender: Schema.optionalKey(GenderQuery),
})
export type PlayerListFilter = typeof PlayerListFilter.Type

Service — follows the same pattern as TeamService:

src/services/PlayerService.ts
import { DynamoClient, Entity } from "effect-dynamodb"
import { type DateTime, Effect, Context } from "effect"
import { ulid } from "ulid"
import type { PlayerListFilter } from "../models/Filters.js"
import { Players } from "../entities/Players.js"
import type { PlayerId } from "../models/Ids.js"
import { applyPagination, type PaginationOptions } from "../models/Pagination.js"
import { MainTable } from "../entities/Schema.js"
export type { DateTime }
type CreatePlayerInput = Entity.Create<typeof Players>
type UpdatePlayerInput = Entity.Update<typeof Players>
export class PlayerService extends Context.Service<PlayerService>()(
"@gamemanager/PlayerService",
{
make: Effect.gen(function* () {
const db = yield* DynamoClient.make({ entities: { Players }, tables: { MainTable } })
const players = db.entities.Players
return {
create: Effect.fn(function* (input: CreatePlayerInput) {
const id = ulid() as PlayerId
return yield* players.put({ ...input, id })
}),
get: (id: PlayerId) => players.get({ id }),
update: (id: PlayerId, updates: UpdatePlayerInput) => players.update({ id }, Entity.set(updates)),
delete: (id: PlayerId) => players.delete({ id }),
list: (filter: PlayerListFilter = {}, pagination?: PaginationOptions) =>
players.fetch(
applyPagination(Players.query.byAll(filter), pagination),
).pipe(
Effect.map((page) => ({
data: page.items,
count: page.items.length,
cursor: page.cursor,
})),
),
}
}),
},
) {}

API:

src/api/PlayersApi.ts
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import { Players } from "../entities/Players.js"
import { PlayerListFilter } from "../models/Filters.js"
import { PlayerId } from "../models/Ids.js"
import { PaginationOptions } from "../models/Pagination.js"
import { Player } from "../models/Player.js"
import { ApiConflict, ApiNotFound } from "./ApiErrors.js"
export const PlayersApi = HttpApiGroup.make("players").add(
HttpApiEndpoint.get("list", "/players", {
query: { ...PlayerListFilter.fields, ...PaginationOptions.fields },
success: Schema.Struct({
data: Schema.Array(Player),
count: Schema.Number,
cursor: Schema.NullOr(Schema.String),
}),
}),
HttpApiEndpoint.get("get", "/players/:id", {
params: { id: PlayerId },
success: Player,
error: ApiNotFound,
}),
HttpApiEndpoint.post("create", "/players", {
payload: Players.createSchema,
success: Player,
error: ApiConflict,
}),
HttpApiEndpoint.put("update", "/players/:id", {
params: { id: PlayerId },
payload: Players.updateSchema,
success: Player,
error: ApiNotFound,
}),
HttpApiEndpoint.delete("remove", "/players/:id", {
params: { id: PlayerId },
error: ApiNotFound,
}),
)

Add CoachId to src/models/Ids.ts:

export const CoachId = Schema.String.pipe(Schema.brand("CoachId"))
export type CoachId = typeof CoachId.Type

Add CoachListFilter to src/models/Filters.ts:

export const CoachListFilter = Schema.Struct({
gender: Schema.optionalKey(GenderQuery),
})
export type CoachListFilter = typeof CoachListFilter.Type

Model:

src/models/Coach.ts
import { Schema } from "effect"
import { CoachId } from "./Ids.js"
import { PersonFields } from "./Person.js"
export class Coach extends Schema.Class<Coach>("Coach")({
id: CoachId,
...PersonFields,
country: Schema.optionalKey(Schema.String),
}) {}

Entity:

src/entities/Coaches.ts
import { DynamoModel, Entity } from "effect-dynamodb"
import { Coach } from "../models/Coach.js"
export const Coaches = Entity.make({
model: DynamoModel.configure(Coach, { id: { field: "coachId", identifier: true } }),
entityType: "Coach",
timestamps: true,
primaryKey: {
pk: { field: "pk", composite: ["id"] },
sk: { field: "sk", composite: [] },
},
indexes: {
byAll: {
name: "gsi1",
pk: { field: "gsi1pk", composite: [] },
sk: { field: "gsi1sk", composite: ["gender", "name"] },
},
},
unique: {
coach: ["gender", "name", "dateOfBirth"],
},
})

Service:

src/services/CoachService.ts
import { DynamoClient, Entity } from "effect-dynamodb"
import { type DateTime, Effect, Context } from "effect"
import { ulid } from "ulid"
import { Coaches } from "../entities/Coaches.js"
import type { CoachListFilter } from "../models/Filters.js"
import type { CoachId } from "../models/Ids.js"
import { applyPagination, type PaginationOptions } from "../models/Pagination.js"
import { MainTable } from "../entities/Schema.js"
export type { DateTime }
type CreateCoachInput = Entity.Create<typeof Coaches>
type UpdateCoachInput = Entity.Update<typeof Coaches>
export class CoachService extends Context.Service<CoachService>()("@gamemanager/CoachService", {
make: Effect.gen(function* () {
const db = yield* DynamoClient.make({ entities: { Coaches }, tables: { MainTable } })
const coaches = db.entities.Coaches
return {
create: Effect.fn(function* (input: CreateCoachInput) {
const id = ulid() as CoachId
return yield* coaches.put({ ...input, id })
}),
get: (id: CoachId) => coaches.get({ id }),
update: (id: CoachId, updates: UpdateCoachInput) => coaches.update({ id }, Entity.set(updates)),
delete: (id: CoachId) => coaches.delete({ id }),
list: (filter: CoachListFilter = {}, pagination?: PaginationOptions) =>
coaches.fetch(
applyPagination(Coaches.query.byAll(filter), pagination),
).pipe(
Effect.map((page) => ({
data: page.items,
count: page.items.length,
cursor: page.cursor,
})),
),
}
}),
}) {}

API:

src/api/CoachesApi.ts
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import { Coaches } from "../entities/Coaches.js"
import { CoachListFilter } from "../models/Filters.js"
import { CoachId } from "../models/Ids.js"
import { PaginationOptions } from "../models/Pagination.js"
import { Coach } from "../models/Coach.js"
import { ApiConflict, ApiNotFound } from "./ApiErrors.js"
export const CoachesApi = HttpApiGroup.make("coaches").add(
HttpApiEndpoint.get("list", "/coaches", {
query: { ...CoachListFilter.fields, ...PaginationOptions.fields },
success: Schema.Struct({
data: Schema.Array(Coach),
count: Schema.Number,
cursor: Schema.NullOr(Schema.String),
}),
}),
HttpApiEndpoint.get("get", "/coaches/:id", {
params: { id: CoachId },
success: Coach,
error: ApiNotFound,
}),
HttpApiEndpoint.post("create", "/coaches", {
payload: Coaches.createSchema,
success: Coach,
error: ApiConflict,
}),
HttpApiEndpoint.put("update", "/coaches/:id", {
params: { id: CoachId },
payload: Coaches.updateSchema,
success: Coach,
error: ApiNotFound,
}),
HttpApiEndpoint.delete("remove", "/coaches/:id", {
params: { id: CoachId },
error: ApiNotFound,
}),
)

Add UmpireId to src/models/Ids.ts:

export const UmpireId = Schema.String.pipe(Schema.brand("UmpireId"))
export type UmpireId = typeof UmpireId.Type

Add UmpireListFilter to src/models/Filters.ts:

export const UmpireListFilter = Schema.Struct({
gender: Schema.optionalKey(GenderQuery),
})
export type UmpireListFilter = typeof UmpireListFilter.Type

Model:

src/models/Umpire.ts
import { Schema } from "effect"
import { UmpireId } from "./Ids.js"
import { PersonFields } from "./Person.js"
export class Umpire extends Schema.Class<Umpire>("Umpire")({
id: UmpireId,
...PersonFields,
country: Schema.optionalKey(Schema.String),
}) {}

Entity:

src/entities/Umpires.ts
import { DynamoModel, Entity } from "effect-dynamodb"
import { Umpire } from "../models/Umpire.js"
export const Umpires = Entity.make({
model: DynamoModel.configure(Umpire, { id: { field: "umpireId", identifier: true } }),
entityType: "Umpire",
timestamps: true,
primaryKey: {
pk: { field: "pk", composite: ["id"] },
sk: { field: "sk", composite: [] },
},
indexes: {
byAll: {
name: "gsi1",
pk: { field: "gsi1pk", composite: [] },
sk: { field: "gsi1sk", composite: ["gender", "name"] },
},
},
unique: {
umpire: ["gender", "name", "dateOfBirth"],
},
})

Service:

src/services/UmpireService.ts
import { DynamoClient, Entity } from "effect-dynamodb"
import { type DateTime, Effect, Context } from "effect"
import { ulid } from "ulid"
import { Umpires } from "../entities/Umpires.js"
import type { UmpireListFilter } from "../models/Filters.js"
import type { UmpireId } from "../models/Ids.js"
import { applyPagination, type PaginationOptions } from "../models/Pagination.js"
import { MainTable } from "../entities/Schema.js"
export type { DateTime }
type CreateUmpireInput = Entity.Create<typeof Umpires>
type UpdateUmpireInput = Entity.Update<typeof Umpires>
export class UmpireService extends Context.Service<UmpireService>()(
"@gamemanager/UmpireService",
{
make: Effect.gen(function* () {
const db = yield* DynamoClient.make({ entities: { Umpires }, tables: { MainTable } })
const umpires = db.entities.Umpires
return {
create: Effect.fn(function* (input: CreateUmpireInput) {
const id = ulid() as UmpireId
return yield* umpires.put({ ...input, id })
}),
get: (id: UmpireId) => umpires.get({ id }),
update: (id: UmpireId, updates: UpdateUmpireInput) => umpires.update({ id }, Entity.set(updates)),
delete: (id: UmpireId) => umpires.delete({ id }),
list: (filter: UmpireListFilter = {}, pagination?: PaginationOptions) =>
umpires.fetch(
applyPagination(Umpires.query.byAll(filter), pagination),
).pipe(
Effect.map((page) => ({
data: page.items,
count: page.items.length,
cursor: page.cursor,
})),
),
}
}),
},
) {}

API:

src/api/UmpiresApi.ts
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import { Umpires } from "../entities/Umpires.js"
import { UmpireListFilter } from "../models/Filters.js"
import { UmpireId } from "../models/Ids.js"
import { PaginationOptions } from "../models/Pagination.js"
import { Umpire } from "../models/Umpire.js"
import { ApiConflict, ApiNotFound } from "./ApiErrors.js"
export const UmpiresApi = HttpApiGroup.make("umpires").add(
HttpApiEndpoint.get("list", "/umpires", {
query: { ...UmpireListFilter.fields, ...PaginationOptions.fields },
success: Schema.Struct({
data: Schema.Array(Umpire),
count: Schema.Number,
cursor: Schema.NullOr(Schema.String),
}),
}),
HttpApiEndpoint.get("get", "/umpires/:id", {
params: { id: UmpireId },
success: Umpire,
error: ApiNotFound,
}),
HttpApiEndpoint.post("create", "/umpires", {
payload: Umpires.createSchema,
success: Umpire,
error: ApiConflict,
}),
HttpApiEndpoint.put("update", "/umpires/:id", {
params: { id: UmpireId },
payload: Umpires.updateSchema,
success: Umpire,
error: ApiNotFound,
}),
HttpApiEndpoint.delete("remove", "/umpires/:id", {
params: { id: UmpireId },
error: ApiNotFound,
}),
)

Add VenueId to src/models/Ids.ts:

export const VenueId = Schema.String.pipe(Schema.brand("VenueId"))
export type VenueId = typeof VenueId.Type

Add VenueListFilter to src/models/Filters.ts:

export const VenueListFilter = Schema.Struct({
country: Schema.optionalKey(Schema.String),
})
export type VenueListFilter = typeof VenueListFilter.Type

Model:

src/models/Venue.ts
import { Schema } from "effect"
import { VenueId } from "./Ids.js"
export class Venue extends Schema.Class<Venue>("Venue")({
id: VenueId,
name: Schema.String,
address: Schema.String,
city: Schema.String,
country: Schema.String,
capacity: Schema.optionalKey(Schema.Number),
}) {}

Entity:

src/entities/Venues.ts
import { DynamoModel, Entity } from "effect-dynamodb"
import { Venue } from "../models/Venue.js"
export const Venues = Entity.make({
model: DynamoModel.configure(Venue, { id: { field: "venueId", identifier: true } }),
entityType: "Venue",
timestamps: true,
primaryKey: {
pk: { field: "pk", composite: ["id"] },
sk: { field: "sk", composite: [] },
},
indexes: {
byAll: {
name: "gsi1",
pk: { field: "gsi1pk", composite: [] },
sk: { field: "gsi1sk", composite: ["country", "city", "name"] },
},
},
unique: {
venue: ["name", "country"],
},
})

Service:

src/services/VenueService.ts
import { DynamoClient, Entity } from "effect-dynamodb"
import { type DateTime, Effect, Context } from "effect"
import { ulid } from "ulid"
import { Venues } from "../entities/Venues.js"
import type { VenueListFilter } from "../models/Filters.js"
import type { VenueId } from "../models/Ids.js"
import { applyPagination, type PaginationOptions } from "../models/Pagination.js"
import { MainTable } from "../entities/Schema.js"
export type { DateTime }
type CreateVenueInput = Entity.Create<typeof Venues>
type UpdateVenueInput = Entity.Update<typeof Venues>
export class VenueService extends Context.Service<VenueService>()("@gamemanager/VenueService", {
make: Effect.gen(function* () {
const db = yield* DynamoClient.make({ entities: { Venues }, tables: { MainTable } })
const venues = db.entities.Venues
return {
create: Effect.fn(function* (input: CreateVenueInput) {
const id = ulid() as VenueId
return yield* venues.put({ ...input, id })
}),
get: (id: VenueId) => venues.get({ id }),
update: (id: VenueId, updates: UpdateVenueInput) => venues.update({ id }, Entity.set(updates)),
delete: (id: VenueId) => venues.delete({ id }),
list: (filter: VenueListFilter = {}, pagination?: PaginationOptions) =>
venues.fetch(
applyPagination(Venues.query.byAll(filter), pagination),
).pipe(
Effect.map((page) => ({
data: page.items,
count: page.items.length,
cursor: page.cursor,
})),
),
}
}),
}) {}

API:

src/api/VenuesApi.ts
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import { Venues } from "../entities/Venues.js"
import { VenueListFilter } from "../models/Filters.js"
import { VenueId } from "../models/Ids.js"
import { PaginationOptions } from "../models/Pagination.js"
import { Venue } from "../models/Venue.js"
import { ApiConflict, ApiNotFound } from "./ApiErrors.js"
export const VenuesApi = HttpApiGroup.make("venues").add(
HttpApiEndpoint.get("list", "/venues", {
query: { ...VenueListFilter.fields, ...PaginationOptions.fields },
success: Schema.Struct({
data: Schema.Array(Venue),
count: Schema.Number,
cursor: Schema.NullOr(Schema.String),
}),
}),
HttpApiEndpoint.get("get", "/venues/:id", {
params: { id: VenueId },
success: Venue,
error: ApiNotFound,
}),
HttpApiEndpoint.post("create", "/venues", {
payload: Venues.createSchema,
success: Venue,
error: ApiConflict,
}),
HttpApiEndpoint.put("update", "/venues/:id", {
params: { id: VenueId },
payload: Venues.updateSchema,
success: Venue,
error: ApiNotFound,
}),
HttpApiEndpoint.delete("remove", "/venues/:id", {
params: { id: VenueId },
error: ApiNotFound,
}),
)

First, register the new entities on the table. Update Schema.ts:

src/entities/Schema.ts
import { DynamoSchema, Table } from "effect-dynamodb"
import { Coaches } from "./Coaches.js"
import { Players } from "./Players.js"
import { Teams } from "./Teams.js"
import { Umpires } from "./Umpires.js"
import { Venues } from "./Venues.js"
export const CricketSchema = DynamoSchema.make({ name: "cricket", version: 1 })
export const MainTable = Table.make({ schema: CricketSchema, entities: { Teams, Players, Coaches, Umpires, Venues } })

Growing the table — Each time you add entities, register them in the entities record on Table.make(...). You also need to add them to the entities record passed to DynamoClient.make(...) in any service or script that should access them — most notably create-table.ts (so db.tables.MainTable.create() derives their GSIs) and the App.ts handler layer.

Update Api.ts to add all groups:

src/api/Api.ts
import type { Schema } from "effect"
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
import { TeamsApi } from "./TeamsApi.js"
import { PlayersApi } from "./PlayersApi.js"
import { CoachesApi } from "./CoachesApi.js"
import { UmpiresApi } from "./UmpiresApi.js"
import { VenuesApi } from "./VenuesApi.js"
export type { Schema }
export const GameManagerApi = HttpApi.make("gamemanager")
.add(TeamsApi)
.add(PlayersApi)
.add(CoachesApi)
.add(UmpiresApi)
.add(VenuesApi)
.annotate(OpenApi.Title, "GameManager API")
.annotate(OpenApi.Version, "2.0.0")

Update App.ts — add handler and service layers for each new entity. Each handler follows the same pattern as TeamsHandler. Here’s the full updated file:

src/api/App.ts
import type { ItemNotFound, UniqueConstraintViolation, Table } from "effect-dynamodb"
import { type DateTime, Effect, Layer } from "effect"
import { HttpServer } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Coach } from "../models/Coach.js"
import { Player } from "../models/Player.js"
import { Team } from "../models/Team.js"
import { Umpire } from "../models/Umpire.js"
import { Venue } from "../models/Venue.js"
import { CoachService } from "../services/CoachService.js"
import { PlayerService } from "../services/PlayerService.js"
import { TeamService } from "../services/TeamService.js"
import { UmpireService } from "../services/UmpireService.js"
import { VenueService } from "../services/VenueService.js"
import { GameManagerApi } from "./Api.js"
import { ApiConflict, ApiNotFound } from "./ApiErrors.js"
export type { Table, DateTime }
// ---------------------------------------------------------------------------
// Error helpers: catch domain errors, convert to HTTP errors
// ---------------------------------------------------------------------------
const entityNotFound =
(entity: string, id: string) =>
<A, E, R>(self: Effect.Effect<A, E | ItemNotFound, R>) =>
Effect.catchTag(
self,
"ItemNotFound",
() => Effect.fail(new ApiNotFound({ error: "NotFound", message: `${entity} not found: ${id}` })),
(e) => Effect.die(e),
)
const uniqueConflict =
(entity: string) =>
<A, E, R>(self: Effect.Effect<A, E | UniqueConstraintViolation, R>) =>
Effect.catchTag(
self,
"UniqueConstraintViolation",
() => Effect.fail(new ApiConflict({ error: "Conflict", message: `${entity} already exists (unique constraint violation)` })),
(e) => Effect.die(e),
)
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
const TeamsHandler = HttpApiBuilder.group(
GameManagerApi,
"teams",
Effect.fn(function* (handlers) {
const svc = yield* TeamService
return handlers
.handle("list", ({ query }) =>
Effect.gen(function* () {
const filter = query.country ? { country: query.country } : undefined
const result = yield* svc.list(filter, query)
return {
data: result.data.map((r) => new Team(r)),
count: result.count,
cursor: result.cursor,
}
}).pipe(Effect.orDie),
)
.handle("get", ({ params: { id } }) =>
svc.get(id).pipe(entityNotFound("Team", id)),
)
.handle("create", ({ payload }) =>
svc.create(payload).pipe(uniqueConflict("Team")),
)
.handle("update", ({ params: { id }, payload }) =>
svc.update(id, payload).pipe(entityNotFound("Team", id)),
)
.handle("remove", ({ params: { id } }) =>
svc.delete(id).pipe(entityNotFound("Team", id)),
)
}),
)
const PlayersHandler = HttpApiBuilder.group(
GameManagerApi,
"players",
Effect.fn(function* (handlers) {
const svc = yield* PlayerService
return handlers
.handle("list", ({ query }) =>
Effect.gen(function* () {
const filter = query.gender ? { gender: query.gender } : undefined
const result = yield* svc.list(filter, query)
return {
data: result.data.map((r) => new Player(r)),
count: result.count,
cursor: result.cursor,
}
}).pipe(Effect.orDie),
)
.handle("get", ({ params: { id } }) =>
svc.get(id).pipe(entityNotFound("Player", id)),
)
.handle("create", ({ payload }) =>
svc.create(payload).pipe(uniqueConflict("Player")),
)
.handle("update", ({ params: { id }, payload }) =>
svc.update(id, payload).pipe(entityNotFound("Player", id)),
)
.handle("remove", ({ params: { id } }) =>
svc.delete(id).pipe(entityNotFound("Player", id)),
)
}),
)
const CoachesHandler = HttpApiBuilder.group(
GameManagerApi,
"coaches",
Effect.fn(function* (handlers) {
const svc = yield* CoachService
return handlers
.handle("list", ({ query }) =>
Effect.gen(function* () {
const filter = query.gender ? { gender: query.gender } : undefined
const result = yield* svc.list(filter, query)
return {
data: result.data.map((r) => new Coach(r)),
count: result.count,
cursor: result.cursor,
}
}).pipe(Effect.orDie),
)
.handle("get", ({ params: { id } }) =>
svc.get(id).pipe(entityNotFound("Coach", id)),
)
.handle("create", ({ payload }) =>
svc.create(payload).pipe(uniqueConflict("Coach")),
)
.handle("update", ({ params: { id }, payload }) =>
svc.update(id, payload).pipe(entityNotFound("Coach", id)),
)
.handle("remove", ({ params: { id } }) =>
svc.delete(id).pipe(entityNotFound("Coach", id)),
)
}),
)
const UmpiresHandler = HttpApiBuilder.group(
GameManagerApi,
"umpires",
Effect.fn(function* (handlers) {
const svc = yield* UmpireService
return handlers
.handle("list", ({ query }) =>
Effect.gen(function* () {
const filter = query.gender ? { gender: query.gender } : undefined
const result = yield* svc.list(filter, query)
return {
data: result.data.map((r) => new Umpire(r)),
count: result.count,
cursor: result.cursor,
}
}).pipe(Effect.orDie),
)
.handle("get", ({ params: { id } }) =>
svc.get(id).pipe(entityNotFound("Umpire", id)),
)
.handle("create", ({ payload }) =>
svc.create(payload).pipe(uniqueConflict("Umpire")),
)
.handle("update", ({ params: { id }, payload }) =>
svc.update(id, payload).pipe(entityNotFound("Umpire", id)),
)
.handle("remove", ({ params: { id } }) =>
svc.delete(id).pipe(entityNotFound("Umpire", id)),
)
}),
)
const VenuesHandler = HttpApiBuilder.group(
GameManagerApi,
"venues",
Effect.fn(function* (handlers) {
const svc = yield* VenueService
return handlers
.handle("list", ({ query }) =>
Effect.gen(function* () {
const filter = query.country ? { country: query.country } : undefined
const result = yield* svc.list(filter, query)
return {
data: result.data.map((r) => new Venue(r)),
count: result.count,
cursor: result.cursor,
}
}).pipe(Effect.orDie),
)
.handle("get", ({ params: { id } }) =>
svc.get(id).pipe(entityNotFound("Venue", id)),
)
.handle("create", ({ payload }) =>
svc.create(payload).pipe(uniqueConflict("Venue")),
)
.handle("update", ({ params: { id }, payload }) =>
svc.update(id, payload).pipe(entityNotFound("Venue", id)),
)
.handle("remove", ({ params: { id } }) =>
svc.delete(id).pipe(entityNotFound("Venue", id)),
)
}),
)
// ---------------------------------------------------------------------------
// Layer assembly
// ---------------------------------------------------------------------------
const HandlerLayers = Layer.mergeAll(
TeamsHandler,
PlayersHandler,
CoachesHandler,
UmpiresHandler,
VenuesHandler,
)
const ServiceLayers = Layer.mergeAll(
Layer.effect(TeamService, TeamService.make),
Layer.effect(PlayerService, PlayerService.make),
Layer.effect(CoachService, CoachService.make),
Layer.effect(UmpireService, UmpireService.make),
Layer.effect(VenueService, VenueService.make),
)
export { GameManagerApi } from "./Api.js"
export const ApiLayer = HttpApiBuilder.layer(GameManagerApi).pipe(
Layer.provide(HandlerLayers),
Layer.provide(ServiceLayers),
Layer.provide(HttpServer.layerServices),
)

Pattern: Repetitive handlers are intentional. Each handler follows the same structure: access service → handle list/get/create/update/remove with error mapping. The repetition is a feature — each handler is self-contained and easy to understand. Resist the urge to abstract a “generic CRUD handler” — the filter logic differs per entity (gender vs country), and as your app grows, handlers will diverge further (e.g., Match handlers are much more complex).

Update create-table.ts to register the four new entities so db.tables.MainTable.create() derives their GSIs:

// In create-table.ts — add the new entities
import { Players } from "./entities/Players.js"
import { Coaches } from "./entities/Coaches.js"
import { Umpires } from "./entities/Umpires.js"
import { Venues } from "./entities/Venues.js"
const db = yield* DynamoClient.make({
entities: { Teams, Players, Coaches, Umpires, Venues },
tables: { MainTable },
})

Run pnpm check, then recreate the table (delete the old one first or use a fresh DynamoDB Local) and restart the server:

Terminal window
# Create a player — capture the ID
PLAYER_ID=$(curl -s -X POST http://localhost:3000/players \
-H "Content-Type: application/json" \
-d '{
"firstName": "Steve",
"lastName": "Smith",
"name": "Steve Smith",
"gender": "Male",
"battingHand": "Right",
"country": "AUS"
}' | jq -r '.id')
echo "Created player: $PLAYER_ID"
# List players
curl -s http://localhost:3000/players | jq
# Create a venue — capture the ID
VENUE_ID=$(curl -s -X POST http://localhost:3000/venues \
-H "Content-Type: application/json" \
-d '{
"name": "Melbourne Cricket Ground",
"address": "Brunton Ave",
"city": "Melbourne",
"country": "AUS",
"capacity": 100024
}' | jq -r '.id')
echo "Created venue: $VENUE_ID"
# Filter venues by country
curl -s "http://localhost:3000/venues?country=AUS" | jq

You now have 5 standalone entities with full CRUD, all sharing a single DynamoDB table.


Now let’s add SquadSelection — an entity that references other entities (Player and Team). When a Player or Team is updated, their denormalized data in SquadSelections should update too.

Add SquadSelectionId to src/models/Ids.ts:

export const SquadSelectionId = Schema.String.pipe(Schema.brand("SquadSelectionId"))
export type SquadSelectionId = typeof SquadSelectionId.Type
src/models/SquadSelection.ts
import { DynamoModel } from "effect-dynamodb"
import { Schema } from "effect"
import { PlayerRoleSchema } from "./Enums.js"
import { Player } from "./Player.js"
import { Team } from "./Team.js"
// SquadSelection — a player selected to a team's squad for a series/season.
class SquadSelection extends Schema.Class<SquadSelection>("SquadSelection")({
id: Schema.String,
team: Team.pipe(DynamoModel.ref),
player: Player.pipe(DynamoModel.ref),
season: Schema.String,
series: Schema.String,
selectionNumber: Schema.Number,
squadRole: PlayerRoleSchema,
isCaptain: Schema.Boolean,
isViceCaptain: Schema.Boolean,
}) {}

Note: Add export before class SquadSelection — the region-synced code above omits export because the backing example is a single file.

Two things to notice:

  1. DynamoModel.ref on team and player — marks them as reference fields. In the input schema, these become teamId and playerId (you provide the ID). In the record schema, they contain the full Team and Player objects (hydrated on read). Unlike Phase 2’s Team, this model is not fully pure: ref fields are inherently DynamoDB-aware because they declare a cross-entity link the library has to walk on read and write. The identifier is still configured at the entity level in the next section.
  2. Separate season and series fields — instead of encoding them in a composite squadId string, each dimension is a proper field that can participate in GSI composites independently.

4.2 SquadSelections entity with refs and cascade indexes

Section titled “4.2 SquadSelections entity with refs and cascade indexes”
src/entities/SquadSelections.ts
import { DynamoModel, Entity } from "effect-dynamodb"
import { SquadSelection } from "../models/SquadSelection.js"
import { Players } from "./Players.js"
import { Teams } from "./Teams.js"
const SquadSelections = Entity.make({
model: DynamoModel.configure(SquadSelection, { id: { field: "selectionId", identifier: true } }),
entityType: "SquadSelection",
primaryKey: {
pk: { field: "pk", composite: ["id"] },
sk: { field: "sk", composite: [] },
},
indexes: {
byTeamSeries: {
name: "gsi1",
pk: { field: "gsi1pk", composite: ["teamId", "season", "series"] },
sk: { field: "gsi1sk", composite: ["selectionNumber"] },
},
byPlayer: {
name: "gsi2",
pk: { field: "gsi2pk", composite: ["playerId"] },
sk: { field: "gsi2sk", composite: ["season", "series"] },
},
// Single-composite GSI keyed on `teamId` — required so `Entity.cascade`
// can fan out from a Team update to every SquadSelection that embeds it.
byTeam: {
name: "gsi3",
pk: { field: "gsi3pk", composite: ["teamId"] },
sk: { field: "gsi3sk", composite: [] },
},
},
refs: {
team: { entity: Teams },
player: { entity: Players },
},
})

Note: Add export before const SquadSelections — the region-synced code above omits it because the backing example is a single file.

Key design decisions:

  • DynamoModel.configure(SquadSelection, { id: { field: "selectionId", identifier: true } }) — Renames id to selectionId in DynamoDB (keeping the REST-friendly single-ID primary key) and marks the field as the entity’s primary business identifier.
  • refs — Maps ref fields to their source entities. { entity: Teams } tells the library to hydrate the team field by looking up the referenced Team entity.
  • byTeamSeries index — GSI for “all selections for a team in a series”. PK composites ["teamId", "season", "series"] enable hierarchical prefix queries.
  • byPlayer index — GSI for “all squads a player has been selected for”. PK by playerId, SK by season + series. This is also the single-composite GSI on playerId that Entity.cascade uses when a Player is updated.
  • byTeam index — GSI keyed exactly on teamId. The cascade engine needs a GSI whose PK composite is exactly [refIdField] for each ref it cascades through; byTeamSeries doesn’t qualify because its PK composite is ["teamId", "season", "series"]. Without this dedicated byTeam GSI, Players.update(..., Entity.cascade({ targets: [SquadSelections] })) would work but Teams.update(..., Entity.cascade({ targets: [SquadSelections] })) would fail with CascadePartialFailure: no usable GSI.

Add the import and update the method:

// In TeamService — add import:
import { SquadSelections } from "../entities/SquadSelections.js"
// In TeamService — update method becomes:
update: Effect.fn(function* (id: TeamId, updates: UpdateTeamInput) {
const current = yield* teams.get({ id })
return yield* teams.update({ id },
Entity.set({ ...current, ...updates }),
Entity.cascade({ targets: [SquadSelections] }),
)
}),

Entity.cascade({ targets: [SquadSelections] }) — After updating the Team, automatically finds and updates all SquadSelection items that reference this team, refreshing the denormalized Team data within them.

Same pattern:

// In PlayerService — add import:
import { SquadSelections } from "../entities/SquadSelections.js"
// In PlayerService — update method becomes:
update: Effect.fn(function* (id: PlayerId, updates: UpdatePlayerInput) {
const current = yield* players.get({ id })
return yield* players.update({ id },
Entity.set({ ...current, ...updates }),
Entity.cascade({ targets: [SquadSelections] }),
)
}),

Add SquadListFilter to src/models/Filters.ts:

export const SquadListFilter = Schema.Struct({
season: Schema.optionalKey(Schema.String),
})
export type SquadListFilter = typeof SquadListFilter.Type

Service:

src/services/SquadSelectionService.ts
import { DynamoClient, Entity } from "effect-dynamodb"
import { type DateTime, Effect, Context } from "effect"
import { ulid } from "ulid"
import { SquadSelections } from "../entities/SquadSelections.js"
import type { SquadListFilter } from "../models/Filters.js"
import type { PlayerId, SquadSelectionId } from "../models/Ids.js"
import { applyPagination, type PaginationOptions } from "../models/Pagination.js"
import { MainTable } from "../entities/Schema.js"
export type { DateTime }
type CreateSquadSelectionInput = Entity.Create<typeof SquadSelections>
export class SquadSelectionService extends Context.Service<SquadSelectionService>()(
"@gamemanager/SquadSelectionService",
{
make: Effect.gen(function* () {
const db = yield* DynamoClient.make({ entities: { SquadSelections }, tables: { MainTable } })
const squads = db.entities.SquadSelections
return {
create: Effect.fn(function* (input: CreateSquadSelectionInput) {
const id = ulid() as SquadSelectionId
return yield* squads.put({ ...input, id })
}),
get: (id: SquadSelectionId) => squads.get({ id }),
delete: (id: SquadSelectionId) => squads.delete({ id }),
// List by player — uses the `byPlayer` GSI for an efficient single-partition query.
listByPlayer: (playerId: PlayerId, pagination?: PaginationOptions) =>
squads.fetch(
applyPagination(SquadSelections.query.byPlayer({ playerId }), pagination),
).pipe(
Effect.map((page) => ({
data: page.items,
count: page.items.length,
cursor: page.cursor,
})),
),
// List all squad selections — falls back to a table scan because there
// is no GSI keyed on `season` alone. For production workloads where the
// season filter is required, define a dedicated GSI and switch this to
// `SquadSelections.query.bySeason(...)`.
list: (_filter: SquadListFilter = {}, pagination?: PaginationOptions) =>
squads.fetch(applyPagination(SquadSelections.scan(), pagination)).pipe(
Effect.map((page) => ({
data: page.items,
count: page.items.length,
cursor: page.cursor,
})),
),
}
}),
},
) {}

No update method — SquadSelections are typically created and deleted, not updated. If you need to change a selection, delete and recreate it. The cascade mechanism handles updates to the referenced entities (Team, Player), not to the selection itself.

list uses scan()SquadSelections.scan() returns a Query.Query descriptor that walks the entire table for items matching the SquadSelection entity type. We pass it through applyPagination (which operates on Query.Query, not BoundQuery) so the endpoint still respects count/cursor/reverse. Filtering by season is not supported by this scan path because there is no GSI keyed by season — a real production deployment would either add a dedicated GSI or perform server-side filtering using Entity.filter.

API:

src/api/SquadSelectionsApi.ts
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import { SquadSelections } from "../entities/SquadSelections.js"
import { SquadListFilter } from "../models/Filters.js"
import { SquadSelectionId } from "../models/Ids.js"
import { PaginationOptions } from "../models/Pagination.js"
import { SquadSelection } from "../models/SquadSelection.js"
import { ApiNotFound, ApiUnprocessableEntity } from "./ApiErrors.js"
export const SquadSelectionsApi = HttpApiGroup.make("squads").add(
HttpApiEndpoint.get("list", "/squads", {
query: { ...SquadListFilter.fields, ...PaginationOptions.fields },
success: Schema.Struct({
data: Schema.Array(SquadSelection),
count: Schema.Number,
cursor: Schema.NullOr(Schema.String),
}),
}),
HttpApiEndpoint.get("get", "/squads/:id", {
params: { id: SquadSelectionId },
success: SquadSelection,
error: ApiNotFound,
}),
HttpApiEndpoint.post("create", "/squads", {
payload: SquadSelections.createSchema,
success: SquadSelection,
error: ApiUnprocessableEntity,
}),
HttpApiEndpoint.delete("remove", "/squads/:id", {
params: { id: SquadSelectionId },
error: ApiNotFound,
}),
)

SquadSelections.createSchema — The create schema for a ref-aware entity automatically includes playerId: PlayerId and teamId: TeamId (branded ID types derived from the ref entities’ identifier fields). The API consumer provides IDs; the library hydrates them on read, so the success: SquadSelection response includes full Team and Player objects.

Register SquadSelections on the table:

src/entities/Schema.ts
import { DynamoSchema, Table } from "effect-dynamodb"
import { Coaches } from "./Coaches.js"
import { Players } from "./Players.js"
import { SquadSelections } from "./SquadSelections.js"
import { Teams } from "./Teams.js"
import { Umpires } from "./Umpires.js"
import { Venues } from "./Venues.js"
export const CricketSchema = DynamoSchema.make({ name: "cricket", version: 1 })
export const MainTable = Table.make({
schema: CricketSchema,
entities: { Teams, Players, Coaches, Umpires, Venues, SquadSelections },
})

Update Api.ts to add the SquadSelectionsApi group:

src/api/Api.ts
import type { Schema } from "effect"
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
import { CoachesApi } from "./CoachesApi.js"
import { PlayersApi } from "./PlayersApi.js"
import { SquadSelectionsApi } from "./SquadSelectionsApi.js"
import { TeamsApi } from "./TeamsApi.js"
import { UmpiresApi } from "./UmpiresApi.js"
import { VenuesApi } from "./VenuesApi.js"
export type { Schema }
export const GameManagerApi = HttpApi.make("gamemanager")
.add(TeamsApi)
.add(PlayersApi)
.add(CoachesApi)
.add(UmpiresApi)
.add(VenuesApi)
.add(SquadSelectionsApi)
.annotate(OpenApi.Title, "GameManager API")
.annotate(OpenApi.Version, "2.0.0")

Update App.ts — add the SquadSelectionsHandler and service. Add a refNotFound error helper for ref hydration failures, and import ApiUnprocessableEntity:

// In App.ts — add imports:
import type { RefNotFound } from "effect-dynamodb"
import { SquadSelectionService } from "../services/SquadSelectionService.js"
import { SquadSelection } from "../models/SquadSelection.js"
import { ApiUnprocessableEntity } from "./ApiErrors.js"
// In App.ts — add error helper (after uniqueConflict):
const refNotFound = <A, E, R>(self: Effect.Effect<A, E | RefNotFound, R>) =>
Effect.catchTag(
self,
"RefNotFound",
(caught) => {
const e = caught as RefNotFound
return Effect.fail(new ApiUnprocessableEntity({ error: "UnprocessableEntity", message: `${e.refEntity} not found: ${e.refId}` }))
},
(e) => Effect.die(e),
)
// In App.ts — add handler (before HandlerLayers):
const SquadSelectionsHandler = HttpApiBuilder.group(
GameManagerApi,
"squads",
Effect.fn(function* (handlers) {
const svc = yield* SquadSelectionService
return handlers
.handle("list", ({ query }) =>
Effect.gen(function* () {
const filter = query.season ? { season: query.season } : undefined
const result = yield* svc.list(filter, query)
return {
data: result.data.map((r) => new SquadSelection(r)),
count: result.count,
cursor: result.cursor,
}
}).pipe(Effect.orDie),
)
.handle("get", ({ params: { id } }) =>
svc.get(id).pipe(entityNotFound("SquadSelection", id)),
)
.handle("create", ({ payload }) =>
svc.create(payload).pipe(refNotFound),
)
.handle("remove", ({ params: { id } }) =>
svc.delete(id).pipe(entityNotFound("SquadSelection", id)),
)
}),
)
// In App.ts — update HandlerLayers and ServiceLayers:
const HandlerLayers = Layer.mergeAll(
TeamsHandler,
PlayersHandler,
CoachesHandler,
UmpiresHandler,
VenuesHandler,
SquadSelectionsHandler,
)
const ServiceLayers = Layer.mergeAll(
Layer.effect(TeamService, TeamService.make),
Layer.effect(PlayerService, PlayerService.make),
Layer.effect(CoachService, CoachService.make),
Layer.effect(UmpireService, UmpireService.make),
Layer.effect(VenueService, VenueService.make),
Layer.effect(SquadSelectionService, SquadSelectionService.make),
)

Update create-table.ts to register SquadSelections so db.tables.MainTable.create() adds the new GSIs (gsi1 for byTeamSeries, gsi2 for byPlayer, and gsi3 for the dedicated byTeam cascade index):

// In create-table.ts — add SquadSelections to the entities record
import { SquadSelections } from "./entities/SquadSelections.js"
const db = yield* DynamoClient.make({
entities: { Teams, Players, Coaches, Umpires, Venues, SquadSelections },
tables: { MainTable },
})

Recreate the table, restart the server, and test. First create a team and player to reference (or reuse IDs from Phase 3):

Terminal window
# Create a team and player to reference
TEAM_ID=$(curl -s -X POST http://localhost:3000/teams \
-H "Content-Type: application/json" \
-d '{"name": "Australia", "country": "Australia", "ranking": 1}' \
| jq -r '.id')
PLAYER_ID=$(curl -s -X POST http://localhost:3000/players \
-H "Content-Type: application/json" \
-d '{
"firstName": "Steve", "lastName": "Smith", "name": "Steve Smith",
"gender": "Male", "battingHand": "Right"
}' | jq -r '.id')
# Create a squad selection — the response includes full Team and Player objects.
# Note that SquadSelection requires every business field; the API rejects
# incomplete payloads with a 422.
SELECTION_ID=$(curl -s -X POST http://localhost:3000/squads \
-H "Content-Type: application/json" \
-d "{
\"teamId\": \"$TEAM_ID\",
\"playerId\": \"$PLAYER_ID\",
\"season\": \"2024-25\",
\"series\": \"BGT\",
\"selectionNumber\": 1,
\"squadRole\": \"batter\",
\"isCaptain\": false,
\"isViceCaptain\": false
}" | jq -r '.id')
echo "Created selection: $SELECTION_ID"
# List squad selections — note the hydrated team and player objects
curl -s http://localhost:3000/squads | jq
# Now update the team name — cascade propagates to squad selections
curl -s -X PUT http://localhost:3000/teams/$TEAM_ID \
-H "Content-Type: application/json" \
-d '{"name": "Australia Cricket"}' | jq
# Check the squad selection — the team data is automatically updated
curl -s http://localhost:3000/squads/$SELECTION_ID | jq '.team.name'
# → "Australia Cricket"

This is the most powerful feature of effect-dynamodb. A Match is a graph of related entities stored as multiple DynamoDB items but accessed as a single domain object.

A Match contains two TeamSheets (one per team). Each TeamSheet references a Team, a Coach, and an array of PlayerSheets:

src/models/Match.ts
import { DynamoModel } from "effect-dynamodb"
import { Schema } from "effect"
import { Coach } from "./Coach.js"
import { Player } from "./Player.js"
import { Team } from "./Team.js"
import { Venue } from "./Venue.js"
// Aggregate domain schemas
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,
}) {}

The region-synced code above defines PlayerSheet, TeamSheet, and Match in a single file (src/models/Match.ts) without export keywords because the backing example is one file. In your multi-file project, every class needs export so other modules (MatchAggregate.ts, MatchService.ts, MatchesApi.ts) can import them. Add export before each class:

// src/models/Match.ts — add `export` before each class declaration
export class PlayerSheet extends Schema.Class<PlayerSheet>("PlayerSheet")(/* ... */) {}
export class TeamSheet extends Schema.Class<TeamSheet>("TeamSheet")(/* ... */) {}
export class Match extends Schema.Class<Match>("Match")(/* ... */) {}

We also need a small custom schema for the update endpoint. Aggregate updates operate on domain objects (team1: TeamSheet), but renaming a match only needs the new name — and the API should not require resending the entire match graph. Define a partial update payload alongside the model:

// src/models/Match.ts — add at the bottom
/** Partial update payload for matches — fields the API exposes for in-place edits */
export const MatchUpdatePayload = Schema.Struct({
name: Schema.optionalKey(Schema.String),
})
export type MatchUpdatePayload = typeof MatchUpdatePayload.Type

Why a custom payload schema? MatchAggregate.updateSchema accepts the full domain object with refs and edges. For a REST update endpoint we usually want to expose a small set of in-place fields (rename, change start time, etc.) without re-sending team sheets. The service is responsible for translating the partial payload into a cursor mutation against the aggregate.

Extending this pattern. Later you can grow MatchUpdatePayload to cover more fields (startDate, result, tossId, etc.) and add validation via Schema.makeFilter + Match.check(...). For this tutorial we keep the surface area small so the example file and the doc stay aligned. See Next Steps at the end of the tutorial for ideas.

This is where the magic happens. The aggregate defines how a complex domain object decomposes into DynamoDB items and reassembles:

src/entities/MatchAggregate.ts
import { Aggregate } from "effect-dynamodb"
import { Match, TeamSheet } from "../models/Match.js"
import { Coaches } from "./Coaches.js"
import { Players } from "./Players.js"
import { Teams } from "./Teams.js"
import { Venues } from "./Venues.js"
import { CricketSchema, MainTable } from "./Schema.js"
// Sub-aggregate: a team's composition within a match
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 }),
},
})
// Top-level aggregate: the full match
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 } }),
},
})

Key aggregate concepts:

  • Aggregate.ref(Teams) — Like DynamoModel.ref but at the aggregate level. The team field in TeamSheet references Teams. Input accepts IDs; output contains full Team objects.

  • Aggregate.one("venue", { entity: Venues, entityType: "MatchVenue" }) — A one-to-one edge. The venue is decomposed into a separate DynamoDB item with entity type MatchVenue, all sharing the match’s partition key.

  • Aggregate.many("players", { entity: Players, entityType: "MatchPlayer" }) — A one-to-many edge. Each player in the team sheet becomes a separate DynamoDB item.

  • TeamSheetAggregate.with({ discriminator: { teamNumber: 1 } }) — Binds a sub-aggregate with a discriminator. Since a match has two TeamSheets, the discriminator (teamNumber: 1 vs teamNumber: 2) distinguishes them in the sort key.

  • collection — An index for fetching all items belonging to one match (the aggregate’s “get” query). In this example it uses an LSI (lsi1).

The MatchesApi follows the same pattern as entity APIs but uses the aggregate’s derived schemas. The Match aggregate in this tutorial only configures a collection (for get/update/delete queries) and not a list GSI, so the API exposes create, get, update, and delete — no list endpoint:

src/api/MatchesApi.ts
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import { MatchAggregate } from "../entities/MatchAggregate.js"
import { Match, MatchUpdatePayload } from "../models/Match.js"
import { ApiNotFound, ApiUnprocessableEntity } from "./ApiErrors.js"
export const MatchesApi = HttpApiGroup.make("matches").add(
HttpApiEndpoint.get("get", "/matches/:id", {
params: { id: Schema.String },
success: Match,
error: ApiNotFound,
}),
HttpApiEndpoint.post("create", "/matches", {
payload: MatchAggregate.createSchema,
success: Match,
error: ApiUnprocessableEntity,
}),
HttpApiEndpoint.put("update", "/matches/:id", {
params: { id: Schema.String },
payload: MatchUpdatePayload,
success: Match,
error: ApiNotFound,
}),
HttpApiEndpoint.delete("remove", "/matches/:id", {
params: { id: Schema.String },
error: ApiNotFound,
}),
)

Key points:

  • MatchAggregate.createSchema — The payload schema for creating matches. Derived from the aggregate’s domain model: PK composites (id) are omitted, entity refs become ID fields (venueId, team1.teamId, team1.coachId, team1.players[].playerId, etc.).

  • MatchUpdatePayload — The custom partial-update schema we defined in section 5.2. The API accepts a small subset of fields (just name for now); the service translates these into a cursor-based mutation against the aggregate.

  • Ref hydration errors — The create endpoint uses ApiUnprocessableEntity (422) for ref hydration failures (e.g., venueId doesn’t point to an existing venue).

The service binds the aggregate to the typed client and translates the partial MatchUpdatePayload into a cursor-based mutation:

src/services/MatchService.ts
import { Aggregate, DynamoClient } from "effect-dynamodb"
import { type DateTime, Effect, type Schema, Context } from "effect"
import { ulid } from "ulid"
import { MatchAggregate } from "../entities/MatchAggregate.js"
import type { MatchUpdatePayload } from "../models/Match.js"
import { MainTable } from "../entities/Schema.js"
export type { DateTime }
type MatchKey = Aggregate.Key<typeof MatchAggregate>
type MatchCreate = Schema.Schema.Type<typeof MatchAggregate.createSchema>
export class MatchService extends Context.Service<MatchService>()("@gamemanager/MatchService", {
make: Effect.gen(function* () {
const db = yield* DynamoClient.make({
entities: {},
aggregates: { MatchAggregate },
tables: { MainTable },
})
const matches = db.aggregates.MatchAggregate
return {
create: Effect.fn(function* (input: Omit<MatchCreate, "id">) {
const id = ulid()
return yield* matches.create({ ...input, id } as MatchCreate)
}),
get: (key: MatchKey) => matches.get(key),
// The partial payload only supports `name` today. Each optional field
// becomes a `cursor.key(...).replace(...)` call inside the mutation.
// The callback must return the next state — either the replaced value
// or the original state when nothing in the payload matches.
update: (key: MatchKey, payload: MatchUpdatePayload) =>
matches.update(key, ({ cursor, state }) => {
if (payload.name !== undefined) {
return cursor.key("name").replace(payload.name)
}
return state
}),
delete: (key: MatchKey) => matches.delete(key),
}
}),
}) {}

Key differences from entity services:

  • DynamoClient.make({ entities: {}, aggregates, tables }) — Aggregates are bound via the same typed gateway. entities is required by every overload, so we pass an empty record when the service only touches an aggregate. Access aggregates through db.aggregates.MatchAggregate — all operations have R = never.

  • matches.createSchema — Like entities, aggregates derive typed schemas. createSchema omits PK composites and replaces ref/edge fields with ID fields (venueId, team1.teamId).

  • matches.create(...) — Decomposes the input into multiple DynamoDB items and writes them atomically in a transaction.

  • matches.get(key) — Queries all items in the aggregate’s partition, then assembles them back into the full Match domain object.

  • matches.update(key, fn) — The update function receives an UpdateContext with a cursor optic pre-bound to the aggregate root, plus state (the current decoded state). cursor.key("name").replace(...) rewrites the name field and returns the next state; the library diffs the old and new states and writes only the changed items. The callback must return the next state — return cursor.key(...).replace(...) for an edit, or return state unchanged when there is nothing to do.

  • No list method — The MatchAggregate in this tutorial only configures a collection (for partition reads), not a list GSI. To support MatchAggregate.list(filter, options) you would add a list: { index, name, pk, sk } block to the aggregate config and a corresponding GSI in create-table.ts.

Schema.ts does not change in this phase. The MatchAggregate is wired to the table via its table: MainTable field inside Aggregate.make (section 5.3), and it’s registered on the typed client in every service, handler, and script that uses it (MatchService, create-table.ts) via DynamoClient.make({ aggregates: { MatchAggregate }, ... }).

Why not register the aggregate on Table.make(...)? — In a multi-file project, MatchAggregate.ts already imports MainTable from Schema.ts (it needs the table for Aggregate.make({ table: MainTable, ... })). If Schema.ts also imported MatchAggregate to register it on Table.make, you would create a circular import between Schema.ts and MatchAggregate.ts and TypeScript would fail with TS7022: 'MatchAggregate' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer. Registering aggregates on the typed client via DynamoClient.make(...) avoids the cycle and db.tables.MainTable.create() still detects and provisions the aggregate’s indexes at runtime (LSIs are auto-detected from the aggregate’s collection config).

Update Api.ts:

src/api/Api.ts
import type { Schema } from "effect"
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
import { CoachesApi } from "./CoachesApi.js"
import { MatchesApi } from "./MatchesApi.js"
import { PlayersApi } from "./PlayersApi.js"
import { SquadSelectionsApi } from "./SquadSelectionsApi.js"
import { TeamsApi } from "./TeamsApi.js"
import { UmpiresApi } from "./UmpiresApi.js"
import { VenuesApi } from "./VenuesApi.js"
export type { Schema }
export const GameManagerApi = HttpApi.make("gamemanager")
.add(TeamsApi)
.add(PlayersApi)
.add(CoachesApi)
.add(UmpiresApi)
.add(VenuesApi)
.add(SquadSelectionsApi)
.add(MatchesApi)
.annotate(OpenApi.Title, "GameManager API")
.annotate(OpenApi.Version, "2.0.0")

Add the MatchesHandler and service in App.ts:

// In App.ts — add imports:
import type { AggregateAssemblyError, RefNotFound } from "effect-dynamodb"
import { MatchService } from "../services/MatchService.js"
// In App.ts — add error helper (after refNotFound):
const aggregateNotFound =
(entity: string, id: string) =>
<A, E, R>(self: Effect.Effect<A, E | AggregateAssemblyError, R>) =>
Effect.catchTag(
self,
"AggregateAssemblyError",
() => Effect.fail(new ApiNotFound({ error: "NotFound", message: `${entity} not found: ${id}` })),
(e) => Effect.die(e),
)

Note: AggregateAssemblyError is imported from effect-dynamodb. Aggregate operations throw AggregateAssemblyError (not ItemNotFound) when a partition has no items.

// In App.ts — add handler (before HandlerLayers):
const MatchesHandler = HttpApiBuilder.group(
GameManagerApi,
"matches",
Effect.fn(function* (handlers) {
const svc = yield* MatchService
return handlers
.handle("get", ({ params: { id } }) =>
svc.get({ id }).pipe(aggregateNotFound("Match", id)),
)
.handle("create", ({ payload }) =>
svc.create(payload).pipe(refNotFound),
)
.handle("update", ({ params: { id }, payload }) =>
svc.update({ id }, payload).pipe(aggregateNotFound("Match", id)),
)
.handle("remove", ({ params: { id } }) =>
svc.delete({ id }).pipe(aggregateNotFound("Match", id)),
)
}),
)

Key differences from entity handlers:

  • svc.get({ id }) — Aggregate keys are objects (not scalar IDs), matching the PK composite structure.
  • svc.update({ id }, payload) — Passes the MatchUpdatePayload directly to the service, which translates the partial payload into a cursor mutation against the aggregate.
  • aggregateNotFound — Catches AggregateAssemblyError instead of ItemNotFound. Aggregates raise this when no items exist in the target partition.
  • refNotFound reused — Match creation hydrates refs (venueId, teamId, coachId, playerId). The same refNotFound helper from Phase 4 maps RefNotFound to a 422.
// In App.ts — update HandlerLayers and ServiceLayers:
const HandlerLayers = Layer.mergeAll(
TeamsHandler,
PlayersHandler,
CoachesHandler,
UmpiresHandler,
VenuesHandler,
SquadSelectionsHandler,
MatchesHandler,
)
const ServiceLayers = Layer.mergeAll(
Layer.effect(TeamService, TeamService.make),
Layer.effect(PlayerService, PlayerService.make),
Layer.effect(CoachService, CoachService.make),
Layer.effect(UmpireService, UmpireService.make),
Layer.effect(VenueService, VenueService.make),
Layer.effect(SquadSelectionService, SquadSelectionService.make),
Layer.effect(MatchService, MatchService.make),
)

Update create-table.ts to register MatchAggregate alongside the entities. The library merges the runtime-registered aggregates into the supplied table when computing the create-time schema, so db.tables.MainTable.create() provisions the aggregate’s lsi1 (the LSI used by its collection index) alongside the existing entity GSIs:

src/create-table.ts
import { DynamoClient } from "effect-dynamodb"
import { Console, Effect } from "effect"
import { Coaches } from "./entities/Coaches.js"
import { MatchAggregate } from "./entities/MatchAggregate.js"
import { Players } from "./entities/Players.js"
import { SquadSelections } from "./entities/SquadSelections.js"
import { Teams } from "./entities/Teams.js"
import { Umpires } from "./entities/Umpires.js"
import { Venues } from "./entities/Venues.js"
import { MainTable } from "./entities/Schema.js"
const program = Effect.gen(function* () {
const db = yield* DynamoClient.make({
entities: { Teams, Players, Coaches, Umpires, Venues, SquadSelections },
aggregates: { MatchAggregate },
tables: { MainTable },
})
yield* Console.log("Creating table...")
yield* db.tables.MainTable.create()
yield* Console.log("Table created successfully")
})
const DynamoLayer = DynamoClient.layer({
region: "local",
endpoint: "http://localhost:8000",
credentials: { accessKeyId: "local", secretAccessKey: "local" },
})
const TableLayer = MainTable.layer({ name: "gamemanager" })
program.pipe(Effect.provide(DynamoLayer), Effect.provide(TableLayer), Effect.runPromise)

Recreate the table, restart the server, and test all match operations:

Terminal window
# Recreate the table (destroy and recreate)
pnpm db:destroy
pnpm db:create
# Start the dev server
pnpm dev

Step 1: Create reference data. Matches reference teams, players, coaches, and venues — all must exist first:

Terminal window
# Create two teams
AUS_ID=$(curl -s -X POST http://localhost:3000/teams \
-H "Content-Type: application/json" \
-d '{"name": "Australia", "country": "Australia", "ranking": 1}' \
| jq -r '.id')
IND_ID=$(curl -s -X POST http://localhost:3000/teams \
-H "Content-Type: application/json" \
-d '{"name": "India", "country": "India", "ranking": 2}' \
| jq -r '.id')
# Create players for Australia
AUS_P1=$(curl -s -X POST http://localhost:3000/players \
-H "Content-Type: application/json" \
-d '{"firstName":"Steve","lastName":"Smith","name":"Steve Smith","gender":"Male","battingHand":"Right"}' \
| jq -r '.id')
AUS_P2=$(curl -s -X POST http://localhost:3000/players \
-H "Content-Type: application/json" \
-d '{"firstName":"Pat","lastName":"Cummins","name":"Pat Cummins","gender":"Male","battingHand":"Right"}' \
| jq -r '.id')
# Create players for India
IND_P1=$(curl -s -X POST http://localhost:3000/players \
-H "Content-Type: application/json" \
-d '{"firstName":"Virat","lastName":"Kohli","name":"Virat Kohli","gender":"Male","battingHand":"Right"}' \
| jq -r '.id')
IND_P2=$(curl -s -X POST http://localhost:3000/players \
-H "Content-Type: application/json" \
-d '{"firstName":"Jasprit","lastName":"Bumrah","name":"Jasprit Bumrah","gender":"Male","battingHand":"Right"}' \
| jq -r '.id')
# Create coaches
AUS_COACH=$(curl -s -X POST http://localhost:3000/coaches \
-H "Content-Type: application/json" \
-d '{"firstName":"Andrew","lastName":"McDonald","name":"Andrew McDonald","gender":"Male"}' \
| jq -r '.id')
IND_COACH=$(curl -s -X POST http://localhost:3000/coaches \
-H "Content-Type: application/json" \
-d '{"firstName":"Gautam","lastName":"Gambhir","name":"Gautam Gambhir","gender":"Male"}' \
| jq -r '.id')
# Create a venue
VENUE=$(curl -s -X POST http://localhost:3000/venues \
-H "Content-Type: application/json" \
-d '{"name":"Melbourne Cricket Ground","address":"Brunton Ave","city":"Melbourne","country":"Australia","capacity":100024}' \
| jq -r '.id')
echo "Reference data created:"
echo " Teams: AUS=$AUS_ID IND=$IND_ID"
echo " Players: AUS_P1=$AUS_P1 AUS_P2=$AUS_P2 IND_P1=$IND_P1 IND_P2=$IND_P2"
echo " Coaches: AUS_COACH=$AUS_COACH IND_COACH=$IND_COACH"
echo " Venue: VENUE=$VENUE"

Step 2: Create a match. The payload uses createSchema — note how entity fields become ID fields:

Terminal window
MATCH_ID=$(curl -s -X POST http://localhost:3000/matches \
-H "Content-Type: application/json" \
-d "{
\"name\": \"Boxing Day Test 2025\",
\"venueId\": \"$VENUE\",
\"team1\": {
\"teamId\": \"$AUS_ID\",
\"coachId\": \"$AUS_COACH\",
\"homeTeam\": true,
\"players\": [
{\"playerId\": \"$AUS_P1\", \"battingPosition\": 4, \"isCaptain\": false},
{\"playerId\": \"$AUS_P2\", \"battingPosition\": 7, \"isCaptain\": true}
]
},
\"team2\": {
\"teamId\": \"$IND_ID\",
\"coachId\": \"$IND_COACH\",
\"homeTeam\": false,
\"players\": [
{\"playerId\": \"$IND_P1\", \"battingPosition\": 4, \"isCaptain\": true},
{\"playerId\": \"$IND_P2\", \"battingPosition\": 11, \"isCaptain\": false}
]
}
}" | jq -r '.id')
echo "Created match: $MATCH_ID"

Notice how the create payload maps to the aggregate’s edge structure:

  • venue: VenuevenueId: string
  • team1.team: Teamteam1.teamId: string
  • team1.coach: Coachteam1.coachId: string
  • team1.players[].player: Playerteam1.players[].playerId: string

Step 3: Get a match. The response is the fully assembled domain object — all refs hydrated:

Terminal window
curl -s http://localhost:3000/matches/$MATCH_ID | jq

The response contains full Team, Player, Coach, and Venue objects — not IDs. This is a single get call that queries the aggregate’s lsi1 collection index, fetches all items sharing the partition key, and assembles them into the domain graph.

Step 4: Update a match. The current MatchUpdatePayload only exposes the name field; we’ll rename the match in place:

Terminal window
# Rename the match
curl -s -X PUT http://localhost:3000/matches/$MATCH_ID \
-H "Content-Type: application/json" \
-d '{"name": "Boxing Day Test"}' | jq '.name'
# → "Boxing Day Test"
# Verify the rename — refs are still hydrated
curl -s http://localhost:3000/matches/$MATCH_ID | jq '{name, venue: .venue.name, team1: .team1.team.name, team2: .team2.team.name}'

The aggregate’s update engine diffs old state vs new and writes only the changed item — renaming a match touches just the root MatchItem, leaving every team sheet, coach, player, and venue item untouched.

Extending the update payload. To support more in-place edits (start date, result, toss, winner, etc.) you can grow MatchUpdatePayload and add matching cursor.key(...).replace(...) branches in MatchService.update. Validation rules belong on the assembled Match via Schema.makeFilter + Match.check(...). See Next Steps at the end of the tutorial for ideas.

Step 5: Test ref-not-found. Creating a match with an invalid venueId should produce a 422:

Terminal window
curl -s -X POST http://localhost:3000/matches \
-H "Content-Type: application/json" \
-d "{
\"name\": \"Bad Ref Match\",
\"venueId\": \"nonexistent-venue-id\",
\"team1\": {
\"teamId\": \"$AUS_ID\",
\"coachId\": \"$AUS_COACH\",
\"homeTeam\": true,
\"players\": [{\"playerId\": \"$AUS_P1\", \"battingPosition\": 1, \"isCaptain\": true}]
},
\"team2\": {
\"teamId\": \"$IND_ID\",
\"coachId\": \"$IND_COACH\",
\"homeTeam\": false,
\"players\": [{\"playerId\": \"$IND_P1\", \"battingPosition\": 1, \"isCaptain\": true}]
}
}" | jq
# → 422: { "error": "UnprocessableEntity", "message": "Venue not found: nonexistent-venue-id" }

Step 6: Delete a match.

Terminal window
# Delete the match — removes all items in the aggregate's partition
curl -s -X DELETE http://localhost:3000/matches/$MATCH_ID
# Verify it's gone
curl -s http://localhost:3000/matches/$MATCH_ID | jq
# → 404: { "error": "NotFound", "message": "Match not found: ..." }

You now have the complete cricket match management system running — with CRUD for all entity types, ref hydration, cascade updates, and aggregate-based match management.


ConceptWhat it doesWhere you used it
Schema.ClassRuntime-validated domain modelEvery model file
Schema.brandBranded nominal types for IDs — preserved through Entity.Input, Entity.Key, and ref typesIds.ts, services, API params
Schema.optionalKeyOptional property key (may be absent)All optional fields
Schema.LiteralsRuntime literal union schemaEnums.ts
DynamoModel.configureRename fields, mark identifier/ref at entity levelEvery entity
DynamoSchema + TableApplication namespace + physical table bindingSchema.ts
Entity.makeBinds model to table with indexesEvery entity
Entity.createSchemaDerived schema: input fields minus PK composites, ref-awareAPI create payloads
Entity.updateSchemaDerived schema: optional non-key fields, ref-awareAPI update payloads
Entity.cascadePropagate updates to referencing entitiesTeamService, PlayerService
Single-composite cascade GSIRequired for cascade — PK composite must be exactly [refIdField]SquadSelections byPlayer/byTeam
Query combinatorsComposable query buildingPagination helpers, services
Context.ServiceDependency-injectable serviceEvery service
Aggregate.makeGraph-based composite entityMatchAggregate
Aggregate.createSchemaDerived schema: input minus PK, edges→IDsMatchService create
Aggregate.one / manyDecompose to separate itemsVenue, Coach, Players
Sub-aggregate + discriminatorReuse aggregate structure with differentiationteam1/team2 TeamSheets
Aggregate cursor opticTargeted in-place mutation inside updateMatchService update
Table.definition (aggregates)Include aggregate GSIs in table creationcreate-table.ts
AggregateAssemblyErrorError when aggregate partition has no itemsMatchesHandler not-found
Schema.ErrorClass API errorsTyped HTTP errors with constructors, status codes, clean JSONApiErrors.ts
Effect.catchTagConvert domain errors to HTTP errorsApp.ts error helpers
Layer compositionWire everything together at runtimeApp.ts, server.ts

From here you can extend the application:

  • Grow the Match model — Add fields like gender, matchType, series, season, startDate, finishDate, toss, winner, result, and an umpires sub-aggregate. Each new ref field becomes a ${name}Id in MatchAggregate.createSchema.
  • Aggregate-level validation — Define Schema.makeFilter<Match>(...) rules (e.g. team1.team.id !== team2.team.id, ≥1 player per team) and compose them with Match.check(...). Decode the assembled match through the validated schema in MatchService.create/update, mapping SchemaError to a 400.
  • Aggregate list endpoint — Add a list: { index, name, pk, sk } block to MatchAggregate and a matching GSI in create-table.ts. The service can then call matches.list(filter, { limit, cursor }) to expose GET /matches.
  • Richer update payloads — Extend MatchUpdatePayload with more fields (date changes, toss, winner) and add corresponding cursor.key(...).replace(...) branches in MatchService.update. For ref fields like tossId, resolve the target via the cursor’s current state or via an entity lookup before applying the mutation.
  • Add more enums — Stronger validation via richer literal unions (BowlingStyle, PlayingRole, MatchType, Season).
  • Add tests — Use vi.fn() to mock DynamoClient methods and test services/handlers without a real database.

See the full implementation at goals/gamemanager-v2/ for the complete application with all features.