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 Duration: ~1 hour

Overview

This talk demonstrates how Test-Driven Development (TDD) and Domain-Driven Design (DDD) can work together from the beginning of a project, starting with simple CRUD operations and evolving into a more sophisticated domain model as business complexity emerges.

Key Domain: University Enrollment System

Initial Model

  • Students register at the university
  • Courses are included in the catalog
  • Rooms have capacity limits
  • Students enroll in courses
  • Core constraint: Rooms cannot be overfilled

Entities

Student (name) → enrolls in → Course (name) → assigned to → Room (name, capacity)

TDD Methodology

The Red-Green-Refactor Cycle

  1. Red Phase: Write a failing test
  2. Green Phase: Make the test pass with minimal code
  3. Refactor Phase: Clean up and improve the code

Key TDD Principles Demonstrated

1. Tiny Steps

  • Add the smallest possible test increment
  • Implement the minimum code to pass
  • "Walking a tightrope" - balance between tests and implementation
  • Taking too large steps increases risk of falling

2. Red Can Mean Two Things

  • Red when you add a new test (expected)
  • Red when you remove/modify code (also expected!)
  • When removing functionality: Remove code first → see test fail → remove test

3. Hard-Coded Values Are Fine Initially

// First pass - hard-coded
public IActionResult Get(int id)
{
    return Ok(new Student { Id = 1, Name = "Test Student" });
}

// Then parameterize when you add more test cases

4. Test Architecture Matters

  • Started with API-level tests (black box)
  • Later dropped to unit tests for complex algorithms
  • Trade-off: API tests protect refactorability, unit tests are faster and easier to set up

Code Evolution Journey

Phase 1: Basic CRUD Operations

Students Registration

// Test structure (Given-When-Then style, loosely BDD)
[Fact]
public async Task Given_I_Am_A_Student_When_I_Register_It_Should_Register_A_New_Student()
{
    // Arrange
    var request = new RegisterStudentRequest { Name = "Test Student" };

    // Act
    var response = await _api.RegisterStudent(request);

    // Assert
    response.Should().BeCreatedResponse();
}

Static Factory Methods for Ubiquitous Language

// Instead of constructor
public class Student
{
    // Use domain language
    public static Student Register(RegisterStudentRequest request)
    {
        return new Student 
        { 
            Id = Guid.NewGuid(),
            Name = request.Name 
        };
    }
}

// In controller
var student = Student.Register(request);

Why? This mirrors how business experts talk: "The student registers" - not "Create student"

Test Structure Evolution

// Separate API response models from domain models
public class StudentResponse // Used in tests
{
    public Guid Id { get; set; }
    public string Name { get; set; }
}

public class Student // Domain model
{
    public Guid Id { get; set; }
    public string Name { get; set; }
}

Why? If you use the same class, tests won't catch breaking API changes that affect consumers.

Phase 2: Relationships and Validation

Room Assignment to Courses

[Fact]
public async Task When_Including_Course_It_Should_Confirm_Room_Assignment()
{
    // Arrange
    var room = await _api.SetupRoom(new SetupRoomRequest 
    { 
        Name = "Room 101", 
        Capacity = 30 
    });

    // Act
    var course = await _api.IncludeCourse(new IncludeCourseRequest 
    { 
        Name = "CS 101",
        RoomId = room.Id 
    });

    // Assert
    course.RoomId.Should().Be(room.Id);
}

Domain Logic Encapsulation Pattern

// BAD: Business logic in controller
public IActionResult IncludeCourse(IncludeCourseRequest request)
{
    var room = await _context.Rooms.FindAsync(request.RoomId);
    if (room == null) 
        return BadRequest("Room not found");

    var course = new Course { Name = request.Name, RoomId = room.Id };
    // ...
}

// GOOD: Encapsulate in domain method
public IActionResult IncludeCourse(IncludeCourseRequest request)
{
    var room = await _context.Rooms.FindAsync(request.RoomId);
    var course = Course.IncludeInCatalog(request, room);

    if (course == null)
        return BadRequest(); // Don't care about specific reason

    return CreatedAtAction(nameof(Get), new { id = course.Id }, course);
}

// Domain logic
public static Course? IncludeInCatalog(IncludeCourseRequest request, Room? room)
{
    if (room == null) return null;

    return new Course 
    { 
        Name = request.Name,
        Room = room 
    };
}

Key insight: Test the outcome (course is null), not the specific rule (room is null). This keeps domain logic encapsulated.

Phase 3: Enrollment with Capacity Check

Naive Implementation (Race Condition)

