Skip to content

AOT / NativeAOT / Trimming / Single-file compatibility #2204

Description

@einari

Goal

Make Cratis Arc fully compatible with:

  • PublishAot (NativeAOT compilation)
  • PublishTrimmed (IL Linker / trimming)
  • PublishSingleFile (single-file publish)

This is a prerequisite for consumer apps (MAUI, minimal APIs, cloud-native) that target AOT or trimmed deployment profiles.

Reference: Cratis/Fundamentals#1043 — generated type discovery with AOT-safe metadata via Roslyn source generators (the direction Arc should follow for type discovery).

Important: We do not want any breaking changes, so this change should be fully transparent


Work Items

1. Remove Castle.DynamicProxy + Polly resilience layer

Files: Source/DotNET/MongoDB/Resilience/ (entire folder) + MongoDB/MongoDBClientFactory.cs

Castle.DynamicProxy generates proxy types at runtime — fundamentally incompatible with AOT/trimming. The entire folder should be deleted:

  • MongoClientInterceptor.cs
  • MongoDatabaseInterceptor.cs
  • MongoCollectionInterceptor.cs
  • MongoCollectionInterceptorForReturnValues.cs
  • MongoClientInterceptorSelector.cs
  • MongoDatabaseInterceptorSelector.cs
  • MongoCollectionInterceptorSelector.cs
  • MongoClientDisposeInterceptor.cs
  • RetryingChangeStreamCursor.cs
  • EmptyAsyncCursor.cs
  • WellKnownErrorMessages.cs

MongoDBClientFactory.cs should be simplified to create a plain MongoClient with RetryReads=true and RetryWrites=true in the connection string / settings (the MongoDB driver has had built-in retry since v2.6/2.9 and it is on by default).


2. Replace DependencyContext + Assembly.Load type discovery

File: Arc.Core/GeneratedMetadataRegistration.cs

DependencyContext.Default is null in single-file publish; Assembly.Load(new AssemblyName(...)) does not work in AOT. Replace with a source-generated provider pattern following the approach in Fundamentals PR #1043.


3. Fix MakeGenericMethod / MakeGenericType + Invoke patterns

These patterns are not statically analysable and will be trimmed away or throw under NativeAOT. Each site needs to be restructured to use closed generics knowable at compile time, or use source-generated dispatch tables.

The approach depends on the site:

a. Validation: DiscoverableValidators.cs

Replace IsAssignableTo(typeof(AbstractValidator<>).MakeGenericType(modelType)) with interface inspection on already-constructed types — GetGenericTypeDefinition() and GetGenericArguments() on concrete interface instances are AOT-safe:

!_.GetInterfaces().Any(i =>
    i.IsGenericType &&
    i.GetGenericTypeDefinition() == typeof(IValidator<>) &&
    i.GetGenericArguments()[0] == modelType)

b. Validation: FluentValidationFilter.csValidationContext<> creation

The filter currently constructs ValidationContext<T> via MakeGenericType because it holds object instance and IValidator (untyped). Push the generic instantiation into BaseValidator<T> itself behind a non-generic interface:

// New interface (internal to framework — no consumer API change)
public interface IObjectValidator
{
    Task<ValidationResult> ValidateObjectAsync(object instance, CancellationToken ct = default);
}

// BaseValidator<T> gains an explicit implementation:
Task<ValidationResult> IObjectValidator.ValidateObjectAsync(object instance, CancellationToken ct) =>
    ValidateAsync((T)instance, ct);

FluentValidationFilter calls objectValidator.ValidateObjectAsync(instance). The ValidationContext<T> is now created inside AbstractValidator<T>.ValidateAsync(T) — FluentValidation's own statically-typed code, which AOT handles fine. Zero consumer impactBaseValidator<T> subclasses see no change.

c. Validation: FluentValidationFilter.cs — recursive property scan

The GetProperties() loop that recursively validates nested objects on a command (lines 53–70) is not trimmable — the trimmer removes unreferenced properties. Fix with a Roslyn source generator that emits a compile-time dispatch table:

