Skip to content

Tags and conditional registration

Register or map only part of an assembly by tagging items and filtering at the call site.

3 min read

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:

Program.cs
// 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

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 versionSignature
C# 13 and laterparams ReadOnlySpan<string>
C# 12 and earlierparams 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.

Where to go next