Bean & Bark: the orders table, typed
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.
- Model an order as a typed entity whose
pk/skcompose 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
devandprodon 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.
- 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.
<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:
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.
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.
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.
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.
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:
// 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.
- A single-table entity carries
pk/skfromIDynamoDbEntity;[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-registeredIOrderRepository— you declare a partial with no body.FindOrderAsync(customerId, orderNumber)reads one item; the paginatedFindOrdersAsync(page, customerId)reads a customer's partition a page at a time — the write-side twin of the analyst'sbegins_with(sk, 'ORDER#')query.cfg.Namespaceprefixes the physical table per stage, so correctly configured stages use distinct tables.
CustomerId = "c-014" and OrderNumber = "8842" on a new Order and call InsertAsync. What are the stored pk and sk?FindOrdersAsync with a page size, when FindOrderAsync takes none?pk = CUSTOMER#<id>, sk = PROFILE — same partition as their orders, a constant sort key. Sketch the Customer entity's two key properties.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.[DynamoDBHashKey("pk")] [KeyTemplate("CUSTOMER#{CustomerId}")] public string PartitionKey { get; set; } = "";[DynamoDBRangeKey("sk")] [KeyTemplate("PROFILE")] public string SortKey { get; set; } = ""; 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?dev writes actually land?Show solution
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.