Services
Annotate a class with [Service] and the generator registers it in DI — with conventional contract and lifetime resolution — through the module's ConfigureDefaultServices.
A service is any module-owned class you want in DI that is not a handler
or scheduled job. [Service] is an auto-detected application
pattern: annotate the class and Elarion emits its registration and wires it into the owning module
automatically — you never hand-write an Add…() call or touch a service collection.
using Elarion.Abstractions;
namespace MyApp.Application.Modules.Clients.Services;
public interface IClientNumberGenerator {
string Next();
}
[Service]
public sealed class ClientNumberGenerator : IClientNumberGenerator {
public string Next() => "C-001";
}That is the whole opt-in. The class implements one interface, so it registers against
IClientNumberGenerator with the default Scoped lifetime, and any handler or other service in the
module can inject IClientNumberGenerator.
How registration is wired
ModuleServiceRegistrationGenerator runs when [assembly: UseElarion] or
[assembly: GenerateModuleServices] is present. It emits a per-service registration helper and an
aggregating Add{Module}Services() per module (longest-prefix namespace match places each service in
its module). You never call these directly: the generator also contributes the AddServices filler
to the module's generated ConfigureDefaultServices(IServiceCollection), which the host bootstrapper
invokes — gated by IsModuleEnabled — for every [AppModule]. So a [Service] in a disabled feature
module disappears alongside its handlers, jobs, and consumers.
ConfigureDefaultServices aggregates all of a module's discovered registrations (handlers, services,
validation metadata, scheduled jobs, event consumers, module API); a module's hand-written ConfigureServices
is reserved for the non-generated registrations only. See
Modules and Source generation.
A service whose namespace falls under no [AppModule] is not picked up by any module aggregation, so
it never reaches DI. Keep [Service] classes inside a module's namespace.
Contract resolution
The generator decides which contract(s) to register the implementation against, in order:
[Service(typeof(...))] lists service types, those are used. Each must be assignable from the implementation, or the generator reports ELSG002.When more than one contract is registered, the first is the concrete anchor and the rest resolve to the same instance, so all contracts share one object per scope. Pin the contracts explicitly when a class implements several interfaces but should only be injectable through some of them:
[Service(typeof(IClock), Scope = ServiceScope.Singleton)]
public sealed class SystemClock : IClock, IDisposable {
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
public void Dispose() { }
}Lifetime
The default scope is Scoped. Override it with the Scope property; ServiceScope has Scoped,
Singleton, and Transient:
[Service(typeof(IClock), Scope = ServiceScope.Singleton)]
public sealed class SystemClock : IClock { /* ... */ }Use Singleton only when the service and its dependencies are thread-safe and hold no scoped state
such as a DbContext. For the full contract and lifetime rules, see
Conventions.
Generic service implementations are rejected (ELSG003) until
open-generic aliasing semantics are defined. Register open generics manually for now.
Hosted services
A class that implements IHostedService or derives from BackgroundService is auto-detected as a
hosted service. Hosted services must be singletons — a scoped or transient hosted service is an
error (ELSG001).
The generator registers the concrete service (and any non-IHostedService contracts) and adds a
singleton IHostedService forwarder that resolves the same instance, so the host runs its
StartAsync/StopAsync while ordinary consumers inject the typed contract:
public interface IMailboxPollingService {
Task PollNowAsync(CancellationToken cancellationToken);
}
[Service(typeof(IMailboxPollingService), Scope = ServiceScope.Singleton)]
public sealed class MailboxPollingService : IMailboxPollingService, IHostedService {
Task IHostedService.StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
Task IHostedService.StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task PollNowAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}Implementing IHostedService explicitly (as above) keeps the lifecycle methods off the public
contract, so callers see only PollNowAsync. IHostedService is never registered as an injectable
contract itself — only as the lifecycle forwarder.
For recurring or delayed background work, prefer scheduled jobs over a
hand-rolled BackgroundService loop. Scheduled jobs share one scheduler with explicit overlap,
misfire, and resilience policies plus telemetry.
Module services vs. contract sets
[Service] is deliberately module-shaped: registrations are aggregated per [AppModule] and
invoked gated on Modules:{Name}:Enabled, so a feature module's services disappear with the module.
Both properties are wrong for a class of registrations module-less host assemblies have:
infrastructure seams — N implementations of one contract composed into a routing or dispatch
table at boot (protocol packet bindings, codec catalogs, pipeline stages). There is no module to own
them, and a config gate that silently empties a transport's dispatch table while the listener stays
up produces a server that boots deaf.
For these, declare a contract set: author the composition method you already call — a
static partial extension method — and let the generator fill in the body:
public static partial class PacketBindingRegistrations {
[GenerateContractSetRegistration(typeof(IPacketBinding))]
public static partial IServiceCollection AddPacketBindings(this IServiceCollection services);
}You name and place the method, so go-to-definition works and no naming convention is involved; the
generator finds every non-abstract, non-generic class in the declaring assembly that is assignable
to the contract and implements the method, registering each implementation with TryAddEnumerable
(calling it twice never duplicates the set) at Singleton scope by default (Scope overrides).
Discovery is compilation-local by design: the assembly that declares the seam owns its
implementations; a host wanting external ones registers them explicitly.
When do you use which?
[Service]— feature-gated module services, pushed by the gated module bootstrapper. The registration lives and dies with the module.- Contract sets — unconditional infrastructure seams, pulled by the host from its composition
root, exactly once. Nothing gates the composition; the registry consuming
IEnumerable<TContract>validates the set at startup and is free to fail loudly on an invalid one. An empty set is a warning at compile time (ELSG015) — almost always a typo'd contract or refactor casualty.
A type must pick one mechanism per contract: an implementation that also carries [Service]
resolving to the same contract would register twice — once gated, once not — and reports
ELSG018. See
[GenerateContractSetRegistration]
for the full parameter and diagnostic reference.
Reacting to events from a service
A [Service] can host a lightweight event consumer for a small side effect: an instance method
annotated with [ConsumeEvent]. The marker interface on the event selects the plane
(IDomainEvent → inline; IIntegrationEvent → after commit), and the method is always a fan-out
subscriber returning void/Task/ValueTask — the event bus is pub/sub-only. Optional
IEventContext and CancellationToken parameters are supplied by the runtime:
[Service]
public sealed class ClientNotifier {
[ConsumeEvent]
public Task OnClientCreated(ClientCreated @event, CancellationToken cancellationToken)
=> Task.CompletedTask;
}[ConsumeEvent] is only valid on a [Service] class for the method form — a consumer method elsewhere
reports ELEVT001. Unlike the handler form, the method form runs no
decorator pipeline: reach for a handler-based consumer when the consumer is first-class business
logic that needs tracing, validation, resilience, or cache invalidation. See
Consuming events.
Modules
A module is an application boundary marked with [AppModule]. Its handlers, services, validation metadata, scheduled jobs, and event consumers are discovered and registered automatically, and feature-gated as one unit.
Validation
Two-tier request validation — DataAnnotations on the request DTO enforced at runtime and exported to every contract surface, business rules in the handler.