blog post

Productive Web API Development with FastEndpoints and Vertical Slice Architecture in .NET

A Big Thanks To The Sponsors of This Blog Post

Master The Clean Architecture. This comprehensive course will teach you how to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture. Join 3,150+ students to accelerate your growth as a software architect.

Learn more

Master The Modular Monolith Architecture. This in-depth course will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario. Join 1,050+ students to accelerate your growth as a software architect.

Learn more

In recent projects, I started using Vertical Slice Architecture (VSA). I chose VSA because it provides fast feature development and easy navigation in the codebase. Each slice encapsulates all aspects of a specific feature, including the Web API, business logic, and data access.

I was looking for a way to become more productive with my VSA when creating Web APIs. And I found a FastEndoints library that helped me with productivity.

Today I want to share with you my personal experience — how to be more productive when developing Web APIs with FastEndpoints and Vertical Slice Architecture. We will go step-by-step and implement a production ready application that has Web API, validation, stores and retrieves data from a database.

What is FastEndpoints?

FastEndpoints is an open-source library for .NET that simplifies the creation of Web APIs by eliminating the need of writing boilerplate code. Built on top of ASP.NET Core Minimal APIs, it leverages all the performance benefits while providing a more straightforward programming model.

In the Minimal APIs, you need to define yourself how you want to structure your endpoints, how to group or not group them together in a single file. In FastEndpoints you define each endpoint in a separate class, which results in a Single Responsible and maintainable endpoints.

For me, this concept ideally fits in Vertical Slice Architecture.

Now let's explore an application we will be building.

The Application We Will Be Building

Today I'll show you how to implement a Shipping Application that is responsible for creating customers, orders and shipments for ordered products.

This application implements the following use cases:

  • Create a customer
  • Create an Order with OrderItems, place a Shipment
  • Get a Shipment by number
  • Update Shipment state

Initial steps you need to follow when building this application:

  1. Create domain entities
  2. Create EF Core DbContext and mapping for the entities
  3. Create database migrations
  4. Implement use cases as Web API endpoints

Let's explore an Order and OrderItem entities:

csharp
public class Order { private readonly List<OrderItem> _items = new(); public Guid Id { get; private set; } public string OrderNumber { get; private set; } public Guid CustomerId { get; private set; } public Customer Customer { get; private set; } public DateTime Date { get; private set; } public IReadOnlyList<OrderItem> Items => _items.AsReadOnly(); private Order() { } public static Order Create(string orderNumber, Customer customer, List<OrderItem> items) { return new Order { Id = Guid.NewGuid(), OrderNumber = orderNumber, Customer = customer, CustomerId = customer.Id, Date = DateTime.UtcNow }.AddItems(items); } private Order AddItems(List<OrderItem> items) { _items.AddRange(items); return this; } } public class OrderItem { public Guid Id { get; private set; } public string Product { get; private set; } = null!; public int Quantity { get; private set; } public Guid OrderId { get; private set; } public Order Order { get; private set; } = null!; private OrderItem() { } public OrderItem(string productName, int quantity) { Id = Guid.NewGuid(); Product = productName; Quantity = quantity; } }

My entities represent a Rich Domain Model — a concept from Domain Driven Design.

This concept allows me to implement business rules within my entities, in one place. This allows me to avoid spreading business rules throughout the code base in different classes, making my code more manageable.

For example, in my Shipment entity I have the following methods:

csharp
public ErrorOr<Success> Process() { if (Status is not ShipmentStatus.Created) { return Error.Validation("Can only update to Processing from Created status"); } Status = ShipmentStatus.Processing; UpdatedAt = DateTime.UtcNow; return Result.Success; } public ErrorOr<Success> Dispatch() { if (Status is not ShipmentStatus.Processing) { return Error.Validation("Can only update to Dispatched from Processing status"); } Status = ShipmentStatus.Dispatched; UpdatedAt = DateTime.UtcNow; return Result.Success; }

This encapsulates Shipment state changes within the Shipment entity.

