Skip to content
Rehan Akbar
Rehan Akbar
All articles
September 8, 2026·23 min
  • #dotnet
  • #csharp
  • #clean-architecture
  • #aspnetcore
  • #software-architecture
  • #domain-driven-design

Clean Architecture in .NET 10: Beyond Folder Conventions to Strict Dependency Direction

A comprehensive, production-grade guide to Clean Architecture in .NET 10 applied to an enterprise Course Management System. Learn the true mechanics of Dependency Inversion, Ports & Adapters, Rich Domain Models, and automated NetArchTest enforcement in CI/CD.

·
23 min read
·
··· reads
On this page

Creating four folders named Api, Application, Domain, and Infrastructure does not make an application Clean Architecture.

Far too many engineering teams building educational platforms and LMS microservices organize their solutions into these neat directories, only to let Entity Framework Core DbContexts leak into domain entities, have use-case handlers execute raw SQL strings directly against infrastructure SDKs, and let HTTP controllers make core course enrollment and capacity decisions.

The Golden Dependency Axiom

The defining characteristic of Clean Architecture is not the folder hierarchy—it is the strict direction of source-code dependencies. Source-code dependencies must point strictly inward toward higher-level business policies.

Clean Architecture Concentric Dependency Rings in .NET 10

Representative Architecture Refactor (Illustrative Case Study)Zero Inward Leaks
BeforeTightly Coupled (Direct DB / ORM in Controllers)
OptimizedStrict Inward Boundaries (Enforced via NetArchTest)

Illustrative architectural benchmark: In an enterprise LMS refactoring, replacing direct infrastructure couplings and in-memory DB test harnesses with isolated domain aggregates and pure unit tests eliminates boundary violations while executing test suites in milliseconds.


The Core Misconception: Folders vs. The Dependency Rule

The folder and project separation is simply an organizational tool to make architectural boundaries visible and enforceable at compile time. The architecture itself is defined entirely by the Dependency Rule:

Source-code dependencies must point strictly inward, toward higher-level business policies.

An inner circle must know absolutely nothing about outer circles. No name, class, variable, function, or data format declared in an outer circle may be mentioned by an inner circle.

Compile-Time Dependencies vs. Runtime Execution Flow
1. Presentation (API)Outer Adapter

Receives HTTP request, binds DTOs, executes pipeline.

2. Application (Use Cases)Inward Flow

Orchestrates the use case and calls port interfaces.

3. Infrastructure (Adapters)Port Implementation

Implements the port: loads and persists the aggregate via EF Core.

4. Domain (Core Rules)Pure Business

Encapsulates course invariants, enrollment limits, and events.

Why Infrastructure sits between Application and Domain here

This is the runtime call sequence, not the compile-time reference direction. Application calls ICourseRepository (a port it owns); EduCore.Infrastructure is the concrete adapter that actually executes, fetching or saving the Course aggregate. The source-code dependency still points the opposite way — Infrastructure references Application to implement its interface, never the reverse. See the Dependency Matrix below for the compile-time picture.

Source-Code Dependency Matrix

In a production .NET 10 solution, compile-time project references follow this strict dependency contract:

Scroll horizontally to see all columns.

Project / LayerAllowed Project ReferencesForbidden DependenciesPrimary Responsibility
EduCore.DomainNone (Zero dependencies)Infrastructure, Api, EF Core, SQL, Redis, HTTP, AWS/Azure SDKsPure course entities, value objects, domain events, business invariants
EduCore.ApplicationEduCore.DomainEduCore.Infrastructure, EduCore.Api, concrete database providers, messaging SDKsUse-case orchestration, CQRS commands/queries, port abstractions (interfaces)
EduCore.InfrastructureEduCore.Application, EduCore.DomainEduCore.ApiConcrete technical adapters: EF Core, Dapper, Redis, RabbitMQ, SendGrid
EduCore.ApiEduCore.Application, EduCore.Infrastructure (Composition Root only)Core domain logic, direct database queriesHTTP transport, middleware, authentication, DI bootstrap
Why API References Infrastructure

The EduCore.Api project references EduCore.Infrastructure solely at the Composition Root (Program.cs) to register concrete implementations into the IServiceCollection container. Controllers and Minimal API endpoints must never directly import or invoke concrete infrastructure classes.


Modern .NET 10 Solution Anatomy

Here is the exact production-ready directory and project layout for an enterprise .NET 10 Course Management solution using C# 14, modern C# idiom (primary constructors, record structs, collection expressions), Minimal APIs, and automated architecture tests:

