Example: Task Manager
A task management application with employees, tasks, and offices in a single DynamoDB table. Adapted from the ElectroDB Task Manager example to showcase effect-dynamodb’s approach to status-driven workflows, range queries, and task archival.
What you’ll learn:
- 3 entities sharing one table with 5 GSIs via entity-level indexes
- Team queries with date-hired range filtering (GSI2)
- Task status index for cross-project status queries (GSI4)
- Task status workflow: open -> in-progress -> closed
- Soft delete for task archival with restore capability
- Atomic onboarding via transactions
- Application-level range filtering on date and salary fields
Requirements
Section titled “Requirements”As stakeholders of this task management system, we need to support these access patterns:
- CRUD — Create, read, update, and delete employees and tasks
- Regional Manager — See office details and employees at a location (workplaces collection)
- Project Manager — Find an employee’s details and their assigned tasks (assignments collection)
- Team Lead — Find all employees on a team, with date-hired range queries
- Scrum Master — Find all tasks by status across projects (open, in-progress, closed)
- Developer — Move a task through the status workflow with GSI recomposition
- Hiring Manager — Find employees by title with salary range queries
- HR — Atomically onboard a new employee with their first task
- PM — Archive completed tasks (soft delete) and restore if needed
Table Design
Section titled “Table Design”All three entities share a single table with 5 Global Secondary Indexes:
| Key | Field | Used by |
|---|---|---|
| PK | pk | Employee (by ID), Task (by ID), Office (by ID) |
| SK | sk | Employee, Task (project + employee), Office |
| GSI1 PK | gsi1pk | Employee (by office), Task (by project), Office (by office) |
| GSI1 SK | gsi1sk | Employee (team + title + ID), Task (employee + status), Office |
| GSI2 PK | gsi2pk | Employee (by team) |
| GSI2 SK | gsi2sk | Employee (dateHired + title) |
| GSI3 PK | gsi3pk | Employee (by ID), Task (by employee) |
| GSI3 SK | gsi3sk | Employee, Task (project + status) |
| GSI4 PK | gsi4pk | Employee (by title), Task (by status) |
| GSI4 SK | gsi4sk | Employee (salary), Task (project + employee) |
| GSI5 PK | gsi5pk | Employee (by manager) |
| GSI5 SK | gsi5sk | Employee (team + office) |
GSI1, GSI3, and GSI4 are overloaded — multiple entity types share the same physical index with different key compositions.
Step 1: Models
Section titled “Step 1: Models”Pure domain models with no DynamoDB concepts:
const Department = { Development: "development", Marketing: "marketing", Finance: "finance", Product: "product",} as constconst DepartmentSchema = Schema.Literals(Object.values(Department))
const TaskStatus = { Open: "open", InProgress: "in-progress", Closed: "closed" } as constconst TaskStatusSchema = Schema.Literals(Object.values(TaskStatus))
class Employee extends Schema.Class<Employee>("Employee")({ employee: Schema.String, firstName: Schema.String, lastName: Schema.String, office: Schema.String, title: Schema.String, team: DepartmentSchema, salary: Schema.String, // Zero-padded "000100.00" for lexicographic SK ordering manager: Schema.String, dateHired: Schema.String,}) {}
class Task extends Schema.Class<Task>("Task")({ task: Schema.String, project: Schema.String, employee: Schema.String, description: Schema.String, status: TaskStatusSchema, points: Schema.Number,}) {}
class Office extends Schema.Class<Office>("Office")({ office: Schema.String, country: Schema.String, state: Schema.String, city: Schema.String, zip: Schema.String, address: Schema.String,}) {}Key modeling decisions:
Department/TaskStatususe the const object +Schema.Literals(Object.values(...))pattern for type-safe literal unionsteamandstatusreference those schemas, keeping models readable while restricting to valid valuespointsisSchema.Number— numeric fields work naturally in entity attributessalaryis zero-padded"000120.00"for correct lexicographic sort key ordering
Step 2: Schema, Table, and Entities
Section titled “Step 2: Schema, Table, and Entities”Schema and Table
Section titled “Schema and Table”const TmSchema = DynamoSchema.make({ name: "taskman", version: 1 })const TmTable = Table.make({ schema: TmSchema, entities: { Employees, Tasks, Offices } })Employee Entity
Section titled “Employee Entity”Employee defines its primary key and GSI indexes. Multi-entity collections are auto-discovered from matching collection names:
const Employees = Entity.make({ model: Employee, entityType: "Employee", primaryKey: { pk: { field: "pk", composite: ["employee"] }, sk: { field: "sk", composite: [] }, }, indexes: { workplaces: { collection: "workplaces", name: "gsi1", pk: { field: "gsi1pk", composite: ["office"] }, sk: { field: "gsi1sk", composite: ["team", "title", "employee"] }, }, byTeam: { name: "gsi2", pk: { field: "gsi2pk", composite: ["team"] }, sk: { field: "gsi2sk", composite: ["dateHired", "title"] }, }, assignments: { collection: "assignments", name: "gsi3", pk: { field: "gsi3pk", composite: ["employee"] }, sk: { field: "gsi3sk", composite: [] }, }, byRole: { name: "gsi4", pk: { field: "gsi4pk", composite: ["title"] }, sk: { field: "gsi4sk", composite: ["salary"] }, }, byManager: { name: "gsi5", pk: { field: "gsi5pk", composite: ["manager"] }, sk: { field: "gsi5sk", composite: ["team", "office"] }, }, }, timestamps: true,})Task Entity
Section titled “Task Entity”Task defines its primary key and GSI indexes. The byStatus index (GSI4) enables cross-project status queries. Task also uses versioned and softDelete for lifecycle management:
const Tasks = Entity.make({ model: Task, entityType: "Task", primaryKey: { pk: { field: "pk", composite: ["task"] }, sk: { field: "sk", composite: ["project", "employee"] }, }, indexes: { byProject: { name: "gsi1", pk: { field: "gsi1pk", composite: ["project"] }, sk: { field: "gsi1sk", composite: ["employee", "status"] }, }, assignments: { collection: "assignments", name: "gsi3", pk: { field: "gsi3pk", composite: ["employee"] }, sk: { field: "gsi3sk", composite: ["project", "status"] }, }, byStatus: { name: "gsi4", pk: { field: "gsi4pk", composite: ["status"] }, sk: { field: "gsi4sk", composite: ["project", "employee"] }, }, }, timestamps: true, versioned: true, softDelete: true,})Key design decisions:
softDelete: trueenables task archival — closed tasks can be archived (removed from all indexes) while remaining retrievable for auditversioned: truecreates version snapshots on updates for audit trail
Office Entity
Section titled “Office Entity”const Offices = Entity.make({ model: Office, entityType: "Office", primaryKey: { pk: { field: "pk", composite: ["office"] }, sk: { field: "sk", composite: [] }, }, indexes: { workplaces: { collection: "workplaces", name: "gsi1", pk: { field: "gsi1pk", composite: ["office"] }, sk: { field: "gsi1sk", composite: [] }, }, }, timestamps: true,})GSI Access Patterns
Section titled “GSI Access Patterns”GSI access patterns are defined as entity-level indexes. Multi-entity collections (workplaces, assignments) are auto-discovered from matching collection names across entities:
// GSI access patterns are now defined as entity-level indexes above.// Multi-entity collections (workplaces, assignments) are auto-discovered// from matching collection names across entities.The byTeam index on Employee is designed for team leads: partition by team, sort by dateHired + title. This enables queries like “all development team members” and “development team members hired between 2020 and 2023”.
The byStatus index on Task partitions by status, sorts by project + employee — enables “all open tasks” and “open tasks for a specific project” queries.
Step 3: Seed Data
Section titled “Step 3: Seed Data”const offices = { portland: { office: "portland", country: "US", state: "OR", city: "Portland", zip: "97201", address: "123 SW Main St", }, nyc: { office: "nyc", country: "US", state: "NY", city: "New York", zip: "10001", address: "456 Broadway", },} as const
const employees = { tyler: { employee: "tyler", firstName: "Tyler", lastName: "Walch", office: "portland", title: "Senior Engineer", team: "development" as const, salary: "000120.00", manager: "tyler", // self-managed dateHired: "2019-03-15", }, sean: { employee: "sean", firstName: "Sean", lastName: "Green", office: "portland", title: "Junior Engineer", team: "development" as const, salary: "000085.00", manager: "tyler", dateHired: "2022-06-01", }, morgan: { employee: "morgan", firstName: "Morgan", lastName: "Lee", office: "nyc", title: "Product Manager", team: "product" as const, salary: "000110.00", manager: "morgan", // self-managed dateHired: "2020-09-01", }, alex: { employee: "alex", firstName: "Alex", lastName: "Chen", office: "nyc", title: "Marketing Lead", team: "marketing" as const, salary: "000095.00", manager: "morgan", dateHired: "2021-01-15", },}
const tasks = { buildApi: { task: "build-api", project: "platform", employee: "tyler", description: "Build the REST API for the platform", status: "open" as const, points: 8, }, writeTests: { task: "write-tests", project: "platform", employee: "sean", description: "Write integration tests for the API", status: "in-progress" as const, points: 5, }, designLanding: { task: "design-landing", project: "website", employee: "alex", description: "Design the landing page for the website", status: "open" as const, points: 13, }, userResearch: { task: "user-research", project: "website", employee: "morgan", description: "Conduct user research interviews", status: "closed" as const, points: 3, }, codeReview: { task: "code-review", project: "platform", employee: "tyler", description: "Review PRs from the team", status: "open" as const, points: 2, }, deployCi: { task: "deploy-ci", project: "platform", employee: "sean", description: "Set up CI/CD pipeline", status: "open" as const, points: 5, },}for (const office of Object.values(offices)) { yield* db.entities.Offices.put(office)}for (const emp of Object.values(employees)) { yield* db.entities.Employees.put(emp)}for (const task of Object.values(tasks)) { yield* db.entities.Tasks.put(task)}Step 4: Fulfilling the Requirements
Section titled “Step 4: Fulfilling the Requirements”Requirement 1: CRUD
Section titled “Requirement 1: CRUD”Create, read, update, and delete employees and tasks.
// #region crud const tyler = yield* db.entities.Employees.get({ employee: "tyler" }) assertEq(tyler.firstName, "Tyler", "get firstName") assertEq(tyler.lastName, "Walch", "get lastName") assertEq(tyler.title, "Senior Engineer", "get title") assertEq(tyler.team, "development", "get team")
const buildApi = yield* db.entities.Tasks.get({ task: "build-api", project: "platform", employee: "tyler", }) assertEq(buildApi.description, "Build the REST API for the platform", "get task description") assertEq(buildApi.status, "open", "get task status") assertEq(buildApi.points, 8, "get task points")
// Update employee title — must provide all GSI composites for affected indexes const promoted = yield* db.entities.Employees.update({ employee: "tyler" }).set({ office: "portland", team: "development", title: "Staff Engineer", salary: "000140.00", manager: "tyler", dateHired: "2019-03-15", }) assertEq(promoted.title, "Staff Engineer", "update title") assertEq(promoted.salary, "000140.00", "update salary") assertEq(promoted.firstName, "Tyler", "update preserves unchanged fields")
yield* db.entities.Employees.delete({ employee: "tyler" })Note that the get call on a deleted item returns an ItemNotFound error. Use catchTag to handle it:
const deleted = yield* db.entities.Employees.get({ employee: "tyler" }).pipe( Effect.map(() => false), Effect.catchTag("ItemNotFound", () => Effect.succeed(true)),)Requirement 2: Workplaces Collection
Section titled “Requirement 2: Workplaces Collection”As a regional manager, see office details and all employees at a location.
Employee and Office share GSI1 via the Workplaces collection. Both use office as the partition key.
const { Employees: portlandEmployees } = yield* db.collections.workplaces!({ office: "portland",}).collect()
const { Offices: portlandOffice } = yield* db.collections.workplaces!({ office: "portland",}).collect()Requirement 3: Assignments Collection
Section titled “Requirement 3: Assignments Collection”As a project manager, find an employee’s details and their assigned tasks.
Employee and Task share GSI3 via the Assignments collection. Both use employee as the partition key.
const { Tasks: tylerTasks } = yield* db.collections.assignments!({ employee: "tyler" }).collect()
// Verify points field (numeric) comes through correctlyconst totalTylerPoints = tylerTasks.reduce((sum: any, t: any) => sum + t.points, 0)
const { Employees: tylerInfo } = yield* db.collections.assignments!({ employee: "tyler",}).collect()Requirement 4: Teams Query with Date Range
Section titled “Requirement 4: Teams Query with Date Range”As a team lead, find all members of a team and filter by hire date.
The byTeam index on Employee (GSI2) uses team as PK and dateHired + title as SK. This enables both full team listings and date-range filtering.
// All development team membersconst devTeam = yield* db.entities.Employees.byTeam({ team: "development" }).collect()
// Range query: development team members hired between 2020 and 2023const allDevForRange = yield* db.entities.Employees.byTeam({ team: "development" }).collect()const recentDevHires = allDevForRange.filter( (e) => e.dateHired >= "2020-01-01" && e.dateHired <= "2023-12-31",)
// Reverse sort: development team by most recently hired firstconst devReversed = yield* db.entities.Employees.byTeam({ team: "development" }) .reverse() .collect()Date-range filtering is done in application code after collecting results. This works because date strings in ISO format sort lexicographically in chronological order.
Requirement 5: Tasks by Status
Section titled “Requirement 5: Tasks by Status”As a scrum master, see all tasks by status across all projects.
The byStatus index on Task (GSI4) uses status as PK and project + employee as SK. This is the other key differentiator from the HR example.
// All open tasks across all projectsconst openTasks = yield* db.entities.Tasks.byStatus({ status: "open" }).collect()
// Verify points are present on status-queried tasksconst totalOpenPoints = openTasks.reduce((sum, t) => sum + t.points, 0)
// In-progress tasksconst inProgressTasks = yield* db.entities.Tasks.byStatus({ status: "in-progress" }).collect()
// Closed tasksconst closedTasks = yield* db.entities.Tasks.byStatus({ status: "closed" }).collect()You can narrow by project using a sort key prefix:
// Open tasks in a specific project — pass SK composites for auto begins_withconst openPlatformTasks = yield* db.entities.Tasks.byStatus({ status: "open", project: "platform",}).collect()Requirement 6: Task Status Workflow
Section titled “Requirement 6: Task Status Workflow”As a developer, move a task through the lifecycle: open -> in-progress -> closed.
Updating a task’s status field triggers GSI recomposition on every index that includes status as a composite. The task moves between partitions in the byStatus GSI4 index.
// #region workflow // Move build-api from open -> in-progress const inProgress = yield* db.entities.Tasks.update({ task: "build-api", project: "platform", employee: "tyler", }).set({ status: "in-progress" })
// Verify: open tasks decreased by 1 const openAfterTransition = yield* db.entities.Tasks.byStatus({ status: "open" }).collect()
// Verify: in-progress tasks increased by 1 const inProgressAfterTransition = yield* db.entities.Tasks.byStatus({ status: "in-progress", }).collect()
// Move build-api from in-progress -> closed const closed = yield* db.entities.Tasks.update({ task: "build-api", project: "platform", employee: "tyler", }).set({ status: "closed" })
// Verify: closed tasks increased const closedAfterWorkflow = yield* db.entities.Tasks.byStatus({ status: "closed" }).collect()
// Restore build-api to original state for clean assertions below yield* db.entities.Tasks.update({ task: "build-api", project: "platform", employee: "tyler", }).set({ status: "open" })Because employee and project are primary key fields (provided in the update key), they are automatically reused for GSI recomposition. Only status — the GSI-only composite — needs to be in set().
Requirement 7: Employee Roles with Salary Range
Section titled “Requirement 7: Employee Roles with Salary Range”As a hiring manager, find employees by title with salary range queries.
The byRole index on Employee (GSI4) has title as PK and salary as SK. Zero-padded salary strings ensure correct lexicographic ordering.
const engineers = yield* db.entities.Employees.byRole({ title: "Senior Engineer" }).collect()
// Salary range query across all Product Managers — filter on salaryconst allPMs = yield* db.entities.Employees.byRole({ title: "Product Manager" }).collect()const wellPaidPMs = allPMs.filter((e) => e.salary >= "000100.00" && e.salary <= "000200.00")Requirement 8: Atomic Onboarding
Section titled “Requirement 8: Atomic Onboarding”As HR, atomically onboard a new employee with their first task.
Transaction.transactWrite ensures both the employee and task are created atomically — if either fails, neither is persisted.
yield* Transaction.transactWrite([ Employees.put({ employee: "jordan", firstName: "Jordan", lastName: "Rivera", office: "portland", title: "Junior Engineer", team: "development", salary: "000080.00", manager: "tyler", dateHired: "2024-02-01", }), Tasks.put({ task: "onboarding", project: "internal", employee: "jordan", description: "Complete new-hire orientation and setup dev environment", status: "open", points: 3, }),])// Verify both items created atomicallyconst newHire = yield* db.entities.Employees.get({ employee: "jordan" })
const { Tasks: onboardingTasks } = yield* db.collections.assignments!({ employee: "jordan",}).collect()
// Verify new hire appears in teams queryconst devTeamAfterHire = yield* db.entities.Employees.byTeam({ team: "development" }).collect()
// Verify new open task appears in statuses queryconst openAfterHire = yield* db.entities.Tasks.byStatus({ status: "open" }).collect()Requirement 9: Task Archival (Soft Delete + Restore)
Section titled “Requirement 9: Task Archival (Soft Delete + Restore)”As a PM, archive completed tasks so they vanish from all status queries but remain retrievable for audit.
Because Task has softDelete: true, deleting a task archives it. GSI keys are stripped, so the task disappears from all index queries. The record is preserved with a deletedAt timestamp for compliance.
// Archive the closed "user-research" taskyield* db.entities.Tasks.delete({ task: "user-research", project: "website", employee: "morgan" })
// Verify: closed tasks no longer include the archived oneconst closedAfterArchive = yield* db.entities.Tasks.byStatus({ status: "closed" }).collect()
// Verify: morgan's assignments no longer include the archived taskconst { Tasks: morganTasks } = yield* db.collections.assignments!({ employee: "morgan",}).collect()
// But the archived task is still retrievable via deleted.getconst archivedTask = yield* db.entities.Tasks.deleted.get({ task: "user-research", project: "website", employee: "morgan",})If a task was archived by mistake, restore it:
// Restore if needed (e.g., task was archived by mistake)const unarchived = yield* db.entities.Tasks.restore({ task: "user-research", project: "website", employee: "morgan",})// Verify: task is back in status queriesconst closedAfterRestore = yield* db.entities.Tasks.byStatus({ status: "closed" }).collect()This is the natural end of the task lifecycle: open -> in-progress -> closed -> archived. Soft delete strips all GSI keys, so archived tasks are invisible to every query while the data is preserved.
Running the Example
Section titled “Running the Example”The complete runnable example is at examples/task-manager.ts in the repository. It seeds data, runs all 9 access patterns, and verifies each with assertions.
docker run -d -p 8000:8000 amazon/dynamodb-localnpx tsx examples/task-manager.tsLayer Setup
Section titled “Layer Setup”The example wires dependencies via Effect Layers — no global config or constructor options:
const AppLayer = Layer.mergeAll( DynamoClient.layer({ region: "us-east-1", endpoint: "http://localhost:8000", credentials: { accessKeyId: "local", secretAccessKey: "local" }, }), TmTable.layer({ name: "taskman-table" }),)
const main = program.pipe(Effect.provide(AppLayer))
Effect.runPromise(main).then( () => console.log("\nDone."), (err) => console.error("\nFailed:", err),)Key Takeaways
Section titled “Key Takeaways”| Concept | How it’s used |
|---|---|
| Single-table design | 3 entities, 1 table, 5 GSIs with structured key values |
| Index overloading | GSI1 serves employees-by-office, tasks-by-project, and offices-by-office |
| Collections | Workplaces (GSI1) and Assignments (GSI3) enable cross-entity queries |
byTeam index (GSI2) | Partition by team, sort by dateHired for range queries |
byStatus index (GSI4) | Partition by status for cross-project task visibility |
| Range filtering | Collect from collection, filter in application code for date and salary ranges |
| Status workflow | Updating status triggers automatic GSI recomposition across all affected indexes |
| Soft delete archival | Closed tasks archived via softDelete — invisible to queries, preserved for audit |
| Restore | Entity.restore recomposes all keys, returning archived items to full index visibility |
| Transactions | Atomic multi-entity writes for employee onboarding |
What’s Next?
Section titled “What’s Next?”- Modeling Guide — Deep dive into models, schemas, tables, and entities
- Indexes & Collections — Access pattern design and collection patterns
- Lifecycle — Soft delete, versioning, and TTL
- Library System — Sparse indexes, 4-entity collections, and book loan workflows
- Human Resources — Full HR system with transfers, termination, and rehire