Layered Architecture & DI Registration

A clean split into API / Application / Domain / Infrastructure layers, where each layer registers its own services through an extension method. Program.cs stays a short, readable list of intent.

The layers

  • Domain — entities, enums, and repository/service interfaces. No dependencies on anything else.
  • Application — business logic (services), DTOs, validation, mapping extensions. Depends only on Domain.
  • Shared — the DTOs and contracts that cross a process boundary: request/response shapes a controller returns and a frontend deserializes. Referenced by both API and every frontend project; references nothing else in the solution.
  • Infrastructure — EF Core, repository implementations, external integrations (hashing, email, etc). Implements Domain's interfaces.
  • API — controllers, auth wiring, Swagger, rate limiting. Depends on Application + Infrastructure.

Why Shared is its own project

Application's DTOs are internal to the backend — a service can return whatever shape is convenient. Shared is different: it's the contract. Every frontend in the solution (Blazor, Angular, a WinForms app, a CLI tool) references it directly and deserializes exactly what the API serializes, with zero duplication of the DTO shape across projects.

project references
YourApp.API             -> YourApp.Application, YourApp.Infrastructure, YourApp.Shared
YourApp.Application      -> YourApp.Domain, YourApp.Shared
YourApp.UI (Blazor)      -> YourApp.Shared   (never touches Application/Infrastructure directly)
YourApp.WebClient (Angular) -> generates its TS types from YourApp.Shared, or duplicates by hand
YourApp.Cli               -> YourApp.Shared, YourApp.Application (see Building a CLI Tool)

A DTO belongs in Shared the moment more than one project needs to agree on its shape — a request body, a response body, an enum sent over the wire. Everything else (entities, internal service-to-service DTOs, mapping extensions) stays in Domain/Application where only the backend can see it.

Each layer exposes a "RegisterX" extension

Rather than dumping every AddScoped call into Program.cs, each project gets one static Dependencies class with a public entry point and private helpers per concern.

Application/Dependencies.cs
public static class Dependencies
{
    public static IServiceCollection RegisterApplication(this IServiceCollection services)
    {
        services.RegisterServices();
        return services;
    }

    private static IServiceCollection RegisterServices(this IServiceCollection services)
    {
        services.AddScoped<ITokenService, TokenService>();
        services.AddScoped<IAuthService, AuthService>();
        services.AddScoped<IResourceService, ResourceService>();
        return services;
    }
}
Infrastructure/Dependencies.cs
public static class Dependencies
{
    public static IServiceCollection RegisterInfrastructure(this IServiceCollection services, string connectionString)
    {
        services.RegisterPersistence(connectionString);
        services.RegisterRepositories();
        services.RegisterHelpers();
        return services;
    }

    private static IServiceCollection RegisterPersistence(this IServiceCollection services, string connectionString)
    {
        services.AddDbContext<AppDbContext>(options =>
        {
            options.UseSqlServer(connectionString, sql =>
                sql.MigrationsHistoryTable("Migrations"));
        });
        return services;
    }

    private static IServiceCollection RegisterRepositories(this IServiceCollection services)
    {
        // open generic registration covers every closed IRepository<T>
        services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
        services.AddScoped<IUserRepository, UserRepository>();
        return services;
    }

    private static IServiceCollection RegisterHelpers(this IServiceCollection services)
    {
        services.AddScoped<IPassword, Password>();
        return services;
    }
}

Program.cs reads as a checklist

Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.RegisterAPI(builder.Configuration);
builder.Services.RegisterApplication();
builder.Services.RegisterInfrastructure(
    builder.Configuration.GetConnectionString("DefaultConnection")
        ?? throw new InvalidOperationException("Missing connection string"));

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    context.Database.Migrate();
}

if (app.Environment.IsDevelopment())
{
    app.UseSwagger(o => o.RouteTemplate = "{documentName}/specifications.json");
    app.UseSwaggerUI(o =>
    {
        o.RoutePrefix = "api";
        o.SwaggerEndpoint("specifications.json", "API");
    });
}

app.UseCors();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.UseExceptionHandler("/Error");
app.MapControllers();
app.Run();
Why bother: each layer's registration is testable and reviewable in isolation, and adding a new service means touching one small private method instead of a growing Program.cs.