FastEndpoints, Controllers, and Minimal APIs Compared: A Complete .NET Developer Guide

October 07, 2025#Software Development
Article
Author image.

Kevin Lloyd, Senior Consultant

The year was 2008. I was expanding my web development knowledge beyond ASP Web Forms and PHP. Those had been my staples for a few years. Enter Ruby on Rails 2.0. This was my first introduction to MVC frameworks. I was amazed by the level of structure it brought to a codebase. Prior to that, structuring an application was left up to the developer or team lead. If you’re lucky, someone would pull out a DAL (Data Access Layer) to separate your SQL Server access, but nothing prevented you from doing the wrong thing.

I didn’t know how much I craved the structure that MVC brought. In 2009, Microsoft introduced ASP.NET MVC 1.0. It was a joy to restructure our legacy Web Forms application. No more 1,500-line monster aspx pages. Best of all, no more View State! The Controller was a welcome breath of fresh air. And for a number of years, we all embraced it.

Steve Smith was one of the first to get us thinking about alternatives to controllers. Although we loved the controller, we were forced to think about the times it didn’t quite feel right. That giant UserController with 500+ lines of code, 12 different (but kind of related, maybe) action methods and 6 dependencies. Steve goes on to tell us why controllers are dinosaurs and introduces the REPR (Request-Endpoint-Response) pattern. All of which, was food for thought.

Then in 2021 Microsoft introduced Minimal APIs as recommended for new projects and we all had to rethink our approach to web development. For me, Minimal APIs sounded very intriguing. It was super flexible; there were no classes required; you could dump everything in Program.cs; you could use extension methods; everything was static; there were no rules. However, it lacked the structure I grew to love with MVC.

Enter FastEndpoints ⚡

FastEndpoints gives the speed and plumbing of Minimal APIs with the structure of the REPR pattern. There is an argument to be made that this framework is too opinionated, but I appreciate structure when it comes to the solved problems. If my team and I don’t have to think about how to set up endpoints in an application it frees us up to reason about the hard things.

We’ll walk through a fictional story of three codebases to compare various approaches of building a Web API project.

The Evolution Problem: A Tale of Three Approaches 📈

Controllers: The Story of “Just One More Dependency” 🏗️

Let’s start with how we typically build APIs. We begin our user management API with the best intentions.

Week 1 - The Beautiful Beginning:

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    // ... constructor injection omitted for brevity
    [HttpPost]
    public async Task<IActionResult> CreateUser(CreateUserRequest request)
    {
        var user = await _userService.CreateAsync(request);
        return Ok(user);
    }
}

Clean, simple, follows all the SOLID principles. This is how we should build APIs.

Month 3 - The Innocent Additions:

Then the business requirements start rolling in. “Just a few things” they say:

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    // ... constructor injection omitted for brevity
    [HttpPost]
    public async Task<IActionResult> CreateUser(CreateUserRequest request)
    {
        var validation = await _createValidator.ValidateAsync(request);
        if (!validation.IsValid)
            return BadRequest(validation.Errors);

        var user = await _userService.CreateAsync(request);
        await _emailService.SendWelcomeEmailAsync(user.Email);

        return Ok(_mapper.Map<UserResponse>(user));
    }

    [HttpPut("{id}")]
    public async Task<IActionResult> UpdateUser(int id, UpdateUserRequest request)
    {
        var validation = await _updateValidator.ValidateAsync(request);
        if (!validation.IsValid) return BadRequest(validation.Errors);

        var user = await _userService.UpdateAsync(id, request);
        return Ok(_mapper.Map<UserResponse>(user));
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> GetUser(int id) { /* ... */ }

    [HttpDelete("{id}")]
    public async Task<IActionResult> DeleteUser(int id) { /* ... */ }

    [HttpPost("{id}/avatar")]
    public async Task<IActionResult> UploadAvatar(int id, IFormFile file) { /* ... */ }

    [HttpPost("{id}/invite")]
    public async Task<IActionResult> InviteUser(int id, InviteRequest request) { /* ... */ }

    [HttpGet("{id}/permissions")]
    public async Task<IActionResult> GetPermissions(int id) { /* ... */ }

    [HttpPost("{id}/reset-password")]
    public async Task<IActionResult> ResetPassword(int id) { /* ... */ }
}

We see a clear violation of the Open-Closed Principle here. A telltale sign is seen because we’re adding functionality by editing classes instead of creating new ones. More than half the methods don’t use some of the dependencies.

Year 1 - Feeling The Pain:

  • 25 action methods crammed into one class
  • 12 dependencies in the constructor (and yes, I’ve seen worse)
  • Unit testing that required mocking half the application
  • Code reviews where finding the actual change was like playing Where’s Waldo

Every time we add a new feature, we have to touch this monster. We’re scared we’ll break something unrelated, so we avoid making changes when possible.

This is the classic controller evolution story we’ve all lived through.

Minimal APIs: The Story of “It’s Just One More Line” 🎯

Let’s try a different approach with Minimal APIs.

Week 1 - The Minimalist Dream:

var builder = WebApplication.CreateBuilder();
builder.Services.AddUserService();

var app = builder.Build();

app.MapPost("/api/users", async (CreateUserRequest request, IUserService service) =>
{
    var user = await service.CreateAsync(request);
    return Results.Ok(user);
});

app.Run();

No bloated classes, no ceremony, just pure functionality. Everything is lightweight and modern.

Month 3 - The Creeping Complexity:

But then, inevitably, the business requirements start again.

var builder = WebApplication.CreateBuilder();
builder.Services.AddUserService();
builder.Services.AddProductService();
builder.Services.AddOrderService();
// ... more services

var app = builder.Build();

// Users endpoints
app.MapPost("/api/users", async (
    CreateUserRequest request,
    IUserService service,
    IValidator<CreateUserRequest> validator,
    IEmailService emailService,
    IMapper mapper) =>
{
    var validation = await validator.ValidateAsync(request);
    if (!validation.IsValid)
        return Results.BadRequest(validation.Errors);

    var user = await service.CreateAsync(request);
    await emailService.SendWelcomeEmailAsync(user.Email);

    return Results.Ok(mapper.Map<UserResponse>(user));
});

app.MapPut("/api/users/{id}", async (
    int id,
    UpdateUserRequest request,
    IUserService service,
    IValidator<UpdateUserRequest> validator,
    IMapper mapper) =>
{
    var validation = await validator.ValidateAsync(request);
    if (!validation.IsValid) return Results.BadRequest(validation.Errors);

    var user = await service.UpdateAsync(id, request);
    return Results.Ok(mapper.Map<UserResponse>(user));
});

app.MapGet("/api/users/{id}", async (int id, IUserService service) => { /* ... */ });
app.MapDelete("/api/users/{id}", async (int id, IUserService service, IAuthorizationService auth) => { /* ... */ });

/* Products endpoints
...
*/

/* Order endpoints
...
*/

/* etc.
...
*/

app.Run();

This quickly becomes a nightmare. Endpoints are hard to find; instead of scrolling through one giant controller, we’re scrolling through all the endpoints.

There are better ways to structure this, but you have to dig to find them. And please, don’t use regions.

Year 1 - The Breaking Point:

  • 300+ lines of endpoint registrations
  • 50+ endpoints mixed together with no logical grouping
  • Repeated validation patterns copy-pasted everywhere
  • Impossible to find any specific endpoint

FastEndpoints: The Story of “Structure That Scales” ⚖️

Now let’s try a different approach:

// Program.cs - Clean and stays clean
var builder = WebApplication.CreateBuilder();
builder.Services.AddFastEndpoints();

var app = builder.Build();
app.UseFastEndpoints();
app.Run();
// Features/Users/CreateUser/CreateUserEndpoint.cs
public class CreateUserEndpoint : Endpoint<CreateUserRequest, UserResponse>
{
    // ... constructor injection omitted for brevity
    public override void Configure()
    {
        Post("/api/users");
        AllowAnonymous();
    }

    public override async Task HandleAsync(CreateUserRequest req, CancellationToken ct)
    {
        var user = await _userService.CreateAsync(req);
        await _emailService.SendWelcomeEmailAsync(user.Email);

        await Send.OkAsync(new UserResponse
        {
            Id = user.Id,
            Name = user.Name,
            Email = user.Email
        });
    }
}

Week 1 - Initial Thoughts:

