The Kids Learn prototype started as a weekend project. A Next.js frontend calling a .NET Minimal API backend, a couple of EF Core entities, a PostgreSQL database, and a single call to the Gemini API for generating quiz questions. It worked. Parents could sign up, kids could take adaptive quizzes, and the AI would adjust difficulty based on right and wrong answers. I shipped it, showed it to a few people, and got genuinely excited feedback.
Then reality hit.
We needed a curriculum alignment engine that mapped every quiz question to specific learning standards. We needed a parent dashboard that showed progress over time with breakdowns by subject and skill level. We needed a teacher portal so schools could assign content. We needed subscription billing with Stripe. We needed an analytics pipeline for tracking learning outcomes. We needed the adaptive engine to get smarter — not just harder/easier questions, but entirely different learning paths based on how each child learns.
I looked at the codebase. A single LearningController was 800 lines long. It called QuizRepository directly, which also called GeminiService, which also queried the database to get context for its prompts. The UserService had a dependency on IEmailService, ISubscriptionService, IQuizRepository, and IGeminiClient. Changing how we stored quiz results required modifying the controller, the repository, two services, and three DTOs. Everything was wired to everything else.
I’d built a Big Ball of Mud with nice folder names.
This is the story of how I restructured Kids Learn using Clean Architecture — not by copy-pasting a template from GitHub, but by actually understanding what the rules mean and why they exist. This is Part 1 of a 7-part series. By the end of the series, we’ll have a production-ready .NET 10 application with a rich domain model, CQRS with MediatR, EF Core 10 with PostgreSQL and pgvector, Gemini AI integration, and comprehensive testing at every layer.
But first, we need to understand the rules.
Uncle Bob’s Circles — In C#
Robert C. Martin introduced Clean Architecture in 2012, and it’s been misunderstood ever since. People see the concentric circles diagram and think it’s about folder structure. It’s not. It’s about one rule.
The Dependency Rule: Source code dependencies can only point inward.
That’s it. One rule. Everything else follows from it.
The inner circles are policies. The outer circles are mechanisms. Code in the inner circles doesn’t know that the outer circles exist. Your business logic doesn’t know it’s being served over HTTP. Your domain entities don’t know they’re being stored in PostgreSQL. Your use cases don’t know that AI content is coming from Google Gemini rather than OpenAI or a local model.
Let me make this concrete with C# code, because “source code dependencies” is abstract until you see it in a .csproj file.
Here’s what the Dependency Rule looks like in a .NET solution:
KidsLearn.Domain → references NOTHING
KidsLearn.Application → references KidsLearn.Domain
KidsLearn.Infrastructure → references KidsLearn.Application (and transitively, Domain)
KidsLearn.Api → references KidsLearn.Infrastructure (and transitively, everything)
The Domain project has zero <ProjectReference> entries and zero NuGet packages. It’s pure C#. No framework. No ORM. No serialization attributes. If you deleted every NuGet package from your machine, the Domain project would still compile.
The Application project knows about Domain types — entities, value objects, domain events — but it has no idea those entities get stored in PostgreSQL via EF Core. It defines interfaces like IQuizRepository and IContentGenerator, but it doesn’t implement them. It doesn’t know how quizzes are stored or how content is generated. It only knows what operations are available.
The Infrastructure project is where the real world leaks in. EF Core, HTTP clients, Redis, email services, file storage — all the messy, external, change-prone stuff lives here. And critically, Infrastructure implements the interfaces that Application defines. This is Dependency Inversion — the “D” in SOLID — applied at the architectural level.
The Api project (Presentation layer) is just the entry point. It wires everything together with dependency injection, exposes endpoints, and handles HTTP concerns like authentication, routing, and serialization.
Here’s the key insight that took me too long to understand: the arrows between these projects are enforced by the compiler. If someone on your team tries to reference Microsoft.EntityFrameworkCore from the Domain project, the build fails. If someone tries to use HttpContext in the Application layer, it won’t compile. The architecture is not a suggestion documented in a wiki that nobody reads. It’s a build constraint.
// This is in KidsLearn.Application — it compiles fine
// because Application references Domain
using KidsLearn.Domain.Entities;
public interface IQuizRepository
{
Task<Quiz?> GetByIdAsync(QuizId id, CancellationToken ct = default);
Task AddAsync(Quiz quiz, CancellationToken ct = default);
}
// This would NOT compile in KidsLearn.Application
// because Application does NOT reference Infrastructure
using Microsoft.EntityFrameworkCore; // ❌ Build error!
That build error is the entire point of Clean Architecture. It’s not about diagrams. It’s about making the wrong thing impossible.
The 4 Layers in .NET 10
Let me walk through each layer with examples from the Kids Learn codebase. Not theoretical examples — real code that solves real problems.
Domain Layer: The Heart of the System
The Domain layer contains your business rules. For Kids Learn, that means entities like Student, Quiz, LearningPath, Lesson, and CurriculumStandard. It also contains value objects like Age, DifficultyLevel, Score, and SubjectArea. Domain events like QuizCompletedEvent and LearningPathAdjustedEvent. Custom exceptions like InvalidAgeRangeException. Enumerations like LearningStyle and SubscriptionTier.
What the Domain layer does not contain: database annotations, JSON serialization attributes, validation libraries, logging frameworks, or any reference to any NuGet package.
// KidsLearn.Domain/Entities/Student.cs
namespace KidsLearn.Domain.Entities;
public class Student : AggregateRoot<StudentId>
{
public StudentName Name { get; private set; }
public Age Age { get; private set; }
public LearningStyle PreferredStyle { get; private set; }
private readonly List<LearningPath> _learningPaths = [];
public IReadOnlyCollection<LearningPath> LearningPaths => _learningPaths.AsReadOnly();
private Student() { } // EF Core needs this, but Domain doesn't know why
public static Student Create(StudentName name, Age age, LearningStyle style)
{
var student = new Student
{
Id = StudentId.New(),
Name = name,
Age = age,
PreferredStyle = style
};
student.RaiseDomainEvent(new StudentCreatedEvent(student.Id));
return student;
}
public void AdjustLearningPath(LearningPath path, DifficultyLevel newLevel)
{
if (!_learningPaths.Contains(path))
throw new LearningPathNotAssignedException(Id, path.Id);
path.AdjustDifficulty(newLevel);
RaiseDomainEvent(new LearningPathAdjustedEvent(Id, path.Id, newLevel));
}
}
Notice a few things. The constructor is private — you create a Student through a factory method that enforces business rules and raises domain events. The _learningPaths collection is a private field exposed as IReadOnlyCollection — nobody outside the entity can modify it directly. All state changes go through methods that validate invariants.
This is a rich domain model, not an anemic one. The entity does things. It makes decisions. It protects its own consistency.
Zero NuGet packages in this project’s .csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<!-- No PackageReference entries. None. -->
<!-- No ProjectReference entries. None. -->
</Project>
Application Layer: Use Cases and Orchestration
The Application layer contains the use cases of your system. For Kids Learn, a use case might be “Student starts a quiz,” “Parent views weekly progress report,” or “System generates an adaptive learning path.”
This layer depends on Domain (it uses entities and value objects) but knows nothing about how things are persisted or how external services work. It defines interfaces — ports — that the Infrastructure layer will implement.
// KidsLearn.Application/Quizzes/Commands/StartQuiz/StartQuizCommand.cs
namespace KidsLearn.Application.Quizzes.Commands.StartQuiz;
public sealed record StartQuizCommand(
Guid StudentId,
string SubjectArea,
int QuestionCount = 10
) : IRequest<StartQuizResult>;
public sealed record StartQuizResult(
Guid QuizId,
IReadOnlyList<QuestionDto> Questions
);
// KidsLearn.Application/Quizzes/Commands/StartQuiz/StartQuizCommandHandler.cs
namespace KidsLearn.Application.Quizzes.Commands.StartQuiz;
public sealed class StartQuizCommandHandler
: IRequestHandler<StartQuizCommand, StartQuizResult>
{
private readonly IQuizRepository _quizRepository;
private readonly IStudentRepository _studentRepository;
private readonly IContentGenerator _contentGenerator;
private readonly IUnitOfWork _unitOfWork;
public StartQuizCommandHandler(
IQuizRepository quizRepository,
IStudentRepository studentRepository,
IContentGenerator contentGenerator,
IUnitOfWork unitOfWork)
{
_quizRepository = quizRepository;
_studentRepository = studentRepository;
_contentGenerator = contentGenerator;
_unitOfWork = unitOfWork;
}
public async Task<StartQuizResult> Handle(
StartQuizCommand request,
CancellationToken ct)
{
var studentId = new StudentId(request.StudentId);
var student = await _studentRepository.GetByIdAsync(studentId, ct)
?? throw new StudentNotFoundException(studentId);
var subject = SubjectArea.FromString(request.SubjectArea);
var difficulty = student.CurrentDifficultyFor(subject);
// IContentGenerator is an interface defined HERE in Application.
// The actual implementation (calling Gemini API) is in Infrastructure.
// This handler has no idea it's talking to Google.
var questions = await _contentGenerator.GenerateQuestionsAsync(
subject, difficulty, student.Age, request.QuestionCount, ct);
var quiz = Quiz.Create(student, subject, questions, difficulty);
await _quizRepository.AddAsync(quiz, ct);
await _unitOfWork.SaveChangesAsync(ct);
return new StartQuizResult(
quiz.Id.Value,
questions.Select(q => q.ToDto()).ToList()
);
}
}
Look at the IContentGenerator interface. The handler calls GenerateQuestionsAsync without knowing whether those questions come from Gemini, OpenAI, a local LLM, or a hardcoded JSON file for testing. That’s the power of the Dependency Rule. The Application layer defines what it needs; Infrastructure provides it.
The Application project typically has a small number of NuGet packages — MediatR for CQRS dispatching, FluentValidation for input validation, and that’s about it:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="12.*" />
<PackageReference Include="FluentValidation" Version="11.*" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\KidsLearn.Domain\KidsLearn.Domain.csproj" />
</ItemGroup>
</Project>
One ProjectReference — to Domain. Nothing else.
Infrastructure Layer: The Messy Real World
Infrastructure is where you implement all the interfaces defined in the Application layer. This is where the database lives. Where the HTTP clients live. Where the email sender lives. Where the Gemini AI integration lives.
// KidsLearn.Infrastructure/AI/GeminiContentGenerator.cs
namespace KidsLearn.Infrastructure.AI;
public sealed class GeminiContentGenerator : IContentGenerator
{
private readonly GeminiClient _client;
private readonly ILogger<GeminiContentGenerator> _logger;
public GeminiContentGenerator(GeminiClient client, ILogger<GeminiContentGenerator> logger)
{
_client = client;
_logger = logger;
}
public async Task<IReadOnlyList<Question>> GenerateQuestionsAsync(
SubjectArea subject,
DifficultyLevel difficulty,
Age studentAge,
int count,
CancellationToken ct)
{
var prompt = BuildPrompt(subject, difficulty, studentAge, count);
_logger.LogInformation(
"Generating {Count} questions for {Subject} at {Difficulty} for age {Age}",
count, subject, difficulty, studentAge);
var response = await _client.GenerateContentAsync(prompt, ct);
return ParseQuestionsFromResponse(response);
}
// ... prompt building and response parsing
}
// KidsLearn.Infrastructure/Persistence/Repositories/QuizRepository.cs
namespace KidsLearn.Infrastructure.Persistence.Repositories;
public sealed class QuizRepository : IQuizRepository
{
private readonly KidsLearnDbContext _context;
public QuizRepository(KidsLearnDbContext context)
{
_context = context;
}
public async Task<Quiz?> GetByIdAsync(QuizId id, CancellationToken ct = default)
{
return await _context.Quizzes
.Include(q => q.Questions)
.FirstOrDefaultAsync(q => q.Id == id, ct);
}
public async Task AddAsync(Quiz quiz, CancellationToken ct = default)
{
await _context.Quizzes.AddAsync(quiz, ct);
}
}
The Infrastructure project references the Application project (and transitively, Domain). It has all the heavy NuGet packages: Microsoft.EntityFrameworkCore, Npgsql.EntityFrameworkCore.PostgreSQL, Google.Cloud.AIPlatform, StackExchange.Redis, etc. This is the project that knows about the outside world.
Presentation Layer: The Entry Point
In our case, this is KidsLearn.Api — a .NET 10 Minimal API project. Its job is simple: receive HTTP requests, dispatch them to the Application layer, and return HTTP responses.
// KidsLearn.Api/Endpoints/QuizEndpoints.cs
namespace KidsLearn.Api.Endpoints;
public static class QuizEndpoints
{
public static void MapQuizEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/quizzes")
.WithTags("Quizzes")
.RequireAuthorization();
group.MapPost("/start", async (
StartQuizCommand command,
ISender sender,
CancellationToken ct) =>
{
var result = await sender.Send(command, ct);
return Results.Created($"/api/quizzes/{result.QuizId}", result);
})
.WithName("StartQuiz")
.Produces<StartQuizResult>(StatusCodes.Status201Created)
.ProducesValidationProblem();
}
}
The endpoint handler is about 5 lines of code. It receives a command, sends it through MediatR, and returns the result. It doesn’t contain business logic. It doesn’t call repositories. It doesn’t know about databases or AI services. It just translates HTTP to use cases and back.
Setting Up the Solution
Enough theory. Let’s create the actual solution structure. Here are the dotnet CLI commands I ran for Kids Learn:
# Create the solution
dotnet new sln -n KidsLearn
# Create the projects
dotnet new classlib -n KidsLearn.Domain
dotnet new classlib -n KidsLearn.Application
dotnet new classlib -n KidsLearn.Infrastructure
dotnet new webapi -n KidsLearn.Api
# Add projects to solution
dotnet sln add KidsLearn.Domain
dotnet sln add KidsLearn.Application
dotnet sln add KidsLearn.Infrastructure
dotnet sln add KidsLearn.Api
# Set up references — THIS IS WHERE THE DEPENDENCY RULE LIVES
dotnet add KidsLearn.Application reference KidsLearn.Domain
dotnet add KidsLearn.Infrastructure reference KidsLearn.Application
dotnet add KidsLearn.Api reference KidsLearn.Infrastructure
After running these commands, here’s what the solution looks like:
KidsLearn/
├── KidsLearn.sln
├── KidsLearn.Domain/
│ ├── KidsLearn.Domain.csproj
│ ├── Entities/
│ ├── ValueObjects/
│ ├── Events/
│ ├── Enums/
│ ├── Exceptions/
│ └── Common/
├── KidsLearn.Application/
│ ├── KidsLearn.Application.csproj
│ ├── Common/
│ │ ├── Interfaces/ ← IQuizRepository, IContentGenerator, IUnitOfWork
│ │ ├── Behaviors/ ← MediatR pipeline behaviors (validation, logging)
│ │ └── Mappings/ ← DTO mapping profiles
│ ├── Quizzes/
│ │ ├── Commands/
│ │ │ └── StartQuiz/ ← Command, Handler, Validator
│ │ └── Queries/
│ │ └── GetQuizResults/ ← Query, Handler, Validator
│ ├── Students/
│ ├── LearningPaths/
│ └── DependencyInjection.cs ← AddApplication() extension method
├── KidsLearn.Infrastructure/
│ ├── KidsLearn.Infrastructure.csproj
│ ├── Persistence/
│ │ ├── KidsLearnDbContext.cs
│ │ ├── Configurations/ ← EF Core entity type configurations
│ │ ├── Repositories/ ← Repository implementations
│ │ └── Migrations/
│ ├── AI/
│ │ └── GeminiContentGenerator.cs
│ ├── Services/
│ │ ├── EmailService.cs
│ │ └── CacheService.cs
│ └── DependencyInjection.cs ← AddInfrastructure() extension method
└── KidsLearn.Api/
├── KidsLearn.Api.csproj
├── Program.cs
├── Endpoints/
├── Middleware/
└── appsettings.json
Now let’s verify the dependency chain. Open each .csproj file and check the <ProjectReference> elements:
KidsLearn.Domain.csproj — zero references:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
</Project>
KidsLearn.Application.csproj — references only Domain:
<ItemGroup>
<ProjectReference Include="..\KidsLearn.Domain\KidsLearn.Domain.csproj" />
</ItemGroup>
KidsLearn.Infrastructure.csproj — references Application (gets Domain transitively):
<ItemGroup>
<ProjectReference Include="..\KidsLearn.Application\KidsLearn.Application.csproj" />
</ItemGroup>
KidsLearn.Api.csproj — references Infrastructure (gets everything transitively):
<ItemGroup>
<ProjectReference Include="..\KidsLearn.Infrastructure\KidsLearn.Infrastructure.csproj" />
</ItemGroup>
The dependency chain is strictly linear and inward-pointing: Api → Infrastructure → Application → Domain. No cycles. No shortcuts. No “just this once let’s reference Infrastructure from Domain.”
The DI Wiring in Program.cs
The Api project — being the composition root — is where all the dependency injection happens. Each layer exposes an extension method that registers its own services:
// KidsLearn.Api/Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddApplication() // MediatR, FluentValidation, pipeline behaviors
.AddInfrastructure(builder.Configuration); // EF Core, repositories, AI client
var app = builder.Build();
app.MapQuizEndpoints();
app.MapStudentEndpoints();
app.MapLearningPathEndpoints();
app.Run();
// KidsLearn.Application/DependencyInjection.cs
namespace KidsLearn.Application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly);
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
return services;
}
}
// KidsLearn.Infrastructure/DependencyInjection.cs
namespace KidsLearn.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddDbContext<KidsLearnDbContext>(options =>
options.UseNpgsql(
configuration.GetConnectionString("KidsLearn"),
npgsql => npgsql.UseVector()));
services.AddScoped<IUnitOfWork>(sp =>
sp.GetRequiredService<KidsLearnDbContext>());
services.AddScoped<IQuizRepository, QuizRepository>();
services.AddScoped<IStudentRepository, StudentRepository>();
services.AddScoped<IContentGenerator, GeminiContentGenerator>();
return services;
}
}
This is the Dependency Inversion Principle at work. The Application layer defines IContentGenerator. The Infrastructure layer provides GeminiContentGenerator. The Api layer wires them together. At no point does Application know about Gemini. At no point does Domain know about any of this.
C# 14 Features That Matter for Clean Architecture
.NET 10 ships with C# 14, and several new features directly impact how we build Clean Architecture solutions. Here are the ones that changed how I write the Kids Learn codebase.
The field Keyword for Property Validation
Before C# 14, if you wanted to validate a property setter in a domain entity, you had two choices: use a full backing field (verbose) or skip validation (dangerous). The new field keyword gives us a third option.
Before (C# 13 and earlier):
public class Student
{
private string _name = string.Empty;
public string Name
{
get => _name;
private set
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
if (value.Length > 200)
throw new DomainValidationException("Student name cannot exceed 200 characters");
_name = value;
}
}
}
After (C# 14 with field keyword):
public class Student
{
public string Name
{
get;
private set
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
if (value.Length > 200)
throw new DomainValidationException("Student name cannot exceed 200 characters");
field = value;
}
}
}
The difference looks small, but multiply it across 30 entities with 5-10 validated properties each. The field keyword eliminates hundreds of lines of boilerplate backing fields. More importantly, it makes the intent clearer — you see field = value and immediately know “this property has custom validation.” No hunting for the matching _name field.
This matters for Clean Architecture because Domain entities are where property validation lives. Every entity has invariants that need enforcement. C# 14 makes those invariants cheaper to write and easier to read.
Extension Members
C# 14 introduces extension members — a more powerful evolution of the extension methods we’ve been using since C# 3. Now you can define extension properties, static methods, and indexers.
Why does this matter for Clean Architecture? One of the tensions in a Clean Architecture codebase is where to put conversion logic. Your Domain has a Score value object. Your Application needs to convert it to a DTO. Where does the mapping live?
Before: You either pollute the Domain entity with ToDto() methods (bad — Domain shouldn’t know about DTOs) or write static mapper classes (verbose).
After (C# 14 extension members):
// KidsLearn.Application/Common/Mappings/ScoreExtensions.cs
namespace KidsLearn.Application.Common.Mappings;
public implicit extension ScoreMappings for Score
{
public ScoreDto ToDto() => new(Value, Percentage, Grade.ToString());
public static Score FromCommand(int rawScore, int maxScore)
=> Score.Create(rawScore, maxScore);
}
The extension feels like a natural method on Score, but it’s defined in the Application layer. The Domain entity stays pure. The mapping logic lives where it belongs — in the layer that needs it. No third-party mapping library required.
Native AOT and Architecture Choices
.NET 10 improves Native AOT (Ahead-of-Time compilation) support significantly, and this has real implications for Clean Architecture.
The traditional Clean Architecture stack relies heavily on reflection: MediatR uses reflection to discover handlers, EF Core uses it for model building, FluentValidation uses it for validator discovery, and dependency injection uses it for service resolution. Native AOT eliminates the JIT compiler, which means reflection-heavy patterns either don’t work or require source generators.
For Kids Learn, we had a choice: target Native AOT from the start, or stick with JIT and optimize later. We chose JIT for now, but we’re structuring the code to be AOT-ready:
// Instead of assembly scanning (reflection-heavy):
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
// AOT-ready alternative with source generators:
[RegisterMediatR] // Source generator produces the registrations at compile time
public static partial class MediatRRegistration;
The architectural lesson: if you’re building a new .NET 10 project and Native AOT is a requirement (serverless, CLI tools, microservices with fast cold-start), favor source generators over reflection from day one. If AOT is a nice-to-have for later, structure your code so the switch from reflection to source generators is a one-layer change in Infrastructure and Api, not a rewrite of every handler.
This is another benefit of Clean Architecture. The reflection happens in Infrastructure and Api (the outer layers). Domain and Application are already AOT-friendly because they don’t use reflection. When the time comes to switch, you only change the outer layers.
The Mistake: Layers as Folders
I need to address the most common “Clean Architecture” pattern I see in .NET codebases, because I made this mistake myself on two previous projects.
The pattern looks like this:
KidsLearn/ ← Single project
├── KidsLearn.csproj ← One csproj with ALL NuGet packages
├── Domain/
│ ├── Entities/
│ └── ValueObjects/
├── Application/
│ ├── Commands/
│ └── Queries/
├── Infrastructure/
│ ├── Persistence/
│ └── Services/
└── Api/
├── Controllers/
└── Middleware/
Looks organized. Has all the right names. Is absolutely not Clean Architecture.
Why? Because there’s one .csproj file. One assembly. One set of NuGet references. The Domain folder can import Microsoft.EntityFrameworkCore. The Application folder can reference HttpContext. Nothing prevents a developer from writing using KidsLearn.Infrastructure.Persistence; inside a file in the Domain/ folder.
The entire point of the Dependency Rule is that it’s enforced. Not by code reviews. Not by naming conventions. Not by a “please don’t do this” comment in the README. By the compiler. If Domain is a separate project that doesn’t reference Infrastructure, then importing Infrastructure types from Domain code is a build error. The CI pipeline catches it. The PR can’t merge.
I’ve seen the folders-not-projects approach defended with “but it’s simpler” and “we’re a small team, we’ll be disciplined.” I’ve been on those small, disciplined teams. The discipline lasts about three sprints. Then there’s a deadline, and someone adds a quick shortcut, and nobody catches it in review, and six months later your “Clean Architecture” is the same big ball of mud with nicer folder names.
Separate projects have a cost — more .csproj files, more configuration, slightly longer build times. That cost is worth it. The cost of an unenforced architecture is always higher.
There’s a middle ground for genuinely tiny projects: use folders but add an architecture test that enforces the dependency rule at test time. Libraries like NetArchTest or ArchUnitNET can verify that types in the Domain namespace don’t reference types in Infrastructure:
[Fact]
public void Domain_Should_Not_Depend_On_Infrastructure()
{
var result = Types.InAssembly(typeof(Student).Assembly)
.That()
.ResideInNamespace("KidsLearn.Domain")
.ShouldNot()
.HaveDependencyOn("KidsLearn.Infrastructure")
.GetResult();
result.IsSuccessful.Should().BeTrue();
}
But honestly? If your project is complex enough to need Clean Architecture, it’s complex enough to justify separate projects. Kids Learn has over 50 entities, 80+ commands and queries, integrations with three external services, and two different database engines (PostgreSQL relational + pgvector). Folders would not have held.
But Wait, Don’t I Have Too Many Projects?
A question I get every time I present this structure: “Four projects for one API? Isn’t that over-engineering?”
Here’s how I think about it. If you’re building a TODO app or a simple CRUD API, yes — four projects is overkill. Use a single project with folders. Ship it. Move on.
But if you’re building something that will grow — something with complex business rules, multiple integration points, a team that will expand — the cost of separate projects is paid once, and the benefit compounds over time:
- Enforced boundaries prevent the codebase from degrading.
- Independent testability — you can unit test Domain and Application without any infrastructure. No database. No HTTP. Just plain C# objects.
- Replaceable infrastructure — we switched from Azure Cognitive Services to Gemini for content generation. The change touched exactly one class in Infrastructure and one line in the DI configuration. Zero changes in Domain or Application.
- Onboarding speed — new developers can read the Application layer to understand what the system does without wading through database queries and HTTP client configuration.
- Parallel development — one developer works on Domain entities while another implements Infrastructure repositories. No merge conflicts because they’re in different projects.
For Kids Learn, the four-project structure paid for itself in the second month. When we added the teacher portal, it was a new set of commands and queries in Application, a couple new endpoints in Api, and some additional repository methods in Infrastructure. The Domain layer didn’t change at all because the business rules around quizzes and learning paths were the same regardless of whether a teacher or a parent initiated the action.
What About the Shared Kernel?
One more thing before we close out Part 1. You’ll sometimes see a fifth project called SharedKernel or Common that contains base classes like Entity<TId>, AggregateRoot<TId>, ValueObject, and IDomainEvent. These are abstractions shared across projects.
For Kids Learn, I put these in Domain:
// KidsLearn.Domain/Common/Entity.cs
namespace KidsLearn.Domain.Common;
public abstract class Entity<TId> : IEquatable<Entity<TId>>
where TId : notnull
{
public TId Id { get; protected init; } = default!;
public override bool Equals(object? obj) =>
obj is Entity<TId> entity && Id.Equals(entity.Id);
public bool Equals(Entity<TId>? other) =>
other is not null && Id.Equals(other.Id);
public override int GetHashCode() => Id.GetHashCode();
public static bool operator ==(Entity<TId>? left, Entity<TId>? right) =>
Equals(left, right);
public static bool operator !=(Entity<TId>? left, Entity<TId>? right) =>
!Equals(left, right);
}
// KidsLearn.Domain/Common/AggregateRoot.cs
namespace KidsLearn.Domain.Common;
public abstract class AggregateRoot<TId> : Entity<TId>
where TId : notnull
{
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyCollection<IDomainEvent> DomainEvents =>
_domainEvents.AsReadOnly();
protected void RaiseDomainEvent(IDomainEvent domainEvent) =>
_domainEvents.Add(domainEvent);
public void ClearDomainEvents() => _domainEvents.Clear();
}
If you have multiple bounded contexts that each become their own solution, then a SharedKernel NuGet package makes sense. For a single solution like Kids Learn, keeping base classes in Domain is simpler and avoids the overhead of managing an internal NuGet feed.
What’s Next
We’ve covered the “why” and the “what” of Clean Architecture in .NET 10. We understand the Dependency Rule, we’ve set up the solution structure, and we know why separate projects matter more than separate folders.
But we haven’t built anything real yet. The solution structure is scaffolding. The architecture is only as good as the code inside it.
In Part 2, we’ll build the Domain layer for Kids Learn — rich entities that enforce business rules, value objects that make illegal states unrepresentable, and domain events that decouple our system. We’ll model the Student, Quiz, LearningPath, and CurriculumStandard aggregates with proper invariant protection, and we’ll use C# 14’s field keyword and strongly-typed IDs throughout.
If you’ve been copy-pasting Clean Architecture templates from GitHub — and I say this as someone who did exactly that for years — Part 2 is where you’ll start to see why the Domain layer is the heart of the whole approach. Templates give you the skeleton. Understanding gives you the muscle.
This is Part 1 of a 7-part series on Clean Architecture in .NET 10. The series follows the development of Kids Learn, an AI-powered adaptive learning platform. Read the companion post on building the Kids Learn SaaS for the full product story.
Series outline:
- Foundations — The Dependency Rule, solution structure, C# 14 features (this post)
- Domain Layer — Entities, value objects, domain events, aggregates
- Application Layer — CQRS with MediatR, validation, pipeline behaviors
- Infrastructure Layer — EF Core 10, PostgreSQL, repository pattern, Gemini AI integration
- Presentation Layer — Minimal APIs, middleware, error handling, OpenAPI
- Testing — Unit tests, integration tests, architecture tests, test containers
- Production Patterns — Performance, observability, deployment, and lessons learned