
N-Layer Architecture in .NET: A Practical Guide for Web APIs
I started with .NET back in the Framework 4.6 days, before .NET Core existed. No cross-platform support, plenty of limitations, and most of my work was MVC with Razor views, ADO.NET for data access, and business logic buried in stored procedures. It worked, but it was a different world.
Then .NET Core showed up — rewritten from scratch, cross-platform, Docker-friendly, runs on Linux. And it turned out to be a fantastic fit for building Web APIs, especially with Visual Studio handling most of the project scaffolding through its UI. That's what this post is about: how I structure Web API projects using an n-layer architecture that's held up well across small and medium-sized projects.
The single-project approach (and why I skip it)
The simplest way to layer a Web API is to keep everything in one project: a Repositories folder, a Services folder, a Controllers folder. It works, and for a tiny project it's honestly fine.
I don't use it, though — not even for small projects. The problem isn't day one, it's month six. Projects grow. Files pile up. And once everything lives in a single project split only by folders, you start feeling it: builds slow down, navigating the codebase gets messier, and the "just add another folder" approach stops scaling with the team or the codebase. I'd rather pay a small setup cost upfront than refactor a monolith later.
The structure I actually use
Instead, I split the solution into three class libraries plus the Web API project — four projects total. Let's say the project is called StockManagement:
StockManagement.Api— the Web API project. This is the entry point and the consumer of everything else.StockManagement.Persistence— models, DbContexts, and migrations (if you're doing code-first). This is optional as a separate project; some setups fold it into the repositories layer.StockManagement.Repositories— repository interfaces and implementations, sitting on top of persistence.StockManagement.Services— business logic: contracts, service implementations, response codes, response messages, domain models.StockManagement.Endpoints— controllers, request/response models, route definitions (never hardcoded strings), and the sharedApiResponsemodel.
On top of that, a shared kernel class library holds anything genuinely common — a Result model used by both repositories and services is a good example. It keeps the layers from repeating the same primitives everywhere.
Each layer that needs services registered gets its own dependency injection extension. The repositories layer registers the DbContext and repository classes, the services layer registers its own services, and so on:
// StockManagement.Repositories/DependencyInjection.cs
public static class RepositoriesServiceExtensions
{
public static IServiceCollection AddRepositoriesLayer(this IServiceCollection services, string connectionString)
{
services.AddDbContext<StockManagementDbContext>(options =>
options.UseSqlServer(connectionString));
services.AddScoped<IProductRepository, ProductRepository>();
services.AddScoped<IStockRepository, StockRepository>();
return services;
}
}
// StockManagement.Services/DependencyInjection.cs
public static class ServicesLayerExtensions
{
public static IServiceCollection AddServicesLayer(this IServiceCollection services)
{
services.AddScoped<IProductService, ProductService>();
services.AddScoped<IStockService, StockService>();
return services;
}
}
Each layer owns its own wiring. The API project just calls these extension methods in Program.cs and doesn't need to know what's inside.
Making the endpoints layer truly modular
Here's the part I like most about this setup. Because controllers live in their own class library (StockManagement.Endpoints), that assembly isn't tied to a single host project. ASP.NET Core can discover controllers from an external assembly using AddApplicationPart, which means any API project can pull in the endpoints layer with one line:
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRepositoriesLayer(builder.Configuration.GetConnectionString("Default"));
builder.Services.AddServicesLayer();
builder.Services.AddControllers()
.AddApplicationPart(typeof(StockManagement.Endpoints.ProductController).Assembly);
var app = builder.Build();
app.MapControllers();
app.Run();
That single .AddApplicationPart(...) call tells ASP.NET Core to scan StockManagement.Endpoints for controllers, just as if they were defined in the API project itself. If you ever need a second host — say, an internal admin API that reuses the same endpoints — you just point it at the same assembly.
File structure
For the folder layout, I keep a Src folder at the solution root, next to the .sln file. Inside it, one folder per project:
StockManagement.sln
Src/
├── StockManagement.Api/
│ └── Src/
│ └── (Program.cs, appsettings.json, etc.)
├── StockManagement.Endpoints/
│ ├── Src/
│ │ ├── Controllers/
│ │ ├── Contracts/ # request/response models
│ │ ├── Routes/
│ │ └── DependencyInjection.cs
│ └── Tests/
│ ├── StockManagement.Endpoints.UnitTests/
│ └── StockManagement.Endpoints.IntegrationTests/
├── StockManagement.Services/
│ ├── Src/
│ │ ├── Contracts/
│ │ ├── Implementations/
│ │ ├── ResponseCodes/
│ │ └── DependencyInjection.cs
│ └── Tests/
├── StockManagement.Repositories/
│ ├── Src/
│ │ ├── Contracts/
│ │ ├── Implementations/
│ │ └── DependencyInjection.cs
│ └── Tests/
└── StockManagement.SharedKernel/
└── Src/
└── Result.cs
Every project gets its own Src and Tests folders — unit tests and integration tests as separate projects under Tests. You can add more (docs, scripts) as needed; it depends on the project.
Is this overkill?
For a genuinely tiny app, maybe. But I've used this structure — or a more customized version of it — across several client projects, and it holds up well for small to medium-sized applications. The real-world versions I've built usually add more layers or extra separation depending on the complexity of the domain, but what's above is the minimal, reusable core: split concerns, modular libraries, and a project that can grow without turning into a folder-soup monolith.
If your app fits the "small to medium" bracket, this is a solid starting point. Adjust it as your actual requirements show up — don't build for complexity you don't have yet.