// Generated output
internal static partial class CommandNestedValidationTargets
{
    internal static IEnumerable<object?> GetChildren(object command) => command switch
    {
        RegisterAuthor c => [c.Address],   // Address has IDiscoverableValidator<Address>
        PlaceOrder c    => [c.DeliveryInfo],
        _               => []
    };
}

The generator finds all [Command] types, walks their properties at compile time, and emits a child only when a corresponding IDiscoverableValidator<TChildType> exists. FluentValidationFilter replaces the reflection loop with CommandNestedValidationTargets.GetChildren(instance). No consumer API change[Command] records and CommandValidator<T> subclasses remain unchanged.

d. Command pipeline: CommandPipeline.csCommandResult<> creation

CreateCommandResultWithResponse (line 386) uses MakeGenericType + Activator.CreateInstance to wrap an object response in CommandResult<T>. The same source generator that emits the validation dispatch table can also emit a typed response wrapper:

// Generated output
internal static partial class CommandResponseWrapper
{
    internal static CommandResult Wrap(CorrelationId correlationId, object response) => response switch
    {
        AuthorId r           => new CommandResult<AuthorId>(correlationId, r),
        ProjectRegistered r  => new CommandResult<ProjectRegistered>(correlationId, r),
        _                    => CommandResult.Success(correlationId)
    };
}

The generator reads the declared return type of each command's Handle() method at compile time. No consumer API change.

e. Remaining reflection sites

File Pattern Approach
Arc.Core/Queries/ObservableQueryHttp.cs GetMethod("CreateResponseForObservable").MakeGenericMethod(...).Invoke(...) Source-generated dispatch or closed-generic overloads
Arc.Core/Queries/ObservableQueryDemultiplexer.cs MakeGenericMethod for SubscribeToSubjectOfType / StreamAsyncEnumerableOfType Source-generated dispatch
Arc.Core/Queries/QueryableExtensions.cs MakeGenericMethod for Count/Skip/Take/OrderBy Typed helper overloads; IQueryable<T> instead of IQueryable at call sites
Arc.Core/Queries/QueryRenderers.cs GetMethod(...).Invoke Source-generated dispatch
Arc/ModelBinding/FromRequestModelBinder.cs typeof(DefaultValueChecker<>).MakeGenericType(type) + Invoke Source-generated or generic constraint
Chronicle/Aggregates/AggregateRootServiceCollectionExtensions.cs MakeGenericMethod(aggregateRootType).Invoke Source-generated registration
MongoDB/MongoDBDefaults.cs Activator.CreateInstance, IBsonClassMapFor<> MakeGenericType + Invoke Source-generated BSON class map registration
MongoDB/ConceptSerializationProvider.cs GetMethod("CreateConceptSerializer").MakeGenericMethod(type).Invoke Source-generated serializer registration
MongoDB/QueryContextAwareSet.cs typeof(EqualityComparer<>).MakeGenericType(idProperty.PropertyType) [DynamicallyAccessedMembers] + EqualityComparer<T> via generic helper
EntityFrameworkCore/DbContextServiceCollectionExtensions.cs addDbContextMethod.MakeGenericMethod(dbContext).Invoke Source-generated registration
EntityFrameworkCore/EntityFrameworkCoreBuilderExtensions.cs .MakeGenericMethod(dbContextType) Source-generated registration
EntityFrameworkCore/Observe/DbSetObserveExtensions.cs MakeGenericMethod + Expression.Property + Expression.Lambda Source-generated expression or typed overloads

4. Fix Expression.Compile() under NativeAOT

File: Arc.Core/Validation/BaseValidator.cs

CreateTransformExpression calls expression.Compile() (line 83) because it treats expression.Body is ParameterExpression as a case requiring pre-compilation to invoke the expression. This is unnecessary: when the body is the parameter, CreateValueExpression already handles the case correctly — the parameter expression is used directly as the null-check target, producing the same result without any compilation.

Fix: remove CreateTransformExpression entirely and use CreateValueExpression for both branches of RuleFor<TValue>:

// Before
if (expression.Body is ParameterExpression)
{
    var transformExpression = CreateTransformExpression(expression);
    return ((AbstractValidator<T>)this).RuleFor(transformExpression);
}

