When we committed to Native AoT for our Lambda functions at Oproto, the decision felt like a performance optimization. Compile ahead of time, skip JIT, get faster cold starts. Simple enough.
What we didn't fully appreciate was that AoT doesn't just change how your code compiles. It changes what code you can write. The core constraint: no reflection at runtime. And reflection is woven so deeply into the .NET ecosystem that removing it touches almost every layer of a typical application.
This post is about what that constraint actually looks like in practice across a platform with 20+ microservices, and the design decisions it forced.
Why Reflection Breaks AoT
Native AoT compiles your application to a native binary at build time. The compiler needs to know, at build time, every type that will be instantiated, every method that will be called, and every property that will be accessed. It uses this information to generate native code and trim everything else.
Reflection breaks this contract. When code calls typeof(T).GetProperties() or Activator.CreateInstance(type), the compiler can't predict at build time which types and members will be needed at runtime. The trimmer may remove code that reflection would have discovered, and the AoT compiler can't generate native code for types it doesn't know about.
The result: code that works perfectly under JIT silently breaks under AoT. Properties return null. Methods throw MissingMethodException. Serialization produces empty objects. The failures are subtle and often don't surface until a specific code path executes in production.
The Ecosystem Problem
The issue isn't that reflection is hard to avoid in your own code. It's that the .NET ecosystem runs on it. Here's a partial list of things that typically use reflection in a Lambda application:
- JSON serialization (
System.Text.Jsondefault mode,Newtonsoft.Json) - DynamoDB entity mapping (the AWS SDK's
DynamoDBContext) - Dependency injection (service resolution, constructor injection)
- Validation (FluentValidation's default property discovery)
- Object mapping (AutoMapper)
- OpenAPI generation (Swashbuckle, NSwag)
- Logging (structured logging with property destructuring)
Every one of these needs a replacement or a configuration change when you go AoT. That's not a single migration task. It's a systematic rethinking of your dependency chain.
JSON Serialization: The First Wall
Every .NET Lambda function serializes and deserializes JSON. Under JIT, System.Text.Json uses reflection to discover properties, create instances, and map JSON fields. Under AoT, you need source-generated serializer contexts:
[JsonSerializable(typeof(CreateOrderRequest))]
[JsonSerializable(typeof(CreateOrderResponse))]
[JsonSerializable(typeof(APIGatewayProxyRequest))]
[JsonSerializable(typeof(APIGatewayProxyResponse))]
public partial class OrderApiJsonContext : JsonSerializerContext { }
Every type that passes through JSON serialization needs to be registered. Miss one and you get a runtime error, not a compile-time error. With 20+ microservices, each with dozens of request/response types, this is a significant surface area to manage.
The discipline we adopted: one JsonSerializerContext per Lambda project, registered in the assembly attribute. Every new DTO gets added to the context as part of the PR. It's manual and it's tedious, but it's predictable.
DynamoDB: Building Our Own Client
This is where the constraint had the biggest impact. The AWS SDK's DynamoDBContext (the high-level document model) uses reflection extensively for entity mapping. It discovers properties via reflection, maps them to DynamoDB attributes at runtime, and constructs entity instances dynamically.
None of that works under AoT.
The low-level AmazonDynamoDBClient works fine since it operates on Dictionary<string, AttributeValue> directly. But writing raw GetItemRequest and PutItemRequest calls for every data access operation is verbose and error-prone.
This is what led us to build FluentDynamoDB. It's a source-generated DynamoDB client that does at compile time what DynamoDBContext does at runtime. You define entities with attributes:
[DynamoDbTable("Orders")]
public partial class Order
{
[PartitionKey(Prefix = "CUSTOMER")]
[DynamoDbAttribute("pk")]
public string CustomerId { get; set; } = string.Empty;
[SortKey(Prefix = "ORDER")]
[DynamoDbAttribute("sk")]
public string OrderId { get; set; } = string.Empty;
[DynamoDbAttribute("total")]
public decimal Total { get; set; }
}
The source generator produces the mapping code at compile time. The generated code is plain C# that reads and writes Dictionary<string, AttributeValue> directly. No reflection, no runtime type discovery, no trimming surprises.
Building this was a significant investment. But the alternative was either abandoning AoT or writing raw SDK calls across the entire platform. Neither was acceptable.
Object Mapping: Mapperly Over AutoMapper
AutoMapper is reflection-heavy. It discovers properties by name, builds mapping plans at runtime, and uses expression compilation for performance. None of this is AoT-compatible.
Mapperly is the source-generated alternative. You define a mapper as a partial class:
[Mapper]
public partial class OrderMapper
{
public partial OrderDto ToDto(OrderEntity entity);
public partial OrderEntity ToEntity(CreateOrderRequest request);
}
The source generator produces the mapping implementation at compile time. It's explicit about which properties map to which, and unmapped properties produce compiler warnings. This is actually an improvement over AutoMapper's convention-based approach, where a renamed property silently breaks a mapping and you don't find out until runtime.
Validation: FluentValidation Works (Mostly)
FluentValidation's core validation engine works under AoT. The rule definitions are explicit method calls, not reflection-based discovery:
public class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.Total).GreaterThan(0);
}
}
The one thing that doesn't work is automatic validator discovery (services.AddValidatorsFromAssembly()). That method scans assemblies using reflection to find all IValidator<T> implementations. Under AoT, you register validators explicitly:
services.AddScoped<IValidator<CreateOrderRequest>, CreateOrderValidator>();
More verbose, but it means you always know exactly which validators are registered.
OpenAPI Documentation: Another Source Generator
Generating OpenAPI specs from Lambda functions has the same reflection problem. The standard tools (Swashbuckle, NSwag) use reflection to discover endpoints, parameter types, and response schemas.
This led us to build LambdaOpenApi, a source generator that produces OpenAPI documentation from Lambda Annotations at compile time. It reads the [RestApi] and [HttpApi] attributes, inspects the method signatures, and generates the OpenAPI spec without any runtime reflection.
Dependency Injection: The Exception
Microsoft's built-in DI container (Microsoft.Extensions.DependencyInjection) does use reflection for constructor injection. It inspects constructor parameters at runtime to resolve dependencies.
This is one area where AoT makes an exception. The .NET team has done significant work to make the built-in DI container AoT-compatible through compile-time analysis. The container knows at build time which constructors it needs to call, and the trimmer preserves them.
The practical implication: stick with the built-in container. Third-party DI containers (Autofac, Ninject, etc.) may not have the same level of AoT support.
The Pattern That Emerges
After migrating the platform, a clear pattern emerged: every reflection-based library gets replaced by a source-generator-based equivalent.
| Concern | Reflection-Based | Source-Generated |
|---|---|---|
| JSON serialization | System.Text.Json (default) |
JsonSerializerContext |
| DynamoDB mapping | DynamoDBContext |
FluentDynamoDB |
| Object mapping | AutoMapper | Mapperly |
| OpenAPI docs | Swashbuckle/NSwag | LambdaOpenApi |
| Validator discovery | AddValidatorsFromAssembly() |
Explicit registration |
The source generator approach has a consistent set of tradeoffs:
You gain: compile-time verification, no runtime surprises, smaller binaries, faster startup.
You lose: convention-based discovery, implicit behavior, the ability to add types without touching a registration file.
For a platform that deploys to Lambda where cold start time matters and runtime failures in production are expensive, the tradeoffs favor source generators every time.
What We'd Do Differently
We had the no-reflection rule from the beginning. The challenge wasn't commitment to the constraint, it was finding compatible tooling. Many of the source-generated alternatives either didn't exist yet or were too immature to rely on, which is what led us to build FluentDynamoDB and LambdaOpenApi in the first place. AI coding assistants were also a constant source of friction early on, defaulting to reflection-based patterns and requiring repeated correction before they internalized the constraint.
The one area we'd invest in earlier is better tooling for the JsonSerializerContext registration problem. Missing a type registration is the most common AoT-related bug we hit, and it's always a runtime failure. A Roslyn analyzer that verifies all serialized types are registered would catch these at build time. That's on our list.
Is It Worth It
Yes, and not just for cold starts.
AoT's native code is less optimized than what the JIT produces after 20 years of runtime optimization work. That's true. But the no-reflection constraint has benefits that go beyond startup time. Source-generated code for serialization, mapping, and data access executes faster than its reflection-based equivalent because there's no runtime type inspection, no dynamic dispatch, no expression tree compilation. The generated code is just plain method calls.
More importantly, it's more reliable. Reflection-based code fails at runtime when a property is renamed, a type is trimmed, or a mapping convention doesn't match. Source-generated code fails at compile time. We catch entire categories of bugs (missing serializer registrations aside) before the code ever runs. Across 20+ microservices, that shift from runtime discovery to compile-time verification has been worth at least as much as the cold start improvement.
The .NET ecosystem is moving in this direction regardless. Source generators are becoming the standard approach for serialization, mapping, and code generation. The libraries that don't support AoT today will either add support or get replaced by ones that do. Starting now means you're ahead of that curve instead of catching up to it later.