In the previous post, we talked about why Swift, why OpenAPI, and why DynamoDB. Now it’s time to start building. In this post, we’ll create the data layer for task-cluster: the domain model, a repository protocol for storage abstraction, and a DynamoDB implementation.
I’m building this with the help of an AI coding agent, so rather than presenting polished code blocks, I’ll walk through the prompts I used at each step and discuss what they produced and why. If you’re following along with your own AI assistant – or just writing the code by hand – the intent behind each step matters more than the exact wording.
We’re starting from an empty Swift package. By the end of this post, we’ll have two targets – TaskClusterModel and TaskClusterDynamoDBModel – with a clean separation between domain logic and persistence.
Step 1: Initialising the Package
First, we need a Swift package. If you don’t already have one:
mkdir task-cluster && cd task-clusterswift package init --type executable --name task-cluster
This gives us a Package.swift with a tools version and an executable target. We’ll be restructuring it heavily, so the scaffolded code is mostly a starting point. Make sure the tools version is set to 6.2 – we’ll need features from the latest Swift toolchain.
// swift-tools-version: 6.2
Step 2: The Domain Model
Before we think about databases or APIs, we need to define what a task is. This is the core of the data layer – a set of types that represent the domain, with no dependencies on any framework or library.
So what does a task look like as a concept? At minimum, it needs an identity (a unique ID), a description of the work (a title, and optionally a longer description), and some notion of importance (priority). Tasks also exist in time – they’re created, they may have a deadline, and they move through a lifecycle. That lifecycle is the status: a task starts as pending, transitions to running when work begins, and eventually reaches a terminal state – completed, failed, or cancelled. We also want to track when things happened, so every task carries a createdAt and updatedAt timestamp.
Here’s how I prompted the agent:
Create a
TaskClusterModeltarget in Package.swift with no dependencies. Add a fileSources/TaskClusterModel/TaskModel.swiftthat defines:
- A
TaskStatusenum with cases: pending, running, completed, failed, cancelled. It should be aString-backed,Codable,Sendable,Equatableenum.- A
TaskItemstruct that isCodable,Sendableand ,Equatable, with properties:taskId(UUID),title(String),description(String?),priority(Int),dueBy(Date?),status(TaskStatus),createdAt(Date),updatedAt(Date). Provide an initialiser with sensible defaults –taskIdshould default to a new UUID,statusto.pending, and the timestamps to the current date.Everything should be package access level. The target should have zero external dependencies.
Why these choices?
Zero dependencies. The domain model target has an empty dependencies: [] array in Package.swift. This is deliberate. Your domain types are the foundation everything else builds on – they shouldn’t be coupled to a web framework, a database library, or anything else. If you later decide to swap Hummingbird for Vapor, or DynamoDB for Postgres, TaskClusterModel doesn’t change.
Codable, Sendable and Equatable. Every type in the model conforms to both. Codable because these types will be serialised to DynamoDB’s storage format. Sendable because Swift 6 enforces concurrency safety, and any type that crosses an isolation boundary – which domain types inevitably do – must be Sendable. Since TaskItem is a struct composed entirely of Sendable types (UUID, String, Int, Date, Optional, and our own Sendable enum), the conformance is automatic. Equatable conformance is useful for testing and is similarly automatic (performing a member-wise comparison).
String-backed enum. TaskStatus uses String raw values, which means Codable synthesis produces clean JSON: "status": "pending" rather than "status": 0. The raw strings also make debugging and logging more readable, and they’ll map cleanly to the OpenAPI spec’s string enum when we build the API layer later.
Mutable properties. TaskItem uses var for its properties rather than let. This might seem surprising – immutable-by-default is generally good practice – but for a domain model that goes through status transitions and priority updates, mutability on a value type is fine. When you write var task = existingTask; task.status = .cancelled, you get a copy with the new status. The original is untouched. Value semantics give you the safety guarantees that immutability would, without the ceremony of builder patterns or with-style copy methods.
Step 3: The Repository Protocol
With the domain model defined, we need an abstraction for storage. The repository pattern gives us a protocol that defines what we can do with tasks, without specifying how. Different implementations can back this with an in-memory dictionary, DynamoDB, Postgres, or anything else.
Add a file
Sources/TaskClusterModel/TaskRepository.swiftto theTaskClusterModeltarget. Define a packageTaskRepositoryprotocol that isSendable, with three methods:
create(task: TaskItem) async throws -> TaskItemget(taskId: UUID) async throws -> TaskItem?update(task: TaskItem) async throws -> TaskItem
A few things worth noting about this interface:
The protocol lives in the model target, not a separate “repository” target. You might be tempted to create a dedicated package for the abstraction, but at this scale that’s unnecessary indirection. The repository protocol is part of the domain vocabulary – it defines the operations the domain supports. Implementations live elsewhere; the contract lives with the model.
The protocol is Sendable. Any type conforming to TaskRepository must itself be Sendable, which means it can be safely shared across concurrency domains. This is important because the repository will be injected into request handlers that may run concurrently. Swift 6’s concurrency checking will enforce this at compile time – if you write a non-Sendable repository implementation, the compiler will tell you.
get returns an optional. A missing task isn’t an error in the domain sense – it’s a valid outcome. The caller decides what to do with nil (return a 404, throw an error, skip processing). Throwing on “not found” would force every caller to catch and inspect the error type, which is more ceremony for no benefit.
create and update return the task. This lets implementations enrich the returned value if needed (adding server-generated fields, for example) and gives callers a consistent pattern: call the method, use the returned value. Even if the current implementation just returns the input unchanged, the contract allows for richer behaviour later.
No delete method. The task-cluster domain doesn’t support deletion – tasks are cancelled, not removed. The API has a cancel endpoint that transitions status, but the record persists. If we need deletion later, we add it to the protocol then. Don’t design for hypothetical requirements.
Step 4: The DynamoDB Target
Now we need a separate target for the DynamoDB implementation. This keeps the persistence logic out of the domain model – TaskClusterModel stays dependency-free, and TaskClusterDynamoDBModel brings in only what it needs.
Add the https://github.com/swift-server-community/dynamo-db-tables package dependency to Package.swift. Create a
TaskClusterDynamoDBModeltarget that depends onTaskClusterModeland on theDynamoDBTablesproduct.
The target declaration is straightforward:
.target( name: "TaskClusterDynamoDBModel", dependencies: [ "TaskClusterModel", .product(name: "DynamoDBTables", package: "dynamo-db-tables"), ]),
The dependency graph is now clear: TaskClusterDynamoDBModel depends on TaskClusterModel for the domain types and on DynamoDBTables for the persistence layer. Higher-level targets that don’t need DynamoDB can depend on TaskClusterModel alone without pulling in the DynamoDB library.
Step 5: The DynamoDB Repository Implementation
With the target structure in place, we can implement the DynamoDB-backed repository. The dynamo-db-tables library provides a Swift-native abstraction over DynamoDB that works with Codable types. It handles serialisation, key management, and optimistic concurrency control.
Create a
Sources/TaskClusterDynamoDBModel/DynamoDBTaskRepository.swift. Implement aDynamoDBTaskRepositorystruct that:
- Is generic over
Table: DynamoDBCompositePrimaryKeyTable & Sendable- Conforms to
TaskRepository- Uses a type alias
TaskDatabaseItem = StandardTypedDatabaseItem<TaskItem>- Uses partition key
"TASK"and sort key"TASK#\(taskId)"for all operations- Implements
createusinginsertItem,getusinggetItem(returning therowValue), andupdatethat fetches the existing item first and usescreateUpdatedItem- Defines a
TaskRepositoryError.notFounderror for update operations on missing items
Design decisions in the implementation
Generic over the table type. The repository takes a generic Table parameter rather than a concrete DynamoDB client. This is critical for testing – in our tests, we’ll pass an InMemoryDynamoDBCompositePrimaryKeyTable that the library provides, giving us a fast, isolated test without any AWS infrastructure. In production, we’ll pass a real DynamoDB table client.
The key schema. Every task uses partition key "TASK" and sort key "TASK#<uuid>". This is a common DynamoDB pattern – the partition key groups items by entity type, and the sort key provides uniqueness within the partition. For our current access patterns (get by ID, create, update), this is all we need. If we later need to query tasks by status or priority, we’d add a Global Secondary Index with a different key structure, but we don’t design for that until we need it.
Optimistic concurrency on update. The update method fetches the existing item first, then calls createUpdatedItem to produce a new version, and finally calls updateItem with both the new and existing items. This is how dynamo-db-tables implements optimistic concurrency control – the library checks that the item hasn’t been modified between the read and the write. If another request updated the same task concurrently, the update will fail with a conditional check error rather than silently overwriting the other change. This is exactly the behaviour you want in a concurrent system.
The StandardTypedDatabaseItem wrapper. DynamoDB items need metadata beyond the domain value – the primary key, a version number for optimistic locking, timestamps for the database row itself. StandardTypedDatabaseItem<TaskItem> wraps our domain struct with this metadata. When reading, we extract the domain value with .rowValue. When writing, the library manages the metadata automatically. Our domain type stays clean – TaskItem doesn’t know it’s being stored in DynamoDB.
Step 6: Testing the DynamoDB Repository
We want to test the repository implementation without connecting to AWS. The dynamo-db-tables library provides InMemoryDynamoDBCompositePrimaryKeyTable, which behaves like a real DynamoDB table but stores everything in memory. This gives us fast, deterministic tests that verify our key schema, and error handling.
Create a
TaskClusterDynamoDBModelTeststest target that depends onTaskClusterDynamoDBModel,TaskClusterModel, andDynamoDBTables. Add tests for:
- Create a task and retrieve it by ID – verify all fields round-trip correctly
- Get returns nil for a non-existent task ID
- Update modifies a stored task and the changes persist
- Update throws for a non-existent task
- Creating the same task twice throws (duplicate key)
Use
InMemoryDynamoDBCompositePrimaryKeyTableas the table implementation. Use a well known date for the tests to avoid precision issues. You can use theEquatableconformance of TaskItem to compare returned and expected tasks
Each test instantiates a fresh InMemoryDynamoDBCompositePrimaryKeyTable and a DynamoDBTaskRepository wrapping it. There’s no shared state between tests, no setup/teardown ceremony. The tests verify the contract: can we store a task and get it back? Does update correctly reject a non-existent item? Does insert correctly reject a duplicate?
The duplicate-key test is particularly important. In production DynamoDB, insertItem will fail if an item with the same key already exists – this prevents accidental overwrites. The in-memory table faithfully replicates this behaviour, so we can verify our code handles it correctly without deploying anything.
InMemoryDynamoDBCompositePrimaryKeyTable is a good-enough approximation of DynamoDB’s functionality for unit tests, giving us a basic level of confidence without needing a more complex setup. Later we will incorporate integration tests for an even greater level of confidence.
To run these tests:
swift test --filter TaskClusterDynamoDBModelTests
What We Have So Far
Two targets, clean separation, zero coupling between domain and persistence:
Sources/ TaskClusterModel/ TaskModel.swift # TaskItem, TaskStatus TaskRepository.swift # Repository protocol TaskClusterDynamoDBModel/ DynamoDBTaskRepository.swift # DynamoDB implementationTests/ TaskClusterDynamoDBModelTests/ DynamoDBTaskRepositoryTests.swift # 5 tests
TaskClusterModel has zero external dependencies – it’s pure Swift. TaskClusterDynamoDBModel depends on TaskClusterModel for the domain types and on DynamoDBTables for persistence. Higher-level targets that don’t need DynamoDB can depend on TaskClusterModel alone.
The repository protocol is the seam between these layers. Higher-level code (controllers, request handlers) will depend on TaskRepository without knowing or caring which implementation backs it. We’ll also add an InMemoryTaskRepository for local development – a simple actor wrapping a dictionary – but that’s a trivial implementation that doesn’t warrant its own walkthrough.
What’s Next
We have domain types and persistence. The next step is to expose them through an API. In the next post, we’ll design the OpenAPI spec and set up the swift-openapi-generator build plugin.
Leave a comment