Skip to content

Entities

Entities define each data model that belongs to a respective table. Following Single Table design, this means that one table will have many Entities belonging to it.

flowchart TD
  A[Table];
  A --> B[Entity];
  A --> C[Entity];
  A --> D[Entity];

In its most basic form, an entity is simply a binding of schema that defines the shape of your data using Zod, and the DynamoTable that it belongs to.

import { DynamoTable, DynamoEntity } from 'dynamo-document-builder';
import { z } from 'zod';
const parentTable = new DynamoTable({
// table config
});
const exampleEntity = new DynamoEntity({
table: parentTable,
schema: z.object({
id: z.string(),
name: z.string(),
}),
});

Many times when working with DynamoDB (especially when working with Single Table design), you will want to compute your primary key attributes from the data in your item.

For example, let’s assume we have the following Todo entity:

const todoEntity = new DynamoEntity({
table: parentTable,
schema: z.object({
id: z.string(),
userId: z.string(),
title: z.string(),
completed: z.boolean(),
}),
});

We might decide that we want our partition key to be a combination of the ID of the to-do itself (the id attribute) and the ID of the user that owns the to-do (the userId attribute). Typically in DynamoDB this would be modeled as a separate PK attribute with these two values combined like this: USER#<userId>#TODO#<todoId>.

However this now adds additional complexity to our application code, as we now have to ensure that every item persisted to DynamoDB has this PK attribute properly set.

Document Builder provides a way to automatically generate these types of keys for you, using a simple function passed to the entity constructor:

const todoEntity = new DynamoEntity({
table: parentTable,
schema: z.object({
id: z.string(),
userId: z.string(),
title: z.string(),
completed: z.boolean(),
}),
partitionKey: todo => `USER#${todo.userId}#TODO#${todo.id}`,
sortKey: todo => `COMPLETED#${todo.completed}`,
});

Now when we use Document Builder to write data to DynamoDB, it will automatically compute the PK and SK attributes for us based on the functions we provided.

Creating keys that are separated by # is such a common pattern in DynamoDB that Document Builder provides a helper function called key that you can use to simplify the process of creating these composite keys.

const todoEntity = new DynamoEntity({
table: parentTable,
schema: z.object({
id: z.string(),
userId: z.string(),
title: z.string(),
completed: z.boolean(),
}),
partitionKey: todo => key('USER', todo.userId, 'TODO', todo.id),
sortKey: todo => key('COMPLETED', todo.completed),
});

This code would result in the same generated keys as the previous example, but now is a little bit more readable.

In addition to defining the primary keys for an entity, you can also define key builders for both global and local secondary indexes.

First, you’ll need to define the actual names of the secondary index attribute keys in the table:

const table = new DynamoTable({
tableName: 'ExampleTable',
documentClient: DynamoDBDocumentClient.from(new DynamoDBClient()),
keyNames: {
partitionKey: 'PK',
sortKey: 'SK',
globalSecondaryIndexes: {
GSI1: {
partitionKey: 'GSI1PK',
sortKey: 'GSI1SK',
},
},
localSecondaryIndexes: {
LSI1: {
sortKey: 'LSI1SK',
},
},
},
});

In the above example, GSI1 and LSI1 are the names of your secondary indexes and are what you use when needing to refer to them elsewhere in your code.

Then you can define the corresponding key builders in the entity itself:

const exampleEntity = new DynamoEntity({
table,
schema: z.object({
id: z.string(),
category: z.string(),
createdAt: z.string(),
}),
partitionKey: item => key('CATEGORY', item.category),
sortKey: item => key('ITEM_ID', item.id),
globalSecondaryIndexes: {
GSI1: {
partitionKey: item => key('ITEM_ID', item.id),
sortKey: item => key('CREATED_AT', item.createdAt),
},
},
localSecondaryIndexes: {
LSI1: {
sortKey: item => key('CREATED_AT', item.createdAt, 'ITEM_ID', item.id),
},
},
});