// After — CreateValueExpression handles both cases identically
var valueExpression = CreateValueExpression(expression);
if (expression.Body is not ParameterExpression)
{
    var propertyName = GetPropertyName(expression);
    return ((AbstractValidator<T>)this).RuleFor(valueExpression).OverridePropertyName(propertyName);
}
return ((AbstractValidator<T>)this).RuleFor(valueExpression);

Delete CreateTransformExpression. No consumer impact.


5. Fix runtime-type JsonSerializer calls

Runtime Type-based serialization calls cannot be statically analysed and will be trimmed. This affects multiple files beyond FromRequestModelBinder.cs:

File Pattern
Arc/ModelBinding/FromRequestModelBinder.cs JsonSerializer.Deserialize(str, instance.GetType())
Arc.Core/Identity/IdentityProvider.cs JsonSerializer.Serialize(result, options) / Deserialize<IdentityProviderResult>(json, options)
Arc.Core/Queries/ClientEnumerableObservableSSE.cs JsonSerializer.Serialize(queryResult, options)
Arc.Core/Queries/ObservableQueryDemultiplexer.cs Multiple Serialize / Deserialize calls on object
Arc.Core/OpenApi/OpenApiExtensions.cs JsonSerializer.Serialize(document, options)

Fix: a single Roslyn source generator emits a JsonSerializerContext populated with [JsonSerializable] for every [Command], [ReadModel], and identity/query result type in the consuming assembly. The generator registers the context via JsonSerializerOptions.TypeInfoResolverChain at startup through Arc's existing ConfigureArcDefaults() extension point.

Consumers add nothing — the source generator discovers types from their assembly automatically and wires everything up. The same generator pass that handles §3c (nested validation dispatch) and §3d (response wrapping) can emit the JsonSerializerContext in a single build step.


6. Fix private FluentValidation reflection

File: Arc.Core/Validation/RuleBuilderInitialExtensions.cs

OverridePropertyName accesses FluentValidation's internal Rule.PropertyName property via BindingFlags.NonPublic reflection (lines 27–38). This will be trimmed and throws under NativeAOT.

Fix: IRuleBuilderInitial<T, TProperty> extends IRuleBuilderOptions<T, TProperty>, which has a public OverridePropertyName(string) extension method in FluentValidation. The concrete RuleBuilder<T, TProperty> behind both interfaces returns this from that method, so casting back to IRuleBuilderInitial<T, TProperty> is safe:

public static IRuleBuilderInitial<T, TProperty> OverridePropertyName<T, TProperty>(
    this IRuleBuilderInitial<T, TProperty> ruleBuilder,
    string propertyName)
{
    ((IRuleBuilderOptions<T, TProperty>)ruleBuilder).OverridePropertyName(propertyName);
    return ruleBuilder;
}

Zero reflection. No consumer impact.


7. Add [DynamicallyAccessedMembers] annotations

Sites that use reflection but cannot easily be restructured should at minimum be annotated so the trimmer preserves the required members. Known sites:

File Members accessed
Arc.Core/Queries/QueryableExtensions.cs Properties by string name
Arc.Core/Queries/ReadModelInterceptors.cs Interface method, Result property
Chronicle/Commands/SubjectValuesProvider.cs Constructors, properties by parameter name
Chronicle/Commands/EventSourceExtensions.cs All properties, implicit operator methods
Arc/ModelBinding/FromRequestModelBinder.cs All properties
EntityFrameworkCore/BaseDbContext.cs Entity type properties, generic arguments

Implementation Order

The items in points 3–6 share a single Roslyn source generator. Suggested order:

  1. Points 4 and 6 first — pure code fixes, no new projects, shippable independently.
  2. Point 3a and 3b — code fixes, no generator needed.
  3. The source generator (points 3c, 3d, and 5 together) — one generator project, one build pass.
  4. Remaining MakeGenericMethod sites (3e) and [DynamicallyAccessedMembers] annotations (7) — can be parallelised once the generator pattern is established.

Out of Scope

  • C# Interceptors (Roslyn): evaluated and rejected — only work at known call sites visible to the generator, not suitable for retrofitting external interfaces.
  • Polly semaphore throttling: redundant with the MongoDB driver's built-in connection pool management.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions