In Part 1, we set up the solution structure for Kids Learn — our AI-powered adaptive learning SaaS — with four projects following the dependency rule. The Domain layer sits at the center with zero outward dependencies. We created the folders, added the project references, and said “the Domain project is where business logic lives.”
But what does that actually mean? Because the first version of Kids Learn had entities that were just property bags. Child had a MasteryLevel property of type decimal. Nothing stopped you from setting it to 5.0 or -1. The LearningSession entity had no idea when it was complete — it just held a bool IsComplete that anyone could flip. All the logic lived in services. The LearningService knew when a session was done, how to calculate mastery, and when to flag a knowledge gap. The entities knew nothing.
This is the anemic domain model, and after 15 years of building enterprise .NET applications, I can tell you it’s the single most common pattern I see in codebases that have become painful to maintain. Martin Fowler called it an anti-pattern back in 2003, yet here we are in 2026 and it’s still the default in most .NET projects I review. Today we fix that.
We’re going to build a domain layer with real behavior: entities that enforce their own invariants, value objects that make illegal states unrepresentable, domain events that decouple side effects, aggregate roots that define transaction boundaries, and architecture tests that ensure nobody breaks the rules later.
Anemic vs Rich Domain Models — The Real Difference
Let me show you exactly what I mean with the Child entity. Here’s how it looked in the first version of Kids Learn:
// The anemic version — a property bag with no behavior
public class Child
{
public Guid Id { get; set; }
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public int GradeLevel { get; set; }
public decimal MasteryLevel { get; set; }
public Guid ParentId { get; set; }
public DateTime CreatedAt { get; set; }
public List<LearningSession> Sessions { get; set; } = new();
public List<LearningProgress> ProgressRecords { get; set; } = new();
}
And the service that did all the work:
public class LearningService
{
public void CompleteLessonForChild(Child child, Lesson lesson, int score)
{
// All business logic lives here, far from the entity
if (score < 0 || score > 100)
throw new ArgumentException("Score must be between 0 and 100");
var progress = child.ProgressRecords
.FirstOrDefault(p => p.TopicId == lesson.TopicId);
if (progress == null)
{
progress = new LearningProgress
{
TopicId = lesson.TopicId,
MasteryLevel = score / 100m
};
child.ProgressRecords.Add(progress);
}
else
{
// Weighted average toward new score
progress.MasteryLevel = progress.MasteryLevel * 0.7m + (score / 100m) * 0.3m;
}
// Anyone could forget to update this
child.MasteryLevel = child.ProgressRecords.Average(p => p.MasteryLevel);
}
}
See the problem? The Child entity has no opinion about its own state. You can set MasteryLevel to 500. You can add a LearningProgress record with a negative mastery. You can set GradeLevel to 99. The entity allows literally anything, and you depend entirely on every caller going through the right service method.
In a team of 6 developers, someone will eventually bypass the service. I guarantee it. Maybe it’s a background job that needs to update mastery after a batch import. Maybe it’s a new endpoint that someone writes in a hurry. Maybe it’s a unit test that creates a Child with MasteryLevel = -0.5m because nobody thought to check. The anemic model puts the burden on the developer to always know which service method to call. The rich model puts the burden on the entity to always be valid.
Here’s the rich version:
public class Child : AggregateRoot
{
private readonly List<LearningProgress> _progressRecords = [];
private readonly List<LearningSession> _sessions = [];
private Child() { } // EF Core needs this
public static Child Enroll(
string firstName,
string lastName,
GradeLevel gradeLevel,
Guid parentId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(firstName);
ArgumentException.ThrowIfNullOrWhiteSpace(lastName);
return new Child
{
Id = Guid.NewGuid(),
FirstName = firstName.Trim(),
LastName = lastName.Trim(),
GradeLevel = gradeLevel,
ParentId = parentId,
CreatedAt = DateTime.UtcNow
};
}
public Guid Id { get; private init; }
public string FirstName { get; private set; }
public string LastName { get; private set; }
public GradeLevel GradeLevel { get; private set; }
public MasteryLevel OverallMastery { get; private set; } = MasteryLevel.Beginner;
public Guid ParentId { get; private init; }
public DateTime CreatedAt { get; private init; }
public IReadOnlyCollection<LearningProgress> ProgressRecords => _progressRecords.AsReadOnly();
public IReadOnlyCollection<LearningSession> Sessions => _sessions.AsReadOnly();
public void RecordLessonCompletion(TopicId topicId, int score, TimeSpan duration)
{
var normalizedScore = MasteryLevel.FromPercentage(score);
var progress = _progressRecords.FirstOrDefault(p => p.TopicId == topicId);
if (progress is null)
{
progress = LearningProgress.StartTracking(Id, topicId, normalizedScore);
_progressRecords.Add(progress);
}
else
{
progress.RecordAttempt(normalizedScore);
}
RecalculateOverallMastery();
AddDomainEvent(new LessonCompleted(Id, topicId, normalizedScore, duration));
if (progress.Mastery >= MasteryLevel.Proficient)
AddDomainEvent(new MasteryAchieved(Id, topicId, progress.Mastery));
if (progress.Mastery < MasteryLevel.AtRisk)
AddDomainEvent(new KnowledgeGapDetected(Id, topicId, progress.Mastery));
}
private void RecalculateOverallMastery()
{
if (_progressRecords.Count == 0) return;
var average = _progressRecords.Average(p => p.Mastery.Value);
OverallMastery = MasteryLevel.FromDecimal(average);
}
}
The difference is fundamental. The rich Child entity enforces its own rules. You can’t create one without a name and grade level. You can’t set mastery to an invalid value. You can’t directly modify progress records — you go through RecordLessonCompletion, which enforces invariants and raises domain events. The entity protects itself.
This isn’t academic. On the Kids Learn project, after we switched to rich domain models, a whole category of bugs disappeared — the kind where some controller or background job would update an entity incorrectly because it didn’t call the right service method.
Entities with Business Invariants
Let’s build out all the Kids Learn entities with C# 14. I’ll use the new field keyword where it makes property validation cleaner.
The Child Entity
We already saw the full Child entity above. The key design decisions:
- Factory method (
Enroll) instead of a public constructor. The name communicates intent — you’re enrolling a child in the learning platform, not “newing up an object.” - Private setters on everything. External code can read state but can’t modify it directly.
IReadOnlyCollectionfor navigation properties. EF Core works with the backing fields; external code can’t add or remove items.- Business methods that enforce rules and raise events.
The field keyword in C# 14 cleans up property validation. Here’s how we use it for the FirstName property:
public string FirstName
{
get => field;
private set
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
if (value.Length > 100)
throw new DomainException("First name cannot exceed 100 characters");
field = value.Trim();
}
}
Without the field keyword, you’d need a separate _firstName backing field and the validation logic in both the constructor and the property setter. C# 14 lets us put it in one place. It’s a small feature, but across a domain with 20+ entities, eliminating those backing fields adds up to significantly cleaner code.
The LearningSession Entity
A LearningSession represents a single sitting where a child works through exercises. It has a lifecycle: started, in progress, completed.
public class LearningSession : Entity
{
private readonly List<ExerciseResult> _exerciseResults = [];
private LearningSession() { }
public static LearningSession Start(Guid childId, TopicId topicId, DifficultyScore difficulty)
{
return new LearningSession
{
Id = Guid.NewGuid(),
ChildId = childId,
TopicId = topicId,
Difficulty = difficulty,
StartedAt = DateTime.UtcNow,
Status = SessionStatus.InProgress
};
}
public Guid Id { get; private init; }
public Guid ChildId { get; private init; }
public TopicId TopicId { get; private init; }
public DifficultyScore Difficulty { get; private set; }
public DateTime StartedAt { get; private init; }
public DateTime? CompletedAt { get; private set; }
public SessionStatus Status { get; private set; }
public IReadOnlyCollection<ExerciseResult> ExerciseResults => _exerciseResults.AsReadOnly();
public int TotalExercises => _exerciseResults.Count;
public int CorrectAnswers => _exerciseResults.Count(e => e.IsCorrect);
public decimal AccuracyRate => TotalExercises == 0
? 0m
: (decimal)CorrectAnswers / TotalExercises;
public void RecordExercise(Guid exerciseId, bool isCorrect, TimeSpan responseTime)
{
if (Status != SessionStatus.InProgress)
throw new DomainException("Cannot record exercises on a completed session");
_exerciseResults.Add(new ExerciseResult(exerciseId, isCorrect, responseTime));
}
public SessionResult Complete()
{
if (Status != SessionStatus.InProgress)
throw new DomainException("Session is already completed");
if (TotalExercises == 0)
throw new DomainException("Cannot complete a session with no exercises");
Status = SessionStatus.Completed;
CompletedAt = DateTime.UtcNow;
var duration = CompletedAt.Value - StartedAt;
var scorePercentage = (int)(AccuracyRate * 100);
return new SessionResult(scorePercentage, duration, TotalExercises, CorrectAnswers);
}
}
public enum SessionStatus
{
InProgress,
Completed,
Abandoned
}
public record SessionResult(
int ScorePercentage,
TimeSpan Duration,
int TotalExercises,
int CorrectAnswers);
Notice how Complete() returns a SessionResult value object. This forces the caller to deal with the result — they can’t just call Complete() and ignore the score. Also notice that you can’t complete a session twice, and you can’t record exercises after completion. The entity enforces its own lifecycle.
There’s also the Abandoned status. In the real Kids Learn app, children sometimes stop a session partway through — they get distracted, the parent closes the browser, or lunchtime arrives. We handle this through a separate Abandon() method (not shown) that captures partial data without counting the session as completed. The state machine is explicit, not implicit.
One pattern I want to highlight: the ExerciseResult is a simple value object that records what happened during an individual exercise.
public record ExerciseResult(Guid ExerciseId, bool IsCorrect, TimeSpan ResponseTime)
{
public bool WasSlow => ResponseTime > TimeSpan.FromSeconds(30);
}
Having ResponseTime on each exercise lets the adaptive engine detect when a child understands the concept but is slow (needs more practice) versus when they’re genuinely lost (needs a different approach). This distinction matters for the AI difficulty adjustment.
The Lesson Entity
Lesson content is generated by AI but validated by domain rules. A lesson must have a valid topic, appropriate difficulty, and content that meets minimum requirements.
public class Lesson : Entity
{
private Lesson() { }
public static Lesson Create(
TopicId topicId,
string title,
string content,
DifficultyScore difficulty,
CurriculumStandard standard)
{
ArgumentException.ThrowIfNullOrWhiteSpace(title);
ArgumentException.ThrowIfNullOrWhiteSpace(content);
if (content.Length < 50)
throw new DomainException("Lesson content must be at least 50 characters");
return new Lesson
{
Id = Guid.NewGuid(),
TopicId = topicId,
Title = title.Trim(),
Content = content,
Difficulty = difficulty,
Standard = standard,
CreatedAt = DateTime.UtcNow,
IsApproved = false
};
}
public Guid Id { get; private init; }
public TopicId TopicId { get; private init; }
public string Title { get; private set; }
public string Content { get; private set; }
public DifficultyScore Difficulty { get; private set; }
public CurriculumStandard Standard { get; private init; }
public DateTime CreatedAt { get; private init; }
public bool IsApproved { get; private set; }
public void Approve()
{
if (IsApproved)
throw new DomainException("Lesson is already approved");
IsApproved = true;
}
public void UpdateContent(string newContent, DifficultyScore newDifficulty)
{
ArgumentException.ThrowIfNullOrWhiteSpace(newContent);
if (newContent.Length < 50)
throw new DomainException("Lesson content must be at least 50 characters");
Content = newContent;
Difficulty = newDifficulty;
IsApproved = false; // Content changed, needs re-approval
}
}
When AI generates lesson content, the domain layer validates it. If the content is too short, the domain rejects it. If a lesson’s content is updated, the approval flag resets automatically. These are business rules that belong in the entity, not scattered across services.
This is particularly important for Kids Learn because the AI content generation pipeline can produce lessons rapidly — dozens per minute. Without domain validation, bad content could slip through to children. The Lesson entity acts as a gatekeeper: AI can propose content, but the domain decides whether it meets the standards. The IsApproved flag adds a human review step for quality assurance, and critically, updating content automatically resets approval so reviewed content can’t be silently replaced.
Value Objects — Making Illegal States Unrepresentable
Value objects are one of the most underused patterns in .NET. Most developers reach for decimal or string and call it a day. But a decimal that represents mastery level (0.0-1.0) is fundamentally different from a decimal that represents a price. The type system should know that.
MasteryLevel
public readonly record struct MasteryLevel : IComparable<MasteryLevel>
{
public decimal Value { get; }
private MasteryLevel(decimal value)
{
if (value < 0.0m || value > 1.0m)
throw new DomainException($"Mastery level must be between 0.0 and 1.0, got {value}");
Value = Math.Round(value, 4);
}
public static MasteryLevel FromDecimal(decimal value) => new(value);
public static MasteryLevel FromPercentage(int percentage) => new(percentage / 100m);
// Named constants for business thresholds
public static readonly MasteryLevel Beginner = new(0.0m);
public static readonly MasteryLevel AtRisk = new(0.4m);
public static readonly MasteryLevel Developing = new(0.6m);
public static readonly MasteryLevel Proficient = new(0.8m);
public static readonly MasteryLevel Expert = new(1.0m);
public MasteryLevel Increase(decimal amount)
{
var newValue = Math.Min(1.0m, Value + amount);
return new MasteryLevel(newValue);
}
public MasteryLevel Decrease(decimal amount)
{
var newValue = Math.Max(0.0m, Value - amount);
return new MasteryLevel(newValue);
}
public MasteryLevel WeightedAverage(MasteryLevel other, decimal weight)
{
if (weight < 0 || weight > 1)
throw new DomainException("Weight must be between 0.0 and 1.0");
var result = Value * (1 - weight) + other.Value * weight;
return new MasteryLevel(result);
}
public int CompareTo(MasteryLevel other) => Value.CompareTo(other.Value);
public static bool operator >(MasteryLevel left, MasteryLevel right) => left.Value > right.Value;
public static bool operator <(MasteryLevel left, MasteryLevel right) => left.Value < right.Value;
public static bool operator >=(MasteryLevel left, MasteryLevel right) => left.Value >= right.Value;
public static bool operator <=(MasteryLevel left, MasteryLevel right) => left.Value <= right.Value;
public override string ToString() => $"{Value:P0}";
}
Why a record struct? Because value objects should be compared by value (records give us that), and mastery levels are small — a single decimal — so they should be allocated on the stack (struct gives us that). The readonly modifier ensures immutability.
The beauty of this approach: you literally cannot create an invalid mastery level. MasteryLevel.FromDecimal(5.0m) throws an exception. Every method returns a new MasteryLevel that’s guaranteed valid. The Increase method caps at 1.0. The Decrease method floors at 0.0. You can’t mess it up.
The named constants (Beginner, AtRisk, Proficient) encode business thresholds. Instead of magic numbers scattered through the codebase — if (mastery > 0.8m) — you write if (mastery >= MasteryLevel.Proficient). When the business decides proficiency is 0.85 instead of 0.8, you change it in one place.
DifficultyScore
public readonly record struct DifficultyScore : IComparable<DifficultyScore>
{
public decimal Value { get; }
private DifficultyScore(decimal value)
{
if (value < 0.0m || value > 1.0m)
throw new DomainException($"Difficulty score must be between 0.0 and 1.0, got {value}");
Value = Math.Round(value, 4);
}
public static DifficultyScore FromDecimal(decimal value) => new(value);
public static readonly DifficultyScore Easy = new(0.2m);
public static readonly DifficultyScore Medium = new(0.5m);
public static readonly DifficultyScore Hard = new(0.8m);
public static readonly DifficultyScore Expert = new(1.0m);
public DifficultyScore AdjustForMastery(MasteryLevel mastery)
{
// Adaptive: push difficulty slightly above current mastery
// This is the core of the adaptive learning algorithm
var target = Math.Min(1.0m, mastery.Value + 0.1m);
return new DifficultyScore(target);
}
public int CompareTo(DifficultyScore other) => Value.CompareTo(other.Value);
public static bool operator >(DifficultyScore left, DifficultyScore right) => left.Value > right.Value;
public static bool operator <(DifficultyScore left, DifficultyScore right) => left.Value < right.Value;
public static bool operator >=(DifficultyScore left, DifficultyScore right) => left.Value >= right.Value;
public static bool operator <=(DifficultyScore left, DifficultyScore right) => left.Value <= right.Value;
public override string ToString() => $"Difficulty: {Value:F2}";
}
DifficultyScore looks structurally similar to MasteryLevel, but it’s a different type. You can’t accidentally pass a MasteryLevel where a DifficultyScore is expected. The compiler catches it. The AdjustForMastery method encodes the adaptive learning algorithm’s core idea — push difficulty slightly above the child’s current mastery level to keep them in the zone of proximal development.
GradeLevel
public readonly record struct GradeLevel
{
public int Value { get; }
private GradeLevel(int value)
{
if (value < 1 || value > 6)
throw new DomainException($"Grade level must be between 1 and 6, got {value}");
Value = value;
}
public static GradeLevel FromInt(int value) => new(value);
public static readonly GradeLevel First = new(1);
public static readonly GradeLevel Second = new(2);
public static readonly GradeLevel Third = new(3);
public static readonly GradeLevel Fourth = new(4);
public static readonly GradeLevel Fifth = new(5);
public static readonly GradeLevel Sixth = new(6);
public GradeLevel Next()
{
if (Value >= 6)
throw new DomainException("Cannot advance beyond grade 6");
return new GradeLevel(Value + 1);
}
public bool IsWithinRange(GradeLevel lower, GradeLevel upper)
=> Value >= lower.Value && Value <= upper.Value;
public override string ToString() => $"Grade {Value}";
}
Why not an enum? Because GradeLevel needs behavior — it knows it can’t go beyond grade 6, it can check ranges, and it validates on construction. An enum would let you cast (GradeLevel)99 and carry on with invalid state. The value object prevents it.
CurriculumStandard
public readonly record struct CurriculumStandard
{
private static readonly Regex StandardPattern = new(
@"^CCSS\.\w+\.\d+\.\w+(\.\w+)*$",
RegexOptions.Compiled);
public string Code { get; }
private CurriculumStandard(string code)
{
if (!StandardPattern.IsMatch(code))
throw new DomainException(
$"Invalid curriculum standard format: '{code}'. " +
"Expected format like 'CCSS.MATH.3.OA.A.1'");
Code = code;
}
public static CurriculumStandard FromCode(string code)
{
ArgumentException.ThrowIfNullOrWhiteSpace(code);
return new CurriculumStandard(code.Trim().ToUpperInvariant());
}
public string Subject => Code.Split('.')[1];
public int GradeNumber => int.Parse(Code.Split('.')[2]);
public string Domain => Code.Split('.')[3];
public override string ToString() => Code;
}
CurriculumStandard wraps a standard ID like “CCSS.MATH.3.OA.A.1” (Common Core State Standards, Math, Grade 3, Operations & Algebraic Thinking, Cluster A, Standard 1). The regex validates the format on construction, and the parsed properties (Subject, GradeNumber, Domain) let you work with the parts without string manipulation scattered through the codebase.
TopicId — A Typed ID
public readonly record struct TopicId
{
public Guid Value { get; }
public TopicId(Guid value)
{
if (value == Guid.Empty)
throw new DomainException("Topic ID cannot be empty");
Value = value;
}
public static TopicId New() => new(Guid.NewGuid());
public static TopicId From(Guid value) => new(value);
public override string ToString() => Value.ToString();
}
Typed IDs prevent a whole class of bugs where you accidentally pass a childId where a topicId was expected. Both are Guid at the infrastructure level, but they’re different types in the domain.
I know some teams think this is over-engineering. Personally, I’ve seen enough bugs caused by swapped Guid parameters to believe the small overhead is worth it. Your mileage may vary. If you’re working on a project with 2 developers and 10 entities, plain Guid is probably fine. With 6 developers and 50 entities, typed IDs save real debugging time.
A quick note on EF Core and value objects: mapping these to database columns requires configuration in the Infrastructure layer. We’ll cover that in Part 4, but the short version is that EF Core 9+ handles record struct value objects well through owned types and value conversions. MasteryLevel maps to a decimal column, GradeLevel maps to an int, and CurriculumStandard maps to a varchar. The database doesn’t know about our domain types — that’s the Infrastructure layer’s job — but the domain never has to compromise.
Domain Events — Decoupling Side Effects from Business Logic
When a child completes a lesson, several things need to happen: update the progress record, recalculate mastery, check for knowledge gaps, notify the parent, update analytics, potentially adjust the AI difficulty model. If you put all of that in the RecordLessonCompletion method, it becomes a 200-line monster that touches 5 different concerns.
Domain events solve this by separating “what happened” from “what should we do about it.”
The IDomainEvent Interface and Base Classes
public interface IDomainEvent
{
Guid EventId { get; }
DateTime OccurredAt { get; }
}
public abstract record DomainEvent : IDomainEvent
{
public Guid EventId { get; } = Guid.NewGuid();
public DateTime OccurredAt { get; } = DateTime.UtcNow;
}
Using abstract record gives us immutability and value equality for free. Each event gets a unique ID and a timestamp.
The base Entity and AggregateRoot classes that collect events:
public abstract class Entity
{
// Common entity concerns can go here
}
public abstract class AggregateRoot : Entity
{
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyCollection<IDomainEvent> DomainEvents
=> _domainEvents.AsReadOnly();
protected void AddDomainEvent(IDomainEvent domainEvent)
=> _domainEvents.Add(domainEvent);
public void ClearDomainEvents()
=> _domainEvents.Clear();
}
Only aggregate roots collect domain events. This is deliberate — events are raised at the aggregate boundary, and they’re dispatched after the aggregate is persisted. We’ll see the dispatch mechanism in Part 3 when we build the Application layer.
Why not put event collection on every entity? Because events represent things that matter to the outside world, and the aggregate root is the gateway to the outside world. If LearningProgress (an entity inside the Child aggregate) raised events directly, you’d have events scattered across the object graph with no clear point of dispatch. By funneling everything through the aggregate root, you get a single place to collect and dispatch events.
The Kids Learn Domain Events
public sealed record LessonCompleted(
Guid ChildId,
TopicId TopicId,
MasteryLevel Score,
TimeSpan Duration) : DomainEvent
{
public bool WasQuickCompletion => Duration < TimeSpan.FromMinutes(2);
public bool WasStruggling => Duration > TimeSpan.FromMinutes(15);
}
public sealed record MasteryAchieved(
Guid ChildId,
TopicId TopicId,
MasteryLevel NewMastery) : DomainEvent
{
public bool IsFullMastery => NewMastery >= MasteryLevel.Expert;
}
public sealed record KnowledgeGapDetected(
Guid ChildId,
TopicId TopicId,
MasteryLevel CurrentMastery) : DomainEvent
{
public bool IsCritical => CurrentMastery < MasteryLevel.FromDecimal(0.2m);
}
Each event carries the data that handlers need. LessonCompleted includes the score and duration so handlers can determine if the child was rushing or struggling. MasteryAchieved includes the new mastery level. KnowledgeGapDetected flags critical gaps.
Notice that events have behavior too — WasQuickCompletion, IsCritical. These are derived from the event data and help handlers make decisions without reimplementing logic.
How Events Flow
Here’s the lifecycle:
- Entity raises event:
Child.RecordLessonCompletion()callsAddDomainEvent(new LessonCompleted(...)). - Events accumulate: The
Childaggregate root holds them in memory. - Persistence happens: The Application layer calls
SaveChangesAsync(). - Infrastructure dispatches: After
SaveChangessucceeds, the infrastructure layer readsDomainEventsfrom the aggregate root and dispatches them to registered handlers. - Handlers execute: Each handler does one thing — send a notification, update a read model, trigger the AI engine.
- Events are cleared: After dispatch,
ClearDomainEvents()is called.
The critical insight is step 4: events are dispatched after persistence. If the database save fails, no events fire. This prevents side effects from happening when the core operation failed. We’ll implement the dispatcher in Part 4 (Infrastructure), but the domain layer only needs to know about steps 1 and 2.
// This is what the dispatch looks like (implemented in Infrastructure, shown for context)
public class DomainEventDispatcher
{
private readonly IServiceProvider _serviceProvider;
public async Task DispatchEventsAsync(AggregateRoot aggregate)
{
var events = aggregate.DomainEvents.ToList();
aggregate.ClearDomainEvents();
foreach (var domainEvent in events)
{
var handlerType = typeof(IDomainEventHandler<>)
.MakeGenericType(domainEvent.GetType());
var handlers = _serviceProvider.GetServices(handlerType);
foreach (dynamic handler in handlers)
{
await handler.HandleAsync((dynamic)domainEvent);
}
}
}
}
Aggregate Roots and Boundaries
This is where a lot of teams get confused. They hear “aggregate” and think it means “a big entity that contains everything.” It doesn’t. An aggregate is a consistency boundary — a cluster of objects that must be consistent together within a single transaction.
For Kids Learn, the Child aggregate looks like this:
What’s Inside the Aggregate
Child(aggregate root) — the entry point. All modifications go throughChild.LearningProgress— one per topic, tracks the child’s mastery for that specific topic. Owned byChild, cannot exist without aChild.LearningSession— a specific learning session. Belongs to theChild.
public class LearningProgress : Entity
{
private LearningProgress() { }
internal static LearningProgress StartTracking(
Guid childId,
TopicId topicId,
MasteryLevel initialScore)
{
return new LearningProgress
{
Id = Guid.NewGuid(),
ChildId = childId,
TopicId = topicId,
Mastery = initialScore,
TotalAttempts = 1,
LastAttemptAt = DateTime.UtcNow
};
}
public Guid Id { get; private init; }
public Guid ChildId { get; private init; }
public TopicId TopicId { get; private init; }
public MasteryLevel Mastery { get; private set; }
public int TotalAttempts { get; private set; }
public DateTime LastAttemptAt { get; private set; }
internal void RecordAttempt(MasteryLevel newScore)
{
// Exponential moving average — recent scores weighted more heavily
Mastery = Mastery.WeightedAverage(newScore, weight: 0.3m);
TotalAttempts++;
LastAttemptAt = DateTime.UtcNow;
}
}
Notice the internal access modifier on StartTracking and RecordAttempt. These methods are only callable from within the Domain assembly. External code can’t create or modify LearningProgress directly — it has to go through the Child aggregate root. This is the consistency boundary in code.
What’s Outside the Aggregate
Topic— referenced byTopicIdonly. ATopicexists independently of anyChild. If you load aChild, you don’t load all topics.Lesson— referenced by ID. Lessons are created by the AI engine and exist independently. AChilddoesn’t own lessons.
This matters for performance and consistency. When you load a Child aggregate, you load its LearningProgress records and recent Sessions. You do NOT load every Topic and Lesson in the system. The aggregate boundary keeps the loaded data small and the transactions fast.
// Right — reference external entities by ID
public class LearningSession : Entity
{
public TopicId TopicId { get; private init; } // Just the ID, not the full Topic
// ...
}
// Wrong — navigating across aggregate boundaries
public class LearningSession : Entity
{
public Topic Topic { get; set; } // Loads the entire Topic entity
public Lesson Lesson { get; set; } // Loads the entire Lesson entity
}
The rule: within an aggregate, use direct object references. Across aggregates, use IDs. This keeps aggregates independent and prevents the “load the entire database” problem that happens when EF Core navigation properties go wild.
I learned this the hard way on a previous project. We had a Customer entity with a navigation property to Orders, and Order had a navigation property to Products, and Products had navigation properties to Categories and Suppliers. Loading a single customer could trigger a cascade of lazy loading that pulled half the database into memory. The EF Core query log was horrifying — 47 queries for a single page load. Aggregate boundaries with ID-only references across boundaries prevent this entirely.
Transaction Boundary
Everything inside the Child aggregate is saved in a single database transaction. When you call RecordLessonCompletion, the Child, its updated LearningProgress, and the domain events are all saved atomically. Either everything succeeds or nothing does.
If you need to update a Topic based on aggregated learning data across many children, that’s a separate transaction. It might be triggered by a domain event handler, but it’s not part of the Child aggregate’s transaction.
// One transaction — everything inside the Child aggregate
public async Task Handle(CompleteLessonCommand command)
{
var child = await _childRepository.GetByIdAsync(command.ChildId);
child.RecordLessonCompletion(
command.TopicId,
command.Score,
command.Duration);
await _childRepository.SaveAsync(child);
// Child + LearningProgress saved atomically
// Domain events dispatched after save
}
Architecture Tests with NetArchTest
We’ve made design decisions. Rich domain models, value objects, aggregate boundaries, the dependency rule. But how do we ensure the team doesn’t accidentally violate these rules six months from now when a new developer joins and takes a shortcut?
Architecture tests. They run with your unit tests and fail the build if someone breaks the rules.
Add the NuGet package to your test project:
dotnet add tests/KidsLearn.Architecture.Tests package NetArchTest.Rules
Rule 1: Domain Has No Dependencies
This is the most important rule. The domain layer depends on nothing.
[Fact]
public void Domain_Should_Not_Reference_Application()
{
var result = Types.InAssembly(typeof(Child).Assembly)
.ShouldNot()
.HaveDependencyOn("KidsLearn.Application")
.GetResult();
Assert.True(result.IsSuccessful,
"Domain layer must not reference Application layer");
}
[Fact]
public void Domain_Should_Not_Reference_Infrastructure()
{
var result = Types.InAssembly(typeof(Child).Assembly)
.ShouldNot()
.HaveDependencyOn("KidsLearn.Infrastructure")
.GetResult();
Assert.True(result.IsSuccessful,
"Domain layer must not reference Infrastructure layer");
}
[Fact]
public void Domain_Should_Not_Reference_WebApi()
{
var result = Types.InAssembly(typeof(Child).Assembly)
.ShouldNot()
.HaveDependencyOn("KidsLearn.WebApi")
.GetResult();
Assert.True(result.IsSuccessful,
"Domain layer must not reference WebApi layer");
}
If someone adds using KidsLearn.Infrastructure; to a domain class — maybe to use an EF Core attribute or a logging library — these tests catch it immediately.
Rule 2: Entities Must Not Have Public Setters
[Fact]
public void Entities_Should_Not_Have_Public_Setters()
{
var entityTypes = Types.InAssembly(typeof(Child).Assembly)
.That()
.Inherit(typeof(Entity))
.GetTypes();
foreach (var type in entityTypes)
{
var publicSetters = type.GetProperties()
.Where(p => p.SetMethod is { IsPublic: true });
Assert.True(
!publicSetters.Any(),
$"Entity '{type.Name}' has public setters on: " +
$"{string.Join(", ", publicSetters.Select(p => p.Name))}. " +
"Use private setters and business methods instead.");
}
}
This enforces our “no public setters on entities” rule automatically.
Rule 3: Domain Events Must Be Sealed Records
[Fact]
public void Domain_Events_Should_Be_Sealed_Records()
{
var eventTypes = Types.InAssembly(typeof(Child).Assembly)
.That()
.ImplementInterface(typeof(IDomainEvent))
.And()
.AreNotAbstract()
.GetTypes();
foreach (var type in eventTypes)
{
Assert.True(type.IsSealed,
$"Domain event '{type.Name}' must be sealed");
Assert.True(
type.GetMethod("<Clone>$") is not null, // Records have a Clone method
$"Domain event '{type.Name}' must be a record type");
}
}
Rule 4: Value Objects Must Be Record Structs
[Fact]
public void Value_Objects_Should_Be_Readonly_Record_Structs()
{
var valueObjectTypes = new[]
{
typeof(MasteryLevel),
typeof(DifficultyScore),
typeof(GradeLevel),
typeof(CurriculumStandard),
typeof(TopicId)
};
foreach (var type in valueObjectTypes)
{
Assert.True(type.IsValueType,
$"Value object '{type.Name}' must be a struct");
Assert.True(
type.GetMethod("<Clone>$") is not null,
$"Value object '{type.Name}' must be a record struct");
}
}
These tests take less than a second to run and they’ve saved us multiple times. One time, a developer added a [Required] attribute from System.ComponentModel.DataAnnotations to a domain entity. The dependency test caught it — that attribute comes from a namespace that’s fine for the domain project to reference technically, but it signaled that validation was being done through attributes rather than through the entity’s own behavior. We caught the design drift early.
Another time, someone added a public setter to LearningProgress.Mastery because they needed to “quickly fix” a data migration issue. The architecture test caught it in the PR pipeline. The fix was a 5-minute method on the Child aggregate instead. Without the test, that public setter would have lived in the codebase forever, waiting for someone to misuse it.
The Complete Test Class
namespace KidsLearn.Architecture.Tests;
using NetArchTest.Rules;
public class DomainLayerArchitectureTests
{
private static readonly Assembly DomainAssembly = typeof(Child).Assembly;
[Fact]
public void Domain_Should_Not_Reference_Any_Other_Project()
{
var otherProjects = new[]
{
"KidsLearn.Application",
"KidsLearn.Infrastructure",
"KidsLearn.WebApi"
};
foreach (var project in otherProjects)
{
var result = Types.InAssembly(DomainAssembly)
.ShouldNot()
.HaveDependencyOn(project)
.GetResult();
Assert.True(result.IsSuccessful,
$"Domain layer must not reference {project}. " +
$"Violating types: {string.Join(", ", result.FailingTypeNames ?? [])}");
}
}
[Fact]
public void Domain_Should_Not_Reference_EFCore()
{
var result = Types.InAssembly(DomainAssembly)
.ShouldNot()
.HaveDependencyOn("Microsoft.EntityFrameworkCore")
.GetResult();
Assert.True(result.IsSuccessful,
"Domain must not reference EF Core. " +
"Use the Infrastructure layer for persistence concerns.");
}
}
Trade-Offs and Honest Assessment
I want to be upfront about the costs of rich domain models:
More code up front. The anemic Child entity was 12 lines. The rich one is 60+. For a CRUD app with simple validation, this is overkill.
EF Core mapping complexity. Private setters, backing fields, value objects — these all need explicit mapping in the Infrastructure layer. We’ll cover this in Part 4, but know that it’s non-trivial. EF Core can handle it, but you’ll write more OnModelCreating configuration.
Learning curve. If your team has been writing anemic models for years, the shift to rich models takes time. People will instinctively reach for services and public setters. The architecture tests help enforce the rules, but the mindset shift takes practice.
When NOT to do this. If you’re building a simple CRUD app — a todo list, a basic content management system, a straightforward data entry form — rich domain models add complexity without proportional benefit. Use this approach when you have genuine business rules, complex invariants, and a domain that changes over time. Kids Learn qualifies because adaptive learning has complex rules around mastery, difficulty adjustment, and knowledge gap detection. Your average settings page does not.
That said, when you do have a complex domain — and most SaaS products eventually get there — rich models pay for themselves many times over. The bugs they prevent, the clarity they provide, and the refactoring safety they enable are worth the up-front investment.
What’s Next
In Part 3, we build the Application layer with CQRS using MediatR. We’ll implement commands like CompleteLessonCommand and queries like GetChildProgressQuery, wire up domain event handlers, and set up the validation pipeline with FluentValidation. The Application layer is where use cases live — it orchestrates the domain objects we built today without adding business logic of its own.
The domain layer we built in this post is the foundation everything else depends on. Get this right, and the rest of the architecture falls into place naturally.
This is Part 2 of a 7-part series on Clean Architecture with .NET 10. Start from Part 1 if you haven’t already.