Now if you were to Put an item using this entity, Document Builder would automatically generate the appropriate attributes for the primary keys as well as the secondary index keys (example below).

{
"PK": "CATEGORY#Books",
"SK": "ITEM_ID#123",
"GSI1PK": "ITEM_ID#123",
"GSI1SK": "CREATED_AT#2024-06-01T12:00:00Z",
"LSI1SK": "CREATED_AT#2024-06-01T12:00:00Z#ITEM_ID#123",
"id": "123",
"category": "Books",
"createdAt": "2024-06-01T12:00:00Z"
}

When defining secondary indexes, you may not always want every item in your entity to be indexed. For example, you may only want to index items that have a specific attribute set.

Document Builder supports this use case by allowing you to return undefined from your secondary index key builders. If either the partition key or sort key builder for a secondary index returns undefined, then that index will not be created for that item.

const sparseIndexEntity = new DynamoEntity({
table: parentTable,
schema: z.object({
id: z.string(),
title: z.string(),
publishedAt: z.string().optional(),
}),
partitionKey: item => key('ITEM', item.id),
sortKey: () => 'METADATA',
globalSecondaryIndexes: {
PublishedItemsIndex: {
partitionKey: item => item.publishedAt
? key('PUBLISHED_AT', item.publishedAt)
: undefined,
sortKey: item => item.publishedAt
? key('ITEM_ID', item.id)
: undefined,
},
},
});

In the above example, the PublishedItemsIndex will only be created for items that have the publishedAt attribute set. If publishedAt is undefined, then the index keys will also be undefined and the index will not be created for that item.

Similar to the key function for creating composite primary keys, Document Builder also provides an indexKey function for creating secondary index keys.

This function works the same way as key, but is intended to be used specifically for sparse secondary indexes.

const sparseIndexEntity = new DynamoEntity({
table: parentTable,
schema: z.object({
id: z.string(),
title: z.string(),
publishedAt: z.string().optional(),
}),
partitionKey: item => key('ITEM', item.id),
sortKey: () => 'METADATA',
globalSecondaryIndexes: {
PublishedItemsIndex: {
partitionKey: indexKey('PUBLISHED_AT', item.publishedAt),
sortKey: indexKey('ITEM_ID', item.id),
},
},
});

The main difference with using indexKey is that if any of the values passed to it are undefined, then the entire key will be undefined, making it easier to create sparse indexes.

Document Builder uses the same command pattern as the AWS SDK v3 to perform operations on entities. This is done using the .send() method on the entity instance.

await todoEntity.send(new Put({
item: {
id: '123',
userId: '456',
title: 'Buy groceries',
completed: false,
},
}));

Using TypeScript, we don’t really care about the type of the DynamoEntity itself, rather the type of the schema data that the entity represents.

For this, Document Builder providers a uility type Entity<T> that you can use to infer the type of your entity’s schema data. This works exactly like Zod’s infer utility type.

const userEntity = new DynamoEntity({
table: parentTable,
schema: z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
}),
});
type User = Entity<typeof userEntity>;

Zod introduced Codecs in version 4.1 which enable developers to implement custom serialization and deserialization logic for their schemas. Document Builder takes full advantage of this feature internally and uses the appropriate encode and decode (async) methods when reading and writing data to DynamoDB.

This allows enables you as the developer to more easily work with higher-level data types in your entity schemas (such as Date, Map, and Set), while still having the data properly persisted to DynamoDB.

Here’s an example of using Codecs to work with Date attributes in your entity schema:

const stringToDate = z.codec(
z.iso.datetime(),
z.date(),
{
encode: date => date.toISOString(),
decode: isoString => new Date(isoString),
}
);
const eventEntity = new DynamoEntity({
table: parentTable,
schema: z.object({
id: z.string(),
name: z.string(),
eventDate: stringToDate,
}),
});
await eventEntity.send(new Put({
item: {
id: '1',
name: 'Conference',
eventDate: new Date(2025, 1, 1),
},
}));
import { DynamoEntity } from 'dynamo-document-builder/core/entity';