EduCore .NET 10 Solution Layout
.NET 10 Solution
EduCore.sln.NET 10
src/
EduCore.Domain/Core (Zero Dependencies)
Common/
Entity.cs
AggregateRoot.cs
IHasDomainEvents.cs
IDomainEvent.cs
ValueObject.cs
Result.cs
Error.cs
Courses/
Course.cs
Module.cs
Lesson.cs
Enrollment.cs
CourseId.cs
InstructorId.cs
StudentId.cs
CourseCode.cs
CourseStatus.cs
EnrollmentLimit.cs
Money.cs
Events/
CourseCreatedDomainEvent.cs
CoursePublishedDomainEvent.cs
StudentEnrolledDomainEvent.cs
CourseCapacityReachedDomainEvent.cs
Exceptions/
DomainException.cs
CourseCapacityExceededException.cs
InvalidCourseStateException.cs
EduCore.Domain.csproj
EduCore.Application/Use Cases & Ports
Abstractions/
Data/ICourseRepository.cs
Data/IUnitOfWork.cs
Notifications/IStudentNotificationService.cs
Services/IDateTimeProvider.cs
Courses/
Commands/EnrollStudent/
EnrollStudentCommand.cs
EnrollStudentCommandHandler.cs
EnrollStudentCommandValidator.cs
EnrollStudentResponse.cs
Commands/CreateCourse/
CreateCourseCommand.cs
CreateCourseCommandHandler.cs
Queries/GetCourseCurriculum/
GetCourseCurriculumQuery.cs
GetCourseCurriculumQueryHandler.cs
CourseCurriculumDto.cs
Behaviors/
ValidationBehavior.cs
LoggingBehavior.cs
DependencyInjection.cs
EduCore.Application.csproj
EduCore.Infrastructure/Technical Adapters
Persistence/
ApplicationDbContext.cs
Configurations/CourseConfiguration.cs
Repositories/CourseRepository.cs
Interceptors/PublishDomainEventsInterceptor.cs
Notifications/
SendGridStudentNotificationService.cs
Outbox/
OutboxMessage.cs
ProcessOutboxMessagesJob.cs
DependencyInjection.cs
EduCore.Infrastructure.csproj
EduCore.Api/Host & Delivery
Endpoints/
CourseEndpoints.cs
Middleware/
GlobalExceptionHandler.cs
Program.cs
appsettings.json
EduCore.Api.csproj
tests/
EduCore.Domain.UnitTests/Unit Tests
CourseEnrollmentTests.cs
EduCore.Application.UnitTests/Unit Tests
EnrollStudentCommandHandlerTests.cs
EduCore.Infrastructure.IntegrationTests/Integration Tests
CourseRepositoryTests.cs
EduCore.ArchitectureTests/CI Gatekeeper
LayerDependencyTests.cs

Deep Dive into the Four Layers

Let's examine the exact code implementation for each layer in modern .NET 10, demonstrating how the Dependency Inversion Principle keeps your core course management business logic completely isolated from third-party frameworks.

1. Domain Layer: Pure Business Rules & Invariants

The Domain layer is the heart of your application. It contains entities, value objects, domain events, and domain exceptions.

Key Rule: The Domain project must have zero NuGet package dependencies on external frameworks (no EF Core, no MediatR, no ASP.NET Core).

csharp
// EduCore.Domain/Courses/CourseId.cs
namespace EduCore.Domain.Courses;
 
public readonly record struct CourseId(Guid Value)
{
    public static CourseId New() => new(Guid.NewGuid());
    public static CourseId Empty => new(Guid.Empty);
}
 
// EduCore.Domain/Courses/InstructorId.cs & StudentId.cs follow the same pattern:
public readonly record struct InstructorId(Guid Value)
{
    public static InstructorId New() => new(Guid.NewGuid());
    public static InstructorId Empty => new(Guid.Empty);
}
 
public readonly record struct StudentId(Guid Value)
{
    public static StudentId New() => new(Guid.NewGuid());
    public static StudentId Empty => new(Guid.Empty);
}
csharp
// EduCore.Domain/Common/IHasDomainEvents.cs & AggregateRoot.cs
namespace EduCore.Domain.Common;
 
public interface IHasDomainEvents
{
    IReadOnlyList<IDomainEvent> DomainEvents { get; }
    void ClearDomainEvents();
}
 
public abstract class AggregateRoot<TId>(TId id) : Entity<TId>(id), IHasDomainEvents
{
    private readonly List<IDomainEvent> _domainEvents = [];
 
    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
 
    protected void RaiseDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);
 
    public void ClearDomainEvents() => _domainEvents.Clear();
}
csharp
// EduCore.Domain/Common/Result.cs & Error.cs
namespace EduCore.Domain.Common;
 
