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¶
- Red Phase: Write a failing test
- Green Phase: Make the test pass with minimal code
- 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¶
- Maximize enrollments - Never turn students away during enrollment
- Optimize resource utilization - Assign popular courses to large rooms
- Flexible scheduling - Can hire additional rooms for very popular courses
- 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¶
- Tiny steps - Add minimal test, minimal code, alternate
- Red-Green-Refactor - Red means expected failure at the right time
- Start simple - Hard-code values, parameterize later
- Test at the right level - API tests for contracts, unit tests for complex algorithms
- Remove code before tests - When removing features, see the red first
DDD Principles¶
- Ubiquitous Language - Use domain expert terminology in code
- Event Storming - Visualize business processes with domain events
- Constraint Modeling - Identify where business rules apply
- Talk to domain experts - Don't assume you understand the domain
- Encapsulation - Business rules belong in domain models, not controllers
Integration of TDD and DDD¶
- Tests enable refactoring - Confidence to make dramatic domain changes
- Static factory methods - Name constructors with domain verbs
- Start simple, evolve - Don't over-engineer for DDD initially
- Let complexity emerge - Add DDD patterns when they become necessary
- Vertical slices - Organize by domain concepts early, layers later
Common Pitfalls¶
- Assuming you understand the domain - Always validate with experts
- Building technical solutions for misunderstood problems - Race conditions that shouldn't exist
- Checking rules at the wrong time - Enrollment vs. scheduling
- Setting properties directly - Bypass business rules
- 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¶
- Start with TDD from day one
- Use ubiquitous language in method names immediately
- Organize by domain (vertical slices) initially
- Add DDD patterns as complexity emerges
- Talk to domain experts when business rules become complex
- Event storm to validate understanding
- Refactor with confidence - tests protect you
- 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.