Writing Single-Table DynamoDB Apps with DynamoModel

👍 0
Published: November 19, 2025
Written by:
Natan Rolnik
Natan Rolnik
👍 0

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

idusernameemail
1Tim Appletim@example.com
2Hair Force Onehair@example.com

And in another table, you would store the workouts:

Table 2: workouts

iduser_iddistanceduration
111030
225.515.2
31825

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.

PKSKIDNameEmailDistanceDuration
USER#1PROFILE1Tim Appletim@example.com
USER#2PROFILE2Hair Force Onehair@example.com
USER#1WORKOUT#111030
USER#2WORKOUT#225.515.2
USER#1WORKOUT#33825

Notice a few things about this table:

  1. For rows that represent a user, distance and duration are empty, because they are not applicable to the user.
  2. 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.
  3. 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:

  1. Implements a custom, non-enum CodingKey which can accept any string value - be it the partition key, the sort key, or any other property name from the base model.
  2. Implements a custom Encoder to 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:

let user = DynamoModelOf(base:
    User(id: 1, name: "Tim Apple", email: "tim@example.com")
)
// Without having to do user.base.id
user.id // 1

// Without having to do user.base.name
user.name // "Tim Apple"

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:

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!
Swift, Xcode, the Swift Package Manager, iOS, macOS, watchOS and Mac are trademarks of Apple Inc., registered in the U.S. and other countries.

© Swift Toolkit, 2024-2025