Workshop · C# · Intermediate
DN100 C# I: the working programmer’s toolkit
The C# a team expects you to know on day one, taught by building. Forty-eight steps across eight sessions: idiomatic C# and the tooling, records and pattern matching, LINQ over real collections, exceptions and disposable resources, xUnit from Fact to Theory to test-first, JSON and CSV and dates, and a multi-project solution with docs — ending in Stockroom, a tested inventory library with a command-line front end whose hidden tests you must pass.
Open Coding Tutor at codingtutor.agilesystemsoftware.com, pick DN100 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
Programmers who can already write a small program in C# or another language (DN050 or any x050 course) and want to work the way a .NET team works: typed models, LINQ, exceptions and resources done right, xUnit, JSON and files, a multi-project solution with documentation. If your first language was not C#, session 1 re-teaches the syntax in a day.
Bridge. Coming from PY050, JV050, RS050, NG050: the concepts you already proved there are credited here (9 of them), and the first session re-teaches the syntax.
Where it leads. DN300 C#, advanced: building a service
What you will be able to do
- Lay out a solution, keep nullable reference types on, format with dotnet format and step through code in the debugger
- Model data with records, enums, interfaces and pattern matching, with decimal for money and nullable for maybe
- Query collections with LINQ: Where, Select, GroupBy, OrderBy, aggregates, and write a generic method
- Throw and catch the right exceptions, define your own, and release resources with using
- Write xUnit Facts and Theories, test first, and read a test report
- Read and write CSV and JSON with System.Text.Json, and handle dates, times and time zones
- Split a program into a library, a CLI and tests with XML docs and a README
- Build a tested library plus CLI from a specification, with a command and 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. Idiomatic C# and the tooling
- 2. Types and data modelling
- 3. Collections and LINQ
- 4. Errors and resources
- 5. Testing
- 6. Files, data formats and time
- 7. Projects, packages and docs
- 8. Capstone: Stockroom
Session 1 · Idiomatic C# and the tooling
Six steps that make the seat a workshop. A solution with projects and references, and what the .csproj really says. Nullable reference types on and what the warnings mean. The syntax a programmer from another language needs in a day: var, properties, expression-bodied members, string interpolation, top-level statements against an explicit Main. dotnet format for style, warnings as errors, and the debugger: a breakpoint, step over, step into, watch. Live in the seat: a solution built from the terminal, a CS8600 warning fixed three ways, a file formatted in one command, and a bug found by stepping instead of printing.
Lab Set up dotnet/dn100/s1 as a solution with a library, a console app and a test project, nullable and warnings-as-errors on, a formatted source tree, and one passing test — the shape every later session reuses. Session 1 checkpoint.
6 mini steps · ~50 min
1.1A solution, not a file8 minchecked · T3
Predict
A console project references a class library. Which one does dotnet run start?
Lesson
A .sln lists projects; each .csproj says its SDK, target framework, output type and references. A library holds logic, a console app holds the entry point, tests reference the library. dotnet build at the solution root builds them all in dependency order.
Demo
The tutor creates dotnet/dn100/demo/shape with dotnet new sln, classlib Core, console App and xunit Core.Tests, adds the references and the projects to the solution, opens the .csproj files and reads each line, then builds once from the root.
Hands-on
Create dotnet/dn100/s1 the same way: solution Shop with Shop.Core (classlib), Shop.Cli (console referencing Core) and Shop.Tests (xunit referencing Core). dotnet build from s1 must succeed.
Explain
Why keep logic in a library rather than in the console project?
Check
The Shop solution has three projects and builds — checked (T3):
seat_fileseat_fileseat_build1.2The syntax in a day9 minchecked · T3
Predict
public string Name { get; init; } — what does init mean?
Lesson
var infers a type you could have written. Properties replace fields: { get; set; }, { get; init; }, { get; }. Expression-bodied members (=>) for one-liners. $"{x:F2}" interpolation. Top-level statements are a Main the compiler writes; a class with static Main is the same thing spelled out.
Demo
The tutor writes a Product class in Core with init properties and an expression-bodied Describe(), creates two with object initialisers in the CLI, prints them, then shows the same CLI with an explicit static int Main.
Hands-on
In Shop.Core add class Product with Name (string, init), Price (decimal, init) and Quantity (int, get/set), and string Describe() => $"{Name} x{Quantity} at {Price:F2}". In Shop.Cli create two products with initialisers and print Describe().
Explain
When would you use init instead of set?
Check
Product with init properties prints two descriptions — checked (T3):
seat_fileseat_run1.3Nullable reference types8 minchecked · T3
Predict
With <Nullable>enable</Nullable>, string? s = null; Console.WriteLine(s.Length); — what does the compiler say?
Lesson
string is never null; string? may be. The compiler tracks it and warns where you dereference a maybe-null. Fix with a check (if (s is not null)), the null-conditional s?.Length, the null-coalescing s ?? "default", or by making the type non-nullable at the source. The ! operator says "trust me" and should be rare.
Demo
The tutor adds string? Note to Product, uses Note.Length to get the CS8602 warning, then fixes it each of the three ways and finally shows TreatWarningsAsErrors turning the warning into a failed build.
Hands-on
Add string? Note { get; init; } to Product and a method NoteLength() that returns Note?.Length ?? 0. Turn on <TreatWarningsAsErrors>true</TreatWarningsAsErrors> in all three .csproj files; the build must stay clean.
Explain
What does the ? after a type promise?
Check
Nullable on, warnings as errors, clean build — checked (T3):
seat_fileseat_fileseat_build1.4dotnet format and warnings as errors7 minchecked · T3
Predict
dotnet format --verify-no-changes on a badly indented file does what?
Lesson
Style is a tool's job. dotnet format rewrites whitespace and ordering by the rules in .editorconfig; with --verify-no-changes it becomes a gate. Analyzer warnings (unused variables, IDE rules) are bugs waiting; with warnings as errors they stop the build, which is where you want them.
Demo
The tutor mangles the indentation of Product.cs, runs dotnet format, shows the diff, adds an .editorconfig with 4-space indentation and file-scoped namespaces, formats again, then runs the verify mode on the clean tree.
Hands-on
Add dotnet/dn100/s1/.editorconfig (indent_size = 4, csharp_style_namespace_declarations = file_scoped:warning), run dotnet format on the solution, and leave the tree so that dotnet format --verify-no-changes passes.
Explain
Why let a tool decide formatting?
Check
The tree is formatted — checked (T3):
seat_fileseat_lint1.5The debugger9 minchecked · T3
Predict
You set a breakpoint inside a loop and press Continue. Where does the program stop next?
Lesson
A breakpoint pauses the program at a line; Variables shows every value in scope; Step Over runs one line, Step Into enters a call, Continue runs to the next breakpoint. Conditional breakpoints stop only when an expression is true. Ten minutes in the debugger beats an hour of Console.WriteLine.
Demo
The tutor opens dotnet/dn100/samples/s1/Totals.cs (a totaller with an off-by-one), sets a breakpoint in the loop, watches the index and the running total, finds the skipped first item, and fixes it while the program is paused.
Hands-on
Copy samples/s1/Totals.cs into Shop.Core, call Totals.Sum from the CLI on the sample list, and use the debugger (not prints) to find why the result is 10.00 short. Fix it so Sum returns 60.00 for the sample.
Explain
What did the debugger show you that a print would not?
Check
Totals.Sum returns 60.00 for the sample — checked (T3):
seat_fileseat_run1.6Lab: the workshop shape9 minchecked · T3
Predict
Shop.Tests has one [Fact] that asserts Totals.Sum of an empty list is 0. dotnet test reports what?
Lesson
The lab is the shape every later session starts from: a solution with Core, Cli and Tests; nullable on and warnings as errors everywhere; a formatted tree; a passing test for the library. Save it as your template.
Demo
The tutor reviews the demo solution against a checklist on screen (projects, references, nullable, warnings, .editorconfig, test), runs build, format verify and test in a row, and shows the trx report the grader reads.
Hands-on
Complete dotnet/dn100/s1: Shop.Tests has Sum_of_empty_is_zero and Sum_of_sample_is_60 passing; every csproj has Nullable and TreatWarningsAsErrors; dotnet format --verify-no-changes and dotnet test both pass.
Explain
Name the three commands you will run before every commit from now on.
Check
Build, format and two tests pass — checked (T3):
seat_buildseat_lintseat_test
Session 2 · Types and data modelling
Six steps on saying what your data is so the compiler can hold you to it. Records for values, classes for identity, structs when you must. Enums for closed sets and why you parse them, never cast from strings. Interfaces as contracts and the small ones that compose. Pattern matching: switch expressions over types, properties and tuples. Money as decimal with a Money record that refuses mixed currencies. Nullable value types and the Maybe shape. Live in the seat: a Product record with value equality, an Enum.TryParse that saves a crash, an IPriceable with two implementations, a switch on a tuple of (category, quantity), and a Money add that throws on USD + EUR.
Lab Model the shop: Product record, Category enum, Money record with currency, an IDiscount interface with two implementations chosen by a switch expression, and tests for value equality, enum parsing and the mixed-currency rule. Session 2 checkpoint.
6 mini steps · ~50 min
2.1Record, class or struct8 minchecked · T3
Predict
record Point(int X, int Y); var a = new Point(1, 2); var b = a with { Y = 3 }; What is a.Y?
Lesson
A record is a value: equality by content, immutable by default, with-expressions for copies. A class has identity: two instances with equal fields are different objects. A struct is a small value stored inline; use it rarely and keep it immutable. Model data as records unless something must be mutated in place.
Demo
The tutor declares Product as a record and as a class side by side, compares two equal instances of each, prints both, uses with to change a price, and shows the record's generated ToString and deconstruction.
Hands-on
In dotnet/dn100/s2 (copy of the s1 shape) make Product a positional record (string Sku, string Name, decimal Price, int Quantity) in Shop.Core; in Shop.Tests add Equal_records_are_equal and With_copies_without_mutating.
Explain
When would a class be the right choice over a record?
Check
Product is a record and both tests pass — checked (T3):
seat_fileseat_test2.2Enums: closed sets7 minchecked · T3
Predict
enum Category { Food, Tools } — (Category)7 compiles and runs. What is its value?
Lesson
An enum names the members of a closed set. Parse text with Enum.TryParse<Category>(text, true, out var c) and reject the rest; never cast from an int you did not produce. A switch over an enum without a default arm warns when a member is unhandled, which is the point.
Demo
The tutor declares Category { Food, Tools, Stationery }, parses "tools" and "toys" with TryParse, casts 7 to show the hole, and writes a switch expression over Category that the compiler checks for completeness.
Hands-on
Add enum Category { Food, Tools, Stationery } and a Category field to Product. Add static Category? ParseCategory(string text) using TryParse (case-insensitive, Enum.IsDefined) returning null for unknown. Tests: ParseCategory_accepts_any_case, ParseCategory_rejects_unknown.
Explain
Why return null for an unknown category instead of a default member?
Check
Category enum parses safely — checked (T3):
seat_fileseat_test2.3Interfaces as contracts8 minchecked · T3
Predict
interface IDiscount { decimal Apply(decimal price); } — can a record implement it?
Lesson
An interface says what a type can do, not how. Small interfaces (one or two members) compose: a method that takes IDiscount works with every implementation, today's and next year's. Prefer composing small behaviours to deep inheritance.
Demo
The tutor writes IDiscount with Apply, a PercentOff record and a FixedOff record implementing it, a method that takes IDiscount and a price, and swaps implementations without touching the method.
Hands-on
Add interface IDiscount { decimal Apply(decimal price); } with record PercentOff(decimal Percent) and record FixedOff(decimal Amount) (never below zero). Tests: PercentOff_takes_percentage, FixedOff_never_goes_negative.
Explain
What does a method that takes IDiscount gain over one that takes PercentOff?
Check
Two discounts implement IDiscount — checked (T3):
seat_fileseat_test2.4Pattern matching9 minchecked · T3
Predict
(Category.Food, 12) switch { (Category.Food, > 10) => "bulk", (Category.Food, _) => "single", _ => "other" } gives?
Lesson
switch expressions match on constants, types (is PercentOff p), properties ({ Quantity: 0 }), tuples and relational patterns (> 10), with when for anything else. Order matters: first match wins. The discard arm _ is your "I thought about the rest".
Demo
The tutor writes Describe(object o) with type patterns, then ChooseDiscount((Category, int qty)) with tuple and relational patterns, then a property pattern on Product { Quantity: 0 } => "out of stock".
Hands-on
Add static IDiscount? ChooseDiscount(Category c, int qty) as one switch expression: Food with qty >= 10 → PercentOff(10), Tools with qty >= 5 → FixedOff(5), Stationery → PercentOff(5), anything else → null. Tests: ChooseDiscount_bulk_food, ChooseDiscount_none_for_single_tool.
Explain
Why does arm order matter in a switch expression?
Check
ChooseDiscount uses patterns and passes — checked (T3):
seat_fileseat_test2.5Money and maybe8 minchecked · T3
Predict
record Money(decimal Amount, string Currency); new Money(1m, "USD") + new Money(1m, "EUR") — what should happen by the lab rule?
Lesson
Money is a decimal and a currency, together. A Money record with a + operator that throws on mixed currencies turns a silent bug into a loud one. int? and decimal? are nullable value types: HasValue, Value, and ?? for defaults. Absence belongs in the type.
Demo
The tutor writes Money with operator + and a mixed-currency throw, adds two USD values, adds USD and EUR to see the exception, then shows decimal? LastPrice on Product with ?? for a default.
Hands-on
Add record Money(decimal Amount, string Currency) with operator + (throws InvalidOperationException on different currencies) and ToString "12.50 USD". Tests: Money_adds_same_currency, Money_refuses_mixed_currency (Assert.Throws).
Explain
Why throw on mixed currencies instead of converting?
Check
Money adds and refuses correctly — checked (T3):
seat_fileseat_test2.6Lab: the shop model10 minchecked · T3
Predict
A Product of Category.Food, qty 12, price 2.00 USD. Priced through ChooseDiscount, what is the line total?
Lesson
The lab joins the model: Product with Category and a Money price, LineTotal(product) that picks a discount with ChooseDiscount and applies it, and tests that read like the business rules. Everything is a record or an enum; nothing is a bare string or double.
Demo
The tutor writes Pricing.LineTotal(Product) returning Money, prints a few products through the CLI, and runs the whole test suite green, then shows the test names as a list of rules.
Hands-on
Add Money Price to Product (replacing decimal Price), static class Pricing with Money LineTotal(Product p) applying ChooseDiscount. Tests: LineTotal_applies_bulk_food_discount (21.60 USD), LineTotal_without_discount, plus every earlier test green. The CLI prints three sample line totals.
Explain
Which bug can this model no longer express?
Check
The shop model passes all twelve tests — checked (T3):
seat_fileseat_testseat_run
Session 3 · Collections and LINQ
Six steps on working with many things at once. IEnumerable<T> as the contract every collection honours and deferred execution as its surprise. Where, Select, OrderBy and ThenBy. Aggregates: Sum, Count, Min, Max, Average and Aggregate. GroupBy and the shape of a group. Query syntax against method syntax and when each reads better. Writing your own generic method with a constraint. Live in the seat: a query that runs twice because it was enumerated twice, a GroupBy printed as a report, a Sum over decimals against a Sum over doubles, and a generic TopN<T> that works on products and on strings.
Lab Write the inventory reports with LINQ: value by category, the five lowest-stock products, average price per category, and a generic TopN helper, tested on a published product list and on one you did not see. Session 3 checkpoint.
6 mini steps · ~50 min
3.1IEnumerable<T> and deferred execution8 minchecked · T3
Predict
var q = items.Where(x => { count++; return true; }); q.Count(); q.Count(); — how many times did the lambda run for 3 items?
Lesson
IEnumerable<T> promises one thing: you can walk it. LINQ operators build a description of work and run it when something enumerates (foreach, ToList, Count). Enumerate twice and it runs twice; call ToList once when you need the results more than once.
Demo
The tutor builds a Where with a counting side effect, enumerates it twice to show the double run, materialises with ToList, then shows a query over a list that changed after the query was written.
Hands-on
In dotnet/dn100/s3 (copy of s2) write Inventory.InStock(IEnumerable<Product>) returning products with Quantity > 0 as IEnumerable<Product>. Test InStock_is_lazy: add a product after creating the query and show it appears when enumerated.
Explain
When should you call ToList on a query?
Check
InStock is a lazy query — checked (T3):
seat_fileseat_test3.2Where, Select, OrderBy8 minchecked · T3
Predict
products.OrderBy(p => p.Quantity).ThenByDescending(p => p.Price).Take(2) — which two?
Lesson
Where filters, Select transforms, OrderBy/ThenBy sort by keys, Take/Skip slice. Chain them left to right and read them as a sentence. Select into an anonymous type or a record when the output shape differs from the input.
Demo
The tutor chains Where → OrderBy → ThenByDescending → Take → Select into a record (Sku, Quantity) and prints it, then rewrites the chain in query syntax to compare.
Hands-on
Add Inventory.LowStock(IEnumerable<Product>, int n) returning the n lowest-quantity products (ties: higher price first) as records StockLine(string Sku, int Quantity). Tests: LowStock_takes_n_lowest, LowStock_breaks_ties_by_price.
Explain
Why Select into a small record instead of returning Product?
Check
LowStock orders and slices — checked (T3):
seat_fileseat_test3.3Aggregates7 minchecked · T3
Predict
new List<decimal>().Average() does what?
Lesson
Sum, Count, Min, Max and Average fold a sequence into one value; Aggregate does it with your own function. Sum on decimals stays exact; on doubles it drifts. Average and Min/Max throw on empty sequences, so guard them.
Demo
The tutor sums stock value with Sum(p => p.Price.Amount * p.Quantity), averages prices, shows Average on an empty list throwing and the DefaultIfEmpty fix, and compares a decimal Sum with a double Sum over the same 1000 prices.
Hands-on
Add Inventory.TotalValue(IEnumerable<Product>) returning Money (sum of price × quantity, single currency) and Inventory.AveragePrice(IEnumerable<Product>) returning decimal? (null when empty). Tests: TotalValue_sums_price_times_qty, AveragePrice_of_empty_is_null.
Explain
Why return decimal? from AveragePrice?
Check
TotalValue and AveragePrice pass — checked (T3):
seat_fileseat_test3.4GroupBy9 minchecked · T3
Predict
products.GroupBy(p => p.Category) gives a sequence of what?
Lesson
GroupBy partitions by a key into groups you can enumerate; Select over the groups makes a report row per key (g.Key, g.Sum(...), g.Count()). ToDictionary(g => g.Key, g => ...) when you need lookups. OrderBy the report rows, not the groups.
Demo
The tutor groups products by Category, selects (Key, Count, Value) rows, orders by value descending, prints them aligned, then shows the same with query syntax and group ... by ... into.
Hands-on
Add Inventory.ValueByCategory(IEnumerable<Product>) returning IReadOnlyList<CategoryValue(Category Category, int Items, decimal Value)> ordered by Value descending then Category name. Tests: ValueByCategory_groups_and_sums, ValueByCategory_orders_by_value.
Explain
What is the difference between the result of GroupBy and ToDictionary?
Check
ValueByCategory groups, sums and orders — checked (T3):
seat_fileseat_test3.5Writing a generic method8 minchecked · T3
Predict
static IEnumerable<T> TopN<T, TKey>(IEnumerable<T> xs, int n, Func<T, TKey> key) — what does TKey need to be ordered?
Lesson
A generic method is written once for any T: the type parameter stands for the caller's type. Constraints (where T : IComparable<T>, where T : notnull) say what you need from it. LINQ itself is a library of generic methods; yours can sit beside them as extension methods.
Demo
The tutor writes TopN<T, TKey> as an extension method, uses it on products by value and on strings by length, then adds a where TKey : IComparable<TKey> constraint and shows the compiler catching a bad key type.
Hands-on
Add static class Seq with extension method TopN<T, TKey>(this IEnumerable<T> xs, int n, Func<T, TKey> key) where TKey : IComparable<TKey>, returning the n largest by key. Tests: TopN_on_products_by_value, TopN_on_strings_by_length.
Explain
What does the constraint buy you?
Check
TopN is generic and constrained — checked (T3):
seat_fileseat_test3.6Lab: the inventory reports10 minproven · T4
Predict
The published list has 12 products in 3 categories. ValueByCategory returns how many rows?
Lesson
The lab wires the reports into the CLI: "report value", "report low 5", "report avg" over samples/s3/products.csv, each formatted as an aligned table from a pure Render method. The grader runs the library against a product list you have not seen.
Demo
The tutor adds the three report commands to the CLI, renders each from the Inventory results, runs them on the published list and diffs against the expected files, then shows the hidden-test idea with a second list.
Hands-on
Load samples/s3/products.csv in Shop.Cli (the CSV reader from DN050 is in samples/s3/Csv.cs) and implement report value / low <n> / avg printing aligned tables via pure Render methods. The published outputs are in samples/s3/*.expected.txt.
Explain
Why are the reports tested through the library rather than by comparing CLI output only?
Check
Reports match on the published list and the hidden one — proven (T4):
seat_testseat_run
Session 4 · Errors and resources
Six steps on failure done well. The exception hierarchy and which ones you catch: ArgumentException at the edge, IOException around files, never Exception to hide a bug. Throwing early with guard clauses and ArgumentNullException.ThrowIfNull. Your own exception types that name domain failures. try/catch/finally and when finally runs. IDisposable and the using declaration, and writing a disposable of your own. Structured log lines you can grep. Live in the seat: a stack trace read from the bottom, a guard clause that turns a late NullReferenceException into an early ArgumentNullException, a custom OutOfStockException with the Sku in it, a finally that runs after a return, and a disposable timer that logs how long a block took.
Lab Harden the importer: a CSV import that validates every row with guard clauses, raises typed exceptions, releases its reader with using, logs one structured line per rejected row and one summary line, and never crashes on bad data. Session 4 checkpoint.
6 mini steps · ~49 min
4.1Reading a stack trace, choosing what to catch8 minchecked · T3
Predict
catch (Exception) { } around a parse — what does it do to a NullReferenceException from a bug on the same line?
Lesson
Read a stack trace from the top line (the type and message) then down to the first frame in your own code. Catch the most specific type you can handle (FormatException, FileNotFoundException), where you can act. A catch of Exception is only for the last line of defence that logs and exits.
Demo
The tutor provokes a FormatException deep in a call chain, reads the trace to the frame in Shop.Core, catches the specific type where the row is parsed, and shows what a catch (Exception) would have hidden by planting a null bug.
Hands-on
In dotnet/dn100/s4 (copy of s3) write Importer.TryParseRow(string line, out Product? p, out string? error) that catches FormatException and OverflowException only and reports them as error text. Tests: TryParseRow_reports_bad_price, TryParseRow_lets_unexpected_exceptions_through (use a null line and Assert.Throws<ArgumentNullException>).
Explain
Why let an unexpected exception escape?
Check
TryParseRow catches only what it should — checked (T3):
seat_fileseat_test4.2Guard clauses: fail early7 minchecked · T3
Predict
ArgumentNullException.ThrowIfNull(sku); — when sku is null, which line does the stack trace point at?
Lesson
Validate arguments at the top of a public method and throw ArgumentException family exceptions: ThrowIfNull, ThrowIfNegative, ArgumentOutOfRangeException.ThrowIfLessThan. The failure then names the real cause and the real caller. Inside the method, assume the arguments are good.
Demo
The tutor adds guards to Pricing.LineTotal and the Product constructor (negative quantity, empty Sku), triggers each from a test, and compares the clear ArgumentException with the earlier mysterious failure.
Hands-on
Add guard clauses: Product rejects an empty Sku (ArgumentException) and a negative Quantity (ArgumentOutOfRangeException); Inventory.LowStock rejects n < 1. Tests: Product_rejects_empty_sku, Product_rejects_negative_quantity, LowStock_rejects_zero_n.
Explain
Why throw at the edge instead of checking everywhere inside?
Check
Guards throw the argument exceptions — checked (T3):
seat_fileseat_test4.3Your own exception types8 minchecked · T3
Predict
class OutOfStockException : Exception — a catch (InvalidOperationException) above it catches it?
Lesson
A domain exception names a failure your callers can tell apart: OutOfStockException with the Sku and the quantity asked for. Derive from Exception (or a closer base), pass a message to the base constructor, add the data as properties. Throw it from the domain; catch it at the edge and turn it into a message or a code.
Demo
The tutor writes OutOfStockException(string Sku, int Requested, int Available), throws it from Inventory.Reserve, catches it in the CLI to print one friendly line, and shows catching it by type beside a generic catch.
Hands-on
Add OutOfStockException with Sku, Requested and Available properties, and Inventory.Reserve(Product, int qty) returning a Product with Quantity reduced or throwing it. Tests: Reserve_reduces_quantity, Reserve_throws_out_of_stock_with_details.
Explain
What makes a custom exception better than throwing Exception with a message?
Check
OutOfStockException carries its details — checked (T3):
seat_fileseat_test4.4finally and using8 minchecked · T3
Predict
try { return 1; } finally { Console.WriteLine("f"); } — does f print?
Lesson
finally runs whatever happens; using is finally spelled for IDisposable: the using declaration (using var r = ...) disposes at the end of the scope. Files, streams, database connections and HTTP clients are all disposables; forgetting one leaks a handle and loses buffered data.
Demo
The tutor shows a finally running after a return, then a StreamReader opened without using that keeps the file locked after an exception, then the using declaration fixing it, then a manual try/finally doing the same.
Hands-on
Write Importer.Import(string path) that opens a StreamReader with a using declaration, reads line by line through TryParseRow and returns ImportResult(IReadOnlyList<Product> Products, IReadOnlyList<string> Errors). Tests: Import_reads_good_rows, Import_collects_errors_and_continues.
Explain
Why is using better than calling Close yourself?
Check
Import uses using and keeps going on bad rows — checked (T3):
seat_fileseat_test4.5Writing a disposable and logging8 minchecked · T3
Predict
using var t = new Timed("import", log); ... — when is the elapsed time written?
Lesson
Implement IDisposable when your type holds something that must be released or when you want scope-end behaviour: a Timed block that logs elapsed milliseconds. Log lines are structured: key=value pairs on one line (event=import.rejected line=4 reason="bad price") so they can be grepped and counted without a logging library.
Demo
The tutor writes Timed : IDisposable with a Stopwatch and an Action<string> log, wraps the import in it, and emits structured lines for each rejected row and a summary; then greps the output for event=import.rejected.
Hands-on
Add Timed(string name, Action<string> log) : IDisposable logging "event=timed name=<name> ms=<n>" on Dispose. Make Import take an Action<string> log and emit "event=import.rejected line=<n> reason=<text>" per error and "event=import.done ok=<n> rejected=<n>" once. Tests: Timed_logs_on_dispose, Import_logs_rejections.
Explain
Why key=value log lines rather than prose?
Check
Timed disposes and Import logs structured lines — checked (T3):
seat_fileseat_test4.6Lab: the hardened importer10 minproven · T4
Predict
import on a file with 10 rows, 2 bad: what exit code and how many event=import.rejected lines?
Lesson
The lab wires it together in the CLI: "import <path>" runs Import inside a Timed block, prints the structured log to stderr and "Imported N products (M rejected)" to stdout, returns 0; a missing file gives "Cannot read <path>" and 3; bad usage 2. No stack trace ever reaches the user.
Demo
The tutor runs import on the published good, bad and missing files, shows stdout and stderr separately, and confirms the exit codes, then plants a null bug to show it still surfaces as a crash in tests, not swallowed.
Hands-on
Implement import in Shop.Cli as specified over samples/s4/good.csv (8 rows), samples/s4/bad.csv (8 rows, 2 bad) and a missing path. All earlier tests stay green.
Explain
Why does the summary go to stdout and the log to stderr?
Check
import honours the contract on good, bad and missing files — proven (T4):
seat_testseat_run
Session 5 · Testing
Six steps that make tests a tool rather than a chore. Anatomy of a good test: arrange, act, assert, one behaviour, a name that reads as a rule. [Theory] with [InlineData] and [MemberData] for tables of cases. Fixtures and IClassFixture for shared setup. Test first on a real feature. Testing exceptions and edge cases: empty, one, many, the boundary. Reading a failing report and the trx file the grader reads. Live in the seat: a Fact split into a Theory of six rows, a fixture that loads the sample products once, a red-green-refactor cycle on a restock rule, and a failure message rewritten until it says exactly what went wrong.
Lab Build the pricing rules test suite: a Theory table for discounts, a fixture for the sample inventory, exception tests for every guard, test-first for the restock rule, all green; the grader adds its own hidden Theory rows. Session 5 checkpoint.
6 mini steps · ~50 min
5.1Anatomy of a good test7 minchecked · T3
Predict
A test named Test1 asserts three unrelated things and fails. What do you learn from the report?
Lesson
Arrange the inputs, act once, assert one behaviour. Name the test as the rule it proves (LineTotal_applies_bulk_food_discount). Keep tests independent: no shared mutable state, no order. A failing test should tell you what broke without opening the code.
Demo
The tutor takes a sprawling test with four asserts, splits it into four named Facts, breaks the code so one fails, and compares the two reports.
Hands-on
In dotnet/dn100/s5 (copy of s4) rewrite Shop.Tests/PricingTests.cs so every test has one behaviour and a rule-like name; add Assert messages where a bare Equal would be unclear. All tests green and at least 8 Facts in PricingTests.
Explain
Why one behaviour per test?
Check
PricingTests has eight focused Facts — checked (T3):
seat_fileseat_test5.2Theory and InlineData8 minchecked · T3
Predict
A [Theory] with five [InlineData] rows, one row fails. The report shows how many results?
Lesson
[Theory] runs one test body over many rows; [InlineData(10, 2.00, 21.60)] supplies a row. The report names each row by its arguments so you see which case broke. Use a Theory when the rule is a table; keep a Fact for a single story.
Demo
The tutor turns three discount Facts into one Theory with six rows including the boundaries (9 and 10 items), runs it, breaks the boundary in the code and reads the row that fails.
Hands-on
Add PricingTheoryTests with [Theory] LineTotal_by_category_and_quantity over at least six [InlineData] rows (category, qty, unit price, expected) including qty 9 and 10 for Food and 4 and 5 for Tools.
Explain
Which rows belong in every Theory table?
Check
The discount Theory covers the boundaries — checked (T3):
seat_fileseat_test5.3MemberData and fixtures8 minchecked · T3
Predict
A class implements IClassFixture<InventoryFixture>. How many times is InventoryFixture constructed for 12 tests in that class?
Lesson
[MemberData] feeds a Theory from a static property of object[] rows when InlineData cannot express the values (records, lists). IClassFixture<T> shares one expensive setup (loading the sample products) across a class; the test class constructor runs per test for cheap, fresh state.
Demo
The tutor writes InventoryFixture loading samples/s5/products.csv once, injects it into InventoryTests, and converts a Theory to MemberData with Product rows, then logs the fixture constructor to prove it ran once.
Hands-on
Add InventoryFixture (loads samples/s5/products.csv into Products) and make InventoryTests use IClassFixture; add a [Theory] ValueByCategory_matches_expected with [MemberData] rows of (Category, expected value) for the three categories.
Explain
When is a fixture worth it?
Check
Fixture and MemberData in use — checked (T3):
seat_fileseat_test5.4Test first on a real rule9 minchecked · T3
Predict
The restock rule: reorder when Quantity < ReorderLevel. You write the test for Quantity == ReorderLevel first. What should it expect?
Lesson
Red: write the test for the next small rule and watch it fail. Green: write the least code that passes. Refactor: tidy with the tests as a net. Start with the boundary the spec argues about; the test settles it.
Demo
The tutor adds a ReorderLevel to Product, writes NeedsRestock tests for below, equal and above, sees them fail to compile, adds the method returning false, sees one pass, implements the rule, all green, then refactors the comparison.
Hands-on
Add int ReorderLevel to Product and static bool Inventory.NeedsRestock(Product p) test first: NeedsRestock_below_level, NeedsRestock_at_level_is_false, NeedsRestock_above_level_is_false, then Inventory.ToRestock(IEnumerable<Product>) with ToRestock_lists_only_needed.
Explain
What did writing the boundary test first change about your code?
Check
Restock rule passes its four tests — checked (T3):
seat_fileseat_test5.5Exceptions and edges8 minchecked · T3
Predict
Assert.Throws<ArgumentException>(() => new Product("", ...)) when the constructor throws ArgumentOutOfRangeException. Result?
Lesson
Test the unhappy paths on purpose: Assert.Throws<T> for the exact type, ThrowsAny for a family, and inspect the exception's properties. Edge cases every collection method needs: empty, one, many, duplicates, the boundary. A test suite is a list of the questions you already asked.
Demo
The tutor writes Throws tests for every guard from session 4, inspects OutOfStockException.Available, and adds the empty/one/many trio to ValueByCategory, then shows ThrowsAny against Throws.
Hands-on
Add GuardTests: one Assert.Throws test per guard (empty Sku, negative Quantity, LowStock n = 0, Reserve over stock checking Available) and EdgeTests: ValueByCategory_of_empty_is_empty, ValueByCategory_of_one, TopN_more_than_available.
Explain
Why test what should fail?
Check
Guard and edge tests pass — checked (T3):
seat_fileseat_test5.6Lab: the pricing rules suite10 minproven · T4
Predict
The grader adds hidden Theory rows to your discount table. Which of your rows protect you most?
Lesson
The lab is the suite as a product: at least 35 tests, every rule as a Fact or Theory row, fixtures for shared data, guards and edges covered, names that read as the specification. The grader runs your suite plus hidden rows through the same library.
Demo
The tutor reviews the suite against a checklist (rules, boundaries, guards, edges, names), reads the trx summary, and shows how a hidden Theory file is dropped into the test project and run.
Hands-on
Complete dotnet/dn100/s5 so dotnet test reports at least 35 passing tests with no skipped tests, including everything named in this session; keep the tree formatted.
Explain
How can you tell your suite is good enough without counting tests?
Check
35+ tests pass, plus the hidden Theory rows — proven (T4):
seat_lintseat_test
Session 6 · Files, data formats and time
Six steps on data that leaves the process. Paths with Path.Combine and the working directory. CSV done properly: quoted fields with commas, a header, culture-invariant numbers. System.Text.Json: serialise a record, deserialise into one, options for naming and indentation, and what happens to decimal and DateOnly. Round trips that must be byte-equal. DateTime against DateTimeOffset against DateOnly, UTC as the storage form, TimeZoneInfo for display, ISO 8601 everywhere. Live in the seat: a CSV field with a comma inside quotes, a JSON file written and read back equal, a decimal that became a double in someone else's parser, and a meeting at 9:00 in one city shown correctly in another.
Lab Write the data layer: import products from CSV (quoted fields), export and import them as JSON with a byte-equal round trip, and stamp each export with an ISO 8601 UTC time that reads back as the same instant. Session 6 checkpoint.
6 mini steps · ~51 min
6.1Paths and the working directory7 minchecked · T3
Predict
Path.Combine("data", "products.csv") on Linux gives what?
Lesson
Never glue paths with + and a slash: Path.Combine, Path.GetFullPath, Path.GetFileName. Relative paths resolve against Directory.GetCurrentDirectory(), which under dotnet run is the project folder. Directory.CreateDirectory before writing into a folder that may not exist.
Demo
The tutor prints the current directory, combines a path, gets its full form, creates an output folder and writes a file into it, then runs the same program from another folder to show the relative path moving.
Hands-on
In dotnet/dn100/s6 (copy of s5) add Storage.ResolveDataPath(string name) returning Path.GetFullPath(Path.Combine("data", name)) and Storage.EnsureOutputDir() creating "out". Tests: ResolveDataPath_is_absolute, EnsureOutputDir_creates_folder.
Explain
Why is a relative path a trap?
Check
Path helpers pass — checked (T3):
seat_fileseat_test6.2CSV with quoted fields9 minchecked · T3
Predict
The line A1,"Pen, blue",1.20 split on commas gives how many fields?
Lesson
Real CSV quotes fields that contain commas, quotes or newlines, and doubles the quotes inside. Split(',') is not a CSV parser. Write a small state machine: walk characters, track inQuotes, emit a field at an unquoted comma. Write with the same rules in reverse.
Demo
The tutor shows Split breaking on a quoted name, writes Csv.ParseLine as a 25-line state machine, parses the tricky rows of samples/s6/products.csv, then writes Csv.FormatLine and round-trips a row.
Hands-on
Add Csv.ParseLine(string) returning string[] handling quotes and doubled quotes, and Csv.FormatLine(IEnumerable<string>) quoting when needed. Tests: ParseLine_handles_comma_in_quotes, ParseLine_handles_doubled_quotes, FormatLine_quotes_when_needed, Csv_round_trips.
Explain
Why is Split not enough for CSV?
Check
Csv parses and formats quoted fields — checked (T3):
seat_fileseat_test6.3JSON with System.Text.Json8 minchecked · T3
Predict
JsonSerializer.Serialize(new Product(...)) with default options names the property Sku how?
Lesson
JsonSerializer.Serialize turns a record into text; Deserialize<T> turns it back. JsonSerializerOptions control naming (CamelCase), indentation and enum-as-string. decimal round-trips exactly as a JSON number; DateOnly and DateTimeOffset serialise as ISO 8601 strings. Records with a positional constructor deserialise without extra work.
Demo
The tutor serialises a Product with and without CamelCase and WriteIndented, deserialises it back and asserts equality, then adds JsonStringEnumConverter so Category appears as "Food", not 0.
Hands-on
Add Json.Options (CamelCase, WriteIndented, JsonStringEnumConverter) and Json.Save<T>(string path, T value) / Json.Load<T>(string path). Tests: Json_round_trips_product_equal, Json_uses_camel_case_and_enum_names.
Explain
Why serialise enums as names rather than numbers?
Check
Json helpers round-trip a product — checked (T3):
seat_fileseat_test6.4Dates, times and zones9 minchecked · T3
Predict
DateTime.Now serialised and read on a machine in another zone shows what?
Lesson
Store instants as DateTimeOffset or UTC DateTime; store dates without time as DateOnly; convert for display with TimeZoneInfo.FindSystemTimeZoneById. Format with "O" (round-trip ISO 8601) for files and parse with DateTimeOffset.ParseExact and InvariantCulture. Never do arithmetic on local wall-clock times across a zone change.
Demo
The tutor creates 09:00 in Europe/London as a DateTimeOffset, shows it in Asia/Singapore, serialises with "O", parses it back equal, then shows a DateTime.Now losing its meaning after serialisation.
Hands-on
Add Clock.Stamp() returning DateTimeOffset.UtcNow, Clock.Format(DateTimeOffset) using "O" and Clock.Parse(string); Clock.InZone(DateTimeOffset, string zoneId). Tests: Format_then_Parse_is_same_instant, InZone_shifts_wall_clock_not_instant.
Explain
Why store UTC and convert for display?
Check
Clock formats, parses and shifts zones — checked (T3):
seat_fileseat_test6.5Round trips that must be exact8 minchecked · T3
Predict
A price 2.50m goes through JSON and back. What does it read as?
Lesson
A round trip is export then import giving an equal value, and often a byte-equal file. Decimals keep scale; dates keep offsets with "O"; strings keep quotes through CSV. Test round trips on the tricky rows, not the easy ones, and compare whole collections with Assert.Equal on sequences.
Demo
The tutor exports the sample products to JSON and CSV, imports both, asserts sequence equality with the original, then exports again and compares the two files byte for byte.
Hands-on
Add Storage.ExportJson / ImportJson and ExportCsv / ImportCsv for IReadOnlyList<Product>. Tests: Json_export_import_is_equal_sequence, Csv_export_import_is_equal_sequence, Export_twice_is_byte_equal.
Explain
Why test with the tricky rows?
Check
Both formats round-trip exactly — checked (T3):
seat_fileseat_test6.6Lab: the data layer10 minchecked · T3
Predict
An export file carries "exportedAt": "2026-03-04T09:00:00.0000000+00:00". Read back, what zone is it in?
Lesson
The lab puts an envelope around the data: Export(Products, ExportedAt) as a record serialised to JSON; the CLI gains "export <json>" and "import <csv|json>" commands; round trips are tested; the published products CSV imports and exports to the published JSON byte for byte given a fixed ExportedAt.
Demo
The tutor adds the Export envelope, wires the CLI commands, runs import then export, diffs the output against the published JSON with a fixed timestamp argument, and shows the trx report.
Hands-on
Add record Export(IReadOnlyList<Product> Products, DateTimeOffset ExportedAt); CLI "import <path>" (CSV or JSON by extension) then "export <path> [--at <iso>]" writing the envelope. With --at 2026-03-04T09:00:00+00:00 the output for samples/s6/products.csv must equal samples/s6/products.expected.json.
Explain
Why does the check fix the timestamp with --at?
Check
Import CSV, export JSON byte-equal to the published file — checked (T3):
seat_testseat_runseat_file
Session 7 · Projects, packages and docs
Six steps on the shape of a codebase other people use. public against internal and InternalsVisibleTo for tests. Namespaces that follow folders. What a package reference is, how the seat resolves it offline, and why versions are pinned. Directory.Build.props for settings shared by every project. XML documentation comments and the warnings that enforce them. A README that lets a stranger build, run and test in two minutes. Live in the seat: a type made internal and the tests that break, then InternalsVisibleTo; the offline NuGet folder listed; a Directory.Build.props that removes twelve duplicate lines; a /// comment appearing in IntelliSense; a README followed literally by the tutor.
Lab Finish the Shop solution as a product: minimal public surface, shared build settings, every public member documented, and a README whose three commands build, test and run it. Session 7 checkpoint.
6 mini steps · ~46 min
7.1public, internal and the test seam7 minchecked · T3
Predict
Csv is made internal. Shop.Tests, a separate project, calls Csv.ParseLine. What happens?
Lesson
Everything is internal unless a user of the library needs it; public is a promise you must keep. Tests live in another assembly, so grant them access with [assembly: InternalsVisibleTo("Shop.Tests")] (or the csproj item) rather than making helpers public.
Demo
The tutor makes Csv and Json internal, watches the tests fail to build, adds InternalsVisibleTo in Shop.Core.csproj, and lists the remaining public types as the library's surface.
Hands-on
In dotnet/dn100/s7 (copy of s6) make Csv, Json, Timed and Clock internal and add InternalsVisibleTo for Shop.Tests. The public types are exactly Product, Category, Money, IDiscount and its records, Pricing, Inventory, Importer, Storage, Export and OutOfStockException. Build and tests green.
Explain
Why is a smaller public surface safer?
Check
Helpers are internal, tests still build — checked (T3):
seat_fileseat_fileseat_test7.2Namespaces and folders7 minchecked · T3
Predict
File Shop.Core/Data/Csv.cs declares namespace Shop.Core; — does it compile?
Lesson
Namespaces follow folders: Shop.Core.Data for Csv, Json, Storage; Shop.Core.Pricing for discounts. File-scoped namespace declarations save an indent. Turn on the IDE0130 analyzer so the convention is checked, and keep one type per file named after the type.
Demo
The tutor moves the data types into a Data folder, changes their namespaces, fixes the usings the compiler asks for, then enables dotnet_diagnostic.IDE0130.severity = warning in .editorconfig and watches it flag a stray file.
Hands-on
Reorganise Shop.Core into folders Model (Product, Category, Money), Pricing (IDiscount, Pricing), Data (Csv, Json, Storage, Importer, Export) and Time (Clock, Timed), with matching file-scoped namespaces and IDE0130 as a warning. Build clean with warnings as errors.
Explain
What does a reader gain from namespace = folder?
Check
Folders and namespaces agree — checked (T3):
seat_fileseat_fileseat_fileseat_build7.3Packages, offline, pinned8 minchecked · T3
Predict
The seat is offline. A PackageReference to xunit 2.9.3 restores because…
Lesson
A PackageReference names a package and a version; restore resolves it from the sources in NuGet.Config into the build. Pin exact versions and commit the lock file so builds are reproducible. In the seat the only source is the offline folder, so the version list in tools/seat-inventory.json is the whole universe.
Demo
The tutor opens the seat's NuGet.Config, lists the offline folder, shows dotnet restore with no network, then adds <RestorePackagesWithLockFile> and commits packages.lock.json, and shows a version that is not present failing fast.
Hands-on
Enable RestorePackagesWithLockFile in Shop.Tests and produce packages.lock.json; pin xunit 2.9.3, xunit.runner.visualstudio 3.1.4 and Microsoft.NET.Test.Sdk 17.14.1 exactly. Restore and test offline.
Explain
Why pin versions and commit the lock file?
Check
Lock file present with pinned versions — checked (T3):
seat_fileseat_fileseat_test7.4Directory.Build.props7 minchecked · T3
Predict
Directory.Build.props sets <Nullable>enable</Nullable>. A project also sets <Nullable>disable</Nullable>. Which wins?
Lesson
Directory.Build.props at the solution root is imported by every project below it: TargetFramework, Nullable, ImplicitUsings, TreatWarningsAsErrors, LangVersion and analyzer settings in one place. Projects keep only what differs. Directory.Packages.props can centralise package versions the same way.
Demo
The tutor creates Directory.Build.props with the shared settings, deletes the duplicates from three csproj files, rebuilds to prove nothing changed, then adds an analyzer severity and watches all three projects pick it up.
Hands-on
Add dotnet/dn100/s7/Directory.Build.props with TargetFramework net10.0, Nullable enable, ImplicitUsings enable, TreatWarningsAsErrors true; remove those lines from every csproj; build and test green.
Explain
What breaks when settings are duplicated per project?
Check
Shared props, lean csproj files — checked (T3):
seat_fileseat_fileseat_build7.5XML docs and the README8 minchecked · T3
Predict
GenerateDocumentationFile is true and a public method has no /// summary. With warnings as errors, what happens?
Lesson
/// <summary> on every public type and member shows in IntelliSense and in the generated XML file; GenerateDocumentationFile plus warnings as errors makes missing docs a build failure. A README answers, in order: what this is, how to build, how to test, how to run with an example, and the exit codes.
Demo
The tutor enables GenerateDocumentationFile, watches CS1591 fail the build, documents the public surface with <summary>, <param> and <returns>, then writes the README and follows it line by line in a fresh terminal.
Hands-on
Enable GenerateDocumentationFile in Directory.Build.props; document every public member of Shop.Core with /// comments until the build is clean. Write dotnet/dn100/s7/README.md with sections What, Build, Test, Run (an example command and its output) and Exit codes.
Explain
Who is the README for, and what do they need first?
Check
Documented surface, clean build, README with five sections — checked (T3):
seat_fileseat_fileseat_build7.6Lab: Shop as a product9 minchecked · T3
Predict
A stranger clones the repo and runs the three README commands. Which must work with no other step?
Lesson
The lab is a review of the whole solution against the product checklist: public surface minimal and documented, folders match namespaces, shared props, pinned packages with a lock file, formatted, warnings as errors, all tests green, README commands true. The grader runs exactly the README's commands.
Demo
The tutor opens a fresh terminal in the demo solution and runs only the README commands, then walks the checklist on screen ticking each item from the files.
Hands-on
Make dotnet/dn100/s7 pass the checklist: dotnet build, dotnet format --verify-no-changes, dotnet test (48+ tests) and the README's run example (dotnet run --project Shop.Cli -- report value ../samples/s3/products.csv) all succeed as written.
Explain
Which checklist item would you add for your own team?
Check
Build, format, test and the README run example pass — checked (T3):
seat_buildseat_lintseat_testseat_run
Session 8 · Capstone: Stockroom
Six steps that build Stockroom from a specification: a tested inventory library and a command-line front end that imports and exports JSON, applies stock movements, and prints a low-stock report. Reading the spec into a plan. A walking skeleton with every command and exit code. The domain: movements that cannot take stock below zero. The reports. The JSON state file with a schema version. The published and hidden test files and the contract. Live in the seat: the spec turned into a test list, the skeleton returning codes, a movement rejected with a typed exception, and five published scenarios run end to end.
Lab Stockroom passes its own tests, the hidden xUnit tests and the published and hidden command-line scenarios: import, receive, issue, report low, export, with exit codes 0, 2, 3 and 4 by the contract. Session 8 checkpoint and the course placement step.
6 mini steps · ~55 min
8.1The spec into a plan8 minartefact · T2
Predict
The spec: "issue <sku> <qty> returns 4 when stock would go below zero". What does the state file look like after a 4?
Lesson
Read samples/s8/STOCKROOM-SPEC.md twice. Write down commands × exit codes, the state file shape, the domain rules, and which are pure (movements, reports) against I/O (file, console). Each rule becomes a test name before any code.
Demo
The tutor reads the spec aloud, builds the command and exit-code tables on screen, and writes the test list as a file of method names with empty bodies marked Skip.
Hands-on
Create dotnet/dn100/stockroom from your s7 template (Stockroom, Stockroom.Cli, Stockroom.Tests) and write PLAN.md with the command table, the exit-code table, the state file shape and a list of at least twelve test names.
Explain
Which spec rule is the most likely to be implemented wrong, and which test pins it?
Check
PLAN.md with tables and test names — artefact (T2):
seat_file8.2The walking skeleton9 minproven · T4
Predict
stockroom issue A1 5 with no state file present returns which code?
Lesson
Wire every command to a handler that returns the right code with no logic: usage → 2, missing state file → 3 (except import, which creates it), everything else → 0 and no output. Shared props, lock file, docs and README come from the template, so the skeleton is already a product.
Demo
The tutor builds the skeleton with a Commands switch expression, a StateFile path convention (stockroom.json in the working directory), and runs the five commands to show the codes.
Hands-on
Implement the skeleton in Stockroom.Cli: commands import <csv>, receive <sku> <qty>, issue <sku> <qty>, report low <n>, export <path>; usage → 2; missing stockroom.json → 3 for all but import; otherwise 0.
Explain
Why is a missing state file a 3 and not a 2?
Check
The skeleton returns the contract codes — proven (T4):
seat_buildseat_run8.3The domain: movements10 minchecked · T3
Predict
Stock.Issue("a1", 5) on a stock keyed "A1" with 10 units — by the spec, what happens?
Lesson
The domain is pure: Stock holds a dictionary of Sku → Item(Name, Quantity, ReorderLevel) with case-insensitive keys; Receive adds; Issue subtracts and throws InsufficientStockException when the result would be negative, leaving the Stock unchanged. Test first, boundaries first: issue exactly the quantity, issue one more.
Demo
The tutor writes the Issue tests (exact, one over, unknown sku), implements Receive and Issue, and proves the unchanged-on-failure rule with a test that asserts the quantity after a caught exception.
Hands-on
In Stockroom add Item, Stock (case-insensitive), Receive, Issue, InsufficientStockException(Sku, Requested, Available) and UnknownSkuException. Tests: Receive_adds, Issue_subtracts, Issue_exact_quantity_leaves_zero, Issue_one_over_throws, Issue_failure_leaves_stock_unchanged, Sku_is_case_insensitive, Unknown_sku_throws.
Explain
How does the test prove atomicity?
Check
Movement rules pass their seven tests — checked (T3):
seat_fileseat_test8.4Reports9 minchecked · T3
Predict
report low 3 over items with quantities 0, 2, 2, 9 (reorder level 5 each). Which appear and in what order?
Lesson
Reports.Low(stock, n) returns items below their reorder level ordered by quantity then sku, limited to n; Render produces the spec's exact table (sku left in 10, name left in 20, qty right in 6, level right in 6). Pure, tested against the published expected text.
Demo
The tutor implements Low and Render with LINQ, tests Render against samples/s8/low3.expected.txt, wires report low into the CLI and diffs the output.
Hands-on
Add Reports.Low(Stock, int n) and Reports.Render(IEnumerable<Item>) per the spec; tests Low_filters_below_level, Low_orders_by_quantity_then_sku, Render_matches_low3; CLI "report low <n>" prints Render.
Explain
Why order by quantity then sku rather than quantity alone?
Check
report low 3 matches the published text — checked (T3):
seat_testseat_run8.5The state file9 minchecked · T3
Predict
The state file has "schema": 1. A future version 2 adds a field. Reading a version-2 file with version-1 code should…
Lesson
Persistence sits behind an interface: IStockStore with Load() and Save(Stock). JsonStockStore writes { "schema": 1, "savedAt": ..., "items": [...] } atomically (write to a temp file, then move), refuses unknown schemas, and is tested through the interface with a temp folder. The domain never touches files.
Demo
The tutor writes IStockStore, JsonStockStore with the schema check and the temp-then-move write, a test using a temp directory, then corrupts a file to show the exit 3 path.
Hands-on
Add IStockStore and JsonStockStore (schema 1, atomic save, schema check throwing StateFileException). Tests: Store_round_trips_stock, Store_refuses_unknown_schema, Store_save_is_atomic (no temp file left). CLI maps StateFileException to "Cannot read stockroom.json" and exit 3.
Explain
Why write to a temp file and move?
Check
The store round-trips, refuses and saves atomically — checked (T3):
seat_fileseat_test8.6Lab: scenarios and the contract10 minproven · T4
Predict
Scenario: import, issue B2 3 (B2 has 2). Exit code and the state afterwards?
Lesson
Run the five published scenarios in samples/s8/scenarios/*.sh (each a sequence of commands with expected outputs and codes), record them in TESTLOG.md, and make sure the README's commands are true. The grader runs the scenarios, two hidden ones, and a hidden test file through your library.
Demo
The tutor runs the five scenarios in a row, fixes the one that reveals the case-insensitive export bug, writes the test log, and shows how a hidden scenario is run from a clean folder.
Hands-on
Make every published scenario pass with the exact outputs and codes; write TESTLOG.md (scenario, code, matched); README commands true; dotnet test with 25+ tests green.
Explain
What would make you trust a certificate that says you passed this?
Check
Stockroom passes published and hidden scenarios and tests — proven (T4):
seat_fileseat_lintseat_testseat_run
Outcome
By the end of the course the tutor's checks will have verified: a building, tested solution for every session; eight labs that each grow the last — the formatted, linted project, the typed product model, the LINQ reports, the resilient importer, the Theory-driven test suite, the JSON round trip, the documented three-project solution; and the Stockroom capstone, whose JSON import/export, low-stock report and command-line contract pass the published and hidden xUnit tests.
Certificate
Agile System Software Certificate — C# I (DN100) attests that you can design, build, test and document a multi-project C# solution with typed models, LINQ, exceptions, JSON and files, and a command-line contract, verified by a machine from your own seat including 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 Stockroom capstone check (8.6) including the hidden xUnit tests and command-line variants 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, xUnit, System.Text.Json and the C# extension with the debugger, offline; nothing to install. Your work lives in dotnet/dn100; 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 DN100 from the course button in the chat and press Tutor me. Every lesson, demo and hands-on step is also published below.