DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Obfuscating an app that registers itself by convention: assembly-scanning DI binds on type names, so renaming unregisters your services

A great many .NET applications never actually write down their service registrations. They scan. At startup an app sweeps an assembly, finds every type that looks like a service — by a naming convention, usually — and registers it. Scrutor made this a one-liner, and hand-rolled reflection loops did it long before that. It is tidy, it is convention-over-configuration, and it has a specific, quiet failure mode under obfuscation: the convention is almost always a string match against the type name, and renaming changes every name.

The result is the worst shape a bug can take. The build is green — the scan is valid code. The app starts — the scan runs without error. And then the first request asks the container for IOrderService, the container has never heard of it, and you get Unable to resolve service for type 'IOrderService' pointing at the controller, nowhere near the scan that silently registered nothing. This post is about why that happens for Nebula, and the one-line change that makes it stop.

The registration that breaks

Here is the shape that gets written most often — scan the assembly, take everything whose name ends in a convention word, register it against its interfaces:

builder.Services.Scan(scan => scan
    .FromAssemblyOf<OrderService>()
    .AddClasses(c => c.Where(t => t.Name.EndsWith("Service")))   // <-- string match on the name
        .AsImplementedInterfaces()
        .WithScopedLifetime());
Enter fullscreen mode Exit fullscreen mode

Read what that predicate actually does: at startup it enumerates the assembly's types and keeps the ones whose Name ends in the seven characters S-e-r-v-i-c-e. Before obfuscation that is OrderService, PricingService, InventoryService. After Nebula renames them to a, b, c, the predicate asks each of them "does your name end in 'Service'?" and each answers no. The scan keeps zero types, AsImplementedInterfaces has nothing to bind, and IOrderService is never registered.

Nothing in that is a bug the compiler or the obfuscator could catch. The predicate is valid. The rename did exactly what it was told. The two facts are simply incompatible, and the incompatibility only becomes visible when something tries to resolve a service that the empty scan never added.

The distinction that matters: name vs shape

Not every scan breaks. The difference is whether the scan finds a type by its name or by its shape, and that line is the whole of it. A name is a string that renaming rewrites — and the string literal in your predicate does not get rewritten with it, so the comparison goes stale. A shape — implements this interface, carries this attribute, derives from this base — is a relationship in the metadata graph, and renaming is a consistent rewrite of that graph: it renames the interface and the implementing type together, so "implements" still holds.

// SURVIVES — matches on the implements-relationship, a metadata token identity
.AddClasses(c => c.AssignableTo<ICommandHandler>())

// SURVIVES — matches on an attribute type token; renaming keeps attribute and target in sync
.AddClasses(c => c.WithAttribute<ServiceAttribute>())

// BREAKS — matches on a frozen string the rename cannot reach
.AddClasses(c => c.Where(t => t.Name.EndsWith("Handler")))

// BREAKS WORSE — resolves a type from a name string that no longer names anything
.AddSingleton(Type.GetType("Acme.Orders.OrderService")!)
Enter fullscreen mode Exit fullscreen mode

The fix is one line, and it buys back the names

Give the convention a marker and scan for that instead of for the spelling. A marker interface is the cleanest, because it costs nothing at run time and says exactly what the convention meant:

public interface IAppService { }                    // marker; renaming keeps it resolvable by token

public sealed class OrderService : IAppService, IOrderService { /* ... */ }
Enter fullscreen mode Exit fullscreen mode
builder.Services.Scan(scan => scan
    .FromAssemblyOf<OrderService>()
    .AddClasses(c => c.AssignableTo<IAppService>())  // <-- shape, not name
        .AsImplementedInterfaces()
        .WithScopedLifetime());
Enter fullscreen mode Exit fullscreen mode

