Skip to content

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

As stakeholders of this task management system, we need to support these access patterns:

  1. CRUD — Create, read, update, and delete employees and tasks
  2. Regional Manager — See office details and employees at a location (workplaces collection)
  3. Project Manager — Find an employee’s details and their assigned tasks (assignments collection)
  4. Team Lead — Find all employees on a team, with date-hired range queries
  5. Scrum Master — Find all tasks by status across projects (open, in-progress, closed)
  6. Developer — Move a task through the status workflow with GSI recomposition
  7. Hiring Manager — Find employees by title with salary range queries
  8. HR — Atomically onboard a new employee with their first task
  9. PM — Archive completed tasks (soft delete) and restore if needed

All three entities share a single table with 5 Global Secondary Indexes:

KeyFieldUsed by
PKpkEmployee (by ID), Task (by ID), Office (by ID)
SKskEmployee, Task (project + employee), Office
GSI1 PKgsi1pkEmployee (by office), Task (by project), Office (by office)
GSI1 SKgsi1skEmployee (team + title + ID), Task (employee + status), Office
GSI2 PKgsi2pkEmployee (by team)
GSI2 SKgsi2skEmployee (dateHired + title)
GSI3 PKgsi3pkEmployee (by ID), Task (by employee)
GSI3 SKgsi3skEmployee, Task (project + status)
GSI4 PKgsi4pkEmployee (by title), Task (by status)
GSI4 SKgsi4skEmployee (salary), Task (project + employee)
GSI5 PKgsi5pkEmployee (by manager)
GSI5 SKgsi5skEmployee (team + office)

GSI1, GSI3, and GSI4 are overloaded — multiple entity types share the same physical index with different key compositions.


Pure domain models with no DynamoDB concepts:

models.ts
const Department = {
Development: "development",
Marketing: "marketing",
Finance: "finance",
Product: "product",
} as const
const DepartmentSchema = Schema.Literals(Object.values(Department))
const TaskStatus = { Open: "open", InProgress: "in-progress", Closed: "closed" } as const
const 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 / TaskStatus use the const object + Schema.Literals(Object.values(...)) pattern for type-safe literal unions
  • team and status reference those schemas, keeping models readable while restricting to valid values
  • points is Schema.Number — numeric fields work naturally in entity attributes
  • salary is zero-padded "000120.00" for correct lexicographic sort key ordering

entities.ts
const TmSchema = DynamoSchema.make({ name: "taskman", version: 1 })
entities.ts
const TmTable = Table.make({ schema: TmSchema, entities: { Employees, Tasks, Offices } })

Employee defines its primary key and GSI indexes. Multi-entity collections are auto-discovered from matching collection names:

entities.ts
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 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:

entities.ts
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: true enables task archival — closed tasks can be archived (removed from all indexes) while remaining retrievable for audit
  • versioned: true creates version snapshots on updates for audit trail
entities.ts
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 are defined as entity-level indexes. Multi-entity collections (workplaces, assignments) are auto-discovered from matching collection names across entities:

collections.ts
// 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.


seed.ts
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,
},
}
seed.ts
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)
}

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)),
)

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()

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 correctly
const 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 members
const devTeam = yield* db.entities.Employees.byTeam({ team: "development" }).collect()
// Range query: development team members hired between 2020 and 2023
const 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 first
const 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.

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 projects
const openTasks = yield* db.entities.Tasks.byStatus({ status: "open" }).collect()
// Verify points are present on status-queried tasks
const totalOpenPoints = openTasks.reduce((sum, t) => sum + t.points, 0)
// In-progress tasks
const inProgressTasks = yield* db.entities.Tasks.byStatus({ status: "in-progress" }).collect()
// Closed tasks
const 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_with
const openPlatformTasks = yield* db.entities.Tasks.byStatus({
status: "open",
project: "platform",
}).collect()

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 salary
const allPMs = yield* db.entities.Employees.byRole({ title: "Product Manager" }).collect()
const wellPaidPMs = allPMs.filter((e) => e.salary >= "000100.00" && e.salary <= "000200.00")

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 atomically
const newHire = yield* db.entities.Employees.get({ employee: "jordan" })
const { Tasks: onboardingTasks } = yield* db.collections.assignments!({
employee: "jordan",
}).collect()
// Verify new hire appears in teams query
const devTeamAfterHire = yield* db.entities.Employees.byTeam({ team: "development" }).collect()
// Verify new open task appears in statuses query
const 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" task
yield* db.entities.Tasks.delete({ task: "user-research", project: "website", employee: "morgan" })
// Verify: closed tasks no longer include the archived one
const closedAfterArchive = yield* db.entities.Tasks.byStatus({ status: "closed" }).collect()
// Verify: morgan's assignments no longer include the archived task
const { Tasks: morganTasks } = yield* db.collections.assignments!({
employee: "morgan",
}).collect()
// But the archived task is still retrievable via deleted.get
const 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 queries
const 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.


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.

Terminal window
docker run -d -p 8000:8000 amazon/dynamodb-local
Terminal window
npx tsx examples/task-manager.ts

The example wires dependencies via Effect Layers — no global config or constructor options:

main.ts
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),
)

ConceptHow it’s used
Single-table design3 entities, 1 table, 5 GSIs with structured key values
Index overloadingGSI1 serves employees-by-office, tasks-by-project, and offices-by-office
CollectionsWorkplaces (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 filteringCollect from collection, filter in application code for date and salary ranges
Status workflowUpdating status triggers automatic GSI recomposition across all affected indexes
Soft delete archivalClosed tasks archived via softDelete — invisible to queries, preserved for audit
RestoreEntity.restore recomposes all keys, returning archived items to full index visibility
TransactionsAtomic multi-entity writes for employee onboarding