Skip to main content

Preparing NServiceBus for trimming and AOT

This post is part of the NServiceBus 10 Improvements series. (See more)

  • Preparing NServiceBus for trimming and AOT (this post)
  • Next post coming July 14
Trimming a tree

Trimming a tree blindfolded is probably not the best idea in the world. The best-case scenario is “at least the tree didn’t die.” The worst-case scenario is a trip to the emergency room. 1

The .NET ecosystem is getting into the tree-trimming business (in a manner of speaking), and we’re starting to make changes to NServiceBus to ensure you won’t be caught trimming your apps with a blindfold on.

Trimming and ahead-of-time compilation (AOT) reward code that the compiler can see. That changes some of the design assumptions for a framework like NServiceBus, which until now has relied on runtime discovery to maintain a productive developer experience.

🔗What is assembly trimming?

It’s now possible to bundle .NET along with an application (in a self-contained deployment) and run it in an environment where .NET is not already installed. But the resulting application is going to be big, and the memory footprint of loading code that the application doesn’t end up using is going to be higher as well.

Trimming allows the compiler to remove code that your application doesn’t need to call so that the application size, and corresponding memory footprint, can be smaller. This is especially advantageous for serverless and containerized deployments, where startup time, binary size, and memory usage matter.

But trimming comes with a catch: you have to know which code will be called and which code won’t.

The compiler can figure most of this out, but you have a problem when you try to call a method like Assembly.GetTypes(), because from the compiler’s perspective, that’s a blindfold. How can it give the “correct answer” to that method when some of the types in the assembly have been thrown away? Or consider what would happen if you called Activator.CreateInstance(Type type) with a type that has had its public constructors removed. If you trimmed an app and then called any of these APIs anyway, your application could fail in spectacularly confusing ways.

The problem is that NServiceBus is a framework, not a library, which relies on APIs like these (and others) to locate, register, and invoke user code in your message handlers, sagas, and behaviors.

🔗Sharp edges

For years, NServiceBus has used assembly scanning to discover all the pieces of your messaging system and wire them up for you. You implement IHandleMessages<T> or inherit Saga<T>, and the framework finds those classes and makes them work without you having to remember to update a 100-line “register all the things” method.

But trimming breaks assembly scanning because scanning requires methods like Assembly.GetTypes() to return correct answers at runtime. With nothing specifically pointing to your message handler, assembly trimming would hack it away, and so assembly scanning would find no handlers.

As we evolve NServiceBus toward a future that supports trimming and AOT compilation, we wanted to create a strategy that supports manual registration, as trimming requires, but without losing the ease of use that assembly scanning provides.

So we did both.

🔗Assembly scanning now optional

In NServiceBus version 10.0, we focused on enabling registration in the most direct way possible, and in NServiceBus 10.2, we amped it up.

Starting in version 10.0, you can turn off automatic assembly scanning and register your handlers and sagas manually:

endpointConfiguration.AssemblyScanner().Disable = true;
endpointConfiguration.AddHandler<OrderShippedHandler>();
endpointConfiguration.AddSaga<OrderFulfillmentPolicy>();

We didn’t really think many people would do this right away, given the high effort required to manually register every handler and saga without help from assembly scanning. However, anyone who did would get a performance benefit at startup: no need to load all project assemblies with reflection to find all the handlers and sagas. For the 10.0 release, the key was to create a separate manual registration alternative from the default automatic assembly scanning.

In NServiceBus version 10.2, we got to build the secret sauce. Using Roslyn source generators, we moved the assembly scanning capability from runtime to compile time. By generating code based on the handlers found at compile time, you can now register everything in one go:

endpointConfiguration.Handlers.OrderModuleAssembly.AddAll();

The generated source also contains options for registering single handlers or groups of handlers in a namespace:

endpointConfiguration.Handlers.OrderModuleAssembly.OrderHandlers.AddOrderShippedHandler();
endpointConfiguration.Handlers.OrderModuleAssembly.Some.OtherNamespace.AddAll();

The generated code is updated whenever you compile, so you can add a new handler or saga and know the source generator will pick it up the same way assembly scanning would.

Making this code generation goodness work requires applying the [Handler] attribute to your handlers and the [Saga] attribute to each saga2, but we also created a Roslyn analyzer to help you out. Diagnostic NSB0022 will point out missing [Handler] attributes, and NSB0025 will point out missing [Saga] attributes, and the code fixes will fix it for you.

If you want to make extra sure you don’t forget to do this, you can add this to your .editorconfig file, which would make ignoring either diagnostic a build-time error:

# Ensure all handlers are decorated with [Handler] to enable source generation
dotnet_diagnostic.NSB0022.severity = error
# Ensure all sagas are decorated with [Saga] to enable source generation
dotnet_diagnostic.NSB0025.severity = error

With these source-generated APIs, you can keep all the convenience of assembly scanning discovery of handlers and sagas, benefit from the runtime speed-up of skipping assembly scanning, and be ready for an assembly-trimming and AOT future. Or you can keep using assembly scanning as you always have; the choice is yours.

🔗It’s a long road

Even with these advances, NServiceBus is not ready for trimming or AOT support yet, but until the rest of the .NET ecosystem catches up, it’s a bit of a moot point anyway.

We’ve made a lot of progress. We redesigned how features are registered, applied trimming attributes to handler and saga code paths, did it again even better, and countless other improvements. That brought us from 89 trimming-related warnings before version 10.0 to 46 in version 10.2. Each of these warnings must be considered independently and either augmented with annotations or completely redesigned with source generators. Some may require breaking changes that can’t be done until the next major version.

Maybe most importantly, we’ve also introduced a trimming progress test to ensure that number doesn’t get any bigger.

But we don’t want to overinvest when there are other features we’d like to deliver, because the .NET ecosystem around NServiceBus isn’t prepared for trimming and AOT anyway.

Even if NServiceBus were 100% AOT-ready, you can publish an AOT app only if all the code in your compilation (including dependencies) also supports AOT. Our review of NServiceBus component dependencies showed that, in practice, that would only work if choosing PostgreSQL for both transport and persistence. Some transport dependencies (for example, the Azure Service Bus SDK) are trimmable, but AOT sets a higher bar that currently only Npgsql clears. The persistence story is even more constrained: most dependencies used by our NServiceBus persistence components are not even trimmable. 3

The .NET ecosystem is in transition, but we expect the evolution toward trimming and AOT to be inevitable, and we plan to be ready.

🔗Unlocked by modern .NET

The progress toward assembly trimming and ahead-of-time compilation would not be possible without recent advances in the .NET and C# ecosystems. Trimming is only supported in .NET 6 or later, and ahead-of-time compilation requires .NET 8 or later.

But more than that, modernization of NServiceBus would not be possible without improvements made to .NET, such as interceptors to replace the Reflection-based implementation of a method with a source-generated implementation at compile time, or UnsafeAccessor to let code bind directly (and at compile time) to members that would otherwise require Reflection.

As .NET continues to evolve, we are committed to making sure NServiceBus evolves with it.

🔗Summary

If you’re excited about the future of .NET, assembly trimming, and ahead-of-time compilation, we’re right there with you. We are making deliberate, incremental changes that preserve backward compatibility while opening the door to more modern deployment options.

It’s now possible to fully turn off assembly scanning and manually register your handlers and sagas, either individually or via source-generated convenience methods that mimic the ease of use of assembly scanning but in a compiler-visible way. Whether you’re interested in a speed-up in startup time from ditching assembly scanning, or you want your IDE to stop complaining that your message handlers are unreferenced types, there’s a lot to love in NServiceBus 10 already. 4

But that’s not all. This is only the first post in a series describing new improvements to NServiceBus, starting with the release of NServiceBus version 10.0. In the next few posts, we’ll tell you about more exciting changes and features that are already available because of this work. Stay tuned!

Share on Twitter

About the author

Daniel Marbach

Daniel Marbach is a core engineer at Particular Software. He doesn't frequently trim real trees, but appreciates a PR that trims away a bunch of dead code.

More from the NServiceBus 10 Improvements series:
  • Preparing NServiceBus for trimming and AOT (this post)
  • Next post coming July 14

  1. Those scissors are sharp!

  2. This is because source generators do not perform well when driven off implemented interfaces, so Microsoft recommends you Never Do That®. Scans using an attribute are the fastest and most optimized code path, which is important because a source generator can technically run every time you press a key in your IDE.

  3. To check a package for yourself, navigate to the NuGet package page, click the Open in NuGet Package Explorer link such as this one for Npgsql, open the lib folder, then (generally) the latest net*.0 directory, and then click on the assembly DLL. Now look below for any AssemblyMetadata values. A package is trimmable if the key IsTrimmable has value True, and is AOT-compatible if IsAotCompatible has value True.

  4. That's right, if the source generator generates code that refers to your handler type, it will no longer be flagged as having no references!

Don't miss a thing. Sign up today and we'll send you an email when new posts come out.
Thank you for subscribing. We'll be in touch soon.
 
We collect and use this information in accordance with our privacy policy.