Bean & Bark: the orders table, typed

View .md

Build a small Orders API that stores and reads real 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

Start here: a local API

You need the .NET 10 SDK, access to the private Kanject NuGet feed, and the Bean & Bark local dataset. Start DynamoDB Local and seed it before running the API. No AWS account or deployed Orders API is needed for this chapter. Ask your administrator for feed access if restore cannot find the packages.

Download the complete project
  • Bean & Bark Orders API The five C# files below, a project with pinned package versions, and a README with run and verification steps.

Extract the project and run dotnet restore, then dotnet run --urls http://localhost:5080 from its folder. Keep that terminal open. To build it by hand instead, create the following project file and copy each C# block into the named file in the same folder. The provider is the runtime library; the Annotations package supplies the code generator and its attribute dependency.

xml
<Project Sdk="Microsoft.NET.Sdk.Web">  <PropertyGroup>    <TargetFramework>net10.0</TargetFramework>    <ImplicitUsings>enable</ImplicitUsings>    <Nullable>enable</Nullable>    <RootNamespace>BeanAndBark.Orders</RootNamespace>  </PropertyGroup>  <ItemGroup>    <PackageReference Include="Kanject.Core.NoSqlDatabase.Provider.DynamoDb" Version="5.1.0" />    <PackageReference Include="Kanject.Core.NoSqlDatabase.Provider.DynamoDb.Annotations" Version="3.1.1" />  </ItemGroup></Project>

Save the project as BeanAndBark.Orders.csproj. The remaining files are Order.cs, BeanAndBarkDbContext.cs, OrderRepository.cs, Program.cs, and OrderService.cs. If you already have a service, put the model and repository in its data project and register them at startup as shown below.

One order, typed

File: Order.cs. OrderNumber is the raw identifier used in the key; OrderId is the display label (for example, Order#8842). The current downloadable dataset includes both CustomerId and OrderNumber, so a loaded entity has the fields needed to compose its keys again.

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.namespace BeanAndBark.Orders;​public sealed class Order : IDynamoDbEntity{    [DynamoDBHashKey("pk")]    [KeyTemplate("CUSTOMER#{CustomerId}")]    public string PartitionKey { get; set; } = string.Empty;​    [DynamoDBRangeKey("sk")]    [KeyTemplate("ORDER#{OrderNumber}")]    public string SortKey { get; set; } = string.Empty;​    [DynamoDBProperty] public string CustomerId   { get; set; } = string.Empty;    [DynamoDBProperty] public string OrderNumber { 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; }    [DynamoDBProperty] public string Type { get; set; } = "Order";    [DynamoDBProperty] public string Placed { get; set; } = string.Empty;    [DynamoDBProperty("month")] public string Month { get; set; } = string.Empty;    [DynamoDBProperty] public string Status { get; set; } = string.Empty;    [DynamoDBProperty] public string Total { get; set; } = string.Empty;}

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 OrderNumber = "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:

File: BeanAndBarkDbContext.cs.

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".namespace BeanAndBark.Orders;​[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:

File: OrderRepository.cs.

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.namespace BeanAndBark.Orders;​[Repository(Name = "Orders", Version = 1, Entity = typeof(Order))]public partial class OrderRepository;

Wire it up

File: Program.cs. This complete local host connects to DynamoDB Local on port 8000 with the same region and dummy credentials as the seed script. With no namespace prefix, both the API and DynoStudio use BeanAndBark. Run DynamoDB Local with -sharedDb as the setup guide shows.

csharp
using BeanAndBark.Orders;using Kanject.Core.NoSqlDatabase.Provider.DynamoDb.Abstractions.Enums;using Kanject.Core.NoSqlDatabase.Provider.DynamoDb.Extensions;​var builder = WebApplication.CreateBuilder(args);builder.Services.AddDynamoDbContext<BeanAndBarkDbContext>(    options: cfg =>    {        cfg.DbEnvironmentOptions = DbEnvironmentOptions.Local;        cfg.Port = 8000;        cfg.AccessKey = "local";        cfg.SecretKey = "local";        cfg.AwsRegion = "us-east-1";        cfg.Namespace = null; // local tutorial: physical table is BeanAndBark        cfg.CreateTableCheck = true;    },    dbContextOptions: options => options.ServiceCollection.RegisterDynamoDbRepository());builder.Services.AddScoped<OrderService>();​var app = builder.Build();app.MapGet("/customers/{customerId}/orders", async (    string customerId, string? token, OrderService orders) =>{    var (items, metadata) = await orders.ForCustomerAsync(customerId, token);    return Results.Ok(new { items, metadata });});app.MapGet("/customers/{customerId}/orders/{orderNumber}", async (    string customerId, string orderNumber, OrderService orders) =>    await orders.GetAsync(customerId, orderNumber) is { } order        ? Results.Ok(order) : Results.NotFound());app.MapPost("/orders", async (Order order, OrderService orders) =>{    await orders.PlaceAsync(order);    return Results.Created(        $"/customers/{order.CustomerId}/orders/{order.OrderNumber}", order);});app.Run();

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:

File: OrderService.cs. The page size is two so the three sample orders exercise continuation immediately.

csharp
using Kanject.Core.Api.Abstractions.Models;​namespace BeanAndBark.Orders;​public sealed class OrderService(IOrderRepository orders){    public Task PlaceAsync(Order order) => orders.InsertAsync(order);​    public Task<Order?> GetAsync(string customerId, string orderNumber)        => orders.FindOrderAsync(customerId, orderNumber);​    // Return one page AND its continuation metadata to the caller.    public Task<(IList<Order> Items, PayloadMetadata Metadata)> ForCustomerAsync(        string customerId, string? token = null)        => orders.FindOrdersAsync(            page: (token: token, size: 2), customerId: customerId);}

The generated finder name follows the entity: FindOrderAsync for one item (it takes every key-template parameter — customerId for the partition, orderNumber for the sort key), and the pluralized FindOrdersAsync for a whole partition. The overload used here is paginated — you pass a page size and get back a page plus PayloadMetadata. Return that metadata with the items; when HasNextPage is true, pass PageToken as the next request token. The generator also emits an unpaged FindOrdersAsync(customerId) that keeps querying until the partition is exhausted and returns one list; that suits a partition you know is small, and it is the wrong default for one that grows with every order. Paging makes each read a size you chose — the same discipline the analyst learns from the read side.

The payoff: same table, both sides

With the current Bean & Bark dataset seeded, open http://localhost:5080/customers/c-014/orders. Expect two items and metadata.hasNextPage: true. Copy metadata.pageToken, URL-encode it, and request the same URL with ?token=YOUR_ENCODED_TOKEN to retrieve the remaining item. A single-item request to /customers/c-014/orders/8842 returns Dana’s medium-roast order with totalCents: 54000. The equivalent C# calls are:

csharp
// Inside a method with OrderService injected as orders:var (firstPage, metadata) = await orders.ForCustomerAsync("c-014");// firstPage.Count == 2; metadata.HasNextPage == trueif (metadata.HasNextPage){    var (nextPage, nextMetadata) = await orders.ForCustomerAsync(        "c-014", metadata.PageToken);    // nextPage.Count == 1 for the unchanged tutorial dataset.    // Continue while nextMetadata.HasNextPage for larger datasets.}​// All three rows, in sort-key order:// ORDER#8842  Dana Okafor  East  medium  54000// ORDER#9013  Dana Okafor  East  dark    36000// ORDER#9188  Dana Okafor  East  medium  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. Both sides use the same table, key layout, and stored attribute names.

Keep deployed stages separate

The local example deliberately uses the unprefixed BeanAndBark table. For AWS deployment, switch DbEnvironmentOptions to Remote, use your AWS credential chain instead of the local dummy credentials, and set cfg.Namespace from a required stage setting. Namespace dev maps to dev.BeanAndBark; prod maps to prod.BeanAndBark. In DynoStudio, select that same account, region, and physical table, and replace "BeanAndBark" in the lesson queries accordingly.

To seed a separate development table on DynamoDB Local, run node seed-bean-and-bark.mjs --table dev.BeanAndBark. To seed AWS, add --endpoint '' --region YOUR_REGION with your development AWS profile configured. This writes sample data to a billed AWS table. Never seed a production table for this exercise.

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, orderNumber) reads one item; the paginated FindOrdersAsync(page, customerId) reads a customer's partition a page at a time — the write-side twin of the analyst's begins_with(sk, 'ORDER#') query.
  • cfg.Namespace prefixes the physical table per stage, so correctly configured stages use distinct tables.
Check yourself2
You set CustomerId = "c-014" and OrderNumber = "8842" on a new Order and call InsertAsync. What are the stored pk and sk?
Why does this chapter call FindOrdersAsync with a page size, when FindOrderAsync takes none?
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. Verify the stage setting and use AWS permissions that restrict each service to its own resources; a naming convention alone does not enforce access control.
Was this page helpful?