NoSQL modeling for the microservices of our order system.
About this module
Here you'll understand how DynamoDB thinks differently from relational databases and how to model data starting from access patterns. We'll create the project's three tables and validate them with real operations through the console and CLI.
Level: Intermediate · Estimated duration: 3-4 hours
Table of Contents
- Why NoSQL for microservices?
- DynamoDB mental model
- Partition key and sort key
- Capacity modes
- Global Secondary Indexes (GSIs)
- Modeling by access patterns
- Modeling the project's tables
- Creating the tables in the console
- Operations via CLI
- Practice exercises
- Next steps
01. Why NoSQL for microservices?
Before learning DynamoDB specifically, it's worth understanding why NoSQL databases make so much sense in microservices architectures — and why it isn't an automatic choice.
The natural fit with microservices
Microservices and NoSQL don't require each other, but they complement each other for structural reasons. NoSQL databases like DynamoDB were designed to scale horizontally, offer predictable latency, and provide a flexible schema — exactly what microservices need.
In a relational database, normalizing and doing JOINs is the natural path. In a microservices world, JOINs between services don't exist — each service has its own database. NoSQL pushes you to intentionally denormalize data, which is exactly what we need.
When NoSQL isn't the best choice
There are scenarios where SQL still wins: analytical reporting with ad-hoc queries, highly relational data with complex referential integrity, or when the team has far more SQL experience and the scaling gains don't justify the learning curve.
02. DynamoDB mental model
Forget everything you know about relational databases for ten minutes. DynamoDB has a different model — not worse, not better, just different. Forcing SQL patterns here leads to bad designs.
Tables, items, and attributes
- Table — a collection of items. It has no rigid schema — different items can have different attributes.
- Item — a "row" of the table. It's an object with any number of attributes. Each item is at most 400 KB.
- Attribute — a key-value pair within an item. It can be a string, number, boolean, list, map, set, or binary.
- Primary key — the unique identifier for each item. It's the only required attribute. It can be simple (just a partition key) or composite (partition + sort).
What does NOT exist in DynamoDB
- JOINs — you need to model to avoid them, or run multiple queries.
- Complex multi-table transactions — there's TransactWrite, but with strict limits.
- SQL — the language is via the SDK, with operations like PutItem, GetItem, Query, Scan.
- Rigid schema — you only define the keys; the rest is free per item.
- Foreign keys — there's no automatic referential integrity.
- Auto-increment — IDs must be generated by the application (usually UUIDs).
The inversion of thinking
In SQL, you model first (a normalized schema) and then think about the queries. In DynamoDB, it's the opposite: you list every query you're going to run (access patterns), and from that you design the tables. It's a complete inversion of flow.
03. Partition key and sort key
Choosing the keys is the most important decision in DynamoDB modeling. Getting it wrong means hot partitions, impossible queries, or sky-high costs. Getting it right is half the work.
Partition key (hash key)
It's the attribute that determines which physical partition the item will be stored in. DynamoDB hashes this value to distribute items across internal partitions — which is what enables horizontal scaling.
For efficient queries, you need the partition key. Without it, all you're left with is a Scan — an expensive, slow operation that reads the entire table.
Sort key (range key)
Optional. When present, it combines with the partition key to form a composite primary key. It lets multiple items share the same partition key, ordered by the sort key.
This is where much of DynamoDB's power lives: with a smart sort key, you can run queries like "all of customer X's orders between date Y and Z, ordered by date" with constant performance.
Hot partitions: the villain
If your partition key has low cardinality (few distinct values) or is poorly distributed, some items receive far more traffic than others. That's a hot partition — one overloaded partition while the others sit idle.
Bad example: using status (PENDING, PAID, CANCELLED) as the partition key. Since there are only 3 possible values, millions of orders will concentrate on 3 partitions. Result: throttling.
Good example: using orderId (UUID) as the partition key. Each order goes to a different partition — uniform distribution.
Operations: Get, Query, Scan
| Operation | What it does | Cost |
|---|---|---|
| GetItem | Fetches one exact item by the full primary key | Minimal (1 RCU) |
| Query | Fetches items within a partition key, with an optional filter on the sort key | Low |
| Scan | Reads the entire table, applying filters afterward | High — avoid! |
Scan is the last resort. If your application needs to scan regularly, your modeling is wrong. Use GSIs (next section) to create additional "views" over the data.
04. Capacity modes
How do you pay for DynamoDB? There are two modes, with quite different philosophies. Understanding when to use each can significantly reduce your bill.
Provisioned capacity
You reserve a fixed amount of capacity: RCUs (Read Capacity Units) and WCUs (Write Capacity Units). You pay per hour, regardless of usage.
1 RCU = 1 strongly consistent read of an item up to 4 KB per second, or 2 eventually consistent reads. 1 WCU = 1 write of an item up to 1 KB per second.
It's the cheap mode when traffic is predictable. If you know you have a constant 100 reads/sec, you provision 100 RCUs and pay little.
On-demand capacity
You reserve nothing. You pay per request — about 7x more expensive than provisioned at the same traffic, but with no risk of provisioning wrong.
It's the right mode when traffic is unpredictable or sporadic — exactly our case in a study environment.
Which one to use in the project?
| Scenario | Recommended mode |
|---|---|
| Learning / personal dev | On-demand |
| Production with predictable traffic | Provisioned with auto-scaling |
| Production with unpredictable spikes | On-demand |
| Periodic batch workloads | Provisioned |
| Free Tier | Provisioned (25 RCU/WCU free) |
Throttling
If you exceed the provisioned capacity, DynamoDB starts rejecting requests with a ProvisionedThroughputExceededException error. The SDK retries automatically with backoff, but if there's too much traffic, your operations will start failing.
05. Global Secondary Indexes
GSIs are the mechanism that makes DynamoDB truly flexible. Without them, you'd be stuck with queries by the primary key. With them, you can create alternative "views" over the same data.
What is a GSI?
A Global Secondary Index is an automatic copy of your table with a different primary key. You define which of the table's attributes are "projected" into the index, and DynamoDB keeps the index synchronized automatically.
When to use a GSI
Whenever you need an access pattern that doesn't use the main primary key. A concrete example:
- The Orders table with partition key orderId.
- A required access pattern: "list all of customer X's orders".
- Without a GSI, you'd have to Scan filtering by customerId — expensive and slow.
- With a customerId-index GSI (PK=customerId), a simple Query solves it.
The costs of a GSI
GSIs are not free:
- Each GSI has its own RCUs and WCUs (provisioned mode).
- Each write to the main table generates writes to the GSI — you pay 2x the WCUs (or more, if there are multiple GSIs).
- Additional storage, proportional to the projected attributes.
Eventual consistency in GSIs
GSIs are always eventually consistent. There's a small delay between the write to the main table and its propagation to the index — usually milliseconds, but it can be longer under load.
If you need strong consistency, read from the main table. GSIs are for queries where a small delay is acceptable (order lists, filters, near-real-time reports).
06. Modeling by access patterns
This is the methodology. It's how an AWS architect thinks when creating a DynamoDB table from scratch.
The 5 steps
- List all the entities in the domain (Order, Customer, Product, etc).
- List all the access patterns your application will need (e.g., "get an order by ID", "list a customer's orders", "list pending orders").
- Identify the main partition key that serves most of the patterns.
- Use a sort key to solve secondary patterns within the same partition.
- Create GSIs for the remaining patterns that don't fit the main key.
Example: the Orders table
Let's apply the method. What are the access patterns for Orders?
- AP1: get an order by orderId.
- AP2: list a customer's orders, ordered by date.
- AP3: list orders by status (e.g., all PENDING orders).
- AP4: fetch orders within a date range.
Now we think: orderId solves AP1. For AP2, we can use a GSI with PK customerId and SK createdAt. AP3 suggests another GSI with PK status and SK createdAt — but be careful: status has low cardinality, so it can create a hot partition. Decision: accept it for the project, or use a technique called write sharding to spread the load.
Single-table design (vs multi-table)
There are two schools of modeling in DynamoDB. Multi-table: one table per entity, a familiar model for those coming from SQL. Single-table design: all the entities of a domain in a single table, with polymorphic keys.
Single-table is more advanced, more performant, and cheaper — but it requires experience. In our project we'll use multi-table because it's more instructive: one table per microservice, aligned with the "database per service" principle.
07. Modeling the project's tables
Let's formalize the modeling of the three tables. These are the schemas that will be implemented in the coming modules.
Table: Orders
Access patterns:
- AP1: get an order by orderId
- AP2: list a customer's orders by date
- AP3: list orders by status (administrative use) Schema:
| Attribute | Type | Role |
|---|---|---|
| orderId | String (UUID) | Partition key (main) |
| customerId | String (UUID) | PK of the 'by-customer' GSI |
| createdAt | String (ISO 8601) | SK of the 'by-customer' GSI |
| status | String | PK of the 'by-status' GSI |
| items | List<Map> | Order items |
| totalAmount | Number | Total amount |
| paymentId | String | Reference to the payment |
GSIs:
- by-customer — PK: customerId, SK: createdAt
- by-status — PK: status, SK: createdAt
Table: Inventory
Access patterns:
- AP1: get a product by productId
- AP2: list products by category
- AP3: list products with low stock Schema:
| Attribute | Type | Role |
|---|---|---|
| productId | String (UUID) | Partition key (main) |
| category | String | PK of the 'by-category' GSI |
| name | String | Product name |
| price | Number | Current price |
| stockQuantity | Number | Quantity in stock |
| lowStockFlag | String (Y/N) | Sparse attribute for the GSI |
GSIs:
- by-category — PK: category, SK: name
- low-stock — PK: lowStockFlag (sparse index)
Table: Payments
Access patterns:
- AP1: get a payment by paymentId
- AP2: list an order's payments Schema:
| Attribute | Type | Role |
|---|---|---|
| paymentId | String (UUID) | Partition key (main) |
| orderId | String (UUID) | PK of the 'by-order' GSI |
| amount | Number | Amount charged |
| status | String | PENDING, COMPLETED, FAILED |
| method | String | credit_card, pix, etc. |
| processedAt | String (ISO 8601) | Timestamp |
GSI:
- by-order — PK: orderId, SK: processedAt
08. Creating the tables in the console
Time to create the tables. We'll go through the console first so you can see visually how everything works. In future modules, we'll redo this via CloudFormation.
Step by step: the Orders table
- Open the AWS console, log in with your otavio-dev user, and go to the DynamoDB service.
- Confirm you're in the us-east-1 region (top right corner).
- Click Create table.
- Table name: Orders
- Partition key: orderId (String)
- Sort key: leave it blank (the main table doesn't need one).
- Under Settings, choose Customize settings.
- Table class: Standard.
- Capacity mode: Provisioned.
- Read capacity: 5 RCU. Write capacity: 5 WCU.
- Auto scaling: disable it (for this study).
- Add tags: Project=order-system, Environment=dev.
- Click Create table. Wait for the status to turn Active.
Adding the GSIs
Once the table is active, go to Indexes > Create index:
GSI 1: by-customer
- Partition key: customerId (String)
- Sort key: createdAt (String)
- Index name: by-customer-index
- Projected attributes: All
- Read/Write capacity: 5 RCU / 5 WCU
GSI 2: by-status
- Partition key: status (String)
- Sort key: createdAt (String)
- Index name: by-status-index
- Projected attributes: All
- Read/Write capacity: 5 RCU / 5 WCU
Repeat for Inventory and Payments
Use the schemas from section 07. The three tables (with their GSIs) will fit comfortably within the Free Tier. Don't forget the tags — we'll use them later to find and delete everything at once.
09. Operations via CLI
Before writing code, let's validate the tables using the CLI. This gives you familiarity with the fundamental operations.
List your tables
aws dynamodb list-tables --profile aws-curso
# Expected output:
{
"TableNames": ["Orders", "Inventory", "Payments"]
}Insert an item: PutItem
DynamoDB has a peculiar syntax for types. Each value comes with its "type descriptor":
- S — String
- N — Number (always as a string)
- BOOL — Boolean
- M — Map (object)
- L — List (array) Example of inserting into Orders:
aws dynamodb put-item \
--profile aws-curso \
--table-name Orders \
--item '{
"orderId": {"S": "ord-001"},
"customerId": {"S": "cust-123"},
"createdAt": {"S": "2026-01-15T10:30:00Z"},
"status": {"S": "PENDING"},
"totalAmount": {"N": "299.90"}
}'Fetch an item: GetItem
aws dynamodb get-item \
--profile aws-curso \
--table-name Orders \
--key '{"orderId": {"S": "ord-001"}}'Query on the GSI: a customer's orders
aws dynamodb query \
--profile aws-curso \
--table-name Orders \
--index-name by-customer-index \
--key-condition-expression "customerId = :cid" \
--expression-attribute-values \
'{":cid": {"S": "cust-123"}}'Note how we need to specify --index-name to use the GSI, and how the values are passed via expression-attribute-values (placeholders prevent injection and improve query plan caching).
Delete an item
aws dynamodb delete-item \
--profile aws-curso \
--table-name Orders \
--key '{"orderId": {"S": "ord-001"}}'10. Practice exercises
This module's exercises are mandatory before the next one. Without working tables, module 03 (Beanstalk) will have nowhere to store data.
Create the three tables
Follow section 08 and create Orders, Inventory, and Payments with all the GSIs described. Verify that they're all in Active status before proceeding.
Insert test data
Via the CLI, insert 5 products into Inventory (varied categories, some with low stock), 3 fictitious customers making 2 orders each into Orders, and one payment per order into Payments. Use real UUIDs (you can use uuidgen in the terminal).
Validate the access patterns
Using query, validate each of the access patterns we modeled: (a) get an order by orderId; (b) list customer X's orders; (c) list PENDING orders; (d) products in the eletronicos category; (e) products with lowStockFlag=Y. Note: did any query fail? Why?
Trigger a throttling
On one of the tables, temporarily reduce the WCU to 1. In a shell script, run 30 PutItems in quick succession. You should see ProvisionedThroughputExceededException. Set the WCU back to 5 when you're done. This teaches you what to expect when capacity is insufficient.
Modeling reflection
Imagine the client asked for a new feature: "a ranking of the 10 best-selling products of the week". How would you model this? Would you add a GSI? Another table? Precompute it? Justify your answer in free-form text.
11. Next steps
Database modeled and working. Time to deploy the first microservice — Orders — into a real environment.
What you learned
- The DynamoDB mental model and how it differs from SQL.
- Partition key, sort key, and how to avoid hot partitions.
- Capacity modes — provisioned vs on-demand.
- GSIs and when to use them.
- The methodology of modeling by access patterns.
- Complete modeling of the project's three tables.
- Fundamental operations via CLI: PutItem, GetItem, Query.
What's coming in Module 03
Elastic Beanstalk in depth. We'll deploy the first microservice — Orders — using Beanstalk. Deploy cycle, health checks, environments, logs, troubleshooting. It'll be the project's first real deployment.
Modeling is architecture. Enjoy the journey — Module 03 is next.
