DEV Community

Cover image for How to Design a Maintainable .NET Solution Structure for Growing Teams
Mashrul Haque
Mashrul Haque

Posted on

How to Design a Maintainable .NET Solution Structure for Growing Teams

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.

The Problem With "It Works On My Machine" Architecture

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 Folder 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 (Or: Why Your Architecture Falls Apart)

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.

Inside the Projects: Folder Conventions That Don't Suck

Domain Project

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

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" 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

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.

Naming Conventions (The Stuff Nobody Agrees On)

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.

Interfaces

// 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: The Hidden 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.

Common Mistakes I Keep Seeing

1. The "Everything Is A Service" 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 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.

Getting Started With an Existing Mess

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.

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.


About me

I'm a Systems Architect who is passionate about distributed systems, .NET clean code, logging, performance, and production debugging.

🧑‍💻 Check out my projects on GitHub: mashrulhaque

👉 Follow me here on dev.to for more .NET posts

Top comments (0)