Clean Architecture in .NET 10: The Setup (And When It’s Overkill)

This is Part 1 of a 7-part series. Start from the beginning if you haven’t read the introduction.

The Promise

You’ve seen the diagram. Concentric circles. Domain at the center. Dependencies pointing inward. The promise: code that’s testable, maintainable, and survives framework upgrades.

The reality? Four projects for a CRUD API. Interfaces for everything. A CreateUserCommandHandler that does nothing but call a repository method. And somehow, adding a field to an entity requires touching six files.

Here’s the thing: Clean Architecture isn’t wrong. But the way it’s typically taught—as a rigid folder structure—misses the point.

Clean Architecture is about dependency direction, not folder count.

What Problem Are We Actually Solving?

Before creating any folders, let’s be clear about what Clean Architecture gives you:

1. Your domain logic doesn’t depend on your database

You can swap EF Core for Dapper. You can move from SQL Server to Postgres. Your business rules don’t care.

2. Your domain logic doesn’t depend on your web framework

ASP.NET Core, Minimal APIs, gRPC—your core logic stays the same.

3. You can test business logic without spinning up infrastructure

No database. No HTTP. Just logic and assertions.

If you don’t need these benefits—if you’re building an internal tool that will never change databases, will always be ASP.NET, and has minimal business logic—you might not need Clean Architecture.

🔥 Real talk: A lot of enterprise software is just CRUD with authorization. For that, a well-organized monolith with sensible folders might be better than architectural astronautics.

When Clean Architecture Earns Its Keep

Use it when:

  • You have real business rules beyond “save this to the database”
  • Multiple teams will work on the codebase
  • The project will live for years
  • You want to test business logic in isolation
  • You anticipate significant changes to infrastructure

Skip it when:

  • It’s a prototype or proof-of-concept
  • The entire “domain” is data in, data out
  • You’re the only developer and will be for the foreseeable future
  • You’re time-boxed and need to ship

For PromptVault, we have real business rules: prompt versioning, tag normalization, collection access control. Clean Architecture makes sense.

The Project Structure

Let’s create our solution. No magic templates—just dotnet CLI so you understand every file.

mkdir PromptVault
cd PromptVault

# Create the solution
dotnet new sln -n PromptVault

# Create projects
mkdir src tests

# Domain - the core, no dependencies
dotnet new classlib -n PromptVault.Domain -o src/PromptVault.Domain

# Application - use cases, orchestration
dotnet new classlib -n PromptVault.Application -o src/PromptVault.Application

# Infrastructure - EF Core, external services
dotnet new classlib -n PromptVault.Infrastructure -o src/PromptVault.Infrastructure

# API - HTTP layer
dotnet new webapi -n PromptVault.API -o src/PromptVault.API

# Add to solution
dotnet sln add src/PromptVault.Domain
dotnet sln add src/PromptVault.Application
dotnet sln add src/PromptVault.Infrastructure
dotnet sln add src/PromptVault.API

The Dependency Rules

This is the part that matters more than folder names:

# Application depends on Domain (can use entities, value objects)
dotnet add src/PromptVault.Application reference src/PromptVault.Domain

# Infrastructure depends on Domain AND Application
# (implements interfaces defined in Application)
dotnet add src/PromptVault.Infrastructure reference src/PromptVault.Domain
dotnet add src/PromptVault.Infrastructure reference src/PromptVault.Application

# API depends on Application AND Infrastructure
# (wires everything together)
dotnet add src/PromptVault.API reference src/PromptVault.Application
dotnet add src/PromptVault.API reference src/PromptVault.Infrastructure

The key insight: Domain depends on nothing. Application depends only on Domain. Infrastructure implements what Application defines. API composes it all.

┌─────────────────────────────────────────┐
│                  API                     │
│         (composition root)               │
└───────────────┬─────────────────────────┘
                │ references
    ┌───────────┴───────────┐
    │                       │
    ▼                       ▼
┌─────────────┐      ┌─────────────────┐
│ Application │      │ Infrastructure  │
│ (use cases) │      │ (EF, external)  │
└──────┬──────┘      └────────┬────────┘
       │                      │
       │    references        │ implements
       ▼                      │
┌─────────────┐               │
│   Domain    │ ◄─────────────┘
│  (entities) │
└─────────────┘

But Wait—Four Projects for What?

Here’s the honest conversation we need to have.

You’re about to create a User entity in Domain. Then an IUserRepository interface in Application. Then a UserRepository implementation in Infrastructure. Then inject it in API. For every entity. Forever.

The criticism is valid. If your app is small, this is overhead. The response to that criticism isn’t “trust the process.” It’s:

  1. Accept the overhead for the benefit. You’re paying upfront for future flexibility.
  2. Or don’t use this architecture. A single project with folders (/Entities, /Data, /Controllers) is fine for many apps.

We’re using Clean Architecture for PromptVault because:

  • Prompt versioning has real business logic
  • We want to test that logic without a database
  • This is a teaching exercise that should scale to real projects

Initial Domain Setup

Let’s create our first entity. In Clean Architecture, domain entities have behavior, not just properties.

src/PromptVault.Domain/Entities/Prompt.cs

namespace PromptVault.Domain.Entities;

public class Prompt
{
    public Guid Id { get; private set; }
    public string Title { get; private set; } = null!;
    public string Content { get; private set; } = null!;
    public string ModelType { get; private set; } = null!;
    public DateTime CreatedAt { get; private set; }
    public DateTime? UpdatedAt { get; private set; }