public readonly record struct Error(string Code, string Message)
{
    public static readonly Error None = new(string.Empty, string.Empty);
}
 
public class Result
{
    protected Result(bool isSuccess, Error error)
    {
        IsSuccess = isSuccess;
        Error = error;
    }
 
    public bool IsSuccess { get; }
    public bool IsFailure => !IsSuccess;
    public Error Error { get; }
 
    public static Result Success() => new(true, Error.None);
    public static Result Failure(Error error) => new(false, error);
    public static Result<TValue> Success<TValue>(TValue value) => new(value, true, Error.None);
    public static Result<TValue> Failure<TValue>(Error error) => new(default, false, error);
}
 
public sealed class Result<TValue> : Result
{
    private readonly TValue? _value;
 
    internal Result(TValue? value, bool isSuccess, Error error) : base(isSuccess, error)
    {
        _value = value;
    }
 
    public TValue Value => IsSuccess
        ? _value!
        : throw new InvalidOperationException("Cannot access value of a failed result.");
 
    public static implicit operator Result<TValue>(TValue value) => Success(value);
}
csharp
// EduCore.Domain/Courses/CourseCode.cs
namespace EduCore.Domain.Courses;
 
public sealed record CourseCode
{
    public string Value { get; }
 
    private CourseCode(string value) => Value = value;
 
    public static CourseCode Create(string code)
    {
        if (string.IsNullOrWhiteSpace(code))
            throw new ArgumentException("Course code cannot be empty.", nameof(code));
 
        var normalized = code.Trim().ToUpperInvariant();
        if (normalized.Length < 3 || normalized.Length > 10)
            throw new ArgumentException("Course code must be between 3 and 10 characters.", nameof(code));
 
        return new CourseCode(normalized);
    }
}
csharp
// EduCore.Domain/Courses/EnrollmentLimit.cs
namespace EduCore.Domain.Courses;
 
public sealed record EnrollmentLimit
{
    public int Value { get; }
 
    private EnrollmentLimit(int value) => Value = value;
 
    public static EnrollmentLimit Create(int capacity)
    {
        if (capacity is < 1 or > 500)
            throw new ArgumentOutOfRangeException(nameof(capacity), "Course capacity must be between 1 and 500 students.");
 
        return new EnrollmentLimit(capacity);
    }
}
csharp
// EduCore.Domain/Courses/Course.cs
using EduCore.Domain.Common;
using EduCore.Domain.Events;
using EduCore.Domain.Exceptions;
 
namespace EduCore.Domain.Courses;
 
public sealed class Course : AggregateRoot<CourseId>
{
    private readonly List<Module> _modules = [];
    private readonly List<Enrollment> _enrollments = [];
 
    public string Title { get; private set; } = string.Empty;
    public CourseCode Code { get; private set; } = null!;
    public InstructorId InstructorId { get; private set; }
    public CourseStatus Status { get; private set; }
    public EnrollmentLimit Capacity { get; private set; } = null!;
    public Money TuitionFee { get; private set; } = null!;
 
    public IReadOnlyCollection<Module> Modules => _modules.AsReadOnly();
    public IReadOnlyCollection<Enrollment> Enrollments => _enrollments.AsReadOnly();
 
    // Private constructor ensures construction occurs through valid factory methods
    private Course(
        CourseId id,
        string title,
        CourseCode code,
        InstructorId instructorId,
        EnrollmentLimit capacity,
        Money tuitionFee) : base(id)
    {
        Title = title;
        Code = code;
        InstructorId = instructorId;
        Capacity = capacity;
        TuitionFee = tuitionFee;
        Status = CourseStatus.Draft;
    }
 
    // EF Core private constructor for ORM materialization
    private Course() : base(CourseId.Empty) { }
 
    public static Course Create(
        string title,
        CourseCode code,
        InstructorId instructorId,
        EnrollmentLimit capacity,
        Money tuitionFee)
    {
        if (string.IsNullOrWhiteSpace(title))
            throw new DomainException("Course title cannot be blank.");
 
        if (instructorId == InstructorId.Empty)
            throw new DomainException("Course must have an assigned instructor.");
 
        var course = new Course(CourseId.New(), title, code, instructorId, capacity, tuitionFee);
        course.RaiseDomainEvent(new CourseCreatedDomainEvent(course.Id, instructorId, course.Code.Value));
        return course;
    }
 
    public void AddModule(string moduleTitle)
    {
        if (Status == CourseStatus.Archived)
            throw new InvalidCourseStateException("Cannot add modules to an archived course.");
 
        var module = new Module(Guid.NewGuid(), moduleTitle, _modules.Count + 1);
        _modules.Add(module);
    }
 
