Tags and conditional registration
Register or map only part of an assembly by tagging items and filtering at the call site.
Four packages support tags: Immediate.Handlers, Immediate.Apis, Immediate.Injections and Immediate.Jobs. The mechanism is shared, and the semantics are easy to get backwards, so they are stated precisely here. Package-specific examples live on each package’s tagged-registration page.
The use case is one assembly, several hosts. A web host maps the HTTP endpoints; a worker host registers the background handlers; both share the same shared services.
Declaring tags
Tags are declared on the item, as a string array:
[Handler(Tags = ["worker"])]
public sealed partial class ProcessOutboxCommand { /* … */ }
[Handler(Tags = ["fulfillment"]), Job]
public sealed partial class ReserveInventoryJob { /* … */ }
[MapGet("/api/todos", Tags = ["web"])]
public sealed partial class GetTodosQuery { /* … */ }
[RouteGroup("/api/admin", Tags = ["admin"])]
public static partial class AdminGroup { }
[RegisterScoped(Tags = ["worker"])]
public sealed class OutboxProcessor { /* … */ } Filtering at the call site
Filtering happens where you register, not where you declare:
// Worker host — background handlers and their services only
services.AddTodoHandlers(tags: ["worker", "fulfillment"]);
services.AddTodoServices("worker");
services.AddTodoJobs(tags: ["fulfillment"])
.ConfigureStorage(storage => storage.UseInMemory());
// Web host — HTTP endpoints only
app.MapTodoEndpoints(tags: "web");The four rules
- Calling with no tags registers everything, tagged items included. Tags are a filter you opt into, not a gate that hides items by default.
- Untagged items are always registered, whatever tags you pass. There is no way to exclude an untagged item by tagging the others.
- Matching is ordinal string equality. No wildcards, no prefixes, no
case-insensitivity —
"Web"does not match"web". - An item matches if it carries any of the tags you passed.
Rules 1 and 2 together mean tags are additive: they let you pull in an extra slice of the assembly, not carve one out. If a handler must never be registered in the web host, tagging is the wrong tool — put it in a different assembly.
The tags parameter signature
The generated parameter adapts to your project’s C# language version:
| Language version | Signature |
|---|---|
| C# 13 and later | params ReadOnlySpan<string> |
| C# 12 and earlier | params string[] |
Both are params, so the call site is identical either way — AddTodoServices("web", "admin").
The difference only shows if you pass a pre-built collection, where the span form will not accept
a List<string> directly.
AddXxxHandlers takes a lifetime parameter before tags, so tags there are usually passed by
name:
services.AddTodoHandlers(ServiceLifetime.Scoped, "worker");
services.AddTodoHandlers(tags: "worker"); // lifetime defaults to Scoped MapXxxEndpoints takes an optional route prefix before tags:
app.MapTodoEndpoints(tags: ["web"]);
app.MapTodoEndpoints("/v1", "web"); AddXxxJobs accepts tags and returns IImmediateJobsBuilder for other settings. It reads the same
Immediate.Handlers Tags value. Pass the same tags to both registration methods so every
registered job also has its handler:
services.AddTodoHandlers(tags: ["fulfillment"]);
services.AddTodoJobs(tags: ["fulfillment"])
.ConfigureStorage(storage => storage.UseInMemory()); AddXxxJobs does not replace AddXxxHandlers. Queue definitions are registered for the whole
assembly even when tags limit which jobs are added.