public async Task<IActionResult> Enroll(Guid studentId, EnrollRequest request)
{
    var student = await _context.Students.FindAsync(studentId);
    var course = await _context.Courses
        .Include(c => c.Room)
        .Include(c => c.Enrollments)
        .FirstOrDefaultAsync(c => c.Id == request.CourseId);

    // PROBLEM: Race condition!
    var enrollmentCount = await _context.Enrollments
        .CountAsync(e => e.CourseId == course.Id);

    if (enrollmentCount + 1 > course.Room.Capacity)
        return BadRequest("Course is full");

    var enrollment = new Enrollment { Student = student, Course = course };
    _context.Enrollments.Add(enrollment);
    await _context.SaveChangesAsync();

    return Created(enrollment);
}

Testing for Race Conditions

[Fact]
public async Task Given_Course_With_Capacity_Three_When_Four_Students_Enroll_Concurrently()
{
    // Arrange
    var room = await _api.SetupRoom(new SetupRoomRequest { Capacity = 3 });
    var course = await _api.IncludeCourse(new IncludeCourseRequest { RoomId = room.Id });
    var student1 = await _api.RegisterStudent(new RegisterStudentRequest());
    var student2 = await _api.RegisterStudent(new RegisterStudentRequest());
    var student3 = await _api.RegisterStudent(new RegisterStudentRequest());
    var student4 = await _api.RegisterStudent(new RegisterStudentRequest());

    await _api.Enroll(student1.Id, course.Id);
    await _api.Enroll(student2.Id, course.Id);

    // Act - Concurrent enrollment
    var task3 = _api.Enroll(student3.Id, course.Id);
    var task4 = _api.Enroll(student4.Id, course.Id);
    var results = await Task.WhenAll(task3, task4);

    // Assert - One should fail
    results.Should().ContainSingle(r => r.IsSuccessStatusCode);
    results.Should().ContainSingle(r => !r.IsSuccessStatusCode);
}

Problem identified: Two concurrent requests both read count=2, both calculate 2+1≤3, both enroll successfully → room overfilled!

The DDD Revelation: Event Storming Changes Everything

What We Thought We Understood

Room Setup → Course Assignment to Room → Student Enrollment (CHECK CAPACITY HERE)

What Domain Experts Actually Want

Room Setup → Course Catalog → Student Enrollment → Scheduling Algorithm → Course Assignment

Event Storming Visualization

Original (Wrong) Flow:

[Room has been set up] 
    → CONSTRAINT: Must assign room before enrollment
    → [Course included in catalog]
    → [Student registered]
    → CONSTRAINT: Can't overfill room
    → [Student enrolled in course]

Corrected Flow:

[Room has been set up]
    → [Course included in catalog] (NO room assignment yet!)
    → [Student registered]
    → [Student enrolled in course] (NO capacity check!)
    → [Scheduling algorithm runs]
    → CONSTRAINT: Can't overfill room during scheduling
    → [Courses scheduled to rooms]

Why This Is Better

  1. Maximize enrollments - Never turn students away during enrollment
  2. Optimize resource utilization - Assign popular courses to large rooms
  3. Flexible scheduling - Can hire additional rooms for very popular courses
  4. No race conditions - Capacity check happens during scheduling, not enrollment

Implementing the Scheduling Algorithm

Test Structure: Start Simple, Build Complexity

Test 1: Single Course, Single Room

[Fact]
public async Task Given_One_Course_And_One_Room_When_Scheduling_It_Should_Assign_Correctly()
{
    // Arrange
    var room = new Room { Id = Guid.NewGuid(), Capacity = 1 };
    var enrollments = new List<CourseEnrollment>
    {
        new() { Course = new Course(), EnrollmentCount = 1 }
    };

    // Act
    var result = _scheduler.ScheduleCourses(enrollments, new[] { room });

    // Assert
    result.Should().ContainSingle()
        .Which.Room.Should().Be(room);
}

Test 2: Multiple Courses, Multiple Rooms (Sorting)

[Fact]
public async Task Given_Two_Courses_And_Two_Rooms_When_Scheduling()
{
    // Arrange
    var room2Capacity = new Room { Capacity = 2 };
    var room4Capacity = new Room { Capacity = 4 };

    var enrollments = new List<CourseEnrollment>
    {
        new() { Course = new Course { Name = "Popular" }, EnrollmentCount = 4 },
        new() { Course = new Course { Name = "Unpopular" }, EnrollmentCount = 2 }
    };

    // Act
    var result = _scheduler.ScheduleCourses(enrollments, new[] { room2Capacity, room4Capacity });

    // Assert
    result.Should().Contain(s => s.Course.Name == "Popular" && s.Room.Capacity == 4);
    result.Should().Contain(s => s.Course.Name == "Unpopular" && s.Room.Capacity == 2);
}

