Building a well-designed REST API requires attention to many details beyond just returning data. This guide covers the essential aspects of creating professional APIs that are easy to use, maintain, and scale.

API Design Principles

Good REST API design follows several principles:

  1. Use nouns for resource URLs, not verbs
  2. Use HTTP methods appropriately (GET, POST, PUT, PATCH, DELETE)
  3. Return appropriate status codes
  4. Support filtering, sorting, and pagination
  5. Version your API
  6. Provide clear error messages

Setting Up the Project

Create a new ASP.NET Core Web API project:

var builder = WebApplication.CreateBuilder(args);

// Add services
builder.Services.AddControllers()
    .AddJsonOptions(options =>
    {
        options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
        options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
    });

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "Products API",
        Version = "v1",
        Description = "API for managing products",
        Contact = new OpenApiContact
        {
            Name = "API Support",
            Email = "support@example.com"
        }
    });
});

// Register services
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddScoped<IProductRepository, ProductRepository>();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

Resource Design

Define your DTOs (Data Transfer Objects) separately from your domain entities:

// Domain entity
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int CategoryId { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }
    public bool IsActive { get; set; }
    
    public Category Category { get; set; } = null!;
}

// DTOs
public record ProductDto(
    int Id,
    string Name,
    string Description,
    decimal Price,
    string CategoryName,
    DateTime CreatedAt);

public record CreateProductDto(
    [Required] [StringLength(100)] string Name,
    [StringLength(500)] string Description,
    [Range(0.01, 999999.99)] decimal Price,
    [Required] int CategoryId);

public record UpdateProductDto(
    [StringLength(100)] string? Name,
    [StringLength(500)] string? Description,
    [Range(0.01, 999999.99)] decimal? Price,
    int? CategoryId);

Controller Implementation

Create a controller with proper routing and HTTP methods:

[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;
    private readonly ILogger<ProductsController> _logger;

    public ProductsController(
        IProductService productService,
        ILogger<ProductsController> logger)
    {
        _productService = productService;
        _logger = logger;
    }

    /// <summary>
    /// Gets all products with optional filtering and pagination
    /// </summary>
    [HttpGet]
    [ProducesResponseType(typeof(PagedResult<ProductDto>), StatusCodes.Status200OK)]
    public async Task<ActionResult<PagedResult<ProductDto>>> GetProducts(
        [FromQuery] ProductQueryParameters query)
    {
        var result = await _productService.GetProductsAsync(query);
        return Ok(result);
    }

    /// <summary>
    /// Gets a product by ID
    /// </summary>
    [HttpGet("{id:int}")]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult<ProductDto>> GetProduct(int id)
    {
        var product = await _productService.GetProductByIdAsync(id);
        
        if (product is null)
        {
            return NotFound(new ProblemDetails
            {
                Title = "Product not found",
                Detail = $"Product with ID {id} does not exist",
                Status = StatusCodes.Status404NotFound
            });
        }

        return Ok(product);
    }

    /// <summary>
    /// Creates a new product
    /// </summary>
    [HttpPost]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
    [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
    public async Task<ActionResult<ProductDto>> CreateProduct(
        [FromBody] CreateProductDto dto)
    {
        var product = await _productService.CreateProductAsync(dto);
        
        return CreatedAtAction(
            nameof(GetProduct),
            new { id = product.Id },
            product);
    }

    /// <summary>
    /// Updates an existing product
    /// </summary>
    [HttpPut("{id:int}")]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
    public async Task<ActionResult<ProductDto>> UpdateProduct(
        int id,
        [FromBody] UpdateProductDto dto)
    {
        var product = await _productService.UpdateProductAsync(id, dto);
        
        if (product is null)
        {
            return NotFound();
        }

        return Ok(product);
    }

    /// <summary>
    /// Deletes a product
    /// </summary>
    [HttpDelete("{id:int}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> DeleteProduct(int id)
    {
        var deleted = await _productService.DeleteProductAsync(id);
        
        if (!deleted)
        {
            return NotFound();
        }

        return NoContent();
    }
}

Pagination and Filtering

Implement query parameters for pagination and filtering:

public class ProductQueryParameters
{
    private const int MaxPageSize = 50;
    private int _pageSize = 10;

    public int Page { get; set; } = 1;
    
    public int PageSize
    {
        get => _pageSize;
        set => _pageSize = value > MaxPageSize ? MaxPageSize : value;
    }

    public string? Search { get; set; }
    public int? CategoryId { get; set; }
    public decimal? MinPrice { get; set; }
    public decimal? MaxPrice { get; set; }
    public string? SortBy { get; set; }
    public bool SortDescending { get; set; }
}

public class PagedResult<T>
{
    public IEnumerable<T> Items { get; set; } = Enumerable.Empty<T>();
    public int TotalCount { get; set; }
    public int Page { get; set; }
    public int PageSize { get; set; }
    public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
    public bool HasPreviousPage => Page > 1;
    public bool HasNextPage => Page < TotalPages;
}

// Service implementation
public async Task<PagedResult<ProductDto>> GetProductsAsync(ProductQueryParameters query)
{
    var productsQuery = _context.Products
        .Include(p => p.Category)
        .AsQueryable();

    // Apply filters
    if (!string.IsNullOrEmpty(query.Search))
    {
        productsQuery = productsQuery.Where(p =>
            p.Name.Contains(query.Search) ||
            p.Description.Contains(query.Search));
    }

    if (query.CategoryId.HasValue)
    {
        productsQuery = productsQuery.Where(p => p.CategoryId == query.CategoryId.Value);
    }

    if (query.MinPrice.HasValue)
    {
        productsQuery = productsQuery.Where(p => p.Price >= query.MinPrice.Value);
    }

    if (query.MaxPrice.HasValue)
    {
        productsQuery = productsQuery.Where(p => p.Price <= query.MaxPrice.Value);
    }

    // Apply sorting
    productsQuery = query.SortBy?.ToLower() switch
    {
        "name" => query.SortDescending
            ? productsQuery.OrderByDescending(p => p.Name)
            : productsQuery.OrderBy(p => p.Name),
        "price" => query.SortDescending
            ? productsQuery.OrderByDescending(p => p.Price)
            : productsQuery.OrderBy(p => p.Price),
        "created" => query.SortDescending
            ? productsQuery.OrderByDescending(p => p.CreatedAt)
            : productsQuery.OrderBy(p => p.CreatedAt),
        _ => productsQuery.OrderByDescending(p => p.CreatedAt)
    };

    var totalCount = await productsQuery.CountAsync();

    var products = await productsQuery
        .Skip((query.Page - 1) * query.PageSize)
        .Take(query.PageSize)
        .Select(p => new ProductDto(
            p.Id,
            p.Name,
            p.Description,
            p.Price,
            p.Category.Name,
            p.CreatedAt))
        .ToListAsync();

    return new PagedResult<ProductDto>
    {
        Items = products,
        TotalCount = totalCount,
        Page = query.Page,
        PageSize = query.PageSize
    };
}

Error Handling

Implement global error handling:

public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;
    private readonly IHostEnvironment _environment;

    public GlobalExceptionHandler(
        ILogger<GlobalExceptionHandler> logger,
        IHostEnvironment environment)
    {
        _logger = logger;
        _environment = environment;
    }

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        _logger.LogError(exception, "An unhandled exception occurred");

        var problemDetails = exception switch
        {
            ValidationException validationEx => new ValidationProblemDetails(validationEx.Errors)
            {
                Status = StatusCodes.Status400BadRequest,
                Title = "Validation failed"
            },
            NotFoundException notFoundEx => new ProblemDetails
            {
                Status = StatusCodes.Status404NotFound,
                Title = "Resource not found",
                Detail = notFoundEx.Message
            },
            UnauthorizedException => new ProblemDetails
            {
                Status = StatusCodes.Status401Unauthorized,
                Title = "Unauthorized"
            },
            _ => new ProblemDetails
            {
                Status = StatusCodes.Status500InternalServerError,
                Title = "An error occurred",
                Detail = _environment.IsDevelopment() ? exception.Message : null
            }
        };

        httpContext.Response.StatusCode = problemDetails.Status ?? 500;
        await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);

        return true;
    }
}

// Custom exceptions
public class NotFoundException : Exception
{
    public NotFoundException(string message) : base(message) { }
}

public class ValidationException : Exception
{
    public Dictionary<string, string[]> Errors { get; }

    public ValidationException(Dictionary<string, string[]> errors)
        : base("Validation failed")
    {
        Errors = errors;
    }
}

// Registration in Program.cs
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

app.UseExceptionHandler();

API Versioning

Add API versioning support:

// Install package: Asp.Versioning.Mvc

builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;
    options.ApiVersionReader = ApiVersionReader.Combine(
        new UrlSegmentApiVersionReader(),
        new HeaderApiVersionReader("X-Api-Version"),
        new QueryStringApiVersionReader("api-version"));
})
.AddApiExplorer(options =>
{
    options.GroupNameFormat = "'v'VVV";
    options.SubstituteApiVersionInUrl = true;
});

// Versioned controller
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("1.0")]
public class ProductsController : ControllerBase
{
    // v1 implementation
}

[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("2.0")]
public class ProductsV2Controller : ControllerBase
{
    // v2 implementation with breaking changes
}

Rate Limiting

Protect your API with rate limiting:

builder.Services.AddRateLimiter(options =>
{
    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: context.User.Identity?.Name ?? context.Request.Headers.Host.ToString(),
            factory: partition => new FixedWindowRateLimiterOptions
            {
                AutoReplenishment = true,
                PermitLimit = 100,
                Window = TimeSpan.FromMinutes(1)
            }));

    options.AddPolicy("api", context =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
            factory: _ => new FixedWindowRateLimiterOptions
            {
                AutoReplenishment = true,
                PermitLimit = 60,
                Window = TimeSpan.FromMinutes(1)
            }));

    options.OnRejected = async (context, token) =>
    {
        context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
        await context.HttpContext.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Status = 429,
            Title = "Too many requests",
            Detail = "Rate limit exceeded. Please try again later."
        }, token);
    };
});

app.UseRateLimiter();

// Apply to controller
[ApiController]
[Route("api/[controller]")]
[EnableRateLimiting("api")]
public class ProductsController : ControllerBase
{
    // ...
}

CORS Configuration

Configure CORS for cross-origin requests:

builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowSpecificOrigins", policy =>
    {
        policy.WithOrigins(
                "https://example.com",
                "https://app.example.com")
            .AllowAnyHeader()
            .AllowAnyMethod()
            .AllowCredentials();
    });

    options.AddPolicy("AllowAll", policy =>
    {
        policy.AllowAnyOrigin()
            .AllowAnyHeader()
            .AllowAnyMethod();
    });
});

app.UseCors("AllowSpecificOrigins");

Response Caching

Add caching for GET requests:

builder.Services.AddResponseCaching();
builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("products", builder =>
        builder.Expire(TimeSpan.FromMinutes(5))
            .Tag("products"));
});

app.UseOutputCache();

// Apply to endpoint
[HttpGet]
[OutputCache(PolicyName = "products")]
public async Task<ActionResult<PagedResult<ProductDto>>> GetProducts(
    [FromQuery] ProductQueryParameters query)
{
    // ...
}

// Invalidate cache when data changes
[HttpPost]
public async Task<ActionResult<ProductDto>> CreateProduct(
    [FromBody] CreateProductDto dto,
    IOutputCacheStore cacheStore)
{
    var product = await _productService.CreateProductAsync(dto);
    
    // Invalidate the products cache
    await cacheStore.EvictByTagAsync("products", default);
    
    return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
}

Health Checks

Add health checks for monitoring:

builder.Services.AddHealthChecks()
    .AddDbContextCheck<ApplicationDbContext>()
    .AddCheck("ExternalApi", () =>
    {
        // Check external service
        return HealthCheckResult.Healthy();
    });

app.MapHealthChecks("/health", new HealthCheckOptions
{
    ResponseWriter = async (context, report) =>
    {
        context.Response.ContentType = "application/json";
        var response = new
        {
            status = report.Status.ToString(),
            checks = report.Entries.Select(e => new
            {
                name = e.Key,
                status = e.Value.Status.ToString(),
                description = e.Value.Description
            }),
            duration = report.TotalDuration
        };
        await context.Response.WriteAsJsonAsync(response);
    }
});

Conclusion

Building a professional REST API involves many considerations beyond just returning data. Following REST principles, implementing proper error handling, adding versioning, and configuring security features creates an API that is easy to use and maintain.

Key takeaways:

  • Use DTOs to separate API contracts from domain entities
  • Implement pagination for collection endpoints
  • Use proper HTTP status codes
  • Add global error handling with ProblemDetails
  • Version your API from the start
  • Configure rate limiting and CORS
  • Add health checks for monitoring

With these patterns, you can build APIs that meet professional standards and scale with your application needs.