After four posts of building Kids Learn with Clean Architecture, I have a confession: I hate how spread out each feature is. To understand “Generate Lesson”, I have to open files in four different projects. The command is in Application, the handler is in Application, the validator is in Application, the Gemini integration is in Infrastructure, and the endpoint is in Api. One feature. Four projects. Twelve files.

I spent a Tuesday morning tracing a bug in the lesson generation flow. A parent reported that lessons were being generated without respecting their child’s difficulty level preference. I started in the API endpoint, jumped to the command, jumped to the handler, jumped to the Gemini service, jumped to the domain entity, jumped back to the handler. I had six files open across four projects, scrolling back and forth, trying to hold the entire flow in my head.

That was the moment I started questioning whether we were organizing our code for the architecture’s benefit or for the developers’ benefit.

This is Part 5 of my Clean Architecture with .NET 10 series. In Parts 1-4, we built a solid foundation — Domain layer with rich entities, Application layer with MediatR and FluentValidation, Infrastructure with EF Core and Gemini AI, and the API with minimal endpoints. It all works. The dependency rules are clean. The architecture diagrams look beautiful.

But the developer experience of working on a single feature is painful. Let me show you exactly what I mean, and then show you how we fixed it.

The Feature Scattering Problem

Let me walk you through exactly what happens when I need to modify the “Generate Lesson” feature. This is a real feature in Kids Learn — a parent requests an AI-generated lesson for their child on a specific topic, and the system uses Gemini to create age-appropriate, adaptive content.

Here are the files involved:

KidsLearn.Application/
├── Lessons/
│   ├── Commands/
│   │   └── GenerateLesson/
│   │       ├── GenerateLessonCommand.cs       // The request DTO
│   │       ├── GenerateLessonHandler.cs        // The business logic
│   │       └── GenerateLessonValidator.cs      // Input validation
│   └── DTOs/
│       └── GeneratedLessonDto.cs              // The response DTO

KidsLearn.Infrastructure/
├── Services/
│   └── GeminiLessonGenerator.cs               // Gemini API integration
├── Persistence/
│   ├── Configurations/
│   │   └── LessonConfiguration.cs             // EF Core mapping
│   └── Repositories/
│       └── LessonRepository.cs                // Data access

KidsLearn.Domain/
├── Entities/
│   └── Lesson.cs                              // Domain entity
├── ValueObjects/
│   └── DifficultyLevel.cs                     // Value object
└── Events/
    └── LessonGeneratedEvent.cs                // Domain event

KidsLearn.Api/
├── Endpoints/
│   └── LessonEndpoints.cs                     // HTTP endpoint definition

Count them. Twelve files across four projects. To understand one feature.

Here is the actual journey I take when debugging this feature:

  1. A bug report comes in. I start at LessonEndpoints.cs to see what the API accepts.
  2. I follow the command to GenerateLessonCommand.cs to check the request shape.
  3. I open GenerateLessonValidator.cs to see what validation rules exist.
  4. I jump to GenerateLessonHandler.cs for the orchestration logic.
  5. The handler calls an interface, so I find the implementation in GeminiLessonGenerator.cs.
  6. The handler also creates a domain entity, so I open Lesson.cs.
  7. I check LessonConfiguration.cs to verify the database mapping is correct.
  8. I look at GeneratedLessonDto.cs to confirm the response shape.

Eight files. Four projects. My IDE’s tab bar looks like a phone book.

Now multiply this by every feature in the system. “Get Child Progress” — another twelve files spread across four projects. “Update Mastery Level” — same pattern. “Detect Knowledge Gaps” — same pattern. Every single feature follows this explosion of files across project boundaries.

The classic Clean Architecture argument is: “But the layers are cleanly separated! The dependency rules are enforced!” And that’s true. The architecture is correct. But correctness and productivity are different things.

Here is the real cost I measured over two weeks of development:

  • Context switching: Average of 6 file jumps per feature modification
  • Onboarding: New developers took 2-3 hours to trace their first feature end-to-end
  • Code reviews: Reviewers had to open files in 4 projects to understand a single PR
  • Merge conflicts: Two developers working on different features in the same Lessons/Commands/ folder stepped on each other regularly

The irony is that Clean Architecture is supposed to make systems easier to understand. And at the macro level — understanding how the system is structured — it does. But at the feature level — understanding what a specific feature does — it creates unnecessary friction.

Feature scattering problem — GenerateLesson feature requires touching files in 4 different projects, with arrows showing the jump path a developer takes

Vertical Slice Architecture Explained

Vertical Slice Architecture is the opposite philosophy. Instead of organizing code by technical layer (commands here, handlers there, validators over there), you organize by feature. Each “slice” contains everything needed for one use case, from the API endpoint down to the data access.

