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
Phase 1: Project Setup
Section titled “Phase 1: Project Setup”1.1 Initialize the project
Section titled “1.1 Initialize the project”mkdir gamemanager && cd gamemanagerpnpm init1.2 Install dependencies
Section titled “1.2 Install dependencies”pnpm add effect@4.0.0-beta.43 @effect/platform-node@4.0.0-beta.43 effect-dynamodb ulidpnpm add -D typescript@^5.9.0 tsx vitest@^4.1.0 @effect/vitest@4.0.0-beta.43 @biomejs/biome@^2.4.41.3 Language Service Plugin
Section titled “1.3 Language Service Plugin”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:
pnpm add -D @effect-dynamodb/language-serviceThe 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"}1.4 Configure TypeScript
Section titled “1.4 Configure TypeScript”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"]}1.5 Configure package.json
Section titled “1.5 Configure package.json”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" }}1.6 Create the directory structure
Section titled “1.6 Create the directory structure”mkdir -p src/models src/entities src/services src/apiYou 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.
2.1 Branded ID type
Section titled “2.1 Branded ID type”Create branded types for entity IDs. We only need TeamId for now — we’ll add more as we build more entities:
import { Schema } from "effect"
export const TeamId = Schema.String.pipe(Schema.brand("TeamId"))export type TeamId = typeof TeamId.TypeConcept: Schema.brand — Creates a nominal type that is structurally a
stringbut branded at the type level. When you addPlayerIdlater, TypeScript will treatTeamIdandPlayerIdas 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>hasid: TeamId,Entity.Key<typeof Teams>hasid: TeamId. Each branded ID also doubles as a Schema — useTeamIddirectly inHttpApiEndpointparams to decode URL strings into branded types at the API boundary.
2.2 Team model
Section titled “2.2 Team model”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
exportbeforeclass Team— the region-synced code above omitsexportbecause 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:
-
Schema.Class— Defines a runtime-validated class.new Team({ id: "aus", name: "Australia", country: "Australia", ranking: 1 })validates all fields. -
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.
2.3 Teams entity
Section titled “2.3 Teams entity”The entity binds your pure model to DynamoDB with indexes, key composition, and optional features:
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 theidfield toteamIdin 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 underlyingTeammodel 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.pkis composed from theidfield.skhas 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 bycountryalone, orcountry+name.
Key concept:
Entity.makereturns 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 aTable.make({ entities: { Teams } }), which we’ll create next.
2.4 Schema and Table
Section titled “2.4 Schema and Table”Now that we have an entity, we need a DynamoSchema (application namespace) and a Table (physical table binding) to assemble it:
import { DynamoSchema, Table } from "effect-dynamodb"import { Teams } from "./Teams.js"
const CricketSchema = DynamoSchema.make({ name: "cricket", version: 1 })Note: Add
exportbeforeconst CricketSchema— the region-synced code above omitsexportbecause the backing example is a single file. In your multi-file project,Schema.tsis imported by other modules (e.g.,MatchAggregate.tsin Phase 5), so the schema must be exported.
export const MainTable = Table.make({ schema: CricketSchema, entities: { Teams },})Concept: Single-table design — All entities share one DynamoDB table. The schema’s
nameandversionbecome part of every key prefix ($cricket#v1#...), preventing collisions between entity types. You’ll add more entities toTable.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.tsimports entities and registers them on the table. In each service file, you callDynamoClient.make({ entities: { ... }, tables: { MainTable } })listing the entities that service operates on, and the returned typed client hasR = neverfor every operation.
Run pnpm check — the model, entity, and table should compile.
2.5 Pagination helpers
Section titled “2.5 Pagination helpers”Before building the service, create shared pagination schemas that will be reused by every entity:
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:
-
PaginationOptions— ASchema.Structthat doubles as both schema and type. SpreadPaginationOptions.fieldsinto endpointquerydefinitions. The HTTP framework decodes query strings (e.g.,?count=20&reverse=true) into typed values automatically. -
PaginatedResponse(item)— A schema factory for runtime use (e.g., in service implementations). Do not use inHttpApiEndpointdefinitions — the generic function indirection exceeds TypeScript’s conditional type inference depth insideHttpApiBuilder.group, causing the layer’sRparameter to resolve tounknowninstead ofnever. Use inlineSchema.Struct({ data: Schema.Array(Entity), count: Schema.Number, cursor: Schema.NullOr(Schema.String) })in endpointsuccessfields instead. -
applyPagination— Works directly with decodedPaginationOptions. No manual parsing — the Schema layer already decodedcountfrom string to number viaSchema.NumberFromString.
Concept: Query combinators —
Query.limit,Query.reverse, andQuery.startFromare composable functions that modify a query description. Nothing executes until you pass the query to a bound entity terminal method liketeams.fetch(query)orteams.collect(query).
2.6 List filter schema
Section titled “2.6 List filter schema”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:
import { Schema } from "effect"
export const TeamListFilter = Schema.Struct({ country: Schema.optionalKey(Schema.String),})export type TeamListFilter = typeof TeamListFilter.TypePattern: Schema as value + type — Exporting
const Foo = Schema.Struct(...)andtype Foo = typeof Foo.Typeunder the same name gives you a schema (for validation and.fieldsspreading) and a TypeScript type (for function signatures) from one definition.
We’ll add more filter schemas to this file as we add entities.
2.7 TeamService
Section titled “2.7 TeamService”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 amakeeffect that produces the implementation. Consumers access it viayield* TeamService. -
Entity.Create<typeof Teams>— Omits the identifier field (declared viaDynamoModel.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 TeamId—ulid()returnsstring, 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. ResolvesDynamoClient | TableConfigfrom context, binds the listed entities, and returns a typed client where every operation hasR = never. Access bound entities viadb.entities.Teams,db.entities.Players, etc., and table operations viadb.tables.MainTable.create(). Each service typically only registers the entities it uses. -
Bound entity methods —
teams.get(),teams.update(),teams.delete()returnEffectwithR = never— no context requirements. -
teams.fetch(query)— Executes a query descriptor and returns{ items, cursor }for pagination. The entity definition (Teams) provides query descriptors viaTeams.query.byAll(filter)andTeams.scan().applyPaginationapplies limit, order, and cursor to the query descriptor before execution.
2.8 API error types
Section titled “2.8 API error types”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 includinghttpApiStatus. This gives you proper Error instances with constructors (new ApiNotFound({ error: "NotFound", message: "..." })), clean JSON responses (no_tagin 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.
2.9 TeamsApi endpoint definitions
Section titled “2.9 TeamsApi endpoint definitions”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.createSchemaandTeams.updateSchema— Every entity exposes typed schemas derived from its model.createSchemais the input schema minus primary key composites (the common pattern where IDs are auto-generated).updateSchemamakes all non-PK fields optional. Both preserve branded types — ref ID fields usePlayerId,TeamId, etc., not plainstring. Use these directly asHttpApiEndpointpayloads to avoid manually re-specifying field schemas.
2.10 API composition and handler wiring
Section titled “2.10 API composition and handler wiring”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")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 runsTeamService.makeand provides the result.Layer.mergeAllcombines independent layers.Layer.providefeeds dependencies downward.
2.11 Table creation and server entry point
Section titled “2.11 Table creation and server entry point”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 lateraggregates) record passed toDynamoClient.makesodb.tables.MainTable.create()derives the full GSI configuration.
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 DI —
DynamoClient.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 namespacedentities,aggregates,collections, andtablesaccessors — no need to injectDynamoClientdirectly.
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 layersconst 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 loggerconst requestLogger = HttpMiddleware.make((httpApp) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest yield* Effect.logInfo(`>>> ${request.method} ${request.url}`) return yield* httpApp }),)
// Compose and launchconst 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)2.12 Run it!
Section titled “2.12 Run it!”Run pnpm check to verify everything compiles. Then start DynamoDB Local, create the table, and launch the server:
# Start DynamoDB Localdocker run -d -p 8000:8000 amazon/dynamodb-local
# Create the tablepnpm db:create
# Start the dev server (tsx — no build needed)pnpm devScripts:
pnpm db:createandpnpm db:destroymanage the table lifecycle.pnpm devruns the server withtsx(TypeScript directly).pnpm startruns the built server (node dist/server.js) — runpnpm buildfirst.
Test with curl:
# Create a team — capture the ID from the responseTEAM_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 teamscurl -s http://localhost:3000/teams | jq
# Get a team by IDcurl -s http://localhost:3000/teams/$TEAM_ID | jq
# Update a teamcurl -s -X PUT http://localhost:3000/teams/$TEAM_ID \ -H "Content-Type: application/json" \ -d '{"ranking": 2}' | jq
# Delete a teamcurl -s -X DELETE http://localhost:3000/teams/$TEAM_IDYou 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.
Phase 3: Adding More Entities
Section titled “Phase 3: Adding 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.
3.0 Shared enum schemas
Section titled “3.0 Shared enum schemas”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:
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 = GenderSchemaexport 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.TypePattern: Enums via
Schema.Literals—Schema.Literals(["Male", "Female"])produces a runtime-validated literal union schema whose.Typeis"Male" | "Female". Use the schema for runtime validation (inSchema.Classfields andSchema.Structfilters) and the type for TypeScript-only positions.
Why a separate
GenderQuery? It’s an alias ofGenderSchematoday, 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"3.1 Shared person fields
Section titled “3.1 Shared person fields”Players, coaches, and umpires share common fields. Rather than inheritance, use object spread:
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.optionalKeyvsSchema.optional—optionalKeymakes the property key itself optional ({ dateOfBirth?: Date }), meaning the key may be absent.optionalmakes the value optional ({ dateOfBirth: Date | undefined }), where the key is always present. WithexactOptionalPropertyTypes: true, these are distinct types.
3.2 Player
Section titled “3.2 Player”Add the branded ID to src/models/Ids.ts:
export const PlayerId = Schema.String.pipe(Schema.brand("PlayerId"))export type PlayerId = typeof PlayerId.TypeModel:
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:
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.TypeService — follows the same pattern as TeamService:
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:
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, }),)3.3 Coach
Section titled “3.3 Coach”Add CoachId to src/models/Ids.ts:
export const CoachId = Schema.String.pipe(Schema.brand("CoachId"))export type CoachId = typeof CoachId.TypeAdd CoachListFilter to src/models/Filters.ts:
export const CoachListFilter = Schema.Struct({ gender: Schema.optionalKey(GenderQuery),})export type CoachListFilter = typeof CoachListFilter.TypeModel:
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:
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:
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:
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, }),)3.4 Umpire
Section titled “3.4 Umpire”Add UmpireId to src/models/Ids.ts:
export const UmpireId = Schema.String.pipe(Schema.brand("UmpireId"))export type UmpireId = typeof UmpireId.TypeAdd UmpireListFilter to src/models/Filters.ts:
export const UmpireListFilter = Schema.Struct({ gender: Schema.optionalKey(GenderQuery),})export type UmpireListFilter = typeof UmpireListFilter.TypeModel:
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:
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:
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:
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, }),)3.5 Venue
Section titled “3.5 Venue”Add VenueId to src/models/Ids.ts:
export const VenueId = Schema.String.pipe(Schema.brand("VenueId"))export type VenueId = typeof VenueId.TypeAdd VenueListFilter to src/models/Filters.ts:
export const VenueListFilter = Schema.Struct({ country: Schema.optionalKey(Schema.String),})export type VenueListFilter = typeof VenueListFilter.TypeModel:
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:
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:
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:
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, }),)3.6 Wire up all entities and test
Section titled “3.6 Wire up all entities and test”First, register the new entities on the table. Update 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
entitiesrecord onTable.make(...). You also need to add them to theentitiesrecord passed toDynamoClient.make(...)in any service or script that should access them — most notablycreate-table.ts(sodb.tables.MainTable.create()derives their GSIs) and the App.ts handler layer.
Update Api.ts to add all groups:
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:
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 entitiesimport { 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:
# Create a player — capture the IDPLAYER_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 playerscurl -s http://localhost:3000/players | jq
# Create a venue — capture the IDVENUE_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 countrycurl -s "http://localhost:3000/venues?country=AUS" | jqYou now have 5 standalone entities with full CRUD, all sharing a single DynamoDB table.
Phase 4: Entity References and Cascades
Section titled “Phase 4: Entity References and Cascades”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.
4.1 SquadSelection model
Section titled “4.1 SquadSelection model”Add SquadSelectionId to src/models/Ids.ts:
export const SquadSelectionId = Schema.String.pipe(Schema.brand("SquadSelectionId"))export type SquadSelectionId = typeof SquadSelectionId.Typeimport { 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
exportbeforeclass SquadSelection— the region-synced code above omitsexportbecause the backing example is a single file.
Two things to notice:
DynamoModel.refonteamandplayer— marks them as reference fields. In the input schema, these becometeamIdandplayerId(you provide the ID). In the record schema, they contain the fullTeamandPlayerobjects (hydrated on read). Unlike Phase 2’sTeam, 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.- Separate
seasonandseriesfields — instead of encoding them in a compositesquadIdstring, 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”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
exportbeforeconst 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 } })— RenamesidtoselectionIdin 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 theteamfield by looking up the referenced Team entity.byTeamSeriesindex — GSI for “all selections for a team in a series”. PK composites["teamId", "season", "series"]enable hierarchical prefix queries.byPlayerindex — GSI for “all squads a player has been selected for”. PK byplayerId, SK byseason+series. This is also the single-composite GSI onplayerIdthatEntity.cascadeuses when a Player is updated.byTeamindex — GSI keyed exactly onteamId. The cascade engine needs a GSI whose PK composite is exactly[refIdField]for each ref it cascades through;byTeamSeriesdoesn’t qualify because its PK composite is["teamId", "season", "series"]. Without this dedicatedbyTeamGSI,Players.update(..., Entity.cascade({ targets: [SquadSelections] }))would work butTeams.update(..., Entity.cascade({ targets: [SquadSelections] }))would fail withCascadePartialFailure: no usable GSI.
4.3 Adding cascade to TeamService
Section titled “4.3 Adding cascade to TeamService”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.
4.4 Adding cascade to PlayerService
Section titled “4.4 Adding cascade to PlayerService”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] }), )}),4.5 SquadSelectionService
Section titled “4.5 SquadSelectionService”Add SquadListFilter to src/models/Filters.ts:
export const SquadListFilter = Schema.Struct({ season: Schema.optionalKey(Schema.String),})export type SquadListFilter = typeof SquadListFilter.TypeService:
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.
listusesscan()—SquadSelections.scan()returns aQuery.Querydescriptor that walks the entire table for items matching the SquadSelection entity type. We pass it throughapplyPagination(which operates onQuery.Query, notBoundQuery) so the endpoint still respectscount/cursor/reverse. Filtering byseasonis 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 usingEntity.filter.
API:
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 includesplayerId: PlayerIdandteamId: TeamId(branded ID types derived from the ref entities’ identifier fields). The API consumer provides IDs; the library hydrates them on read, so thesuccess: SquadSelectionresponse includes full Team and Player objects.
4.6 Wire up and test
Section titled “4.6 Wire up and test”Register SquadSelections on the table:
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:
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 recordimport { 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):
# Create a team and player to referenceTEAM_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 objectscurl -s http://localhost:3000/squads | jq
# Now update the team name — cascade propagates to squad selectionscurl -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 updatedcurl -s http://localhost:3000/squads/$SELECTION_ID | jq '.team.name'# → "Australia Cricket"Phase 5: The Match Aggregate
Section titled “Phase 5: The Match Aggregate”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.
5.1 Sub-aggregate models
Section titled “5.1 Sub-aggregate models”A Match contains two TeamSheets (one per team). Each TeamSheet references a Team, a Coach, and an array of PlayerSheets:
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 schemasclass 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,}) {}5.2 Exporting the Match model
Section titled “5.2 Exporting the Match model”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 declarationexport 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.TypeWhy a custom payload schema?
MatchAggregate.updateSchemaaccepts 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
MatchUpdatePayloadto cover more fields (startDate,result,tossId, etc.) and add validation viaSchema.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.
5.3 The MatchAggregate
Section titled “5.3 The MatchAggregate”This is where the magic happens. The aggregate defines how a complex domain object decomposes into DynamoDB items and reassembles:
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 matchconst 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 matchconst 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)— LikeDynamoModel.refbut at the aggregate level. Theteamfield inTeamSheetreferences 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 typeMatchVenue, 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: 1vsteamNumber: 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).
5.4 MatchesApi
Section titled “5.4 MatchesApi”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:
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 (justnamefor 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.,venueIddoesn’t point to an existing venue).
5.5 MatchService
Section titled “5.5 MatchService”The service binds the aggregate to the typed client and translates the partial MatchUpdatePayload into a cursor-based mutation:
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.entitiesis required by every overload, so we pass an empty record when the service only touches an aggregate. Access aggregates throughdb.aggregates.MatchAggregate— all operations haveR = never. -
matches.createSchema— Like entities, aggregates derive typed schemas.createSchemaomits 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 anUpdateContextwith acursoroptic pre-bound to the aggregate root, plusstate(the current decoded state).cursor.key("name").replace(...)rewrites thenamefield 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 — returncursor.key(...).replace(...)for an edit, or returnstateunchanged when there is nothing to do. -
No list method — The
MatchAggregatein this tutorial only configures acollection(for partition reads), not alistGSI. To supportMatchAggregate.list(filter, options)you would add alist: { index, name, pk, sk }block to the aggregate config and a corresponding GSI increate-table.ts.
5.6 Wire up MatchesApi
Section titled “5.6 Wire up MatchesApi”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.tsalready importsMainTablefromSchema.ts(it needs the table forAggregate.make({ table: MainTable, ... })). IfSchema.tsalso importedMatchAggregateto register it onTable.make, you would create a circular import betweenSchema.tsandMatchAggregate.tsand TypeScript would fail withTS7022: '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 viaDynamoClient.make(...)avoids the cycle anddb.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:
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:
AggregateAssemblyErroris imported fromeffect-dynamodb. Aggregate operations throwAggregateAssemblyError(notItemNotFound) 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 theMatchUpdatePayloaddirectly to the service, which translates the partial payload into a cursor mutation against the aggregate.aggregateNotFound— CatchesAggregateAssemblyErrorinstead ofItemNotFound. Aggregates raise this when no items exist in the target partition.refNotFoundreused — Match creation hydrates refs (venueId,teamId,coachId,playerId). The samerefNotFoundhelper from Phase 4 mapsRefNotFoundto 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),)5.7 Recreate the table
Section titled “5.7 Recreate the table”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:
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)5.8 Recreate table and test
Section titled “5.8 Recreate table and test”Recreate the table, restart the server, and test all match operations:
# Recreate the table (destroy and recreate)pnpm db:destroypnpm db:create
# Start the dev serverpnpm devStep 1: Create reference data. Matches reference teams, players, coaches, and venues — all must exist first:
# Create two teamsAUS_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 AustraliaAUS_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 IndiaIND_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 coachesAUS_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 venueVENUE=$(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:
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: Venue→venueId: stringteam1.team: Team→team1.teamId: stringteam1.coach: Coach→team1.coachId: stringteam1.players[].player: Player→team1.players[].playerId: string
Step 3: Get a match. The response is the fully assembled domain object — all refs hydrated:
curl -s http://localhost:3000/matches/$MATCH_ID | jqThe 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:
# Rename the matchcurl -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 hydratedcurl -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
MatchUpdatePayloadand add matchingcursor.key(...).replace(...)branches inMatchService.update. Validation rules belong on the assembledMatchviaSchema.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:
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.
# Delete the match — removes all items in the aggregate's partitioncurl -s -X DELETE http://localhost:3000/matches/$MATCH_ID
# Verify it's gonecurl -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.
Concept Summary
Section titled “Concept Summary”| Concept | What it does | Where you used it |
|---|---|---|
| Schema.Class | Runtime-validated domain model | Every model file |
| Schema.brand | Branded nominal types for IDs — preserved through Entity.Input, Entity.Key, and ref types | Ids.ts, services, API params |
| Schema.optionalKey | Optional property key (may be absent) | All optional fields |
| Schema.Literals | Runtime literal union schema | Enums.ts |
| DynamoModel.configure | Rename fields, mark identifier/ref at entity level | Every entity |
| DynamoSchema + Table | Application namespace + physical table binding | Schema.ts |
| Entity.make | Binds model to table with indexes | Every entity |
| Entity.createSchema | Derived schema: input fields minus PK composites, ref-aware | API create payloads |
| Entity.updateSchema | Derived schema: optional non-key fields, ref-aware | API update payloads |
| Entity.cascade | Propagate updates to referencing entities | TeamService, PlayerService |
| Single-composite cascade GSI | Required for cascade — PK composite must be exactly [refIdField] | SquadSelections byPlayer/byTeam |
| Query combinators | Composable query building | Pagination helpers, services |
| Context.Service | Dependency-injectable service | Every service |
| Aggregate.make | Graph-based composite entity | MatchAggregate |
| Aggregate.createSchema | Derived schema: input minus PK, edges→IDs | MatchService create |
| Aggregate.one / many | Decompose to separate items | Venue, Coach, Players |
| Sub-aggregate + discriminator | Reuse aggregate structure with differentiation | team1/team2 TeamSheets |
| Aggregate cursor optic | Targeted in-place mutation inside update | MatchService update |
| Table.definition (aggregates) | Include aggregate GSIs in table creation | create-table.ts |
| AggregateAssemblyError | Error when aggregate partition has no items | MatchesHandler not-found |
| Schema.ErrorClass API errors | Typed HTTP errors with constructors, status codes, clean JSON | ApiErrors.ts |
| Effect.catchTag | Convert domain errors to HTTP errors | App.ts error helpers |
| Layer composition | Wire everything together at runtime | App.ts, server.ts |
Next Steps
Section titled “Next Steps”From here you can extend the application:
- Grow the Match model — Add fields like
gender,matchType,series,season,startDate,finishDate,toss,winner,result, and anumpiressub-aggregate. Each new ref field becomes a${name}IdinMatchAggregate.createSchema. - Aggregate-level validation — Define
Schema.makeFilter<Match>(...)rules (e.g. team1.team.id !== team2.team.id, ≥1 player per team) and compose them withMatch.check(...). Decode the assembled match through the validated schema inMatchService.create/update, mappingSchemaErrorto a 400. - Aggregate list endpoint — Add a
list: { index, name, pk, sk }block toMatchAggregateand a matching GSI increate-table.ts. The service can then callmatches.list(filter, { limit, cursor })to exposeGET /matches. - Richer update payloads — Extend
MatchUpdatePayloadwith more fields (date changes, toss, winner) and add correspondingcursor.key(...).replace(...)branches inMatchService.update. For ref fields liketossId, 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 mockDynamoClientmethods and test services/handlers without a real database.
See the full implementation at goals/gamemanager-v2/ for the complete application with all features.