    private readonly List<PromptVersion> _versions = new();
    public IReadOnlyCollection<PromptVersion> Versions => _versions.AsReadOnly();

    private readonly List<string> _tags = new();
    public IReadOnlyCollection<string> Tags => _tags.AsReadOnly();

    // EF Core needs this, but we make it private
    private Prompt() { }

    public Prompt(string title, string content, string modelType)
    {
        if (string.IsNullOrWhiteSpace(title))
            throw new ArgumentException("Title is required", nameof(title));
        if (string.IsNullOrWhiteSpace(content))
            throw new ArgumentException("Content is required", nameof(content));

        Id = Guid.NewGuid();
        Title = title.Trim();
        Content = content;
        ModelType = modelType ?? "gpt-4";
        CreatedAt = DateTime.UtcNow;

        // Initial version is always created
        _versions.Add(new PromptVersion(this, 1, content));
    }

    public void UpdateContent(string newContent, string? updatedBy = null)
    {
        if (string.IsNullOrWhiteSpace(newContent))
            throw new ArgumentException("Content is required", nameof(newContent));

        if (newContent == Content)
            return; // No change, no new version

        Content = newContent;
        UpdatedAt = DateTime.UtcNow;

        var nextVersion = _versions.Max(v => v.VersionNumber) + 1;
        _versions.Add(new PromptVersion(this, nextVersion, newContent, updatedBy));
    }

    public void AddTag(string tag)
    {
        var normalized = tag.Trim().ToLowerInvariant();
        if (!_tags.Contains(normalized))
            _tags.Add(normalized);
    }

    public void RemoveTag(string tag)
    {
        var normalized = tag.Trim().ToLowerInvariant();
        _tags.Remove(normalized);
    }
}

What’s Happening Here

  1. Private setters — State changes go through methods
  2. Validation in constructor — Can’t create an invalid entity
  3. Behavior on the entityUpdateContent creates a version, AddTag normalizes input
  4. Internal constructorPromptVersion can only be created by Prompt

⚠️ Real-world callout: You’ll notice EF Core needs that private parameterless constructor. This is one of those “in concept vs. real world” moments. Pure domain-driven design says entities shouldn’t know about persistence. EF Core says “give me a constructor or suffer.” We compromise with private Prompt() { }.

The PromptVersion Entity

src/PromptVault.Domain/Entities/PromptVersion.cs

namespace PromptVault.Domain.Entities;

public class PromptVersion
{
    public Guid Id { get; private set; }
    public Guid PromptId { get; private set; }
    public int VersionNumber { get; private set; }
    public string Content { get; private set; } = null!;
    public DateTime CreatedAt { get; private set; }
    public string? CreatedBy { get; private set; }

    private PromptVersion() { } // EF Core

    internal PromptVersion(Prompt prompt, int versionNumber, string content, string? createdBy = null)
    {
        Id = Guid.NewGuid();
        PromptId = prompt.Id;
        VersionNumber = versionNumber;
        Content = content;
        CreatedAt = DateTime.UtcNow;
        CreatedBy = createdBy;
    }
}

Notice the internal constructor. Only code within the Domain project can create versions. External code must go through Prompt.UpdateContent().

The Anemic Entity Trap

Here’s what most tutorials show you:

// ❌ DON'T DO THIS
public class Prompt
{
    public Guid Id { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    // ...all public, no behavior
}

This is a “data bag.” It has no rules. Any code anywhere can set any property to anything. Your “domain” is just a mirror of your database tables.

The problem: Business rules end up scattered across services, controllers, and validators. Six months later, nobody knows where the “real” logic is.

The fix: Entities protect their invariants. If updating content should create a version, that logic lives on Prompt, not in PromptService.UpdateContent().

Testing the Domain (Already!)

Because our Domain has no dependencies, testing is trivial:

[Fact]
public void UpdateContent_CreatesNewVersion()
{
    var prompt = new Prompt("Test", "Original content", "gpt-4");

    prompt.UpdateContent("Updated content");

    Assert.Equal(2, prompt.Versions.Count);
    Assert.Equal("Updated content", prompt.Content);
}

[Fact]
public void UpdateContent_WithSameContent_DoesNotCreateVersion()
{
    var prompt = new Prompt("Test", "Same content", "gpt-4");

    prompt.UpdateContent("Same content");

    Assert.Single(prompt.Versions); // Still just the initial version
}

No database. No HTTP. No mocking. Just logic.

What We Built

At the end of Part 1, you have:

PromptVault/
├── PromptVault.sln
└── src/
    ├── PromptVault.Domain/
    │   └── Entities/
    │       ├── Prompt.cs
    │       └── PromptVersion.cs
    ├── PromptVault.Application/   (empty for now)
    ├── PromptVault.Infrastructure/ (empty for now)
    └── PromptVault.API/            (empty for now)

The Domain project compiles with zero NuGet packages. That’s the whole point.

Key Takeaways

  1. Clean Architecture is about dependency direction, not folder structure
  2. Domain entities should have behavior, not just properties
  3. Accept the overhead consciously — if you don’t need the benefits, use a simpler structure
  4. EF Core will make you compromise — that’s okay, be pragmatic
  5. Testable domain is the real win — everything else is details

Coming Up

In Part 2, we’ll flesh out the Domain layer:

  • Value objects that earn their keep (not just wrapping strings)
  • The Collection entity
  • When domain validation vs. application validation

We’ll also address the elephant: what if your domain really is just CRUD?

👉 Part 2: The Domain Layer — Entities That Actually Have Behavior

Questions? Drop them in the comments. The full source code is on GitHub: PromptVault

Similar Posts