Skip to content

Recipe Aws Lambda Api Gateway

Mark Lauter edited this page Jun 13, 2026 · 4 revisions

Recipe: AWS Lambda behind API Gateway

An AWS Lambda function fronted by API Gateway (REST or HTTP API) that receives an APIGatewayProxyRequest, runs it through a Plumber pipeline, and returns an APIGatewayProxyResponse. The pipeline shape is (APIGatewayProxyRequest, APIGatewayProxyResponse).

The interesting wrinkle is cold start. AWS Lambda freezes the execution environment between invocations, so anything built during the first request is reused for every warm invocation on the same container. A Plumber handler is built once and invoked many times — that maps directly onto Lambda's lifecycle. Build the handler in a static readonly initializer and the cold start cost shows up exactly once per container.

Project setup

Start from the AWS Lambda template for an empty function:

dotnet new install Amazon.Lambda.Templates
dotnet new lambda.EmptyFunction --name MyApi.Lambda
cd MyApi.Lambda/src/MyApi.Lambda

Add the API Gateway event types and Plumber:

dotnet add package Amazon.Lambda.APIGatewayEvents
dotnet add package MSL.Plumber.Pipeline

The template's other packages stay as-is:

  • Amazon.Lambda.Core
  • Amazon.Lambda.Serialization.SystemTextJson

A representative MyApi.Lambda.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <AWSProjectType>Lambda</AWSProjectType>
    <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Amazon.Lambda.Core" Version="2.7.0" />
    <PackageReference Include="Amazon.Lambda.Serialization.SystemTextJson" Version="2.4.5" />
    <PackageReference Include="Amazon.Lambda.APIGatewayEvents" Version="2.7.2" />
    <PackageReference Include="MSL.Plumber.Pipeline" Version="3.*" />
  </ItemGroup>
</Project>

The aws-lambda-tools-defaults.json the template generated handles deployment; nothing in it is Plumber-specific.

The pipeline

A static Pipeline class with a CreateBuilder / Configure split. The split mirrors the README convention and pays off when you write tests — CreateBuilder is the production wiring, Configure is the middleware list, and tests can replace the builder while keeping the chain intact.

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Plumber;
using Amazon.Lambda.APIGatewayEvents;

namespace MyApi.Lambda;

internal static class Pipeline
{
    public static RequestHandlerBuilder<APIGatewayProxyRequest, APIGatewayProxyResponse> CreateBuilder() =>
        RequestHandlerBuilder
            .Create<APIGatewayProxyRequest, APIGatewayProxyResponse>()
            .AddDefaultConfigurationSources()
            .ConfigureLogging(logging => logging
                .SetMinimumLevel(LogLevel.Information)
                .AddJsonConsole())
            .ConfigureServices((services, configuration) => services
                .AddSingleton<IJwtVerifier, HmacJwtVerifier>()
                .AddSingleton<IGreetingService, GreetingService>());

    public static RequestHandler<APIGatewayProxyRequest, APIGatewayProxyResponse> Configure(
        RequestHandler<APIGatewayProxyRequest, APIGatewayProxyResponse> handler) =>
        handler
            .Use<ErrorBoundaryMiddleware>()
            .Use<RouteExtractionMiddleware>()
            .Use<AuthenticationMiddleware>()
            .Use<GreetingMiddleware>();

    public static RequestHandler<APIGatewayProxyRequest, APIGatewayProxyResponse> Build() =>
        Configure(CreateBuilder().Build());
}

AddJsonConsole plays well with CloudWatch Logs — every log line lands as a structured JSON object you can query in Logs Insights.

Middleware

Four middleware:

  • ErrorBoundaryMiddleware — translates uncaught exceptions to 500 responses
  • RouteExtractionMiddleware — pulls path and method into context.Data
  • AuthenticationMiddleware — verifies a bearer JWT, short-circuits with 401
  • GreetingMiddleware — the terminal business logic

ErrorBoundaryMiddleware

Translates exceptions into 500 responses so the function host always sees a well-formed APIGatewayProxyResponse. Register it first.

