ASP.NET Core
23 minute read
Introduction to ASP.NET Core for REST APIs
ASP.NET Core is Microsoft’s modern, high-performance, cross-platform framework for building cloud-based, internet-connected applications. It excels at creating RESTful services with its rich HTTP programming model and extensive middleware ecosystem.
Framework Overview
ASP.NET Core represents a complete redesign of the original ASP.NET framework, built with modern software design principles including:
- Modularity: Use only the components you need
- Dependency injection: Built-in IoC container
- Middleware architecture: Composable request processing pipeline
- Platform independence: Run on Windows, Linux, macOS, or in containers
- Open source development: Community-driven with Microsoft stewardship
Key Features for REST API Development
- Performance: Consistently ranks among the fastest web frameworks in industry benchmarks, with throughput often 10-20× faster than traditional ASP.NET
- Cross-Platform: Runs natively on Windows, Linux, macOS, and in Docker containers
- Built-in Dependency Injection: Native IoC container eliminates the need for third-party DI libraries
- Middleware Architecture: Flexible HTTP pipeline configuration with over 80 official and community middleware components
- Native OpenAPI/Swagger Support: Automatic API documentation generation and interactive testing UI
- Content Negotiation: Format-independent responses that adapt based on client requests
- Robust Security: Comprehensive authentication and authorization capabilities
- Minimal APIs: Lightweight, lambda-based approach introduced in .NET 6 for lower ceremony
- Scalability: Designed for cloud-scale with efficient resource utilization
- API-First Design: Built with modern API development patterns in mind
ASP.NET Core offers two primary approaches to building REST APIs:
- Controller-based APIs - The traditional MVC pattern with explicit controllers
- Minimal APIs - A streamlined, lambda-based approach with reduced ceremony
Getting Started with ASP.NET Core REST APIs
Prerequisites
- .NET SDK 6.0 or later
- A code editor ( Visual Studio , VS Code , or JetBrains Rider )
Creating a Basic REST API Project
Using the Command Line
Create an API project using the .NET CLI:
# Create a new WebAPI project
dotnet new webapi -n TaskManager.Api
# Navigate to the project directory
cd TaskManager.Api
# Run the application
dotnet run
By default, this creates a Weather Forecast API example. The application will start, and you can access the Swagger UI at https://localhost:<port>/swagger
.
Project Structure
A typical ASP.NET Core REST API project structure:
TaskManager.Api/
├── Controllers/
│ └── TasksController.cs
├── Models/
│ ├── Task.cs
│ └── TaskDTO.cs
├── Services/
│ └── ITaskService.cs
│ └── TaskService.cs
├── Data/
│ └── TaskDbContext.cs
├── Properties/
│ └── launchSettings.json
├── appsettings.json
├── appsettings.Development.json
├── Program.cs
└── TaskManager.Api.csproj
Controllers-Based REST APIs
Controllers are the traditional way to build REST APIs in ASP.NET Core. They provide a structured approach for organizing related API endpoints.
Basic Controller Example
using Microsoft.AspNetCore.Mvc;
using TaskManager.Api.Models;
using TaskManager.Api.Services;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace TaskManager.Api.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class TasksController : ControllerBase
{
private readonly ITaskService _taskService;
public TasksController(ITaskService taskService)
{
_taskService = taskService;
}
// GET api/tasks
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<TaskDTO>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<TaskDTO>>> GetAll()
{
IEnumerable<TaskDTO> tasks = await _taskService.GetAllTasksAsync();
return Ok(tasks);
}
// GET api/tasks/{id}
[HttpGet("{id}")]
[ProducesResponseType(typeof(TaskDTO), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TaskDTO>> GetById(int id)
{
TaskDTO task = await _taskService.GetTaskByIdAsync(id);
if (task == null)
{
return NotFound();
}
return Ok(task);
}
// POST api/tasks
[HttpPost]
[ProducesResponseType(typeof(TaskDTO), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<TaskDTO>> Create(CreateTaskDTO taskDTO)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
TaskDTO createdTask = await _taskService.CreateTaskAsync(taskDTO);
return CreatedAtAction(
nameof(GetById),
new { id = createdTask.Id },
createdTask);
}
// PUT api/tasks/{id}
[HttpPut("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(int id, UpdateTaskDTO taskDTO)
{
if (id != taskDTO.Id)
{
return BadRequest();
}
bool exists = await _taskService.TaskExistsAsync(id);
if (!exists)
{
return NotFound();
}
await _taskService.UpdateTaskAsync(taskDTO);
return NoContent();
}
// DELETE api/tasks/{id}
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
bool exists = await _taskService.TaskExistsAsync(id);
if (!exists)
{
return NotFound();
}
await _taskService.DeleteTaskAsync(id);
return NoContent();
}
}
}
Model Classes
namespace TaskManager.Api.Models
{
public class TaskDTO
{
public int Id { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public bool IsCompleted { get; set; }
public DateTime DueDate { get; set; }
public TaskPriority Priority { get; set; }
}
public class CreateTaskDTO
{
[Required]
[StringLength(100, MinimumLength = 3)]
public string Title { get; set; }
[MaxLength(500)]
public string Description { get; set; }
public bool IsCompleted { get; set; } = false;
public DateTime? DueDate { get; set; }
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
}
public class UpdateTaskDTO
{
public int Id { get; set; }
[Required]
[StringLength(100, MinimumLength = 3)]
public string Title { get; set; }
[MaxLength(500)]
public string Description { get; set; }
public bool IsCompleted { get; set; }
public DateTime? DueDate { get; set; }
public TaskPriority Priority { get; set; }
}
public enum TaskPriority
{
Low = 0,
Medium = 1,
High = 2,
Critical = 3
}
}
Minimal APIs
ASP.NET Core 6.0 introduced Minimal APIs, a simpler alternative to controllers for building small REST services with less ceremony.
Basic Minimal API Example
// Program.cs
using Microsoft.EntityFrameworkCore;
using TaskManager.Api.Models;
using TaskManager.Api.Data;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.AddDbContext<TaskDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
WebApplication app = builder.Build();
// Configure middleware
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// Define API endpoints
app.MapGet("/api/tasks", async (TaskDbContext db) =>
{
return await db.Tasks.ToListAsync();
})
.WithName("GetAllTasks")
.WithOpenApi()
.Produces<List<TaskDTO>>(StatusCodes.Status200OK);
app.MapGet("/api/tasks/{id}", async (int id, TaskDbContext db) =>
{
TaskDTO task = await db.Tasks.FindAsync(id);
return task is null
? Results.NotFound()
: Results.Ok(task);
})
.WithName("GetTaskById")
.WithOpenApi()
.Produces<TaskDTO>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
app.MapPost("/api/tasks", async (CreateTaskDTO taskDTO, TaskDbContext db) =>
{
TaskDTO task = new TaskDTO
{
Title = taskDTO.Title,
Description = taskDTO.Description,
IsCompleted = taskDTO.IsCompleted,
DueDate = taskDTO.DueDate ?? DateTime.UtcNow.AddDays(1),
Priority = taskDTO.Priority
};
db.Tasks.Add(task);
await db.SaveChangesAsync();
return Results.CreatedAtRoute("GetTaskById", new { id = task.Id }, task);
})
.WithName("CreateTask")
.WithOpenApi()
.Produces<TaskDTO>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
app.MapPut("/api/tasks/{id}", async (int id, UpdateTaskDTO taskDTO, TaskDbContext db) =>
{
if (id != taskDTO.Id)
return Results.BadRequest();
TaskDTO existingTask = await db.Tasks.FindAsync(id);
if (existingTask is null)
return Results.NotFound();
existingTask.Title = taskDTO.Title;
existingTask.Description = taskDTO.Description;
existingTask.IsCompleted = taskDTO.IsCompleted;
existingTask.DueDate = taskDTO.DueDate ?? existingTask.DueDate;
existingTask.Priority = taskDTO.Priority;
await db.SaveChangesAsync();
return Results.NoContent();
})
.WithName("UpdateTask")
.WithOpenApi()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
app.MapDelete("/api/tasks/{id}", async (int id, TaskDbContext db) =>
{
TaskDTO task = await db.Tasks.FindAsync(id);
if (task is null)
return Results.NotFound();
db.Tasks.Remove(task);
await db.SaveChangesAsync();
return Results.NoContent();
})
.WithName("DeleteTask")
.WithOpenApi()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
app.Run();
REST API Best Practices in ASP.NET Core
1. Use HTTP Methods Appropriately
HTTP Method | Usage |
---|---|
GET | Retrieve resources |
POST | Create new resources |
PUT | Update existing resources (full replacement) |
PATCH | Partially update resources |
DELETE | Remove resources |
2. Return Proper Status Codes
Status Code | Description | Common Usage |
---|---|---|
200 OK | Request succeeded | Successful GET, PUT, PATCH, or DELETE |
201 Created | Resource created | Successful POST |
204 No Content | Request succeeded with no response body | Successful DELETE or PUT |
400 Bad Request | Invalid request format | Validation errors |
401 Unauthorized | Authentication required | Missing or invalid credentials |
403 Forbidden | Insufficient permissions | Authenticated but not authorized |
404 Not Found | Resource not found | Invalid ID or endpoint |
409 Conflict | Request conflicts with current state | Version conflicts |
500 Server Error | Unexpected server error | Exceptions, database errors |
3. Use Resource-Based URLs
# Good
GET /api/tasks # Get all tasks
GET /api/tasks/42 # Get task with ID 42
GET /api/users/5/tasks # Get all tasks for user 5
POST /api/tasks # Create a new task
# Avoid
GET /api/getAllTasks # Action-based instead of resource-based
GET /api/taskById?id=42 # Using query parameters for resource identifier
Best Practices for REST API Development
Design Principles
Use Nouns, Not Verbs for Resources
// Good GET /api/tasks // Get all tasks GET /api/tasks/123 // Get task with ID 123 // Avoid GET /api/getTasks GET /api/getTask/123
Use Plural Resource Names
// Good GET /api/customers GET /api/products // Avoid GET /api/customer GET /api/product
Use Resource Nesting for Relationships
// Good GET /api/customers/42/orders // Get all orders for customer 42 GET /api/customers/42/orders/7 // Get order 7 for customer 42 // Avoid GET /api/customerOrders?customerId=42 GET /api/customerOrder?customerId=42&orderId=7
Use HTTP Methods Appropriately
GET /api/tasks // Get tasks (idempotent, safe) POST /api/tasks // Create a task (non-idempotent) PUT /api/tasks/123 // Update/replace task 123 (idempotent) PATCH /api/tasks/123 // Partial update of task 123 (idempotent) DELETE /api/tasks/123 // Delete task 123 (idempotent)
Provide Meaningful HTTP Status Codes
// Successful operations return Ok(resource); // 200 OK return Created("uri", resource); // 201 Created return NoContent(); // 204 No Content // Client errors return BadRequest(error); // 400 Bad Request return Unauthorized(); // 401 Unauthorized return Forbid(); // 403 Forbidden return NotFound(); // 404 Not Found return Conflict(error); // 409 Conflict return UnprocessableEntity(error); // 422 Unprocessable Entity // Server errors return StatusCode( StatusCodes.Status500InternalServerError, error); // 500 Internal Server Error
Implementation Best Practices
Versioning Your API
Implement API versioning to ensure backward compatibility while evolving your API:
// First, install the versioning package: // dotnet add package Microsoft.AspNetCore.Mvc.Versioning // Program.cs builder.Services.AddApiVersioning(options => { options.DefaultApiVersion = new ApiVersion(1, 0); options.AssumeDefaultVersionWhenUnspecified = true; options.ReportApiVersions = true; // Add version info to response headers }); builder.Services.AddVersionedApiExplorer(options => { options.GroupNameFormat = "'v'VVV"; // Format: 'v'major[.minor][-status] options.SubstituteApiVersionInUrl = true; }); // Versioning through URL path [ApiController] [ApiVersion("1.0")] [Route("api/v{version:apiVersion}/[controller]")] public class TasksV1Controller : ControllerBase { // V1 implementation } [ApiController] [ApiVersion("2.0")] [Route("api/v{version:apiVersion}/[controller]")] public class TasksV2Controller : ControllerBase { // V2 implementation } // Versioning through query string [ApiController] [ApiVersion("1.0")] [ApiVersion("2.0")] [Route("api/[controller]")] public class ProductsController : ControllerBase { [HttpGet] [MapToApiVersion("1.0")] public IActionResult GetV1() { // V1 implementation } [HttpGet] [MapToApiVersion("2.0")] public IActionResult GetV2() { // V2 implementation } }
Error Handling Standardization
Create standardized error responses:
// Create a problem details factory public interface IProblemDetailsFactory { ProblemDetails CreateProblemDetails( HttpContext context, string title = null, string detail = null, string instance = null, int? statusCode = null, string type = null); ValidationProblemDetails CreateValidationProblemDetails( HttpContext context, ModelStateDictionary modelStateDictionary, string detail = null, string instance = null, int? statusCode = null, string title = null, string type = null); } // Controller with standardized error responses [ApiController] [Route("api/[controller]")] public class OrdersController : ControllerBase { private readonly IOrderService _orderService; private readonly IProblemDetailsFactory _problemDetailsFactory; public OrdersController( IOrderService orderService, IProblemDetailsFactory problemDetailsFactory) { _orderService = orderService; _problemDetailsFactory = problemDetailsFactory; } [HttpPost] public async Task<IActionResult> CreateOrder(CreateOrderRequest request) { try { var result = await _orderService.CreateOrderAsync(request); if (result.ValidationErrors.Any()) { var validationProblem = _problemDetailsFactory .CreateValidationProblemDetails( HttpContext, new ModelStateDictionary(), "The order data is invalid", statusCode: StatusCodes.Status400BadRequest); foreach (var error in result.ValidationErrors) { validationProblem.Errors.Add( error.PropertyName, new[] { error.ErrorMessage }); } return new BadRequestObjectResult(validationProblem); } if (result.Status == OrderCreationStatus.ProductNotAvailable) { var problem = _problemDetailsFactory.CreateProblemDetails( HttpContext, title: "Product unavailable", detail: "One or more products in the order are not available", statusCode: StatusCodes.Status409Conflict, type: "https://example.com/errors/product-unavailable"); return new ObjectResult(problem) { StatusCode = StatusCodes.Status409Conflict }; } return CreatedAtAction( nameof(GetOrder), new { id = result.Order.Id }, result.Order); } catch (Exception ex) { return StatusCode( StatusCodes.Status500InternalServerError, _problemDetailsFactory.CreateProblemDetails( HttpContext, title: "An unexpected error occurred", statusCode: StatusCodes.Status500InternalServerError)); } } // Other action methods... }
Asynchronous Processing for Long-Running Operations
Use background processing for long-running operations:
// Use an interface for the queue service public interface IBackgroundTaskQueue { ValueTask QueueBackgroundWorkItemAsync(Func<CancellationToken, ValueTask> workItem); ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken); } // Implementation using Channel public class BackgroundTaskQueue : IBackgroundTaskQueue { private readonly Channel<Func<CancellationToken, ValueTask>> _queue; public BackgroundTaskQueue(int capacity) { var options = new BoundedChannelOptions(capacity) { FullMode = BoundedChannelFullMode.Wait }; _queue = Channel.CreateBounded<Func<CancellationToken, ValueTask>>(options); } public async ValueTask QueueBackgroundWorkItemAsync( Func<CancellationToken, ValueTask> workItem) { if (workItem == null) { throw new ArgumentNullException(nameof(workItem)); } await _queue.Writer.WriteAsync(workItem); } public async ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync( CancellationToken cancellationToken) { return await _queue.Reader.ReadAsync(cancellationToken); } } // Background service to process the queue public class QueuedHostedService : BackgroundService { private readonly IBackgroundTaskQueue _taskQueue; private readonly ILogger<QueuedHostedService> _logger; public QueuedHostedService( IBackgroundTaskQueue taskQueue, ILogger<QueuedHostedService> logger) { _taskQueue = taskQueue; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("Queued Hosted Service is starting."); while (!stoppingToken.IsCancellationRequested) { var workItem = await _taskQueue.DequeueAsync(stoppingToken); try { await workItem(stoppingToken); } catch (Exception ex) { _logger.LogError(ex, "Error occurred executing {WorkItem}", nameof(workItem)); } } _logger.LogInformation("Queued Hosted Service is stopping."); } } // Controller using the background queue [ApiController] [Route("api/[controller]")] public class ReportsController : ControllerBase { private readonly IBackgroundTaskQueue _taskQueue; private readonly IReportService _reportService; public ReportsController( IBackgroundTaskQueue taskQueue, IReportService reportService) { _taskQueue = taskQueue; _reportService = reportService; } [HttpPost] public async Task<IActionResult> GenerateReport(GenerateReportRequest request) { // Create a report tracking record var reportId = Guid.NewGuid(); await _reportService.CreateReportTrackingRecordAsync( new ReportTrackingRecord { Id = reportId, Status = ReportStatus.Pending, CreatedAt = DateTime.UtcNow, Parameters = request }); // Queue the actual report generation await _taskQueue.QueueBackgroundWorkItemAsync(async token => { try { await _reportService.UpdateReportStatusAsync( reportId, ReportStatus.Processing); await _reportService.GenerateReportAsync( reportId, request); await _reportService.UpdateReportStatusAsync( reportId, ReportStatus.Completed); } catch (Exception ex) { await _reportService.UpdateReportStatusAsync( reportId, ReportStatus.Failed, ex.Message); throw; } }); // Return 202 Accepted with a location header to check status return AcceptedAtAction( nameof(GetReportStatus), new { id = reportId }, new { id = reportId, status = "Pending" }); } [HttpGet("{id}/status")] public async Task<IActionResult> GetReportStatus(Guid id) { var report = await _reportService.GetReportStatusAsync(id); if (report == null) { return NotFound(); } return Ok(report); } [HttpGet("{id}/download")] public async Task<IActionResult> DownloadReport(Guid id) { var report = await _reportService.GetReportAsync(id); if (report == null) { return NotFound(); } if (report.Status != ReportStatus.Completed) { return BadRequest(new ProblemDetails { Title = "Report not ready", Detail = $"The report status is {report.Status}", Status = StatusCodes.Status400BadRequest }); } return File( report.FileContents, report.ContentType, report.FileName); } }
Consistent Data Filtering, Sorting, and Pagination
// Query parameters class public class ResourceParameters { private const int MaxPageSize = 50; private int _pageSize = 10; [FromQuery(Name = "page")] public int PageNumber { get; set; } = 1; [FromQuery(Name = "size")] public int PageSize { get => _pageSize; set => _pageSize = Math.Min(value, MaxPageSize); } [FromQuery(Name = "sort")] public string SortBy { get; set; } [FromQuery(Name = "dir")] public string SortDirection { get; set; } = "asc"; [FromQuery] public string Filter { get; set; } [FromQuery] public string Fields { get; set; } // Create a paged query using Dynamic LINQ public IQueryable<T> ApplySorting<T>(IQueryable<T> query) { if (string.IsNullOrWhiteSpace(SortBy)) { return query; } // Validate sort fields and directions to prevent SQL injection var validSortFields = GetValidSortFieldsForType<T>(); if (!validSortFields.Contains(SortBy)) { return query; } string sortExpression = SortDirection?.ToLower() == "desc" ? $"{SortBy} desc" : SortBy; return query.OrderBy(sortExpression); } public IQueryable<T> ApplyFiltering<T>(IQueryable<T> query) { if (string.IsNullOrWhiteSpace(Filter)) { return query; } // Parse the filter expression and apply safely // Implementation will depend on your filtering approach return FilterHelper.ApplySafeFilter<T>(query, Filter); } // Helper method to validate sort fields based on the type private static HashSet<string> GetValidSortFieldsForType<T>() { // Return the valid sort fields for the given type // This could be hardcoded or derived from the type's properties return typeof(T).GetProperties() .Select(p => p.Name.ToLowerInvariant()) .ToHashSet(); } } // Controller with filtering, sorting, and pagination [ApiController] [Route("api/[controller]")] public class ProductsController : ControllerBase { private readonly IProductRepository _repository; private readonly IUriService _uriService; public ProductsController( IProductRepository repository, IUriService uriService) { _repository = repository; _uriService = uriService; } [HttpGet] public async Task<IActionResult> GetProducts([FromQuery] ResourceParameters parameters) { var pagedResult = await _repository.GetProductsAsync(parameters); var previousPageLink = pagedResult.HasPrevious ? _uriService.CreateResourceUri( Request.Path.Value, parameters, ResourceUriType.PreviousPage) : null; var nextPageLink = pagedResult.HasNext ? _uriService.CreateResourceUri( Request.Path.Value, parameters, ResourceUriType.NextPage) : null; var paginationMetadata = new { totalCount = pagedResult.TotalCount, pageSize = pagedResult.PageSize, currentPage = pagedResult.CurrentPage, totalPages = pagedResult.TotalPages, previousPageLink, nextPageLink }; Response.Headers.Add( "X-Pagination", JsonSerializer.Serialize(paginationMetadata)); return Ok(pagedResult.Items); } }
Input Validation with FluentValidation
// First, install the package: // dotnet add package FluentValidation.AspNetCore // Program.cs builder.Services.AddControllers() .AddFluentValidation(fv => fv.RegisterValidatorsFromAssemblyContaining<Program>()); // Create model validator public class CreateUserRequestValidator : AbstractValidator<CreateUserRequest> { public CreateUserRequestValidator(IUserRepository userRepository) { RuleFor(x => x.Username) .NotEmpty().WithMessage("Username is required") .Length(3, 50).WithMessage("Username must be between 3 and 50 characters") .Matches("^[a-zA-Z0-9_]+$").WithMessage("Username can only contain letters, numbers, and underscores") .MustAsync(async (username, cancellation) => !await userRepository.UsernameExistsAsync(username)) .WithMessage("Username is already taken"); RuleFor(x => x.Email) .NotEmpty().WithMessage("Email is required") .EmailAddress().WithMessage("Invalid email format") .MustAsync(async (email, cancellation) => !await userRepository.EmailExistsAsync(email)) .WithMessage("Email is already registered"); RuleFor(x => x.Password) .NotEmpty().WithMessage("Password is required") .MinimumLength(8).WithMessage("Password must be at least 8 characters") .Matches("[A-Z]").WithMessage("Password must contain at least one uppercase letter") .Matches("[a-z]").WithMessage("Password must contain at least one lowercase letter") .Matches("[0-9]").WithMessage("Password must contain at least one digit") .Matches("[^a-zA-Z0-9]").WithMessage("Password must contain at least one special character"); RuleFor(x => x.ConfirmPassword) .NotEmpty().WithMessage("Confirm Password is required") .Equal(x => x.Password).WithMessage("Passwords do not match"); } }
Security Headers and Content Security Policy
// Program.cs // Add security headers builder.Services.AddAntiforgery(); // Configure security headers app.Use(async (context, next) => { // Security Headers context.Response.Headers.Add("X-Content-Type-Options", "nosniff"); context.Response.Headers.Add("X-Frame-Options", "DENY"); context.Response.Headers.Add("X-XSS-Protection", "1; mode=block"); context.Response.Headers.Add("Referrer-Policy", "strict-origin-when-cross-origin"); context.Response.Headers.Add("Permissions-Policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"); // Content Security Policy context.Response.Headers.Add("Content-Security-Policy", "default-src 'self'; " + "img-src 'self' data: https://trusted-cdn.com; " + "style-src 'self' https://trusted-cdn.com; " + "script-src 'self' https://trusted-cdn.com; " + "connect-src 'self' https://api.trusted-service.com; " + "frame-ancestors 'none'; " + "form-action 'self';"); await next(); });
API Design Patterns
Repository Pattern
Abstracting data access logic:
public interface IRepository<T> where T : class { Task<T> GetByIdAsync(int id); Task<IEnumerable<T>> GetAllAsync(); Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate); Task AddAsync(T entity); Task UpdateAsync(T entity); Task DeleteAsync(int id); Task SaveChangesAsync(); } public class Repository<T> : IRepository<T> where T : class { protected readonly DbContext _context; public Repository(DbContext context) { _context = context; } public async Task<T> GetByIdAsync(int id) { return await _context.Set<T>().FindAsync(id); } public async Task<IEnumerable<T>> GetAllAsync() { return await _context.Set<T>().ToListAsync(); } public async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate) { return await _context.Set<T>().Where(predicate).ToListAsync(); } public async Task AddAsync(T entity) { await _context.Set<T>().AddAsync(entity); } public Task UpdateAsync(T entity) { _context.Entry(entity).State = EntityState.Modified; return Task.CompletedTask; } public async Task DeleteAsync(int id) { T entity = await GetByIdAsync(id); _context.Set<T>().Remove(entity); } public async Task SaveChangesAsync() { await _context.SaveChangesAsync(); } }
Unit of Work Pattern
Managing transactions and coordinating multiple repositories:
public interface IUnitOfWork : IDisposable { ITaskRepository Tasks { get; } IUserRepository Users { get; } IProjectRepository Projects { get; } Task<int> SaveChangesAsync(); } public class UnitOfWork : IUnitOfWork { private readonly DbContext _context; public ITaskRepository Tasks { get; } public IUserRepository Users { get; } public IProjectRepository Projects { get; } public UnitOfWork( DbContext context, ITaskRepository taskRepository, IUserRepository userRepository, IProjectRepository projectRepository) { _context = context; Tasks = taskRepository; Users = userRepository; Projects = projectRepository; } public async Task<int> SaveChangesAsync() { return await _context.SaveChangesAsync(); } public void Dispose() { _context.Dispose(); } }
Mediator Pattern (CQRS)
Separating commands and queries:
// First, install the package: // dotnet add package MediatR // Command public class CreateTaskCommand : IRequest<int> { public string Title { get; set; } public string Description { get; set; } public DateTime? DueDate { get; set; } public int Priority { get; set; } } // Command Handler public class CreateTaskCommandHandler : IRequestHandler<CreateTaskCommand, int> { private readonly ITaskRepository _repository; private readonly ILogger<CreateTaskCommandHandler> _logger; public CreateTaskCommandHandler( ITaskRepository repository, ILogger<CreateTaskCommandHandler> logger) { _repository = repository; _logger = logger; } public async Task<int> Handle( CreateTaskCommand request, CancellationToken cancellationToken) { try { var task = new TaskEntity { Title = request.Title, Description = request.Description, DueDate = request.DueDate, Priority = (TaskPriority)request.Priority, CreatedAt = DateTime.UtcNow }; await _repository.AddAsync(task); await _repository.SaveChangesAsync(); _logger.LogInformation( "Task created with ID {TaskId}", task.Id); return task.Id; } catch (Exception ex) { _logger.LogError( ex, "Error creating task: {ErrorMessage}", ex.Message); throw; } } } // Query public class GetTaskQuery : IRequest<TaskDTO> { public int Id { get; set; } } // Query Handler public class GetTaskQueryHandler : IRequestHandler<GetTaskQuery, TaskDTO> { private readonly ITaskRepository _repository; private readonly IMapper _mapper; public GetTaskQueryHandler( ITaskRepository repository, IMapper mapper) { _repository = repository; _mapper = mapper; } public async Task<TaskDTO> Handle( GetTaskQuery request, CancellationToken cancellationToken) { var task = await _repository.GetByIdAsync(request.Id); return _mapper.Map<TaskDTO>(task); } } // Controller using MediatR [ApiController] [Route("api/[controller]")] public class TasksController : ControllerBase { private readonly IMediator _mediator; public TasksController(IMediator mediator) { _mediator = mediator; } [HttpGet("{id}")] public async Task<IActionResult> GetTask(int id) { var task = await _mediator.Send(new GetTaskQuery { Id = id }); if (task == null) { return NotFound(); } return Ok(task); } [HttpPost] public async Task<IActionResult> CreateTask(CreateTaskCommand command) { int taskId = await _mediator.Send(command); return CreatedAtAction( nameof(GetTask), new { id = taskId }, new { id = taskId }); } }
Real-World Examples
E-Commerce API: Product Catalog Endpoint
[ApiController]
[Route("api/v{version:apiVersion}/products")]
[ApiVersion("1.0")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
private readonly ILogger<ProductsController> _logger;
private readonly LinkGenerator _linkGenerator;
public ProductsController(
IProductService productService,
ILogger<ProductsController> logger,
LinkGenerator linkGenerator)
{
_productService = productService;
_logger = logger;
_linkGenerator = linkGenerator;
}
/// <summary>
/// Retrieves products with support for filtering, sorting and pagination
/// </summary>
/// <param name="parameters">Query parameters for filtering and pagination</param>
/// <returns>A collection of products</returns>
[HttpGet(Name = "GetProducts")]
[ProducesResponseType(typeof(PagedResponse<ProductDTO>), StatusCodes.Status200OK)]
[ResponseCache(Duration = 60)] // Cache for 1 minute
public async Task<IActionResult> GetProducts([FromQuery] ProductQueryParameters parameters)
{
_logger.LogInformation(
"Getting products with parameters: {@Parameters}", parameters);
try
{
var products = await _productService.GetProductsAsync(parameters);
var previousPageLink = products.HasPrevious
? CreateProductsResourceUri(parameters, ResourceUriType.PreviousPage)
: null;
var nextPageLink = products.HasNext
? CreateProductsResourceUri(parameters, ResourceUriType.NextPage)
: null;
var paginationMetadata = new
{
totalCount = products.TotalCount,
pageSize = products.PageSize,
currentPage = products.CurrentPage,
totalPages = products.TotalPages,
previousPageLink,
nextPageLink
};
Response.Headers.Add(
"X-Pagination",
JsonSerializer.Serialize(paginationMetadata));
return Ok(new PagedResponse<ProductDTO>(
products.Items,
products.CurrentPage,
products.PageSize,
products.TotalCount,
previousPageLink,
nextPageLink));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving products");
return StatusCode(
StatusCodes.Status500InternalServerError,
new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An error occurred while retrieving products",
Detail = "Please try again later or contact support if the problem persists"
});
}
}
/// <summary>
/// Retrieves a specific product by ID
/// </summary>
/// <param name="id">Product ID</param>
/// <returns>The requested product</returns>
[HttpGet("{id}", Name = "GetProductById")]
[ProducesResponseType(typeof(ProductDetailDTO), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ResponseCache(Duration = 300)] // Cache for 5 minutes
public async Task<IActionResult> GetProduct(int id)
{
try
{
var product = await _productService.GetProductByIdAsync(id);
if (product == null)
{
_logger.LogWarning("Product with ID {ProductId} not found", id);
return NotFound(new ProblemDetails
{
Status = StatusCodes.Status404NotFound,
Title = "Product not found",
Detail = $"The product with ID {id} does not exist"
});
}
// Add HATEOAS links
var links = new List<LinkDTO>
{
new LinkDTO(
_linkGenerator.GetUriByAction(
HttpContext,
action: nameof(GetProduct),
controller: "Products",
values: new { id, version = "1.0" })!,
"self",
"GET"),
new LinkDTO(
_linkGenerator.GetUriByAction(
HttpContext,
action: nameof(UpdateProduct),
controller: "Products",
values: new { id, version = "1.0" })!,
"update_product",
"PUT"),
new LinkDTO(
_linkGenerator.GetUriByAction(
HttpContext,
action: nameof(DeleteProduct),
controller: "Products",
values: new { id, version = "1.0" })!,
"delete_product",
"DELETE")
};
return Ok(new
{
Data = product,
Links = links
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving product with ID {ProductId}", id);
return StatusCode(
StatusCodes.Status500InternalServerError,
new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An error occurred while retrieving the product",
Detail = "Please try again later or contact support if the problem persists"
});
}
}
/// <summary>
/// Creates a new product
/// </summary>
/// <param name="productDto">The product information</param>
/// <returns>The created product</returns>
[HttpPost(Name = "CreateProduct")]
[Authorize(Roles = "Admin,ProductManager")]
[ProducesResponseType(typeof(ProductDTO), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> CreateProduct(CreateProductDTO productDto)
{
try
{
_logger.LogInformation(
"Creating product with name {ProductName}", productDto.Name);
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var createdProduct = await _productService.CreateProductAsync(productDto);
// Return 201 Created with proper location header
return CreatedAtAction(
nameof(GetProduct),
new { id = createdProduct.Id, version = "1.0" },
createdProduct);
}
catch (ValidationException ex)
{
_logger.LogWarning(
"Validation error creating product: {ErrorMessage}", ex.Message);
return BadRequest(new ValidationProblemDetails(
ex.Errors.ToDictionary(
e => e.PropertyName,
e => new[] { e.ErrorMessage })));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating product");
return StatusCode(
StatusCodes.Status500InternalServerError,
new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An error occurred while creating the product",
Detail = "Please try again later or contact support if the problem persists"
});
}
}
// Additional CRUD methods...
private string CreateProductsResourceUri(
ProductQueryParameters parameters,
ResourceUriType uriType)
{
switch (uriType)
{
case ResourceUriType.PreviousPage:
return _linkGenerator.GetUriByAction(
HttpContext,
action: nameof(GetProducts),
controller: "Products",
values: new
{
version = "1.0",
pageNumber = parameters.PageNumber - 1,
pageSize = parameters.PageSize,
category = parameters.Category,
searchTerm = parameters.SearchTerm,
minPrice = parameters.MinPrice,
maxPrice = parameters.MaxPrice,
orderBy = parameters.OrderBy
})!;
case ResourceUriType.NextPage:
return _linkGenerator.GetUriByAction(
HttpContext,
action: nameof(GetProducts),
controller: "Products",
values: new
{
version = "1.0",
pageNumber = parameters.PageNumber + 1,
pageSize = parameters.PageSize,
category = parameters.Category,
searchTerm = parameters.SearchTerm,
minPrice = parameters.MinPrice,
maxPrice = parameters.MaxPrice,
orderBy = parameters.OrderBy
})!;
default:
return _linkGenerator.GetUriByAction(
HttpContext,
action: nameof(GetProducts),
controller: "Products",
values: new
{
version = "1.0",
pageNumber = parameters.PageNumber,
pageSize = parameters.PageSize,
category = parameters.Category,
searchTerm = parameters.SearchTerm,
minPrice = parameters.MinPrice,
maxPrice = parameters.MaxPrice,
orderBy = parameters.OrderBy
})!;
}
}
}
Streaming API for Large Data Sets
[ApiController]
[Route("api/[controller]")]
public class ReportsController : ControllerBase
{
private readonly IReportService _reportService;
public ReportsController(IReportService reportService)
{
_reportService = reportService;
}
// Streaming a large CSV file
[HttpGet("sales/export")]
[Authorize(Roles = "Admin,Analyst")]
public async Task<IActionResult> ExportSalesData(
[FromQuery] DateTime startDate,
[FromQuery] DateTime endDate)
{
if (endDate < startDate)
{
return BadRequest("End date must be after start date");
}
// Set up the CSV streaming response
Response.ContentType = "text/csv";
Response.Headers.Add(
"Content-Disposition",
$"attachment; filename=sales-{startDate:yyyy-MM-dd}-to-{endDate:yyyy-MM-dd}.csv");
// Stream directly to the response, avoiding large memory usage
await _reportService.StreamSalesReportAsync(
HttpContext.Response.Body,
startDate,
endDate);
return new EmptyResult();
}
// Server-sent events (SSE) for real-time data
[HttpGet("sales/realtime")]
[Authorize(Roles = "Admin,Analyst")]
public async Task SalesDataRealtime(
[FromQuery] string region = "all",
[FromQuery] int intervalSeconds = 5)
{
Response.Headers.Add("Content-Type", "text/event-stream");
Response.Headers.Add("Cache-Control", "no-cache");
Response.Headers.Add("Connection", "keep-alive");
// Keep connection alive until canceled
var cancellationToken = HttpContext.RequestAborted;
while (!cancellationToken.IsCancellationRequested)
{
var data = await _reportService.GetRealtimeSalesDataAsync(region);
// Format for SSE
await Response.WriteAsync($"data: {JsonSerializer.Serialize(data)}\n\n");
await Response.Body.FlushAsync();
await Task.Delay(intervalSeconds * 1000, cancellationToken);
}
}
}
// Service implementation for streaming
public class ReportService : IReportService
{
private readonly ISalesRepository _salesRepository;
public ReportService(ISalesRepository salesRepository)
{
_salesRepository = salesRepository;
}
public async Task StreamSalesReportAsync(
Stream outputStream,
DateTime startDate,
DateTime endDate)
{
using var writer = new StreamWriter(outputStream, leaveOpen: true);
using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
// Write header
csv.WriteHeader<SalesReportRow>();
await csv.NextRecordAsync();
// Stream data in batches to avoid memory issues
int page = 1;
const int pageSize = 1000;
int totalRecords = 0;
while (true)
{
var sales = await _salesRepository.GetSalesForPeriodAsync(
startDate,
endDate,
page,
pageSize);
if (!sales.Any())
{
break;
}
foreach (var sale in sales)
{
var reportRow = new SalesReportRow
{
OrderId = sale.OrderId,
OrderDate = sale.OrderDate,
CustomerName = sale.Customer.Name,
ProductId = sale.ProductId,
ProductName = sale.Product.Name,
Quantity = sale.Quantity,
UnitPrice = sale.UnitPrice,
TotalPrice = sale.TotalPrice,
Region = sale.Customer.Region
};
csv.WriteRecord(reportRow);
await csv.NextRecordAsync();
totalRecords++;
}
page++;
await writer.FlushAsync();
}
}
public async Task<RealtimeSalesData> GetRealtimeSalesDataAsync(string region)
{
var startTime = DateTime.UtcNow.AddMinutes(-5);
var endTime = DateTime.UtcNow;
var salesData = await _salesRepository.GetRealtimeSalesAsync(startTime, endTime, region);
return new RealtimeSalesData
{
Timestamp = DateTime.UtcNow,
Region = region,
TotalSales = salesData.Sum(s => s.TotalPrice),
OrderCount = salesData.Count(),
TopProducts = salesData
.GroupBy(s => s.ProductId)
.Select(g => new ProductSalesData
{
ProductId = g.Key,
ProductName = g.First().Product.Name,
Quantity = g.Sum(s => s.Quantity),
Revenue = g.Sum(s => s.TotalPrice)
})
.OrderByDescending(p => p.Revenue)
.Take(5)
.ToList()
};
}
}
GraphQL Integration
// First, install required packages:
// dotnet add package HotChocolate.AspNetCore
// dotnet add package HotChocolate.Data.EntityFramework
// Program.cs
builder.Services
.AddGraphQLServer()
.AddQueryType<Query>()
.AddMutationType<Mutation>()
.AddSubscriptionType<Subscription>()
.AddType<TaskType>()
.AddType<UserType>()
.AddFiltering()
.AddSorting()
.AddProjections()
.AddAuthorization();
// In configure section
app.UseWebSockets();
app.MapGraphQL();
// Query class
public class Query
{
[UseDbContext(typeof(TaskDbContext))]
[UseProjection]
[UseFiltering]
[UseSorting]
public IQueryable<TaskEntity> GetTasks([ScopedService] TaskDbContext context)
{
return context.Tasks;
}
[UseDbContext(typeof(TaskDbContext))]
[UseProjection]
public async Task<TaskEntity> GetTaskById(
[ScopedService] TaskDbContext context,
int id)
{
return await context.Tasks.FindAsync(id);
}
[UseDbContext(typeof(TaskDbContext))]
[UseProjection]
[UseFiltering]
[UseSorting]
public IQueryable<UserEntity> GetUsers([ScopedService] TaskDbContext context)
{
return context.Users;
}
}
// Mutation class
public class Mutation
{
[UseDbContext(typeof(TaskDbContext))]
[Authorize(Roles = new[] { "Admin", "TaskManager" })]
public async Task<TaskPayload> CreateTask(
[ScopedService] TaskDbContext context,
CreateTaskInput input)
{
var task = new TaskEntity
{
Title = input.Title,
Description = input.Description,
DueDate = input.DueDate,
Priority = input.Priority,
CreatedAt = DateTime.UtcNow
};
context.Tasks.Add(task);
await context.SaveChangesAsync();
return new TaskPayload(task);
}
[UseDbContext(typeof(TaskDbContext))]
[Authorize(Roles = new[] { "Admin", "TaskManager" })]
public async Task<TaskPayload> UpdateTask(
[ScopedService] TaskDbContext context,
UpdateTaskInput input)
{
TaskEntity task = await context.Tasks.FindAsync(input.Id);
if (task == null)
{
return new TaskPayload(
new UserError("Task not found", "NOT_FOUND"));
}
task.Title = input.Title ?? task.Title;
task.Description = input.Description ?? task.Description;
task.IsCompleted = input.IsCompleted ?? task.IsCompleted;
task.DueDate = input.DueDate ?? task.DueDate;
task.Priority = input.Priority ?? task.Priority;
await context.SaveChangesAsync();
return new TaskPayload(task);
}
}
// Subscription class
public class Subscription
{
[Subscribe]
[Topic]
public TaskEntity OnTaskCreated([EventMessage] TaskEntity task)
{
return task;
}
[Subscribe]
[Topic]
public TaskEntity OnTaskUpdated([EventMessage] TaskEntity task)
{
return task;
}
}
// REST controller with GraphQL integration
[ApiController]
[Route("api/[controller]")]
public class HybridController : ControllerBase
{
private readonly IHttpClientFactory _clientFactory;
public HybridController(IHttpClientFactory clientFactory)
{
_clientFactory = clientFactory;
}
// REST endpoint that uses GraphQL internally for complex queries
[HttpGet("dashboard")]
public async Task<IActionResult> GetDashboardData()
{
var client = _clientFactory.CreateClient("GraphQLClient");
var query = @"
query GetDashboardData {
tasks(
where: { isCompleted: { eq: false } }
order: { dueDate: ASC }
first: 5
) {
nodes {
id
title
dueDate
priority
assignedUser {
name
avatarUrl
}
}
}
recentlyCompletedTasks: tasks(
where: { isCompleted: { eq: true } }
order: { completedAt: DESC }
first: 3
) {
nodes {
id
title
completedAt
assignedUser {
name
}
}
}
taskStats {
totalCount
completedCount
overdueCount
upcomingCount
}
userPerformance {
userName
tasksCompleted
averageCompletionTime
}
}";
var request = new StringContent(
JsonSerializer.Serialize(new { query }),
Encoding.UTF8,
"application/json");
var response = await client.PostAsync("/graphql", request);
var content = await response.Content.ReadAsStringAsync();
// Process GraphQL response and return as REST response
return Ok(JsonDocument.Parse(content));
}
}
Conclusion
ASP.NET Core provides a comprehensive, high-performance platform for building modern REST APIs. By leveraging its built-in features such as content negotiation, security, middleware, and OpenAPI integration, developers can create robust, scalable API solutions that follow REST principles.
The framework’s flexibility allows for implementation approaches that suit various project sizes and requirements, from minimal APIs for simpler microservices to controller-based APIs for more complex enterprise applications.
When building REST APIs with ASP.NET Core, consider these key takeaways:
- Use the right approach for your needs - Choose between Controller-based or Minimal APIs based on your project’s complexity and team familiarity
- Embrace HTTP semantics - Leverage HTTP methods, status codes, and headers to create truly RESTful services
- Document your API - Use built-in OpenAPI/Swagger tools to provide interactive documentation
- Implement proper validation - Validate all input to ensure data integrity and security
- Design with performance in mind - Use asynchronous patterns, caching, and efficient resource management
- Monitor and measure - Implement logging, health checks, and metrics to maintain visibility into your API’s behavior
By following these principles and leveraging the patterns and practices outlined in this guide, you can build ASP.NET Core REST APIs that are maintainable, secure, and performant.