Jimmy Bogard introduced this concept years ago, and it resonated with developers who were tired of the ceremony of traditional layered architecture. His core insight was simple: features change together, so features should live together.

Think about it. When was the last time you changed every validator in your system at once? Never. But when was the last time you changed the command, handler, validator, and endpoint for a single feature in one PR? Every single time.

The principle is called the Common Closure Principle: classes that change together should be packaged together. Vertical Slice Architecture takes this seriously.

Here is what pure Vertical Slice Architecture looks like for our “Generate Lesson” feature:

// Features/Lessons/GenerateLesson.cs
// Everything for this feature in ONE file

namespace KidsLearn.Features.Lessons;

public static class GenerateLesson
{
    // The request
    public record Command(
        Guid ChildId,
        string Topic,
        string Subject,
        int TargetDifficultyLevel) : IRequest<Result>;

    // The response
    public record Result(
        Guid LessonId,
        string Title,
        string Content,
        int DifficultyLevel,
        List<string> LearningObjectives);

    // The validation
    public class Validator : AbstractValidator<Command>
    {
        public Validator()
        {
            RuleFor(x => x.ChildId).NotEmpty();
            RuleFor(x => x.Topic).NotEmpty().MaximumLength(200);
            RuleFor(x => x.Subject).NotEmpty().MaximumLength(100);
            RuleFor(x => x.TargetDifficultyLevel)
                .InclusiveBetween(1, 5)
                .WithMessage("Difficulty must be between 1 (easiest) and 5 (hardest)");
        }
    }

    // The handler — contains ALL the logic
    public class Handler : IRequestHandler<Command, Result>
    {
        private readonly AppDbContext _db;
        private readonly IGeminiClient _gemini;
        private readonly ILogger<Handler> _logger;

        public Handler(AppDbContext db, IGeminiClient gemini, ILogger<Handler> logger)
        {
            _db = db;
            _gemini = gemini;
            _logger = logger;
        }

        public async Task<Result> Handle(Command request, CancellationToken ct)
        {
            var child = await _db.Children
                .Include(c => c.ProgressRecords)
                .FirstOrDefaultAsync(c => c.Id == request.ChildId, ct)
                ?? throw new NotFoundException("Child", request.ChildId);

            var prompt = BuildPrompt(child, request);

            var aiResponse = await _gemini.GenerateContentAsync(prompt, ct);
            var parsed = ParseLessonContent(aiResponse);

            var lesson = Lesson.Create(
                child.Id,
                parsed.Title,
                parsed.Content,
                request.Subject,
                request.Topic,
                DifficultyLevel.From(request.TargetDifficultyLevel),
                parsed.LearningObjectives);

            _db.Lessons.Add(lesson);
            await _db.SaveChangesAsync(ct);

            _logger.LogInformation(
                "Generated lesson {LessonId} for child {ChildId} on {Topic}",
                lesson.Id, child.Id, request.Topic);

            return new Result(
                lesson.Id,
                lesson.Title,
                lesson.Content,
                lesson.DifficultyLevel.Value,
                lesson.LearningObjectives.ToList());
        }

        private static string BuildPrompt(Child child, Command request)
        {
            // Prompt building logic right here, not in another project
            var recentTopics = child.ProgressRecords
                .OrderByDescending(p => p.CompletedAt)
                .Take(5)
                .Select(p => p.Topic);

            return $"""
                Create an educational lesson for a {child.Age}-year-old child.
                Topic: {request.Topic}
                Subject: {request.Subject}
                Difficulty level: {request.TargetDifficultyLevel}/5
                Recently studied: {string.Join(", ", recentTopics)}

                Requirements:
                - Age-appropriate language
                - Interactive elements
                - 3-5 learning objectives
                - Include a brief quiz at the end
                """;
        }

        private static ParsedLesson ParseLessonContent(string aiResponse)
        {
            // Parsing logic right here too
            // ...
        }
    }

    // The endpoint
    public static void MapEndpoints(IEndpointRouteBuilder app)
    {
        app.MapPost("/api/lessons/generate", async (
            Command command,
            IMediator mediator,
            CancellationToken ct) =>
        {
            var result = await mediator.Send(command, ct);
            return Results.Created($"/api/lessons/{result.LessonId}", result);
        })
        .WithName("GenerateLesson")
        .WithTags("Lessons")
        .RequireAuthorization("ParentPolicy")
        .Produces<Result>(StatusCodes.Status201Created)
        .ProducesValidationProblem();
    }
}