using Microsoft.Extensions.Logging;
using Plumber;
using Amazon.Lambda.APIGatewayEvents;

namespace MyApi.Lambda;

internal sealed class ErrorBoundaryMiddleware(
    RequestMiddleware<APIGatewayProxyRequest, APIGatewayProxyResponse> next,
    ILogger<ErrorBoundaryMiddleware> logger)
{
    public async Task InvokeAsync(RequestContext<APIGatewayProxyRequest, APIGatewayProxyResponse> context)
    {
        try
        {
            await next(context);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "request {Id} failed", context.Id);
            context.Response = new APIGatewayProxyResponse
            {
                StatusCode = 500,
                Body = "{\"error\":\"internal server error\"}",
                Headers = new Dictionary<string, string> { ["Content-Type"] = "application/json" },
            };
        }
    }
}

RouteExtractionMiddleware

Pulls the path and method off the proxy request into context.Data. Downstream middleware then reads keyed values instead of re-parsing the request.

using Plumber;
using Amazon.Lambda.APIGatewayEvents;

namespace MyApi.Lambda;

internal sealed class RouteExtractionMiddleware(
    RequestMiddleware<APIGatewayProxyRequest, APIGatewayProxyResponse> next)
{
    public Task InvokeAsync(RequestContext<APIGatewayProxyRequest, APIGatewayProxyResponse> context)
    {
        var request = context.Request;
        context.Data["route.path"] = request.Path ?? "/";
        context.Data["route.method"] = request.HttpMethod ?? "GET";
        return next(context);
    }
}

AuthenticationMiddleware

Short-circuits with a 401 when the Authorization header is missing or the token fails verification.

using Plumber;
using Amazon.Lambda.APIGatewayEvents;

namespace MyApi.Lambda;

internal sealed class AuthenticationMiddleware(
    RequestMiddleware<APIGatewayProxyRequest, APIGatewayProxyResponse> next)
{
    public Task InvokeAsync(
        RequestContext<APIGatewayProxyRequest, APIGatewayProxyResponse> context,
        IJwtVerifier verifier)
    {
        if (!TryGetBearerToken(context.Request, out var token)
            || !verifier.TryVerify(token, out var subject))
        {
            context.Response = Unauthorized();
            return Task.CompletedTask; // short-circuit — no next() call
        }

        context.Data["auth.subject"] = subject;
        return next(context);
    }

    private static bool TryGetBearerToken(APIGatewayProxyRequest request, out string token)
    {
        token = string.Empty;
        if (request.Headers is null) return false;
        if (!request.Headers.TryGetValue("Authorization", out var header)) return false;
        if (!header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) return false;
        token = header["Bearer ".Length..].Trim();
        return token.Length > 0;
    }

    private static APIGatewayProxyResponse Unauthorized() => new()
    {
        StatusCode = 401,
        Body = "{\"error\":\"unauthorized\"}",
        Headers = new Dictionary<string, string> { ["Content-Type"] = "application/json" },
    };
}

IJwtVerifier is method-injected, resolved from the per-request DI scope on every request. That stays correct even though HmacJwtVerifier is registered as a singleton — when you later swap it for a verifier that depends on a scoped HTTP client (for JWKS lookup, say), the middleware code stays the same.

GreetingMiddleware

The terminal business logic. By the time it runs the path, method, and authenticated subject are already in context.Data.

using System.Text.Json;
using Plumber;
using Amazon.Lambda.APIGatewayEvents;

namespace MyApi.Lambda;

