Hey, listen… ASP.NET Core has become the preferred framework for many developers due to its flexibility, performance, and extensive ecosystem. Whether you’re building microservices, web applications, or enterprise solutions, understanding the best practices for .NET REST API design can significantly boost your project’s maintenance and performance. In this article, I’ll show my techniques and hints for creating APIs that scale gracefully.
RESTful API Best Practices
Adhere to REST Principles
REST (Representational State Transfer) is more than a programming style, it’s a set of architectural principles that lead to scalable and maintainable APIs.
I’ll try to enumerate the more important ones “illustrating” with code examples.
Statelessness
Each request must include all necessary information. For instance, using tokens for authentication avoids server-side sessions
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetOrder(int id)
{
var order = _orderService.GetOrderById(id);
return order != null ? Ok(order) : NotFound();
}
}
Resource-Based URIs
Use clear, logical URLs to represent resources. For example:
// URL: /api/products
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet]
public IActionResult GetProducts()
{
var products = _productService.GetAllProducts();
return Ok(products);
}
}
Use of Standard HTTP Methods
Implement CRUD operations using the appropriate HTTP verbs:
// GET, POST, PUT, DELETE methods in the same controller
[ApiController]
[Route("api/[controller]")]
public class CustomersController : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetCustomer(int id)
{
/* ... */
}
[HttpPost]
public IActionResult CreateCustomer([FromBody] Customer customer)
{
/* ... */
}
[HttpPut("{id}")]
public IActionResult UpdateCustomer(int id, [FromBody] Customer customer)
{
/* ... */
}
[HttpDelete("{id}")]
public IActionResult DeleteCustomer(int id)
{
/* ... */
}
}
Implement Versioning
As your API evolves, versioning prevents breaking changes. ASP.NET Core supports several versioning strategies. You can read more at API Versioning with ASP.NET and Swagger UI.
URL Path Versioning
// URL: /api/v1/products
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
public class ProductsController : ControllerBase
{
[HttpGet]
public IActionResult GetProducts() { /* ... */ }
}
Query String Versioning
// URL: /api/products?api-version=1.0
services.AddApiVersioning(options =>
{
options.AssumeDefaultVersionWhenUnspecified = true;
options.DefaultApiVersion = new ApiVersion(1, 0);
options.ApiVersionReader = new QueryStringApiVersionReader("api-version");
});
Header Versioning
// Use a custom header, e.g., "X-API-Version"
services.AddApiVersioning(options =>
{
options.ApiVersionReader = new HeaderApiVersionReader("X-API-Version");
});
Ensure Security
Security IS A MUST for any API! ASP.NET Core offers several built-in security features like:
Authentication & Authorization
// Configure JWT authentication in Startup.cs
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = Configuration["Jwt:Issuer"],
ValidAudience = Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["Jwt:Key"]))
};
});
Data Validation
public class Product
{
public int Id { get; set; }
[Required]
[StringLength(100)]
public string Name { get; set; }
[Range(0.01, double.MaxValue)]
public decimal Price { get; set; }
}
Rate Limiting
Consider middleware solutions like AspNetCoreRateLimit to prevent abuse.
// In Startup.cs - Configure rate limiting options
services.Configure<IpRateLimitOptions>(Configuration.GetSection("IpRateLimiting"));
services.AddInMemoryRateLimiting();
services.AddSingleton<IRateLimitConfiguration, RateLimitConfiguration>();
Optimization and Performance
Performance is critical for a good user experience. Key practices include caching, asynchronous programming (very important), and efficient data access.
Caching
This is a simple example using a memory cache but you could use a more sophisticated mechanism like Redis.
// Using in-memory caching
public class ProductsController : ControllerBase
{
private readonly IMemoryCache _cache;
public ProductsController(IMemoryCache cache) => _cache = cache;
[HttpGet]
public IActionResult GetProducts()
{
var cacheKey = "productList";
if (!_cache.TryGetValue(cacheKey, out List<Product> products))
{
products = _productService.GetAllProducts();
var cacheEntryOptions = new MemoryCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromMinutes(5));
_cache.Set(cacheKey, products, cacheEntryOptions);
}
return Ok(products);
}
}
Asynchronous Programming
This is A MUST for high-load scenarios because it releases the thread to the thread pool when waiting for any IO, like database access.
[HttpGet("{id}")]
public async Task<IActionResult> GetProductAsync(int id)
{
var product = await _productService.GetProductByIdAsync(id);
return product != null ? Ok(product) : NotFound();
}
Efficient Data Access
The secret here is to use AsNoTracking when querying data that you will not update or delete. This method tells Microsoft Entity Framework (EF) to stop monitoring the entity.
// Using Entity Framework Core with async queries
public async Task<List<Product>> GetAllProductsAsync()
{
return await _context.Products.AsNoTracking().ToListAsync();
}
Middleware Integration in ASP.NET Core
Middleware is central to ASP.NET Core’s request processing pipeline. Below are examples of custom middleware and integration of third-party middleware.
Building Custom Middleware
Custom middleware can be used to handle exceptions, logging, and more. For example, a global exception handler.
public class GlobalExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionMiddleware> _logger;
public GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task Invoke(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception occurred.");
context.Response.StatusCode = 500;
await context.Response.WriteAsync("An unexpected error occurred. Please try again later.");
}
}
}
Register the middleware in Startup.cs:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseMiddleware<GlobalExceptionMiddleware>();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
Integrating Third-Party Middleware
ASP.NET Core has a rich ecosystem of middleware for tasks such as logging, CORS, and response compression.
Logging with Serilog
This is just a crude example of using Serilog. Take a look at Enhance logging in .NET Core Web API and Set up Datalust Seq in a Docker Container for a better understanding.
// In Program.cs
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.File("logs/log.txt", rollingInterval: RollingInterval.Day)
.CreateLogger();
hostBuilder.UseSerilog();
CORS Management
// In Startup.cs, configure CORS policy
public void ConfigureServices(IServiceCollection services)
{
services.AddCors(options =>
{
options.AddPolicy("AllowSpecificOrigin",
builder => builder.WithOrigins("http://example.com")
.AllowAnyHeader()
.AllowAnyMethod());
});
services.AddControllers();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseCors("AllowSpecificOrigin");
app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapControllers());
}
Response Compression
// In Startup.cs, add response compression
public void ConfigureServices(IServiceCollection services)
{
services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
});
services.AddControllers();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseResponseCompression();
app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapControllers());
}
Leveraging Dependency Injection (DI)
ASP.NET Core’s built-in DI container simplifies service management, improves testability, and promotes a clean separation of concerns.
Why Dependency Injection?
DI helps reduce tight coupling by injecting dependencies rather than hard-coding them. This leads to more maintainable and testable code.
Registering Services
Register services in the ConfigureServices method with appropriate lifetimes:
public void ConfigureServices(IServiceCollection services)
{
// Transient: New instance per use
services.AddTransient<INotificationService, EmailNotificationService>();
// Scoped: One instance per request
services.AddScoped<IProductService, ProductService>();
// Singleton: One instance for the entire application lifetime
services.AddSingleton<ILoggingService, LoggingService>();
services.AddControllers();
}
Injecting Services into Controllers
After registering, inject services into controllers via constructor injection
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
private readonly ILoggingService _loggingService;
public ProductsController(IProductService productService, ILoggingService loggingService)
{
_productService = productService;
_loggingService = loggingService;
}
[HttpGet]
public async Task<IActionResult> GetProducts()
{
_loggingService.Log("Fetching all products.");
var products = await _productService.GetAllProductsAsync();
return Ok(products);
}
}
Testing with DI
Unit tests become easier when dependencies can be mocked. For instance, using Moq for testing
public class ProductsControllerTests
{
[Fact]
public async Task GetProducts_ReturnsOkResult_WithProductList()
{
// Arrange
var mockService = new Mock<IProductService>();
mockService.Setup(service => service.GetAllProductsAsync())
.ReturnsAsync(new List<Product> { new Product { Id = 1, Name = "Test Product", Price = 9.99M } });
var mockLogger = new Mock<ILoggingService>();
var controller = new ProductsController(mockService.Object, mockLogger.Object);
// Act
var result = await controller.GetProducts();
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
var products = Assert.IsAssignableFrom<IEnumerable<Product>>(okResult.Value);
Assert.Single(products);
}
}
Additional Best Practices for Scalable API Design
Documentation and Testing
API Documentation with Swagger
Integrate Swagger to auto-generate interactive API documentation.
// In Startup.cs - Configure Swagger services
public void ConfigureServices(IServiceCollection services)
{
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });
});
services.AddControllers();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1");
});
app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapControllers());
}
Automated Testing
In addition to unit tests, consider integration tests using the in-memory TestServer
public class IntegrationTests
{
[Fact]
public async Task GetProducts_EndpointReturnsSuccess()
{
// Arrange
var builder = WebApplication.CreateBuilder();
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
var client = app.GetTestClient();
// Act
var response = await client.GetAsync("/api/products");
// Assert
response.EnsureSuccessStatusCode();
}
}
Monitoring and Logging
Robust logging and monitoring are crucial for production environments
Using Application Insights
Read more about insights at learn.microsoft.com
// In Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddApplicationInsightsTelemetry(Configuration["ApplicationInsights:InstrumentationKey"]);
services.AddControllers();
}
Structured Logging Example
This is important for loggers like SEQ
// In a service or middleware
_logger.LogInformation("User {UserId} performed action {Action} at {Time}", userId, action, DateTime.UtcNow);
Continuous Integration and Deployment
Automate your builds and deployments with CI/CD pipelines. Here’s an example YAML configuration for GitHub Actions:
name: ASP.NET Core CI/CD
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup .NET
uses: actions/setup-dotnet@v1
with:
dotnet-version: '6.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Test
run: dotnet test --no-build --verbosity normal
Conclusion
Building robust RESTful APIs with ASP.NET Core requires attention to design principles, effective middleware integration, and leveraging dependency injection for modular, testable code. By following the best practices best practices for REST API discussed, adhering to RESTful conventions, integrating middleware efficiently, and using DI, you can create scalable, maintainable, and high-performance APIs. Additionally, incorporating comprehensive documentation, automated testing, and monitoring ensures your API remains reliable in production.
These expanded code examples provide practical insights into how each component works within the ASP.NET Core ecosystem. Whether you’re starting a new project or enhancing an existing one, these strategies will help you build APIs that evolve with your business needs while maintaining excellence in performance and security.






