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.
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.
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;
}
}
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
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.