    public void Publish()
    {
        if (_modules.Count == 0)
            throw new DomainException("A course cannot be published without at least one curriculum module.");
 
        if (Status == CourseStatus.Published)
            throw new InvalidCourseStateException("Course is already published.");
 
        Status = CourseStatus.Published;
        RaiseDomainEvent(new CoursePublishedDomainEvent(Id, DateTime.UtcNow));
    }
 
    public void EnrollStudent(StudentId studentId, DateTime enrolledAt)
    {
        if (Status != CourseStatus.Published)
            throw new InvalidCourseStateException("Students can only enroll in published courses.");
 
        if (_enrollments.Count >= Capacity.Value)
            throw new CourseCapacityExceededException($"Course {Code.Value} has reached its maximum capacity of {Capacity.Value} students.");
 
        if (_enrollments.Any(e => e.StudentId == studentId && e.IsActive))
            throw new DomainException($"Student {studentId.Value} is already enrolled in this course.");
 
        var enrollment = new Enrollment(Guid.NewGuid(), studentId, enrolledAt);
        _enrollments.Add(enrollment);
 
        RaiseDomainEvent(new StudentEnrolledDomainEvent(Id, studentId, enrolledAt, TuitionFee));
 
        if (_enrollments.Count == Capacity.Value)
        {
            RaiseDomainEvent(new CourseCapacityReachedDomainEvent(Id, _enrollments.Count));
        }
    }
}
Why Domain Events Belong on the Aggregate

Notice how Course.EnrollStudent() raises a StudentEnrolledDomainEvent. The aggregate maintains an internal collection of domain events raised during state transitions. When the unit of work commits in Infrastructure, an interceptor dispatches these events to application handlers. This keeps the domain decoupled from any event dispatcher interface or email provider.

Production Trap: The Dual-Write Problem

Crucial Real-World Extension: What happens if the database transaction commits, but your event publisher or message broker crashes immediately afterwards? Or what if publishing succeeds, but the database rolls back?

In production distributed systems, never publish cross-service integration events synchronously after SaveChangesAsync(). Instead, decouple them using the Transactional Outbox Pattern with non-blocking row claiming and idempotent consumers.

👉 Read the companion architecture deep dive: The Dual-Write Trap: Production-Grade Transactional Outbox and Idempotent Consumers in .NET 10.


2. Application Layer: Use Cases & Ports

The Application layer coordinates workflows. It handles commands and queries (CQRS), runs validation pipelines, and interacts with domain entities and external abstractions.

Dependency Inversion: Interface Ports vs Infrastructure Adapters

Defining the Ports (Interfaces)

The Application layer owns the contract interfaces because the use cases define what capabilities they need, not how technical drivers implement them.

csharp
// EduCore.Application/Abstractions/Data/ICourseRepository.cs
using EduCore.Domain.Courses;
 
namespace EduCore.Application.Abstractions.Data;
 
public interface ICourseRepository
{
    Task<Course?> GetByIdAsync(CourseId id, CancellationToken cancellationToken = default);
    Task AddAsync(Course course, CancellationToken cancellationToken = default);
    void Update(Course course);
}
csharp
// EduCore.Application/Abstractions/Data/IUnitOfWork.cs
namespace EduCore.Application.Abstractions.Data;
 
