Build a .NET 10 Web API - Minimal APIs, EF Core & SQL Server
We'll build a Web API in .NET 10 using Minimal APIs, Entity Framework Core and SQL Server. We cover Centralised Package Management, Swagger, a Clean Architecture solution split across Application and Infrastructure projects and FluentValidation.
Creating the Web API project
In Visual Studio, create a new project and choose ASP.NET Core Web API. Give the project a name of Api and a solution name of MyShop.
Tick Enable OpenAPI support and untick Use Controllers, since we are going to be using Minimal APIs. Minimal APIs cut out a lot of the boilerplate code that Controllers need, and they are the approach Microsoft now recommends for new projects.
Setting up Centralised Package Management
If you share a package across multiple projects, it's easy to end up with different versions of the same package in different places, which leads to version mismatches. Centralised Package Management fixes this by keeping every package version in one file.
Run the following command in the Package Manager Console, in the folder where your solution is:
dotnet new packagespropsThis creates a Directory.Packages.props file. Move the package reference and its version into this file, using PackageVersion instead of PackageReference:
<!-- Directory.Packages.props -->
<Project>
<PropertyGroup>
<!-- Enable central package management, https://learn.microsoft.com/en-us/nuget/consume-packages/Central-Package-Management -->
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
</ItemGroup>
</Project>Then remove the version from the .csproj file, since it is now managed centrally:
<!-- Api.csproj -->
<Project Sdk="Microsoft.NET.Sdk.Web">
...
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
</ItemGroup>
</Project>Adding Swagger
Swagger used to be installed and configured automatically when creating a new Web API project, but this was removed in .NET 9, so it needs to be added back in. Install the Swashbuckle.AspNetCore.SwaggerUI into the Api project.
Then add the following:
// Program.cs
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/openapi/v1.json", "Api");
});
}Updating launchSettings.json
To have the Swagger UI load automatically when running the application, set launchBrowser to true and launchUrl to swagger in launchSettings.json:
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"Api": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7021;http://localhost:5015",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}Running the application should now load the Swagger UI.
Installing Visual Studio and SQL Server
If you do not already have Visual Studio installed, the Community edition is sufficient for individual developers. The Professional edition is aimed at companies that need a paid licence.
If you need to install a local database, go to Tools > Get Tools and Features in Visual Studio, select Individual components, and search for SQL. Install SQL Server Data Tools if you want to manage the database from within Visual Studio, and SQL Server Express LocalDB if you do not already have SQL Server installed.
You can check the database connection in Visual Studio under View > SQL Server Object Explorer. Right click on SQL Server, and select Add SQL Server.... Use the local DB server name (localdb)\MSSQLLocalDB, select Windows authentication, and press Connect to test it.
Creating the Infrastructure project
Right click on the solution, add a new project, select Class Library, and name it Infrastructure. This project holds the database logic.
Installing Entity Framework Core
Install Microsoft.EntityFrameworkCore.Design, Microsoft.EntityFrameworkCore.SqlServer, and Microsoft.EntityFrameworkCore.Tools into the Infrastructure project. These packages are needed for migrations, which let Entity Framework Core apply database changes automatically rather than relying on manually written scripts.
For migrations to run correctly from the Infrastructure project, remove the following from each of the package references in the .csproj file:
<!-- Infrastructure.csproj -->
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>Creating and configuring the Product entity
Create the entity in the Infrastructure project:
// Product.cs
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 DateTime Created { get; set; }
public DateTime? LastUpdated { get; set; }
}By default, Entity Framework Core sets string properties to nvarchar(max), which can cause performance issues on a large database. Configure the entity to set explicit maximum lengths, a primary key, and a table name by implementing IEntityTypeConfiguration<Product>:
// ProductConfiguration.cs
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.HasKey(x => x.Id);
builder.Property(x => x.Name)
.HasMaxLength(100);
builder.Property(x => x.Description)
.HasMaxLength(1000);
builder.ToTable("Products");
}
}Setting up the DbContext
The DbContext is the core class of Entity Framework Core, and this is where queries take place. Rather than adding each entity configuration manually inside OnModelCreating, call ApplyConfigurationsFromAssembly so that every IEntityTypeConfiguration in the assembly is picked up automatically:
// MyShopDbContext.cs
public class MyShopDbContext : DbContext
{
public required DbSet<Product> Products { get; init; }
public MyShopDbContext()
{
}
public MyShopDbContext(DbContextOptions<MyShopDbContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(MyShopDbContext).Assembly);
}
}Creating the Application project
Add another Class Library project named Application. This project holds the business logic. Set up the project references so that Api references Infrastructure, and Infrastructure references Application.
To avoid exposing entities directly through the API, DTOs are used to only expose the properties that are actually needed, converting them into entities once they reach the Infrastructure project.
Creating the DTO and repository
Create the DTO used to create and update a product as a record in the Application project, since it should be immutable:
// UpsertProductDto.cs
public record UpsertProductDto(
string Name,
string Description,
decimal Price
);Create the repository in the Infrastructure project, injecting the DbContext and passing a CancellationToken as a safe way to stop long running asynchronous requests. Inside there, add CreateAsync method which passes in the DTO and cancellation token.
Project the DTO to a new Product entity, add it to the DbContext before saving the changes, and returning the inserted ID.
// ProductRepository.cs
public class ProductRepository
{
private readonly MyShopDbContext _context;
public ProductRepository(MyShopDbContext context)
{
_context = context;
}
public async Task<int> CreateAsync(
UpsertProductDto upsertProduct,
CancellationToken cancellationToken)
{
var product = new Product
{
Name = upsertProduct.Name,
Description = upsertProduct.Description,
Price = upsertProduct.Price,
Created = DateTime.UtcNow
};
await _context.AddAsync(product, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return product.Id;
}
}Create a matching interface in the Application project, which is what the rest of the application communicates through, and implement it on ProductRepository:
// IProductRepository.cs
public interface IProductRepository
{
Task<int> CreateAsync(UpsertProductDto upsertProduct, CancellationToken cancellationToken);
}Registering the DbContext and repositories
Create a ConfigureServices.cs file in the Infrastructure project to keep service registration in one place:
// ConfigureServices.cs
public static class ConfigureServices
{
extension (IServiceCollection services)
{
public IServiceCollection AddMyShopDbContext(IConfiguration configuration)
{
services.AddDbContext<MyShopDbContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("MyShopDbContext")));
return services;
}
public IServiceCollection AddRepositories()
{
services.AddScoped<IProductRepository, ProductRepository>();
return services;
}
}
}Then register them in Program.cs, after importing the Infrastructure project:
// Program.cs
using Infrastructure;
...
builder.Services
.AddMyShopDbContext(builder.Configuration)
.AddRepositories();Adding the connection string
Add the connection string to appsettings.Development.json, under the key referenced in AddMyShopDbContext:
"ConnectionStrings": {
"MyShopDbContext": "Server=(localdb)\\MSSQLLocalDB; Database=MyShop; Trusted_Connection=True; TrustServerCertificate=True; Integrated Security=true;"
}Adding a migration and updating the database
Open the Package Manager Console in Visual Studio by going to Tools > NuGet Package Manager > Package Manager Console. Set the default project to Infrastructure, and run:
Add-Migration AddProducts -ErrorAction ContinueThen apply the migration to create the database and the table:
Update-Database -ErrorAction ContinueYou can confirm this worked under View > SQL Server Object Explorer in Visual Studio, where you should see the MyShop database with a Products table.
Setting up validation with FluentValidation
Data annotations work, but they are difficult to unit test and do not support asynchronous validation. FluentValidation solves both of these problems. Install FluentValidation and FluentValidation.DependencyInjectionExtensions into the Application project, then create the validator:
// UpsertProductDtoValidator.cs
public class UpsertProductDtoValidator : AbstractValidator<UpsertProductDto>
{
public UpsertProductDtoValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.WithMessage("Name is required")
.MinimumLength(3)
.WithMessage("Name must be at least 3 characters")
.MaximumLength(100)
.WithMessage("Name must have no more than 100 characters");
RuleFor(x => x.Description)
.NotEmpty()
.WithMessage("Description is required")
.MinimumLength(50)
.WithMessage("Description must be at least 50 characters")
.MaximumLength(1000)
.WithMessage("Description must have no more than 1000 characters");
RuleFor(x => x.Price)
.NotEmpty()
.WithMessage("Price is required")
.GreaterThan(0)
.WithMessage("Price must be more than 0")
.LessThanOrEqualTo(10000)
.WithMessage("Price must be less than or equal to 10,000");
}
}Registering the validator
Create a ConfigureServices.cs file in the Application project as well:
// ConfigureServices.cs
public static class ConfigureServices
{
extension(IServiceCollection services)
{
public IServiceCollection AddValidators()
{
services.AddScoped<IValidator<UpsertProductDto>, UpsertProductDtoValidator>();
return services;
}
}
}Then register it in Program.cs, after importing the Application project:
// Program.cs
using Application;
...
builder.Services.AddValidators();Creating the ProductService
A service brings the validation and repository together into one method, rather than repeating the same database logic across multiple services.
In CreateAsync, it validates the DTO and will throw an exception if there is a validation problem. If not, it calls CreateAsync in ProductRepository.
// IProductService.cs
public interface IProductService
{
public Task<int> CreateAsync(UpsertProductDto upsertProduct);
}// ProductService.cs
public class ProductService : IProductService
{
private readonly IValidator<UpsertProductDto> _upsertProductDtoValidator;
private readonly IProductRepository _productRepository;
public ProductService(IValidator<UpsertProductDto> upsertProductDtoValidator,
IProductRepository productRepository)
{
_upsertProductDtoValidator = upsertProductDtoValidator;
_productRepository = productRepository;
}
public async Task<int> CreateAsync(UpsertProductDto upsertProduct)
{
_upsertProductDtoValidator.ValidateAndThrow(upsertProduct);
return await _productRepository.CreateAsync(upsertProduct, CancellationToken.None);
}
}Registering the service
Add AddServices to ConfigureServices.cs in the Application project:
// ConfigureServices.cs
public static class ConfigureServices
{
extension(IServiceCollection services)
{
public IServiceCollection AddServices()
{
services.AddScoped<IProductService, ProductService>();
return services;
}
...
}
}Then add it to Program.cs:
// Program.cs
builder.Services.AddServices()
.AddValidators();Creating the create product endpoint
We use Minimal API endpoints as it avoid the boilerplate that comes with controllers.
Rather than adding all the endpoints into Program.cs, we create a new ProductsEndpoints class, and create an extension member which uses the WebApplication instance.
In MapProductsEndpoints(), we create a group for the products endpoints which will be prefixed with /api/products. We then use that group to add a POST endpoint which points to CreateAsync.
CreateAsync returns a 201 Created response and returns the response from productService.CreateAsync().
// ProductsEndpoints.cs
public static class ProductsEndpoints
{
extension (WebApplication app)
{
public WebApplication MapProductsEndpoints()
{
var group = app.MapGroup("/api/products");
group.MapPost("/", CreateAsync);
return app;
}
}
public static async Task<Created<int>> CreateAsync(UpsertProductDto upsertProduct,
IProductService productService)
{
return TypedResults.Created(
string.Empty,
await productService.CreateAsync(upsertProduct));
}
}Register it in Program.cs, after importing the endpoints namespace:
// Program.cs
using Api.Endpoints;
...
app.MapProductsEndpoints();Minimal API endpoints are registered in Swagger automatically, so the endpoint can be tested straight away. A successful request returns a 201 status code along with the new product ID.
You can go deeper with Minimal APIs using our online course which also focuses on authentication, unit testing and logging.
Handling validation errors with an exception handler
Without any exception handling in place, calling ValidateAndThrow in FluentValidation throws a 500 status code instead of a 400. Rather than handling this in middleware, an exception handler keeps the logic in its own class, separate from other concerns.
To do this, we add a framework reference to the Application project's .csproj file:
<!-- Application.csproj -->
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>Then create the exception handler:
// ValidationHandler.cs
public class ValidationHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not ValidationException validationException)
{
return false;
}
var errors = validationException
.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray());
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(
new ValidationProblemDetails(errors)
{
Title = "Validation error",
Status = httpContext.Response.StatusCode,
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1"
}, cancellationToken);
return true;
}
}Register and use it in Program.cs:
// ConfigureServices.cs
public static class ConfigureServices
{
extension(IServiceCollection services)
{
...
public IServiceCollection AddExceptionHandlers()
{
services.AddExceptionHandler<ValidationHandler>();
services.AddProblemDetails();
return services;
}
}
}// Program.cs
builder.Services
.AddServices()
.AddValidators()
.AddExceptionHandlers(); // <-- This needs to be added
...
var app = builder.Build();
app.UseExceptionHandler(); // <-- This needs to be added as wellRunning the same invalid request again now returns a 400 status code, along with a readable list of the validation errors.
Getting a product
Add a DTO to project only the data that is needed, rather than returning the whole entity:
// GetProductDto.cs
public record GetProductDto(
int Id,
string Name,
string Description,
decimal Price
);Add the query to the repository:
// ProductRepository.cs
public class ProductRepository : IProductRepository
{
...
public async Task<GetProductDto?> GetAsync(int id, CancellationToken cancellationToken)
{
var product = await _context.Products
.Where(x => x.Id == id)
.Select(x => new GetProductDto(x.Id, x.Name, x.Description, x.Price))
.FirstOrDefaultAsync(cancellationToken);
if (product == null)
{
return null;
}
return product;
}
...
}// IProductRepository.cs
public interface IProductRepository
{
Task<GetProductDto?> GetAsync(int id, CancellationToken cancellationToken);
...
}Call it from the service:
// ProductService.cs
public class ProductService : IProductService
{
...
public Task<GetProductDto?> GetAsync(int id, CancellationToken cancellationToken)
{
return _productRepository.GetAsync(id, cancellationToken);
}
...
}// IProductService.cs
public interface IProductService
{
Task<GetProductDto?> GetAsync(int id, CancellationToken cancellationToken);
...
}Then map the endpoint, returning a 404 if the product does not exist, or a 200 with the product DTO if it does:
// ProductsEndpoints.cs
public static class ProductsEndpoints
{
extension (WebApplication app)
{
public WebApplication MapProductsEndpoints()
{
...
group.MapGet("{id:int}", GetAsync);
...
return app;
}
}
public static async Task<Results<Ok<GetProductDto>, NotFound>> GetAsync(int id,
IProductService productService,
CancellationToken cancellationToken)
{
var product = await productService.GetAsync(id, cancellationToken);
if (product == null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(product);
}
...
}Updating a product
The update method uses the same UpsertProductDto used to create the product, and returns a boolean so the endpoint knows whether the record existed:
// ProductRepository.cs
public class ProductRepository : IProductRepository
{
...
public async Task<bool> UpdateAsync(int id, UpsertProductDto upsertProduct,
CancellationToken cancellationToken)
{
var product = await _context.Products.SingleOrDefaultAsync(x => x.Id == id,
cancellationToken);
if (product == null)
{
return false;
}
product.Name = upsertProduct.Name;
product.Description = upsertProduct.Description;
product.Price = upsertProduct.Price;
product.LastUpdated = DateTime.UtcNow;
await _context.SaveChangesAsync(cancellationToken);
return true;
}
}// IProductRepository.cs
public interface IProductRepository
{
...
Task<bool> UpdateAsync(int id, UpsertProductDto upsertProduct,
CancellationToken cancellationToken);
}The same validator used for creating a product is reused in the service:
// ProductService.cs
public class ProductService : IProductService
{
...
public async Task<bool> UpdateAsync(int id, UpsertProductDto upsertProduct)
{
_upsertProductDtoValidator.ValidateAndThrow(upsertProduct);
return await _productRepository.UpdateAsync(
id,
upsertProduct,
CancellationToken.None);
}
}// IProductService.cs
public interface IProductService
{
...
Task<bool> UpdateAsync(int id, UpsertProductDto upsertProduct);
}Then map the endpoint, returning a 204 on success or a 404 if the record does not exist:
// ProductsEndpoints.cs
public static class ProductsEndpoints
{
extension (WebApplication app)
{
public WebApplication MapProductsEndpoints()
{
...
group.MapPut("{id:int}", UpdateAsync);
return app;
}
}
...
public static async Task<Results<NoContent, NotFound>> UpdateAsync(int id, UpsertProductDto upsertProduct,
IProductService productService)
{
var updated = await productService.UpdateAsync(id, upsertProduct);
if (!updated)
{
return TypedResults.NotFound();
}
return TypedResults.NoContent();
}
}Deleting a product
Deleting only needs the ID, rather than a DTO:
// ProductRepository.cs
public class ProductRepository : IProductRepository
{
...
public async Task<bool> DeleteAsync(int id,
CancellationToken cancellationToken)
{
var product = await _context.Products.SingleOrDefaultAsync(x => x.Id == id,
cancellationToken);
if (product == null)
{
return false;
}
_context.Remove(product);
await _context.SaveChangesAsync(cancellationToken);
return true;
}
}// IProductRepository.cs
public interface IProductRepository
{
...
Task<bool> DeleteAsync(int id,
CancellationToken cancellationToken);
}// ProductService.cs
public class ProductService : IProductService
{
...
public async Task<bool> DeleteAsync(int id)
{
return await _productRepository.DeleteAsync(
id,
CancellationToken.None);
}
}// IProductService.cs
public interface IProductService
{
...
Task<bool> DeleteAsync(int id);
}Then map the endpoint, once again returning a 204 or a 404 depending on whether the record existed:
// ProductsEndpoints.cs
public static class ProductsEndpoints
{
extension (WebApplication app)
{
public WebApplication MapProductsEndpoints()
{
var group = app.MapGroup("/api/products");
...
group.MapDelete("{id:int}", DeleteAsync);
return app;
}
}
...
public static async Task<Results<NoContent, NotFound>> DeleteAsync(int id,
IProductService productService)
{
var deleted = await productService.DeleteAsync(id);
if (!deleted)
{
return TypedResults.NotFound();
}
return TypedResults.NoContent();
}
}Watch the video
Watch the video where we build this .NET 10 Web API from start to finish, using Minimal APIs, Entity Framework Core and SQL Server.
Related tutorials
Learn why EF Core without migrations is a disaster. Learn how to setup, configure, and deploy using migrations to keep databases consistent across environments.
Learn how to use the new LeftJoin and RightJoin LINQ methods introduced in EF Core for .NET 10, replacing the verbose GroupJoin and SelectMany pattern.