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.cs — ValidationContext<> 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 impact — BaseValidator<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.cs — CommandResult<> 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:
- Points 4 and 6 first — pure code fixes, no new projects, shippable independently.
- Point 3a and 3b — code fixes, no generator needed.
- The source generator (points 3c, 3d, and 5 together) — one generator project, one build pass.
- 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.
Goal
Make Cratis Arc fully compatible with:
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).
Work Items
1. Remove Castle.DynamicProxy + Polly resilience layer
Files:
Source/DotNET/MongoDB/Resilience/(entire folder) +MongoDB/MongoDBClientFactory.csCastle.DynamicProxy generates proxy types at runtime — fundamentally incompatible with AOT/trimming. The entire folder should be deleted:
MongoClientInterceptor.csMongoDatabaseInterceptor.csMongoCollectionInterceptor.csMongoCollectionInterceptorForReturnValues.csMongoClientInterceptorSelector.csMongoDatabaseInterceptorSelector.csMongoCollectionInterceptorSelector.csMongoClientDisposeInterceptor.csRetryingChangeStreamCursor.csEmptyAsyncCursor.csWellKnownErrorMessages.csMongoDBClientFactory.csshould be simplified to create a plainMongoClientwithRetryReads=trueandRetryWrites=truein 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.Loadtype discoveryFile:
Arc.Core/GeneratedMetadataRegistration.csDependencyContext.Defaultisnullin 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+InvokepatternsThese 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.csReplace
IsAssignableTo(typeof(AbstractValidator<>).MakeGenericType(modelType))with interface inspection on already-constructed types —GetGenericTypeDefinition()andGetGenericArguments()on concrete interface instances are AOT-safe:b. Validation:
FluentValidationFilter.cs—ValidationContext<>creationThe filter currently constructs
ValidationContext<T>viaMakeGenericTypebecause it holdsobject instanceandIValidator(untyped). Push the generic instantiation intoBaseValidator<T>itself behind a non-generic interface:FluentValidationFiltercallsobjectValidator.ValidateObjectAsync(instance). TheValidationContext<T>is now created insideAbstractValidator<T>.ValidateAsync(T)— FluentValidation's own statically-typed code, which AOT handles fine. Zero consumer impact —BaseValidator<T>subclasses see no change.c. Validation:
FluentValidationFilter.cs— recursive property scanThe
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:The generator finds all
[Command]types, walks their properties at compile time, and emits a child only when a correspondingIDiscoverableValidator<TChildType>exists.FluentValidationFilterreplaces the reflection loop withCommandNestedValidationTargets.GetChildren(instance). No consumer API change —[Command]records andCommandValidator<T>subclasses remain unchanged.d. Command pipeline:
CommandPipeline.cs—CommandResult<>creationCreateCommandResultWithResponse(line 386) usesMakeGenericType+Activator.CreateInstanceto wrap anobjectresponse inCommandResult<T>. The same source generator that emits the validation dispatch table can also emit a typed response wrapper:The generator reads the declared return type of each command's
Handle()method at compile time. No consumer API change.e. Remaining reflection sites
Arc.Core/Queries/ObservableQueryHttp.csGetMethod("CreateResponseForObservable").MakeGenericMethod(...).Invoke(...)Arc.Core/Queries/ObservableQueryDemultiplexer.csMakeGenericMethodforSubscribeToSubjectOfType/StreamAsyncEnumerableOfTypeArc.Core/Queries/QueryableExtensions.csMakeGenericMethodfor Count/Skip/Take/OrderByIQueryable<T>instead ofIQueryableat call sitesArc.Core/Queries/QueryRenderers.csGetMethod(...).InvokeArc/ModelBinding/FromRequestModelBinder.cstypeof(DefaultValueChecker<>).MakeGenericType(type)+ InvokeChronicle/Aggregates/AggregateRootServiceCollectionExtensions.csMakeGenericMethod(aggregateRootType).InvokeMongoDB/MongoDBDefaults.csActivator.CreateInstance,IBsonClassMapFor<>MakeGenericType + InvokeMongoDB/ConceptSerializationProvider.csGetMethod("CreateConceptSerializer").MakeGenericMethod(type).InvokeMongoDB/QueryContextAwareSet.cstypeof(EqualityComparer<>).MakeGenericType(idProperty.PropertyType)[DynamicallyAccessedMembers]+EqualityComparer<T>via generic helperEntityFrameworkCore/DbContextServiceCollectionExtensions.csaddDbContextMethod.MakeGenericMethod(dbContext).InvokeEntityFrameworkCore/EntityFrameworkCoreBuilderExtensions.cs.MakeGenericMethod(dbContextType)EntityFrameworkCore/Observe/DbSetObserveExtensions.csMakeGenericMethod+Expression.Property+Expression.Lambda4. Fix
Expression.Compile()under NativeAOTFile:
Arc.Core/Validation/BaseValidator.csCreateTransformExpressioncallsexpression.Compile()(line 83) because it treatsexpression.Body is ParameterExpressionas a case requiring pre-compilation to invoke the expression. This is unnecessary: when the body is the parameter,CreateValueExpressionalready handles the case correctly — the parameter expression is used directly as the null-check target, producing the same result without any compilation.Fix: remove
CreateTransformExpressionentirely and useCreateValueExpressionfor both branches ofRuleFor<TValue>:Delete
CreateTransformExpression. No consumer impact.5. Fix runtime-type
JsonSerializercallsRuntime
Type-based serialization calls cannot be statically analysed and will be trimmed. This affects multiple files beyondFromRequestModelBinder.cs:Arc/ModelBinding/FromRequestModelBinder.csJsonSerializer.Deserialize(str, instance.GetType())Arc.Core/Identity/IdentityProvider.csJsonSerializer.Serialize(result, options)/Deserialize<IdentityProviderResult>(json, options)Arc.Core/Queries/ClientEnumerableObservableSSE.csJsonSerializer.Serialize(queryResult, options)Arc.Core/Queries/ObservableQueryDemultiplexer.csSerialize/Deserializecalls onobjectArc.Core/OpenApi/OpenApiExtensions.csJsonSerializer.Serialize(document, options)Fix: a single Roslyn source generator emits a
JsonSerializerContextpopulated with[JsonSerializable]for every[Command],[ReadModel], and identity/query result type in the consuming assembly. The generator registers the context viaJsonSerializerOptions.TypeInfoResolverChainat startup through Arc's existingConfigureArcDefaults()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
JsonSerializerContextin a single build step.6. Fix private FluentValidation reflection
File:
Arc.Core/Validation/RuleBuilderInitialExtensions.csOverridePropertyNameaccesses FluentValidation's internalRule.PropertyNameproperty viaBindingFlags.NonPublicreflection (lines 27–38). This will be trimmed and throws under NativeAOT.Fix:
IRuleBuilderInitial<T, TProperty>extendsIRuleBuilderOptions<T, TProperty>, which has a publicOverridePropertyName(string)extension method in FluentValidation. The concreteRuleBuilder<T, TProperty>behind both interfaces returnsthisfrom that method, so casting back toIRuleBuilderInitial<T, TProperty>is safe:Zero reflection. No consumer impact.
7. Add
[DynamicallyAccessedMembers]annotationsSites that use reflection but cannot easily be restructured should at minimum be annotated so the trimmer preserves the required members. Known sites:
Arc.Core/Queries/QueryableExtensions.csArc.Core/Queries/ReadModelInterceptors.csResultpropertyChronicle/Commands/SubjectValuesProvider.csChronicle/Commands/EventSourceExtensions.csArc/ModelBinding/FromRequestModelBinder.csEntityFrameworkCore/BaseDbContext.csImplementation Order
The items in points 3–6 share a single Roslyn source generator. Suggested order:
MakeGenericMethodsites (3e) and[DynamicallyAccessedMembers]annotations (7) — can be parallelised once the generator pattern is established.Out of Scope