public interface IUnitOfWork
{
    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

Implementing the Use-Case Command Handler

In .NET 10, we use Primary Constructors and C# Record Types for clean, readable handler definitions:

csharp
// EduCore.Application/Courses/Commands/EnrollStudent/EnrollStudentCommand.cs
using MediatR;
using EduCore.Domain.Common;
 
namespace EduCore.Application.Courses.Commands.EnrollStudent;
 
public sealed record EnrollStudentCommand(
    Guid CourseId,
    Guid StudentId,
    string StudentEmail
) : IRequest<Result<EnrollStudentResponse>>;
 
public sealed record EnrollStudentResponse(
    Guid CourseId,
    Guid StudentId,
    DateTime EnrolledAt
);
csharp
// EduCore.Application/Courses/Commands/EnrollStudent/EnrollStudentCommandHandler.cs
using MediatR;
using EduCore.Application.Abstractions.Data;
using EduCore.Application.Abstractions.Services;
using EduCore.Domain.Common;
using EduCore.Domain.Courses;
 
namespace EduCore.Application.Courses.Commands.EnrollStudent;
 
public sealed class EnrollStudentCommandHandler(
    ICourseRepository courseRepository,
    IUnitOfWork unitOfWork,
    IDateTimeProvider dateTimeProvider
) : IRequestHandler<EnrollStudentCommand, Result<EnrollStudentResponse>>
{
    public async Task<Result<EnrollStudentResponse>> Handle(
        EnrollStudentCommand request,
        CancellationToken cancellationToken)
    {
        var courseId = new CourseId(request.CourseId);
        var course = await courseRepository.GetByIdAsync(courseId, cancellationToken);
 
        if (course is null)
        {
            return Result.Failure<EnrollStudentResponse>(
                new Error("Course.NotFound", $"Course with ID {request.CourseId} was not found.")
            );
        }
 
        var studentId = new StudentId(request.StudentId);
        var enrolledAt = dateTimeProvider.UtcNow;
 
        try
        {
            course.EnrollStudent(studentId, enrolledAt);
        }
        catch (DomainException ex)
        {
            return Result.Failure<EnrollStudentResponse>(new Error("Course.EnrollmentFailed", ex.Message));
        }
 
        courseRepository.Update(course);
        await unitOfWork.SaveChangesAsync(cancellationToken);
 
        return new EnrollStudentResponse(course.Id.Value, studentId.Value, enrolledAt);
    }
}

3. Infrastructure Layer: Concrete Technical Adapters

The Infrastructure layer contains all implementation details: database contexts, SQL mapping, ORMs, cloud storage, Redis caching, and messaging queues.

EF Core 10 Entity Type Configuration

Notice how EF Core configures private backing fields (_modules, _enrollments), strongly-typed IDs, and complex value objects without polluting the Domain layer with EF attributes:

csharp
// EduCore.Infrastructure/Persistence/Configurations/CourseConfiguration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using EduCore.Domain.Courses;
 
namespace EduCore.Infrastructure.Persistence.Configurations;
 
public sealed class CourseConfiguration : IEntityTypeConfiguration<Course>
{
    public void Configure(EntityTypeBuilder<Course> builder)
    {
        builder.ToTable("Courses");
 
        builder.HasKey(c => c.Id);
 
        builder.Property(c => c.Id)
            .HasConversion(id => id.Value, value => new CourseId(value));
 
        builder.Property(c => c.Title)
            .HasMaxLength(200)
            .IsRequired();
 
        builder.ComplexProperty(c => c.Code, codeBuilder =>
        {
            codeBuilder.Property(c => c.Value)
                .HasColumnName("Code")
                .HasMaxLength(15)
                .IsRequired();
        });
 
        builder.ComplexProperty(c => c.Capacity, capacityBuilder =>
        {
            capacityBuilder.Property(c => c.Value)
                .HasColumnName("MaxCapacity")
                .IsRequired();
        });
 
        builder.Property(c => c.Status)
            .HasConversion<string>()
            .HasMaxLength(30)
            .IsRequired();
 
        // Map private collection backing field for curriculum modules
        builder.HasMany(c => c.Modules)
            .WithOne()
            .HasForeignKey("CourseId")
            .IsRequired()
            .OnDelete(DeleteBehavior.Cascade);
 
        builder.Navigation(c => c.Modules)
            .UsePropertyAccessMode(PropertyAccessMode.Field);
 
        // Map private collection backing field for enrollments
        builder.HasMany(c => c.Enrollments)
            .WithOne()
            .HasForeignKey("CourseId")
            .IsRequired()
            .OnDelete(DeleteBehavior.Cascade);
 
        builder.Navigation(c => c.Enrollments)
            .UsePropertyAccessMode(PropertyAccessMode.Field);
    }
}

EF Core 10 Repository Adapter

csharp
// EduCore.Infrastructure/Persistence/Repositories/CourseRepository.cs
using Microsoft.EntityFrameworkCore;
using EduCore.Application.Abstractions.Data;
using EduCore.Domain.Courses;
 
namespace EduCore.Infrastructure.Persistence.Repositories;
 
public sealed class CourseRepository(ApplicationDbContext dbContext) : ICourseRepository
{
    public async Task<Course?> GetByIdAsync(CourseId id, CancellationToken cancellationToken = default)
    {
        return await dbContext.Courses
            .Include(c => c.Modules)
            .Include(c => c.Enrollments)
            .FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
    }
 
    public async Task AddAsync(Course course, CancellationToken cancellationToken = default)
    {
        await dbContext.Courses.AddAsync(course, cancellationToken);
    }
 
