DEV Community

Sukhpinder Singh
Sukhpinder Singh

Posted on

The Factory Class I Finally Deleted

The PR added a WhatsApp notification channel. Three changes: a new WhatsAppSender class, fair. Its registration, fair. And NotificationSenderFactory, where a switch statement quietly grew its fourth arm. I've been approving some version of that third change since 2016 — different codebases, same class, same switch. Keyed services made it deletable back in .NET 8, and this week, on .NET 10, I finally sat down and deleted it.

The class in question

You've written this class. Maybe you called it a factory, maybe a resolver, maybe a provider. One interface, a few implementations, and a string deciding who does the work:

public sealed class NotificationSenderFactory(IServiceProvider sp)
{
    public INotificationSender Create(string channel) => channel switch
    {
        Channels.Email => sp.GetRequiredService<EmailSender>(),
        Channels.Sms => sp.GetRequiredService<SmsSender>(),
        Channels.Push => sp.GetRequiredService<PushSender>(),
        _ => throw new ArgumentException($"Unknown channel '{channel}'.", nameof(channel)),
    };
}
Enter fullscreen mode Exit fullscreen mode

There are only two ways to write this class and both are a little embarrassing. Either the factory news up the senders itself, which makes every constructor dependency the senders have the factory's problem too. Or it wraps IServiceProvider like mine does, which is the service locator pattern wearing a name tag that says "factory". And the registration tax rides along: every concrete type registered so the factory can pull it back out, plus the factory itself.

builder.Services.AddScoped<EmailSender>();
builder.Services.AddScoped<SmsSender>();
builder.Services.AddScoped<PushSender>();
builder.Services.AddScoped<NotificationSenderFactory>();
Enter fullscreen mode Exit fullscreen mode

Four registrations, one extra class, zero business value.

Three lines instead

Keyed services collapse the whole arrangement into registrations that carry the key themselves. (Autofac folks, I know, you've had named services since forever. The rest of us stayed on the built-in container and waited.)

builder.Services.AddKeyedScoped<INotificationSender, EmailSender>(Channels.Email);
builder.Services.AddKeyedScoped<INotificationSender, SmsSender>(Channels.Sms);
builder.Services.AddKeyedScoped<INotificationSender, PushSender>(Channels.Push);
Enter fullscreen mode Exit fullscreen mode

Consumption has two flavors. When the call site already knows which implementation it wants, an attribute says so. When the key only exists at runtime — a route value, a message header — you ask by key:

// the endpoint declares which implementation it wants
app.MapPost("/notify/email", ([FromKeyedServices(Channels.Email)] INotificationSender sender) =>
    Results.Ok(new { channel = Channels.Email, result = sender.Send("build is green") }));

// or the route decides at runtime
app.MapPost("/notify/{channel}", (string channel, IServiceProvider sp) =>
    Results.Ok(new { channel, result = sp.GetRequiredKeyedService<INotificationSender>(channel).Send("build is green") }));
Enter fullscreen mode Exit fullscreen mode

Yes, the runtime-key endpoint still touches IServiceProvider. When the key arrives at runtime, somebody has to do a lookup. The difference is that the mapping now lives in the registrations instead of a second class that can drift away from them.

My demo app probes itself with HttpClient (.NET 10, small Linux container — this is behavior, not benchmarks) and the exchanges are exactly what you'd hope:

== keyed services ==
POST /notify/email      -> 200 {"channel":"email","result":"email queued: \"build is green\""}
POST /notify/push       -> 200 {"channel":"push","result":"push queued: \"build is green\""}
POST /digest            -> 200 {"result":"email queued: \"your daily digest\""}
Enter fullscreen mode Exit fullscreen mode

That /digest line matters more than it looks. Behind it sits a plain class taking one specific keyed implementation through its constructor. No factory, no locator, and the class states its actual dependency instead of hiding it behind a resolver:

public sealed class DailyDigestService([FromKeyedServices(Channels.Email)] INotificationSender email)
{
    public string SendDigest() => email.Send("your daily digest");
}
Enter fullscreen mode Exit fullscreen mode

The part that bit me

I added a diagnostics block to the demo because two behaviors surprised me the first time I hit them. Real output:

== what the container actually sees ==
GetServices<INotificationSender>()          -> 0 implementations
GetKeyedServices(KeyedService.AnyKey)       -> EmailSender, SmsSender, PushSender
GetRequiredKeyedService("fax")              -> InvalidOperationException: No keyed service for type 'INotificationSender' using key type 'System.String' has been registered.
Enter fullscreen mode Exit fullscreen mode

First: keyed registrations are invisible to plain enumeration. Inject IEnumerable<INotificationSender> for some broadcast-to-every-channel feature and you get an empty sequence. No exception, no warning, just a loop that iterates nothing. GetKeyedServices<T>(KeyedService.AnyKey) is the escape hatch that actually sees them all.

Second, read that exception message closely. It names the service type and the key's type. It does not name the key. "fax" appears nowhere in it. My old factory's default arm was my code, so the message included the offending value. Now I log the key at the call site before resolving, because "using key type 'System.String'" narrows the suspect list down to every string in the system.

When the factory stays

Keyed services map a key to a registration, and that's the entire trick. If your factory contains actual logic — construction that depends on config, parameters passed at creation time, pooling — it's earning its keep. Leave it alone. Same if you have one implementation chosen once at startup by environment: register the right one conditionally and skip keys entirely.

And it's still runtime resolution. A typo'd key compiles clean and fails as a 500 in production, exactly like the factory's default arm did. My rule now, stated as opinion: keys live in a consts class (Channels.Email, never "email" sprinkled around), and one test walks every routable key and resolves it. Costs three minutes to write, catches the dumb thing forever.

The switch statement was never evil. It just lived in a class whose only job was to exist between my endpoints and my registrations, and the built-in container has been willing to do that job since .NET 8. Deleting a whole class in a PR feels better than adding one. Every time.

Full runnable sample: https://github.com/ssukhpinder/dev-to-code-samples/tree/main/018-keyed-di-services

What's the ceremony class you keep rewriting in every codebase? I'll go first, obviously — factories. Tell me yours in the comments.

— still deleting classes nobody asked me to

Top comments (0)