One file. Everything. When I need to understand “Generate Lesson”, I open one file and read top to bottom. When I need to debug it, I am already looking at every piece of the puzzle. When a new developer asks “how does lesson generation work?”, I point them to one file.

The extreme version of Vertical Slice Architecture goes further — no shared abstractions at all. Each slice has its own database context, its own models, its own everything. Each slice is essentially a tiny application that happens to be deployed together.

I have seen teams succeed with this extreme approach, particularly in microservice environments where each slice might eventually become its own service. But for Kids Learn, going full vertical slice created its own problems:

  • Code duplication: The Child entity was redefined in multiple slices with slightly different shapes
  • Inconsistent validation: Each slice rolled its own validation approach
  • No shared behaviors: We lost the pipeline behaviors (logging, validation, error handling) that MediatR gives us
  • Database chaos: Multiple slices defining their own EF Core configurations for the same tables led to migration conflicts

Pure Vertical Slice Architecture trades one problem (feature scattering) for another (feature isolation taken too far). We needed something in between.

Vertical slice — all code for GenerateLesson colocated in one folder: command, handler, validator, endpoint

The Hybrid Approach: Clean Architecture Boundaries, Vertical Slice Organization

After two weeks of experimenting with pure vertical slices — and dealing with the duplication and consistency problems — we landed on a hybrid approach. The insight was that Clean Architecture and Vertical Slice Architecture solve different problems, and we could take the best of both.

The rules are simple:

  1. Keep the Domain layer separate. Business rules, entities, value objects, and domain events are genuinely cross-cutting. The Lesson entity is used by lesson generation, lesson completion, progress tracking, and reporting. It belongs in a shared Domain project.

  2. Keep the Infrastructure layer separate. Database configuration, external service integrations, and infrastructure concerns are shared resources. EF Core’s DbContext, the Gemini client wrapper, and the email service are used by multiple features. They belong in Infrastructure.

  3. Organize the Application layer by feature. This is where the magic happens. Instead of organizing by technical concern (Commands/, Queries/, Validators/), organize by feature. Each feature gets one file or one small folder containing its command, handler, validator, and response DTO.

  4. Map API endpoints to features. The Api project’s endpoint groups mirror the Application layer’s feature structure.

Here is what the Application project looks like after the reorganization:

KidsLearn.Application/
├── Features/
│   ├── Lessons/
│   │   ├── GenerateLesson.cs          // command + handler + validator
│   │   ├── GetLessonById.cs           // query + handler
│   │   ├── CompleteLessonCommand.cs   // command + handler + validator
│   │   ├── ListLessonsByChild.cs      // query + handler
│   │   └── DeleteLesson.cs            // command + handler
│   ├── Progress/
│   │   ├── GetChildProgress.cs        // query + handler
│   │   ├── UpdateMastery.cs           // command + handler + validator
│   │   ├── DetectKnowledgeGaps.cs     // query + handler (calls Gemini)
│   │   └── RecordQuizResult.cs        // command + handler + validator
│   ├── Children/
│   │   ├── RegisterChild.cs           // command + handler + validator
│   │   ├── GetChildProfile.cs         // query + handler
│   │   └── UpdateChildPreferences.cs  // command + handler + validator
│   └── Dashboard/
│       ├── GetParentDashboard.cs      // query + handler (aggregation)
│       ├── GetWeeklyReport.cs         // query + handler
│       └── GetLearningInsights.cs     // query + handler (calls Gemini)
├── Common/
│   ├── Behaviors/
│   │   ├── ValidationBehavior.cs      // shared validation pipeline
│   │   ├── LoggingBehavior.cs         // shared logging pipeline
│   │   └── PerformanceBehavior.cs     // shared perf monitoring
│   ├── Interfaces/
│   │   ├── IGeminiClient.cs           // port for AI service
│   │   ├── IEmailService.cs           // port for email
│   │   └── ICurrentUserService.cs     // port for auth context
│   ├── Exceptions/
│   │   ├── NotFoundException.cs
│   │   └── ForbiddenException.cs
│   └── Models/
│       └── PagedResult.cs             // shared pagination model
└── DependencyInjection.cs             // MediatR + FluentValidation registration

Look at the difference. The Features/ folder is organized by business capability, not by technical pattern. When I want to work on lesson generation, I open Features/Lessons/GenerateLesson.cs. When I want to work on progress tracking, I open Features/Progress/. Everything I need for a feature is either in that one file or in the clearly-defined shared Common/ folder.

Let me show you what a complete “slice” file looks like in this hybrid approach:

// KidsLearn.Application/Features/Progress/UpdateMastery.cs