    public void Update(Course course)
    {
        dbContext.Courses.Update(course);
    }
}

Transactional Outbox: Writing Domain Events Before They're Dispatched

Why not just call IPublisher from the interceptor

A common shortcut is calling IPublisher.Publish directly inside SavingChangesAsync. That dispatches events before the transaction actually commits — if SaveChangesAsync then fails (a concurrency conflict, a constraint violation), subscribers have already reacted to a state change that never persisted. The transactional outbox pattern fixes this: events are written as rows in the same database transaction as the aggregate, so they only exist if the save actually succeeded. A separate background job then delivers them.

csharp
// EduCore.Infrastructure/Persistence/Outbox/OutboxMessage.cs
namespace EduCore.Infrastructure.Persistence.Outbox;
 
public sealed class OutboxMessage
{
    public Guid Id { get; init; }
    public string Type { get; init; } = string.Empty;
    public string Content { get; init; } = string.Empty;
    public DateTime OccurredOnUtc { get; init; }
    public DateTime? ProcessedOnUtc { get; set; }
    public string? Error { get; set; }
}
csharp
// EduCore.Infrastructure/Persistence/Interceptors/PublishDomainEventsInterceptor.cs
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using EduCore.Domain.Common;
using EduCore.Infrastructure.Persistence.Outbox;
 
namespace EduCore.Infrastructure.Persistence.Interceptors;
 
public sealed class PublishDomainEventsInterceptor : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        if (eventData.Context is ApplicationDbContext context)
        {
            AppendDomainEventsAsOutboxMessages(context);
        }
 
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }
 
    private static void AppendDomainEventsAsOutboxMessages(ApplicationDbContext context)
    {
        var outboxMessages = context.ChangeTracker
            .Entries<IHasDomainEvents>()
            .Select(e => e.Entity)
            .SelectMany(aggregate =>
            {
                var domainEvents = aggregate.DomainEvents;
                aggregate.ClearDomainEvents();
                return domainEvents;
            })
            .Select(domainEvent => new OutboxMessage
            {
                Id = Guid.NewGuid(),
                Type = domainEvent.GetType().Name,
                Content = JsonSerializer.Serialize(domainEvent, domainEvent.GetType()),
                OccurredOnUtc = DateTime.UtcNow,
            })
            .ToList();
 
        context.Set<OutboxMessage>().AddRange(outboxMessages);
    }
}

Because this runs inside SavingChangesAsync, the OutboxMessage rows are added to the same DbContext.ChangeTracker as the aggregate being saved — they commit in the same transaction, or not at all. Nothing is dispatched yet; the events just become durable.

csharp
// EduCore.Infrastructure/Persistence/Outbox/ProcessOutboxMessagesJob.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using EduCore.Domain.Common;
 
namespace EduCore.Infrastructure.Persistence.Outbox;
 
public sealed class ProcessOutboxMessagesJob(ApplicationDbContext dbContext, IPublisher publisher)
{
    public async Task ExecuteAsync(CancellationToken cancellationToken)
    {
        var messages = await dbContext.Set<OutboxMessage>()
            .Where(m => m.ProcessedOnUtc is null)
            .OrderBy(m => m.OccurredOnUtc)
            .Take(20)
            .ToListAsync(cancellationToken);
 
        foreach (var message in messages)
        {
            try
            {
                var eventType = Type.GetType(message.Type)
                    ?? throw new InvalidOperationException($"Unknown event type: {message.Type}");
 
                var domainEvent = (IDomainEvent)JsonSerializer.Deserialize(message.Content, eventType)!;
                await publisher.Publish(domainEvent, cancellationToken);
            }
            catch (Exception ex)
            {
                message.Error = ex.Message;
            }
 
            message.ProcessedOnUtc = DateTime.UtcNow;
        }
 
        await dbContext.SaveChangesAsync(cancellationToken);
    }
}

Run ProcessOutboxMessagesJob on a recurring background schedule (a BackgroundService, Quartz, or Hangfire). Only after the aggregate's transaction has committed does the job pick up and publish the event — so a StudentEnrolledDomainEvent handler (e.g. sending a confirmation email) never fires for an enrollment that didn't actually save.


4. Presentation / API Layer: Transport & Composition Root

The API layer translates HTTP requests into Application commands/queries, returning RFC 9457 compliant ProblemDetails or typed responses.

ASP.NET Core Minimal API Route Group

csharp
// EduCore.Api/Endpoints/CourseEndpoints.cs
using MediatR;
using Microsoft.AspNetCore.Http.HttpResults;
using EduCore.Application.Courses.Commands.EnrollStudent;
using EduCore.Application.Courses.Queries.GetCourseCurriculum;
 
namespace EduCore.Api.Endpoints;
 
public static class CourseEndpoints
{
    public static RouteGroupBuilder MapCourseEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/courses").WithTags("Courses");
 
        group.MapPost("/{id:guid}/enroll", EnrollStudentAsync)
            .WithName("EnrollStudent")
            .WithSummary("Enrolls a verified student into a published course cohort.");
 
