Best practices for .NET REST API

Discover the best practices for .NET REST API, explore middleware integration and DI, and build robust, scalable APIs with ASP.NET Core

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.