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
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/
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
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();
}
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.
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
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
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}
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
}
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>
Application.csproj - References Domain only
<ItemGroup>
<ProjectReference Include="..\MyApp.Domain\MyApp.Domain.csproj" />
</ItemGroup>
Infrastructure.csproj - References Application (and transitively Domain)
<ItemGroup>
<ProjectReference Include="..\MyApp.Application\MyApp.Application.csproj" />
</ItemGroup>
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>
The Api project wires everything together in Program.cs:
builder.Services
.AddApplication() // From Application's DependencyInjection.cs
.AddInfrastructure(); // From Infrastructure's DependencyInjection.cs
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
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
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
}
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
When Infrastructure gets too big, split it:
MyApp.Infrastructure.Persistence/
MyApp.Infrastructure.Email/
MyApp.Infrastructure.BackgroundJobs/
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:
- Add architecture tests - Start enforcing rules before adding more violations
- Extract Domain - Pull out entities and business rules first
- Define interfaces - Create boundaries with interfaces before moving implementations
- Extract Infrastructure - Move external dependencies behind those interfaces
- 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)