You're building a notification system. Users can receive notifications via email, SMS, or push notification. Simple enough:
public class NotificationService{ public async Task SendAsync(string userId, string message, string channel) { switch (channel) { case "email": var smtp = new SmtpClient("smtp.company.com"); await smtp.SendMailAsync(new MailMessage("noreply@app.com", userId, "Alert", message)); break; case "sms": var twilio = new TwilioClient("account-sid", "auth-token"); await twilio.SendSmsAsync(userId, message); break; case "push": var firebase = new FirebaseClient("server-key"); await firebase.SendPushAsync(userId, message); break; default: throw new NotSupportedException($"Channel {channel} not supported"); } }}
Next quarter, someone adds Slack. Then Microsoft Teams. Then WhatsApp. Each time, you open NotificationService, add a case, add a dependency, and pray you don't break the other channels.
Every new channel requires modifying existing, working code. That's the opposite of extensible.
The core issue is that NotificationService is doing two things: deciding which notifier to create AND sending the notification. These are separate concerns.
Testing is painful too. You can't test the email path without also having Twilio and Firebase dependencies available. Mocking means mocking the entire service. And each new channel makes the test setup worse.
The Factory Method pattern defines an interface for creating an object, but lets subclasses or implementations decide which class to instantiate. The creation logic is separated from the usage logic.
Define a common interface and a factory method:
// Common interface for all notifierspublic interface INotifier{ Task SendAsync(string recipient, string message);} // Concrete notifierspublic class EmailNotifier : INotifier{ private readonly SmtpSettings _settings; public EmailNotifier(SmtpSettings settings) => _settings = settings; public async Task SendAsync(string recipient, string message) { using var client = new SmtpClient(_settings.Host, _settings.Port); var mail = new MailMessage(_settings.From, recipient, "Notification", message); await client.SendMailAsync(mail); }} public class SmsNotifier : INotifier{ private readonly ITwilioClient _client; public SmsNotifier(ITwilioClient client) => _client = client; public async Task SendAsync(string recipient, string message) { await _client.SendSmsAsync(recipient, message); }} public class PushNotifier : INotifier{ private readonly IFirebaseClient _client; public PushNotifier(IFirebaseClient client) => _client = client; public async Task SendAsync(string recipient, string message) { await _client.SendPushAsync(recipient, message); }}
Now the factory method that creates the right notifier:
public interface INotifierFactory{ INotifier Create(string channel);} public class NotifierFactory : INotifierFactory{ private readonly IServiceProvider _provider; public NotifierFactory(IServiceProvider provider) { _provider = provider; } public INotifier Create(string channel) { return channel.ToLower() switch { "email" => _provider.GetRequiredService<EmailNotifier>(), "sms" => _provider.GetRequiredService<SmsNotifier>(), "push" => _provider.GetRequiredService<PushNotifier>(), _ => throw new NotSupportedException($"Channel '{channel}' is not supported") }; }}
The service becomes clean:
public class NotificationService{ private readonly INotifierFactory _factory; public NotificationService(INotifierFactory factory) { _factory = factory; } public async Task SendAsync(string userId, string message, string channel) { // Creation is delegated to the factory var notifier = _factory.Create(channel); await notifier.SendAsync(userId, message); }}
NotificationService no longer knows about SMTP, Twilio, or Firebase. It asks the factory for a notifier and uses it. Adding Slack means adding SlackNotifier and updating the factory. The service never changes.
Open for extension, closed for modification. Adding a new notification channel means creating one new class and updating the factory. NotificationService stays untouched.
Each notifier is testable independently. You test EmailNotifier with an SMTP mock. You test SmsNotifier with a Twilio mock. No cross-contamination.
Dependencies are isolated. EmailNotifier only knows about SMTP. PushNotifier only knows about Firebase. No more God class with every SDK injected.
For teams with many implementations, register notifiers by convention so the factory discovers them automatically:
// Marker attribute for auto-discovery[AttributeUsage(AttributeTargets.Class)]public class NotificationChannelAttribute : Attribute{ public string Channel { get; } public NotificationChannelAttribute(string channel) => Channel = channel;} [NotificationChannel("slack")]public class SlackNotifier : INotifier{ public Task SendAsync(string recipient, string message) => /* ... */;} // Convention-based factory using reflectionpublic class ConventionNotifierFactory : INotifierFactory{ private readonly Dictionary<string, Type> _notifiers; private readonly IServiceProvider _provider; public ConventionNotifierFactory(IServiceProvider provider) { _provider = provider; // Discover all notifiers at startup _notifiers = typeof(INotifier).Assembly .GetTypes() .Where(t => t.GetCustomAttribute<NotificationChannelAttribute>() != null) .ToDictionary( t => t.GetCustomAttribute<NotificationChannelAttribute>()!.Channel, t => t); } public INotifier Create(string channel) { if (!_notifiers.TryGetValue(channel.ToLower(), out var type)) throw new NotSupportedException($"Channel '{channel}' not registered"); return (INotifier)_provider.GetRequiredService(type); }}
Now adding a channel is truly a single class. No factory changes needed.
Sometimes the factory needs to create objects based on runtime configuration:
public class PaymentProcessorFactory{ private readonly IServiceProvider _provider; private readonly IConfiguration _config; public PaymentProcessorFactory(IServiceProvider provider, IConfiguration config) { _provider = provider; _config = config; } public IPaymentProcessor Create(string region) { // Different payment processors per region var processorType = _config[$"Payments:{region}:Processor"]; return processorType switch { "Stripe" => _provider.GetRequiredService<StripeProcessor>(), "Adyen" => _provider.GetRequiredService<AdyenProcessor>(), "PayU" => _provider.GetRequiredService<PayUProcessor>(), _ => _provider.GetRequiredService<StripeProcessor>() // default }; }}
The right payment processor is selected based on the customer's region, all controlled through appsettings.json. No code changes when you expand to a new market.
When there's only one implementation. If you only send emails and never plan to support other channels, a factory adds unnecessary abstraction.
When the creation logic is trivial. If creating the object is just new MyClass() with no conditions, skip the factory.
When you're using DI already. Sometimes the DI container itself is your factory. If you inject INotifier and only ever have one implementation, the container resolves it directly. No manual factory needed.
The Factory Method pattern provides a way to create objects without specifying the exact class. You define an interface for creation and let implementations decide which concrete class to instantiate. The calling code works with the interface, unaware of the concrete type.
Use it when the exact type of object to create depends on runtime data (like configuration, user input, or business rules), when you want to isolate creation logic, or when you anticipate adding new types frequently.
If you have one implementation that never changes, yes. The pattern adds value when you have multiple implementations or expect new ones. A good litmus test: if your creation logic has a switch or if-else, a factory might help.
Factory Method creates a single product. Abstract Factory creates a family of related products that must work together. Use Factory Method for one product, Abstract Factory for a coordinated set.
The Factory Method pattern is one of the most practical patterns you'll use in production .NET code. It shows up naturally in notification systems, payment processing, document generation, and anywhere else where the type of object depends on runtime conditions.
Keep it simple: extract creation logic when the switch starts growing. Don't abstract for the sake of abstraction.
That's all from me today.
P.S. Follow me on YouTube.
If you made it this far, you're clearly serious about writing better .NET code. Here's a 20% discount code: DEEP20 for Design Patterns that Deliver. Consider it a thank-you for actually reading the whole thing.
Here are 2 ebooks I have about design patterns:
Design Patterns that Deliver — 5 essential patterns (Builder, Decorator, Strategy, Adapter, Mediator) with production-ready C# code and real-world examples. Or try a free chapter on the Builder Pattern first.
Design Patterns Simplified — A beginner-friendly guide to understanding design patterns without the academic fluff.
Stop arguing about code style. In this course you get a production-proven setup with analyzers, CI quality gates, and architecture tests — the exact system I use in real projects. Join here.
Not sure yet? Grab the free Starter Kit — a drop-in setup with the essentials from Module 01.
Design Patterns that Deliver — Solve real problems with 5 battle-tested patterns (Builder, Decorator, Strategy, Adapter, Mediator) using practical, real-world examples. Trusted by 650+ developers.
Just getting started? Design Patterns Simplified covers 10 essential patterns in a beginner-friendly, 30-page guide for just $9.95.
Every Monday morning, I share 1 actionable tip on C#, .NET & Architecture that you can use right away. Join here.
Join 20,000+ subscribers who mass-improve their .NET skills with actionable tips on C#, Software Architecture & Best Practices.
Subscribe to the TheCodeMan.net and be among the 20,000+ subscribers gaining practical tips and resources to enhance your .NET expertise.