You are probably doing dependency injection registration wrong!

What’s wrong?

A mistake I’ve seen a few times is when apps that consume injectable dependencies take on the responsibility of registering those dependencies. I consider this a mistake because at the point you need a new project (such as an Azure Functions App) you then need to register all the same dependencies – meaning you must duplicate code or move the registration to shared code.

Another common problem we’ve probably all experienced is when we create a new injectable class and then forget to register it – we run the app, it crashes, then we fix the problem. Or worse, when the dependent class depends on an IEnumerable<T> and expects multiple – in which case you might not even notice it wasn’t registered.

So what should I do about it?

These things annoy me, and I don’t like the extra effort, so I created AutoRegister.

1: Build-time scanning
2: No runtime reflection
3: Code-generated registration
4: ZERO runtime dependencies!

And this is how you use it.

1: Add a package reference to Fody to your project containing your injectable dependencies.
2: Add a package reference to Morris.AutoRegister.Fody
3: Add a class to the project like so

public partial class MyServices{}

4: Decorate that class with [AutoRegister] attributes to define what you want it to register. As a very basic example, if you had the following

public interface IScopedService {}
public class Scoped1 : IScopedService {}
public class Scoped2 : IScopedService {}

You can register Scoped1 and Scoped2 like so

[AutoRegister(Find.Exactly, typeof(IScopedService), RegisterAs.ImplementingClass, WithLifetime.Scoped)]
public partial class MyServices {} 

5: In your consuming app, call MyServices.RegisterServices, like so

BusinessLayer.MyServices.RegisterServices(builder.Services); 

And that’s it!

Whenever you add a new class implementing IScopedService, it will be registered.

With the [AutoRegister] attribute you specify

A: What you want to search for (class / interface)
B: How you want to register the implementing classes that match the criteria.

Here’s another example that doesn’t require a marker interface like IScopedService. This example registers by the convention that any class implementing an interface with a name ending Repository should be registered as Scoped.

public interface ICustomerRepository { }
public class CustomerRepository : ICustomerRepository { }
public interface IOrderRepository { }
public class OrderRepository : IOrderRepository { }

[AutoRegister(
    Find.DescendantsOf,
    typeof(object), // Any type of object
    RegisterAs.FirstDiscoveredInterfaceOnClass, // Register as interface
    WithLifetime.Scoped,
    ServiceTypeFilter = @"Repository$")] // Interface names ending with "Repository"
public partial class ServicesRegistration { }

What to find

  • Find.Exactly
    As in the previous example, the implementing class must either implement the exact type specified or be the exact type.
  • Find.DescendantsOf
    As above, but only descendants will be considered and not the type specified. The following would register classes implementing interfaces descending from IRepository<T> such as IPersonRepository or ICustomerRepository.
[AutoRegister(Find.DescendantsOf, typeof(IRepository<>), ....)] 
  • Find.AnyTypeOf
    Will match the exact type specified and also descendants.

How you want the service registered

  • ImplementingClass
    As in the earlier example, the type of the class implementing service will be used in the service registration. This is useful when you inject classes rather than interfaces and want to use a marker interface to indicate what to register.
  • SearchedType
    The type specified in the attribute will be used as the service type. This is useful when your consuming class expects an enumerable of <TService>.
[AutoRegister(Find.Exactly, typeof(IDiscountStrategy), RegisterAs.SearchedType, ...)]

Then in the consumer

public class ConsumerClass
{
  ... inject via the constructor
  private readonly IEnumerable<IDiscountStrategy> DiscountStrategies;

  public decimal CalculateDiscount(Order order, Customer customer) =>
	DiscountStrategies.Select(x => x.GetDiscount(order, customer)).Max();
} 
  • SearchTypeAsClosedGeneric
    When the type specified in the attribute is an open generic, then the implementing service will be registered using the closed generic, for example…
[AutoRegister(Find.DescendantsOf, typeof(IRepository<>), RegisterAs.SearchTypeAsClosedGeneric, ...)]

