AWS Serverless and DynamoDb Single Table Design using .Net 6 – Part 1
Introduction
When developing a high performance scalable application everybody tends to use the below technologies.
- Serverless
Functions
orLambdas
- Cloud managed
NoSQL
databases likeDynamoDb
orCosmosDb
- Database design strategies like
Single Table Design
In this article we’ll cover about Single Table Design
. Next part we’ll create a Serverless application using .Net 6
and DynamoDb
.
Use Case
Recently we worked on a Social Networking platform and we used Single Table Design
in that project. That use case is very complex and overwhelming for a beginner, so let’s consider an imaginary use case (this may not exactly fit the Single Table Design). But let’s consider an Employee REST API which will help us to design a basic Single Table Design
. Here are the features of the API:
- User will be able to add a Employee
- User will be able to fetch the Employee details with the
EmployeeCode
- User will be able to fetch the immediate Reportees (for the sake of simplicity) of the Employee/Manager
Schema
EmployeeCodeEmailIdFirstNameLastNameReportingManagerCode
In the RDBMS world EmployeeCode
will be the Primary Key
and ReportingManagerCode
will be the foreign key
pointing to the same table using self join.
Single Table Design
In RDBMS, we use multiple tables in a database, and that tables may be interrelated with foreign keys and we tend to normalize the tables up to a certain level and avoid duplicate storage as far as we can. In the NoSQL world(especially in DynamoDb
), there are no foreign keys and joins(and there is a reason for that), and do not care about duplicacy. In Single Table Design
, we put all the entities(eg: Post, User, Comment, Follower etc.) in a single table and may use the ‘Type’ attribute to identify the entities.
Why
In a Read Heavy database, many(millions of) users will be accessing the different content at the same time. So you have to fetch the data as fast as possible. If you want to return the data quickly you have to minimize the database requests for a single API call. In RDBMS even though we’re making a single call most of the queries will have complex joins and involve multiple tables, as the data size increases these queries take more time.
If you have to fetch the Posts of all the users who I’m following, then in SQL-based databases you have to join ‘Users’, ‘Followers’, ‘Posts’, ‘Comments’ etc. If you store the entities in separate DynamoDb tables then you’ve to make multiple calls from your backend to DynamoDb and do some JSON manipulations and return that to the Frontend. We cannot afford that many Db calls from the backend, so we need to get all the data in a single Db request.
How
In DynamoDb within each table, you must have a partition key, which is a string, numeric, or binary value. This key is a hash value used to locate items in constant time regardless of table size. It is conceptually different to an ID or primary key field in a SQL-based database and does not relate to data in other tables. When there is only a partition key, these values must be unique across items in a table.
Each table can optionally have a sort key. This allows you to search and sort within items that match a given primary key. While you must search on exact single values in the partition key, you can pattern search on sort keys. It’s common to use a numeric sort key with timestamps to find items within a date range, or use string search operators to find data in hierarchical relationships.
With only partition keys and sort keys, this limits the possible types of query without duplicating data in a table(even though there is no harm in duplicating the data as storage cost is very less, but modifying the multiple copies is another headache). To solve this issue, DynamoDB also offers two types of indexes ie: Local secondary indexes (LSIs) and Global secondary indexes (GSIs). We can discuss these topics in a separate session.
Single Table Design is not ‘Agile’, you have to identify the Data Access Patterns in the beginning of the project, otherwise you may identify a use case later which may require an entire redesign of the data structure. So let’s identify our access patterns first.
Data Access Patterns
Our patterns are the GET API responses we discussed earlier. In our case it’s simple as of now.
- User will be able to fetch the Employee details with the EmployeeCode
- User will be able to fetch the immediate Reportees of the Employee/Manager
User will be able to add an Employees
Let’s create the table as follows. Partition Key is EmployeeCode, and Sort Key is ReportingManagerCode
Now things look simple, we can get the entity based on pk, let’s evaluate the next access pattern and come back here if required.
User will be able to fetch the immediate Reportees of the Employee/Manager
Suppose we need to fetch all the direct reportees of user 11. We can see if we can query using the sort key then we could have fetched all the reportees of 11 in a single query, but here your challenges start. You cannot query a DynamoDb table without providing a pk equals statement. So if you query pk=11 then you’ll get only one record.
Now the next step is to evaluate whether we can use LSI or GSIs to solve the problem or not. LSI is just another sort key and in query we need to provide pk then LSI won’t work here. If we use GSI then you can create one more pk and sk, but then you can’t use the main pk or sk.
Next option is to duplicate the data, let’s think about that. In the above table structure one record was self-sufficient(it had both EmployeeCode and ReportingManagerCode), but in the below format.we separated the entity. We also added a type attribute to identify the type of entity.
In user entities both pk and sk are the same (ie EmployeeCode). We duplicated the same entities and swapped the pk and sk and assigned the type as ‘reportee’. Now let’s evaluate the query. If we ran a query as pk=11 we will get three records
One record is for the Manager and multiple records for the reportees, we can filter out the user type by filter expressions if required. So the second access pattern has been solved, but we’ve a problem in the first access pattern now. Our first query was pk=11
, but now that will return 3 records so we need to fix that. We can use pk=11
and sk=11
. Solved!
Conclusions
The use case we discussed here was very basic one, but still we had to take care of multiple things and we also had to duplicate the data. Duplicating the data will further complicate things like updation
and deletions
etc. You may need to implement Message Queues
like SQS
and BackgroundService
to solve that issue.
In the next article, we will cover the actual practical implementation with code samples.