Arguably, we’re writing more code than a single action method in a controller. However, the beauty is that our changes are isolated to individual classes. Code reviews become easier when the file name gives you enough context of what’s changed.

Year 1 - Still Pleasantly Organized:

Fast forward a year and our file structure actually makes sense.

Features/
├── Users/
│   ├── CreateUser/
│   │   ├── CreateUserEndpoint.cs
│   │   ├── CreateUserRequest.cs
│   │   ├── CreateUserResponse.cs
│   │   └── CreateUserValidator.cs
│   ├── UpdateUser/
│   │   ├── UpdateUserEndpoint.cs
│   │   ├── UpdateUserRequest.cs
│   │   ├── UpdateUserResponse.cs
│   │   └── UpdateUserValidator.cs
│   └── GetUser/
│       ├── GetUserEndpoint.cs
│       ├── GetUserRequest.cs
│       └── GetUserResponse.cs
├── Products/
│   └── ... similar structure
└── Orders/
    └── ... similar structure

Everything is colocated, related features are easy to identify. We’re no longer scared to make changes.

The Complete Comparison: Controllers vs Minimal APIs vs FastEndpoints 📊

After seeing all three approaches in action, here’s how they stack up across the dimensions that matter most:

Aspect Controllers Minimal APIs FastEndpoints
Learning Curve Easy - familiar to most .NET developers Moderate - new paradigm, flexible patterns Moderate - opinionated structure, clear conventions
Project Structure Controller-based grouping, tends to bloat All endpoints in Program.cs or extension methods Feature-based folders with colocated files
Code Organization ❌ Poor - methods grouped by entity, not functionality ⚠️ Flexible - can be great or terrible depending on discipline ✅ Excellent - each endpoint is isolated with related files
Best For Traditional web apps, existing large codebases Small APIs, prototypes, microservices Growing APIs, teams that value structure

The Verdict 🏆

  • Controllers: Great for getting started, terrible for scaling
  • Minimal APIs: Powerful and flexible, but requires discipline to maintain
  • FastEndpoints: Opinionated structure that scales well, with some flexibility trade-offs

The Fine Print: What FastEndpoints Takes Away ⚠️

Before we get too excited about FastEndpoints, let’s talk about the important trade-offs we need to be honest about.

The Great Data Annotations Goodbye 👋

Here’s the big trade-off: FastEndpoints doesn’t support Data Annotations validation. At all. You must use FluentValidation instead. Technically, this is an issue with the underlying Minimal APIs, but it’s something we should take note of.

This might seem like a very opinionated decision, but I think there’s an upside. FluentValidation is more powerful and keeps validation logic separate from our models. The downside? We have to learn a new way if we’re used to Data Annotations. Some teams love this trade-off, others hate it.

Getting Started with FastEndpoints 🚀

Enough theory - let’s build something real. Let’s walk through creating a complete User management API that demonstrates the key concepts.

I will say, the documentation for this framework is some of the best I’ve come across. For a deeper dive, I definitely suggest walking through it.

Installation and Basic Setup 📦

Start with a new Web API project:

dotnet new webapi -n FastEndpointsDemo
cd FastEndpointsDemo
dotnet add package FastEndpoints

Replace your Program.cs with this minimal setup:

using FastEndpoints;

var builder = WebApplication.CreateBuilder();

// Add FastEndpoints
builder.Services.AddFastEndpoints();

// Add your services to DI
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IEmailService, EmailService>();

var app = builder.Build();
app.UseFastEndpoints();
app.Run();

That’s it for the basic setup. FastEndpoints will automatically discover all your endpoints.

Building Your First Endpoint 🔨

Let’s create a user registration endpoint with validation:

Models/User.cs:

public class User
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
}

Features/Users/Register/RegisterUserRequest.cs:

public class RegisterUserRequest
{
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;
}

Features/Users/Register/RegisterUserResponse.cs:

public class RegisterUserResponse
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
}

Features/Users/Register/RegisterUserValidator.cs:

using FastEndpoints;
using FluentValidation;

public class RegisterUserValidator : Validator<RegisterUserRequest>
{
    public RegisterUserValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty()
            .MinimumLength(2)
            .MaximumLength(50);

        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress();

