Obfuscating a .NET app that uses AutoMapper, MediatR and FluentValidation
These three libraries are the backbone of a modern ASP.NET Core service, and all three resolve things at runtime — by type and by property name — through convention and assembly scanning. That makes them behave very differently under renaming. AutoMapper matches members by property name, so renamed DTOs silently stop mapping; MediatR finds handlers by interface shape, so it usually survives untouched; FluentValidation discovers validators by shape but reports errors by property name, so discovery holds while your PropertyName strings go opaque. Here is exactly what breaks in each, the minimal keep rule for each, a consolidated nebula.config.json, and how to prove it with integration tests and Glass.NET.
AutoMapper, MediatR and FluentValidation are the backbone of a great many ASP.NET Core services: map the request to a domain type, dispatch it to a handler, validate it on the way in. All three are “convention over configuration” — they discover and wire things up at startup by scanning assemblies and matching on types and member names. That is exactly the machinery that renaming obfuscation interferes with, and the interesting part is that the three libraries interfere in three different ways. One breaks loudly, one usually does not break at all, and one keeps working while quietly corrupting the data it reports. If you obfuscate the whole service and run the un-obfuscated tests, you will not see any of it.
The root cause is the one from obfuscation, reflection and serialization pitfalls: renaming is safe for anything resolved by a metadata token and unsafe for anything resolved by a string name at runtime. The three frameworks sit at different points on that line. Let us take each in turn — what breaks, the minimal keep rule, how to confirm — and then consolidate it into one Nebula.NET config.
AutoMapper: the map survives, the member matching does not
CreateMap<Order, OrderDto>() identifies both types by their token, so the registration itself survives renaming untouched. The breakage is one level down, in how AutoMapper fills the destination. Its default convention pairs a source member with a destination member when their names are equal, and it reads MemberInfo.Name to do that comparison at runtime:
public sealed class Order { public decimal Amount { get; set; } }
public sealed class OrderDto { public decimal Amount { get; set; } }
cfg.CreateMap<Order, OrderDto>();
var dto = mapper.Map<OrderDto>(order); // before: dto.Amount == order.Amount
Before obfuscation the convention matches "Amount" == "Amount". After renaming, Order.Amount becomes a and OrderDto.Amount becomes some other letter — the two are different types and get independent names — so the comparison is now "a" == "x", which fails. AutoMapper leaves OrderDto.Amount at its default and maps nothing into it. No exception, just a 0.00 where a value should be.
What breaks: every member mapped by name convention — which, in most codebases, is most members. ReverseMap, ProjectTo<T> and flattening (Customer.Name → CustomerName) all rely on the same name matching and fail the same way.
The minimal keep rule: keep the public property names of the types that appear in a map — on both the source and the destination side. Keep the names only; the backing fields, constructors and any logic on those types still rename and still get full protection. The alternative, if you want to keep renaming those types too, is to make every mapping explicit, because a member-access lambda is an expression tree the compiler resolves to a token:
cfg.CreateMap<Order, OrderDto>()
.ForMember(dest => dest.Amount, opt => opt.MapFrom(src => src.Amount)); // token-based, survives
That is robust but verbose for a wide DTO. Keeping the property names is simpler, and those names usually have to be kept for serialization anyway.
How to confirm: call configuration.AssertConfigurationIsValid() as a test against the protected assembly. It throws AutoMapperConfigurationException listing every unmapped destination member, which turns a silent 0.00 into a failed test. Back it with a round-trip: map a populated source and assert the destination fields are equal.
MediatR: resolved by shape, so it usually just works
MediatR is the reassuring one. RegisterServicesFromAssembly scans for every type implementing the open generics IRequestHandler<,> and INotificationHandler<>, and dispatch pairs a request to its handler through a closed generic type — IRequestHandler<CreateOrder, Guid>. Both of those are token identities, not strings. This is the same distinction drawn in obfuscating assembly-scanning dependency injection: a scan that matches on shape survives, because renaming rewrites the implements-relationship consistently on the handler and on the interface.
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
public sealed class CreateOrderHandler : IRequestHandler<CreateOrder, Guid>
{
public Task<Guid> Handle(CreateOrder request, CancellationToken ct) { /* ... */ }
}
Nebula is free to rename CreateOrderHandler, CreateOrder and the Handle body — the scan still finds the handler by its IRequestHandler<,> implementation, and Send still routes to it by the closed generic. Pipeline behaviours registered as IPipelineBehavior<,> are found the same way. So handlers need nothing on the keep-list, and their bodies — often where your real domain logic lives — should get the full treatment, virtualization included.
What still breaks: the non-shape escape hatches. If you replaced the convention scan with a hand-rolled registration that filters handlers by t.Name.EndsWith("Handler"), that is a string match and it empties out exactly as in the DI post. And any Type.GetType("MyApp.Handlers.CreateOrderHandler") — sometimes seen in custom dispatchers or saga wiring — resolves a frozen string that no longer names anything and returns null.
The minimal keep rule: none for idiomatic MediatR. Only if you use a name-based custom registration (switch it to AssignableTo the handler interface instead) or a Type.GetType string (keep that one type’s name, or better, resolve it via IMediator and the request type).
How to confirm: an integration test that Sends one instance of every request type and Publishes every notification against the protected build. A handler the scan failed to register throws on Send; assert a non-null result for each.
FluentValidation: discovery survives, the reported name does not
FluentValidation splits down the middle. Discovery is shape-based and safe; the property names it reports are string-derived and not.
AddValidatorsFromAssembly scans for every IValidator<T> implementation — a shape match, so it survives renaming exactly as MediatR’s scan does, and your AbstractValidator<T> subclasses can all rename freely. The problem is what a rule knows about the property it guards. RuleFor(x => x.Email) reads the MemberInfo behind that lambda when the validator is built and stores its name, so after renaming the stored name is a:
public sealed class CreateOrderValidator : AbstractValidator<CreateOrder>
{
public CreateOrderValidator()
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
// After renaming: failure.PropertyName == "a", message "'a' must not be empty."
}
}
What breaks: ValidationFailure.PropertyName becomes the obfuscated name, and any {PropertyName} placeholder in a message renders it. That matters the moment anything downstream keys on that name: ASP.NET Core model-state maps PropertyName to form/field keys, API error contracts surface it to clients, and a front end matching errors to inputs by name finds "a" instead of "email". The validation still runs correctly — it just labels the result with a name nobody outside the assembly recognises.
The minimal keep rule: keep the model property names that surface in errors — the same DTO/model names AutoMapper and your serializer already need. Or, to keep renaming them, decouple the reported name from the member, which is the sturdier pattern (it is the validation analogue of pinning a JSON contract with [JsonPropertyName]):
RuleFor(x => x.Email).NotEmpty().WithName("email"); // per rule
// or, once per property, independent of the lambda:
RuleFor(x => x.Email).OverridePropertyName("email").NotEmpty();
How to confirm: a test that validates a deliberately invalid model against the protected build and asserts result.Errors.Single().PropertyName == "email" — not whatever the obfuscator renamed it to.
The consolidated keep-list
Notice the whole right-hand column reduces to one thing: DTO and model property names. The set AutoMapper matches on, the set FluentValidation reports, and the set your serializer writes are overwhelmingly the same types, so a single keep rule on your contracts and models covers all three. Everything else — handlers, validators, profiles, mapping internals, your logic — is found by shape or called directly and renames freely. Following the discipline from building a Nebula keep-list, keep the leaf, not the branch: members: "public" pins the property names and lets private fields rename.
{
"schemaVersion": 1,
"inputs": [ "bin/Release/net8.0/Acme.Api.dll" ],
"outputDirectory": "protected",
"preservePublicApi": true,
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "Acme.Api.Contracts.*", "action": "keep", "members": "public" },
{ "pattern": "Acme.Api.Models.*", "action": "keep", "members": "public" },
{ "pattern": "Acme.Api.*", "action": "rename" }
]
}
}
The first-match-wins order matters: the narrow keep rules sit above the broad rename catch-all, so your DTO property names stay while Acme.Api.Handlers, Acme.Api.Validators and everything else fall through to rename. There is deliberately no keep for handlers or validators — keeping them would leave your application map in cleartext for nothing, since both are resolved by shape. For a single reflected handler you cannot refactor, prefer an attribute on that exact type over widening the config:
[Obfuscation(Feature = "renaming", Exclude = true)] // resolved via Type.GetType(string)
public sealed class LegacySagaHandler : IRequestHandler<ResumeSaga, Unit> { /* ... */ }
Reserve your heaviest transforms for the handler bodies. Code virtualization and whole-method encryption — both Pro features in Nebula — belong on the methods that carry your real domain logic, not on the DTOs, which only need their names kept.
Prove it, don’t assume it
None of these failures appear at compile time, and two of the three are silent at run time, so verification is not optional. Run the three checks above as integration tests against the protected build: AssertConfigurationIsValid() for AutoMapper, a Send/Publish of every message for MediatR, and an invalid-model PropertyName assertion for FluentValidation. Then open the protected assembly in Glass.NET and confirm both halves of the boundary at once — your handlers and validators read as a, b, c and your mapping internals are gone, while the DTO property names your three frameworks resolve by are still intact. The full decompiler procedure is in did your obfuscation actually work?. When the tests are green against the obfuscated build and the decompiler shows names kept exactly where a string resolves them and nowhere else, you have protection that does not cost you a broken request pipeline.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.