Skip to content

TDD & DDD from the Ground Up - Chris Simon

Source: NDC Melbourne 2025 Speaker: Chris Simon Video: https://www.youtube.com/watch?v=9YjCbS22WtE Repo: https://github.com/chrissimon-au/tdd-ddd-demo-dotnet Duration: ~1 hour

Overview

Chris builds a small university enrolment API with strict TDD and, at first, no DDD at all. Domain concepts "gradually filter in as they become necessary". The turning point is a race condition. Instead of fixing it with locks, he runs an event storm with the domain experts. That shows the capacity rule is applied at the wrong moment, and the tests make the drastic model change safe.

The talk is a walk through the repo's git history. Every red, green and refactor step is its own commit (N-RED / N-GREEN / N-REFACTOR): 123 commits across 42 cycles. You'd normally not commit a red step; here it makes the build-up replayable.

Stack: - .NET 8 and ASP.NET Core controllers - xUnit with WebApplicationFactory<Program> - EF Core on in-memory SQLite (DataSource=:memory:) with UseLazyLoadingProxies() - Plain xUnit Assert, no assertion library

Code names use British spelling: Enrol, Enrolment, EnrolingController.

Domain: university enrolment

  • Students register at the university
  • Courses are included in the catalog
  • Rooms are set up, each with a capacity
  • Students enrol in courses
  • Core constraint: a room can't be overfilled

His starting point is the typical noun-first ER diagram: Student → Course → Room (capacity). He calls it "a pretty typical way that a lot of people approach thinking about the nouns in their domain". The Course → Room link turns out to be the misunderstanding.

TDD lessons

Tiny steps: the tightrope

  • Make the green step "as small as possible… as little work as you need to do to make the test pass."
  • Metaphor: walking a tightrope between tests and implementation. Small wobbles from side to side feel great. Too much work on one side and you fall.
  • If a cycle grows too big, he gets uncomfortable, throws the work away and starts again with a smaller cycle.

The first cycle shows how small a step can be:

// 1-RED (cc02c1d)
[Fact]
public async Task GivenIAmAStudent_WhenIRegister()
{
    var client = _factory.CreateClient();
    var response = await client.PostAsync("/students", null);
    ItShouldRegisterANewStudent(response);
}

// 1-GREEN (fc3a229)
[HttpPost]
public ActionResult Register()
{
    return Created("", null);
}

Hard-coded first, then let a second case force the real code

"Throw in some hard-coded data. There's absolutely nothing wrong with it." It validates the flow before you parameterize. The sequence:

  1. 6-GREEN: Get([FromRoute] Guid id) returns Ok(new Student { Id = id, Name = "Test Student" }).
  2. 6-REFACTOR: only the test changes, from [Fact] to [Theory] with [InlineData("Test Student")], as preparation.
  3. 7-RED: add [InlineData("Another Student")]. The hard-coded answer fails.
  4. 7-GREEN: only now add UniversityContext and persistence (context.Students.Add / Find).

The rule: "only one thing at a time: tests or code, not both."

Red can mean two things

Red because you changed a test, or red because you changed the code. To remove a feature, remove the code first, watch the right test fail, then remove the test (commits 35 and 36). "Don't be scared of red… it's about is it doing what you expected." That means you need an expectation at every step.

The concurrency test was the exception: 34b-GREEN deleted it on its own, as a step to prepare for removing the capacity check.

Start with a case that stays true

The first scheduling test has one student, one course, and one room with capacity 1. It stays valid however complex the algorithm gets. Picking the simple cases first is "a really important part of doing TDD".

Test level is a trade-off

  • API-level tests are black box. They protect the public contract and refactorability. "Test the public contract as much as you can."
  • For the scheduling algorithm he drops to unit tests on the Scheduler domain service. Setting up many enrolments over HTTP is cumbersome, and domain objects are fast to build. The cost is that tests tie to internals, so refactoring gets harder. "Don't just go and do this blindly."
  • The repo ends with 2 API scheduling scenarios and 3 scheduler unit tests.

Test patterns used in the repo

