Writing Single-Table DynamoDB Apps with DynamoModel
Every once in a while you face a new paradigm that changes everything you knew about a specific domain. If you worked on iOS for a while and knew UIKit, you might have felt this way when SwiftUI came out: forget everything you know about UIKit, and get ready to start from scratch. This same feeling happened to me when I tried to build a Lambda function that stored data using DynamoDB.
If you come from an background on iOS development, you might have used Core Data or Realm (RIP) to store data locally. If you’ve done backend development, you might have used a relational database like PostgreSQL or MySQL. However, DynamoDB is the mother of all NoSQL databases, and it’s a different beast. Joins? They don’t exist. Multiple tables? You’re doing it wrong.
In this post, you’ll go over a brief introduction to single-table design in DynamoDB, and learn how a tiny and dependency-free library can help you standardize your data models, making it easier to
A Primer on Single-Table Design
DynamoDB has a few advantages over traditional relational databases: it’s fully managed, highly scalable, and has a pay-as-you-go pricing model. However, this also means that it’s very different from traditional databases. First, it doesn’t have a defined schema (except for the primary key and sort key properties), so you can store data in any way you want. Additionally, there are no joins, and you shouldn’t need more than one table to store all your data - every read or write operation should be done in a single transaction. In the end of the day, you’ll need to think about how to access your data and from there think of the data model.
To make it easier to understand, let’s think about an example of an app that allows users to track their running workouts.
Using a Relational Database
If you were using a database such as PostgreSQL or MySQL, you would have something along these lines:
Table 1: users
| id | username | |
|---|---|---|
| 1 | Tim Apple | tim@example.com |
| 2 | Hair Force One | hair@example.com |
And in another table, you would store the workouts:
Table 2: workouts
| id | user_id | distance | duration |
|---|---|---|---|
| 1 | 1 | 10 | 30 |
| 2 | 2 | 5.5 | 15.2 |
| 3 | 1 | 8 | 25 |
To retrieve the workouts for a user, you would need to join the two tables. In this example, it’s not a big deal, but if you had more data, it would become more complex.
Using DynamoDB
With DynamoDB, as stated before, you need to think how you’ll access your data and from there think of the data model. To store the same information in DynamoDB, you would have something along these lines.
Important: PK stands for Primary Key and SK stands for Sort Key. A table can have only a primary key configured, but using it together with the sort key, you can achieve a very flexible data model. An object is uniquely identified and therefore queried by its primary key and sort key.
| PK | SK | ID | Name | Distance | Duration | |
|---|---|---|---|---|---|---|
| USER#1 | PROFILE | 1 | Tim Apple | tim@example.com | ||
| USER#2 | PROFILE | 2 | Hair Force One | hair@example.com | ||
| USER#1 | WORKOUT#1 | 1 | 10 | 30 | ||
| USER#2 | WORKOUT#2 | 2 | 5.5 | 15.2 | ||
| USER#1 | WORKOUT#3 | 3 | 8 | 25 |
Notice a few things about this table:
- For rows that represent a user, distance and duration are empty, because they are not applicable to the user.
- For rows that represent a workout, the ID represents the workout ID (and not the user ID), and the name and email are empty, because they are not applicable to the workout.
- Data that belongs to the same user, either a profile or a workout, are grouped together under the same primary key (USER#1 or USER#2).
One of the fundamental patterns in single-table DynamoDB design is using prefixes in your primary and sort keys to discriminate between different entity types. In the example above, you can see how USER# prefixes in the primary key group all user-related data together, while the sort key uses PROFILE and WORKOUT# prefixes to distinguish between user profiles and individual workouts. This pattern allows you to efficiently query all items belonging to a specific user (by querying on the PK), while also enabling you to filter or retrieve specific entity types using the sort key.
If you want to learn more about single-table design, check out the links in the Explore Further section at the end of the post.
Prefixes act as a form of metadata embedded directly in your keys, eliminating the need for separate tables or complex joins while maintaining clear boundaries between different entity types in your application. In the context of a Swift app, this results in safe decoding from key-value objects into your Decodable data types.
This is an extremely simple example, but it scratches the surface of how your app can use DynamoDB to store data.
Modeling Your Data
After you have invested enough thought on your table design, you’ll want to start coding your models. In an initial approach, you might start by creating the user model from the first row of the table:
struct User: Codable {
let pk: String
let sk: String
let id: UUID // This is the user ID
let name: String
let email: String
} While this is a valid approach and it definitely works, you’ll notice that for every model you create, you’ll be repeating the primary key and sort key properties again and again, while they mostly do not change. Not only that, but these two DynamoDB properties are not intrinsically part of your model, but rather a way to represent the user in DynamoDB itself.
Eliminating Boilerplate
With that in mind, another, better approach which does not involve subclassing, might be to create a protocol that your models can conform to:
protocol DynamoModel {
var partitionKey: String { get }
var sortKey: String? { get }
} A few small notes: the partition key is always required by DynamoDB, the sort key might be optional, although it’s highly recommended to use it. Additionally, it can be from other types than String, but for simplicity’s sake, we’ll keep it as a String for now.
Now, you could conform your models to this protocol like this:
extension User: DynamoModel {
var partitionKey: String { "USER#(id)" }
let sortKey: String? = "PROFILE"
} In the case above, the partition key depends on the user ID, so it has to be a computed property. The sort key is always the same, so it can be a constant.
For the workout model, we could do something similar:
struct Workout: Codable {
let id: UUID // This is the workout ID
let userId: UUID // This is the user ID
let distance: Double
let duration: Double
} Conforming to the protocol is similar to the user model, but this time the sort key depends on the workout ID. Notice how the partition key is the same as the user model, because the workout belongs to the user.
extension Workout: DynamoModel {
var partitionKey: String { "USER#(userId)" }
var sortKey: String? { "WORKOUT#(id)" }
} For now, both models are Codable, but the partition key and sort key are not included in the encoding/decoding, because these two properties are not part of the model itself. If you’re asking how you can make sure that both keys are included in the encoding/decoding, worry not: this is exactly what the next section is about.
Including the Keys in the Encoding/Decoding
One possible solution is to create a wrapper struct that takes the partition key and the sort key into encoding and decoding. You could start with the following:
struct DynamoModelOf<T: Codable & DynamoModel>: Codable {
private(set) var base: T
public init(base: T) {
self.base = base
}
} So far, this only wraps the base model, and doesn’t take the keys into account - it will just encode the base model under the base key. To do that, implementing encode(to:) is necessary. Starting with some pseudo-code:
func encode(to encoder: any Encoder) throws {
// 1. Get the encoder container
// 2. Encode the partition key and the sort key
// 3. Encode the base model
} All of the properties - the base model, and both keys - need to be encoded in the same root container. For example, below you can see a JSON representation of the user being wrapped by the DynamoModelOf struct, without the custom encoding, compared to the one with the custom encoding, side by side:
Without custom encoding
{
"pk": "USER#1",
"sk": "PROFILE"
"base": {
"id": "1",
"name": "Tim Apple",
"email": "tim@example.com"
}
}With custom encoding
{
"pk": "USER#1",
"sk": "PROFILE",
"id": "1",
"name": "Tim Apple",
"email": "tim@example.com"
}Meet the DynamoModel Package
Achieving this is not trivial, even though it seems like a simple task in a first moment. For that reason, we’ve created a package that does that: DynamoModel. Its purpose is to serve as a skeleton for your models in a single-table design, by providing both DynamoModel and DynamoModelOf, while also taking care of the encoding/decoding.
To perform that, the package implements a few tricks:
- Implements a custom, non-enum
CodingKeywhich can accept any string value - be it the partition key, the sort key, or any other property name from the base model. - Implements a custom
Encoderto keep all the properties in the same root container.
Covering these two points is out of scope for this post, but you can check the package’s source code if you’re curious about the implementation details.
Dynamic Member Lookup
The last bit that the wrapper struct does, is be @dynamicMemberLookup so you can access the properties of the base model as if they were part of the wrapper struct itself, without having to access the base property everywhere:
No Dependencies
More importantly, the package has no dependencies in the AWS Swift SDK or in the community-maintained Soto package - it relies solely on Foundation, and can be used with any DynamoDB library you choose, as long as it relies on Codable for encoding/decoding.
Explore Further
Congrats for reaching the end of another post! DynamoDB is a powerful database with many advantages, and it makes you start thinking in new and creative ways! Learning how to use it effectively is essential for many reasons. If you want to discover more about it, make sure to check the following links:
- Swift Server Side Meetup #4: Serverless Swift with Hummingbird and DynamoDB
- DynamoDB Guide by Alex DeBrie
- Alex DeBrie’s talk at AWS re:Invent 2023 - Advanced data modeling with Amazon DynamoDB (DAT410)
- Rick Houlihan’s talk at AWS re:Invent 2018: Amazon DynamoDB Deep Dive: Advanced Design Patterns for DynamoDB (DAT401)
- Soto: DynamoDB and Codable
If you have any questions or comments, feel free to reach out on Mastodon or X.
See you at the next post. Have a good one!
