DEV Community

Cover image for How to Structure a .NET Solution That Actually Scales: Clean Architecture Guide
Mashrul Haque
Mashrul Haque

Posted on • Edited on

How to Structure a .NET Solution That Actually Scales: Clean Architecture Guide

A practical guide to Clean Architecture folder structure, project organization, and dependency management in .NET—battle-tested patterns that help teams maintain large codebases.


Look, I've seen some things. I've opened solutions with 47 projects, each containing exactly one class (I am being dramatic!). I've navigated folder hierarchies so deep that Visual Studio needed a compass. I've traced project references that formed dependency graphs resembling spaghetti thrown at a wall.

And every time, someone said: "We'll refactor it later."

Spoiler: they didn't.


Table of Contents


Common .NET Project Structure Problems

Here's what typically happens. You start a project. It's small. Everything lives in one project because why not? Then features grow. You add folders. More folders. Folders inside folders.

Six months later:

MyApp/
├── Controllers/
├── Services/
├── Repositories/
├── Models/
├── ViewModels/
├── DTOs/
├── Helpers/
├── Utilities/
├── Extensions/
├── Common/
├── Shared/
├── Core/
├── Infrastructure/
└── Misc/   ← This is where dreams go to die
Enter fullscreen mode Exit fullscreen mode

Everything depends on everything. Your "Repository" calls your "Helper" which calls your "Service" which calls your "Repository" again. You've built a circular reference, but the compiler doesn't complain because it's all one project.

Then a new developer joins. They ask: "Where should I put this new feature?"

You stare blankly. "Uh... probably Services? Or maybe Helpers? What does it do exactly?"

The .NET Solution Structure That Actually Scales

After years of trial and error (mostly error), here's what works:

src/
├── MyApp.Domain/           # Zero dependencies. Just your business logic.
├── MyApp.Application/      # Use cases. References Domain only.
├── MyApp.Infrastructure/   # External concerns. Databases, APIs, files.
├── MyApp.Api/              # Your web host. Thin. Composition root.
└── MyApp.Shared.Kernel/    # Cross-cutting primitives (optional)

tests/
├── MyApp.Domain.Tests/
├── MyApp.Application.Tests/
├── MyApp.Infrastructure.Tests/
└── MyApp.Api.Tests/
Enter fullscreen mode Exit fullscreen mode

Five projects. Clear boundaries. Dependencies flow one direction: inward.

"But that's just Clean Architecture!"

Yes. And there's a reason everyone keeps reinventing it. It works.

The Dependency Rule in Clean Architecture

Here's the one rule that matters:

Dependencies point inward. Always.

Api → Application → Domain
Infrastructure → Application → Domain
Enter fullscreen mode Exit fullscreen mode

Domain knows nothing about databases. Application knows nothing about HTTP. Infrastructure implements the interfaces that Application defines.

Break this rule once, and you've broken it forever. I've watched teams add "just one small reference" from Domain to Infrastructure. Three months later, your business logic imports System.Data.SqlClient.

The compiler won't save you here. You need discipline. Or a tool like NetArchTest to enforce it:

[Fact]
public void Domain_Should_Not_Reference_Infrastructure()
{
    var result = Types.InAssembly(typeof(Order).Assembly)
        .ShouldNot()
        .HaveReferenceTo("MyApp.Infrastructure")
        .GetResult();

    result.IsSuccessful.Should().BeTrue();
}
Enter fullscreen mode Exit fullscreen mode

Write this test on day one. Thank me later.

Folder Conventions Inside Each Project

Domain Project Structure

MyApp.Domain/
├── Entities/
│   ├── Order.cs
│   └── Customer.cs
├── ValueObjects/
│   ├── Money.cs
│   └── Address.cs
├── Enums/
│   └── OrderStatus.cs
├── Events/
│   └── OrderPlacedEvent.cs
├── Exceptions/
│   └── InsufficientStockException.cs
└── Interfaces/
    └── IOrderRepository.cs       # Just the interface. No implementation.
Enter fullscreen mode Exit fullscreen mode

Notice what's not here: no Services folder. Domain services exist, but they're rare. If you're creating domain services for every entity, you're probably doing anemic domain modeling.

Application Project Structure

MyApp.Application/
├── Features/
│   ├── Orders/
│   │   ├── Commands/
│   │   │   ├── PlaceOrder/
│   │   │   │   ├── PlaceOrderCommand.cs
│   │   │   │   ├── PlaceOrderHandler.cs
│   │   │   │   └── PlaceOrderValidator.cs
│   │   │   └── CancelOrder/
│   │   │       └── ...
│   │   └── Queries/
│   │       └── GetOrderById/
│   │           ├── GetOrderByIdQuery.cs
│   │           ├── GetOrderByIdHandler.cs
│   │           └── OrderDto.cs
│   └── Customers/
│       └── ...
├── Common/
│   ├── Behaviors/
│   │   ├── ValidationBehavior.cs
│   │   └── LoggingBehavior.cs
│   └── Interfaces/
│       └── IApplicationDbContext.cs
└── DependencyInjection.cs
Enter fullscreen mode Exit fullscreen mode

This is "Vertical Slice Architecture" meets "CQRS lite." Each feature is self-contained. Want to understand how placing an order works? Look in one folder.

The Misc folder from earlier? It doesn't exist because there's nowhere for random code to hide.

Infrastructure Project Structure

MyApp.Infrastructure/
├── Persistence/
│   ├── ApplicationDbContext.cs
│   ├── Configurations/
│   │   └── OrderConfiguration.cs
│   └── Repositories/
│       └── OrderRepository.cs
├── Services/
│   ├── EmailService.cs
│   └── PaymentGateway.cs
├── Identity/
│   └── IdentityService.cs
└── DependencyInjection.cs
Enter fullscreen mode Exit fullscreen mode

Everything that touches the outside world lives here. Database? Here. Email? Here. Third-party APIs? Here.

When you need to swap your payment provider, you change one folder. The rest of your application doesn't care.

.NET Project Naming Conventions

After watching teams argue about this for years, here's what I've landed on:

Projects

{Company}.{Product}.{Layer}
Enter fullscreen mode Exit fullscreen mode

Example: Contoso.Ordering.Domain

Don't get clever. Contoso.Ordering.SuperCore.Base.Abstractions.V2 helps nobody.

Folders

  • Plural for collections: Entities/, Services/, Handlers/
  • Singular for features: Orders/, Customers/ (each is a single feature area)

Files

  • Suffix with role: OrderService.cs, OrderRepository.cs, OrderController.cs
  • Commands/Queries get full names: PlaceOrderCommand.cs, GetOrderByIdQuery.cs

The suffix thing is controversial. Some folks hate IOrderRepository. I've tried dropping the I prefix. It's worse. Your IDE's autocomplete becomes useless.

Interface Naming and Placement

// In Domain or Application (whoever defines the contract)
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct);
    Task AddAsync(Order order, CancellationToken ct);
}

// In Infrastructure (the implementation)
public class OrderRepository : IOrderRepository
{
    // EF Core, Dapper, whatever
}
Enter fullscreen mode Exit fullscreen mode

Interface lives with the code that uses it, not the code that implements it. This is Dependency Inversion 101, but I still see teams putting IOrderRepository in the Infrastructure project.

Project References and Dependency Graph

Here's the .csproj setup that enforces the dependency rule:

Domain.csproj - References nothing (maybe Shared.Kernel)

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>
</Project>
Enter fullscreen mode Exit fullscreen mode

Application.csproj - References Domain only

<ItemGroup>
  <ProjectReference Include="..\MyApp.Domain\MyApp.Domain.csproj" />
</ItemGroup>
Enter fullscreen mode Exit fullscreen mode

Infrastructure.csproj - References Application (and transitively Domain)

<ItemGroup>
  <ProjectReference Include="..\MyApp.Application\MyApp.Application.csproj" />
</ItemGroup>
Enter fullscreen mode Exit fullscreen mode