        group.MapGet("/{id:guid}/curriculum", GetCourseCurriculumAsync)
            .WithName("GetCourseCurriculum")
            .WithSummary("Retrieves the full curriculum module outline.");
 
        return group;
    }
 
    private static async Task<Results<Ok<EnrollStudentResponse>, ValidationProblem, ProblemHttpResult>> EnrollStudentAsync(
        Guid id,
        EnrollStudentRequest request,
        ISender sender,
        CancellationToken cancellationToken)
    {
        var command = new EnrollStudentCommand(id, request.StudentId, request.StudentEmail);
        var result = await sender.Send(command, cancellationToken);
 
        if (result.IsFailure)
        {
            return TypedResults.Problem(
                title: "Student enrollment failed",
                detail: result.Error.Message,
                statusCode: StatusCodes.Status400BadRequest
            );
        }
 
        return TypedResults.Ok(result.Value);
    }
 
    private static async Task<Results<Ok<CourseCurriculumDto>, NotFound>> GetCourseCurriculumAsync(
        Guid id,
        ISender sender,
        CancellationToken cancellationToken)
    {
        var query = new GetCourseCurriculumQuery(id);
        var result = await sender.Send(query, cancellationToken);
 
        return result.IsSuccess
            ? TypedResults.Ok(result.Value)
            : TypedResults.NotFound();
    }
}
 
public sealed record EnrollStudentRequest(Guid StudentId, string StudentEmail);

Composition Root (Program.cs)

csharp
// EduCore.Api/Program.cs
using EduCore.Api.Endpoints;
using EduCore.Api.Middleware;
using EduCore.Application;
using EduCore.Infrastructure;
 
var builder = WebApplication.CreateBuilder(args);
 
// Register application and infrastructure via extension methods
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
 
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
 
var app = builder.Build();
 
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}
 
app.UseExceptionHandler();
app.UseHttpsRedirection();
 
// Map Minimal API endpoint groups
app.MapCourseEndpoints();
 
app.Run();

Executable Architecture Tests: Enforcing Boundaries in CI/CD

Architectural documentation rots over time. New developers unintentionally add forbidden project references, inject DbContext directly into endpoints, or import vendor SDKs into use cases.

To prevent architectural drift, use NetArchTest.Rules to write automated unit tests that run on every pull request.

csharp
// tests/EduCore.ArchitectureTests/LayerDependencyTests.cs
using NetArchTest.Rules;
using Xunit;
 
namespace EduCore.ArchitectureTests;
 
public class LayerDependencyTests
{
    private const string DomainNamespace = "EduCore.Domain";
    private const string ApplicationNamespace = "EduCore.Application";
    private const string InfrastructureNamespace = "EduCore.Infrastructure";
    private const string ApiNamespace = "EduCore.Api";
 
    [Fact]
    public void Domain_Should_Not_HaveDependencyOn_OtherProjects()
    {
        // Arrange
        var assembly = typeof(EduCore.Domain.Courses.Course).Assembly;
 
        var otherProjects = new[]
        {
            ApplicationNamespace,
            InfrastructureNamespace,
            ApiNamespace
        };
 
        // Act
        var result = Types.InAssembly(assembly)
            .Should()
            .NotHaveDependencyOnAny(otherProjects)
            .GetResult();
 
        // Assert
        Assert.True(result.IsSuccessful, "Domain layer must have zero dependencies on outer projects.");
    }
 
    [Fact]
    public void Application_Should_Not_HaveDependencyOn_Infrastructure_Or_Api()
    {
        // Arrange
        var assembly = typeof(EduCore.Application.Courses.Commands.EnrollStudent.EnrollStudentCommand).Assembly;
 
        var forbiddenProjects = new[]
        {
            InfrastructureNamespace,
            ApiNamespace
        };
 
        // Act
        var result = Types.InAssembly(assembly)
            .Should()
            .NotHaveDependencyOnAny(forbiddenProjects)
            .GetResult();
 
        // Assert
        Assert.True(result.IsSuccessful, "Application layer must not depend on Infrastructure or API.");
    }
 
    [Fact]
    public void Infrastructure_Should_Not_HaveDependencyOn_Api()
    {
        // Arrange
        var assembly = typeof(EduCore.Infrastructure.Persistence.ApplicationDbContext).Assembly;
 
        // Act
        var result = Types.InAssembly(assembly)
            .Should()
            .NotHaveDependencyOn(ApiNamespace)
            .GetResult();
 
        // Assert
        Assert.True(result.IsSuccessful, "Infrastructure layer must not depend on the API layer.");
    }
 