using FluentValidation;
using KidsLearn.Application.Common.Interfaces;
using KidsLearn.Domain.Entities;
using KidsLearn.Domain.ValueObjects;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace KidsLearn.Application.Features.Progress;

public static class UpdateMastery
{
    // --- Request ---
    public record Command(
        Guid ChildId,
        string Subject,
        string Topic,
        int QuizScore,
        int TotalQuestions,
        TimeSpan TimeSpent) : IRequest<Response>;

    // --- Response ---
    public record Response(
        Guid ProgressId,
        string Subject,
        string Topic,
        MasteryStatus PreviousLevel,
        MasteryStatus NewLevel,
        bool LeveledUp,
        string? Recommendation);

    // --- Validation ---
    public class CommandValidator : AbstractValidator<Command>
    {
        public CommandValidator()
        {
            RuleFor(x => x.ChildId)
                .NotEmpty()
                .WithMessage("Child ID is required");

            RuleFor(x => x.Subject)
                .NotEmpty()
                .MaximumLength(100);

            RuleFor(x => x.Topic)
                .NotEmpty()
                .MaximumLength(200);

            RuleFor(x => x.QuizScore)
                .GreaterThanOrEqualTo(0)
                .LessThanOrEqualTo(x => x.TotalQuestions)
                .WithMessage("Quiz score cannot exceed total questions");

            RuleFor(x => x.TotalQuestions)
                .GreaterThan(0)
                .WithMessage("Total questions must be at least 1");

            RuleFor(x => x.TimeSpent)
                .GreaterThan(TimeSpan.Zero)
                .LessThan(TimeSpan.FromHours(2))
                .WithMessage("Time spent seems unreasonable");
        }
    }

    // --- Handler ---
    public class CommandHandler : IRequestHandler<Command, Response>
    {
        private readonly IApplicationDbContext _db;
        private readonly IGeminiClient _gemini;
        private readonly ILogger<CommandHandler> _logger;

        public CommandHandler(
            IApplicationDbContext db,
            IGeminiClient gemini,
            ILogger<CommandHandler> logger)
        {
            _db = db;
            _gemini = gemini;
            _logger = logger;
        }

        public async Task<Response> Handle(Command request, CancellationToken ct)
        {
            var progress = await _db.ProgressRecords
                .FirstOrDefaultAsync(p =>
                    p.ChildId == request.ChildId &&
                    p.Subject == request.Subject &&
                    p.Topic == request.Topic, ct);

            var previousLevel = progress?.MasteryLevel ?? MasteryStatus.NotStarted;

            if (progress is null)
            {
                progress = ProgressRecord.Create(
                    request.ChildId,
                    request.Subject,
                    request.Topic);
                _db.ProgressRecords.Add(progress);
            }

            // Domain logic lives in the entity
            progress.RecordAttempt(
                request.QuizScore,
                request.TotalQuestions,
                request.TimeSpent);

            var leveledUp = progress.MasteryLevel != previousLevel;

            // Get AI recommendation if the child is struggling
            string? recommendation = null;
            if (progress.MasteryLevel == MasteryStatus.Struggling)
            {
                recommendation = await GetAiRecommendation(
                    request.ChildId, request.Subject, request.Topic, ct);
            }

            await _db.SaveChangesAsync(ct);

            _logger.LogInformation(
                "Updated mastery for child {ChildId}: {Subject}/{Topic} " +
                "{Previous} -> {New} (Score: {Score}/{Total})",
                request.ChildId, request.Subject, request.Topic,
                previousLevel, progress.MasteryLevel,
                request.QuizScore, request.TotalQuestions);

            return new Response(
                progress.Id,
                progress.Subject,
                progress.Topic,
                previousLevel,
                progress.MasteryLevel,
                leveledUp,
                recommendation);
        }

        private async Task<string?> GetAiRecommendation(
            Guid childId, string subject, string topic, CancellationToken ct)
        {
            try
            {
                var prompt = $"""
                    A child is struggling with {topic} in {subject}.
                    Suggest one specific, actionable learning activity
                    that could help them improve. Keep it under 2 sentences.
                    """;

                return await _gemini.GenerateContentAsync(prompt, ct);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex,
                    "Failed to get AI recommendation for {ChildId}", childId);
                return null; // Recommendation is nice-to-have, don't fail the request
            }
        }
    }
}

Everything about “Update Mastery” is in this one file. The request shape, the validation, the business logic orchestration, the response mapping. The only things that live elsewhere are the things that genuinely need to be shared: the ProgressRecord domain entity (used by multiple features), the IGeminiClient interface (shared infrastructure port), and the pipeline behaviors (cross-cutting concerns).

The dependency rules are still enforced at the project level. KidsLearn.Application references KidsLearn.Domain but not KidsLearn.Infrastructure. The Dependency Inversion Principle is intact. We just reorganized the interior of the Application project.

Here is what the corresponding API endpoint looks like:

// KidsLearn.Api/Endpoints/ProgressEndpoints.cs

using KidsLearn.Application.Features.Progress;
using MediatR;

namespace KidsLearn.Api.Endpoints;

public static class ProgressEndpoints
{
    public static void MapProgressEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/progress")
            .WithTags("Progress")
            .RequireAuthorization("ParentPolicy");

        group.MapPut("/mastery", async (
            UpdateMastery.Command command,
            IMediator mediator,
            CancellationToken ct) =>
        {
            var result = await mediator.Send(command, ct);
            return Results.Ok(result);
        })
        .WithName("UpdateMastery")
        .Produces<UpdateMastery.Response>()
        .ProducesValidationProblem();

        group.MapGet("/child/{childId:guid}", async (
            Guid childId,
            IMediator mediator,
            CancellationToken ct) =>
        {
            var result = await mediator.Send(
                new GetChildProgress.Query(childId), ct);
            return Results.Ok(result);
        })
        .WithName("GetChildProgress")
        .Produces<GetChildProgress.Response>();

        group.MapGet("/child/{childId:guid}/gaps", async (
            Guid childId,
            IMediator mediator,
            CancellationToken ct) =>
        {
            var result = await mediator.Send(
                new DetectKnowledgeGaps.Query(childId), ct);
            return Results.Ok(result);
        })
        .WithName("DetectKnowledgeGaps")
        .Produces<DetectKnowledgeGaps.Response>();
    }
}

Notice how the endpoint file references UpdateMastery.Command and UpdateMastery.Response directly from the feature class. The naming is clean and self-documenting. There is no ambiguity about which command belongs to which feature.

Hybrid architecture — Domain and Infrastructure as separate projects (Clean Architecture boundaries), Application layer organized by feature folders (Vertical Slices inside)

Shared Kernel and Cross-Cutting Concerns

The Common/ folder in the Application project is the shared kernel. It contains things that genuinely serve multiple features. The key word is “genuinely” — I have seen teams dump everything into a shared folder until it becomes a junk drawer that defeats the purpose of feature organization.

Here is my rule: if something is used by only one feature, it belongs in that feature’s file. If it’s used by two or more features, it can go in Common/. If it’s used by everything, it definitely goes in Common/.

Pipeline Behaviors

These are the cross-cutting concerns that apply to every request:

// KidsLearn.Application/Common/Behaviors/ValidationBehavior.cs

using FluentValidation;
using MediatR;

namespace KidsLearn.Application.Common.Behaviors;

public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext<TRequest>(request);

        var validationResults = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, ct)));

        var failures = validationResults
            .SelectMany(r => r.Errors)
            .Where(f => f is not null)
            .ToList();

        if (failures.Count > 0)
            throw new ValidationException(failures);

        return await next();
    }
}

This behavior applies to every command and query. It does not belong in any specific feature — it is infrastructure for the Application layer itself.

Interface Definitions (Ports)

The ports that Infrastructure implements are shared because multiple features need them:

// KidsLearn.Application/Common/Interfaces/IGeminiClient.cs

namespace KidsLearn.Application.Common.Interfaces;

public interface IGeminiClient
{
    Task<string> GenerateContentAsync(string prompt, CancellationToken ct = default);

    Task<T> GenerateStructuredContentAsync<T>(
        string prompt,
        CancellationToken ct = default) where T : class;
}
// KidsLearn.Application/Common/Interfaces/IApplicationDbContext.cs

using KidsLearn.Domain.Entities;
using Microsoft.EntityFrameworkCore;

namespace KidsLearn.Application.Common.Interfaces;

public interface IApplicationDbContext
{
    DbSet<Child> Children { get; }
    DbSet<Lesson> Lessons { get; }
    DbSet<ProgressRecord> ProgressRecords { get; }
    DbSet<Parent> Parents { get; }

    Task<int> SaveChangesAsync(CancellationToken ct = default);
}

The IGeminiClient is used by lesson generation, knowledge gap detection, learning insights, and recommendation features. The IApplicationDbContext is used by virtually everything. These are rightfully shared.

What Does NOT Go in Common

Here are things I have seen teams put in Common/ that should actually live in their feature files:

  • Feature-specific DTOs: If LessonSummaryDto is only used by the GetParentDashboard feature, put it inside GetParentDashboard.cs. Do not create a shared DTOs/ folder.
  • Feature-specific interfaces: If ILessonPromptBuilder is only used by GenerateLesson, define it inside GenerateLesson.cs or put it in the Features/Lessons/ folder.
  • Mapping profiles: If you are using AutoMapper (we are not — we use manual mapping), each feature’s mapping belongs in the feature file.
  • Constants specific to one feature: If MaxLessonDuration is only relevant to lesson generation, it lives in the lesson feature folder.