Test 3: Capacity Checking (Complex)

[Fact]
public async Task Given_Courses_And_Rooms_When_Scheduling_It_Should_Respect_Capacity()
{
    // Arrange
    var rooms = new[]
    {
        new Room { Name = "R1", Capacity = 2 },
        new Room { Name = "R2", Capacity = 6 },
        new Room { Name = "R3", Capacity = 5 },
        new Room { Name = "R4", Capacity = 10 }
    };

    var enrollments = new[]
    {
        new CourseEnrollment { Course = new Course { Name = "C1" }, EnrollmentCount = 4 },
        new CourseEnrollment { Course = new Course { Name = "C2" }, EnrollmentCount = 2 },
        new CourseEnrollment { Course = new Course { Name = "C3" }, EnrollmentCount = 7 }
    };

    // Act
    var result = _scheduler.ScheduleCourses(enrollments, rooms);

    // Assert
    // C1 (4) should go to R3 (5) - first available fit
    // C2 (2) should go to R1 (2) - exact fit
    // C3 (7) should go to R4 (10) - only room big enough
}

Implementation Evolution

Version 1: Hard-coded

public List<CourseSchedule> ScheduleCourses(
    IEnumerable<CourseEnrollment> enrollments, 
    IEnumerable<Room> rooms)
{
    var firstCourse = enrollments.First();
    var firstRoom = rooms.First();

    return new List<CourseSchedule>
    {
        new() { Course = firstCourse.Course, Room = firstRoom }
    };
}

Version 2: Simple Sorting

public List<CourseSchedule> ScheduleCourses(
    IEnumerable<CourseEnrollment> enrollments, 
    IEnumerable<Room> rooms)
{
    var sortedCourses = enrollments
        .OrderByDescending(e => e.EnrollmentCount)
        .ToList();

    var sortedRooms = rooms
        .OrderByDescending(r => r.Capacity)
        .ToList();

    return sortedCourses
        .Zip(sortedRooms, (course, room) => new CourseSchedule 
        { 
            Course = course.Course, 
            Room = room 
        })
        .ToList();
}

Version 3: Named Comparisons (Refactored)

public List<CourseSchedule> ScheduleCourses(
    IEnumerable<CourseEnrollment> enrollments, 
    IEnumerable<Room> rooms)
{
    var sortedCourses = enrollments
        .OrderByDescending(ByMostPopular())
        .ToList();

    var sortedRooms = rooms
        .OrderByDescending(ByMostSpacious())
        .ToList();

    return sortedCourses
        .Zip(sortedRooms, (course, room) => 
        {
            course.Course.AssignTo(room);
            return new CourseSchedule { Course = course.Course, Room = room };
        })
        .ToList();
}

private static Func<CourseEnrollment, int> ByMostPopular() => 
    e => e.EnrollmentCount;

private static Func<Room, int> ByMostSpacious() => 
    r => r.Capacity;

Note: "Intention-revealing names" - Eric Evans' principle from DDD

Version 4: Capacity Validation

public List<CourseSchedule> ScheduleCourses(
    IEnumerable<CourseEnrollment> enrollments, 
    IEnumerable<Room> rooms)
{
    var sortedCourses = enrollments
        .OrderByDescending(ByMostPopular())
        .ToList();

    var sortedRooms = rooms
        .OrderByDescending(ByMostSpacious())
        .ToList();

    var schedules = new List<CourseSchedule>();
    var availableRooms = sortedRooms.ToList();

    foreach (var courseEnrollment in sortedCourses)
    {
        var suitableRoom = availableRooms
            .FirstOrDefault(r => courseEnrollment.CanFitIn(r));

        if (suitableRoom != null)
        {
            courseEnrollment.Course.AssignTo(suitableRoom);
            schedules.Add(new CourseSchedule 
            { 
                Course = courseEnrollment.Course, 
                Room = suitableRoom 
            });
            availableRooms.Remove(suitableRoom);
        }
    }

    return schedules;
}

Code Organization: Vertical Slice Architecture

Folder Structure

/Students
    StudentsController.cs
    Student.cs (domain model)
    StudentResponse.cs
    RegisterStudentRequest.cs
    StudentTests.cs
    StudentApi.cs (test helper)

/Courses
    CoursesController.cs
    Course.cs
    CourseResponse.cs
    IncludeCourseRequest.cs
    CourseTests.cs

/Rooms
    RoomsController.cs
    Room.cs
    ...