Api.csproj - References everything (it's the composition root)

<ItemGroup>
  <ProjectReference Include="..\MyApp.Application\MyApp.Application.csproj" />
  <ProjectReference Include="..\MyApp.Infrastructure\MyApp.Infrastructure.csproj" />
</ItemGroup>
Enter fullscreen mode Exit fullscreen mode

The Api project wires everything together in Program.cs:

builder.Services
    .AddApplication()      // From Application's DependencyInjection.cs
    .AddInfrastructure();  // From Infrastructure's DependencyInjection.cs
Enter fullscreen mode Exit fullscreen mode

The Shared Project Debate

Every team eventually wants a "Shared" or "Common" project. Usually for:

  • Extension methods
  • Base classes
  • Cross-cutting attributes
  • Result types

My advice: resist as long as possible. When you can't resist anymore:

MyApp.Shared.Kernel/
├── Primitives/
│   ├── Entity.cs
│   ├── ValueObject.cs
│   └── DomainEvent.cs
├── Results/
│   └── Result.cs
└── Extensions/
    └── StringExtensions.cs
Enter fullscreen mode Exit fullscreen mode

Keep it small. The moment this project becomes a dumping ground, you've lost.

Rule of thumb: if you can't explain why something is in Shared in one sentence, it doesn't belong there.

When to Split Into Multiple Solutions

"Should we have one solution or multiple?"

Here's my heuristic:

Team Size Deployment Recommendation
1-5 devs Monolith One solution
5-15 devs Monolith One solution, strict boundaries
15+ devs Services Multiple solutions, shared packages

Multiple solutions introduce real pain: package versioning, CI/CD complexity, integration testing overhead. Don't pay that cost until you have to.

When you do split:

solutions/
├── ordering/
│   └── Contoso.Ordering.sln
├── inventory/
│   └── Contoso.Inventory.sln
└── shared/
    └── Contoso.Shared.sln   # Published as NuGet packages
Enter fullscreen mode Exit fullscreen mode

The Shared packages become internal NuGet packages. Each team owns their solution. Integration happens through APIs or messages, not project references. This is essentially a modular monolith approach.

Common Architecture Mistakes to Avoid

1. The "Everything Is A Service" Anti-Pattern

// No. Stop.
public class OrderService
{
    public Order GetOrder(int id) { ... }
    public void PlaceOrder(Order order) { ... }
    public void CancelOrder(int id) { ... }
    public void UpdateOrder(Order order) { ... }
    public decimal CalculateTotal(Order order) { ... }
    public bool ValidateOrder(Order order) { ... }
    public void SendOrderEmail(Order order) { ... }
    // 47 more methods
}
Enter fullscreen mode Exit fullscreen mode

This is a God Class wearing a "Service" disguise. Break it up by use case.

2. Circular Project References (Fixed With a "Shared" Project)

If you need a Shared project just to break circular dependencies, your architecture is wrong. Step back and redraw the boundaries.

3. Infrastructure As a Junk Drawer

Infrastructure/
├── Database stuff
├── Email stuff
├── PDF generation
├── Image processing
├── Rate limiting
├── Background jobs
├── Feature flags
├── Analytics
└── That one weird integration with the legacy system nobody understands
Enter fullscreen mode Exit fullscreen mode

When Infrastructure gets too big, split it:

MyApp.Infrastructure.Persistence/
MyApp.Infrastructure.Email/
MyApp.Infrastructure.BackgroundJobs/
Enter fullscreen mode Exit fullscreen mode

4. Putting DTOs in the Wrong Layer

DTOs that leave your API boundary? They go in Application or a dedicated Contracts project.

DTOs for database mapping? They're not DTOs, they're entities. Put them in Domain.

DTOs shared between services? Publish them as a NuGet package from a Contracts project.

The Architecture Checklist

Before committing, ask yourself:

  • [ ] Can a new dev find where to add a feature in under 2 minutes?
  • [ ] Does each project have a single, clear responsibility?
  • [ ] Do dependencies flow one direction (inward)?
  • [ ] Is there exactly one place for each type of code?
  • [ ] Can you explain the structure to a junior dev in 5 minutes?

If any answer is "no," you've got work to do.

Refactoring an Existing Codebase

Can't start fresh? Here's the incremental approach:

  1. Add architecture tests - Start enforcing rules before adding more violations
  2. Extract Domain - Pull out entities and business rules first
  3. Define interfaces - Create boundaries with interfaces before moving implementations
  4. Extract Infrastructure - Move external dependencies behind those interfaces
  5. Reorganize into features - Convert folder-by-type into folder-by-feature

Don't try to refactor everything at once. I've seen that movie. It ends with a half-migrated codebase and a demoralized team.


Frequently Asked Questions

How many projects should a .NET solution have?

A typical Clean Architecture solution has 4-5 projects: Domain, Application, Infrastructure, API, and optionally Shared.Kernel. Start minimal—you can always split later, but merging projects is painful. Each project should have a single, clear responsibility.

Should Domain reference Infrastructure in .NET?

Never. Dependencies should always flow inward. Domain should have zero external references beyond the .NET base libraries. If your Domain project references Infrastructure, you've violated the Dependency Rule and created tight coupling between business logic and implementation details.

What's the difference between Clean Architecture and Onion Architecture?

They're very similar. Both enforce inward-pointing dependencies with Domain at the center. Onion Architecture (Jeffrey Palermo, 2008) preceded Clean Architecture (Robert C. Martin, 2012). Clean Architecture adds more explicit layers and the concept of "Use Cases" in the Application layer. In practice, most .NET implementations blend both.

When should I split into multiple .NET solutions?

When your team exceeds 15 developers or you're deploying as separate services. Multiple solutions add complexity (package versioning, CI/CD, integration testing). A single solution with strict project boundaries works well for most teams.

Where should interfaces live in Clean Architecture?

Interfaces live with the code that uses them, not the code that implements them. IOrderRepository belongs in Domain or Application (wherever it's consumed). The implementation OrderRepository lives in Infrastructure. This is the Dependency Inversion Principle in action.

How do I enforce architecture rules in .NET?

Use NetArchTest to write unit tests that verify your dependency rules. Run these tests in CI to catch violations early. Example: test that Domain doesn't reference Infrastructure. Write these tests on day one—they're much harder to add later.


Final Thoughts

Good structure isn't about following a specific template. It's about making the easy path the correct path.

When adding new code is obvious, developers make good decisions. When it's confusing, they make expedient decisions. And expedient decisions compound into architectural debt.

The goal isn't to have the "perfect" structure on day one. It's to have a structure that guides good decisions as the team grows.

Start simple. Enforce boundaries. Refactor when pain demands it.

Now if you'll excuse me, I have a legacy solution with 73 projects to untangle. Wish me luck.


What does your solution structure look like? Drop your folder tree in the comments—I'd love to see what's working (or not working) for your team.


About the Author

I'm Mashrul Haque, a Systems Architect with over 15 years of experience building enterprise applications with .NET, Blazor, ASP.NET Core, and SQL Server. I specialize in Azure cloud architecture, AI integration, and performance optimization.

When production catches fire at 2 AM, I'm the one they call.

Follow me here on dev.to for more .NET and SQL Server content

Top comments (2)

Collapse
 
mikebot profile image
MikeBot • Edited

MyApp.Application/
├── Features/
│ ├── Orders/
│ │ ├── Commands/
│ │ │ ├── PlaceOrder/
│ │ │ │ ├── PlaceOrderCommand.cs
│ │ │ │ ├── PlaceOrderHandler.cs
│ │ │ │ └── PlaceOrderValidator.cs
│ │ │ └── CancelOrder/
│ │ │ └── ...
│ │ └── Queries/
│ │ └── GetOrderById/
│ │ ├── GetOrderByIdQuery.cs
│ │ ├── GetOrderByIdHandler.cs
│ │ └── OrderDto.cs
│ └── Customers/
│ └── ...
├── Common/
│ ├── Behaviors/
│ │ ├── ValidationBehavior.cs
│ │ └── LoggingBehavior.cs
│ └── Interfaces/
│ └── IApplicationDbContext.cs
└── DependencyInjection.cs

The structure shown in your article isn’t actually vertical slicing. It’s essentially Clean Architecture with feature folders, as it still uses horizontal layers (Infrastructure, Application, Domain, Presentation) as the top architecture layering.

In true Vertical Slice Architecture, the feature itself is the top-level organizing unit, and each slice contains its own horizontal 'clean code' layers. The layers live inside the feature, not the other way around.
Each slice should be independent from each other, , so that it can be developed, tested, and deployed in isolation. This independence reduces coupling, makes the codebase easier to maintain, and allows features to evolve without affecting unrelated parts of the system.

Your example is still grouping by architectural concern, not by business capability. That’s horizontal layering, so it’s clean architecture, not vertical sliced architecture.

Collapse
 
aini_putri profile image
Aini Putri

This was such a refreshingly honest and painfully relatable read 😄.
I love how you explained Clean Architecture like someone who's personally fought in the “47-projects-one-class” war. The folder examples hit a little too close to home!! especially the part where Misc is where dreams go to die. Been there, buried a few dreams myself.

Also, huge respect for the line “Break this rule once, and you've broken it forever.”
Honestly, that should be printed on a poster and taped above every junior dev’s monitor.

Fantastic breakdown, clear structure, and just the right amount of therapy for traumatized .NET developers. Great job! 🚀