Efficient DynamoDB in Kotlin with Tempest
Published: 2021-07-02 05:23:50

## Efficient DynamoDB

DynamoDB applications perform best (and cost the least to operate!) when data is organized for locality:

* **Multiple types per table**: The application can store different entity types in a single table. DynamoDB schemas are flexible.
* **Related entities are stored together**: Entities that are accessed together should be stored together. This makes it possible to answer common queries in as few requests as possible, [ideally one](https://www.alexdebrie.com/posts/dynamodb-single-table/#the-solution-pre-join-your-data-into-item-collections).

### Example

Let's build a music library with the following features:

* Fetching multiple albums, each of which contains multiple tracks.
* Fetching individual tracks.

We express it like this in code:

```kotlin
interface MusicLibrary {
  fun getAlbum(key: AlbumKey): Album
  fun getTrack(key: TrackKey): Track
}

data class Album(
  val album_title: String,
  val album_artist: String,
  val release_date: String,
  val genre: String,
  val tracks: List<Track>
)

data class Track(
  val track_title: String,
  val run_length: String
)
```

We optimize for this access pattern by putting albums and tracks in the same table:

<table>
  <tbody>
    <tr>
      <td colspan=2 align="center">Primary Key</td>
      <td rowspan=2 colspan=4 align="center" valign="top">Attributes</td>
    </tr>
    <tr>
      <td><strong>partition_key</strong></td>
      <td><strong>sort_key</strong></td>
    </tr>
    <tr>
      <!-- Note: It is important to declare both vertical-align and valign here. 
           vertical-align only works in the project website 
           while valign only works in Github's formatting for README.md. -->
      <td rowspan=2 style="vertical-align:bottom;" valign="bottom">ALBUM_1</td>
      <td rowspan=2 style="vertical-align:bottom;" valign="bottom">INFO</td>
      <td><strong>album_title</strong></td>
      <td><strong>album_artiest</strong></td>
      <td><strong>release_date</strong></td>
      <td><strong>genre</strong></td>
    </tr>
    <tr>
      <td>The Dark Side of the Moon</td>
      <td>Pink Floyd</td>
      <td>1973-03-01</td>
      <td>Progressive rock</td>
    </tr>
    <tr>
      <td rowspan=2 style="vertical-align:bottom;" valign="bottom">ALBUM_1</td>
      <td rowspan=2 style="vertical-align:bottom;" valign="bottom">TRACK_1</td>
      <td><strong>track_title</strong></td>
      <td><strong>run_length</strong></td>
      <td>&nbsp;</td>
      <td>&nbsp;</td>
    </tr>
    <tr>
      <td>Speak to Me</td>
      <td>PT1M13S</td>
      <td>&nbsp;</td>
      <td>&nbsp;</td>
    </tr>
    <tr>
      <td rowspan=2 style="vertical-align:bottom;" valign="bottom">ALBUM_1</td>
      <td rowspan=2 style="vertical-align:bottom;" valign="bottom">TRACK_2</td>
      <td><strong>track_title</strong></td>
      <td><strong>run_length</strong></td>
      <td>&nbsp;</td>
      <td>&nbsp;</td>
    </tr>
    <tr>
      <td>Breathe</td>
      <td>PT2M43S</td>
      <td>&nbsp;</td>
      <td>&nbsp;</td>
    </tr>
    <tr>
      <td rowspan=2 style="vertical-align:bottom;" valign="bottom">ALBUM_1</td>
      <td rowspan=2 style="vertical-align:bottom;" valign="bottom">TRACK_3</td>
      <td><strong>track_title</strong></td>
      <td><strong>run_length</strong></td>
      <td>&nbsp;</td>
      <td>&nbsp;</td>
    </tr>
    <tr>
      <td>On the Run</td>
      <td>PT3M36S</td>
      <td>&nbsp;</td>
      <td>&nbsp;</td>
    </tr>
    <tr>
      <td colspan=6>...</td>
    </tr>
    <tr>
      <td rowspan=2 style="vertical-align:bottom;" valign="bottom">ALBUM_2</td>
      <td rowspan=2 style="vertical-align:bottom;" valign="bottom">INFO</td>
      <td><strong>album_title</strong></td>
      <td><strong>album_artiest</strong></td>
      <td><strong>release_date</strong></td>
      <td><strong>genre</strong></td>
    </tr>
    <tr>
      <td>The Wall</td>
      <td>Pink Floyd</td>
      <td>1979-11-30</td>
      <td>Progressive rock</td>
    </tr>
    <tr>
      <td rowspan=2 style="vertical-align:bottom;" valign="bottom">ALBUM_2</td>
      <td rowspan=2 style="vertical-align:bottom;" valign="bottom">TRACK_1</td>
      <td><strong>track_title</strong></td>
      <td><strong>run_length</strong></td>
      <td>&nbsp;</td>
      <td>&nbsp;</td>
    </tr>
    <tr>
      <td>In the Flesh?</td>
      <td>PT3M20S</td>
      <td>&nbsp;</td>
      <td>&nbsp;</td>
    </tr>
    <tr>
      <td colspan=6>...</td>
    </tr>
  </tbody>
</table>

This table uses a [composite primary key](https://aws.amazon.com/blogs/database/choosing-the-right-dynamodb-partition-key/), `(parition_key, sort_key)`, to identify each item.

* The key `("ALBUM_1", "INFO")` identifies `ALBUM_1`'s metadata.
* The key `("ALBUM_1", "TRACK_1")` identifies `ALBUM_1`'s first track.

This table stores tracks belonging to the same album together and sorts them by the track number. The application needs only one request to DynamoDB to get the album and its tracks.

```
aws dynamodb query \
    --table-name music_library_items \
    --key-conditions '{ 
        "PK": { 
            "ComparisonOperator": "EQ",
            "AttributeValueList": [ { "S": "ALBUM_1" } ]
        } 
    }'
```

## Why Tempest?

For locality, we smashed together several entity types in the same table. This improves performance! But it breaks type safety in DynamoDBMapper.

### DynamoDBMapper API

[`DynamoDBMapper`](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBMapper.html) / [`DynamoDbEnhancedClient`](https://sdk.amazonaws.com/java/api/latest/software/amazon/awssdk/enhanced/dynamodb/DynamoDbEnhancedClient.html), the official Java API, forces you to write weakly-typed code that models the actual persistence type.

```kotlin
// NOTE: This is not Tempest! It is an example used for comparison.
@DynamoDBTable(tableName = "music_library_items")
class MusicLibraryItem {
  // All Items.
  @DynamoDBHashKey
  var partition_key: String? = null
  @DynamoDBRangeKey
  var sort_key: String? = null

  // AlbumInfo.
  @DynamoDBAttribute
  var album_title: String? = null
  @DynamoDBAttribute
  var album_artist: String? = null
  @DynamoDBAttribute
  var release_date: String? = null
  @DynamoDBAttribute
  var genre: String? = null

  // AlbumTrack.
  @DynamoDBAttribute
  var track_title: String? = null
  @DynamoDBAttribute
  var run_length: String? = null
}
```

Note that `MusicLibraryItem` is a union type of all the entity types: `AlbumInfo` and `AlbumTrack`. Because all of its attributes are nullable and mutable, code that interacts with it is brittle and error prone.

### Tempest API

Tempest restores maintainability without losing locality. It lets you declare strongly-typed key and item classes for each logical type in the domain layer.

```kotlin
data class AlbumInfo(
  @Attribute(name = "partition_key")
  val album_token: String,
  val album_title: String,
  val album_artist: String,
  val release_date: String,
  val genre_name: String
) {
  @Attribute(prefix = "INFO_")
  val sort_key: String = ""

  data class Key(
    val album_token: String
  ) {
    val sort_key: String = ""
  }
}

data class AlbumTrack(
  @Attribute(name = "partition_key")
  val album_token: String,
  @Attribute(name = "sort_key", prefix = "TRACK_")
  val track_token: String,
  val track_title: String,
  val run_length: String
) {
  data class Key(
    val album_token: String,
    val track_token: String
  )
}
```

You build business logic with logical types. Tempest handles mapping them to the underlying persistence type.

```kotlin
interface MusicLibraryTable : LogicalTable<MusicLibraryItem> {
  val albumInfo: InlineView<AlbumInfo.Key, AlbumInfo>
  val albumTracks: InlineView<AlbumTrack.Key, AlbumTrack>
}

private val musicLibrary: MusicLibraryTable

// Load.
fun getAlbumTitle(albumToken: String): String? {
  val key = AlbumInfo.Key(albumToken)
  val albumInfo = musicLibrary.albumInfo.load(key) ?: return null
  return albumInfo.album_title
}

// Update.
fun addAlbumTrack(
  albumToken: String, 
  track_token: String, 
  track_title: String, 
  run_length: String
) {
  val newAlbumTrack = AlbumTrack(albumToken, track_token, track_title, run_length)
  musicLibrary.albumTracks.save(newAlbumTrack)
} 

// Query.
fun getAlbumTrackTitles(albumToken: String): List<String> {
  val page = musicLibrary.albumTracks.query(
    keyCondition = BeginsWith(AlbumTrack.Key(albumToken))
  )
  return page.contents.map { it.track_title }
}
```

## Get Tempest

See the [project website](https://cashapp.github.io/tempest) for documentation and APIs.

For AWS SDK 1.x:

```groovy
implementation "app.cash.tempest:tempest:1.5.2"
```

For AWS SDK 2.x:

```groovy
implementation "app.cash.tempest:tempest2:1.5.2"
```