When it finds a CustomerRepository class that implements IRepository<Customer> it will use the service type IRepository<Customer> to register CustomerRepository.

  • FirstDiscoveredInterfaceOnClass
    The service type registered will be the first interface that is found on the class. If the search type is an interface, then it will be the first interface found other than the type specified (otherwise you’d just specify RegisterAs.SearchedType).

If your service class is likely to have more than one interface, you can specify an optional RegEx like so

[AutoRegister(......, ServiceTypeFilter = "Repository$")]

Which would only register the first interface that ended with the word Repository.

Lifetime

[AutoRegister(Find..., typeof(...), RegisterAs..., WithLifetime.Scoped)]
  • Singleton
  • Scoped
  • Transient

Filtering

You can specify optional RegEx properties to filter candidate classes and their keys.

  • ServiceImplementationFilter
    The full name of the service class must match this regex otherwise it won’t be considered as a candidate for injection.
  • ServiceTypeFilter
    The type that will be used to register the service must match this regex otherwise it won’t be considered as a candidate for injection.

Or, you can use the [AutoRegisterFilter] attribute for filtering out candidate classes for all [AutoRegister] attributes on the same class.

For example, if you have an OptionalServices namespace then you can exclude classes within it from being registered.

// Don't consider service implementations with .OptionalServices. in their namespace
[AutoRegisterFilter(@"^((?!\.OptionalServices\.).)*$")]
[AutoRegister(.....)]
[AutoRegister(.....)]
[AutoRegister(.....)]
public partial class MyServices()
{
}

// Only consider service implementations with .OptionalServices.PaymentProcessing in their namespace
[AutoRegisterFilter(@"\.OptionalServices\.PaymentProcessing\."
[AutoRegister(.....)]
[AutoRegister(.....)]
[AutoRegister(.....)]
public partial class PaymentProcessingServices
{
}

How AutoRegister works

When you decorate a class with [AutoRegister] or [AutoRegisterFilter] a Roslyn code generator will generate the following methods on that class.

static partial void AfterRegisterServices(IServiceCollection services);

public static void RegisterServices(IServiceCollection services)
{
  // Discovered services are registered here
  AfterRegisterServices(services);
}

When you build your project, AutoRegister will scan the project for classes matching the criteria and insert the code required to register them.

At runtime the code is executed as if you’d written it yourself.

  • No runtime reflection
  • No Roslyn generator scanning every class on every keypress

How do I know what’s registered?

AutoRegister also generates a CSV file called “YourProjectName.Morris.AutoRegister.csv” with the following columns

Module,Attribute,Scope,ServiceType,ServiceImplementation
  • Module
    The full name of the class you decorated with [AutoRegister] attributes
  • Attribute
    A description of the settings of the attribute
  • Scope
    Singleton/Scoped/Transient
  • ServiceType
    The name of the type you use to Resolve an instance of the service
  • ServiceImplementation
    The class that implements that type

What if someone doesn’t check the manifest file into source control?

If someone changes the app in a way that results in more/less/different registrations, then the manifest file is updated and can be inspected in pull requests to ensure no mistakes have been made. But what if the developer doesn’t include the manifest file in the PR?

You can check for it in your pipeline!

When your pipeline builds the project, a new manifest file will be generated. If the manifest file in source control matches the new binary, then git will report no changes to that file. If git says there are changes then someone has not checked in the manifest file.

* Azure

- script: |
    result=$(git status *.Morris.AutoRegister.csv --porcelain)
    if [ -n "$result" ]; then
      echo "Error: Uncommitted changes detected in .Morris.AutoRegister.csv files:"
      echo "$result"
      exit 1
    else
      echo "AutoRegister manifests are up to date."
    fi
  displayName: 'Ensure AutoRegister manifests are up to date'

* Github Actions

 - name: Ensure AutoRegister manifests are up to date
      run: |
        result=$(git status *.Morris.AutoRegister.csv --porcelain)
        if [ -n "$result" ]; then
          echo "Error: Uncommitted changes detected in .Morris.AutoRegister.csv files:"
          echo "$result"
          exit 1
        else
          echo "AutoRegister manifests are up to date."
        fi

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *