.NET Programming With Me

Structuring a .NET 8 Web API

A .NET 8 Web API that starts as a tidy three-file project has a way of becoming a navigational puzzle by the time it reaches twenty endpoints and three developers. The folder structure you choose in week one silently shapes every refactor, every onboarding session, and every pull request review that follows. Getting it right early is far cheaper than untangling it later.

By the end of this post you will have a concrete, battle-tested folder layout, a layer model with clear responsibilities, and a set of naming conventions you can copy directly into your next project or use to evaluate your current one.


1 Prerequisites

Before starting, make sure you have:

  • .NET 8 SDK (8.0.100 or later)
  • Visual Studio 2022 17.8+, JetBrains Rider, or VS Code with the C# Dev Kit extension
  • Familiarity with dependency injection and the ASP.NET Core request pipeline
Note: The folder structure described here targets controller-based APIs. If your team has chosen Minimal APIs, the layer model and naming conventions still apply; only the Controllers folder becomes an Endpoints folder of static extension classes.

2 Start with a solution, not just a project

Even for a single API, wrapping it in a solution from day one pays dividends when you add a test project or an SDK library later. Create the solution structure with the CLI so the layout is deliberate rather than IDE-generated defaults.

// Run these in your terminal — not C# code, shown here for clarity
// dotnet new sln -n Catalog
// dotnet new webapi -n Catalog.Api --no-openapi
// dotnet new classlib -n Catalog.Core
// dotnet new classlib -n Catalog.Infrastructure
// dotnet new xunit -n Catalog.Tests
// dotnet sln add **/*.csproj

This gives you a four-project solution from the start. Catalog.Api owns HTTP concerns. Catalog.Core owns domain logic and interfaces. Catalog.Infrastructure owns data access and external integrations. Catalog.Tests tests all three. Project references flow inward: Api references Core and Infrastructure; Infrastructure references Core; Core references nothing in the solution.

Tip: Resist the urge to collapse everything into a single project to keep things simple. A single project forces you to enforce layer boundaries by convention alone; separate projects make accidental cross-layer dependencies a compile error rather than a code-review catch.

3 The folder layout inside each project

The structure below is organized by layer first and by feature second inside each layer. This is the layout that scales most predictably for teams of two to ten developers on a single API.

Catalog.Api

// Catalog.Api/
// ├── Controllers/
// │   ├── ProductsController.cs
// │   └── CategoriesController.cs
// ├── Filters/
// │   └── ValidationExceptionFilter.cs
// ├── Models/                        // Request and response DTOs for the HTTP layer
// │   ├── Requests/
// │   │   ├── CreateProductRequest.cs
// │   │   └── UpdateProductRequest.cs
// │   └── Responses/
// │       └── ProductResponse.cs
// ├── Extensions/
// │   └── ServiceCollectionExtensions.cs
// └── Program.cs

Catalog.Core

// Catalog.Core/
// ├── Domain/
// │   ├── Entities/
// │   │   └── Product.cs
// │   └── ValueObjects/
// │       └── Money.cs
// ├── Interfaces/
// │   ├── IProductRepository.cs
// │   └── IProductService.cs
// └── Services/
//     └── ProductService.cs

Catalog.Infrastructure

// Catalog.Infrastructure/
// ├── Persistence/
// │   ├── CatalogDbContext.cs
// │   ├── Configurations/
// │   │   └── ProductConfiguration.cs
// │   └── Repositories/
// │       └── ProductRepository.cs
// └── Extensions/
//     └── ServiceCollectionExtensions.cs

Keep the Extensions folder in each project; it is where IServiceCollection extension methods live. This prevents Program.cs from becoming a registration dump that grows to 200 lines.


4 Naming conventions that communicate intent

Consistent naming reduces the cognitive load of reading unfamiliar code. Apply these rules across the entire solution and enforce them in pull request reviews.

Controllers

