Bean & Bark: the orders table, typed

View .md

The Orders API is live on dev — but it's still returning sample data. Tunde's storefront posts a real order and gets a cheerful 201; nothing is stored. This chapter gives the API a spine: a typed order that writes into the single-table BeanAndBark design — the same DynamoDB table the roastery's analyst reads in the DynoStudio series. You write the shape once; the generator writes the repository.

You'll learn
  • Model an order as a typed entity whose pk/sk compose from [KeyTemplate]
  • Register a [DbContext] and let [Repository] generate the CRUD + typed finders
  • Insert an order and read one back with the generated FindOrderAsync
  • Read a whole customer's partition with the paginated FindOrdersAsync
  • Keep dev and prod on separate physical tables with one config line

One order, typed

In a single-table design every item shares one physical table and is told apart by its key prefix. An order lives under its customer's partition (CUSTOMER#<id>) with a sort key that marks it as an order (ORDER#<n>). You don't hand-format those strings — you declare a [KeyTemplate] and the generator composes the key from the entity's own properties:

csharp
using Amazon.DynamoDBv2.DataModel;using Kanject.Core.NoSqlDatabase.Provider.DynamoDb.Abstractions.Interfaces;using Kanject.Core.NoSqlDatabase.Provider.DynamoDb.Annotations.Attributes;// One wholesale order in the single-table BeanAndBark design. The two keys// are composed from templates; everything else is a plain attribute. The// [DynamoDB*] attributes are AWS's (Amazon.DynamoDBv2.DataModel); only// [KeyTemplate] is Kanject's.public sealed class Order : IDynamoDbEntity{    [DynamoDBHashKey("pk")]    [KeyTemplate("CUSTOMER#{CustomerId}")]    public string PartitionKey { get; set; } = string.Empty;    [DynamoDBRangeKey("sk")]    [KeyTemplate("ORDER#{OrderId}")]    public string SortKey { get; set; } = string.Empty;    [DynamoDBProperty] public string CustomerId   { get; set; } = string.Empty;    [DynamoDBProperty] public string OrderId      { get; set; } = string.Empty;    [DynamoDBProperty] public string CustomerName { get; set; } = string.Empty;    [DynamoDBProperty] public string Region       { get; set; } = string.Empty;    [DynamoDBProperty] public string RoastLevel   { get; set; } = string.Empty;    [DynamoDBProperty] public long   TotalCents   { get; set; }}

The entity implements IDynamoDbEntity, so it carries PartitionKey and SortKey. The [DynamoDBHashKey("pk")] / [DynamoDBRangeKey("sk")] attributes are AWS's — they name the physical key attributes. The [KeyTemplate] attributes are Kanject's — they say how each key's value is built. Set CustomerId = "c-014" and OrderId = "8842", and the stored keys become CUSTOMER#c-014 / ORDER#8842.

The table, and the repository

A [DbContext] names the physical table and maps every entity that lives in it. One table, one context — you add Customer, Subscription, and Product to the same MapEntity call as the model grows:

csharp
using Kanject.Core.NoSqlDatabase.Provider.DynamoDb.Abstractions.DataContext.EntityBuilder;using Kanject.Core.NoSqlDatabase.Provider.DynamoDb.Annotations.Attributes;// One table, many entity shapes. MapEntity names the physical table and// registers each entity's key layout. Add Customer, Subscription, Product// here as the model grows — they all share "BeanAndBark".[DbContext]public partial class BeanAndBarkDbContext{    protected override void OnModelCreating(EntityModelBuilder builder)        => builder.MapEntity("BeanAndBark", new Order());}

Then the repository — the part you'd normally hand-write and get subtly wrong. You declare four lines; the source generator emits the CRUD methods, the typed finders, and an IOrderRepository interface it registers for you:

csharp
using Kanject.Core.NoSqlDatabase.Provider.DynamoDb.Annotations.Attributes;// The generator reads this attribute and emits a partial with typed finders,// plus an IOrderRepository interface (GenerateInterface defaults true) that it// auto-registers in DI. You write four lines; the CRUD + finders are generated.[Repository(Name = "Orders", Version = 1, Entity = typeof(Order))]public partial class OrderRepository;

Wire it up

Registration mirrors the CloudFunction hosting you already have: one AddDynamoDbContext<T> call, plus RegisterDynamoDbRepository() to pick up the generated repositories. The Namespace is the quiet hero here — it prefixes the physical table per stage, so dev and prod can never write to each other's data even by accident:

csharp
using Kanject.Core.NoSqlDatabase.Provider.DynamoDb.Extensions;builder.Services.AddDynamoDbContext<BeanAndBarkDbContext>(    options: cfg =>    {        cfg.Namespace = builder.Configuration["Stage"];   // "dev" or "prod"        cfg.AwsRegion = "eu-west-1";        cfg.CreateTableCheck = true;   // create the table on first run if missing    },    dbContextOptions: options => options.ServiceCollection.RegisterDynamoDbRepository());

Write, then read it back

Inject IOrderRepository and the whole data layer is three methods. Note what you don't write: no PutItem, no key-string concatenation, no marshalling — the generated finders take your domain identifiers and compose the keys:

csharp
public sealed class OrderService(IOrderRepository orders){    // Write: set the template fields (CustomerId, OrderId) and the plain    // attributes; the pk/sk strings compose themselves at insert time.    public Task PlaceAsync(Order order) => orders.InsertAsync(order);    // One order by its two identifiers. Keys resolve to    //   pk = CUSTOMER#c-014 , sk = ORDER#8842    public Task<Order?> GetAsync(string customerId, string orderId)        => orders.FindOrderAsync(customerId, orderId);    // Every order in a customer's partition — paginated, because DynamoDB    // hands back a page and a continuation token, never an unbounded list.    // This is the write-side twin of the analyst's    //   WHERE pk = 'CUSTOMER#c-014' AND begins_with(sk, 'ORDER#')    public async Task<IReadOnlyList<Order>> ForCustomerAsync(string customerId)    {        var (page, _) = await orders.FindOrdersAsync(            page: (token: null, size: 100), customerId: customerId);        return page;    }}

The generated finder name follows the entity: FindOrderAsync for one item (it takes every key-template parameter — customerId for the partition, orderId for the sort key), and the pluralized FindOrdersAsync for a whole partition. The collection finder is paginated by construction — you pass a page size and get back a page plus a continuation token. That's not ceremony; it's DynamoDB refusing to let you accidentally read an unbounded partition, the same discipline the analyst learns from the read side.

The payoff: same table, both sides

Seed the Bean & Bark dataset (or let the API write a few orders), then ask the repository for customer c-014 — Dana Okafor:

csharp
var (danas, _) = await orders.FindOrdersAsync(    page: (token: null, size: 100), customerId: "c-014");// danas.Count == 3//   ORDER#8842  Dana Okafor  East  Decaf   54000//   ORDER#9013  Dana Okafor  East  House   36000//   ORDER#9188  Dana Okafor  East  Single  42000

Three orders. Those are the exact rows the DynoStudio series pulls with WHERE pk = 'CUSTOMER#c-014' AND begins_with(sk, 'ORDER#') — because it's one table. You just met it from the write side: a typed C# repository. The analyst meets it from the read side: ad-hoc PartiQL. Neither side had to agree on anything but the key layout.

Recap
  • A single-table entity carries pk/sk from IDynamoDbEntity; [KeyTemplate] composes their values from the entity's own properties (CUSTOMER#{CustomerId}CUSTOMER#c-014).
  • [DynamoDBHashKey/RangeKey/Property] are AWS attributes (physical layout); [KeyTemplate], [DbContext], [Repository] are Kanject (code generation).
  • [Repository] generates CRUD, typed finders, and an auto-registered IOrderRepository — you declare a partial with no body.
  • FindOrderAsync(customerId, orderId) reads one item; the paginated FindOrdersAsync(page, customerId) reads a whole partition — the write-side twin of the analyst's begins_with(sk, 'ORDER#') query.
  • cfg.Namespace prefixes the physical table per stage, so dev and prod never share data.
Check yourself2
You set CustomerId = "c-014" and OrderId = "8842" on a new Order and call InsertAsync. What are the stored pk and sk?
Why does FindOrdersAsync require a page size, when FindOrderAsync doesn't?
Try it yourself 2
1 Add the customer profile
A customer's profile lives at pk = CUSTOMER#<id>, sk = PROFILE — same partition as their orders, a constant sort key. Sketch the Customer entity's two key properties.
The partition template matches the order's. The sort key is a literal — no interpolation, like the sample's META row.
Show solution
[KeyTemplate("CUSTOMER#{CustomerId}")] on PartitionKey, and [KeyTemplate("PROFILE")] on SortKey — a literal-only template composes sk = "PROFILE". Now a customer's profile and all their orders share one partition, so a single query can fetch the customer and their order history together.
csharp
[DynamoDBHashKey("pk")]  [KeyTemplate("CUSTOMER#{CustomerId}")] public string PartitionKey { get; set; } = "";[DynamoDBRangeKey("sk")] [KeyTemplate("PROFILE")]              public string SortKey    { get; set; } = "";
2 Predict the isolation
The API runs on dev with cfg.Namespace = "dev" and on prod with "prod". A load test hammers dev with 10,000 fake orders. Without looking anything up: can the analyst's production reports be affected?
The namespace prefixes the physical table name. Where do dev writes actually land?
Show solution
No. Each stage's namespace gives it a separate physical DynamoDB table, so dev writes and prod reads never touch the same data. The fake orders pile up in the dev table and are invisible to production. That one config line is the entire blast-radius guarantee — which is exactly why it belongs at the composition root, not scattered per call.
Was this page helpful?