DEV Community

Eelco Los
Eelco Los

Posted on

Migrating Azure Function Calls to Minimal API with FastEndpoints

Azure Functions are a popular choice for building serverless, event-driven applications. However, as applications grow in complexity, developers often encounter challenges that require greater control and efficiency. Furthermore, the frequent updates to Azure Functions APIs, including the upcoming deprecation of the in-process worker model in November 2026, make continuous upgrades cumbersome. For business-critical applications, ensuring zero downtime during these transitions can be especially challenging.

In this post, we'll explore how to migrate business-critical Azure Function calls to Minimal API, leveraging FastEndpoints to simplify the transition. While FastEndpoints is the focus, the design concepts discussed here can be applied even without relying on specific packages.


Why Migrate to Minimal API?

Azure Functions are excellent for quick, lightweight serverless operations, but they can present limitations for complex, business-critical applications. Here are some reasons to consider transitioning to Minimal API:

  • Performance Boost: Minimal APIs are optimized for low latency and high throughput.
  • Improved Structure: FastEndpoints offers clear, organized handling of API routes and processes, helping maintain clean separation of concerns.
  • Enhanced Control: Minimal APIs provide developers with fine-grained control over dependency injection, middleware, and other components.

Comparing Azure Functions to Minimal API with FastEndpoints

To illustrate the differences, let’s compare a simple Azure Function to an equivalent implementation using Minimal API and FastEndpoints.

Azure Function Example

[FunctionName("MyFunction")]
public async Task<IActionResult> Run(
    [HttpTrigger(AuthorizationLevel.Function, 
        "post", Route = "demo/{RouteId}/{SubId}")] 
        HttpRequest req,
    string RouteId,
    string SubId,
    ILogger log)
{
    log.LogInformation("Processing a request...");

    // Validate RouteId
    if (!Guid.TryParse(RouteId, out Guid routeId))
    {
        return new BadRequestObjectResult(new ProblemDetails
        {
            Title = "Invalid RouteId",
            Status = StatusCodes.Status400BadRequest,
            Detail = "RouteId must be a valid GUID"
        });
    }

    // Validate SubId
    if (!Guid.TryParse(SubId, out Guid subId))
    {
        return new BadRequestObjectResult(new ProblemDetails
        {
            Title = "Invalid SubId",
            Status = StatusCodes.Status400BadRequest,
            Detail = "SubId must be a valid GUID"
        });
    }

    // Extract CompanyId from JWT token
    var tokenHandler = new JwtSecurityTokenHandler();
    var token = req.Headers["Authorization"].ToString().Split(" ")[1];
    var companyId = Guid.Parse(
        tokenHandler.ReadJwtToken(token)
            .Claims.First(claim => claim.Type == "YourCompanyIdClaim").Value
    );

    return new OkObjectResult("Hello from Azure Function");
}


Enter fullscreen mode Exit fullscreen mode

This implementation directly interacts with HttpRequest, leaving validation and structure to the developer. While flexible, it lacks the strong typing and modularity of more modern approaches.

Minimal API Example with FastEndpoints

public class MyEndpoint : Endpoint<MyRequest>
{
    public override void Configure()
    {
        // Define the route and HTTP method
        Post("demo/{RouteId}/{SubId}");
    }

    public override async Task HandleAsync(MyRequest req, CancellationToken ct)
    {
        // Return a response directly
        await SendOkAsync("Hello from Minimal API", ct);
    }
}

// Model defining expected input
public record MyRequest
{
    Guid RouteId { get; init; }
    Guid SubId { get; init; }

    // Automatically bind claim from JWT token
    [FromClaim("YourCompanyIdClaim")]
    Guid CompanyId { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

Here, the main action happens in the HandleAsync method, while the MyRequest object handles model binding. FastEndpoints simplifies validation and dependency injection, reducing boilerplate code and improving maintainability.


Breaking Down the Key Differences

Comparing the two approaches side by side reveals key differences. Let’s explore how these differences impact structure, developer experience, and flexibility:

Separation of Concern

Good structure hinges on separation of concern (SoC). Code should tell a cohesive story while keeping distinct components independent. Azure Functions and FastEndpoints approach this differently:

Azure Functions:

  • Offers flexibility but often ties too much logic into a single method.
  • Requires manual handling of aspects like model validation and parsing.

FastEndpoints:

  • Divides API workflows into distinct stages:
    • Model Binding
    • Validation
    • Preprocessing
    • Main Handling
    • Postprocessing
  • Encourages a clean and predictable structure while maintaining flexibility.

Model Binding

FastEndpoints simplifies model binding by automatically converting inputs (e.g., route parameters, claims, or query strings) into strongly-typed objects. For example:

// Example of strongly-typed model binding with FastEndpoints
public record MyRequest
{
    Guid RouteId { get; init; }
    Guid SubId { get; init; }
    [FromClaim("YourCompanyIdClaim")]
    Guid CompanyId { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

This ensures a clean and developer-friendly experience compared to manually validating and parsing data in Azure Functions.

Validation

Validation occurs before the main handler in FastEndpoints, ensuring only valid models reach the core logic. For instance:

public class MyRequestValidator : Validator<MyRequest>
{
    public MyRequestValidator()
    {
        RuleFor(x => x.RouteId)
            .NotEmpty().WithMessage("RouteId is required")
            .NotEqual(Guid.Empty).WithMessage("RouteId must be a valid GUID");

        RuleFor(x => x.SubId)
            .NotEmpty().WithMessage("SubId is required")
            .NotEqual(Guid.Empty).WithMessage("SubId must be a valid GUID");
    }
}
Enter fullscreen mode Exit fullscreen mode

This approach improves readability and eliminates repetitive validation code inside the main handler.

Flexibility

FastEndpoints offers flexibility for scenarios where the default workflow doesn’t fit. For example, preprocessing can populate additional fields in the model before handling:

var connection = _connectionService.GetAsync(companyId,  subId);
if (connection is null)
{
  return new BadRequestObjectResult(new ProblemDetails
            {
                Title = "No Connection for your company",
                Status = StatusCodes.Status400BadRequest,
                Detail = "No External Connection Found that matches your company"
            });
}
Enter fullscreen mode Exit fullscreen mode

In FastEndpoints, you could use a preprocessor for this, where you can copy the exact same code, with some small tweaks:

public async Task PreProcessAsync(IPreProcessorContext<MyRequest> ctx, CancellationToken c)
 {
        if (ctx.Request is null)
        {
            await ctx.HttpContext.Response.SendAsync(new FastEndpoints.ProblemDetails(ctx.ValidationFailures), StatusCodes.Status400BadRequest, cancellation: c);
            return;
        }
        var connectionService = ctx.HttpContext.RequestServices.GetRequiredService<ExternalConnectionService>();

        var connection = await connectionService.GetAsync(ctx.Request.CompanyId, Guid.Parse(ctx.Request.SubId));
        if (connection is null)
        {
            await ctx.HttpContext.Response.SendAsync(new FastEndpoints.ProblemDetails([new() { ErrorMessage = "No External Connection Found that matches your company", PropertyName = "subId" }]),
                                                     StatusCodes.Status400BadRequest,
                                                     cancellation: c);
            return;
        }
        ctx.Request.ExternalConnection = connection;
}
Enter fullscreen mode Exit fullscreen mode

This flexibility is key for handling custom scenarios without compromising structure or clarity.

Files

Handling file uploads in Azure Functions often lacks clear guidance. In contrast, FastEndpoints follows ASP.NET Core standards for file uploads, such as using multipart/form-data. For more complex use cases, RequestBinder can transform the request into a compatible format:

On the official guide of Microsoft, stated here, it is stated that files should be a multipart/form-data, within it containing the file. This is not what Azure Functions describe. They actually don't describe anything at all.
The design of files in asp.net is rather odd in that regard, where any other language than c# is requiring some modelling to push the file in the FormFile factor, except for a specific http form setup.
For FastEndpoints, this options is used too, as it does conform to the standard layed out by Asp.Net.
While I was migrating an azure function, it used its body as the file itself.
Again, FastEndpoints allows you to then short-circuit the modelbinding, to have a FormFile process, using the RequestBinder option to transform the HttpRequest body into a FormFile.

public class XmlFileRequestFromBodyBinder : IRequestBinder<MyRequest>
{
    public override async ValueTask<MyRequest> BindAsync(BinderContext ctx, CancellationToken ct)
    {
        var req = await base.BindAsync(ctx, ct);

        using var streamReader = new StreamReader(ctx.HttpContext.Request.Body);
        var stream = new MemoryStream();
        await streamReader.BaseStream.CopyToAsync(stream, ct);
        stream.Position = 0;
        string filename = ctx.HttpContext.Request.Query["fileName"]!;

        req.File = new FormFile(stream, 0, stream.Length, "file", filename)
        {
            Headers = new HeaderDictionary(),
            ContentType = MediaTypeNames.Application.Xml,
        };

        return req;
    }
}
Enter fullscreen mode Exit fullscreen mode

This ensures compliance with best practices while providing tools for customization when needed.


Migration Guide

To transition from Azure Functions to FastEndpoints or a similar structured approach:

  1. Model Binding: Define input models that encapsulate route parameters, claims, and other data.
  2. Validation: Use validation libraries (e.g., FluentValidation) to ensure all inputs meet business rules before proceeding.
  3. Main Handler: Implement the core logic in a dedicated handler function or method.
  4. Flexibility: Use preprocessors, custom bindings, or middleware to handle non-standard requirements.

Also, consider essential business-critical requirements such as:

  • Monitoring: Integrate with logging services like Application Insights.
  • Error Handling: Implement centralized error-handling mechanisms.
  • Performance Tuning: Run load tests to validate performance improvements.

Conclusion

Migrating from Azure Functions to Minimal API can bring significant benefits, especially for business-critical applications requiring better structure and performance. By leveraging FastEndpoints, you can ensure a smooth transition while maintaining clarity and scalability in your code.
Have you migrated an application from Azure Functions to Minimal API? Share your experience in the comments or try out FastEndpoints to see how it simplifies API development.

Let me know if you'd like more detailed changes or have additional questions!

Top comments (0)