internal sealed class GreetingMiddleware(
    RequestMiddleware<APIGatewayProxyRequest, APIGatewayProxyResponse> next)
{
    public Task InvokeAsync(
        RequestContext<APIGatewayProxyRequest, APIGatewayProxyResponse> context,
        IGreetingService greetings)
    {
        var path = (string)context.Data["route.path"]!;
        var subject = (string)context.Data["auth.subject"]!;

        if (path != "/greet")
        {
            context.Response = NotFound();
            return next(context);
        }

        var greeting = greetings.For(subject);
        context.Response = new APIGatewayProxyResponse
        {
            StatusCode = 200,
            Body = JsonSerializer.Serialize(new { greeting }),
            Headers = new Dictionary<string, string> { ["Content-Type"] = "application/json" },
        };
        return next(context);
    }

    private static APIGatewayProxyResponse NotFound() => new()
    {
        StatusCode = 404,
        Body = "{\"error\":\"not found\"}",
        Headers = new Dictionary<string, string> { ["Content-Type"] = "application/json" },
    };
}

For more than a handful of routes, replace the if (path != "/greet") ladder with a dispatch table or a separate per-route middleware. The shape stays the same.

The entry point

The Lambda handler is a single method on a single class. Build the Plumber handler once in a static readonly initializer and reuse it across every warm invocation.

using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.Core;
using Plumber;

[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]

namespace MyApi.Lambda;

public sealed class Function
{
    // Built once per cold start, reused for every warm invocation.
    private static readonly RequestHandler<APIGatewayProxyRequest, APIGatewayProxyResponse> Handler =
        Pipeline.Build();

    public Task<APIGatewayProxyResponse?> FunctionHandler(
        APIGatewayProxyRequest request,
        ILambdaContext context) =>
        Handler.InvokeAsync(request);
}

The static initializer runs on the worker thread that processed the cold-start invocation — not on a background thread. The first request pays the build cost. Every subsequent request on the same container reuses Handler.

The FunctionHandler signature returns Task<APIGatewayProxyResponse?> because InvokeAsync returns nullable. In practice the ErrorBoundaryMiddleware guarantees a non-null response on every path, so the nullable is paperwork rather than a real concern.

To assert non-null at the boundary instead, do it there:

public async Task<APIGatewayProxyResponse> FunctionHandler(
    APIGatewayProxyRequest request,
    ILambdaContext context) =>
    await Handler.InvokeAsync(request)
        ?? throw new InvalidOperationException("pipeline produced no response");

Cold-start and lifetime considerations

Plumber handlers are designed to be reused. The DI container, the configuration root, the compiled middleware dispatch — all of it survives between invocations. Build the handler once per Lambda container and keep it in a static field.

What that buys you on a warm invocation:

  • No DI container construction.
  • No configuration providers re-loaded.
  • No middleware reflection or expression-tree compilation.
  • A fresh DI scope and a fresh RequestContext per invocation. Singletons are shared across requests; method-injected dependencies are resolved fresh.

The trade-off: any work the handler does at construction time (reading configuration, resolving singletons that hit the network) lives on the cold-start path. Keep singleton constructors lightweight, or warm them lazily inside the first middleware that needs them.

Instance field alternative

A common alternative is an instance field on the function class:

public sealed class Function
{
    private readonly RequestHandler<APIGatewayProxyRequest, APIGatewayProxyResponse> handler;

    public Function() => handler = Pipeline.Build();

    public Task<APIGatewayProxyResponse?> FunctionHandler(
        APIGatewayProxyRequest request,
        ILambdaContext context) =>
        handler.InvokeAsync(request);
}

