Why aspects resisted dependency injection
Two properties of PostSharp aspects made them awkward to combine with a container:
- They are instantiated at build time. The aspect object is created by the compiler, serialized into the assembly, and deserialized at run time. No container is involved, so no container can inject anything into it.
- They usually have a static scope. Unless it implements
IInstanceScopedAspect, an aspect instance is stored in a static field, even when it is applied to an instance method. It is shared by all the objects of the class.
So the usual pattern was a global service locator, resolved when the aspect is initialized at run time:
[PSerializable]
public sealed class AuditedAttribute : OnMethodBoundaryAspect
{
private static IAuditLog auditLog;
public override void RuntimeInitialize( MethodBase method )
{
auditLog = ServiceLocator.Current.GetInstance<IAuditLog>();
}
public override void OnSuccess( MethodExecutionArgs args )
{
auditLog.Write( "Executed " + args.Method.DeclaringType.Name + "." + args.Method.Name + "." );
}
}
Caching was configured the same way, once for the whole process, at startup:
CachingServices.DefaultBackend = new MemoryCachingBackend();
This works, and it has run in production for years. It also has costs that grow once the rest of the application uses a container:
- Hidden dependencies.
OrderServiceneeds anIAuditLog, but nothing in its constructor says so. You find out when the locator throws. - Initialization order. The locator must be ready before the first enhanced class is used, which rules out the entry point and makes startup fragile.
- One global per process. Two tests, or two hosts in the same process, share the same audit log and the same cache. Scoped services, such as a per-request
DbContext, are out of reach.
There was a second pattern, closer to dependency injection. An instance-scoped aspect introduced a property into the target class, copied an [Import] attribute onto it, and read it back with [ImportMember]:
[PSerializable]
public sealed class AuditedAttribute : OnMethodBoundaryAspect, IInstanceScopedAspect
{
[IntroduceMember( Visibility = Visibility.Family, OverrideAction = MemberOverrideAction.Ignore )]
[CopyCustomAttributes( typeof(ImportAttribute) )]
[Import( typeof(IAuditLog) )]
public IAuditLog AuditLog { get; set; }
[ImportMember( "AuditLog", IsRequired = true )]
public Property<IAuditLog> AuditLogProperty;
public override void OnSuccess( MethodExecutionArgs args )
{
this.AuditLogProperty.Get().Write( "Executed " + args.Method.Name + "." );
}
// CreateInstance and RuntimeInitializeInstance omitted.
}
This worked with MEF and the other attribute-based frameworks of the time, which set the properties of an object after creating it. It does not work with Microsoft.Extensions.DependencyInjection or any other constructor-based container: they pass every dependency to the constructor and never look at properties, and an aspect had no way to add a parameter to a constructor.
Pulling dependencies from constructors
This is what PostSharp 2027.0 adds: aspects themselves can pull their dependencies from the constructor. An aspect requests a service by returning an ImportDependencyAdviceInstance from IAdviceProvider.ProvideAdvices. PostSharp then adds a parameter of the service type to the constructors of the target class, and the advices receive the service through an [ImportMember] parameter.
Here is the same Audited aspect, extended to request three services: a required IAuditLog, an optional IMetrics, and an INotifier that is resolved only when it is first used:
[PSerializable]
public sealed class AuditedAttribute : MethodLevelAspect, IAdviceProvider
{
public IEnumerable<AdviceInstance> ProvideAdvices( object targetElement )
{
// Required: the constructor fails when the service is not available.
yield return new ImportDependencyAdviceInstance( typeof(IAuditLog) );
// Optional: the dependency can be null.
yield return new ImportDependencyAdviceInstance( typeof(IMetrics) ) { IsRequired = false };
// Lazy: the service is resolved when the advice first uses it.
yield return new ImportDependencyAdviceInstance( typeof(INotifier) ) { IsLazy = true };
}
[OnMethodSuccessAdvice]
public static void OnSuccess( [Declaration] MethodBase method,
[ImportMember] IAuditLog auditLog,
[ImportMember] IMetrics? metrics,
[ImportMember] INotifier notifier )
{
string name = method.DeclaringType!.Name + "." + method.Name;
auditLog.Write( "Executed " + name + "." );
metrics?.Increment( name );
notifier.Notify( name + " succeeded." );
}
}
The advice is a static method with bound parameters, as described in our previous post on advice parameter binding. The aspect remains statically scoped: it holds no service. The services live in the object that the container created.
The class that uses the aspect declares only its own dependency:
public class OrderService
{
private readonly IOrderRepository repository;
public OrderService( IOrderRepository repository )
{
this.repository = repository;
}
[Audited]
public void PlaceOrder( int orderId ) => this.repository.Save( orderId );
}
And the composition root registers the services as usual:
var services = new ServiceCollection();
services.AddSingleton<IAuditLog, ConsoleAuditLog>();
services.AddSingleton<IMetrics, ConsoleMetrics>();
services.AddSingleton<INotifier, ConsoleNotifier>();
// Microsoft.Extensions.DependencyInjection does not create Lazy<T> by itself.
services.AddTransient( provider => new Lazy<INotifier>( provider.GetRequiredService<INotifier> ) );
services.AddSingleton<IOrderRepository, OrderRepository>();
services.AddTransient<OrderService>();
serviceProvider.GetRequiredService<OrderService>() returns an OrderService with all four services. The notifier is created only when PlaceOrder succeeds for the first time.
What PostSharp generates
The container can pass the services because the build changed the constructors of OrderService. Here is the woven class, decompiled and cleaned up (we renamed the generated fields and removed the compiler attributes):
public class OrderService
{
private readonly IOrderRepository repository;
private readonly IAuditLog auditLog;
private readonly IMetrics metrics;
private readonly Lazy<INotifier> notifier;
// The original constructor, now a forwarder.
protected internal OrderService( IOrderRepository repository )
: this( repository, null, null, null )
{
}
// The enhanced constructor, the only public one.
public OrderService( IOrderRepository repository,
IAuditLog auditLog,
IMetrics metrics = null,
Lazy<INotifier> lazy )
{
this.auditLog = auditLog ?? RunTimeServiceLocator.GetRequiredService<IAuditLog>();
this.metrics = metrics ?? RunTimeServiceLocator.GetService<IMetrics>();
this.notifier = lazy ?? new Lazy<INotifier>( RunTimeServiceLocator.GetRequiredService<INotifier> );
this.repository = repository;
}
protected IAuditLog AuditLog => this.auditLog;
protected IMetrics Metrics => this.metrics;
protected INotifier Notifier => this.notifier.Value;
public void PlaceOrder( int orderId )
{
this.repository.Save( orderId );
AuditedAttribute.OnSuccess( /* the MethodBase of PlaceOrder */, this.AuditLog, this.Metrics, this.Notifier );
}
}
The transformation follows a few rules:
- One public constructor. The original constructor keeps its body, attributes and parameters in the enhanced constructor, which receives the services as extra parameters. The original signature becomes a
protected internalforwarder, so the container sees one public constructor and has no ambiguity to resolve. - The fields are assigned first. The services are stored right after the call to the base constructor, before the body of your constructor runs, as C# would do for a field initialized from a parameter.
- Your code wins. If the class already has a field, a property or a constructor parameter of exactly the service type, PostSharp uses it and adds nothing. This is how you give the class itself access to the same service.
- Derived classes follow. The classes that derive from
OrderService, in the same project or in another project that PostSharp processes, receive the dependencies of their base class, and theirbase(...)calls pass them. Derived classes can also read the services through theprotectedproperties. - One field per service. When the aspect is applied to ten methods of the class, the class still gets one field and one constructor parameter per service type.
Migrate at your own pace
An application that has been in production for ten years does not move to a container in one commit. Some objects will come from the container long before the others stop using new. PostSharp supports both.
When the source code calls new OrderService( repository ), it calls the forwarder, which passes null for each service. The enhanced constructor then gets the service from RunTimeServiceLocator, a process-wide registry of service factories that PostSharp 2027.0 adds. Register the services there at startup, from your old composition code or from the new container:
RunTimeServiceLocator.RegisterService<IAuditLog>( () => new ConsoleAuditLog() );
RunTimeServiceLocator.RegisterService<INotifier>( () => new ConsoleNotifier() );
new OrderService( new OrderRepository() ).PlaceOrder( 3 );
A required service that nobody registered fails at construction with a message that says what to do:
No service of type MyApp.IAuditLog is registered in RunTimeServiceLocator. Register it with RunTimeServiceLocator.RegisterService, or pass it to the constructor.
An optional service that nobody registered stays null, which is why the advice above writes metrics?.Increment. A lazy service is resolved, and fails if needed, when the advice first uses it.
RunTimeServiceLocator is a bridge for the objects created with new, and it is deliberately limited: it has no scopes and does not dispose anything. As more of the application moves to the container, fewer objects need it.
Caching receives its cache from the container
PostSharp Caching is the first library of ours that uses this mechanism. Set UseDependencyInjection on [CacheConfiguration], on the assembly or on a class, and the classes with cached methods receive an ICachingService from their constructors:
[CacheConfiguration( UseDependencyInjection = true )]
public class ProductCatalog
{
private readonly IProductRepository repository;
public ProductCatalog( IProductRepository repository )
{
this.repository = repository;
}
[Cache( IgnoreThisParameter = true )]
public Product GetProduct( int id ) => this.repository.Load( id );
[InvalidateCache( nameof(GetProduct) )]
public void RenameProduct( int id, string name ) => this.repository.Rename( id, name );
}
The cache, its backend and its profiles are now configured in the composition root, next to the services they depend on, such as the IMemoryCache of the container:
services.AddMemoryCache();
services.AddSingleton<CachingBackend>( provider => new MemoryCacheBackend( provider.GetRequiredService<IMemoryCache>() ) );
services.AddSingleton<ICachingService>( provider =>
{
var cachingService = new CachingService( provider.GetRequiredService<CachingBackend>() );
cachingService.Profiles.Default.AbsoluteExpiration = TimeSpan.FromMinutes( 10 );
return cachingService;
} );
services.AddScoped<ProductCatalog>();
Each container now has its own cache, disposed with the container, so two tests in the same process no longer see each other’s cached items. A test can also replace ICachingService with its own implementation.
Static methods, and methods of structs, have no constructor to receive the service. They keep using CachingServices.DefaultService, the process-wide service that replaces DefaultBackend, and PostSharp reports the warning CAC011 so that you know which ones. Raise it to an error if every cached method must use the container.
Try it
Dependency injection is available from PostSharp 2027.0.6-preview. The documentation covers both halves of the feature:
- Receiving Dependencies from the Constructors of the Target Class for your own aspects.
- Using Caching with Dependency Injection for PostSharp Caching.
See What’s New in PostSharp 2027.0 for the rest of the release. Pick the aspect that holds your oldest service locator, convert it, and tell us how it went on GitHub.
Happy meta-programming with PostSharp!