DEV Community

Cover image for Clean Architecture in .NET 10: Why Your Code Turns Into Spaghetti (And How to Prevent It)
Brian Spann
Brian Spann

Posted on

Clean Architecture in .NET 10: Why Your Code Turns Into Spaghetti (And How to Prevent It)

Who This Series Is For

You know C#. You've built some projects—maybe in school, maybe at your first job. Things worked. But something felt off:

  • Adding a feature meant changing 10 files
  • You were scared to refactor because everything might break
  • Your controllers were 500 lines long
  • Testing felt impossible, so you didn't
  • Senior devs talked about "architecture," and you nodded along, confused

This series is for you.

We're going to build a real application from scratch. By the end, you'll understand why experienced developers structure code the way they do—and more importantly, you'll know when these patterns matter and when they're overkill.


The Problem We're Solving

Let me show you code that works but will ruin your life in six months.

The "Just Make It Work" Approach

[ApiController]
[Route("api/prompts")]
public class PromptsController : ControllerBase
{
    private readonly AppDbContext _context;

    public PromptsController(AppDbContext context)
    {
        _context = context;
    }

    [HttpPost]
    public async Task<IActionResult> Create(CreatePromptRequest request)
    {
        // Validation mixed with business logic
        if (string.IsNullOrEmpty(request.Title))
            return BadRequest("Title is required");

        if (request.Title.Length > 200)
            return BadRequest("Title too long");

        // Check for duplicates
        if (await _context.Prompts.AnyAsync(p => p.Title == request.Title))
            return BadRequest("Title already exists");

        // Business logic in the controller
        var prompt = new Prompt
        {
            Id = Guid.NewGuid(),
            Title = request.Title.Trim(),
            Content = request.Content,
            CreatedAt = DateTime.UtcNow
        };

        // Create initial version
        var version = new PromptVersion
        {
            Id = Guid.NewGuid(),
            PromptId = prompt.Id,
            VersionNumber = 1,
            Content = request.Content,
            CreatedAt = DateTime.UtcNow
        };

        // Tags normalization
        if (request.Tags != null)
        {
            foreach (var tag in request.Tags)
            {
                prompt.Tags.Add(tag.Trim().ToLower());
            }
        }

        _context.Prompts.Add(prompt);
        _context.PromptVersions.Add(version);
        await _context.SaveChangesAsync();

        return CreatedAtAction(nameof(GetById), new { id = prompt.Id }, prompt);
    }

    // ... 20 more methods, all 50+ lines each
}
Enter fullscreen mode Exit fullscreen mode

This works. It ships. Users are happy (for now).

Why It Becomes a Problem

Month 1: "Can you add email notifications when a prompt is created?"
You add the email code to the controller. It's getting long, but manageable.

Month 2: "We need the same logic in our mobile API."
You copy-paste the controller. Now you have duplicate business logic.

Month 3: "We need to write tests for the prompt creation."
You realize you can't test the logic without spinning up a database and HTTP server.

Month 4: "The tag normalization is wrong. Fix it everywhere."
You search the codebase. It's in 6 controllers, 3 services, and a background job. You miss one.

Month 6: "We're switching from SQL Server to PostgreSQL."
Everything breaks. Database code is everywhere.

The Root Cause

The problem isn't that you're a bad programmer. The problem is everything is mixed together:

  • HTTP handling (what format is the request?)
  • Validation (is the data valid?)
  • Business logic (what should happen?)
  • Data access (how do we save it?)

When everything is in one place, changing one thing risks breaking everything else.


What "Clean Architecture" Actually Means

Forget the complicated diagrams for now. Clean Architecture boils down to one idea:

Separate things that change for different reasons.

  • HTTP formats change (JSON today, maybe something else tomorrow)
  • Validation rules change (business decides title max is now 500)
  • Business logic changes (versioning rules become more complex)
  • Database changes (new ORM, new database, new table structure)

If these are all tangled together, every change is scary. If they're separate, you change one thing without touching the others.


An Analogy: The Restaurant Kitchen

Think of a restaurant:

  • Front of House (Waiters) — Takes orders from customers, brings food out
  • Kitchen — Cooks the food according to recipes
  • Pantry/Storage — Stores ingredients, retrieves them when needed

Now imagine a restaurant where:

  • The waiter cooks the food while taking your order
  • The cook stores ingredients in their pockets
  • There's no menu—customers describe what they want and the waiter figures it out

Chaos, right?

Clean Architecture is the same idea for code:

Restaurant Code
Waiter API Layer (Controllers)
Kitchen Application Layer (Use Cases)
Recipes Domain Layer (Business Rules)
Pantry Infrastructure Layer (Database)

The waiter doesn't need to know how to cook. The chef doesn't need to know where ingredients are stored. Each part has one job.


What We're Building

Throughout this series, we'll build PromptVault—an API for storing and organizing AI prompts (like the ones you'd use with ChatGPT or Claude).

Why this example?

  • It's relevant (you probably use AI tools)
  • It has real business logic (versioning, tagging, collections)
  • It's simple enough to understand, complex enough to be realistic

Features we'll implement:

  • Create prompts with automatic versioning
  • Tag prompts for organization
  • Group prompts into collections
  • Search by content or tags
  • Track version history

The Four Layers (Simply Explained)

1. Domain — The Business Rules

This is the heart of your application. It knows nothing about HTTP, databases, or frameworks. It only knows the rules of your business.

"Updating a prompt should create a new version."
"Tags should be lowercase and unique."
"A prompt must have a title."

If you explained your app to a non-technical person, the Domain is what you'd talk about.

2. Application — The Use Cases

This coordinates what happens when a user wants to do something:

"Create a prompt" → Check if title exists, create the prompt, save it.
"Get prompt history" → Find the prompt, load its versions, return them.

The Application layer knows WHAT to do, but not HOW data is stored.

3. Infrastructure — The Real World

This is where we talk to external things:

  • Databases (Entity Framework)
  • File systems
  • External APIs
  • Email services

The Infrastructure layer knows HOW to store and retrieve data.

4. API — The Door

This is where requests come in:

  • HTTP endpoints (controllers or minimal APIs)
  • Request/response formatting
  • Authentication

The API layer translates between "HTTP" and "what the application needs to do."


The Key Rule: Dependencies Point Inward

Here's the rule that makes everything work:

API → Application → Domain
       ↓
   Infrastructure
Enter fullscreen mode Exit fullscreen mode
  • Domain depends on nothing
  • Application depends only on Domain
  • Infrastructure depends on Domain and Application
  • API depends on Application

What this means in practice:

Your Prompt entity (Domain) doesn't know Entity Framework exists.
Your CreatePromptHandler (Application) doesn't know you're using SQL Server.
Your controller (API) doesn't know how data is stored.

If you swap PostgreSQL for MongoDB, only Infrastructure changes. The rest of your code doesn't care.


"This Seems Like a Lot of Work"

It is. That's the honest truth.

For a simple CRUD app that one person maintains, this is overkill. You'd spend more time on architecture than features.

Use Clean Architecture when:

  • Multiple developers will work on the code
  • The project will live for years
  • You have real business logic (not just save/load data)
  • You want to write tests
  • You might change databases or frameworks

Skip it when:

  • It's a prototype or hackathon project
  • You're the only developer forever
  • The whole app is just CRUD
  • You need to ship in a week

We'll be honest throughout this series about when patterns are worth the cost.


Prerequisites

To follow along, you should know:

  • C# basics — classes, methods, async/await
  • Some ASP.NET Core — you've built a controller before
  • Basic Entity Framework — you've saved something to a database

You don't need to know:

  • MediatR (we'll explain it)
  • Design patterns (we'll introduce them as needed)
  • Previous architecture experience (that's what we're teaching)

What's Coming

Part Topic
1 The Setup — Creating the project structure
2 Domain Layer — Entities with real behavior
3 Application Layer — CQRS and MediatR
4 Infrastructure Layer — EF Core without leakage
5 API Layer — Controllers that do almost nothing
6 Production Polish — Validation, logging, caching
7 Testing — What matters and what to skip

Each part:

  • Shows you the "textbook" approach
  • Explains "in the real world, here's what happens."
  • Gives you working code you can run

Let's Start

By the end of this series, you'll:

  • Understand why senior developers make certain choices
  • Know when to use these patterns (and when not to)
  • Have a real project to reference for future work
  • Be able to explain Clean Architecture in a job interview

Ready?

👉 Part 1: The Setup (And When Clean Architecture Is Overkill) — coming next!


Follow me for the next part. Drop a comment if you have questions—I read all of them.

Top comments (0)