Use the plural resource name followed by Controller: ProductsController, OrdersController, CategoriesController. Route templates use the conventional [controller] token so the route mirrors the class name automatically. Every controller action should return Task<IActionResult> or a typed ActionResult<T> rather than a bare type, so the framework can generate accurate OpenAPI response descriptions.

// Controllers/ProductsController.cs
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;

    public ProductsController(IProductService productService)
        => _productService = productService;

    [HttpGet("{id:int}")]
    [ProducesResponseType(typeof(ProductResponse), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult<ProductResponse>> GetById(
        int id, CancellationToken ct)
    {
        var product = await _productService.GetByIdAsync(id, ct);
        return product is null ? NotFound() : Ok(product);
    }
}

DTOs and request/response models

Name request bodies with the verb and the resource: CreateProductRequest, UpdateProductRequest. Name response models with the resource and the suffix Response: ProductResponse, ProductSummaryResponse. Never expose your domain entity directly as an API response type; the shape of your persistence model and the shape of your public API contract will diverge, and the day they need to diverge is not the day you want to write a refactor.

Interfaces and services

Prefix interfaces with I and name them after the capability: IProductService, IProductRepository. Implementations drop the prefix: ProductService, ProductRepository. Avoid descriptive suffixes like ProductServiceImpl or ConcreteProductRepository; they carry no information a developer cannot infer from the file location.

// Core/Interfaces/IProductService.cs
public interface IProductService
{
    Task<ProductResponse?> GetByIdAsync(int id, CancellationToken ct = default);
    Task<IReadOnlyList<ProductSummaryResponse>> GetAllAsync(CancellationToken ct = default);
    Task<int> CreateAsync(CreateProductRequest request, CancellationToken ct = default);
    Task UpdateAsync(int id, UpdateProductRequest request, CancellationToken ct = default);
    Task DeleteAsync(int id, CancellationToken ct = default);
}

5 Dependency registration as extension methods

Each project that registers services should expose a single AddX extension method on IServiceCollection. Program.cs then reads as a declaration of intent rather than an implementation listing.

// Infrastructure/Extensions/ServiceCollectionExtensions.cs
public static class InfrastructureServiceCollectionExtensions
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddDbContext<CatalogDbContext>(opts =>
            opts.UseSqlServer(configuration.GetConnectionString("Catalog")));

        services.AddScoped<IProductRepository, ProductRepository>();

        return services;
    }
}
// Api/Extensions/ServiceCollectionExtensions.cs
public static class ApiServiceCollectionExtensions
{
    public static IServiceCollection AddApplicationServices(
        this IServiceCollection services)
    {
        services.AddScoped<IProductService, ProductService>();
        return services;
    }
}
// Program.cs — lean and declarative
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddApplicationServices();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

Notice that Program.cs does not know which concrete types are registered. Adding a new service or repository is a change inside one extension method, not a change to the entry point. This matters when merge conflicts and code ownership come into play across a team.


6 Running and verifying the structure

With the structure in place, run the project and open the Swagger UI to verify that your controllers, their routes, and their response types are all reflected correctly in the generated document.

// From the solution root
// dotnet build
// dotnet run --project Catalog.Api

// Expected output includes:
// info: Microsoft.Hosting.Lifetime[14]
//       Now listening on: https://localhost:7001
// Navigate to https://localhost:7001/swagger to see the generated API document

At this point your Swagger UI should list every controller action with its route, HTTP method, and declared response types. If a controller action is missing, check that MapControllers() is called in Program.cs and that the controller class is in the same assembly as the API project or is explicitly included via AddApplicationPart.


Wrapping up

A well-structured .NET 8 Web API separates concerns across projects, organizes code by layer inside each project, applies consistent naming conventions, and keeps Program.cs declarative through extension methods. None of these choices are exotic; their value is in being applied uniformly so that every developer on the team can navigate, extend, and test the codebase without a guide.

The next post in this series covers validation: specifically whether FluentValidation or Data Annotations is the right choice for your API, with working code for both.

Got a question or ran into a problem? Drop a comment below and I will reply.

No comments: