As a C# developer, you might have overlooked the ModuleInitializer attribute introduced in version 9 of the language. This attribute is a hidden gem in the extensive BCL APIs and designates a method that the runtime should first invoke when loading the assembly. Before delving into its practical application in class library projects, let’s clarify the difference between a module and an assembly.
Let’s start.
Module vs Assembly
To understand the core difference between a module and an assembly, it is important to note that a module cannot be deployed independently; instead, it is always part of an assembly.
On the other hand, an assembly serves as a logical unit that contains one or more modules and includes additional metadata that describes the types, resources, and references within the assembly. As the primary building block of a .NET application, an assembly provides the necessary information for the runtime to load and execute code.
In this context, we can consider a class within an assembly as a module. We can apply the ModuleInitializer
attribute to any method within that class, provided that the method fulfills specific prerequisites.
Let’s explore these prerequisites.
Rules of a ModuleInitializer Attribute Marked Method
To consider a method as a module initialization method, it must meet the following conditions:
- The method must be
static
- It should not have any parameters
- The method’s signature must be either
void
orasync void
- It cannot be generic or reside within a generic type
- The method must be accessible through the module’s
public
orinternal
access modifiers
Now that we have an understanding of what qualifies a method to be a module initializer, let’s delve into its application in a class library project.
How to Use the ModuleInitializer Attribute in a Class Library
The dependency injection principle is important for writing clean and loosely coupled code. We register our dependencies at the startup or entry point of the application. However, class library projects do not have a startup or entry point. To mitigate this, we can create extension methods to register dependencies for the classes used in the project. We can then invoke these extension methods in the application startup.
However, this implementation requires exposing contracts or interfaces to the library’s consumer (the application with the startup class), which is a flaw. What happens if our library’s consumer is another class library project, such as a Unit Test project?
Let’s see how we can overcome this limitation with the help of the ModuleInitiailizer attribute by doing a mock implementation of the Notification
component, which can send SMS or Email.
Firstly, we must add the required NuGet package to use Dependency Injection in our class library project:
dotnet add package Microsoft.Extensions.DependencyInjection
With the dependency now installed, let’s move ahead and create our interface responsible for sending notifications:
namespace MessagingLibrary; internal interface INotification { string SendNotification(); }
We define an interface INotification
with internal
access modifier, as we must hide our internal implementation from the library’s consumers. This interface provides a contract for implementing classes to send notifications and includes a SendNotification()
method that returns a string.
We can proceed with implementing the INotification
interface now:
namespace MessagingLibrary; internal class Email : INotification { public string SendNotification() { return "Sending Email"; } } internal class Sms : INotification { public string SendNotification() { return "Sending SMS"; } }
The MessagingLibrary
namespace contains two internal
classes, Email
and Sms
, which implements the INotification
interface and provide unique implementations for the SendNotification()
method.
Achieving Dependency Injection
Let’s move on to the Dependency Injection part and register the dependencies and decorate it with the ModuleInitializer
attribute:
internal static class DependenciesRegistration { internal static ServiceProvider DependenciesProvider { get; private set; } [ModuleInitializer] [SuppressMessage("Usage", "CA2255:The \'ModuleInitializer\' attribute should not be used in libraries", Justification = "The consumer of the library is abstracted from this because of usage of Internal Access Modifier." + "Only used in this project for dependency registration.")] internal static void RegisterDependencies() { var services = new ServiceCollection(); services.AddScoped<INotification, Sms>(); services.AddScoped<INotification, Email>(); DependenciesProvider = services.BuildServiceProvider(); } }
The RegisterDependencies()
method registers dependencies using the ModuleInitializer
attribute. It abides by all the prerequisites for this attribute. It adds scoped dependencies for INotification
implementations of Sms
and Email
to the ServiceCollection
.
The dependencies are built and stored in the DependenciesProvider
property, allowing access to the registered dependencies within the assembly. As this method is decorated with the ModuleInitializer
attribute, this method will be executed when the library gets loaded.
Our internal implementation for sending notifications is complete. Now, we must create a public API for library consumers to use:
public class Notifier { private readonly INotification? _notification; private readonly IEnumerable? _notificationServiceLocator; public string NotificationResult { get; } public Notifier(string notificationType) { _notificationServiceLocator = DependenciesRegistration.DependenciesProvider.GetServices(); if (_notificationServiceLocator is not null) _notification = notificationType switch { "sms" => _notificationServiceLocator.OfType()<Sms>.FirstOrDefault(), "email" => _notificationServiceLocator.OfType()<Email>.FirstOrDefault(), _ => throw new ArgumentException("Invalid notification type") }; NotificationResult = _notification?.SendNotification()!; } }
The Notifier
class is responsible for sending notifications. In the constructor, we retrieve available implementations from the service provider and select the appropriate implementation based on the notification type using switch expression. Then we invoke the SendNotification()
method on the implementation chosen. The result is stored in the NotificationResult
property.
We have created a new public API for sending notifications, which separates our internal business logic from the library consumer. To test our library, we can now use unit tests to consume its APIs:
namespace Tests; public class NotifierUnitTest { [Fact] public void WhenSmsIsPassedAsParameter_ThenSmsMustBeSent() { var notifier = new Notifier("sms"); Assert.Equal("Sending SMS", notifier.NotificationResult); } [Fact] public void WhenEmailIsPassedAsParameter_ThenEmailMustBeSent() { var notifier = new Notifier("email"); Assert.Equal("Sending Email", notifier.NotificationResult); } [Fact] public void WhenInvalidParameterIsPassed_ThenExceptionMustBeThrown() { Assert.Throws<ArgumentException>(() => new Notifier("invalid")); } }
These test methods cover different scenarios to ensure that the Notifier
class behaves correctly when different notification types are provided as parameters. The tests help ensure the expected behavior of the Notifier
class and provide confidence in its functionality. All the tests are green, proving our approach works.
By utilizing the ModuleInitalizer attribute, we were able to initiate the code within the method where all dependencies were registered with the service provider. This accomplishment would not have been feasible without the use of this attribute since the class library lacks an entry point.
Conclusion
We’ve learned how to use the ModuleInitializer attribute on a method that the runtime executes when loading an assembly. This attribute can be helpful for executing initialization code in projects that lack entry points, such as class libraries.