Agile System SoftwareLearn

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.

  • LevelAdvanced
  • Durationself-paced ~40–50 h
  • Size8 sessions · 48 mini steps · ~424 min of lessons
  • EnvironmentA 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
  • PrerequisitesDN100, or pass its placement step 8.6. Comfortable with records, LINQ, xUnit and exceptions.
    Courses: DN100
  • CertificateAgile System Software Certificate — C#, advanced (DN300)

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).

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. 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_file seat_file seat_file seat_build

    #s1-1

  2. 1.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_file seat_test

    #s1-2

  3. 1.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_file seat_file seat_test

    #s1-3

  4. 1.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_file seat_run seat_test

    #s1-4

  5. 1.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_file seat_test seat_run

    #s1-5

  6. 1.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_build seat_test seat_run

    #s1-6

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
  1. 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_file seat_test

    #s2-1

  2. 2.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_file seat_test

    #s2-2

  3. 2.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_file seat_test

    #s2-3

  4. 2.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_file seat_test

    #s2-4

  5. 2.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_file seat_test

    #s2-5

  6. 2.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_test seat_run

    #s2-6

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
  1. 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_file seat_test

    #s3-1

  2. 3.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_file seat_test

    #s3-2

  3. 3.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_file seat_file seat_test

    #s3-3

  4. 3.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_file seat_test

    #s3-4

  5. 3.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_file seat_test

    #s3-5

  6. 3.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_test seat_run

    #s3-6

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
  1. 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_file seat_http

    #s4-1

  2. 4.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_http

    #s4-2

  3. 4.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_test seat_http

    #s4-3

  4. 4.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_file seat_test

    #s4-4

  5. 4.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_file seat_test

    #s4-5

  6. 4.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_test seat_http seat_run

    #s4-6

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
  1. 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_file seat_test

    #s5-1

  2. 5.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_file seat_test

    #s5-2

  3. 5.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_file seat_test

    #s5-3

  4. 5.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_file seat_test

    #s5-4

  5. 5.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_file seat_test

    #s5-5

  6. 5.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_lint seat_test seat_test seat_file

    #s5-6

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
  1. 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_file seat_run seat_file

    #s6-1

  2. 6.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_file seat_file seat_test

    #s6-2

  3. 6.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_file seat_test

    #s6-3

  4. 6.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_file seat_test

    #s6-4

  5. 6.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): craft seat_file seat_file

    #s6-5

  6. 6.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_file seat_run seat_test

    #s6-6

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
  1. 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_file

    #s7-1

  2. 7.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_file

    #s7-2

  3. 7.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_file

    #s7-3

  4. 7.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_file seat_test

    #s7-4

  5. 7.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_test seat_file

    #s7-5

  6. 7.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_file seat_test

    #s7-6

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
  1. 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_file

    #s8-1

  2. 8.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_run seat_file

    #s8-2

  3. 8.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_file seat_test

    #s8-3

  4. 8.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_test seat_run

    #s8-4

  5. 8.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_file seat_test seat_run

    #s8-5

  6. 8.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_file seat_lint seat_test seat_run seat_http

    #s8-6

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.