.NET Web API Development: The Ultimate Do's and Don'ts Guide
Building robust, maintainable, and secure Web APIs in .NET requires more than just knowing the syntax. It demands adherence to established patterns, understanding of HTTP semantics, and awareness of common pitfalls that can turn a well-intentioned API into a maintenance nightmare. This guide compiles the most critical Do's and Don'ts for modern .NET Web API development.
⚡ The Reality: A poorly designed API can become a significant liability, causing client-side bugs, performance issues, and security vulnerabilities. Following established best practices from the start is far cheaper than retrofitting them later.
Part 1: Critical Do's
✅ DO Use [ApiController] for Controllers
The [ApiController] attribute enables several opinionated, helpful behaviors automatically. It enforces attribute routing, triggers automatic ProblemDetails responses for validation errors, and infers binding sources for parameters.
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
// Your actions
}
💡 Pro Tip: [ApiController] automatically returns a 400 Bad Request with ProblemDetails when model validation fails, saving you from writing repetitive validation code.
✅ DO Keep Controllers Thin
Controllers should be the "HTTP translation layer," not the business logic layer. They should be responsible for:
- Receiving requests and returning responses
- Validating input
- Mapping between DTOs and domain models
- Delegating work to services
// ✅ CORRECT - Thin controller
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IOrderService _orderService;
private readonly IMapper _mapper;
public OrdersController(IOrderService orderService, IMapper mapper)
{
_orderService = orderService;
_mapper = mapper;
}
[HttpGet("{id}")]
public async Task> GetOrder(int id)
{
var order = await _orderService.GetOrderAsync(id);
if (order == null)
return NotFound();
return Ok(_mapper.Map(order));
}
}
✅ DO Use DTOs Instead of Exposing Entities
Never expose your Entity Framework Core entities directly in your API. This couples your database schema to your API contract, causing breaking changes when your data model evolves.
⚠️ Critical: Exposing entities can lead to over-posting attacks and leaking sensitive database fields.
// ❌ WRONG - Exposing Entity
[HttpGet]
public async Task>> GetProducts()
{
return await _context.Products.ToListAsync();
}
// ✅ CORRECT - Using DTO
[HttpGet]
public async Task>> GetProducts()
{
var products = await _context.Products.ToListAsync();
return Ok(_mapper.Map>(products));
}
✅ DO Use Proper HTTP Status Codes
Use the appropriate HTTP status codes to communicate success or failure to the client. This makes your API self-documenting and easier to consume.
| Status Code | Usage |
|---|---|
| 200 OK | Successful GET, PUT, or PATCH request |
| 201 Created | Successful POST request that creates a resource |
| 204 No Content | Successful DELETE request |
| 400 Bad Request | Validation failure or malformed request |
| 404 Not Found | Resource does not exist |
| 409 Conflict | Resource state conflict (e.g., duplicate order) |
// ✅ CORRECT - Using proper status codes
[HttpPost]
public async Task> CreateOrder(CreateOrderRequest request)
{
var order = await _orderService.CreateOrderAsync(request);
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}
[HttpDelete("{id}")]
public async Task DeleteOrder(int id)
{
var result = await _orderService.DeleteOrderAsync(id);
if (!result) return NotFound();
return NoContent();
}
✅ DO Use ProblemDetails for Errors
Use ProblemDetails (RFC 7807) for consistent error responses. This is the standard approach for communicating errors in ASP.NET Core.
// ✅ CORRECT - Using ProblemDetails
[ApiController]
public class ProductsController : ControllerBase
{
[HttpGet("{id}")]
public async Task> GetProduct(int id)
{
var product = await _productService.GetProductAsync(id);
if (product == null)
{
return Problem(
title: "Product not found",
detail: $"The product with ID {id} was not found.",
statusCode: StatusCodes.Status404NotFound,
type: "https://example.com/problems/product-not-found"
);
}
return Ok(product);
}
}
✅ DO Use Dependency Injection
Use constructor-based dependency injection for all dependencies. This makes your code more testable, maintainable, and follows the explicit dependencies principle.
// ✅ CORRECT - DI via constructor
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
private readonly ILogger _logger;
public ProductsController(IProductService productService, ILogger logger)
{
_productService = productService;
_logger = logger;
}
}
✅ DO Use Async/Await
Use asynchronous programming for I/O-bound operations. This improves scalability by freeing threads for other work while awaiting I/O completion.
💡 Pro Tip: In EF Core, always use AsNoTracking() for read-only queries to improve performance.
// ✅ CORRECT - Async all the way
[HttpGet]
public async Task>> GetProducts()
{
var products = await _context.Products
.AsNoTracking()
.ToListAsync();
return Ok(_mapper.Map>(products));
}
Part 2: Critical Don'ts
❌ DON'T Use HttpClient Directly Without HttpClientFactory
Creating a new HttpClient for each request can lead to socket exhaustion. Always use IHttpClientFactory to manage HttpClient instances centrally.
// ❌ WRONG - Causes socket exhaustion
using var client = new HttpClient();
var response = await client.GetAsync(url);
// ✅ CORRECT - Use IHttpClientFactory
public class ProductService
{
private readonly HttpClient _client;
public ProductService(HttpClient client) // Injected via factory
{
_client = client;
}
}
// Program.cs
builder.Services.AddHttpClient(client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
});
❌ DON'T Use Database Exceptions for Control Flow
Don't use exceptions for expected scenarios. Check for existence before updating or deleting, and return appropriate status codes.
// ❌ WRONG - Using exceptions for flow control
[HttpPut("{id}")]
public async Task UpdateProduct(int id, ProductDto product)
{
try
{
var entity = await _context.Products.FindAsync(id);
// Update logic
await _context.SaveChangesAsync();
return Ok();
}
catch (DbUpdateConcurrencyException)
{
return Conflict();
}
}
// ✅ CORRECT - Check explicitly
[HttpPut("{id}")]
public async Task UpdateProduct(int id, ProductDto product)
{
var entity = await _context.Products.FindAsync(id);
if (entity == null)
return NotFound();
_mapper.Map(product, entity);
await _context.SaveChangesAsync();
return Ok();
}
❌ DON'T Use Magic Strings
Avoid hardcoding strings for session keys, cookie names, policy names, or error messages. Define them in constants or configuration.
// ❌ WRONG - Magic strings
if (HttpContext.User.Identity.Name == "admin") { }
// ✅ CORRECT - Constants
public static class ClaimTypes
{
public const string Admin = "admin";
}
if (HttpContext.User.IsInRole(ClaimTypes.Admin)) { }
❌ DON'T Use Console.WriteLine or Debug.Write
Use structured logging with a library like Serilog. This enables proper log levels, sinks, and structured data.
// ❌ WRONG
Console.WriteLine($"Order {orderId} created");
// ✅ CORRECT
_logger.LogInformation("Order {OrderId} created by {UserId}", orderId, userId);
❌ DON'T Return Plain Strings for Errors
Always return structured error responses (ProblemDetails or ValidationProblemDetails) instead of plain strings.
// ❌ WRONG - Plain string error
return BadRequest("Order lines cannot be empty");
// ✅ CORRECT - ProblemDetails
return Problem(
title: "Validation Failed",
detail: "Order lines cannot be empty.",
statusCode: StatusCodes.Status400BadRequest
);
❌ DON'T Ignore Versioning
Always version your APIs to handle breaking changes without breaking clients.
// ✅ CORRECT - API Versioning
[ApiController]
[Route("api/v{version:apiVersion}/orders")]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class OrdersController : ControllerBase
{
[MapToApiVersion("1.0")]
[HttpGet("{id}")]
public async Task> GetOrderV1(int id)
{
// Returns old format
}
[MapToApiVersion("2.0")]
[HttpGet("{id}")]
public async Task> GetOrderV2(int id)
{
// Returns new format with additional fields
}
}
⚠️ Important: Adding new optional fields is non-breaking. Removing or renaming fields, changing data types, or making a field required are breaking changes.
❌ DON'T Hardcode Configuration
Store configuration in appsettings.json and use the Options pattern. This makes your app environment-aware and more secure.
// ❌ WRONG - Hardcoded
var connectionString = "Server=localhost;Database=MyDb;";
// ✅ CORRECT - Options pattern
public class DatabaseOptions
{
public string ConnectionString { get; set; } = string.Empty;
}
// Program.cs
builder.Services.Configure(
builder.Configuration.GetSection("Database"));
// Usage
private readonly DatabaseOptions _dbOptions;
public MyService(IOptions options)
{
_dbOptions = options.Value;
}
Part 3: Best Practices Summary
✅ Use Consistent Naming
Use plural nouns for resources (/users), singular for items (/users/{id}), and avoid verbs in routes.
✅ Keep APIs Stateless
Each request should contain all the information needed to process it. Use JWT or other tokens for state.
✅ Use Health Checks
Add health checks for monitoring and alerting.
✅ Group Services with Extensions
Simplify Program.cs using extension methods to group service registrations.
✅ Use FluentValidation
Keep validation logic separate from your models with FluentValidation.
✅ Implement Rate Limiting
Protect your API from abuse with rate limiting.
Common Pitfalls Summary
❌ No API Versioning
Failing to version APIs makes breaking changes impossible without breaking clients.
❌ Overposting / Underposting
Exposing entities leads to security vulnerabilities. Always use DTOs.
❌ Ignoring CORS
Configure CORS properly to enable secure cross-origin access.
❌ Not Logging Sensitive Data
Avoid logging passwords, credit cards, or other PII.
❌ Using POST for Reads
Use GET for retrieving data, POST for creating. GET responses should be cacheable.
❌ No Exception Handling
Use global exception handling middleware for consistent error responses.
Choosing the Right Style
In .NET, you can choose between Controllers and Minimal APIs:
| Feature | Controllers | Minimal APIs |
|---|---|---|
| Best For | Complex APIs, existing codebases | Small to medium APIs, microservices |
| Validation | Automatic via [ApiController] | Manual or with endpoint filters |
| Organization | Controllers folder (or feature folders) | Endpoint groups with MapGroup |
| OpenAPI | Automatic via conventions | Explicit metadata required |
💡 Pro Tip: Minimal APIs are now the default in .NET 10. They are lighter and faster, but controllers still provide more governance by default.
The Bottom Line
📌 Key Takeaway: Successful API development requires discipline, consistency, and adherence to HTTP standards. Use the right tools for the job, keep your controllers thin, use DTOs, implement proper error handling with ProblemDetails, and always consider backward compatibility.
🔍 Further Reading: Explore Microsoft's official documentation for in-depth guidance on ASP.NET Core Web API development, versioning strategies, and security best practices.