Skip to content

dotnet controller migration

GitHub Actions edited this page Feb 3, 2026 · 1 revision

Migration Guide: Controllers to Minimal APIs

This guide helps you convert existing Controller-based code to Minimal APIs.

Quick Conversion Reference

Basic Controller → Minimal API

Before:

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly IUserService _service;
    
    public UsersController(IUserService service)
    {
        _service = service;
    }
    
    [HttpGet]
    public async Task<IActionResult> GetAll()
    {
        var users = await _service.GetAllAsync();
        return Ok(users);
    }
}

After:

public static class UsersEndpoints
{
    public static void MapUsersEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/users").WithTags("Users");
        
        group.MapGet("/", async (IUserService service) =>
        {
            var users = await service.GetAllAsync();
            return Results.Ok(users);
        });
    }
}

// In Program.cs
app.MapUsersEndpoints();

Conversion Patterns

1. Route Parameters

Before:

[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
    var user = await _service.GetByIdAsync(id);
    if (user == null) return NotFound();
    return Ok(user);
}

After:

group.MapGet("/{id:int}", async (int id, IUserService service) =>
{
    var user = await service.GetByIdAsync(id);
    return user is null ? Results.NotFound() : Results.Ok(user);
});

2. Request Body

Before:

[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateUserDto dto)
{
    var result = await _service.CreateAsync(dto);
    return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}

After:

group.MapPost("/", async (CreateUserDto dto, IUserService service) =>
{
    var result = await service.CreateAsync(dto);
    return Results.CreatedAtRoute("GetUserById", new { id = result.Id }, result);
})
.WithName("CreateUser");

3. Query Parameters

Before:

[HttpGet("search")]
public async Task<IActionResult> Search([FromQuery] string name, [FromQuery] int? age)
{
    var results = await _service.SearchAsync(name, age);
    return Ok(results);
}

After:

group.MapGet("/search", async (string name, int? age, IUserService service) =>
{
    var results = await service.SearchAsync(name, age);
    return Results.Ok(results);
});

4. Authorization

Before:

[Authorize]
[HttpGet("protected")]
public async Task<IActionResult> GetProtected()
{
    return Ok("Protected data");
}

[Authorize(Roles = "Admin")]
[HttpPost("admin-only")]
public async Task<IActionResult> AdminOnly()
{
    return Ok("Admin data");
}

After:

group.MapGet("/protected", async () => Results.Ok("Protected data"))
    .RequireAuthorization();

group.MapPost("/admin-only", async () => Results.Ok("Admin data"))
    .RequireAuthorization("AdminPolicy");

5. Model Validation

Before:

[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateUserDto dto)
{
    if (!ModelState.IsValid)
        return BadRequest(ModelState);
        
    var result = await _service.CreateAsync(dto);
    return Ok(result);
}

After:

// Add validation filter
group.MapPost("/", async (CreateUserDto dto, IUserService service) =>
{
    var result = await service.CreateAsync(dto);
    return Results.Ok(result);
})
.AddEndpointFilter<ValidationFilter<CreateUserDto>>();

// ValidationFilter implementation
public class ValidationFilter<T> : IEndpointFilter where T : class
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context, 
        EndpointFilterDelegate next)
    {
        var argument = context.GetArgument<T>(0);
        var validationResults = new List<ValidationResult>();
        var isValid = Validator.TryValidateObject(
            argument, 
            new ValidationContext(argument), 
            validationResults, 
            true);

        if (!isValid)
        {
            return Results.ValidationProblem(
                validationResults.ToDictionary(
                    vr => vr.MemberNames.First(), 
                    vr => new[] { vr.ErrorMessage! }));
        }

        return await next(context);
    }
}

6. Multiple Dependencies

Before:

public class UsersController : ControllerBase
{
    private readonly IUserService _userService;
    private readonly ILogger<UsersController> _logger;
    private readonly IMapper _mapper;
    
    public UsersController(
        IUserService userService,
        ILogger<UsersController> logger,
        IMapper mapper)
    {
        _userService = userService;
        _logger = logger;
        _mapper = mapper;
    }
    
    [HttpGet]
    public async Task<IActionResult> GetAll()
    {
        _logger.LogInformation("Getting all users");
        var users = await _userService.GetAllAsync();
        return Ok(_mapper.Map<List<UserDto>>(users));
    }
}

After:

group.MapGet("/", async (
    IUserService userService,
    ILogger<Program> logger,
    IMapper mapper) =>
{
    logger.LogInformation("Getting all users");
    var users = await userService.GetAllAsync();
    return Results.Ok(mapper.Map<List<UserDto>>(users));
});

7. MediatR Pattern

Before:

[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
    private readonly IMediator _mediator;
 
    public AuthController(IMediator mediator)
    {
        _mediator = mediator;
    }
 
    [HttpPost("register")]
    public async Task<IActionResult> Register([FromBody] RegisterCommand command)
    {
        var result = await _mediator.Send(command);
        return Ok(result);
    }
 
    [HttpPost("login")]
    public async Task<IActionResult> Login([FromBody] LoginCommand command)
    {
        var result = await _mediator.Send(command);
        return Ok(result);
    }
}

After:

public static class AuthEndpoints
{
    public static void MapAuthEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/auth")
            .WithTags("Auth")
            .WithOpenApi();

        group.MapPost("/register", async (RegisterCommand command, IMediator mediator) =>
        {
            var result = await mediator.Send(command);
            return Results.Ok(result);
        })
        .WithName("Register")
        .Produces<AuthResponseDto>(StatusCodes.Status200OK);

        group.MapPost("/login", async (LoginCommand command, IMediator mediator) =>
        {
            var result = await mediator.Send(command);
            return Results.Ok(result);
        })
        .WithName("Login")
        .Produces<AuthResponseDto>(StatusCodes.Status200OK);
    }
}

// In Program.cs
app.MapAuthEndpoints();

Return Type Conversions

Controller Return Minimal API Return
Ok(data) Results.Ok(data)
NotFound() Results.NotFound()
BadRequest() Results.BadRequest()
Created(uri, data) Results.Created(uri, data)
CreatedAtAction() Results.CreatedAtRoute()
NoContent() Results.NoContent()
Unauthorized() Results.Unauthorized()
Forbid() Results.Forbid()
ValidationProblem() Results.ValidationProblem()
Problem() Results.Problem()

File Reorganization

Before (Controllers)

src/Api/
├── Controllers/
│   ├── UsersController.cs
│   ├── OrdersController.cs
│   └── AuthController.cs
└── Program.cs

After (Minimal APIs)

src/Api/
├── Endpoints/
│   ├── UsersEndpoints.cs
│   ├── OrdersEndpoints.cs
│   └── AuthEndpoints.cs
└── Program.cs

Program.cs Changes

Remove:

builder.Services.AddControllers();
// ...
app.MapControllers();

Add:

// Register endpoints
app.MapUsersEndpoints();
app.MapOrdersEndpoints();
app.MapAuthEndpoints();

Complete Migration Checklist

  • Remove all using Microsoft.AspNetCore.Mvc statements from endpoint files
  • Change [ApiController] classes to static classes
  • Remove all [Route], [HttpGet], [HttpPost], etc. attributes
  • Convert method signatures to lambda expressions
  • Change IActionResult to Results.* methods
  • Update dependency injection from constructor to method parameters
  • Rename *Controller.cs to *Endpoints.cs
  • Move files from Controllers/ to Endpoints/ folder
  • Add Map{Entity}Endpoints extension method
  • Update Program.cs to call endpoint mapping methods
  • Remove builder.Services.AddControllers()
  • Remove app.MapControllers()
  • Update all tests to use new endpoint structure
  • Update OpenAPI/Swagger configuration if needed

Testing Changes

Before:

var controller = new UsersController(mockService.Object);
var result = await controller.GetAll();
var okResult = Assert.IsType<OkObjectResult>(result);

After:

// Use WebApplicationFactory for integration tests
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/users");
response.EnsureSuccessStatusCode();
var users = await response.Content.ReadFromJsonAsync<List<UserDto>>();

Benefits After Migration

Less Code: No controller classes, no attributes
Better Performance: Reduced memory allocation
Easier Testing: Pure functions with direct DI
Modern Pattern: Aligned with .NET 6+ best practices
Cleaner Code: Functional programming style

Need Help?

Run @developer "Convert {ControllerName} to Minimal API" and the agent will do the conversion for you!

Clone this wiki locally