csharp
public enum ShipmentStatus { Created, Processing, Dispatched, InTransit, Delivered, Received, Cancelled }

You can download the source code for the entire application at the end of this blog post.

You can follow Domain Driven Design principle for your domain entities or use anemic entities with plain get and set properties:

csharp
public class Order { public Guid Id { get; set; } public string OrderNumber { get; set; } public Guid CustomerId { get; set; } public Customer Customer { get; set; } public DateTime Date { get; set; } public List<OrderItem> Items { get; set; } = []; }

If you don't have such business logic as I have with shipments, I recommend using plain get and set properties.

Now let's create a Web API endpoint for creating orders. There are several code architecture styles for implementing use cases presented by an API:

  • Layered Architecture (N-Tier)
  • Clean Architecture
  • Vertical Slice Architecture (VSA)

Now a few words why I prefer VSA.

In Layered Architecture, there is a tendency to create too big classes for services and repositories. In Clean Architecture, while having a good design in the codebase, a single feature implementation is spread throughout multiple projects.

In Vertical Slice Architecture, I have all implementation needed for a single feature in a single folder or even a single file. Check out my blog post to learn more about different ways to structure your projects with Vertical Slices.

With Vertical Slices, I tend to start simple: implement all the logic in the webapi endpoint directly without using extra abstractions. If my endpoint becomes too complex or the logic should be reused across multiple endpoints, I extract the logic into the Application Layer by creating MediatR commands and queries.

Implementing CreateOrder Use Case

Let's create request and response models for creating Order with items:

csharp
public sealed record CreateOrderRequest( string CustomerId, List<OrderItemRequest> Items, Address ShippingAddress, string Carrier, string ReceiverEmail); public sealed record OrderItemRequest( string ProductName, int Quantity); public sealed record OrderResponse( Guid OrderId, string OrderNumber, DateTime OrderDate, List<OrderItemResponse> Items); public sealed record OrderItemResponse( string ProductName, int Quantity);

And now let's create an API endpoint using FastEndpoints:

csharp
public class CreateOrderEndpoint( ShippingDbContext dbContext, ILogger<CreateOrderEndpoint> logger) : Endpoint<CreateOrderRequest, Results<Ok<OrderResponse>, ValidationProblem, Conflict<string>, NotFound<string>>> { public override void Configure() { Post("/api/orders"); AllowAnonymous(); Validator<CreateOrderRequestValidator>(); } public override async Task<Results<Ok<OrderResponse>, ValidationProblem, Conflict<string>, NotFound<string>>> ExecuteAsync( CreateOrderRequest request, CancellationToken cancellationToken) { var customer = await dbContext.Set<Customer>() .FirstOrDefaultAsync(c => c.Id == Guid.Parse(request.CustomerId), cancellationToken); if (customer is null) { logger.LogWarning("Customer with ID '{CustomerId}' does not exist", request.CustomerId); return TypedResults.NotFound($"Customer with ID '{request.CustomerId}' does not exist"); } var order = Order.Create( orderNumber: GenerateNumber(), customer, request.Items.Select(x => new OrderItem(x.ProductName, x.Quantity)).ToList() ); var shipment = Shipment.Create( number: GenerateNumber(), orderId: order.Id, address: request.ShippingAddress, carrier: request.Carrier, receiverEmail: request.ReceiverEmail, items: [] ); var shipmentItems = CreateShipmentItems(order.Items, shipment.Id); shipment.AddItems(shipmentItems); await dbContext.Set<Order>().AddAsync(order, cancellationToken); await dbContext.Set<Shipment>().AddAsync(shipment, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); logger.LogInformation("Created order: {@Order} with shipment: {@Shipment}", order, shipment); var response = order.MapToResponse(); return TypedResults.Ok(response); } }

As you can see I am using EF Core DbContext directly inside endpoint ExecuteAsync method. And nothing is wrong with this approach. If I need to create an order from multiple places, like from other API endpoints or event handlers - I will extract this logic into a MediatR command.

In the CreateOrder file, I also have the Validator:

csharp
public class CreateOrderRequestValidator : Validator<CreateOrderRequest> { public CreateOrderRequestValidator() { RuleFor(x => x.CustomerId) .NotEmpty() .Must(id => Guid.TryParse(id, out _)); RuleFor(x => x.Items) .NotEmpty().WithMessage("Order must have at least one item.") .ForEach(item => { item.SetValidator(new OrderItemRequestValidator()); }); RuleFor(x => x.ShippingAddress) .NotNull() .SetValidator(new AddressValidator()); RuleFor(x => x.Carrier) .NotEmpty(); RuleFor(x => x.ReceiverEmail) .NotEmpty() .EmailAddress(); } } public class OrderItemRequestValidator : Validator<OrderItemRequest> { public OrderItemRequestValidator() { RuleFor(x => x.ProductName) .NotEmpty(); RuleFor(x => x.Quantity) .GreaterThan(0); } }

And in the end of the file, I have mapping:

csharp
static file class MappingExtensions { public static OrderResponse MapToResponse(this Order order) { return new OrderResponse( OrderId: order.Id, OrderNumber: order.OrderNumber, OrderDate: order.Date, Items: order.Items.Select(x => new OrderItemResponse( ProductName: x.Product, Quantity: x.Quantity)).ToList() ); } }

Our use case for creating order looks really simple: API endpoint, database logic, validation and mapping all in a single place — in one file.

Here is how all the Vertical Slices look like in my solution.

Screenshot_4

Here is how my solution looks like:

Screenshot_4

You can extract validation and mapping into other files, this is also a good great approach. For example:

Screenshot_4

Why FastEndpoints?

Why am I using FastEndpoints and not Minimal APIs?

FastEndpoints offer the following advantages:

  • FastEndpoints offer a ready code structure for API endpoints with a great design, so you don't need to implement your own with Minimal APIs
  • FastEndpoints implement REPR pattern (Request-Endpoint-Response) where you need to specify a Request and Response types for the endpoint. It brings compiler time safety as you can't return a wrong object or HTTP status code by a mistake.
  • Each endpoint is implemented in a separate Single Responsible class which makes it an ideal choice for Vertical Slices
  • FastEndpoints has built-in Model Binding which is more flexible than built-in model binding in Minimal APIs
  • FastEndpoints has built-in support for FluentValidation

Implementing DeliverShipmentEndpoint Use Case

Let's explore how to implement a DeliverShipmentEndpoint that changes Shipment state into Delivered state:

csharp
public class DeliverShipmentEndpoint(ShippingDbContext dbContext, ILogger<DeliverShipmentEndpoint> logger) : EndpointWithoutRequest<Results<NoContent, NotFound<string>, ProblemHttpResult>> { public override void Configure() { Post("/api/shipments/deliver/{shipmentNumber}"); AllowAnonymous(); } public override async Task<Results<NoContent, NotFound<string>, ProblemHttpResult>> ExecuteAsync(CancellationToken cancellationToken) { var shipmentNumber = Route<string>("shipmentNumber"); var shipment = await dbContext.Shipments.FirstOrDefaultAsync(x => x.Number == shipmentNumber, cancellationToken); if (shipment is null) { logger.LogDebug("Shipment with number {ShipmentNumber} not found", shipmentNumber); return TypedResults.NotFound($"Shipment with number '{shipmentNumber}' not found"); } var response = shipment.Deliver(); if (response.IsError) { return response.Errors.ToProblem(); } await dbContext.SaveChangesAsync(cancellationToken); logger.LogInformation("Delivered shipment with {ShipmentNumber}", shipmentNumber); return TypedResults.NoContent(); } }

Look how easy it is to change the shipment's state by just calling shipment.Deliver() method. That's because we have encapsulated all business logic inside a Shipment entity.

All the rest use cases in this project are implemented in a same manner.

Hope you find this blog post useful. Happy coding!

You can download source code for this blog post for free

Improve Your .NET and Architecture Skills

Join my community of 2000+ developers and architects.

Each week you will get 1 practical tip with best practises and architecture advice.