Tests are an external consumer. The test project has its own StudentResponse, CourseResponse and so on, and its own copies of the request DTOs. It never reuses the API's classes. If you share the class, a rename "will pass for a whole bunch of changes that you don't want it to", while real consumers break.

Given/When names, named assertions. Assertions are private ItShould… methods in the test class:

[Fact]
public async Task GivenIAmAStudent_WhenIRegister()
{
    var api = CreateStudentApi();
    var registerStudent = new RegisterStudentRequest { Name = Guid.NewGuid().ToString() };
    var (response, student) = await api.RegisterStudent(registerStudent);

    ItShouldRegisterANewStudent(response);
    ItShouldAllocateANewId(student);
    ItShouldShowWhereToLocateNewStudent(response, api.UriForStudentId(student?.Id));
    ItShouldConfirmStudentDetails(registerStudent, student);
}

He likes BDD and Cucumber, "but for a simple application, maybe not necessary."

An API wrapper per slice. StudentApi, CourseApi, RoomApi, EnrolmentApi and SchedulingApi each wrap HttpClient and return tuples. They don't call EnsureSuccessStatusCode, because tests assert on failures too. They could be generated from OpenAPI.

public async Task<(HttpResponseMessage, StudentResponse?)> RegisterStudent(RegisterStudentRequest registerStudent)
{
    var response = await client.PostAsync("/students", JsonContent.Create(registerStudent));
    var student = await response.Content.ReadFromJsonAsync<StudentResponse>();
    return (response, student);
}

CreatedAtAction over Created(url). The coupling is to the action name, not to a duplicated route string.

DDD lessons

Ubiquitous language in the code

Post or Create, as in "all the examples from Microsoft", says nothing about business intent. Instead, use static factory methods named after the business event:

public static Student Register(RegisterStudentRequest request)   // started as Register() in 2-REFACTOR
    => new Student { Id = Guid.NewGuid(), Name = request.Name };
// likewise: Room.Setup, Course.IncludeInCatalog, Enrolment.StudentEnroled

"It literally reads the way a business person would say: well, the student is registering."

Contextive: the glossary next to the code

Contextive is Chris's own IDE extension, for VS Code and JetBrains, with Visual Studio in progress. The repo keeps the glossary in university.glossary.yml:

contexts:
  - name: Enrolment
    domainVisionStatement: To allow students to register and enrol in courses.
    terms:
      - name: student
        definition: A person who would like to study a course.
        examples:
          - The student has registered at the university.
          - The student has enroled in the course.
      - name: schedule
        definition: Either the process of creating a schedule, or the schedule itself …
        examples:
          - I will be scheduling the courses on Friday, after enrolments are closed.
  • Hover: it matches words, not code, so it works in C#, TypeScript, SQL or Markdown.
  • Autocomplete: it suggests terms before the code exists.
  • Examples matter: they capture how domain experts actually use a word.
  • Coming: a cloud version that syncs with Notion and Confluence, so domain experts can edit the definitions.

Rules belong in the domain

The domain can't reach the database, so the controller gathers data and the domain decides.

  • 24-GREEN: the controller checks room == null and returns BadRequest().
  • 24-REFACTOR: the controller does var room = context.Rooms.Find(request.RoomId); and passes the room to Course.IncludeInCatalog(request, Room? room). That method returns null when the room is missing, and the controller returns 400 when the course is null.

Test the outcome (course is null, so 400), not the rule itself. "A null course encapsulates all possible reasons that we might fail." Null isn't the only option; he likes result types, "but for now, keep it simple."

The capacity rule moves the same way (cycle 34):

  1. Naive, in the controller: CountAsync(e => e.CourseId == course.Id), then if (numEnrolments + 1 > course?.Room?.Capacity) return BadRequest(); He calls this "definitely business logic… what's this doing in our controller?"
  2. Named on the room: Room.WouldEnrolmentExceedCapacity(numEnrolments). "Even that is a bit clunky."
  3. Deliberate red (7716767): write the code you wish you had, var enrolment = course.Enrol(student);, before it exists.
  4. Made real with lazy loading: public virtual IEnumerable<Enrolment>? Enrolments plus Course.Enrol(student), which returns null when full. He's fine with virtual lazy-loaded properties here but would avoid leaning on them in a much more complex app.

Vertical slices: organise by domain operation

ChrisSimonAu.UniversityApi/
  Students/    Student.cs  StudentsController.cs  RegisterStudentRequest.cs
  Rooms/       Room.cs  RoomsController.cs  SetupRoomRequest.cs
  Courses/     Course.cs  CourseController.cs  CourseResponse.cs  IncludeCourseInCatalogRequest.cs
  Enroling/    Enrolment.cs  EnrolingController.cs  EnrolmentResponse.cs  …
  Scheduling/  Scheduler.cs  SchedulesController.cs  CourseEnrolment.cs  CourseEnrolmentQuery.cs  Schedule.cs  …
ChrisSimonAu.UniversityApi.Tests/   (same folders: *Tests.cs, *Api.cs, own request/response DTOs)
  • Primary vs secondary organising principle: domain operations first, layers (ports and adapters) second.
  • Fit changes with maturity: early changes are domain-oriented; later ones are layer-oriented, such as swapping REST for gRPC. "No one ever gets the time to change the structure… you kind of get stuck with what you started with." So he starts with slices and pulls layers out when sensible.
  • Cross-slice imports are a signal that domain slices aren't well encapsulated. Strict ports and adapters can hide that signal.
  • He isn't against ports and adapters: "there's really good reasons for that style."
  • Slices can be entity- or process-oriented. He prefers to push towards process-oriented ones.

The race condition and the event storm

The bug. After go-live, a course ends up with more enrolments than its room's capacity. Two concurrent requests both read the count, both pass the +1 ≤ capacity check, and both insert. His example was 21 students in a room for 20.

Proving it (34a-RED, a red commit on purpose). The test sets up a room with capacity 3 and two students already enrolled. It then starts two enrolment tasks without awaiting them, calls await Task.WhenAll(...), and asserts that the 4th enrolment is rejected. It almost always fails. Awaiting the two calls one after the other makes it pass.

The usual fixes his audience suggested: - a pessimistic lock or semaphore - optimistic concurrency, which is awkward here because only a new Enrolment row is inserted, so nothing changes to detect a conflict on - checking SQL rows affected - a DDD aggregate as a consistency boundary

His verdict: "very interesting technical navel gazing… none of it has anything to do with the domain."

Event storming. - Orange stickies are domain events: past tense, facts. - Purple stickies are constraints. Chris attributes this "constraint modelling" layer to someone from DDD Europe / DDD Academy; the name isn't clear in the captions.

Before:

[Room has been set up] → [Course included in catalog] → [Student registered] → [Student enrolled]
     constraints: course must be allocated to a room · can't overfill the room (at enrolment)

The domain experts' reaction: "Why would we allocate the courses to rooms before we even know how popular the courses are?" His example: 100 students for Myths and Legends of Ancient Greece and 2 for CS 101, with CS in the 100-seat room. The business goal is room utilisation.

After:

[Room set up] → [Course included] → [Student registered] → [Student enrolled — no check]
     → policy: scheduling (popular courses → large rooms) → [Courses scheduled to rooms]
     constraint "can't overfill the room" now applies at scheduling

Why the new model is better: - Enrolments are maximised; nobody is turned away at enrolment. - Rooms are used better. - It's flexible: set rooms up late, hire extra rooms, or run a popular course twice. - The race condition disappears, because there's no check at enrolment.

"We think we understand the domain and we construct convoluted technical solutions to try and protect what we have misunderstood." He adds: "I guarantee you all of you have fallen prey to this at some point… I know I certainly did."

The scheduler (cycles 37–42)

Scheduler.ScheduleCourses(List<CourseEnrolment> courseEnrolments, List<Room> rooms) returns a Schedule with IList<Course> Courses. CourseEnrolment is a read model: a course plus its EnrolmentCount. Later it's filled by CourseEnrolmentQuery, a LINQ group-by registered with AddScoped.

  • 39-GREEN: zip the lists and set course.Room (works for a single course and a single room).
  • 40-GREEN: sort both descending with lambdas, then zip.
  • 40-REFACTOR: intention-revealing names (Eric Evans), and course.AssignTo(room) instead of setting course.Room:
    private static Comparison<CourseEnrolment> ByMostPopular = (ce1, ce2) => ce2.EnrolmentCount.CompareTo(ce1.EnrolmentCount);
    private static Comparison<Room> ByMostSpacious = (r1, r2) => r2.Capacity.CompareTo(r1.Capacity);
    
  • 41-RED: courses with 4 / 2 / 7 enrolments; rooms with capacity 2 / 6 / 5 / 10. It expects First → Room3 (5), Second → Room1 (2), Third → Room4 (10). Descending-zip gives Third → Room4, First → Room2, Second → Room3. Nothing overflows, but the allocation is inefficient.
  • 41-GREEN (the final version): the sort flips to ascending, and each course takes the smallest room that fits:
    courseEnrolments.Sort(ByLeastPopular);
    rooms.Sort(ByLeastSpacious);
    foreach (var courseEnrolment in courseEnrolments)
    {
        var course = courseEnrolment.Course;
        var candidateRoom = rooms.FirstOrDefault(r => r.CanCourseFit(courseEnrolment.EnrolmentCount));
        if (course.AssignTo(candidateRoom))          // bool AssignTo(Room? room)
        {
            schedule.Courses.Add(course);
            rooms.Remove(candidateRoom!);
        }
    }
    // Room: public bool CanCourseFit(int numEnrolments) => numEnrolments <= Capacity;
    

Final check: make the setters private

The last commit (404c1b6) changes every property on Course, Room and Enrolment to { get; private set; }, and all tests still pass. Nothing outside the entities was setting state, so the business rules really are encapsulated: "it turns out that actually we have been doing DDD."

Strictly, Student keeps public setters at HEAD; the commit didn't touch it.

Key takeaways

TDD - Use tiny steps, alternating test and code. If a cycle grows big, throw it away. - Hard-coded values are fine; a second case forces the real implementation. - Change one thing at a time: tests or code, never both. - Red is fine when you expect it. To remove a feature, remove the code first, then the test. - Test the public contract. Drop to unit tests deliberately, for complex algorithms.

DDD - Use the business's words in method names (static factories) and keep a glossary. - Rules live in the domain. Controllers gather data, and the domain decides. - Organise by domain operation (slices) early, and add layers as the project matures. - Talk to domain experts and event storm. Ask when a constraint applies, not only what it is. - Private setters are a cheap encapsulation check.

Together - Technical complexity is a prompt to re-check your understanding of the domain before reaching for locks. - The test suite makes a whole-cloth model change calm: "without a care in the world." - DDD comes in when it becomes necessary, not on day one.

Using the repo

Check out an early commit and step through the history with ./forwards.sh and ./backwards.sh. These use Jujutsu: jj abandon && jj next / jj prev. watch.sh runs watchexec … dotnet test, and init.sh scaffolds the solution. Dev tooling is defined with devenv (Nix).

Resources mentioned

  • Contextive: https://contextive.tech (VS Code, JetBrains)
  • DDD Australia: monthly meetups alternating between Melbourne and Sydney, live-streamed. Chris co-organises it.
  • DDD Academy: Chris runs DDD training in partnership with them.
  • Contact: LinkedIn or Bluesky.

Personal notes

TDD and DDD reinforce each other. You don't need full DDD patterns on day one: start with TDD and domain language, and let the patterns appear when complexity demands them.

Most valuable lesson: the race condition was a real bug in a misunderstood model. When facing technical complexity, step back and validate your domain understanding before building machinery to protect it.

This talk is also the basis of an interactive session deck: portfolio/decks/tdd-ddd/, on branch deck/tdd-ddd-ground-up.

  • dotnet-microservices-auth-architecture — the same discipline applied one level out: its Part 4 runs the cycle against a live service boundary (WebApplicationFactory, Testcontainers), where the tests here stay inside the domain model. Useful as the counterexample to this page's warning about building technical machinery for a misunderstood domain.