Now the scan asks each type "do you implement IAppService?", which the metadata answers correctly before and after obfuscation. Nebula is free to rename OrderService, IOrderService and every other type in the layer, because the scan no longer reads a single name — the only thing it depends on is the implements relationship, and renaming preserves that by construction. You get the obfuscation you wanted and the convention registration you wanted, with no overlap between them.

An attribute works identically if you prefer not to add an interface to the type's hierarchy:

[Service(ServiceLifetime.Scoped)]
public sealed class PricingService : IPricingService { /* ... */ }
Enter fullscreen mode Exit fullscreen mode
.AddClasses(c => c.WithAttribute<ServiceAttribute>())
Enter fullscreen mode Exit fullscreen mode

The attribute is matched by its type token, which the rename keeps consistent on the attribute class and on every type decorated with it.

When you cannot change the scan

Sometimes the scan is in a library you do not own, and it filters on a name you cannot influence. The escape hatch is to keep the names the scan relies on — but keep them narrowly, and understand the cost. A Nebula config excludes just the convention-named types from renaming:

{
  "inputs": [ "bin/Release/net8.0/Acme.Orders.dll" ],
  "outputDirectory": "protected",

  "encryptStrings": true,
  "controlFlowObfuscation": true,

  "renaming": {
    "rules": [
      { "pattern": "*Service",    "action": "keep"   },
      { "pattern": "*Handler",    "action": "keep"   },
      { "pattern": "*Repository", "action": "keep"   },
      { "pattern": "Acme.Orders.*", "action": "rename" }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Be clear-eyed about the trade: every keep rule here leaves a class name in the metadata, and the set of names you are exposing is precisely your service layer — the map of what the app does. The earlier the rule sits, the more it wins over the broad rename, so a *Service keep pins every service's name. That is why the refactor is the better answer whenever the scan is yours: switching one predicate from Name.EndsWith to AssignableTo turns the whole keep block back into rename and hands the names back to the obfuscator. Use the exclude only for the third-party scan you genuinely cannot reach.

Fail fast instead of at the first request

The reason this bug is expensive is distance — the empty scan and the failed resolution are far apart. Close the distance with a startup smoke test that asserts the services you expect are actually registered, and run it against the obfuscated build, because that is the only build where the scan can come up empty:

[Fact]
public void Expected_services_are_registered_after_scanning()
{
    using var provider = BuildProductionServiceProvider();   // same Scan(...) as the app

    // Resolve the way the app will — if the scan registered nothing, this throws here,
    // in a test, instead of on the first production request.
    Assert.NotNull(provider.GetService<IOrderService>());
    Assert.NotNull(provider.GetService<IPricingService>());

    // Or assert the count the convention should have produced:
    var handlers = provider.GetServices<ICommandHandler>().ToList();
    Assert.Equal(expected: 7, handlers.Count);
}
Enter fullscreen mode Exit fullscreen mode

BuildServiceProvider(validateOnBuild: true) will not catch this on its own — it validates that the registrations present can be constructed, and a missing registration is simply absent, not invalid. You have to assert the presence of what the scan should have found. Run this test over the protected assembly in CI and a scan that renaming emptied turns red in the build, which is exactly where you want it, rather than in a production stack trace that blames the controller.

The takeaway

Convention-based DI registration is a string match against type names far more often than it looks, and renaming obfuscation changes every one of those names while leaving the string literal in your predicate frozen — so the scan quietly registers nothing and the app fails at the first resolution, a green build away from the cause. The fix is to make the scan match on shape rather than spelling: AssignableTo a marker interface or WithAttribute a marker attribute, both of which renaming rewrites consistently across the metadata graph, so the scan keeps finding your types no matter what they are called. That one change lets Nebula rename your entire service layer — the names you obfuscated to hide — while the registration keeps working. When the scan is not yours to change, pin just the convention-named types with narrow keep rules and accept that you are leaving those names visible. Either way, prove it with a startup test that resolves the expected services against the obfuscated build, so an emptied scan fails in CI and never in production.

Top comments (0)