    [Fact]
    public void CommandHandlers_Should_Have_NameEndingWith_CommandHandler()
    {
        // Arrange
        var assembly = typeof(EduCore.Application.Courses.Commands.EnrollStudent.EnrollStudentCommand).Assembly;
 
        // Act
        var result = Types.InAssembly(assembly)
            .That()
            .ImplementInterface(typeof(MediatR.IRequestHandler<,>))
            .Should()
            .HaveNameEndingWith("Handler")
            .GetResult();
 
        // Assert
        Assert.True(result.IsSuccessful, "All MediatR handlers must follow the 'Handler' naming convention.");
    }
}
Gatekeeping Your CI/CD Pipeline

Add dotnet test tests/EduCore.ArchitectureTests to your GitHub Actions /Azure DevOps pipeline. If anyone attempts to merge a pull request where EduCore.Domain references Microsoft.EntityFrameworkCore, the build fails immediately.


Clean Architecture vs. Common Anti-Patterns in Educational Platforms

Clean Architecture Best Practices
  • ✓Course aggregates encapsulate enrollment capacity checks & state invariants with private setters.
  • ✓Application use cases define interfaces (ports) based strictly on pedagogical & business requirements.
  • ✓Database providers, email gateways, and LMS SDKs are swappable adapters in Infrastructure.
  • ✓Architecture rules are executable unit tests run continuously in CI/CD via NetArchTest.
  • ✓Domain events (e.g. StudentEnrolled) are raised inside aggregates, written to a transactional outbox during SaveChanges, and dispatched only after the transaction commits.
Critical Architecture Anti-Patterns
  • ✗Anemic Domain Models: Course entity with public get/set properties and zero capacity validation.
  • ✗Leaking IQueryable<Course> or DbContext directly into Application handlers or HTTP controllers.
  • ✗Writing student prerequisite checks inside EF Core interceptors or ASP.NET Core middleware.
  • ✗Calling SendGrid or Canvas LMS APIs directly from inside domain entity methods.
  • ✗Over-engineering a simple read-only course catalog query with 4 layers when Dapper in Application suffices.

When NOT to Use Clean Architecture

Clean Architecture is a powerful tool for complex domain logic, long-lived enterprise applications, and multi-team codebases. However, it is not a silver bullet.

Pragmatic Architectural Guidelines
  • Do use Clean Architecture when: The core business rules (enrollment capacities, tuition payment rules, grading rubrics, multi-stage course approvals) are complex, subject to frequent change, and require thorough unit testing without spun-up databases or mocks.

  • Do not use Clean Architecture when: You are building a simple read-only static course catalog, a thin gateway proxy, or a short-lived proof of concept. In those cases, a Vertical Slice Architecture or simple Minimal API with FastEndpoints delivers faster time-to-market with significantly less boilerplate.


Summary & Key Takeaways

  1. Architecture is Dependency Direction: Four folders do not make an architecture. Compile-time references must strictly point inward toward the Domain.
  2. Ports Belong to the Caller: Application interfaces (ICourseRepository, IStudentNotificationService) are owned by the Core because the use case dictates what it needs.
  3. Keep Domain Untainted: Zero third-party NuGet dependencies in the Domain project. Use EF Core Fluent API in Infrastructure to map private collections and value objects.
  4. Make Boundaries Executable: Don't rely on code reviews alone. Use NetArchTest.Rules to enforce architectural constraints in CI/CD.

Continue Your Architecture Journey

Once you have mastered in-process boundaries and strict dependency direction with Clean Architecture, the next challenge in production systems is distributed consistency:

Recommended Next Deep Dive

From In-Process Boundaries to Distributed Reliability

Domain events decoupled inside your Clean Architecture aggregate need to be published across service boundaries without silent data loss. When database transactions and message brokers collide, uncoordinated dual-writes cause severe state drift.

👉 Read Next: The Dual-Write Trap: Production-Grade Transactional Outbox and Idempotent Consumers in .NET 10 — Learn how to implement atomic outbox persistence with EF Core interceptors, contention-free SKIP LOCKED relays, and idempotent consumer inboxes.


Have questions about structuring enterprise .NET 10 solutions, eliminating architectural debt, or implementing Clean Architecture in your systems? Explore my background and advisory services or connect directly on LinkedIn.

Rehan Akbar

Written by Rehan Akbar

Software Engineer · Singapore · .NET, Cloud & Applied AI

I write about the engineering trade-offs behind reliable production systems—Clean Architecture, distributed resilience, observability, and practical AI engineering. Open for architecture advisory and technical collaborations.

Discussion & Comments

Share your thoughts, ask technical questions, or discuss architecture edge cases.