        RuleFor(x => x.Password)
            .NotEmpty()
            .MinimumLength(8)
            .Matches(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)")
            .WithMessage("Password must contain at least one uppercase letter, one lowercase letter, and one digit");
    }
}

Features/Users/Register/RegisterUserEndpoint.cs:

using FastEndpoints;

public class RegisterUserEndpoint : Endpoint<RegisterUserRequest, RegisterUserResponse>
{
    private readonly IUserService _userService;
    private readonly IEmailService _emailService;

    public RegisterUserEndpoint(IUserService userService, IEmailService emailService)
    {
        _userService = userService;
        _emailService = emailService;
    }

    public override void Configure()
    {
        Post("/api/users/register");
        AllowAnonymous();
    }

    public override async Task HandleAsync(RegisterUserRequest req, CancellationToken ct)
    {
        // Check if email already exists
        var existingUser = await _userService.GetByEmailAsync(req.Email);
        if (existingUser != null)
        {
            ThrowError("Email is already registered"); // Returns 400 Bad Request with error message
        }

        // Create the user
        var user = await _userService.CreateAsync(req.Name, req.Email, req.Password);

        // Send welcome email
        await _emailService.SendWelcomeEmailAsync(user.Email);

        // Send response
        await Send.OkAsync(new RegisterUserResponse
        {
            Id = user.Id,
            Name = user.Name,
            Email = user.Email,
            CreatedAt = user.CreatedAt
        });
    }
}

Adding More Endpoints ➕

Let’s add a few more endpoints to show the pattern:

Features/Users/GetUser/GetUserRequest.cs:

public class GetUserRequest
{
    public int Id { get; set; }
}

Features/Users/GetUser/GetUserResponse.cs:

public class GetUserResponse
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
}

Features/Users/GetUser/GetUserEndpoint.cs:

public class GetUserEndpoint : Endpoint<GetUserRequest, GetUserResponse>
{
    private readonly IUserService _userService;

    public GetUserEndpoint(IUserService userService)
    {
        _userService = userService;
    }

    public override void Configure()
    {
        Get("/api/users/{id}");
        AllowAnonymous();
    }

    public override async Task HandleAsync(GetUserRequest req, CancellationToken ct)
    {
        var user = await _userService.GetByIdAsync(req.Id);

        if (user == null)
        {
            await Send.NotFoundAsync();
            return;
        }

        await Send.OkAsync(new GetUserResponse
        {
            Id = user.Id,
            Name = user.Name,
            Email = user.Email,
            CreatedAt = user.CreatedAt
        });
    }
}

When FastEndpoints Makes Sense (And When It Doesn’t) 🤔

FastEndpoints is Great When: ✅

  • Greenfield projects
  • You’re building APIs that will grow beyond 10-15 endpoints
  • Your team values consistent patterns over flexibility
  • You’re comfortable with FluentValidation (or willing to migrate)
  • You want better organization than Minimal APIs provide
  • Performance matters (it’s on par with Minimal APIs)

Consider Alternatives When: ❌

  • Your team is heavily invested in Data Annotations validation
  • You need complex action filter chains, it has its own methodology for this
  • You prefer the simplicity of Minimal APIs for small projects
  • You have extensive existing controller-based architecture
  • Small microservices with 3-5 endpoints, probably prefer minimal apis

Migration Considerations 🔄

If you’re considering migrating from controllers to FastEndpoints:

  1. Start with new features - Don’t rewrite everything at once
  2. Plan validation migration - Data Annotations → FluentValidation takes time
  3. Convert action filters - Map them to PreProcessors/PostProcessors
  4. Test thoroughly - The middleware pipeline differences can be subtle

Conclusion 🎯

FastEndpoints offers a compelling middle ground between controller bloat and minimal API chaos. By embracing the REPR pattern with strong opinions about validation and organization, it provides a sustainable path for API growth.

The trade-offs are real: you give up Data Annotations support and some middleware flexibility in exchange for consistent patterns and better organization. Whether these trade-offs make sense depends on your team’s priorities and existing investment in other approaches.

In our next articles, we’ll explore comprehensive testing strategies for FastEndpoints, covering both unit testing approaches for testing endpoint logic in isolation, and integration testing patterns that build on proven WebApplicationFactory approaches.

Resources 📚


Copyright © 2025 NimblePros - All Rights Reserved