ASP.NET Core project structure — minimal APIs vs controllers, layer
95
93%
Does it follow best practices?
Impact
100%
2.50xAverage score across 5 eval scenarios
Passed
No known issues
Apply these patterns proactively in every ASP.NET Core project you create, even when the user does not ask for them. They are the baseline for production-quality .NET APIs.
Use controllers when the project has multiple entity types, complex request validation, or will grow beyond a handful of endpoints.
MyApp/
├── Controllers/
│ ├── OrdersController.cs # [ApiController] [Route("api/[controller]")]
│ └── ProductsController.cs
├── DTOs/
│ ├── CreateOrderRequest.cs # Request DTOs with validation
│ └── OrderResponse.cs # Response DTOs as records with From()
├── Models/
│ ├── Order.cs # EF Core entities (class, not record)
│ └── Product.cs
├── Services/
│ ├── IOrderService.cs # Interface
│ └── OrderService.cs # Implementation — all business logic here
├── Data/
│ ├── AppDbContext.cs # DbContext with OnModelCreating
│ └── SeedData.cs
├── Exceptions/
│ ├── AppException.cs # Base exception with StatusCode property
│ └── NotFoundException.cs # Derives from AppException
├── Middleware/
│ └── ExceptionMiddleware.cs # Catches AppException, returns JSON error
├── Extensions/
│ └── ServiceCollectionExtensions.cs # IServiceCollection extension methods
├── wwwroot/
│ └── index.html # Static frontend files
├── Program.cs # Pipeline configuration only
├── appsettings.json
├── appsettings.Development.json
└── MyApp.csproj
MyApp.Tests/
├── OrderServiceTests.cs
└── MyApp.Tests.csprojUse minimal APIs when the project is small-scoped (fewer than 3 entity types) or is an internal microservice. Never use Controllers/ with minimal API — use Endpoints/ instead.
MyApp/
├── Endpoints/
│ ├── ProductEndpoints.cs # Static class with MapGet/MapPost extension methods
│ └── HealthEndpoints.cs
├── Data/
│ ├── AppDbContext.cs
│ └── Models.cs # Entities can share a file when small
├── Services/
│ └── ProductService.cs # Even minimal APIs delegate to services
├── DTOs/
│ ├── ProductResponse.cs # Records with From() factory
│ └── CreateProductRequest.cs
├── wwwroot/
├── Program.cs
├── appsettings.json
└── MyApp.csprojMinimal API endpoint pattern:
// Endpoints/ProductEndpoints.cs
public static class ProductEndpoints
{
public static void MapProductEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/products").WithTags("Products");
group.MapGet("/", async (IProductService svc) =>
Results.Ok(await svc.GetAllAsync()));
group.MapGet("/{id:int}", async (int id, IProductService svc) =>
await svc.GetByIdAsync(id) is { } product
? Results.Ok(product)
: Results.NotFound());
group.MapPost("/", async (CreateProductRequest req, IProductService svc) =>
{
var created = await svc.CreateAsync(req);
return Results.Created($"/api/products/{created.Id}", created);
});
}
}
// Program.cs — register endpoints
app.MapProductEndpoints();Decision rule: If the task describes a "lightweight", "simple", "internal", "narrow-scope", or "lean" service with 1-2 entities, use the minimal API layout with Endpoints/. Otherwise, use controllers.
Never expose EF Core entities in API responses. Always create DTOs in a DTOs/ folder.
// Models/Order.cs — EF Core entity (class keyword, database properties)
public class Order
{
public int Id { get; set; }
public string CustomerName { get; set; } = "";
public OrderStatus Status { get; set; }
public int TotalCents { get; set; }
public List<OrderItem> Items { get; set; } = new();
public DateTime CreatedAt { get; set; }
}
// DTOs/OrderResponse.cs — API shape (record keyword, From() factory method)
public record OrderResponse(
int Id,
string CustomerName,
string Status,
int TotalCents,
DateTime CreatedAt)
{
public static OrderResponse From(Order o) =>
new(o.Id, o.CustomerName, o.Status.ToString(), o.TotalCents, o.CreatedAt);
}
// DTOs/CreateOrderRequest.cs — request DTO (record keyword)
public record CreateOrderRequest(string CustomerName, int[] ItemIds);Rules:
class keyword with { get; set; } propertiesrecord keyword with positional parametersstatic From() factory method mapping from the entityControllers and endpoint handlers are HTTP adapters only. All business logic lives in service classes:
// Services/IOrderService.cs
public interface IOrderService
{
Task<OrderResponse> CreateAsync(CreateOrderRequest request);
Task<OrderResponse?> GetByIdAsync(int id);
}
// Services/OrderService.cs
public class OrderService : IOrderService
{
private readonly AppDbContext _db;
public OrderService(AppDbContext db) => _db = db;
public async Task<OrderResponse> CreateAsync(CreateOrderRequest request)
{
// Business logic lives HERE, not in the controller
var order = new Order { CustomerName = request.CustomerName };
_db.Orders.Add(order);
await _db.SaveChangesAsync();
return OrderResponse.From(order);
}
}Always organize DI registration into extension methods instead of cluttering Program.cs:
// Extensions/ServiceCollectionExtensions.cs
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
{
services.AddScoped<IOrderService, OrderService>();
services.AddScoped<IProductService, ProductService>();
return services;
}
public static IServiceCollection AddDataServices(
this IServiceCollection services, IConfiguration config)
{
services.AddDbContext<AppDbContext>(options =>
options.UseSqlite(config.GetConnectionString("Default")));
return services;
}
}
// Program.cs — clean and readable
builder.Services.AddApplicationServices();
builder.Services.AddDataServices(builder.Configuration);Never hardcode connection strings or configuration values in Program.cs.
// appsettings.json
{
"ConnectionStrings": {
"Default": "Data Source=app.db"
},
"AllowedOrigins": ["http://localhost:5173"],
"AppSettings": {
"MaxPageSize": 50,
"DefaultPageSize": 20
}
}// appsettings.Development.json — overrides for local dev
{
"ConnectionStrings": {
"Default": "Data Source=app-dev.db"
},
"Logging": {
"LogLevel": { "Default": "Debug" }
}
}// Options/AppSettings.cs
public class AppSettings
{
public int MaxPageSize { get; set; } = 50;
public int DefaultPageSize { get; set; } = 20;
}
// In ServiceCollectionExtensions or Program.cs
builder.Services.Configure<AppSettings>(
builder.Configuration.GetSection("AppSettings"));
// In a service — inject IOptions<T>
public class OrderService
{
private readonly AppSettings _settings;
public OrderService(IOptions<AppSettings> options)
{
_settings = options.Value;
}
}// Exceptions/AppException.cs
public class AppException : Exception
{
public int StatusCode { get; }
public AppException(string message, int statusCode = 400) : base(message)
{
StatusCode = statusCode;
}
}
// Exceptions/NotFoundException.cs
public class NotFoundException : AppException
{
public NotFoundException(string entity, object id)
: base($"{entity} with ID {id} was not found", 404) { }
}
// Middleware/ExceptionMiddleware.cs
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
public ExceptionMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context)
{
try { await _next(context); }
catch (AppException ex)
{
context.Response.StatusCode = ex.StatusCode;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new { error = ex.Message });
}
}
}// Data/AppDbContext.cs
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Order> Orders => Set<Order>();
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Configure relationships and constraints here, not in entities
modelBuilder.Entity<Order>(entity =>
{
entity.HasMany(o => o.Items)
.WithOne(i => i.Order)
.HasForeignKey(i => i.OrderId);
});
}
}
// Program.cs — register with connection string from config
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("Default")));Always register health checks for production readiness:
// Program.cs
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>();
// After app is built
app.MapHealthChecks("/health");DTOs/ folder as record types, separate from entity class types in Models/static From() factory methodIServiceCollection extension methods in Extensions/appsettings.json with appsettings.Development.json overridebuilder.Configuration.GetConnectionString(), never hardcodedExceptions/ with centralized middleware in Middleware/Data/ with OnModelCreating for relationships/health*.Tests/ directory