DynamoDB for .NET Developers: Why Your SQL Server Instincts Will Fight You

 


After eight-plus years building enterprise applications on SQL Server and Oracle, DynamoDB was the first database that actually made me unlearn habits instead of just picking up new syntax. There's no JOIN, no stored procedures, and no "I'll fix the schema later" — the schema has to follow your access patterns from day one, or you end up fighting the database instead of using it. This is what I wish someone had told me before my first production table.

1. The Mental Shift: Access Patterns Before Schema

In SQL Server, you normalize first and figure out queries later — that's just how relational modeling works, and EF Core's migrations make iterating on the schema cheap. DynamoDB punishes that approach. You define your access patterns — "get all orders for a customer," "get an order by ID," "get recent orders by status" — before you design a single table, because every query has to be answerable by a partition key and, optionally, a sort key. There's no ad-hoc WHERE clause to bail you out later.

This is also why single-table design, which looks bizarre coming from a relational background, is actually the recommended pattern — cramming multiple entity types into one table with a generic partition/sort key scheme is normal in DynamoDB, not a hack.

2. Setting Up the .NET SDK

dotnet add package AWSSDK.DynamoDBv2

The SDK gives you two ways to work: the low-level AmazonDynamoDBClient, which maps closely to the raw API and gives you full control, and the high-level DynamoDBContext, which maps POCOs the way EF Core maps entities. I'd recommend starting with the high-level context for anything CRUD-shaped, and dropping to the low-level client only when you need fine control over consistency, conditional writes, or transactions.

3. Modeling an Entity

using Amazon.DynamoDBv2.DataModel;

[DynamoDBTable("Orders")]
public class Order
{
    [DynamoDBHashKey("CustomerId")]
    public string CustomerId { get; set; }

    [DynamoDBRangeKey("OrderId")]
    public string OrderId { get; set; }

    [DynamoDBProperty("Status")]
    public string Status { get; set; }

    [DynamoDBProperty("Amount")]
    public decimal Amount { get; set; }

    [DynamoDBProperty("CreatedAt")]
    public DateTime CreatedAt { get; set; }
}

CustomerId as the partition key and OrderId as the sort key means "all orders for a customer" is a cheap, fast Query — exactly the access pattern we decided on before writing any code. If I'd picked OrderId alone as the key, that same query would require a full table Scan, which is the single most common performance mistake I see from developers new to DynamoDB.

4. Reading and Writing Data

var context = new DynamoDBContext(client);

// Write
var order = new Order
{
    CustomerId = "CUST-1001",
    OrderId = "ORD-5001",
    Status = "Pending",
    Amount = 149.99m,
    CreatedAt = DateTime.UtcNow
};
await context.SaveAsync(order);

// Read a single item
var fetched = await context.LoadAsync<Order>("CUST-1001", "ORD-5001");

// Query all orders for a customer — uses the partition key, fast and cheap
var query = context.QueryAsync<Order>("CUST-1001");
var results = await query.GetRemainingAsync();

5. Query vs. Scan — The Line Between Fast and Expensive

This is the comparison that matters more than anything else in this post. Coming from SQL Server, it's tempting to treat Scan like a SELECT * — it isn't, and treating it that way is how a table that performed fine in testing starts timing out and racking up cost in production.

Operation What it does Cost behavior
Query Uses the partition key (and optional sort key condition) to fetch a targeted set of items Reads only the matching items — predictable and cheap
Scan Reads every item in the table, then optionally filters Billed for the entire table read, regardless of how few items match your filter

If you find yourself writing a Scan with a FilterExpression in production code, that's usually a sign your table's key design doesn't match an access pattern you actually need — which means going back to step 1, not reaching for a bigger read capacity.

6. Global Secondary Indexes (and Their Catch)

A Global Secondary Index (GSI) lets you query the same data by a different key — for example, querying orders by Status instead of CustomerId. This is the closest DynamoDB gets to a SQL Server non-clustered index, but there's a catch that bit me early on: GSIs are eventually consistent by default, and unlike the base table, you can't request strongly consistent reads on a GSI at all. If your application logic assumes "I just wrote this, so it must be queryable immediately," design that assumption out before it becomes a hard-to-reproduce bug report.

[DynamoDBGlobalSecondaryIndexHashKey("StatusIndex")]
public string Status { get; set; }

7. Capacity Modes

DynamoDB gives you two billing/throughput models:

  • On-demand — no capacity planning, scales automatically, billed per request. Good for unpredictable or spiky traffic, and for getting a new feature into production without guessing at load.
  • Provisioned (with auto-scaling) — you set read/write capacity units and let AWS scale within bounds. Usually cheaper once your traffic pattern is well understood, but requires tuning to avoid throttling during spikes.

My practical rule: start on-demand while a feature is new and its traffic is unknown, then switch to provisioned with auto-scaling once you have a few weeks of real traffic data to size it against. Flipping too early means guessing; staying on-demand forever usually costs more at steady, predictable volume.

Common Mistakes .NET Developers Make With DynamoDB

  • Designing the table like a normalized SQL Server schema — one table per entity type — then being surprised by how many round trips and GSIs it takes to answer a single screen's worth of data.
  • Using Scan with a filter instead of designing a Query-friendly key or GSI, usually because it "worked" in local testing with a handful of rows.
  • Assuming GSI reads are as fresh as a base-table strongly consistent read — they're not, and there's no setting to make them so.
  • Storing numbers as strings or vice versa inconsistently across items, which breaks sort-key range queries and comparisons in ways that don't show up until you query a wider range of data.
  • Not setting a Time To Live (TTL) attribute on data that should expire — session tokens, temporary locks — and manually cleaning it up instead, which is exactly what DynamoDB's built-in TTL exists to avoid.

Final Thoughts

DynamoDB isn't "SQL Server without SQL" — treating it that way is how most of the pain starts. It's a genuinely different tool that rewards knowing your access patterns up front and punishes modeling by habit. For enterprise .NET teams used to relational design, the adjustment is real, but once it clicks, the performance and scaling characteristics are hard to argue with for the right workloads — high-traffic, predictable-access, read/write-heavy systems where a normalized schema was never buying you much anyway.

Waqar Kabir

Certified .Net Specialist

Post a Comment

Previous Post Next Post