/Enrolling
    EnrollingController.cs
    Enrollment.cs
    ...

/Scheduling
    SchedulingController.cs
    CourseScheduler.cs (domain service)
    SchedulerTests.cs

Why Vertical Slices?

Traditional (Ports & Adapters):

/Controllers
/Services
/Domain
/Persistence

Vertical Slice:

/Students (all student-related code)
/Courses (all course-related code)

Rationale: - Early in projects: changes are domain-oriented (add feature to students) - Later in projects: changes are layer-oriented (switch from REST to gRPC) - Start with what's most convenient early (vertical), refactor later if needed - Cross-namespace dependencies indicate poor domain boundaries

Ubiquitous Language in Practice

Contextive Extension

Tool created by Chris Simon to document and enforce ubiquitous language.

Definition File (YAML)

contexts:
  - name: university
    purpose: Allow students to register and enroll in courses

terms:
  - name: Student
    definition: A person who would like to study a course
    usage:
      - "The student has registered at the university"
      - "The student has enrolled in the course"

  - name: Register
    definition: The process of a student registering with the university
    usage:
      - "Students register before they can enroll"

  - name: Enroll
    definition: The process of a student signing up for a specific course
    usage:
      - "After registering, students enroll in courses"

  - name: Catalog
    definition: The list of courses available for students to enroll in

  - name: Include
    definition: The process of adding a course to the catalog

  - name: Setup
    definition: The process of defining a new room

  - name: Schedule
    definition: The process of assigning courses to rooms based on enrollment

