CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/aspnet-project-structure

ASP.NET Core project structure — minimal APIs vs controllers, layer

95

2.50x
Quality

93%

Does it follow best practices?

Impact

100%

2.50x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/aspnet-project-structure/

name:
aspnet-project-structure
description:
ASP.NET Core project structure — Clean Architecture layers, minimal APIs vs controllers, IServiceCollection extensions, IOptions pattern, EF Core DbContext configuration, and proper DTO separation. Apply proactively when building any .NET API, even when the user does not ask for structure guidance.
keywords:
aspnet project structure, dotnet project layout, aspnet core minimal api, aspnet controllers, clean architecture dotnet, aspnet layers, aspnet dto, ioptions pattern, ef core dbcontext, service collection extensions, fluent validation, health checks, swagger openapi
license:
MIT

ASP.NET Core Project Structure

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.


1. Choose the Right API Style

Controllers (3+ entity types or complex validation)

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.csproj

Minimal API (1-2 entity types, internal/lightweight service)

Use 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.csproj

Minimal 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.


2. DTOs — Always Separate from Models

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:

  • Models use class keyword with { get; set; } properties
  • DTOs use record keyword with positional parameters
  • Every response DTO has a static From() factory method mapping from the entity
  • Request DTOs validate input; response DTOs shape output

3. Service Layer — No Business Logic in Controllers/Endpoints

Controllers 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);
    }
}

4. IServiceCollection Extension Methods

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);

5. Configuration — appsettings.json with Environment Overrides

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" }
  }
}

IOptions Pattern for Typed Configuration

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

6. Exception Handling — Typed Exceptions with Centralized Middleware

// 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 });
        }
    }
}

7. EF Core DbContext Configuration

// 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")));

8. Health Checks

Always register health checks for production readiness:

// Program.cs
builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>();

// After app is built
app.MapHealthChecks("/health");

Checklist — Apply to Every ASP.NET Core Project

  • Choose correct API style: Controllers for complex apps, Endpoints/ with MapGet/MapPost for minimal APIs
  • DTOs in DTOs/ folder as record types, separate from entity class types in Models/
  • Every response DTO has a static From() factory method
  • Controllers/endpoints have zero business logic — delegate to service interfaces
  • Services registered via IServiceCollection extension methods in Extensions/
  • Configuration in appsettings.json with appsettings.Development.json override
  • Connection strings read from builder.Configuration.GetConnectionString(), never hardcoded
  • Typed exceptions in Exceptions/ with centralized middleware in Middleware/
  • DbContext configured in Data/ with OnModelCreating for relationships
  • Health checks registered at /health
  • Test project in separate *.Tests/ directory

Verifiers

  • aspnet-organized — Separate controllers/endpoints, services, models, and DTOs
  • aspnet-minimal-api — Minimal API uses Endpoints/ with MapGet/MapPost
  • aspnet-dto-pattern — DTOs as records with From() factory methods
  • aspnet-service-registration — IServiceCollection extensions and IOptions pattern
  • aspnet-configuration — Configuration in appsettings.json, not hardcoded

skills

aspnet-project-structure

tile.json