Workshop · C# · Advanced
DN300 C#, advanced: building a service
The course that turns a C# programmer into someone a team trusts with a service. Forty-eight steps across eight sessions: architecture with dependencies pointing inward, Task and cancellation without deadlocks, PostgreSQL through Npgsql and Dapper behind a repository, an ASP.NET Core minimal API with validation at the edge, integration tests that run against the real database, measurement before optimisation, defect-hunting review of code you did not write — and Orders, an order-processing service with a reject file, reason codes and an exit-code contract, proven on inputs you never saw.
Open Coding Tutor at codingtutor.agilesystemsoftware.com, pick DN300 from the course button in the chat and press Tutor me. Every lesson, demo and hands-on step is also published below.
Who it is for
Working programmers who have finished DN100 (or can pass its placement step) and want to build and run a production-shaped .NET service: layered architecture, async and cancellation, PostgreSQL with Npgsql and Dapper, a minimal API, integration tests, measured performance, and the review habits the AI era demands. Expect to read code as much as you write it.
What you will be able to do
- Lay out a service in layers with dependencies pointing inward, inject them, and configure from arguments and environment without secrets in source
- Write async code with Task, await, CancellationToken and Channels that never deadlocks or leaks
- Persist through a repository over PostgreSQL with Npgsql and Dapper, parameterised SQL and transactions
- Expose an HTTP API with ASP.NET Core minimal APIs: routes, status codes, validation and typed clients
- Test at scale: fakes, integration tests over the real database and API, table-driven cases
- Measure before optimising: timing, allocations and complexity on a real hot path
- Read, review and correct code you did not write, including machine-generated code
- Deliver a service with a reject file, reason codes, structured logs and an exit-code contract
Syllabus
Every session ends in a lab the machine checks on your own code. Open a session's mini steps to read each lesson, demo and hands-on task; the badge shows how far the engine can verify it: checked · T3 a check runs over your files, proven · T4 the grader also tries inputs you did not see, tutored · T0 done on your word. Each step has its own link (for example #s1-1).
- 1. Architecture of a service
- 2. Concurrency
- 3. Persistence with PostgreSQL
- 4. An HTTP API with minimal APIs
- 5. Testing at scale
- 6. Performance: measure first
- 7. Reading, reviewing and the AI era
- 8. Capstone: Orders
Session 1 · Architecture of a service
Six steps on the shape of a service before any feature. Layers: Domain (rules, no dependencies), Application (use cases over interfaces), Infrastructure (files, database, HTTP) and a Host that wires them. The dependency rule: references point inward and the compiler enforces it through project references. Interfaces owned by the inner layer. Dependency injection by hand and with Microsoft.Extensions.DependencyInjection inside an ASP.NET Core host. Configuration from arguments and environment with no secrets in source. Live in the seat: a project reference that the compiler refuses because it points outward, a use case tested with a fake clock, the same use case hosted in a console and in a web host, and a connection string read from the environment with a clear failure when missing.
Lab Build the Orders skeleton: Domain, Application, Infrastructure, Host.Cli and Tests projects with the dependency rule enforced, one use case behind interfaces with a fake in the tests, and configuration from environment. Session 1 checkpoint.
6 mini steps · ~54 min
1.1Layers and the dependency rule9 minchecked · T3
Predict
Orders.Domain references Orders.Infrastructure so a rule can read a file. What does the architecture say?
Lesson
Domain: entities, value objects, rules; references nothing. Application: use cases that orchestrate the domain through interfaces the application owns (IOrderRepository, IClock). Infrastructure: implementations (Postgres, files, HTTP). Host: composition root. Project references make the rule mechanical: Infrastructure references Application, never the reverse.
Demo
The tutor creates dotnet/dn300/demo/layers with the five projects, adds references in the inward direction, tries an outward reference and shows the compiler's circular-dependency refusal, then draws the graph with dotnet list reference.
Hands-on
Create dotnet/dn300/orders: Orders.Domain, Orders.Application (→ Domain), Orders.Infrastructure (→ Application), Orders.Host.Cli (→ Infrastructure, Application) and Orders.Tests (→ Application, Domain), from your DN100 template with Directory.Build.props. dotnet build clean.
Explain
What does the dependency rule buy you in a year?
Check
Five projects with inward references — checked (T3):
seat_fileseat_fileseat_fileseat_build1.2The domain: value objects and rules9 minchecked · T3
Predict
record Quantity(int Value) with a constructor guard rejecting values below 1. new Quantity(0) does what?
Lesson
Value objects make invalid states unrepresentable: Money(decimal, Currency), Quantity(int ≥ 1), Sku(normalised upper-case). An Order aggregates lines and exposes behaviour (AddLine, Total) rather than setters. Domain exceptions name the broken rule. No I/O, no framework, pure and fully testable.
Demo
The tutor writes Sku, Quantity, Money and Order with AddLine and Total, shows a guard throwing, adds a DomainException base, and tests Total over three lines with mixed quantities.
Hands-on
In Orders.Domain add record Sku (upper-case, non-empty), record Quantity (≥ 1), record Money (+, same-currency rule), class Order (Id, CustomerId, Lines, AddLine, Total) and DomainException. Tests: Sku_normalises_case, Quantity_rejects_zero, Order_total_sums_lines, Money_refuses_mixed_currency.
Explain
Why validate in the constructor rather than at use?
Check
Domain value objects and Order pass — checked (T3):
seat_fileseat_test1.3Use cases over interfaces9 minchecked · T3
Predict
PlaceOrder depends on IOrderRepository and IClock. In a unit test, which implementations do you pass?
Lesson
A use case is a class with one Execute: PlaceOrder(IOrderRepository repo, IClock clock, IPricing pricing). The interfaces live in Application; fakes live in Tests as plain classes (InMemoryOrderRepository, FixedClock). The use case returns a result type, not an exception, for expected business outcomes.
Demo
The tutor writes IOrderRepository, IClock, PlaceOrder returning PlaceOrderResult (Accepted(OrderId) | Rejected(reason)), the two fakes, and tests the accepted and rejected paths with the fixed clock visible in the saved order.
Hands-on
In Orders.Application add IOrderRepository (Save, Get), IClock (UtcNow), PlaceOrder with Execute(PlaceOrderRequest) → PlaceOrderResult. In Tests add InMemoryOrderRepository and FixedClock. Tests: PlaceOrder_saves_with_clock_time, PlaceOrder_rejects_empty_lines, PlaceOrder_rejects_unknown_customer (customers from a fake ICustomerDirectory).
Explain
Why return a result type instead of throwing for a rejected order?
Check
PlaceOrder tested with fakes — checked (T3):
seat_fileseat_fileseat_test1.4Dependency injection: by hand and by container9 minchecked · T3
Predict
services.AddScoped<IOrderRepository, PostgresOrderRepository>() — in a console host without scopes, resolving it from the root provider does what?
Lesson
The composition root builds the object graph once. By hand: new PlaceOrder(new PostgresRepo(conn), new SystemClock()). With Microsoft.Extensions.DependencyInjection (available inside the ASP.NET Core shared framework in the seat): AddSingleton, AddScoped, AddTransient and a scope per request or per command. Lifetimes are the whole art: a scoped repository, a singleton clock.
Demo
The tutor wires Host.Cli by hand first, then shows the same wiring with a ServiceCollection in a web host project, creates a scope per command, and triggers the scoped-from-root exception on purpose.
Hands-on
In Orders.Host.Cli write Composition.Build(Settings) that constructs the graph by hand (InMemory repository for now, SystemClock) and returns PlaceOrder; the CLI command place <customer> <sku> <qty> prints "Accepted <id>" or "Rejected: <reason>". Test: Composition_builds_place_order.
Explain
Why is the composition root the only place that knows concrete types?
Check
Composition root wires the use case — checked (T3):
seat_fileseat_runseat_test1.5Configuration without secrets8 minproven · T4
Predict
The connection string is read from ORDERS_DB. It is unset. The best behaviour is…
Lesson
Settings come from arguments, then environment variables, then a settings file, in that precedence, and are validated at startup into a typed Settings record. Secrets (connection strings with passwords, API keys) are never in source or in committed files; the seat's PostgreSQL uses a local socket so the string carries no password.
Demo
The tutor writes Settings.Load(args, env) with ORDERS_DB and ORDERS_LOG_LEVEL, shows the precedence with an argument overriding the variable, unsets the variable to see the fail-fast exit 2, and greps the repo for the word password.
Hands-on
Add record Settings(string Db, string LogLevel) with static Settings Load(string[] args, IDictionary env): --db and --log-level override ORDERS_DB and ORDERS_LOG_LEVEL; missing Db → SettingsException. CLI maps it to the message and exit 2. Tests: Settings_argument_overrides_env, Settings_missing_db_throws.
Explain
Why must a missing setting fail at startup rather than at first use?
Check
Settings load with precedence and fail fast — proven (T4):
seat_fileseat_testseat_run1.6Lab: the Orders skeleton10 minproven · T4
Predict
Which project may reference Npgsql when it arrives in session 3?
Lesson
The lab is the architecture made concrete: five projects with inward references, a domain with value objects, a use case behind interfaces with fakes, a composition root, typed settings, and the CLI contract (0 done, 2 usage or settings, 4 rejected order). Every later session adds an implementation without moving a boundary.
Demo
The tutor reviews the skeleton against the architecture checklist, runs the CLI through accepted, rejected and misconfigured paths, and shows dotnet list reference for each project as proof of the dependency rule.
Hands-on
Complete dotnet/dn300/orders so that: references point inward; 10+ tests pass; place C1 A1 2 → "Accepted <id>", exit 0; place C1 A1 0 → "Rejected: quantity must be at least 1", exit 4; ORDERS_DB unset → exit 2; README documents the commands.
Explain
Which boundary will session 3 cross, and what stays untouched?
Check
Skeleton: inward references, 10 tests, CLI contract — proven (T4):
seat_buildseat_testseat_run
Session 2 · Concurrency
Six steps on doing more than one thing at a time without lying to yourself. Task and async/await: what a Task is, what await really does, and why .Result deadlocks. CancellationToken threaded through every await and honoured with a deadline. Parallel.ForEachAsync with a degree of parallelism and shared state done with Interlocked or a lock. Channels as the producer–consumer queue. Timeouts, retries and the difference between cancelling and giving up. Live in the seat: a synchronous wait that hangs, the same code awaited, a cancellation that unwinds a pipeline cleanly, a race condition reproduced and fixed, and a channel that applies back-pressure.
Lab Build the order-import pipeline: read orders from a file asynchronously, validate them in parallel with a degree of parallelism, write results through a channel, honour a cancellation deadline, and report counts that are exactly right under concurrency. Session 2 checkpoint.
6 mini steps · ~53 min
2.1Task, await and the deadlock9 minchecked · T3
Predict
var t = ReadAsync(); var s = t.Result; inside an environment with a single-threaded synchronisation context does what?
Lesson
A Task is a promise of a result. await registers the rest of the method as a continuation and returns to the caller; nothing blocks. Async all the way: a method that awaits returns Task or Task<T>; .Result and .Wait() in async code are the classic deadlock and the classic thread hog. ConfigureAwait matters in libraries, not in hosts.
Demo
The tutor writes ReadOrdersAsync over a file with StreamReader.ReadLineAsync, calls it with .Result from a context that deadlocks, then awaits it, then shows the stack of an awaited method in the debugger to make the continuation visible.
Hands-on
In dotnet/dn300/orders add Infrastructure FileOrderSource : IOrderSource with IAsyncEnumerable<OrderLine> ReadAsync(path, CancellationToken) using ReadLineAsync. Tests: ReadAsync_yields_each_line, ReadAsync_is_not_blocking (asserts the method returns a task before the file is fully read, using a slow stream fake).
Explain
What does await actually do with the rest of the method?
Check
FileOrderSource streams lines asynchronously — checked (T3):
seat_fileseat_test2.2CancellationToken everywhere8 minchecked · T3
Predict
A loop awaits Task.Delay(100) without passing the token. Cancellation is requested. When does the loop stop?
Lesson
Every async method takes a CancellationToken and passes it to every await. A CancellationTokenSource with CancelAfter is a deadline. Cancelled operations throw OperationCanceledException; catch it at the top to report "cancelled" and exit with its own code, not as a failure. Linked sources combine a user cancel and a timeout.
Demo
The tutor adds a token to the pipeline, requests cancellation mid-file, shows the exception unwinding cleanly and the counts of processed lines, then forgets to pass the token in one place and watches it keep going.
Hands-on
Add Application ImportOrders.ExecuteAsync(path, CancellationToken) that reads through IOrderSource and counts lines, passing the token to every await; the CLI "import <path> --timeout <ms>" uses CancelAfter and prints "Cancelled after N lines" with exit 5. Tests: Import_counts_lines, Import_stops_on_cancellation (slow fake source).
Explain
Why is cancellation cooperative?
Check
ImportOrders honours the token — checked (T3):
seat_fileseat_test2.3Parallel work and shared state9 minchecked · T3
Predict
int count = 0; Parallel.ForEachAsync(items, async (x, ct) => { count++; }); over 10,000 items. What is count?
Lesson
Parallel.ForEachAsync runs a body concurrently with MaxDegreeOfParallelism. Shared mutable state needs a plan: Interlocked for counters, lock for compound updates, ConcurrentDictionary for maps, or no sharing at all by collecting per-item results and aggregating after. Reproduce the race in a test before fixing it.
Demo
The tutor validates 10,000 lines in parallel with a plain counter, prints a wrong total, writes a test that fails most runs, fixes with Interlocked, then shows the per-item-result approach that needs no sharing.
Hands-on
Add ValidateOrders.ExecuteAsync(IAsyncEnumerable<OrderLine>, int parallelism, ct) that validates lines with Parallel.ForEachAsync and returns ValidationCounts(int Valid, int Invalid) exactly, using Interlocked. Tests: Validate_counts_are_exact_under_parallelism (10,000 lines, half invalid), Validate_respects_parallelism (a probe fake records the maximum concurrent calls).
Explain
Which shared-state strategy did you pick and why?
Check
Parallel validation counts exactly — checked (T3):
seat_fileseat_test2.4Channels: producer and consumer9 minchecked · T3
Predict
A bounded channel of capacity 10 is full. The producer calls WriteAsync. What happens?
Lesson
System.Threading.Channels gives a typed async queue: Channel.CreateBounded<T>(capacity), writer.WriteAsync, reader.ReadAllAsync, writer.Complete() to end the stream. One producer reads the file, N consumers validate, one writer records results; bounded capacity keeps memory flat. Complete the writer in finally.
Demo
The tutor builds a three-stage pipeline with two channels, watches memory stay flat with a slow consumer, forgets Complete() and shows the consumer hanging, then fixes it with try/finally.
Hands-on
Add Application OrderPipeline.RunAsync(source, parallelism, sink, ct): source → bounded channel (64) → N validators → bounded channel → one sink writer (IResultSink.WriteAsync). Writers completed in finally. Tests: Pipeline_delivers_every_line, Pipeline_completes_sink_on_cancel.
Explain
What does a bounded channel protect you from?
Check
Pipeline with channels delivers and completes — checked (T3):
seat_fileseat_test2.5Timeouts, retries and giving up8 minchecked · T3
Predict
Retry three times with 100 ms, 200 ms, 400 ms delays on a transient failure. The operation fails all four tries. Total time waited?
Lesson
A timeout is a deadline on one attempt; a retry is a new attempt after a transient failure; both honour the outer token. Exponential back-off with jitter avoids thundering herds. Retry only what is safe to repeat (idempotent reads, writes with an idempotency key). Giving up is a result, logged with the attempt count.
Demo
The tutor writes Retry.ExecuteAsync(op, attempts, baseDelay, ct) with back-off and a predicate for transient exceptions, tests it with a fake that fails twice then succeeds, and shows the outer cancellation cutting the retry short.
Hands-on
Add Application Retry.ExecuteAsync<T>(Func<CancellationToken, Task<T>> op, int attempts, TimeSpan baseDelay, Func<Exception, bool> isTransient, ct, TimeProvider time). Tests with a fake TimeProvider: Retry_succeeds_after_transient_failures, Retry_gives_up_after_attempts, Retry_honours_outer_cancellation.
Explain
Why inject a TimeProvider into Retry?
Check
Retry with back-off and cancellation — checked (T3):
seat_fileseat_test2.6Lab: the import pipeline10 minproven · T4
Predict
import on a 1,000-line file with parallelism 8 prints "Valid 900 Invalid 100". Run again with parallelism 1. What prints?
Lesson
The lab wires the pipeline into the CLI: import <path> --parallelism N --timeout ms, printing "Valid V Invalid I" and exit 0, or "Cancelled after N lines" and exit 5. The grader runs it with several parallelism values and a hidden file; the counts must be identical every time.
Demo
The tutor runs import on the published 1,000-line file with parallelism 1, 4 and 16, diffs the outputs, then runs with a 50 ms timeout to show the cancelled path.
Hands-on
Complete the CLI import command over samples/s2/orders-1000.csv: identical counts for any parallelism; cancellation path with exit 5; log lines event=import.done valid=V invalid=I ms=N on stderr.
Explain
How did you prove there is no race?
Check
Counts identical across parallelism; cancellation exits 5 — proven (T4):
seat_testseat_run
Session 3 · Persistence with PostgreSQL
Six steps on storing state in a real database without letting it leak into the domain. The seat's PostgreSQL 17 over a local socket. Npgsql: connections, commands, parameters, and why string concatenation into SQL is the one thing you never do. Dapper to map rows to records. A hand-written migration runner with a schema_version table. Transactions: an order and its lines saved together or not at all. The repository behind IOrderRepository, tested against the real database in a per-test schema. Live in the seat: a query built with concatenation and the injected quote that breaks it, the same query parameterised, Dapper mapping a row to a record in one line, a migration applied twice idempotently, and a transaction rolled back by a thrown exception.
Lab Implement PostgresOrderRepository behind IOrderRepository with migrations and transactions, tested against the seat's PostgreSQL in an isolated schema, and swap it into the composition root with one line. Session 3 checkpoint.
6 mini steps · ~53 min
3.1Connecting and parameterising9 minchecked · T3
Predict
"SELECT * FROM customers WHERE id = '" + id + "'" with id = x' OR '1'='1 returns what?
Lesson
NpgsqlDataSource is created once; connections are opened per operation and disposed. Every value goes through a parameter (@id) so the database never parses data as SQL. The connection string for the seat is a local socket with no password; it comes from Settings, never from source.
Demo
The tutor opens a connection with NpgsqlDataSource, runs a concatenated query with the injected quote to show every row, rewrites it with a parameter and shows one row, then shows the connection disposed in a using.
Hands-on
In Orders.Infrastructure add PostgresDb(NpgsqlDataSource) with Task<int> CountCustomersAsync(ct) and Task<Customer?> FindCustomerAsync(string id, ct) using parameters. Tests in Orders.Tests.Integration (real database, schema per test run): FindCustomer_uses_parameters (an id containing a quote returns null, not every row).
Explain
Why is a parameter safer than escaping quotes yourself?
Check
Queries are parameterised — checked (T3):
seat_fileseat_test3.2Dapper: rows to records8 minchecked · T3
Predict
conn.QueryAsync<Customer>("SELECT id, name FROM customers") with record Customer(string Id, string Name) — how are columns mapped?
Lesson
Dapper adds QueryAsync<T>, QuerySingleOrDefaultAsync<T>, ExecuteAsync to a connection: SQL in, records out, parameters from an anonymous object. Alias snake_case columns to PascalCase (customer_id AS CustomerId) or set DefaultTypeMap.MatchNamesWithUnderscores. Keep SQL as constants near the repository, not scattered.
Demo
The tutor replaces the hand-written reader with Dapper, maps a row into a record with one call, shows a mapping mismatch producing a default value, fixes it with an alias, and inserts with ExecuteAsync and an anonymous parameter object.
Hands-on
Rewrite PostgresDb with Dapper: FindCustomerAsync via QuerySingleOrDefaultAsync<Customer>, ListCustomersAsync via QueryAsync, InsertCustomerAsync via ExecuteAsync with new { id, name }. Tests: Insert_then_find_round_trips, List_orders_by_id.
Explain
What does Dapper save you, and what does it not do?
Check
Dapper maps and inserts — checked (T3):
seat_fileseat_test3.3Migrations by hand9 minchecked · T3
Predict
The migration runner is run twice on the same database. The second run should…
Lesson
A migration is a numbered SQL file (001_customers.sql, 002_orders.sql) applied once, in order, inside a transaction, and recorded in schema_version. The runner reads the folder, compares with the table, applies the rest. No tool is needed for this; forty lines of C# and you understand every byte.
Demo
The tutor writes Migrator.ApplyAsync(folder) with schema_version, runs it on an empty schema, lists the tables, runs it again to show no-op, then adds 003 and runs once more.
Hands-on
Add Infrastructure Migrator.ApplyAsync(string folder, ct) and the files migrations/001_customers.sql, 002_orders.sql (orders and order_lines with a foreign key), 003_schema_notes.sql. Tests: Migrator_applies_in_order, Migrator_is_idempotent, Migrator_rolls_back_a_failing_file (a bad 004 in a temp folder leaves version at 3).
Explain
Why apply each migration inside a transaction?
Check
Migrator applies, repeats and rolls back — checked (T3):
seat_fileseat_fileseat_test3.4Transactions: all or nothing9 minchecked · T3
Predict
Inside a transaction: INSERT order; INSERT line 1; INSERT line 2 throws; the using block ends without Commit. What is in the database?
Lesson
await using var tx = await conn.BeginTransactionAsync(ct); pass tx to every command; CommitAsync at the end; dispose without commit rolls back, including on exceptions. Saving an aggregate (order + lines) is one transaction or it is a bug. Keep transactions short and never await user input inside one.
Demo
The tutor saves an order with three lines in one transaction, throws before the commit, queries to show nothing saved, then commits and shows all four rows.
Hands-on
Add Infrastructure PostgresOrderRepository : IOrderRepository with SaveAsync (order + lines in one transaction) and GetAsync (order with its lines). Tests: Save_then_get_round_trips_lines, Save_rolls_back_when_a_line_fails (a line with an invalid sku provokes a constraint violation; the order must not exist).
Explain
How does the rollback test prove atomicity?
Check
Repository saves atomically — checked (T3):
seat_fileseat_test3.5Integration tests in an isolated schema8 minchecked · T3
Predict
Two integration test classes run in parallel against the same schema and both insert customer C1. The likely result?
Lesson
Integration tests talk to the real PostgreSQL: a fixture creates a fresh schema (orders_test_<guid>), sets search_path, runs the migrations, and drops the schema on dispose. Each test class gets its own; tests inside a class clean up or use unique ids. A test that depends on another's data is a flaky test.
Demo
The tutor writes DbFixture with schema creation, search_path, Migrator and drop-on-dispose, converts the earlier integration tests to use it, and runs the suite twice in parallel to show stability.
Hands-on
Add Orders.Tests DbFixture : IAsyncLifetime (create schema, search_path, migrate, drop) and make every integration test class an IClassFixture<DbFixture>. Mark integration tests with [Trait("Category", "Integration")]. Full suite green twice.
Explain
Why a schema per fixture rather than one shared test database?
Check
Isolated schema fixture in use — checked (T3):
seat_fileseat_test3.6Lab: the Postgres repository10 minproven · T4
Predict
Swapping InMemoryOrderRepository for PostgresOrderRepository changes how many files outside Infrastructure?
Lesson
The lab swaps the repository in the composition root, adds a "migrate" command, and makes "place" persist for real: place then "show <id>" reads the order back from PostgreSQL. The grader runs migrate, place and show against a fresh schema, then a hidden sequence that includes a rejected order which must leave no rows.
Demo
The tutor swaps the one line in Composition, runs migrate, place and show, queries the tables in pgweb, then places a rejected order and shows the tables unchanged.
Hands-on
Composition uses PostgresOrderRepository; CLI gains migrate (applies migrations, prints "Schema at version 3") and show <id> (prints the order and its lines). place C1 A1 2 persists; place C1 A1 0 is rejected with exit 4 and leaves no rows. All tests green.
Explain
Which test gave you the most confidence in the repository, and why?
Check
migrate, place and show work against PostgreSQL; rejections leave no rows — proven (T4):
seat_testseat_run
Session 4 · An HTTP API with minimal APIs
Six steps on exposing the service over HTTP. The ASP.NET Core minimal API host from the webapi template, trimmed to what matters: MapGet, MapPost, route parameters, typed results and status codes. Validation at the edge with ProblemDetails for 400s. Dependency injection with the real container and scoped repositories per request. HttpClient with a typed client and IHttpClientFactory. Running the API in the seat and calling it from the CLI. Live in the seat: a route returning 404 instead of null, a bad body turned into a 400 with field errors, the same use case served by the CLI and the API, and a typed client that fails loudly on a 500.
Lab Expose Orders over HTTP: POST /orders, GET /orders/{id}, GET /health, with validation, ProblemDetails, correct status codes, the scoped Postgres repository, and a typed client the CLI uses to place orders remotely. Session 4 checkpoint.
6 mini steps · ~52 min
4.1The minimal API host8 minchecked · T3
Predict
app.MapGet("/health", () => "ok"); — what status code and content type does a GET return?
Lesson
WebApplication.CreateBuilder(args) → services → app.MapGet/MapPost → app.Run(). Lambdas take route values, query, body and injected services by parameter. Return TypedResults (Ok, Created, NotFound, ValidationProblem) so the status code is in the signature. Keep the host thin: use cases do the work.
Demo
The tutor creates Orders.Host.Api with dotnet new webapi, deletes the sample, adds /health returning TypedResults.Ok(new { status = "ok" }), runs it on a fixed port, and calls it with the seat's HttpClient from a script.
Hands-on
Add Orders.Host.Api (webapi template, Infrastructure and Application references) with GET /health → 200 { "status": "ok", "version": "<assembly version>" } and the composition registered in the container (AddSingleton IClock, AddScoped IOrderRepository, AddScoped PlaceOrder). Runs on http://127.0.0.1:5088.
Explain
Why return TypedResults rather than plain objects?
Check
/health answers 200 with status ok — checked (T3):
seat_fileseat_http4.2Routes, bodies and status codes9 minchecked · T3
Predict
GET /orders/{id} for an id that does not exist should return…
Lesson
MapPost("/orders", (PlaceOrderRequest body, PlaceOrder useCase, ct) => ...) binds the JSON body to a record. Return Created("/orders/{id}", dto) on success, NotFound() when missing, Conflict or UnprocessableEntity for business rejections (this course: 422 with the reason). Map domain results to HTTP in one place.
Demo
The tutor adds POST /orders and GET /orders/{id}, returns 201 with a Location header, 404 for a missing id and 422 for a rejected order, and shows each with the client script.
Hands-on
Add POST /orders (201 Created with Location and the order DTO; 422 with { "reason": "..." } on rejection) and GET /orders/{id} (200 DTO or 404). DTOs are records in Orders.Host.Api/Contracts.
Explain
Why 422 for a rejected order and not 400?
Check
POST and GET with the right codes — checked (T3):
seat_http4.3Validation at the edge9 minchecked · T3
Predict
POST /orders with a body that is not JSON at all returns…
Lesson
Validate every request at the edge before it reaches a use case: required fields, lengths, ranges, a maximum number of lines, a maximum body size. Return TypedResults.ValidationProblem(errors) — ProblemDetails with an errors dictionary. The domain validates again; the edge is about rejecting junk cheaply and clearly.
Demo
The tutor sends a missing customerId, a 300-character sku and 1,001 lines, shows each as a 400 ProblemDetails with the field named, then shows the body-size limit refusing a 10 MB request.
Hands-on
Add RequestValidation.Validate(PlaceOrderRequest) returning a Dictionary<string, string[]> of errors (customerId required ≤ 32 chars; 1–100 lines; sku 1–16 chars; quantity 1–10000) and return ValidationProblem on failure. Tests (unit): Validate_requires_customer, Validate_limits_lines, Validate_passes_good_request.
Explain
Why validate at the edge when the domain validates too?
Check
Bad requests become 400 ProblemDetails — checked (T3):
seat_testseat_http4.4Scopes, lifetimes and the request8 minchecked · T3
Predict
NpgsqlDataSource registered as Scoped instead of Singleton. What is the effect under load?
Lesson
Singleton for things that are thread-safe and expensive (NpgsqlDataSource, IClock, TimeProvider). Scoped for per-request state (repository with its connection, use cases). Transient for cheap stateless helpers. The container validates scopes in development; keep that on. A request is a scope; the CLI makes one scope per command.
Demo
The tutor registers the data source as scoped, hammers the API with a loop and shows the connection count climbing in pgweb, fixes the lifetime, repeats, then shows the scope-validation exception for a captured dependency.
Hands-on
Register NpgsqlDataSource as singleton, PostgresOrderRepository and use cases as scoped, TimeProvider.System as singleton; enable ValidateScopes and ValidateOnBuild. Test: Container_builds_with_validation (build the service provider with validation in a unit test).
Explain
Why must the data source be a singleton?
Check
Lifetimes correct and validated — checked (T3):
seat_fileseat_test4.5HttpClient and a typed client8 minchecked · T3
Predict
await client.PostAsJsonAsync(...) returns a 500. response.EnsureSuccessStatusCode() does what?
Lesson
Never new HttpClient() per call; use IHttpClientFactory or one long-lived instance. A typed client (OrdersClient) wraps the HTTP details: PostAsJsonAsync, ReadFromJsonAsync<T>, timeouts, mapping 422 to a Rejected result and other failures to exceptions with the status code and body.
Demo
The tutor writes OrdersClient with PlaceAsync returning Accepted(id) or Rejected(reason), handles 422 and 400 and 500 differently, sets a 5-second timeout, and calls the running API from the CLI.
Hands-on
Add Orders.Host.Cli OrdersClient(HttpClient) with PlaceAsync(PlaceOrderRequest, ct) → PlaceOrderResult (201 → Accepted, 422 → Rejected, else OrdersApiException(status, body)). CLI command "remote-place <url> <customer> <sku> <qty>". Tests with a fake HttpMessageHandler: Client_maps_201, Client_maps_422, Client_throws_on_500.
Explain
Why map 422 to a result but 500 to an exception?
Check
Typed client maps codes correctly — checked (T3):
seat_fileseat_test4.6Lab: Orders over HTTP10 minproven · T4
Predict
The CLI remote-place hits the API which rejects the order (422). The CLI exit code by the course contract?
Lesson
The lab is the API end to end against PostgreSQL: health, create, read, validation and rejection paths with the right codes, and the CLI placing orders remotely with the same exit codes as locally. The grader starts your API, runs a published request sequence and a hidden one, and checks every status and body.
Demo
The tutor starts the API, runs the published sequence (health, 201, 200, 404, 400, 422), then remote-place from the CLI for an accepted and a rejected order, reading the exit codes.
Hands-on
Make the published sequence in samples/s4/requests.md pass exactly; remote-place returns 0 on Accepted and 4 on Rejected and prints the same lines as place; structured request logs (event=http.request method= path= status= ms=) on stderr.
Explain
What stayed identical between the CLI and the API paths?
Check
API sequence and remote CLI pass, including a hidden sequence — proven (T4):
seat_testseat_httpseat_run
Session 5 · Testing at scale
Six steps on a test suite that stays trustworthy as the service grows. The test pyramid for this service: many unit tests over the domain and use cases with fakes, fewer integration tests over PostgreSQL, a handful of end-to-end tests that start the API and call it with HttpClient. Fakes against stubs against spies, written by hand. Table-driven Theories for pricing and validation rules. Contract tests: the same tests run against the in-memory and the Postgres repository. Flakiness: finding it, fixing the clock and the ordering, never retrying it away. Reading the trx report. Live in the seat: a repository contract suite passing on both implementations, an end-to-end test that boots the API on a random port, a flaky test made deterministic with a fixed TimeProvider, and the report the grader reads.
Lab Ship the Orders test suite: 60+ tests across unit, integration and end-to-end, a repository contract suite on both implementations, table-driven rules, zero flakiness over three consecutive runs; the grader adds hidden contract rows and hidden Theory data. Session 5 checkpoint.
6 mini steps · ~52 min
5.1The pyramid for this service8 minchecked · T3
Predict
A rule "orders over 1,000 USD need approval" is best tested at which level?
Lesson
Unit tests (hundreds, milliseconds): domain and use cases with fakes. Integration (tens, seconds): repository and migrations against PostgreSQL. End-to-end (a handful): the API booted in-process or as a process, called over HTTP. Each level answers a different question; pushing rule tests up the pyramid makes them slow and vague.
Demo
The tutor sorts the existing tests into three folders with traits, times each category, and shows the ratio, then moves a rule test wrongly written as an API test down to a unit test.
Hands-on
Organise Orders.Tests into Unit/, Integration/ and EndToEnd/ with [Trait("Category", ...)] on each class; add an approval rule (orders over 1,000 USD → Pending) as a unit test first: PlaceOrder_marks_large_orders_pending, PlaceOrder_accepts_small_orders.
Explain
What question does each level of the pyramid answer?
Check
Tests organised by level; approval rule unit-tested — checked (T3):
seat_fileseat_test5.2Fakes, stubs and spies by hand8 minchecked · T3
Predict
You want to assert that PlaceOrder called INotifier.SendAsync exactly once. Which double?
Lesson
A fake is a working lightweight implementation (InMemoryOrderRepository). A stub returns canned answers (FixedClock). A spy records calls (SpyNotifier.Sent). All three are small classes you write; no mocking library is in the seat and none is needed. Prefer fakes for state, spies for interactions, and assert on outcomes before calls.
Demo
The tutor adds INotifier to PlaceOrder, writes SpyNotifier recording messages, asserts one notification with the order id, then shows a stub ICustomerDirectory that returns a fixed customer and a fake that stores them.
Hands-on
Add Application INotifier.SendAsync(OrderPlaced, ct) called once per accepted order; Tests/Fakes/SpyNotifier records calls. Tests: PlaceOrder_notifies_once_on_accept, PlaceOrder_does_not_notify_on_reject.
Explain
Why assert on outcomes before interactions?
Check
Spy notifier proves one notification — checked (T3):
seat_fileseat_test5.3Table-driven rules8 minchecked · T3
Predict
Pricing: 10% off for 10+ units; 5% customer discount for tier Silver; both apply multiplicatively. 10 units at 10.00 USD for a Silver customer totals?
Lesson
Rules with several inputs are tables: [Theory] with [MemberData] from a static source of (tier, quantity, unit, expected) rows, including boundaries (9 and 10 units) and zero. Name the Theory as the rule; let the row arguments name the case. The grader adds rows you have not seen.
Demo
The tutor writes Pricing.Total(tier, lines) with volume and tier discounts, a MemberData table of twelve rows, runs it, breaks the boundary and reads the failing row.
Hands-on
Add Domain Pricing.Total(CustomerTier tier, IEnumerable<OrderLine>) with 10% volume discount at 10+ units per line and tier discounts Silver 5%, Gold 10% (multiplicative, rounded to cents away from zero at the end). Theory Total_by_tier_and_quantity with at least twelve MemberData rows.
Explain
Which rows did you include that a casual tester would miss?
Check
Pricing Theory with twelve rows passes — checked (T3):
seat_fileseat_test5.4Contract tests for the repository9 minchecked · T3
Predict
A contract test suite passes on InMemoryOrderRepository and fails on PostgresOrderRepository for "Get returns null for unknown id". Where is the bug?
Lesson
An abstract test class defines the contract of IOrderRepository (save then get, get unknown returns null, save twice updates); two concrete subclasses supply the in-memory and the Postgres implementation. The fake stays honest, so unit tests built on it mean something.
Demo
The tutor writes OrderRepositoryContract with four tests and an abstract Create(), derives InMemory and Postgres versions, runs both, and fixes the Postgres Get that threw on unknown id.
Hands-on
Add Orders.Tests/Contracts/OrderRepositoryContract (abstract: Save_then_get, Get_unknown_returns_null, Save_twice_updates, Get_includes_lines) with InMemoryOrderRepositoryContractTests and PostgresOrderRepositoryContractTests (uses DbFixture). All eight pass.
Explain
What does a contract suite protect?
Check
Contract suite passes on both repositories — checked (T3):
seat_fileseat_test5.5End-to-end and flakiness9 minchecked · T3
Predict
An end-to-end test asserts the order's createdAt equals DateTimeOffset.UtcNow. It fails one run in twenty. The fix?
Lesson
An end-to-end test starts the API (as a child process on a random free port, with ORDERS_DB pointing at the fixture schema), waits for /health, then drives it with HttpClient. Flakiness comes from time, ordering and shared state; fix the cause with a fixed TimeProvider, deterministic ordering and isolated schemas. Never add retries to tests.
Demo
The tutor writes ApiFixture that launches the API process with a free port and a fake-time switch, waits for health, runs a create-then-get test, reproduces a time flake, and fixes it with ORDERS_FIXED_TIME.
Hands-on
Add Orders.Tests/EndToEnd/ApiFixture (process start, free port, health wait, dispose kills) and tests Api_creates_and_reads_order, Api_rejects_invalid_body_with_400, Api_uses_fixed_time_when_configured (ORDERS_FIXED_TIME). Run the suite three times in a row: identical results.
Explain
Name the three usual sources of flakiness and the fix for each.
Check
End-to-end tests pass three runs in a row — checked (T3):
seat_fileseat_test5.6Lab: the suite as a product10 minproven · T4
Predict
The grader drops a hidden contract subclass that runs your contract suite against its own repository implementation with a bug. What happens?
Lesson
The lab is the suite reviewed as a product: 60+ tests, the three levels with traits, a contract suite that catches a planted bug, table-driven rules with boundaries, no flakiness across three runs, formatted and documented. The grader adds hidden Theory rows and a hidden contract subclass over a deliberately broken repository that your contract must fail.
Demo
The tutor walks the checklist, runs the suite three times with --filter per category, and shows the hidden contract subclass failing against the planted bug as the desired outcome.
Hands-on
Complete the suite: 60+ tests; traits on every class; contract suite with at least six tests; Theories with boundaries; three consecutive green runs; README section "Testing" explaining the levels and how to run each.
Explain
How would you explain to a colleague why the hidden contract subclass is supposed to fail?
Check
60+ tests, three stable runs, hidden rows pass, hidden broken contract fails — proven (T4):
seat_lintseat_testseat_testseat_file
Session 6 · Performance: measure first
Six steps on making the service fast without guessing. Stopwatch and a tiny harness that repeats, warms up and reports medians, because BenchmarkDotNet is not in the seat and you should understand the method anyway. Allocation counting with GC.GetTotalAllocatedBytes. Complexity made visible: the same import at 1k, 10k and 100k lines. The usual suspects: string concatenation in loops, LINQ in hot paths, N+1 queries, synchronous I/O. Batching database writes. Measuring again, and knowing when to stop. Live in the seat: a loop that gets quadratically slower, a StringBuilder making it linear, an N+1 shown in the PostgreSQL log and fixed with one query, and a batch insert ten times faster than row-by-row.
Lab Make the import hot path measurably faster: a measurement harness with before/after numbers, the N+1 removed, batched inserts, allocations cut, and a PERF.md that shows the medians at three sizes. Session 6 checkpoint.
6 mini steps · ~51 min
6.1A measurement harness8 minchecked · T3
Predict
You time one run of a method once, cold, and get 42 ms. How much can you conclude?
Lesson
Bench.Run(name, action, iterations): warm up a few times, run N times with Stopwatch, report min, median and p95 plus allocated bytes from GC.GetTotalAllocatedBytes(precise: true). Compare medians, change one thing, measure again. Write numbers down with the commit they belong to.
Demo
The tutor writes the harness, times a trivial method cold and warm to show the JIT effect, then times the import parser at 10k lines and records the baseline.
Hands-on
Add Orders.Bench (console) with Bench.Run(string name, Action action, int iterations = 20) printing "name median=… ms p95=… ms alloc=… KB" and a Program timing OrderLineParser over samples/s6/orders-10k.csv. Record the baseline in dotnet/dn300/orders/PERF.md.
Explain
Why report a median rather than a mean?
Check
Bench harness runs and prints medians — checked (T3):
seat_fileseat_runseat_file6.2Complexity made visible8 minchecked · T3
Predict
Deduplicating lines with list.Contains inside a loop over n lines is how expensive?
Lesson
Measure at three sizes (1k, 10k, 100k): linear work grows tenfold each step; quadratic grows a hundredfold. List.Contains in a loop, string += in a loop, nested loops over two collections are the classic quadratics; HashSet, Dictionary and StringBuilder are the fixes. The numbers tell you which you have.
Demo
The tutor benches the duplicate-sku check at three sizes, reads the hundredfold growth, replaces List.Contains with HashSet.Add and reads the tenfold growth, then does the same for a string-building report.
Hands-on
Bench the importer's duplicate check at 1k, 10k and 100k (samples/s6/*.csv) and record the three medians in PERF.md; replace the quadratic structure with a HashSet; record the new medians. Unit test Duplicates_detected_in_linear_time (100k lines under a generous bound).
Explain
How did the numbers tell you the complexity?
Check
Quadratic duplicate check replaced; PERF.md shows three sizes — checked (T3):
seat_fileseat_fileseat_test6.3Allocations and strings8 minchecked · T3
Predict
Building a 10,000-line report with s += line allocates roughly how much compared with a StringBuilder?
Lesson
Allocations cost twice: once to allocate, once to collect. GC.GetTotalAllocatedBytes shows them. StringBuilder for building text, Span and stackalloc only when measured, avoid LINQ and lambdas in the innermost loop of a hot path, reuse buffers. Measure; most code does not need this, the hot path does.
Demo
The tutor benches the report renderer with += and with StringBuilder, reads alloc going from hundreds of MB to a few hundred KB, then removes a LINQ chain from the parser loop and measures the smaller gain.
Hands-on
Rewrite the reject-file writer and the report renderer to StringBuilder (or TextWriter directly); bench before and after and record alloc in PERF.md. Test Render_allocates_under_budget (10k lines, allocation below a stated budget using GC.GetAllocatedBytesForCurrentThread).
Explain
When should you stop optimising allocations?
Check
Renderer uses StringBuilder and meets its budget — checked (T3):
seat_fileseat_test6.4N+1 and batching9 minchecked · T3
Predict
Importing 1,000 orders looks up each customer with its own SELECT. How many round trips?
Lesson
N+1: one query per item instead of one query for all; fix with WHERE id = ANY(@ids) and a dictionary. Batching writes: one INSERT with many rows (or COPY) inside one transaction instead of a thousand single inserts. Count round trips in the PostgreSQL log; each one is a network hop.
Demo
The tutor turns on statement logging in the seat's PostgreSQL, imports 1,000 orders and counts 1,001 statements, rewrites the lookup with ANY and the insert as a batch, and counts 3.
Hands-on
Rewrite customer lookup in the import to one query with = ANY(@ids) and order insertion to batches of 500 rows in one transaction. Bench import of samples/s6/orders-10k.csv before and after; record in PERF.md. Integration test Import_uses_bounded_statement_count (a counting NpgsqlDataSource wrapper or pg_stat_statements delta below 50 for 1k orders).
Explain
Why do round trips matter more than row count?
Check
Lookups use ANY, inserts are batched — checked (T3):
seat_fileseat_test6.5Async I/O and the thread pool8 minchecked · T3
Predict
An endpoint calls File.ReadAllText (synchronous) 200 times concurrently. What does the thread pool do?
Lesson
Synchronous I/O in an async service blocks pool threads; under load p95 explodes while CPU stays idle. Use the Async APIs end to end, avoid Task.Run for I/O, and measure p95 under concurrency with a small load loop. Thread pool starvation shows as rising latency with low CPU.
Demo
The tutor adds a synchronous file read to an endpoint, drives 200 concurrent requests with a HttpClient loop and reads p95, switches to ReadAllTextAsync and reads it again.
Hands-on
Add Orders.Bench "load <url> <concurrency> <requests>" driving GET /orders/{id} and printing p50/p95; remove any remaining synchronous I/O from the API path (grep for ReadAllText, Result, Wait). Record p95 at concurrency 50 in PERF.md.
Explain
What symptom tells you the thread pool is starving?
Check
No synchronous I/O on the API path; load tool reports p95 — checked (T3):
craftseat_fileseat_file6.6Lab: the measured hot path10 minchecked · T3
Predict
After the fixes, import at 100k lines is 8× faster than the baseline. The lab asks for a target of…
Lesson
The lab is PERF.md as a deliverable: baseline and final medians at 1k, 10k, 100k for parse and import, allocation before and after, p95 under load, and a one-paragraph explanation of each change and its effect. The grader runs the bench and checks the 100k import against a published bound.
Demo
The tutor runs the bench suite end to end, fills PERF.md from the output, and compares the 100k import against the published bound.
Hands-on
Complete PERF.md with the before/after table; the 100k import (samples/s6/orders-100k.csv) completes under 20 seconds on the seat with allocation under 200 MB; all tests green.
Explain
Which single change gave the biggest win, and how do you know?
Check
PERF.md complete; 100k import within bounds — checked (T3):
seat_fileseat_runseat_test
Session 7 · Reading, reviewing and the AI era
Six steps on the skill that now matters most: reading code you did not write and deciding whether to trust it. Finding the entry point and following the data. Naming the invariants before touching anything. A review checklist for C# services: nullability, async, disposal, exceptions, SQL, boundaries. Writing a review that a colleague can act on. Verifying machine-generated code: treating it as a pull request from a stranger, testing its claims, and catching the confident mistake. Live in the seat: a supplied 300-line module read aloud with the data path drawn, five planted defects found with the checklist, a review written in the house format, a generated method whose tests pass but whose edge case fails, and the fix made under test.
Lab Review the supplied ReviewMe module: find and document its five planted defects with reproductions, write the review, fix each under a new test, and verify a machine-generated replacement for one method against your tests. Session 7 checkpoint.
6 mini steps · ~53 min
7.1Finding the entry point and the data path8 minartefact · T2
Predict
You open a 300-line class you have never seen. The best first move?
Lesson
Start from the public surface: which methods are called from outside? Pick one, follow its parameters through every transformation to every output, and write the path down as a list. Note what is read, what is written, what is thrown. Then do the next entry point. The notes are the map you review against.
Demo
The tutor opens samples/s7/ReviewMe.cs (an order settlement module), lists its three public methods, follows Settle(orderId) through repository, pricing, ledger and notifier, and writes the data path as eight lines on screen.
Hands-on
Copy samples/s7/ReviewMe.cs into Orders.Review (a classlib in the solution) and write REVIEW-NOTES.md: the public entry points, the data path of Settle step by step, every external call, every exception that can escape.
Explain
What did following one input teach you that reading top to bottom would not?
Check
REVIEW-NOTES.md maps the module — artefact (T2):
seat_file7.2Naming the invariants8 minartefact · T2
Predict
"A settled order is never settled twice" is which kind of statement?
Lesson
Before judging code, write what must always be true: money in equals money out; an order settles at most once; the ledger and the order status agree; no notification without a committed settlement. Then read each path asking "does this keep every invariant, including when it throws?" Most planted defects are invariant breaks on a failure path.
Demo
The tutor writes five invariants for ReviewMe, then walks Settle's exception paths and finds the notification sent before the ledger commit.
Hands-on
Add an Invariants section to REVIEW-NOTES.md with at least five invariants of the settlement module, each with the paths you checked it against.
Explain
Which invariant is most often broken by exception paths, and why?
Check
Five invariants written down — artefact (T2):
seat_file7.3The review checklist9 minartefact · T2
Predict
catch (Exception) { return false; } around a database write in Settle. Which checklist item fires?
Lesson
A C# service checklist: nullability honoured; every async method takes and passes a token; every IDisposable disposed; exceptions caught specifically and never swallowed; SQL parameterised; money is decimal; time comes from a provider; boundaries validate; transactions wrap multi-step writes; logs carry ids. Run the list down every method; write each hit with file, line and the invariant it threatens.
Demo
The tutor runs the checklist over ReviewMe and finds the five planted defects: a concatenated SQL string, a swallowed exception, a missing transaction around two writes, a double in a money calculation, and a notification sent before commit; each gets file:line and an invariant.
Hands-on
Add a Findings section to REVIEW-NOTES.md with every defect you find (file, line, checklist item, invariant threatened, severity). There are exactly five planted defects; samples/s7/KEY.md is sealed until step 7.5.
Explain
Which checklist item found the defect you would have missed, and why?
Check
Findings listed with lines and invariants — artefact (T2):
seat_file7.4Reproduce, then fix under test9 minchecked · T3
Predict
You are sure the money calculation uses double and is wrong. Before fixing, you should…
Lesson
Every finding becomes a failing test before a fix: the double rounding error shown in cents, the swallowed exception shown by a stub that throws, the missing transaction shown by a stub that fails on the second write, the notification-before-commit shown with a spy and a failing ledger. Then fix each, test green, and record it in the review.
Demo
The tutor writes the failing test for the money defect (0.1 + 0.2 in a settlement), fixes it with decimal, then does the transaction defect with a stub ledger that throws on the second write.
Hands-on
In Orders.Tests/Review add one failing-then-passing test per defect: Settle_uses_decimal_money, Settle_does_not_swallow_ledger_failure, Settle_is_atomic_across_order_and_ledger, Settle_notifies_only_after_commit, Reconcile_uses_parameterised_sql (asserting the SQL text contains @ and no concatenation). Fix ReviewMe until all pass.
Explain
Which defect was hardest to reproduce, and what double did you need?
Check
Five defect tests pass after the fixes — checked (T3):
seat_fileseat_test7.5Verifying machine-generated code9 minchecked · T3
Predict
A generated Refund method comes with three passing tests it also generated. What does that prove?
Lesson
Treat generated code as a pull request from a stranger: read it with the checklist, run your tests not its tests, add the boundaries it did not mention (zero, negative, already refunded, partial refund over the total), and check its claims in comments against the code. Confident prose is not evidence; a failing test is.
Demo
The tutor pastes samples/s7/GeneratedRefund.cs (a plausible Refund with its own tests), runs the module's tests (green), adds a partial-refund-over-total test (red), finds the sign error, and fixes it.
Hands-on
Replace Refund in ReviewMe with samples/s7/GeneratedRefund.cs; write at least four independent tests including Refund_cannot_exceed_settled_amount, Refund_of_zero_is_rejected, Refund_twice_is_idempotent_by_key, Refund_updates_ledger_and_status_atomically; fix the generated code until they pass. Open samples/s7/KEY.md and reconcile your findings in REVIEW-NOTES.md.
Explain
What habit will you keep when accepting generated code?
Check
Generated Refund verified and corrected — checked (T3):
seat_testseat_file7.6Lab: the written review10 minchecked · T3
Predict
A review comment says "this is wrong". A colleague can act on it when it also has…
Lesson
The lab is REVIEW.md in the house format: summary, invariants, findings (file:line, item, invariant, reproduction test, fix, severity), the generated-code verification, and what you would ask the author. All five planted defects found and fixed under test; the module's full suite green.
Demo
The tutor writes REVIEW.md from the notes, reads it as the author would, and trims every sentence that does not help the author act.
Hands-on
Write dotnet/dn300/orders/Orders.Review/REVIEW.md with sections Summary, Invariants, Findings (all five, each with a test name), Generated code, Questions for the author. All review tests green; REVIEW.md names the five tests from 7.4.
Explain
What makes your review different from a list of complaints?
Check
REVIEW.md complete; review tests green — checked (T3):
seat_fileseat_test
Session 8 · Capstone: Orders
Six steps that finish Orders as a production-shaped batch and API service from a specification. The run command: read an order file, validate every line, price with the tier rules, write a posting report and a reject file with reason codes, persist accepted orders, and return 0 when all posted, 4 when some were rejected, 8 on a fatal problem. Structured logs with a run id. Idempotent reruns. The five published input sets and their expected outputs. Live in the seat: the spec turned into a test list, the walking skeleton returning codes, the reject file growing one reason code at a time, a rerun that posts nothing twice, and the five sets run end to end with diffs against the published files.
Lab Orders run <file> produces the exact report and reject file, persists accepted orders once even on rerun, logs with a run id, and returns 0/4/8 by the contract on the five published sets and two you did not see; the API still serves what was posted. Session 8 checkpoint and the course placement step.
6 mini steps · ~56 min
8.1The spec into tests8 minartefact · T2
Predict
A run with 10 orders where 3 are rejected for unknown customers and 7 posted returns…
Lesson
Read samples/s8/ORDERS-SPEC.md twice. Write the exit-code table, the reason codes (R01 unknown customer, R02 bad quantity, R03 unknown sku, R04 duplicate order id, R05 over credit limit), the report layout and the reject-file layout, and turn each into a test name. Mark which are unit, integration and end-to-end.
Demo
The tutor builds the tables on screen from the spec and writes RunOrders tests as skipped stubs with names from the rules.
Hands-on
Write dotnet/dn300/orders/CAPSTONE-PLAN.md: exit-code table, reason-code table, report and reject layouts, and at least fifteen test names grouped by level.
Explain
Which rule in the spec is ambiguous, and how did you resolve it?
Check
CAPSTONE-PLAN.md has the tables and test names — artefact (T2):
seat_file8.2The walking skeleton9 minproven · T4
Predict
run on a missing file: exit code and output?
Lesson
RunOrders.ExecuteAsync(path, ct) returns RunResult(posted, rejected, fatal?) and the CLI maps it to 0/4/8; a RunId (ulid-like, from the clock) appears in every log line (event=run.start run=… file=…). The skeleton writes an empty report and reject file with headers and returns 0 for an empty input. Logs to stderr, results to files, summary to stdout.
Demo
The tutor wires run in the CLI, generates a run id, logs start and done, writes headers only, and runs the empty and missing cases.
Hands-on
Add Application RunOrders and CLI run <file> [--out <dir>]: empty file → report.txt and rejects.csv with headers, stdout "Posted 0 Rejected 0", exit 0; missing file → exit 8 with event=run.fatal reason="Cannot read <path>". Every log line carries run=<id>.
Explain
Why does every log line carry the run id?
Check
Skeleton: empty → 0 with headers; missing → 8 — proven (T4):
seat_runseat_file8.3Validation and reason codes10 minchecked · T3
Predict
An order has an unknown customer and a bad quantity on line 2. How many reject rows, by the spec?
Lesson
OrderValidator.Validate(order, customers, catalogue) returns a list of Rejection(OrderId, Line, Code, Detail) covering every failed rule, in code order: R01 unknown customer, R02 quantity outside 1–10000, R03 unknown sku, R04 duplicate order id within the file, R05 total over the customer's credit limit. Table-driven tests with every code.
Demo
The tutor writes the validator with a Theory of seven rows covering each code and a two-failure order, implements rule by rule, and shows the reject rows in code order.
Hands-on
Add Domain OrderValidator with the five rules and Rejection record; Theory Validate_emits_reason_codes with at least eight rows (each code, a clean order, a two-failure order, a duplicate pair). Reject rows are written as orderId,line,reason,detail.
Explain
Why report every failed rule rather than the first?
Check
Validator emits the five codes in order — checked (T3):
seat_fileseat_test8.4Pricing, posting and the report10 minchecked · T3
Predict
The report total line sums posted orders only. A set with 7 posted at 100.00 and 3 rejected at 50.00 shows a total of…
Lesson
Accepted orders are priced with Pricing.Total, persisted in one batch transaction per run, and listed in report.txt: header, one line per posted order (id left 12, customer left 10, lines right 5, total right 12), a rule, totals (count, sum) and the reject count. Rendering is pure and tested against the published report for set 1.
Demo
The tutor implements posting and the renderer, runs set 1, and diffs report.txt against the published file until byte-equal.
Hands-on
Implement posting (batched save) and ReportRenderer.Render(RunSummary) per the spec; test Render_matches_set1 against samples/s8/set1.report.txt; run set1 end to end: report byte-equal, rejects.csv byte-equal, exit 4 (set 1 has two rejects).
Explain
Why is the renderer pure when the run itself touches the database?
Check
Set 1 report and rejects byte-equal — checked (T3):
seat_testseat_run8.5Idempotent reruns9 minchecked · T3
Predict
The same file is run twice. The second run should…
Lesson
Idempotency: a unique constraint on orders.id plus a pre-check that marks already-posted ids as "P00 already posted" in the report's note column, not as rejects. The run writes a run row (id, file hash, started, finished, code) so an operator can see what ran. A crash mid-run leaves no partial posting because the batch is one transaction.
Demo
The tutor runs set 1 twice, shows the second run posting zero and noting eight already posted, then kills the process mid-run with a planted delay and shows the database unchanged.
Hands-on
Add migration 004_runs.sql (runs table) and the already-posted check; run set1 twice: second run prints "Posted 0 Rejected 2 (8 already posted)" and exit 4; integration test Run_twice_posts_once; test Run_records_a_run_row.
Explain
Why rely on a database constraint as well as a check?
Check
Reruns post nothing twice and record a run row — checked (T3):
seat_fileseat_testseat_run8.6Lab: the five sets and the contract10 minproven · T4
Predict
Set 4 is all rejects. Exit code, report total and reject row count for 6 orders?
Lesson
Run the five published sets (1 standard, 2 clean, 3 empty, 4 all rejects, 5 duplicates and credit limits) and record code and diffs in TESTLOG.md; then the API must serve GET /orders/{id} for a posted order from set 1. The grader runs the five, two hidden sets, the hidden tests, and the API read.
Demo
The tutor runs the five sets in a row with diffs, fixes the credit-limit rounding that set 5 reveals, writes the test log, and reads a posted order back through the API.
Hands-on
All five sets byte-equal with the right codes (1 → 4, 2 → 0, 3 → 0, 4 → 4, 5 → 4); TESTLOG.md rows per set; README complete; dotnet test 85+ green; the API returns 200 for an order posted by set 2.
Explain
What in this capstone would you show a hiring manager, and why?
Check
Orders passes five published sets, two hidden sets, hidden tests and the API read — proven (T4):
seat_fileseat_lintseat_testseat_runseat_http
Outcome
By the end of the course the tutor's checks will have verified: a tested, documented service for every session; eight labs that each grow the last — the layered skeleton, the cancellable pipeline, the repository over PostgreSQL, the minimal API, the integration suite, the measured hot path, the reviewed and corrected module; and the Orders capstone, whose validation, pricing, report, reject file with reason codes and exit codes 0/4/8 match the published expected values on the five published and two hidden input sets.
Certificate
Agile System Software Certificate — C#, advanced (DN300) attests that you can design, build, test, measure and review a production-shaped C# service with asynchronous processing, PostgreSQL persistence, an HTTP API and an exit-code contract, verified by a machine from your own seat including inputs and tests you did not see.
To earn it:
- Pass the lab check of every session (steps 1.6 through 7.6) on your own code.
- Pass the Orders capstone check (8.6) including the two hidden input sets and the hidden xUnit tests the grader supplies.
- Score 80% or better on the 30-question reading quiz.
Gates (step · tier required):
Plus a 30-question reading quiz at 80%, retake after 48 h. Certificates are issued once the seat check runner is live; until then every step records what the engine saw.
Environment
A VS Code seat in your browser with the .NET 10 SDK, ASP.NET Core minimal APIs, xUnit, Npgsql and Dapper, and PostgreSQL 17 running inside the seat, all offline; nothing to install. Your work lives in dotnet/dn300; the tutor's demo files live apart from yours and the tutor never edits your hands-on file while you are working on it.
Open Coding Tutor at codingtutor.agilesystemsoftware.com, pick DN300 from the course button in the chat and press Tutor me. Every lesson, demo and hands-on step is also published below.