Conventions
The problem
Attributes are explicit, which is a virtue right up until you have forty of them saying the same thing:
[TransientService] public class CreateOrderHandler : IRequestHandler<CreateOrder, OrderId> { }
[TransientService] public class RenameOrderHandler : IRequestHandler<RenameOrder, OrderId> { }
[TransientService] public class ShipOrderHandler : IRequestHandler<ShipOrder, Unit> { }
// … thirty-seven moreNothing here is a decision. Every handler is transient because every handler is transient, and the only real event is the day someone writes the forty-first and forgets the attribute. You are back to the hand-maintained list, just spread across forty files instead of gathered in one.
How DependencyModules helps
State the rule once, and let the generator find the types that fit while it builds:
using DependencyModules.Runtime.Conventions;
[DependencyModule]
public partial class DataModule : IConventionModule {
void IConventionModule.Conventions(IConventionDefinitions conventions) {
conventions.RegisterAll(typeof(IRequestHandler<,>)).AsTransient();
}
}Forty registrations, one declaration, and the forty-first handler registers itself by existing.
Nothing extra to install: the contracts are part of DependencyModules.Runtime and the generator that reads them is part of DependencyModules.SourceGenerator, both of which you already have.
Explicit or implicit, either compiles
void IConventionModule.Conventions(…) as above, or an ordinary public void Conventions(IConventionDefinitions conventions) — both are matched. The explicit form is used when a type somehow carries both, since that is the one satisfying the interface.
The body never runs
This is the one genuinely surprising thing on this page, and everything else follows from it.
Conventions is read at compile time, not executed. The generator parses that method as source and works out what you asked for. It is a declaration that happens to be written in C# syntax.
Two consequences:
Only the calls documented on this page may appear in it. A loop, an if, a local variable or a call to your own helper method cannot be read, and is reported as DM0009 rather than silently ignored.
What comes out is ordinary registration code — one services.AddTransient(…) per match, sitting in your assembly. Turn on EmitCompilerGeneratedFiles and read it:
// generated
services.AddTransient(typeof(IRequestHandler<CreateOrder, OrderId>), typeof(CreateOrderHandler));
services.AddTransient(typeof(IRequestHandler<RenameOrder, OrderId>), typeof(RenameOrderHandler));What matches
A type matches when it declares the service type, or declares an interface that extends it:
public interface IAuditedRepository : IRepository { }
public class OrderRepository : IRepository { } // matches
public class AuditedOrders : IAuditedRepository { } // matches — IAuditedRepository extends IRepositoryAn interface declaring that it extends another is a deliberate statement that it is substitutable for it, so it counts.
Reaching the service type through a base class does not count, unless you ask for it:
public abstract class RepositoryBase : IRepository { }
public class ProductRepository : RepositoryBase { } // no match by default
conventions.RegisterAll<IRepository>().IncludeBaseClasses().AsScoped(); // now it matchesTurn it on for the common CreateOrderValidator : AbstractValidator<CreateOrder> shape, where the interface only ever arrives through a framework base class. Bear in mind that every future subclass of that base joins the convention too.
Attributes always win
A type carrying [SingletonService], [ScopedService], [TransientService] or [CrossWireService] is never a convention candidate, so an attribute is how you exempt one type from a rule that would otherwise catch it.
Neither is a [Decorator] — a decorator implements the interface it decorates, and it is not a service in its own right.
Open generics
An open generic cannot be written as a type argument, so use the Type overload. Each match is registered against the closed construction it actually implements:
public class CreateOrderHandler : IRequestHandler<CreateOrder, OrderId> { }
public class RenameOrderHandler : IRequestHandler<RenameOrder, OrderId> { }
conventions.RegisterAll(typeof(IRequestHandler<,>)).AsTransient();// generated
services.AddTransient(typeof(IRequestHandler<CreateOrder, OrderId>), typeof(CreateOrderHandler));
services.AddTransient(typeof(IRequestHandler<RenameOrder, OrderId>), typeof(RenameOrderHandler));A type implementing several closings is registered against all of them:
public class OrderEvents
: INotificationHandler<OrderPlaced>, INotificationHandler<OrderShipped> { }Both are registered. They are different service types, so this is not one implementation registered twice.
A generic implementation that closes nothing registers as the open generic, and the container closes it per request:
public class PassThroughCache<T> : ICache<T> { } // registers ICache<> itselfNarrowing what matches
A service type is often too broad on its own. Filters chain, and combine with and; alternatives go inside a single call:
conventions.RegisterAll<IRepository>()
.InNamespaceOf<OrderMarker>() // and: in this namespace or below it
.WithoutName("*Legacy") // and: not named like this
.WithAttribute<AuditedAttribute>() // and: carrying this attribute
.AsScoped();| Filter | Matches |
|---|---|
InNamespaceOf<TMarker>() | the marker's namespace and those beneath it |
InNamespaces(params string[]) | the given namespaces and those beneath them |
InExactNamespaces(params string[]) | only those namespaces, not nested ones |
NotInNamespaceOf<TMarker>(), NotInNamespaces(…) | excludes; applied after inclusions |
WithAttribute<T>(), WithoutAttribute<T>() | the attribute type, resolved rather than name-matched |
WithName(params string[]), WithoutName(…) | name globs — see below |
Namespace and name inclusions of the same kind combine with or. Exclusions are applied afterwards, and any one of them removes a match.
Name globs
Two wildcards, and no regular expressions:
| Token | Matches |
|---|---|
* | zero or more characters |
? | exactly one character |
A pattern containing a dot is matched against the full Namespace.TypeName; otherwise against the bare type name. Matching is ordinal and case-sensitive, like C# identifiers.
conventions.RegisterAll<IRepository>().WithName("*Repository", "*Store").AsScoped();Prefer a service type, an attribute or a namespace wherever you can. A name pattern will cheerfully match a class somebody adds next year — and *Handler matches LoggingHandler too.
Registering types that implement nothing
Some things worth registering implement no interface at all. RegisterAll() with no service type selects by filter alone:
conventions.RegisterAll()
.InNamespaceOf<OrderMarker>()
.WithName("*Calculator")
.AsSelf()
.AsScoped();Because there is no interface to constrain it, this form requires a shape and at least one filter. Missing either is DM0009.
What each match is registered as
| Call | Registers |
|---|---|
| (default) | the service type the convention matched |
AsSelf() | the match's own concrete type, instead of the interface |
AlsoAsSelf() | the matched service type and the concrete type, sharing one instance |
AsSelfWithInterfaces() | the concrete type and every interface it implements, sharing one instance |
AsMatchingInterface() | the interface named after the type — Foo as IFoo |
As<TService>() | one named service type, whatever the match matched through |
One instance or several
This is the distinction that catches people out with every scanning library, so it is worth being explicit about.
conventions.RegisterAll<IFoo>().AsSingleton(); // one registration
conventions.RegisterAll<IBar>().AsSingleton(); // another, same classA class matched through two different interfaces gets two registrations and two instances. That is what Scrutor and MediatR both produce, and for handlers it is usually what you want.
When you want one instance reachable through several service types, say so:
conventions.RegisterAll(typeof(IValidator<>)).IncludeBaseClasses().AlsoAsSelf().AsScoped();AlsoAsSelf() and AsSelfWithInterfaces() both cross-wire — resolving any of the registered service types gives the same instance. The difference is reach: AlsoAsSelf() registers only the interfaces the convention matched, while AsSelfWithInterfaces() registers everything the type implements.
AsSelfWithInterfaces skips System interfaces
Interfaces in System or a namespace beginning System. are not expanded into, so a type whose base implements IDisposable does not become resolvable as IDisposable.
This applies only to the automatic expansion. A service type you name yourself is always honoured, so RegisterAll<IDisposable>() still registers IDisposable.
Lifetime, keys and registration strategy
A lifetime is required; there is no default. Omitting one is DM0009 rather than a silent transient.
conventions.RegisterAll<IRepository>()
.AsScoped()
.Using(RegistrationType.Try) // Add, Try, TryEnumerable or Replace
.WithKey("primary"); // literal, const or enum memberRegistering only in some environments
A convention can carry an environment condition, so a whole rule applies only where you want it rather than needing the attribute repeated on every class it matches:
conventions.RegisterAll<IDiagnostic>().IfEnvironment("Development").AsScoped();
conventions.RegisterAll<IAuditSink>().IfEnvironmentValue("AUDIT", "on").AsSingleton();| Call | Registers when |
|---|---|
IfEnvironment(params string[]) | the environment name matches any of them |
IfNotEnvironment(params string[]) | it matches none of them |
IfEnvironmentValue(key) · IfEnvironmentValue(key, value) | the key is present, or equals exactly |
IfNotEnvironmentValue(…) | the inverse of either form |
The test runs when the modules are applied, not while the build runs — so this changes what gets registered, not what the convention matched. Every match is still emitted, behind the same guard.
A class carrying its own condition combines with the convention's using and, so neither declaration can quietly override the other:
conventions.RegisterAll<IFoo>().IfEnvironment("Development").AsSingleton();
[IfEnvironmentValue("REGION", "eu")]
public class EuFoo : IFoo { } // Development AND REGION=euWhen two conventions collide
Two conventions in one module registering the same implementation under the same service type is DM0004, an error — the lifetime would be ambiguous:
conventions.RegisterAll<IRepository>().AsScoped();
conventions.RegisterAll<IRepository>().AsSingleton(); // DM0004A type filling two different roles is not a collision, and registers as both:
public class OrderEvents : INotificationHandler<OrderPlaced>, IRequestPreProcessor<ShipOrder> { }
conventions.RegisterAll(typeof(INotificationHandler<>)).AsTransient();
conventions.RegisterAll(typeof(IRequestPreProcessor<>)).AsTransient(); // fineConventions in different modules never collide, because each registers into its own realm.
What conventions will not do
Anything needing a lambda over the matched types — a predicate, or a lifetime chosen per type — cannot be expressed, because the declaration is read rather than run. There is no way to evaluate your code at compile time.
Use IServiceCollectionConfiguration for those, alongside your conventions:
[DependencyModule]
public partial class DataModule : IConventionModule, IServiceCollectionConfiguration {
void IConventionModule.Conventions(IConventionDefinitions conventions) {
conventions.RegisterAll<IRepository>().AsScoped();
}
public void ConfigureServices(IServiceCollection services) {
// unrestricted access to IServiceCollection, at run time
}
}Next
- Scanning a package — matching types in an assembly you do not own
- Convention API reference — every call in one table
- Diagnostics — what each DM code means