The Common folder in Kids Learn currently has 11 files. The Features folder has 15 feature files. That ratio feels right. If Common has more files than Features, something has gone wrong — you are building a framework, not a product.

Decision Framework: When to Use Which Approach

After working with all three approaches across different projects, here is my honest assessment of when each one makes sense:

FactorPure Clean ArchitectureHybrid (Our Approach)Pure Vertical Slices
Team sizeLarge (10+)Medium (3-10)Small (1-3)
Domain complexityHighMedium-HighLow-Medium
Feature independenceLowMediumHigh
Ceremony/boilerplateHighMediumLow
Onboarding timeWeeksDaysHours
Refactoring safetyVery highHighMedium
Code navigationDifficult (by design)BalancedVery easy
Shared behavior reuseExcellentGoodPoor (by design)
Best suited forEnterprise systems, regulated industriesMost SaaS products, growing startupsMicroservices, MVPs, prototypes
.NET 10 fitNatural fitNatural fitWorks, but fights the framework

Let me be more specific about each:

Pure Clean Architecture shines when you have a large team where developers work on different layers simultaneously. If you have dedicated backend developers, infrastructure engineers, and API developers, the layer separation maps to team boundaries. It also shines in regulated industries where you need to prove that business logic is completely isolated from infrastructure — auditors love the clean separation.

But for a team of 3-5 developers building a SaaS product like Kids Learn, the ceremony is overhead. We do not have dedicated infrastructure engineers. The same developer who writes the handler also writes the endpoint and configures the database mapping. The layer separation creates busy work for that developer.

Pure Vertical Slices shine when features are truly independent. In a microservice architecture where each service handles one capability, vertical slices make perfect sense — each slice might eventually become its own service. They also work great for MVPs and prototypes where you want to move fast and refactor later.

But for Kids Learn, features are not independent. Lesson generation, progress tracking, and the parent dashboard all share the same domain model. Pure vertical slices meant duplicating entity definitions or creating awkward references between slices. The “no shared abstractions” philosophy broke down quickly.

The Hybrid Approach is the pragmatic middle ground. You get the safety of Clean Architecture’s dependency rules at the project boundary level, and the developer experience of vertical slices within the Application layer. You keep shared infrastructure concerns properly isolated, and you keep cross-cutting behaviors properly shared. But each feature is still easy to find, understand, and modify.

There is one more factor I want to be honest about: team familiarity. If your team already knows Clean Architecture, the hybrid approach is an easy sell — it is a reorganization of what they already understand, not a paradigm shift. If your team has never worked with Clean Architecture, jumping straight to pure vertical slices might be faster to adopt.

Kids Learn: Our Final Solution Structure

After the reorganization, here is the complete solution structure we landed on:

KidsLearn/
├── src/
│   ├── KidsLearn.Domain/
│   │   ├── Entities/
│   │   │   ├── Child.cs                    // Rich entity with behavior
│   │   │   ├── Parent.cs
│   │   │   ├── Lesson.cs
│   │   │   └── ProgressRecord.cs
│   │   ├── ValueObjects/
│   │   │   ├── DifficultyLevel.cs
│   │   │   ├── MasteryStatus.cs
│   │   │   ├── AgeRange.cs
│   │   │   └── EmailAddress.cs
│   │   ├── Events/
│   │   │   ├── LessonGeneratedEvent.cs
│   │   │   ├── MasteryLevelChangedEvent.cs
│   │   │   └── ChildRegisteredEvent.cs
│   │   ├── Enums/
│   │   │   └── Subject.cs
│   │   └── Common/
│   │       ├── Entity.cs
│   │       ├── AggregateRoot.cs
│   │       └── IDomainEvent.cs
│   │
│   ├── KidsLearn.Application/
│   │   ├── Features/
│   │   │   ├── Lessons/
│   │   │   │   ├── GenerateLesson.cs
│   │   │   │   ├── GetLessonById.cs
│   │   │   │   ├── CompleteLessonCommand.cs
│   │   │   │   ├── ListLessonsByChild.cs
│   │   │   │   └── DeleteLesson.cs
│   │   │   ├── Progress/
│   │   │   │   ├── GetChildProgress.cs
│   │   │   │   ├── UpdateMastery.cs
│   │   │   │   ├── DetectKnowledgeGaps.cs
│   │   │   │   └── RecordQuizResult.cs
│   │   │   ├── Children/
│   │   │   │   ├── RegisterChild.cs
│   │   │   │   ├── GetChildProfile.cs
│   │   │   │   └── UpdateChildPreferences.cs
│   │   │   └── Dashboard/
│   │   │       ├── GetParentDashboard.cs
│   │   │       ├── GetWeeklyReport.cs
│   │   │       └── GetLearningInsights.cs
│   │   ├── Common/
│   │   │   ├── Behaviors/
│   │   │   │   ├── ValidationBehavior.cs
│   │   │   │   ├── LoggingBehavior.cs
│   │   │   │   └── PerformanceBehavior.cs
│   │   │   ├── Interfaces/
│   │   │   │   ├── IApplicationDbContext.cs
│   │   │   │   ├── IGeminiClient.cs
│   │   │   │   ├── IEmailService.cs
│   │   │   │   └── ICurrentUserService.cs
│   │   │   ├── Exceptions/
│   │   │   │   ├── NotFoundException.cs
│   │   │   │   └── ForbiddenException.cs
│   │   │   └── Models/
│   │   │       └── PagedResult.cs
│   │   └── DependencyInjection.cs
│   │
│   ├── KidsLearn.Infrastructure/
│   │   ├── Persistence/
│   │   │   ├── ApplicationDbContext.cs
│   │   │   ├── Configurations/
│   │   │   │   ├── ChildConfiguration.cs
│   │   │   │   ├── LessonConfiguration.cs
│   │   │   │   ├── ProgressRecordConfiguration.cs
│   │   │   │   └── ParentConfiguration.cs
│   │   │   ├── Migrations/
│   │   │   └── Interceptors/
│   │   │       ├── AuditableEntityInterceptor.cs
│   │   │       └── DomainEventDispatcherInterceptor.cs
│   │   ├── Services/
│   │   │   ├── GeminiClient.cs
│   │   │   ├── EmailService.cs
│   │   │   └── CurrentUserService.cs
│   │   └── DependencyInjection.cs
│   │
│   └── KidsLearn.Api/
│       ├── Endpoints/
│       │   ├── LessonEndpoints.cs
│       │   ├── ProgressEndpoints.cs
│       │   ├── ChildEndpoints.cs
│       │   └── DashboardEndpoints.cs
│       ├── Middleware/
│       │   ├── ExceptionHandlingMiddleware.cs
│       │   └── RequestLoggingMiddleware.cs
│       └── Program.cs

├── tests/
│   ├── KidsLearn.Domain.Tests/
│   ├── KidsLearn.Application.Tests/
│   │   ├── Features/
│   │   │   ├── Lessons/
│   │   │   │   ├── GenerateLessonTests.cs
│   │   │   │   └── CompleteLessonTests.cs
│   │   │   ├── Progress/
│   │   │   │   ├── UpdateMasteryTests.cs
│   │   │   │   └── DetectKnowledgeGapsTests.cs
│   │   │   └── Dashboard/
│   │   │       └── GetParentDashboardTests.cs
│   │   └── Common/
│   │       └── Behaviors/
│   │           └── ValidationBehaviorTests.cs
│   └── KidsLearn.Api.Tests/

└── KidsLearn.sln

A few things to notice:

The Domain project is unchanged. It has the same structure from Part 2. Business rules are genuinely cross-cutting, and the Domain layer’s stability is one of Clean Architecture’s genuine strengths. I would not reorganize this layer.

The Infrastructure project is unchanged. EF Core configurations, external service implementations, and interceptors are shared resources. There is no benefit to scattering database configurations across feature folders — that would actually make database migration management harder.

The Application project is the only thing that changed. And the change was purely organizational — no new abstractions, no new base classes, no new patterns. We just moved files from Lessons/Commands/GenerateLesson/ to Features/Lessons/GenerateLesson.cs. The code inside each handler is identical. MediatR does not care where the files are. FluentValidation does not care where the validators are defined. Assembly scanning picks them all up regardless of folder structure.

The test project mirrors the feature structure. GenerateLessonTests.cs tests the handler, validator, and any private methods inside the GenerateLesson static class. You do not need to remember “is the test for the handler in the Handlers test folder or the Commands test folder?” It is in the same place as the feature.

The Api project’s endpoint files map to feature groups. LessonEndpoints.cs maps all lesson-related routes, ProgressEndpoints.cs maps all progress-related routes. This is not a strict rule — if a feature group grows large enough, you could split endpoints further. But for our 15-feature application, one endpoint file per feature group is clean enough.

Here is the Program.cs that ties it all together:

// KidsLearn.Api/Program.cs

using KidsLearn.Api.Endpoints;
using KidsLearn.Api.Middleware;
using KidsLearn.Application;
using KidsLearn.Infrastructure;

var builder = WebApplication.CreateBuilder(args);

// Layer registrations - Clean Architecture dependency flow
builder.Services.AddApplication();       // MediatR, FluentValidation, behaviors
builder.Services.AddInfrastructure(      // EF Core, Gemini, email
    builder.Configuration);

// API concerns
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddAuthorization();

var app = builder.Build();

// Middleware pipeline
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseMiddleware<RequestLoggingMiddleware>();
app.UseSwagger();
app.UseSwaggerUI();
app.UseAuthorization();

// Map all endpoint groups
app.MapLessonEndpoints();
app.MapProgressEndpoints();
app.MapChildEndpoints();
app.MapDashboardEndpoints();

app.Run();

Clean. Each Map*Endpoints() call registers a group of related routes. The AddApplication() and AddInfrastructure() extension methods handle DI registration for their respective layers. The dependency flow is still Domain <- Application <- Infrastructure <- Api.

The Migration: How We Actually Did It

The reorganization took us one afternoon. Here is what we did:

  1. Created the Features/ folder structure in the Application project
  2. Moved each handler, command, and validator into a single static class per feature
  3. Updated namespaces (VS 2025’s “Change Namespace” refactoring did most of this)
  4. Ran all tests — they passed without modification
  5. Updated the endpoint files to reference the new class names (e.g., GenerateLesson.Command instead of GenerateLessonCommand)

That is it. No architectural changes. No new NuGet packages. No refactoring of business logic. The runtime behavior is identical. MediatR’s assembly scanning found all the handlers in their new locations automatically. FluentValidation’s assembly scanning found all the validators automatically.

The total diff was about 400 lines of namespace changes and file moves. Zero logic changes. This is important because it means you can do this refactoring on a live project without risk. If your team decides the hybrid approach is not working, you can move the files back to the traditional structure in another afternoon.

The Practical Impact

After living with this structure for six weeks, here is what changed:

Code reviews got faster. A PR for a new feature is now 1-3 files instead of 6-12. Reviewers can read top-to-bottom and understand the entire feature without jumping between projects. Our average PR review time dropped from 45 minutes to about 20 minutes.

Onboarding improved dramatically. A new developer joined the team in week three of the reorganization. I told them: “Pick a feature file, read it top to bottom, and you’ll understand how one feature works. Then look at two more and you’ll understand the pattern.” They were productive in two days instead of the week it used to take.

Feature ownership became natural. Each developer “owns” a set of feature folders. When a bug report comes in for lesson generation, everyone knows which developer to ask and which folder to look in. There is no ambiguity about where the code lives.

But there are downsides. Feature files can get long. Our GenerateLesson.cs is about 180 lines. The GetParentDashboard.cs is over 200 lines because the aggregation logic is complex. We set an informal rule: if a feature file exceeds 300 lines, it is a sign that the feature should be split into smaller features, not that we need more files.

There is also a discoverability trade-off. In pure Clean Architecture, if you want to see “all commands in the system,” you open the Commands/ folder. In the hybrid approach, commands are spread across feature files. We mitigate this with a naming convention — all command classes are named Command inside their feature’s static class — and with IDE search. Typing IRequest in a solution-wide search shows every command and query in the system.

What’s Next

We have a well-organized, feature-focused codebase that still respects Clean Architecture’s dependency rules. The structure is pragmatic, easy to navigate, and welcoming to new developers.

But none of this matters if we cannot prove it works. In Part 6, we will tackle testing — unit tests for domain entities, integration tests for feature handlers with a real database, and end-to-end tests for the API endpoints. I will show you how the feature-organized structure actually makes testing easier, because every test file mirrors a feature file. You will see how we test Gemini AI integrations without hitting the real API, how we handle database state in integration tests with Testcontainers, and how we catch regressions before they reach production.

The test pyramid for a .NET 10 Clean Architecture project looks different from what the textbooks show, and I think you will find our approach surprisingly straightforward.


This is Part 5 of 7 in the Clean Architecture with .NET 10 series. The full series:

  1. Why Clean Architecture Still Matters in 2026
  2. Domain Layer — Entities, Value Objects, and Domain Events
  3. Application Layer — MediatR, FluentValidation, and CQRS
  4. Infrastructure & API — EF Core, Gemini AI, and Minimal Endpoints
  5. Vertical Slices Inside Clean Architecture (you are here)
  6. Testing — Unit, Integration, and End-to-End
  7. Deployment — Docker, CI/CD, and Cloudflare
Export for reading

Comments