Benefits

  • Hover over terms in code to see definitions
  • Autocomplete suggests domain terms
  • Works across languages (C#, TypeScript, SQL, etc.)
  • Not tied to code structure
  • Guides developers to use correct terminology

Key Testing Patterns

1. API Test Helper Pattern

public class StudentApi
{
    private readonly HttpClient _client;

    public StudentApi(HttpClient client)
    {
        _client = client;
    }

    public async Task<StudentResponse> RegisterStudent(RegisterStudentRequest request)
    {
        var response = await _client.PostAsJsonAsync("/students", request);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<StudentResponse>();
    }

    public async Task<StudentResponse> GetStudent(Guid id)
    {
        var response = await _client.GetAsync($"/students/{id}");
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<StudentResponse>();
    }
}

Benefits: - Encapsulates HTTP operations - Reusable across tests - Can be auto-generated from OpenAPI specs - Makes tests more readable

2. Assertion Method Pattern

// In test base class
public static class ResponseAssertions
{
    public static void Should_BeCreatedResponse(this HttpResponseMessage response)
    {
        response.StatusCode.Should().Be(HttpStatusCode.Created);
        response.Headers.Location.Should().NotBeNull();
    }

    public static void Should_ConfirmStudentDetails(
        this StudentResponse response, 
        string expectedName)
    {
        response.Should().NotBeNull();
        response.Id.Should().NotBeEmpty();
        response.Name.Should().Be(expectedName);
    }
}

// Usage
[Fact]
public async Task Given_I_Register_It_Should_Confirm_Details()
{
    var response = await _api.RegisterStudent(new() { Name = "John" });
    response.Should_ConfirmStudentDetails("John");
}

3. Test Data Builder Pattern

public class TestDataBuilder
{
    private readonly StudentApi _api;

    public async Task<(Room room, Course course, Student student)> SetupEnrollmentScenario(
        int roomCapacity = 10)
    {
        var room = await _api.SetupRoom(new SetupRoomRequest 
        { 
            Name = $"Room-{Guid.NewGuid()}", 
            Capacity = roomCapacity 
        });

        var course = await _api.IncludeCourse(new IncludeCourseRequest 
        { 
            Name = $"Course-{Guid.NewGuid()}", 
            RoomId = room.Id 
        });

        var student = await _api.RegisterStudent(new RegisterStudentRequest 
        { 
            Name = $"Student-{Guid.NewGuid()}" 
        });

        return (room, course, student);
    }
}

4. Parameterized Tests

[Theory]
[InlineData("Alice")]
[InlineData("Bob")]
[InlineData("Charlie")]
public async Task Given_Student_Name_When_Registered_It_Should_Confirm_Name(string name)
{
    // Arrange
    var request = new RegisterStudentRequest { Name = name };

    // Act
    var location = await _api.RegisterStudent(request);
    var student = await _api.GetStudent(location);

    // Assert
    student.Name.Should().Be(name);
}

Entity Framework Patterns

Virtual Properties for Lazy Loading

public class Course
{
    public Guid Id { get; set; }
    public string Name { get; set; }

    // Virtual enables lazy loading
    public virtual Room? Room { get; set; }
    public virtual ICollection<Enrollment> Enrollments { get; set; } = new List<Enrollment>();

    public static Course? IncludeInCatalog(IncludeCourseRequest request, Room? room)
    {
        if (room == null) return null;

        return new Course 
        { 
            Id = Guid.NewGuid(),
            Name = request.Name,
            Room = room  // EF tracks this relationship
        };
    }
}

DbContext Setup

public class UniversityContext : DbContext
{
    public DbSet<Student> Students { get; set; }
    public DbSet<Course> Courses { get; set; }
    public DbSet<Room> Rooms { get; set; }
    public DbSet<Enrollment> Enrollments { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Configure relationships if needed
        modelBuilder.Entity<Course>()
            .HasOne(c => c.Room)
            .WithMany()
            .HasForeignKey("RoomId")
            .OnDelete(DeleteBehavior.SetNull);
    }
}

Final DDD Validation: Make All Setters Private

The ultimate test of whether you've properly encapsulated business logic:

// Before
public class Student
{
    public Guid Id { get; set; }
    public string Name { get; set; }
}

// After - All setters private!
public class Student
{
    public Guid Id { get; private set; }
    public string Name { get; private set; }

    public static Student Register(RegisterStudentRequest request)
    {
        return new Student 
        { 
            Id = Guid.NewGuid(),
            Name = request.Name 
        };
    }

    // Only domain methods can modify properties
}

If all tests still pass after making setters private, you've achieved proper encapsulation!

Key Takeaways

TDD Principles

  1. Tiny steps - Add minimal test, minimal code, alternate
  2. Red-Green-Refactor - Red means expected failure at the right time
  3. Start simple - Hard-code values, parameterize later
  4. Test at the right level - API tests for contracts, unit tests for complex algorithms
  5. Remove code before tests - When removing features, see the red first

DDD Principles

  1. Ubiquitous Language - Use domain expert terminology in code
  2. Event Storming - Visualize business processes with domain events
  3. Constraint Modeling - Identify where business rules apply
  4. Talk to domain experts - Don't assume you understand the domain
  5. Encapsulation - Business rules belong in domain models, not controllers

Integration of TDD and DDD

  1. Tests enable refactoring - Confidence to make dramatic domain changes
  2. Static factory methods - Name constructors with domain verbs
  3. Start simple, evolve - Don't over-engineer for DDD initially
  4. Let complexity emerge - Add DDD patterns when they become necessary
  5. Vertical slices - Organize by domain concepts early, layers later

Common Pitfalls

  1. Assuming you understand the domain - Always validate with experts
  2. Building technical solutions for misunderstood problems - Race conditions that shouldn't exist
  3. Checking rules at the wrong time - Enrollment vs. scheduling
  4. Setting properties directly - Bypass business rules
  5. Testing implementation details too early - Reduces refactorability

Code Repository

GitHub: Available with 122+ commits showing complete evolution - Each TDD cycle is a separate commit - Use forward script to step through changes - Full working example with all tests - https://github.com/chrissimon-au/tdd-ddd-demo-dotnet

Tools Mentioned

Contextive Extension

  • VS Code and JetBrains IDEs supported
  • Visual Studio in development
  • Cloud version coming soon with Notion/Confluence sync
  • Provides glossary tooltips and autocomplete
  • Works across all file types (code, SQL, markdown, etc.)

Resources

  • DDD Australia - Monthly meetups in Melbourne/Sydney (live-streamed)
  • DDD Academy - Training partnerships
  • Speaker contacts - LinkedIn, Blue Sky (Chris Simon)

Practical Application Strategy

  1. Start with TDD from day one
  2. Use ubiquitous language in method names immediately
  3. Organize by domain (vertical slices) initially
  4. Add DDD patterns as complexity emerges
  5. Talk to domain experts when business rules become complex
  6. Event storm to validate understanding
  7. Refactor with confidence - tests protect you
  8. Make setters private - validate proper encapsulation

Personal Notes

This talk brilliantly demonstrates that TDD and DDD are complementary practices that reinforce each other. The key insight is that you don't need to choose between them or implement full DDD patterns from day one. Start simple with TDD, use domain language, and let DDD patterns emerge naturally as complexity requires them.

The university enrollment example powerfully shows how developers can completely misunderstand a domain and build complex technical solutions (race condition handling) for a problem that shouldn't exist (enrollment-time capacity checking). Event storming with domain experts revealed a fundamentally better model (deferred scheduling) that eliminates the technical complexity entirely.

Most valuable lesson: When facing technical complexity, step back and validate your domain understanding before implementing complex solutions.