# .NET 8: Building an API with ASP.NET Core > Complete guide to building a professional REST API with .NET 8 and ASP.NET Core. Controllers, Entity Framework Core, validation and best practices explained. - Published: 2026-01-17 - Updated: 2026-03-28 - Author: SharpSkill - Tags: dotnet, aspnet core, csharp, rest api, entity framework - Reading time: 14 min --- .NET 8 represents a major release of Microsoft's framework, bringing significant improvements in performance and productivity for API development. ASP.NET Core combines the power of C# with a modern, modular architecture ideal for enterprise applications. This guide covers the complete creation of a professional REST API, from initial setup to deployment-ready code. > **.NET 8 LTS** > > .NET 8 is a Long-Term Support (LTS) release with 3 years of support. Minimal APIs and Native AOT improvements make it an optimal choice for microservices and cloud-native applications. ## Initial Project Setup with .NET 8 Creating an ASP.NET Core API project uses the .NET CLI to generate an optimized project structure. Configuring essential NuGet packages prepares the foundation for development. ```bash # terminal # Check installed .NET version dotnet --version # Expected: 8.0.x # Create the API project dotnet new webapi -n ProductApi -o ProductApi cd ProductApi # Add essential packages dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Design dotnet add package FluentValidation.AspNetCore dotnet add package Swashbuckle.AspNetCore ``` These commands create an API project with the necessary dependencies for Entity Framework Core, validation, and Swagger documentation. ```csharp // Program.cs using Microsoft.EntityFrameworkCore; using ProductApi.Data; using ProductApi.Services; using FluentValidation; using FluentValidation.AspNetCore; var builder = WebApplication.CreateBuilder(args); // Configure Entity Framework Core with SQL Server builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); // Register business services builder.Services.AddScoped(); builder.Services.AddScoped(); // Configure controllers with validation builder.Services.AddControllers(); builder.Services.AddFluentValidationAutoValidation(); builder.Services.AddValidatorsFromAssemblyContaining(); // Configure Swagger for documentation builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new() { Title = "Product API", Version = "v1" }); }); var app = builder.Build(); // Middleware pipeline if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run(); ``` This configuration uses .NET 8's Minimal API pattern while retaining controllers for a clear and maintainable structure. ## Data Models and Entity Framework Core Models represent the business entities of the application. Entity Framework Core handles object-relational mapping with fluent configuration and smart conventions. ```csharp // Models/Product.cs namespace ProductApi.Models; public class Product { // Primary key with auto-increment public int Id { get; set; } // Required properties (non-nullable in C# 8+) public required string Name { get; set; } public required string Description { get; set; } // Price with decimal precision public decimal Price { get; set; } // Stock with default value public int StockQuantity { get; set; } = 0; // Product status public bool IsActive { get; set; } = true; // Relationship with Category (foreign key) public int CategoryId { get; set; } public Category? Category { get; set; } // Automatic tracking dates public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? UpdatedAt { get; set; } } ``` The `required` keyword from C# 11 ensures essential properties are always initialized during creation. ```csharp // Models/Category.cs namespace ProductApi.Models; public class Category { public int Id { get; set; } public required string Name { get; set; } // Slug for friendly URLs public required string Slug { get; set; } public string? Description { get; set; } // Inverse navigation: list of products in this category public ICollection Products { get; set; } = new List(); public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } ``` ```csharp // Data/AppDbContext.cs using Microsoft.EntityFrameworkCore; using ProductApi.Models; namespace ProductApi.Data; public class AppDbContext : DbContext { public AppDbContext(DbContextOptions options) : base(options) { } // DbSets for each entity public DbSet Products => Set(); public DbSet Categories => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { // Product entity configuration modelBuilder.Entity(entity => { // Index on name for fast search entity.HasIndex(p => p.Name); // Price precision: 18 digits, 2 decimals entity.Property(p => p.Price) .HasPrecision(18, 2); // Relationship with Category entity.HasOne(p => p.Category) .WithMany(c => c.Products) .HasForeignKey(p => p.CategoryId) .OnDelete(DeleteBehavior.Restrict); }); // Category entity configuration modelBuilder.Entity(entity => { // Unique slug entity.HasIndex(c => c.Slug).IsUnique(); // Maximum name length entity.Property(c => c.Name).HasMaxLength(100); }); } } ``` The Fluent API configuration provides precise control over the database schema generated by EF Core migrations. > **Entity Framework Core Migrations** > > Migrations version the database schema. Run `dotnet ef migrations add InitialCreate` then `dotnet ef database update` to apply changes. ## DTOs and Validation with FluentValidation DTOs (Data Transfer Objects) separate domain models from data exposed via the API. FluentValidation provides declarative and maintainable validation. ```csharp // DTOs/ProductDtos.cs namespace ProductApi.DTOs; // DTO for product creation public record CreateProductDto( string Name, string Description, decimal Price, int StockQuantity, int CategoryId ); // DTO for product update public record UpdateProductDto( string? Name, string? Description, decimal? Price, int? StockQuantity, bool? IsActive ); // DTO for response (read) public record ProductDto( int Id, string Name, string Description, decimal Price, int StockQuantity, bool IsActive, string CategoryName, DateTime CreatedAt ); // DTO for list with pagination public record ProductListDto( int Id, string Name, decimal Price, int StockQuantity, bool IsActive, string CategoryName ); ``` Using C# 9+ records makes DTOs immutable and concise, with automatic value equality. ```csharp // Validators/ProductValidators.cs using FluentValidation; using ProductApi.DTOs; namespace ProductApi.Validators; public class CreateProductValidator : AbstractValidator { public CreateProductValidator() { // Name is required and limited to 200 characters RuleFor(x => x.Name) .NotEmpty().WithMessage("Product name is required.") .MaximumLength(200).WithMessage("Name cannot exceed 200 characters."); // Description required with minimum length RuleFor(x => x.Description) .NotEmpty().WithMessage("Description is required.") .MinimumLength(10).WithMessage("Description must contain at least 10 characters."); // Positive price required RuleFor(x => x.Price) .GreaterThan(0).WithMessage("Price must be greater than 0.") .LessThanOrEqualTo(999999.99m).WithMessage("Maximum price is 999,999.99."); // Non-negative stock RuleFor(x => x.StockQuantity) .GreaterThanOrEqualTo(0).WithMessage("Stock cannot be negative."); // Valid category RuleFor(x => x.CategoryId) .GreaterThan(0).WithMessage("A valid category is required."); } } public class UpdateProductValidator : AbstractValidator { public UpdateProductValidator() { // Conditional validation: only if value is provided RuleFor(x => x.Name) .MaximumLength(200) .When(x => !string.IsNullOrEmpty(x.Name)); RuleFor(x => x.Price) .GreaterThan(0) .When(x => x.Price.HasValue); RuleFor(x => x.StockQuantity) .GreaterThanOrEqualTo(0) .When(x => x.StockQuantity.HasValue); } } ``` FluentValidation integrates automatically with the ASP.NET Core validation pipeline, returning structured 400 errors. ## Business Services and Abstraction Layer The service layer encapsulates business logic and database operations, facilitating testing and maintenance. ```csharp // Services/IProductService.cs using ProductApi.DTOs; namespace ProductApi.Services; public interface IProductService { // Retrieval with pagination Task<(IEnumerable Items, int TotalCount)> GetAllAsync( int page = 1, int pageSize = 10, string? search = null, int? categoryId = null); // Retrieval by ID Task GetByIdAsync(int id); // Creation Task CreateAsync(CreateProductDto dto); // Update Task UpdateAsync(int id, UpdateProductDto dto); // Deletion Task DeleteAsync(int id); } ``` ```csharp // Services/ProductService.cs using Microsoft.EntityFrameworkCore; using ProductApi.Data; using ProductApi.DTOs; using ProductApi.Models; namespace ProductApi.Services; public class ProductService : IProductService { private readonly AppDbContext _context; public ProductService(AppDbContext context) { _context = context; } public async Task<(IEnumerable Items, int TotalCount)> GetAllAsync( int page = 1, int pageSize = 10, string? search = null, int? categoryId = null) { // Build base query var query = _context.Products .Include(p => p.Category) .AsQueryable(); // Filter by text search if (!string.IsNullOrWhiteSpace(search)) { query = query.Where(p => p.Name.Contains(search) || p.Description.Contains(search)); } // Filter by category if (categoryId.HasValue) { query = query.Where(p => p.CategoryId == categoryId.Value); } // Total count before pagination var totalCount = await query.CountAsync(); // Apply pagination var items = await query .OrderByDescending(p => p.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(p => new ProductListDto( p.Id, p.Name, p.Price, p.StockQuantity, p.IsActive, p.Category!.Name)) .ToListAsync(); return (items, totalCount); } public async Task GetByIdAsync(int id) { // Retrieve with category inclusion var product = await _context.Products .Include(p => p.Category) .FirstOrDefaultAsync(p => p.Id == id); if (product == null) return null; // Map to DTO return new ProductDto( product.Id, product.Name, product.Description, product.Price, product.StockQuantity, product.IsActive, product.Category?.Name ?? "Uncategorized", product.CreatedAt); } public async Task CreateAsync(CreateProductDto dto) { // Create entity var product = new Product { Name = dto.Name, Description = dto.Description, Price = dto.Price, StockQuantity = dto.StockQuantity, CategoryId = dto.CategoryId }; // Add and save _context.Products.Add(product); await _context.SaveChangesAsync(); // Load category for response await _context.Entry(product) .Reference(p => p.Category) .LoadAsync(); return new ProductDto( product.Id, product.Name, product.Description, product.Price, product.StockQuantity, product.IsActive, product.Category?.Name ?? "Uncategorized", product.CreatedAt); } public async Task UpdateAsync(int id, UpdateProductDto dto) { // Retrieve existing entity var product = await _context.Products .Include(p => p.Category) .FirstOrDefaultAsync(p => p.Id == id); if (product == null) return null; // Conditional field updates if (!string.IsNullOrEmpty(dto.Name)) product.Name = dto.Name; if (!string.IsNullOrEmpty(dto.Description)) product.Description = dto.Description; if (dto.Price.HasValue) product.Price = dto.Price.Value; if (dto.StockQuantity.HasValue) product.StockQuantity = dto.StockQuantity.Value; if (dto.IsActive.HasValue) product.IsActive = dto.IsActive.Value; // Update modification date product.UpdatedAt = DateTime.UtcNow; await _context.SaveChangesAsync(); return new ProductDto( product.Id, product.Name, product.Description, product.Price, product.StockQuantity, product.IsActive, product.Category?.Name ?? "Uncategorized", product.CreatedAt); } public async Task DeleteAsync(int id) { // Direct deletion without prior loading var result = await _context.Products .Where(p => p.Id == id) .ExecuteDeleteAsync(); return result > 0; } } ``` Using `ExecuteDeleteAsync` (new in EF Core 7+) improves performance by avoiding entity loading before deletion. ## API Controllers and REST Endpoints Controllers expose REST endpoints and orchestrate calls to business services with appropriate HTTP status code handling. ```csharp // Controllers/ProductsController.cs using Microsoft.AspNetCore.Mvc; using ProductApi.DTOs; using ProductApi.Services; namespace ProductApi.Controllers; [ApiController] [Route("api/[controller]")] [Produces("application/json")] public class ProductsController : ControllerBase { private readonly IProductService _productService; public ProductsController(IProductService productService) { _productService = productService; } /// /// Retrieves the list of products with pagination and filters. /// [HttpGet] [ProducesResponseType(typeof(PaginatedResponse), StatusCodes.Status200OK)] public async Task GetAll( [FromQuery] int page = 1, [FromQuery] int pageSize = 10, [FromQuery] string? search = null, [FromQuery] int? categoryId = null) { // Validate pagination parameters if (page < 1) page = 1; if (pageSize < 1 || pageSize > 100) pageSize = 10; var (items, totalCount) = await _productService.GetAllAsync( page, pageSize, search, categoryId); // Standardized paginated response var response = new PaginatedResponse { Items = items, Page = page, PageSize = pageSize, TotalCount = totalCount, TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) }; return Ok(response); } /// /// Retrieves a product by its identifier. /// [HttpGet("{id:int}")] [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetById(int id) { var product = await _productService.GetByIdAsync(id); if (product == null) { return NotFound(new { message = $"Product with ID {id} not found." }); } return Ok(product); } /// /// Creates a new product. /// [HttpPost] [ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task Create([FromBody] CreateProductDto dto) { // Validation is automatic via FluentValidation var product = await _productService.CreateAsync(dto); // Returns 201 with the created resource URL return CreatedAtAction( nameof(GetById), new { id = product.Id }, product); } /// /// Updates an existing product. /// [HttpPut("{id:int}")] [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task Update(int id, [FromBody] UpdateProductDto dto) { var product = await _productService.UpdateAsync(id, dto); if (product == null) { return NotFound(new { message = $"Product with ID {id} not found." }); } return Ok(product); } /// /// Deletes a product. /// [HttpDelete("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Delete(int id) { var deleted = await _productService.DeleteAsync(id); if (!deleted) { return NotFound(new { message = $"Product with ID {id} not found." }); } // 204 No Content for successful deletion return NoContent(); } } ``` The `ProducesResponseType` attributes document possible response codes for automatic Swagger documentation generation. ```csharp // DTOs/PaginatedResponse.cs namespace ProductApi.DTOs; public class PaginatedResponse { public IEnumerable Items { get; set; } = Enumerable.Empty(); public int Page { get; set; } public int PageSize { get; set; } public int TotalCount { get; set; } public int TotalPages { get; set; } public bool HasPreviousPage => Page > 1; public bool HasNextPage => Page < TotalPages; } ``` > **Route Constraints** > > Using constraints like `{id:int}` prevents routing conflicts and automatically returns a 404 if the format is incorrect. ## Global Error Handling An error handling middleware centralizes exception processing for consistent and secure responses. ```csharp // Middleware/ExceptionMiddleware.cs using System.Net; using System.Text.Json; namespace ProductApi.Middleware; public class ExceptionMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; private readonly IHostEnvironment _env; public ExceptionMiddleware( RequestDelegate next, ILogger logger, IHostEnvironment env) { _next = next; _logger = logger; _env = env; } public async Task InvokeAsync(HttpContext context) { try { // Continue pipeline await _next(context); } catch (Exception ex) { // Log the error _logger.LogError(ex, "An unhandled exception occurred"); // Prepare response context.Response.ContentType = "application/json"; context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; // Different response based on environment var response = _env.IsDevelopment() ? new ErrorResponse( StatusCode: context.Response.StatusCode, Message: ex.Message, Details: ex.StackTrace) : new ErrorResponse( StatusCode: context.Response.StatusCode, Message: "An internal error occurred.", Details: null); // Serialize with camelCase options var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; var json = JsonSerializer.Serialize(response, options); await context.Response.WriteAsync(json); } } } // DTO for error responses public record ErrorResponse(int StatusCode, string Message, string? Details); // Extension to register middleware public static class ExceptionMiddlewareExtensions { public static IApplicationBuilder UseExceptionMiddleware(this IApplicationBuilder app) { return app.UseMiddleware(); } } ``` ```csharp // Program.cs (adding middleware) var app = builder.Build(); // Exception middleware must be first app.UseExceptionMiddleware(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } // ... rest of configuration ``` ## Configuration and Environment Variables Externalized configuration allows adapting the application to different environments without code changes. ```json // appsettings.json { "ConnectionStrings": { "DefaultConnection": "Server=localhost;Database=ProductDb;User Id=sa;Password=YourPassword;TrustServerCertificate=true" }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", "Microsoft.EntityFrameworkCore": "Warning" } }, "ApiSettings": { "DefaultPageSize": 10, "MaxPageSize": 100, "ApiVersion": "1.0" } } ``` ```csharp // Configuration/ApiSettings.cs namespace ProductApi.Configuration; public class ApiSettings { public int DefaultPageSize { get; set; } = 10; public int MaxPageSize { get; set; } = 100; public string ApiVersion { get; set; } = "1.0"; } ``` ```csharp // Program.cs (injecting configuration) builder.Services.Configure( builder.Configuration.GetSection("ApiSettings")); // Usage in a service public class ProductService : IProductService { private readonly ApiSettings _settings; public ProductService(IOptions settings) { _settings = settings.Value; } } ``` ## Unit Testing with xUnit Unit tests validate the behavior of services and controllers in isolation. ```csharp // Tests/ProductServiceTests.cs using Microsoft.EntityFrameworkCore; using ProductApi.Data; using ProductApi.DTOs; using ProductApi.Models; using ProductApi.Services; using Xunit; namespace ProductApi.Tests; public class ProductServiceTests { private AppDbContext CreateInMemoryContext() { // Configure in-memory database var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; return new AppDbContext(options); } [Fact] public async Task CreateAsync_ValidDto_ReturnsProductDto() { // Arrange using var context = CreateInMemoryContext(); // Add test category var category = new Category { Id = 1, Name = "Electronics", Slug = "electronics" }; context.Categories.Add(category); await context.SaveChangesAsync(); var service = new ProductService(context); var dto = new CreateProductDto( Name: "Test Product", Description: "Test Description", Price: 99.99m, StockQuantity: 10, CategoryId: 1); // Act var result = await service.CreateAsync(dto); // Assert Assert.NotNull(result); Assert.Equal("Test Product", result.Name); Assert.Equal(99.99m, result.Price); Assert.Equal("Electronics", result.CategoryName); } [Fact] public async Task GetByIdAsync_NonExistent_ReturnsNull() { // Arrange using var context = CreateInMemoryContext(); var service = new ProductService(context); // Act var result = await service.GetByIdAsync(999); // Assert Assert.Null(result); } [Fact] public async Task UpdateAsync_ExistingProduct_UpdatesFields() { // Arrange using var context = CreateInMemoryContext(); var category = new Category { Id = 1, Name = "Tech", Slug = "tech" }; var product = new Product { Id = 1, Name = "Original Name", Description = "Original Description", Price = 50.00m, StockQuantity = 5, CategoryId = 1 }; context.Categories.Add(category); context.Products.Add(product); await context.SaveChangesAsync(); var service = new ProductService(context); var updateDto = new UpdateProductDto( Name: "Updated Name", Description: null, Price: 75.00m, StockQuantity: null, IsActive: null); // Act var result = await service.UpdateAsync(1, updateDto); // Assert Assert.NotNull(result); Assert.Equal("Updated Name", result.Name); Assert.Equal(75.00m, result.Price); // Fields not provided remain unchanged Assert.Equal(5, result.StockQuantity); } [Fact] public async Task DeleteAsync_ExistingProduct_ReturnsTrue() { // Arrange using var context = CreateInMemoryContext(); var category = new Category { Id = 1, Name = "Test", Slug = "test" }; var product = new Product { Id = 1, Name = "To Delete", Description = "Will be deleted", Price = 10.00m, CategoryId = 1 }; context.Categories.Add(category); context.Products.Add(product); await context.SaveChangesAsync(); var service = new ProductService(context); // Act var result = await service.DeleteAsync(1); // Assert Assert.True(result); Assert.Null(await context.Products.FindAsync(1)); } [Fact] public async Task GetAllAsync_WithSearch_FiltersResults() { // Arrange using var context = CreateInMemoryContext(); var category = new Category { Id = 1, Name = "Category", Slug = "category" }; context.Categories.Add(category); context.Products.AddRange( new Product { Id = 1, Name = "Apple iPhone", Description = "Phone", Price = 999, CategoryId = 1 }, new Product { Id = 2, Name = "Samsung Galaxy", Description = "Phone", Price = 899, CategoryId = 1 }, new Product { Id = 3, Name = "Apple MacBook", Description = "Laptop", Price = 1999, CategoryId = 1 } ); await context.SaveChangesAsync(); var service = new ProductService(context); // Act var (items, totalCount) = await service.GetAllAsync(search: "Apple"); // Assert Assert.Equal(2, totalCount); Assert.All(items, p => Assert.Contains("Apple", p.Name)); } } ``` Tests are run with `dotnet test` from the project root. ## Conclusion .NET 8 with ASP.NET Core provides a complete and performant ecosystem for building professional REST APIs. The combination of Entity Framework Core for data access, FluentValidation for validation, and native dependency injection enables building maintainable and testable applications. ### Checklist for a Quality .NET API - ✅ Separate DTOs from domain models - ✅ Implement a service layer for business logic - ✅ Use FluentValidation for declarative validations - ✅ Configure global error handling middleware - ✅ Externalize configuration with IOptions - ✅ Write unit tests for services - ✅ Document the API with Swagger/OpenAPI The layered architecture (Controllers → Services → Repository/DbContext) promotes separation of concerns and facilitates application evolution. .NET 8 features like records, required properties, and ExecuteDeleteAsync modernize code while improving performance. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/en/blog/dotnet/dotnet-8-building-api-aspnet-core