Skip to content

Service Registration

This guide covers all the ways to register services in Pragmatic.Composition: the [Service] attribute family, decorators, factories, property injection, and assembly scanning.

[Service] — Attribute-Based Registration

Section titled “[Service] — Attribute-Based Registration”

The simplest way to register a service. The source generator emits compile-time registration code — no reflection at runtime.

[Service]
public class OrderService : IOrderService { /* ... */ }

Default behavior:

  • Lifetime: Scoped
  • Service type: First implemented interface
  • If the class implements no interfaces, the SG reports a diagnostic. Use AsSelf = true for concrete registration.

When a class implements multiple interfaces, use [Service<TInterface>] to pick one:

[Service<IPaymentProvider>(Key = "stripe")]
public class StripePaymentProvider : IPaymentProvider, IDisposable { /* ... */ }

This registers as IPaymentProvider only, ignoring IDisposable.

PropertyTypeDefaultDescription
LifetimeServiceLifetimeScopedSingleton, Scoped, or Transient
AsType?nullRegister as this type (prefer ServiceAttribute<T>)
AsSelfboolfalseRegister as concrete type instead of interface
Keystring?nullKeyed service registration (.NET 8+)

Defined in Pragmatic.Composition.Attributes (mirrors Microsoft.Extensions.DependencyInjection.ServiceLifetime):

ValueBehavior
SingletonOne instance shared across all requests
ScopedOne instance per scope (HTTP request) — default
TransientNew instance every time
// Scoped (default), registered as IOrderService
[Service]
public class OrderService : IOrderService { }
// Singleton, registered as concrete type
[Service(AsSelf = true, Lifetime = ServiceLifetime.Singleton)]
public class CacheWarmupService { }
// Keyed service (explicit interface via generic)
[Service<IPaymentProvider>(Key = "stripe")]
public class StripePaymentProvider : IPaymentProvider { }
// Transient
[Service(Lifetime = ServiceLifetime.Transient)]
public class NotificationFormatter : INotificationFormatter { }

Decorators wrap an existing service, adding cross-cutting behavior. They must implement the same interface as the service they decorate.

[Decorator(Order = 1)]
public class LoggingReservationPricingService(
IReservationPricingService inner,
ILogger<LoggingReservationPricingService> logger) : IReservationPricingService
{
public decimal CalculatePrice(Reservation reservation)
{
logger.LogInformation("Calculating price for reservation {Id}", reservation.Id);
var price = inner.CalculatePrice(reservation);
logger.LogInformation("Price calculated: {Price}", price);
return price;
}
}

The Order property determines decoration order:

  • Lower order = closer to the real service (applied first, called last on the way in)
  • Higher order = outermost (first to receive calls)
Call flow: Outer (Order=2) --> Inner (Order=1) --> RealService
Return: Outer (Order=2) <-- Inner (Order=1) <-- RealService
  • The decorator must implement the same interface as the decorated service.
  • The constructor must accept the inner service as a parameter (by its interface type).
  • Additional constructor parameters are resolved from DI.

For cases where attribute-based decoration is not suitable, use the Decorate<TService, TDecorator>() extension method:

// In IStartupStep.ConfigureServices
services.Decorate<IOrderService, CachingOrderService>();

Or with a factory:

services.Decorate<IOrderService>((inner, sp) =>
{
var logger = sp.GetRequiredService<ILogger<LoggingOrderService>>();
return new LoggingOrderService(inner, logger);
});

The Decorate methods live in ServiceCollectionDecorateExtensions in Pragmatic.Abstractions, so domain modules can use them without referencing ASP.NET Core.

[ServiceFactory] / [Factory] — Factory Methods

Section titled “[ServiceFactory] / [Factory] — Factory Methods”

For services that need custom construction logic, use factory classes:

[ServiceFactory]
public class InfrastructureFactories
{
[Factory(Lifetime = ServiceLifetime.Singleton)]
public IDbConnection CreateConnection(IConfiguration config)
{
var connStr = config.GetConnectionString("Default");
return new SqlConnection(connStr);
}
[Factory(Lifetime = ServiceLifetime.Scoped)]
public IFileStorage CreateStorage(IHostEnvironment env, ILogger<LocalDiskFileStorage> logger)
{
var basePath = Path.Combine(env.ContentRootPath, "uploads");
return new LocalDiskFileStorage(basePath, logger);
}
}

Rules:

  • [ServiceFactory] marks the container class. The class itself is registered as a singleton.
  • [Factory] marks individual factory methods.
  • The return type of each method becomes the registered service type.
  • Method parameters are resolved from DI.
  • FactoryAttribute.Lifetime controls the lifetime of the created service (default: Scoped).

[Inject] — Property and Method Injection

Section titled “[Inject] — Property and Method Injection”

For optional dependencies or post-construction initialization. The class must also be marked with [Service].

[Service]
public class NotificationService : INotificationService
{
// Optional -- null if IEmailSender is not registered
[Inject]
public IEmailSender? EmailSender { get; set; }
// Required -- throws if not available
[Inject(Required = true)]
public IMessageQueue Queue { get; set; } = null!;
// Keyed service
[Inject(Key = "priority")]
public IMessageQueue? PriorityQueue { get; set; }
}
PropertyTypeDefaultDescription
RequiredboolfalseThrow if service not resolvable
Keystring?nullKeyed service key
  • Prefer constructor injection for required dependencies (the normal pattern).
  • Use [Inject] for optional dependencies that may or may not be registered.
  • Use [Inject] to break circular dependency chains.
  • Use [Inject(Key = "...")] for keyed services on properties.

For convention-based registration without attributes. The fluent API is accessible via services.Scan():

services.Scan(scan => scan
.FromAssemblyOf<OrderService>()
.AddClasses(filter => filter.AssignableTo<ICommandHandler>())
.AsImplementedInterfaces()
.WithScopedLifetime());
MethodDescription
FromAssemblyOf<T>()Assembly containing type T
FromAssemblies(params Assembly[])Explicit assembly list
FromAssembliesMatching(string pattern)Glob pattern on DLL filenames (e.g., "MyApp.*")
FromCallingAssembly()Calling assembly
FromEntryAssembly()Entry assembly
FromDependencyContext(predicate?)DependencyContext.Default runtime libraries
FromDependencyContext(string prefix)Libraries whose name starts with prefix

Applied within AddClasses(filter => ...):

MethodDescription
AssignableTo<T>()Implements T (supports open generics like IRepository<>)
AssignableTo(Type type)Same, non-generic
WithAttribute<T>()Has specific attribute
InNamespace(string ns)Exact namespace match
InNamespaceOf<T>()Namespace starts with T’s namespace
Where(Func<Type, bool>)Custom predicate
NotWhere(Func<Type, bool>)Exclude by predicate

Filters are composable — they chain as AND conditions:

scan.FromAssemblyOf<OrderService>()
.AddClasses(f => f
.AssignableTo<IEventHandler>()
.InNamespace("MyApp.Booking.Handlers")
.NotWhere(t => t.Name.Contains("Test")))
.AsImplementedInterfaces()
.WithScopedLifetime();
MethodDescription
AsImplementedInterfaces()Register for all implemented interfaces
AsSelf()Register as concrete type
AsSelfWithInterfaces()Register as concrete type AND all interfaces
As<T>()Register as specific type
As(Type type)Register as specific type (non-generic)
As(Func<Type, Type> selector)Custom type selector
AsMatchingInterface()Register as I{ClassName} (convention-based)

Control what happens when a service type is already registered:

scan.FromAssemblyOf<OrderService>()
.AddClasses(f => f.AssignableTo<IRepository>())
.AsImplementedInterfaces()
.UsingRegistrationStrategy(RegistrationStrategy.Skip)
.WithScopedLifetime();
StrategyBehavior
AppendAlways add (default). Multiple implementations for same interface.
SkipSkip if service type already registered
ReplaceRemove existing registrations, add new one
ThrowThrow InvalidOperationException on duplicate

For registering specific types without filtering:

scan.FromAssemblyOf<OrderService>()
.AddType<SpecialService>()
.AsSelf()
.WithSingletonLifetime();
scan.FromAssemblyOf<OrderService>()
.AddTypes<ServiceA, ServiceB, ServiceC>()
.AsImplementedInterfaces()
.WithScopedLifetime();

The Scan method accepts an optional error callback for logging assembly load failures:

services.Scan(
scan => scan.FromAssembliesMatching("MyApp.*").AddClasses().AsImplementedInterfaces().WithScopedLifetime(),
onScanError: (message, ex) => logger.LogWarning(ex, message));

For conditional registration (add only if not already registered):

services.TryAddService<IOrderService, OrderService>(ServiceLifetime.Scoped);

This is useful for default implementations that should be overridable.

FeatureAttribute-Based (SG)Scanning (Runtime)
WhenCompile-timeRuntime (startup)
ReflectionNoneAssembly scanning uses reflection
Cross-assemblyVia [PragmaticMetadata]Via assembly loading
Best forKnown servicesConvention-based bulk registration
PerformanceZero overheadSmall startup cost

Recommendation: Use [Service] and [Decorator] for all known services. Use scanning only for convention-based scenarios where attribute decoration is impractical.