The Lambda runtime constructs Function once per container and reuses the instance, so the lifetime matches the static-field version. The static field is a touch easier to share across multiple handler classes in the same project. The instance field is easier to dispose deterministically (Lambda's container shutdown lets Function be collected, but it does not call Dispose).

Deterministic disposal

For handlers that hold open connections you want to flush, wire up an AppDomain.ProcessExit handler:

private static readonly RequestHandler<APIGatewayProxyRequest, APIGatewayProxyResponse> Handler =
    BuildAndRegisterShutdown();

private static RequestHandler<APIGatewayProxyRequest, APIGatewayProxyResponse> BuildAndRegisterShutdown()
{
    var handler = Pipeline.Build();
    AppDomain.CurrentDomain.ProcessExit += (_, _) => handler.Dispose();
    return handler;
}

Lambda makes no promise to fire ProcessExit before recycling a container, but for graceful shutdown of well-behaved containers it does the job.

Error handling

ErrorBoundaryMiddleware is the catch-all. Two finer-grained patterns are worth knowing.

Distinguishing timeouts from caller cancellation

The FunctionHandler signature can accept a CancellationToken linked to ILambdaContext.RemainingTime. Pass it through:

public Task<APIGatewayProxyResponse?> FunctionHandler(
    APIGatewayProxyRequest request,
    ILambdaContext context)
{
    using var cts = new CancellationTokenSource(context.RemainingTime - TimeSpan.FromMilliseconds(500));
    return Handler.InvokeAsync(request, cts.Token);
}

The 500-ms buffer gives the response time to serialize before Lambda kills the container. Inside ErrorBoundaryMiddleware, separate the catches:

catch (TimeoutException)
{
    logger.LogWarning("request {Id} exceeded handler timeout", context.Id);
    context.Response = new APIGatewayProxyResponse { StatusCode = 504, Body = "{\"error\":\"timeout\"}" };
}
catch (OperationCanceledException)
{
    logger.LogWarning("request {Id} cancelled (Lambda time exhausted)", context.Id);
    context.Response = new APIGatewayProxyResponse { StatusCode = 504, Body = "{\"error\":\"timeout\"}" };
}

Per-route validation

Earlier middleware can short-circuit with a 400 response without touching the error boundary. Set context.Response and return Task.CompletedTask instead of calling next.

Testing the pipeline

The Lambda function class is a thin shim — the interesting code lives in the middleware. PlumberApplicationFactory<TReq, TRes> builds the real pipeline once, lets you swap selected services for stubs, and invokes the chain end to end. See Testing and PlumberApplicationFactory for the full surface.

using Plumber.Testing;
using Microsoft.Extensions.DependencyInjection;
using Amazon.Lambda.APIGatewayEvents;
using Xunit;

namespace MyApi.Lambda.Tests;

public sealed class GreetingPipelineTests
{
    [Fact]
    public async Task ValidJwtReturns200Async()
    {
        using var factory = new PlumberApplicationFactory<APIGatewayProxyRequest, APIGatewayProxyResponse>(
                _ => Pipeline.CreateBuilder(),
                Pipeline.Configure)
            .WithServices(services =>
                services.AddSingleton<IJwtVerifier>(new StubJwtVerifier(subject: "alice")));

        var request = new APIGatewayProxyRequest
        {
            Path = "/greet",
            HttpMethod = "GET",
            Headers = new Dictionary<string, string> { ["Authorization"] = "Bearer test-token" },
        };

        var response = await factory.InvokeAsync(request);

        Assert.NotNull(response);
        Assert.Equal(200, response.StatusCode);
        Assert.Contains("alice", response.Body);
    }

    [Fact]
    public async Task MissingAuthorizationReturns401Async()
    {
        using var factory = new PlumberApplicationFactory<APIGatewayProxyRequest, APIGatewayProxyResponse>(
            _ => Pipeline.CreateBuilder(),
            Pipeline.Configure);

        var response = await factory.InvokeAsync(new APIGatewayProxyRequest
        {
            Path = "/greet",
            HttpMethod = "GET",
            Headers = new Dictionary<string, string>(),
        });

        Assert.NotNull(response);
        Assert.Equal(401, response.StatusCode);
    }
}

The factory builds the real pipeline once, swaps IJwtVerifier for a stub, and exercises the full chain.

Tested against

  • .NET 10
  • MSL.Plumber.Pipeline 3.*
  • MSL.Plumber.Pipeline.Testing 3.* (test project)
  • Amazon.Lambda.Core 2.7.0
  • Amazon.Lambda.Serialization.SystemTextJson 2.4.5
  • Amazon.Lambda.APIGatewayEvents 2.7.2
  • AWS Lambda .NET 10 managed runtime

Lambda runtime types and SDK versions move quickly. Check the Amazon.Lambda.* packages on NuGet before copying these versions into a new project.

See also

Clone this wiki locally