| name | asp-net |
| description | [Applies to: **/*] This guide provides opinionated, actionable best practices for building high-performance, maintainable, and secure ASP.NET Core 9+ applications, focusing on modern C# patterns and common pitfalls. |
| source | cursor_mdc |
asp-net Best Practices
This document outlines the definitive best practices for developing ASP.NET Core applications within our team. Adhere to these guidelines to ensure consistent, performant, and maintainable backend services.
1. Code Organization and Structure
Organize your projects by feature or bounded context, not by technical layer. This improves cohesion and reduces coupling.
✅ GOOD: Feature-First Organization
src/
├── MyProject.Api/
│ ├── Program.cs
│ ├── Features/
│ │ ├── Products/
│ │ │ ├── ProductEndpoints.cs
│ │ │ ├── ProductController.cs
│ │ │ ├── GetProduct.cs
│ │ │ ├── CreateProduct.cs
│ │ │ └── ProductService.cs
│ ├── Common/
│ │ ├── Filters/
│ │ ├── Middleware/
│ │ └── Extensions/
├── MyProject.Application/
│ ├── Interfaces/
│ ├── Services/
│ └── DTOs/
├── MyProject.Domain/
├── MyProject.Infrastructure/
│ ├── Data/
│ ├── Repositories/
│ └── ExternalServices/
└── MyProject.Tests/
❌ BAD: Layer-First Organization
src/
├── MyProject.Api/
│ ├── Controllers/
│ ├── Services/
│ ├── Repositories/
│ └── DTOs/
2. API Design: Minimal APIs vs. API Controllers
Always prefer Minimal APIs for new endpoints due to their simplicity and directness. Use API Controllers only when you require advanced features like automatic model binding from multiple sources, built-in validation attributes, or complex attribute routing that Minimal APIs don't easily provide.
✅ GOOD: Minimal API for Simplicity
app.MapGet("/products/{id}", async (Guid id, IProductService service) =>
{
var product = await service.GetProductByIdAsync(id);
return product is not null ? Results.Ok(product) : Results.NotFound();
})
.WithName("GetProductById")
.Produces<ProductDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
✅ GOOD: API Controller for Complex Scenarios
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IOrderService _orderService;
public OrdersController(IOrderService orderService) => _orderService = orderService;
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
var order = await _orderService.CreateOrderAsync(request);
return CreatedAtAction(nameof(GetOrderById), new { id = order.Id }, order);
}
}
3. Performance Considerations
3.1. Avoid Blocking Calls
Always use async/await for I/O-bound operations (database, network, file system). Never block the thread pool.
❌ BAD: Blocking Calls
public Product GetProduct(Guid id)
{
return _dbContext.Products.Find(id);
}
var result = SomeAsyncTask().Result;
SomeAsyncTask().Wait();
✅ GOOD: Asynchronous I/O
public async Task<Product?> GetProductAsync(Guid id)
{
return await _dbContext.Products.FindAsync(id);
}
var result = await SomeAsyncTask();
3.2. Return Paged Collections
Always paginate large collections to prevent OutOfMemoryException and slow responses.
❌ BAD: Returning Large Collections Unpaged
app.MapGet("/products", async (IProductService service) =>
{
var allProducts = await service.GetAllProductsAsync();
return Results.Ok(allProducts);
});
✅ GOOD: Paging Collections
app.MapGet("/products", async (int page = 1, int pageSize = 20, IProductService service) =>
{
var pagedProducts = await service.GetPagedProductsAsync(page, pageSize);
return Results.Ok(pagedProducts);
});
3.3. Minimize Large Object Allocations
Cache frequently used large objects. Avoid creating large objects (>= 85KB) in hot code paths to reduce GC pressure. Use ArrayPool<T> for large arrays.
4. Dependency Injection (DI)
Always use ASP.NET Core's built-in DI container. Register services with the correct lifetime.
- Singleton: For stateless services, configuration objects, or services that manage shared state.
- Scoped: For services tied to a single HTTP request (e.g.,
DbContext, IUnitOfWork).
- Transient: For lightweight services that should be new for every injection (rarely needed for typical backend services).
✅ GOOD: Correct Service Lifetimes
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<ICacheService, RedisCacheService>();
services.AddDbContext<AppDbContext>(options => , ServiceLifetime.Scoped);
services.AddScoped<IProductRepository, ProductRepository>();
services.AddScoped<IProductService, ProductService>();
}
5. Security Best Practices
5.1. Authentication and Authorization
Always secure your API endpoints. Use JWT Bearer tokens for API authentication. Define and enforce authorization policies.
✅ GOOD: Policy-Based Authorization
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => { });
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanManageProducts", policy =>
policy.RequireRole("Admin", "ProductManager"));
});
app.MapPost("/products", async (CreateProductRequest request, IProductService service) =>
{
}).RequireAuthorization("CanManageProducts");
[Authorize(Policy = "CanManageProducts")]
[HttpPost]
public async Task<IActionResult> CreateProduct([FromBody] CreateProductRequest request)
{ }
5.2. Input Validation
Always validate all incoming request data. Use Data Annotations or FluentValidation for robust validation.
✅ GOOD: DTO Validation
public record CreateProductRequest(
[Required] [StringLength(100)] string Name,
[Range(0.01, 10000.00)] decimal Price,
[Url] string? ImageUrl
);
app.MapPost("/products", (CreateProductRequest request) =>
{
var validationContext = new ValidationContext(request);
var validationResults = new List<ValidationResult>();
if (!Validator.TryValidateObject(request, validationContext, validationResults, true))
{
return Results.ValidationProblem(validationResults.ToDictionary(r => r.MemberNames.First(), r => new[] { r.ErrorMessage }));
}
});
6. Error Handling
Implement global exception handling middleware. Provide consistent, developer-friendly error responses without exposing sensitive details.
✅ GOOD: Global Exception Handler
app.UseExceptionHandler(appBuilder =>
{
appBuilder.Run(async context =>
{
var exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>();
var exception = exceptionHandlerPathFeature?.Error;
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An error occurred while processing your request.",
Detail = "Please try again later. If the problem persists, contact support.",
Instance = context.Request.Path
};
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogError(exception, "Unhandled exception for request {Path}", context.Request.Path);
context.Response.StatusCode = problemDetails.Status.Value;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(problemDetails);
});
});
7. Structured Logging
Always use structured logging to enable easy querying and analysis of logs. Prefer ILogger<T> for context-specific logging.
✅ GOOD: Structured Logging
public class ProductService : IProductService
{
private readonly ILogger<ProductService> _logger;
public ProductService(ILogger<ProductService> logger) => _logger = logger;
public async Task<ProductDto> CreateProductAsync(CreateProductRequest request)
{
_logger.LogInformation("Creating product {ProductName} with price {ProductPrice}", request.Name, request.Price);
_logger.LogInformation("Product {ProductId} created successfully.", newProduct.Id);
return newProductDto;
}
}
❌ BAD: Unstructured Logging
_logger.LogInformation("Creating product with name: " + request.Name + " and price: " + request.Price);
8. Testing Approaches
Prioritize unit and integration tests.
- Unit Tests: Focus on isolated business logic (services, domain models). Mock all external dependencies.
- Integration Tests: Verify API endpoints, middleware, and database interactions. Use
WebApplicationFactory<TStartup> for in-memory testing.
✅ GOOD: Unit Test Example
public class ProductServiceTests
{
[Fact]
public async Task CreateProductAsync_ValidRequest_ReturnsProductDto()
{
var mockRepo = new Mock<IProductRepository>();
mockRepo.Setup(r => r.AddAsync(It.IsAny<Product>())).ReturnsAsync(new Product { Id = Guid.NewGuid(), Name = "Test", Price = 10m });
var service = new ProductService(mockRepo.Object, Mock.Of<ILogger<ProductService>>());
var request = new CreateProductRequest("Test Product", 10.00m, null);
var result = await service.CreateProductAsync(request);
Assert.NotNull(result);
Assert.Equal("Test Product", result.Name);
mockRepo.Verify(r => r.AddAsync(It.IsAny<Product>()), Times.Once);
}
}
✅ GOOD: Integration Test Example
public class ProductsApiTests : IClassFixture<CustomWebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public ProductsApiTests(CustomWebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetProducts_ReturnsSuccessAndCorrectContentType()
{
var response = await _client.GetAsync("/products");
response.EnsureSuccessStatusCode();
Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType?.ToString());
}
}