GoF Creational Pattern

Factory Method Pattern

You say what you need, but the subclass decides exactly how to build it. Object creation without locking yourself to a specific class.

29 Q&As 6 Bug Studies 10 Pitfalls 4 Testing Strategies C# / .NET
Section 1

TL;DR

  • How Factory Method decouples object creation from the code that uses objects
  • When to reach for Factory Method vs. a simple constructor or Abstract Factory
  • Real-world .NET implementations: ILoggerFactory, IHttpClientFactory, custom factories
  • 6 production bugs caused by wrong factory patterns and how to avoid them

Define an interface for creating objects — let subclasses decide which class to instantiate.

You've already used this pattern — you just didn't know it had a name. Every time you call LoggerFactory.CreateLogger("Orders") or HttpClientFactory.CreateClient("github"), you're using Factory Method. The framework decides which concrete logger or HTTP client to build — you just ask for one by name. The pattern is everywhere in .NET, and once you see it, you'll spot it in every codebase you touch.

What: Imagine you're writing code that needs to create objects, but you don't want to hardcode which exact class to create. Maybe today you need a PdfExporter, but tomorrow your teammate needs an ExcelExporter. Instead of littering your code with new PdfExporter(), you define a method — a "factory method" — that creates the object. Then, different subclassesA class that inherits from another class. In Factory Method, each subclass overrides the factory method to return a different type of product. The parent class defines the "what" (create a document exporter), each subclass defines the "which" (PDF, Excel, CSV). can override that method to return different types. The code that uses the object never knows which concrete class was created — it just works with the shared interface.

When: Use Factory Method when a class can't anticipate which objects it needs to create, or when you want subclasses to specify the objects. It's perfect when you're building a frameworkA reusable set of classes that provides a skeleton for applications. Frameworks use Factory Method heavily because the framework author can't know what specific objects YOUR application needs — they just define the "shape" and let you fill in the details. or library where the user (another developer) should be able to plug in their own types without modifying your code.

In C# / .NET: The classic approach uses an abstract class with a virtual or abstract method that subclasses override. In modern .NET, you'll often see it done through DI registrationDependency Injection registration: telling the DI container "when someone asks for IExporter, give them PdfExporter." The container becomes the factory — you register the mapping once in Program.cs, and the right type gets created automatically wherever it's needed. or factory delegates. .NET itself uses this pattern everywhere: ILoggerFactory.CreateLogger(), IHttpClientFactory.CreateClient(), WebApplication.CreateBuilder().

Quick Code:

// The Product interface — what all created objects share public interface INotification { Task SendAsync(string to, string message); } // Concrete Products — each knows how to send one way public class EmailNotification(ISmtpClient smtp) : INotification { public async Task SendAsync(string to, string msg) => await smtp.SendEmailAsync(to, "Alert", msg); } public class SmsNotification(ISmsGateway gateway) : INotification { public async Task SendAsync(string to, string msg) => await gateway.SendTextAsync(to, msg); } // The Creator — declares the factory method public abstract class NotificationCreator { public abstract INotification CreateNotification(); // Shared logic that uses the factory method public async Task NotifyAsync(string to, string message) { var notification = CreateNotification(); // subclass decides WHICH await notification.SendAsync(to, message); } } // Concrete Creators — each overrides to return a specific product public class EmailNotificationCreator(ISmtpClient smtp) : NotificationCreator { public override INotification CreateNotification() => new EmailNotification(smtp); } public class SmsNotificationCreator(ISmsGateway gw) : NotificationCreator { public override INotification CreateNotification() => new SmsNotification(gw); }
Factory Method says: "define a method for creating objects, but let subclasses decide the concrete type." The caller works with the product interface and never knows (or cares) which specific class was instantiated. In .NET, you see this pattern in ILoggerFactory, IHttpClientFactory, and countless custom factories.
Section 2

Prerequisites

Factory Method builds on a few foundational ideas. If these concepts feel familiar, you're ready. If not, a quick review will make everything click faster.
Section 3

Analogies

You walk into a restaurant and order "a burger." You don't walk into the kitchen, pick the buns, season the patty, and fire up the grill yourself. You just say what you want. The kitchen decides how to make it — which recipe to follow, which chef handles it, which ingredients to use. A burger at a fast-food joint and a burger at a gourmet bistro are both "burgers," but the kitchen (the creator) behind each restaurant produces a completely different product.

The key insight: you (the client code) never touch the creation details. You just ask for "a burger" through the menu (the interface). If the restaurant wants to add a new item — say, a veggie burger — they update the kitchen, not the menu. Existing customers don't even notice the change.

Real WorldWhat it representsIn code
Restaurant chainThe base class that defines the processCreator (abstract class)
Specific restaurant (e.g., gourmet kitchen)A subclass that decides what to buildConcreteCreator
"Make me a burger"The factory method callCreateBurger()
The actual burger you receiveThe concrete object createdConcreteProduct
"Burger" as a concept (any style)The shared interface all products followIBurger (Product interface)
Adding a veggie burger optionNew subclass, zero changes elsewhereNew VeggieKitchen : Kitchen
Customer "I want a burger" Kitchen (Creator) FastFoodKitchen GourmetKitchen VeggieKitchen Classic patty Wagyu beef Plant-based Each kitchen overrides "MakeBurger()" to create a different product

The diagram above shows the flow: the customer (your code) asks for a burger. The abstract Kitchen decides the overall process. Each concrete kitchen — fast food, gourmet, or veggie — overrides the creation step to produce its specific burger. Adding a new kitchen type doesn't change anything for the customer or the other kitchens.

Factory Method is like ordering food at a restaurant — you say what you want, and the specific kitchen decides how to make it. The caller never touches the creation details, and adding new product types means adding new "kitchens," not changing existing ones.
Section 4

Core Concept Diagram

Factory Method has four roles that work together. The Creator is a base class (or interface) that declares the factory method — it says "there will be a method called CreateProduct(), but I'm not going to implement it." Each ConcreteCreator (a subclass) overrides that method and returns a specific product. The Product is the interface that all created objects share, and each ConcreteProduct is a specific implementation. The beauty is: the Creator can use the product without ever knowing its concrete type.

FactoryMethod-UML
Factory Method UML — Creator declares abstract CreateProduct(), ConcreteCreators override it to return ConcreteProducts «abstract» Creator + CreateProduct(): IProduct + DoWork() var p = CreateProduct(); p.Execute(); «interface» IProduct + Execute(): Result creates & uses ConcreteCreatorA + CreateProduct(): IProduct ConcreteCreatorB ConcreteProductA + Execute(): Result ConcreteProductB extends implements returns new extends / implements creates

Here's what the UML shows: the Creator (top-left) has an abstract CreateProduct() method and a concrete DoWork() method that calls the factory method. The Creator doesn't know what it's creating — it just trusts that CreateProduct() will return something that implements IProduct. Each ConcreteCreator overrides the factory method and returns a specific ConcreteProduct. The yellow dashed arrow shows the "returns new" relationship — ConcreteCreatorA creates ConcreteProductA.

Sequence Diagram — Runtime Flow

Let's trace what happens at runtime when client code calls DoWork() on a ConcreteCreator.

FactoryMethod-Sequence
Client ConcreteCreator ConcreteProduct DoWork() 1 CreateProduct() 2 — overridden in subclass new ConcreteProduct() 3 product instance 4 product.Execute() 5 — Creator uses product without knowing its type result

Walk through it step by step: (1) Client calls DoWork() on the Creator. (2) Inside DoWork(), the Creator calls its own CreateProduct() — but because the ConcreteCreator overrides this method, the subclass's version runs. (3) The ConcreteCreator creates a specific product. (4) That product is returned to the Creator. (5) The Creator calls Execute() on the product — it never knows the concrete type. The Creator works with the product purely through the IProduct interface.

Key Insight: The Creator contains the shared business logic (validation, logging, error handling) — the factory method is just the "hook" that lets subclasses customize what gets created. This is why Factory Method is more than "a method that creates objects." It's about embedding a customization point inside a larger workflow. In .NET, ILoggerFactory.CreateLogger() is exactly this: the factory has shared logic (formatting, filtering by log level), and each logging provider (Console, Serilog, NLog) overrides the creation part. Four roles make Factory Method work: Creator (declares the factory method), ConcreteCreator (overrides it to return a specific type), Product (shared interface), and ConcreteProduct (the actual object). The Creator embeds a customization point in its workflow — subclasses plug in different products without changing the workflow itself.
Section 5

Code Implementations

Let's see Factory Method in action across three different real-world scenarios. Each one uses the same structural idea — "let the subclass decide what to create" — but the domain and style vary so you can see how flexible this pattern is.

Imagine you're building a system that sends alerts — sometimes by email, sometimes by SMS, sometimes by push notification. The logic around sending (retry on failure, log the attempt, validate the recipient) is always the same. The only thing that changes is how the notification is built. That's a perfect Factory Method scenario.

// Product interface — all notifications share this contract public interface INotification { string Channel { get; } // "Email", "SMS", "Push" Task<bool> SendAsync(string recipient, string message); } // Concrete Products public class EmailNotification(ISmtpClient smtp) : INotification { public string Channel => "Email"; public async Task<bool> SendAsync(string recipient, string message) { await smtp.SendAsync(recipient, "System Alert", message); return true; } } public class SmsNotification(ISmsGateway gateway) : INotification { public string Channel => "SMS"; public async Task<bool> SendAsync(string recipient, string message) { // SMS has a 160-char limit — truncate if needed var body = message.Length > 160 ? message[..157] + "..." : message; await gateway.SendTextAsync(recipient, body); return true; } } public class PushNotification(IPushService push) : INotification { public string Channel => "Push"; public async Task<bool> SendAsync(string recipient, string message) => await push.SendToDeviceAsync(recipient, new PushPayload(message)); } // Creator — contains the shared workflow (retry, logging, validation) public abstract class NotificationCreator(ILogger logger) { // THE factory method — subclasses override this protected abstract INotification CreateNotification(); // Shared business logic that USES the factory method public async Task<bool> NotifyWithRetryAsync( string recipient, string message, int maxRetries = 3) { var notification = CreateNotification(); // hook — subclass decides type logger.LogInformation("Sending via {Channel} to {To}", notification.Channel, recipient); for (int attempt = 1; attempt <= maxRetries; attempt++) { try { var success = await notification.SendAsync(recipient, message); if (success) { logger.LogInformation("Sent on attempt {N}", attempt); return true; } } catch (Exception ex) { logger.LogWarning(ex, "Attempt {N} failed", attempt); if (attempt < maxRetries) await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt))); } } logger.LogError("All {Max} attempts failed for {To}", maxRetries, recipient); return false; } } // Concrete Creators — each wires up a specific notification type public class EmailNotificationCreator(ISmtpClient smtp, ILogger logger) : NotificationCreator(logger) { protected override INotification CreateNotification() => new EmailNotification(smtp); } public class SmsNotificationCreator(ISmsGateway gateway, ILogger logger) : NotificationCreator(logger) { protected override INotification CreateNotification() => new SmsNotification(gateway); } public class PushNotificationCreator(IPushService push, ILogger logger) : NotificationCreator(logger) { protected override INotification CreateNotification() => new PushNotification(push); }

Notice the pattern: the NotifyWithRetryAsync method has all the shared logic — retry with exponential backoff, logging, error handling. It calls CreateNotification() without knowing or caring what comes back. Each ConcreteCreator just says "for me, the notification is an email" or "for me, it's an SMS." Adding a SlackNotificationCreator tomorrow means writing one new class — the retry logic, logging, and everything else stays exactly the same.

Many apps need to export data in different formats — PDF, Excel, CSV. The "export" workflow is always the same: gather the data, transform it into the target format, and stream it to the user. The only part that changes is which format gets produced. Factory Method makes each format pluggable.

// Product interface public interface IDocumentExporter { string FileExtension { get; } string ContentType { get; } Task<byte[]> ExportAsync(ReportData data); } // Concrete Products public class PdfExporter(IPdfRenderer renderer) : IDocumentExporter { public string FileExtension => ".pdf"; public string ContentType => "application/pdf"; public async Task<byte[]> ExportAsync(ReportData data) { var doc = renderer.CreateDocument(); doc.AddTitle(data.Title); foreach (var section in data.Sections) { doc.AddHeading(section.Name); doc.AddTable(section.Rows, section.Columns); } return await doc.RenderAsync(); } } public class ExcelExporter : IDocumentExporter { public string FileExtension => ".xlsx"; public string ContentType => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; public Task<byte[]> ExportAsync(ReportData data) { using var workbook = new XLWorkbook(); foreach (var section in data.Sections) { var sheet = workbook.Worksheets.Add(section.Name); sheet.Cell(1, 1).InsertTable(section.Rows); } using var stream = new MemoryStream(); workbook.SaveAs(stream); return Task.FromResult(stream.ToArray()); } } public class CsvExporter : IDocumentExporter { public string FileExtension => ".csv"; public string ContentType => "text/csv"; public Task<byte[]> ExportAsync(ReportData data) { var sb = new StringBuilder(); var first = data.Sections.FirstOrDefault(); if (first is not null) { sb.AppendLine(string.Join(",", first.Columns)); foreach (var row in first.Rows) sb.AppendLine(string.Join(",", row.Values.Select(v => $"\"{v}\""))); } return Task.FromResult(Encoding.UTF8.GetBytes(sb.ToString())); } } // Creator — shared report generation workflow public abstract class ReportService(IReportRepository repo, ILogger logger) { protected abstract IDocumentExporter CreateExporter(); public async Task<FileResult> GenerateReportAsync(int reportId) { logger.LogInformation("Generating report {Id}", reportId); var data = await repo.GetReportDataAsync(reportId); if (data is null) throw new NotFoundException($"Report {reportId} not found"); var exporter = CreateExporter(); var bytes = await exporter.ExportAsync(data); logger.LogInformation("Exported as {Ext} ({Size} bytes)", exporter.FileExtension, bytes.Length); return new FileResult(bytes, exporter.ContentType, $"report-{reportId}{exporter.FileExtension}"); } } // Concrete Creators public class PdfReportService( IReportRepository repo, IPdfRenderer renderer, ILogger logger) : ReportService(repo, logger) { protected override IDocumentExporter CreateExporter() => new PdfExporter(renderer); } public class ExcelReportService(IReportRepository repo, ILogger logger) : ReportService(repo, logger) { protected override IDocumentExporter CreateExporter() => new ExcelExporter(); } public class CsvReportService(IReportRepository repo, ILogger logger) : ReportService(repo, logger) { protected override IDocumentExporter CreateExporter() => new CsvExporter(); }

The GenerateReportAsync method handles fetching data, validation, logging, and returning the file — all shared. Each subclass only provides the exporter. When the team needs a Word document exporter, they write DocxExporter and DocxReportService. The core report pipeline doesn't change at all.

Payment gateways are a classic example: every provider (Stripe, PayPal, Square) follows the same high-level flow — validate, charge, log — but the actual API calls are completely different. Factory Method keeps the workflow stable while letting each gateway plug in its own integration.

// Product interface public interface IPaymentGateway { string Provider { get; } Task<ChargeResult> ChargeAsync(decimal amount, string token); Task<RefundResult> RefundAsync(string transactionId); } // Concrete Products public class StripeGateway( IStripeClient client, IOptions<StripeSettings> opts) : IPaymentGateway { public string Provider => "Stripe"; public async Task<ChargeResult> ChargeAsync(decimal amount, string token) { var charge = await client.CreateChargeAsync(new() { Amount = (long)(amount * 100), // Stripe uses cents Currency = opts.Value.Currency, Source = token }); return new(charge.Status == "succeeded", charge.Id, charge.FailureMessage); } public async Task<RefundResult> RefundAsync(string transactionId) { var refund = await client.CreateRefundAsync(transactionId); return new(refund.Status == "succeeded", refund.Id); } } public class PayPalGateway( IHttpClientFactory httpFactory) : IPaymentGateway { public string Provider => "PayPal"; public async Task<ChargeResult> ChargeAsync(decimal amount, string token) { var http = httpFactory.CreateClient("paypal"); var resp = await http.PostAsJsonAsync("/v2/checkout/orders", new { purchase_units = new[] { new { amount = new { value = amount.ToString("F2") } } }, intent = "CAPTURE" }); var body = await resp.Content .ReadFromJsonAsync<PayPalOrderResponse>(); return new(resp.IsSuccessStatusCode, body?.Id ?? "", body?.Error); } public async Task<RefundResult> RefundAsync(string transactionId) { var http = httpFactory.CreateClient("paypal"); var resp = await http.PostAsync( $"/v2/payments/captures/{transactionId}/refund", null); return new(resp.IsSuccessStatusCode, transactionId); } } // Creator — shared checkout workflow public abstract class CheckoutProcessor( ILogger logger, IOrderRepository orders) { protected abstract IPaymentGateway CreateGateway(); public async Task<OrderResult> ProcessAsync(Order order) { logger.LogInformation("Processing order {Id} for {Amount:C}", order.Id, order.Total); var gateway = CreateGateway(); var charge = await gateway.ChargeAsync( order.Total, order.PaymentToken); if (!charge.Success) { logger.LogWarning("Charge failed: {Error}", charge.Error); return OrderResult.Failed(charge.Error!); } order.TransactionId = charge.TransactionId; order.Status = OrderStatus.Paid; await orders.UpdateAsync(order); logger.LogInformation("Order {Id} paid via {Provider}", order.Id, gateway.Provider); return OrderResult.Ok(order.Id, charge.TransactionId); } } // Concrete Creators public class StripeCheckout( IStripeClient client, IOptions<StripeSettings> opts, ILogger logger, IOrderRepository orders) : CheckoutProcessor(logger, orders) { protected override IPaymentGateway CreateGateway() => new StripeGateway(client, opts); } public class PayPalCheckout( IHttpClientFactory httpFactory, ILogger logger, IOrderRepository orders) : CheckoutProcessor(logger, orders) { protected override IPaymentGateway CreateGateway() => new PayPalGateway(httpFactory); }

The ProcessAsync workflow — charge, update order, log — is identical for every gateway. Each ConcreteCreator just wires up the right gateway. When the team adds Square support, it's one SquareGateway class and one SquareCheckout class. Nothing in the existing payment code changes.

Execution Flow

This diagram traces what happens when the Notification example runs end-to-end, from DI resolution through the factory method call and into the concrete product.

FactoryMethod-ExecutionFlow
DI Container resolves creator EmailNotification Creator ConcreteCreator NotifyWithRetry Async() shared workflow in Creator CreateNotification () factory method (overridden) EmailNotification ConcreteProduct notification .SendAsync() called via INotification The Creator never sees "EmailNotification" — it only talks to INotification

The flow is straightforward: the DI container creates the right ConcreteCreator (based on what's registered in Program.cs). When the shared workflow calls CreateNotification(), the overridden version in the ConcreteCreator runs, returning the specific product. From that point on, the Creator interacts with the product only through the INotification interface — it has no idea it's dealing with email, SMS, or anything else.

Three real-world examples — notifications, document export, and payments — all follow the same pattern: shared workflow in the Creator, customization point via the factory method, and concrete subclasses that plug in specific products. Adding a new variant means writing a new class, not touching existing code.
Section 6

Junior vs Senior

Problem Statement

Build a document export service that can generate reports in multiple formats (PDF, Excel, CSV). The format is chosen by the user at request time. The service should be easy to extend with new formats (e.g., Word) without modifying existing code.

How a Junior Thinks

"I'll just use a switch statementThe classic procedural approach: check the format string, then create the right exporter inside a big method. Seems clean at first, but every new format means adding another case to the switch — touching tested code and risking merge conflicts. to pick the right format. It's all in one file, easy to understand."

public class ExportService { public async Task<byte[]> ExportReport( string format, ReportData data) { switch (format.ToLower()) { case "pdf": var renderer = new PdfRenderer(); // newed up directly var doc = renderer.CreateDocument(); doc.AddTitle(data.Title); foreach (var section in data.Sections) { doc.AddHeading(section.Name); doc.AddTable(section.Rows, section.Columns); } return await doc.RenderAsync(); case "excel": using var workbook = new XLWorkbook(); foreach (var section in data.Sections) { var sheet = workbook.Worksheets.Add(section.Name); sheet.Cell(1, 1).InsertTable(section.Rows); } using (var stream = new MemoryStream()) { workbook.SaveAs(stream); return stream.ToArray(); } case "csv": var sb = new StringBuilder(); var first = data.Sections.FirstOrDefault(); if (first is not null) { sb.AppendLine(string.Join(",", first.Columns)); foreach (var row in first.Rows) sb.AppendLine(string.Join(",", row.Values)); } return Encoding.UTF8.GetBytes(sb.ToString()); default: throw new ArgumentException( $"Unsupported format: {format}"); } } } // Problems: // 1. Adding Word format = editing this method (OCP violation) // 2. PdfRenderer newed up inside — can't mock for unit tests // 3. CSV export logic is mixed with PDF logic in the same class // 4. No shared workflow — each case handles everything differently // 5. String-based format selection — typos cause runtime crashes

Problems

OCP Violation

Adding a Word exporter means opening this method and adding another case branch. You're modifying tested, working code. In a team of 10, everyone editing the same switch statement means constant merge conflicts.

Untestable

PdfRenderer is created with new inside the method — you can't mock it. Testing the PDF path requires an actual PDF library. Testing CSV accidentally runs through PDF validation too. Each format is tangled with the others.

No Shared Workflow

Each case handles everything from scratch — no shared logging, no shared validation, no shared error handling. When the team decides "all exports must log the file size," someone has to add that logging code to every single case branch.

How a Senior Thinks

"Each export format is a separate concern with its own dependencies. I'll define a Product interface (IDocumentExporter), implement each format as its own class, and use a Creator that handles the shared workflow — fetching data, logging, error handling. The factory method is the only part that changes per format. Adding Word? One new class, one DI registration."

// Product interface — every export format implements this public interface IDocumentExporter { string FormatName { get; } // "PDF", "Excel", "CSV" string FileExtension { get; } // ".pdf", ".xlsx", ".csv" string ContentType { get; } // MIME type for HTTP response Task<byte[]> ExportAsync(ReportData data); } // Records for clean data transfer public record ReportData( string Title, List<ReportSection> Sections); public record ReportSection( string Name, List<string> Columns, List<ReportRow> Rows); public record ReportRow(List<string> Values); public record FileResult( byte[] Content, string ContentType, string FileName); // Concrete Product — PDF export // Has its own dependency: IPdfRenderer public sealed class PdfExporter( IPdfRenderer renderer) : IDocumentExporter { public string FormatName => "PDF"; public string FileExtension => ".pdf"; public string ContentType => "application/pdf"; public async Task<byte[]> ExportAsync(ReportData data) { var doc = renderer.CreateDocument(); doc.AddTitle(data.Title); foreach (var section in data.Sections) { doc.AddHeading(section.Name); doc.AddTable(section.Rows, section.Columns); } return await doc.RenderAsync(); } } // ExcelExporter and CsvExporter follow the same pattern — // each implements IDocumentExporter with its own logic // Creator — shared workflow: fetch, validate, export, log public abstract class ReportService( IReportRepository repo, ILogger logger) { // THE factory method — subclass overrides this protected abstract IDocumentExporter CreateExporter(); public async Task<FileResult> GenerateReportAsync(int reportId) { // Shared: data fetching var data = await repo.GetReportDataAsync(reportId); if (data is null) throw new NotFoundException( $"Report {reportId} not found"); // Shared: call the factory method var exporter = CreateExporter(); // Shared: export + logging var bytes = await exporter.ExportAsync(data); logger.LogInformation( "Generated {Format} for report {Id} ({Size:N0} bytes)", exporter.FormatName, reportId, bytes.Length); return new FileResult(bytes, exporter.ContentType, $"report-{reportId}{exporter.FileExtension}"); } } // Concrete Creators public class PdfReportService( IReportRepository repo, IPdfRenderer renderer, ILogger logger) : ReportService(repo, logger) { protected override IDocumentExporter CreateExporter() => new PdfExporter(renderer); } public class ExcelReportService( IReportRepository repo, ILogger logger) : ReportService(repo, logger) { protected override IDocumentExporter CreateExporter() => new ExcelExporter(); } // DI registration — .NET 8 Keyed Services builder.Services.AddScoped<IPdfRenderer, QuestPdfRenderer>(); builder.Services.AddKeyedScoped<ReportService, PdfReportService>("pdf"); builder.Services.AddKeyedScoped<ReportService, ExcelReportService>("excel"); builder.Services.AddKeyedScoped<ReportService, CsvReportService>("csv"); // Endpoint — resolve by format key at runtime app.MapGet("/reports/{id}/export", async ( int id, [FromQuery] string format, IServiceProvider sp) => { var service = sp.GetRequiredKeyedService<ReportService>( format); var result = await service.GenerateReportAsync(id); return Results.File(result.Content, result.ContentType, result.FileName); }); // Adding Word export? ONE new exporter + ONE new creator // + ONE DI line: // builder.Services.AddKeyedScoped<ReportService, // DocxReportService>("docx"); // Zero changes to existing code.

Design Decisions

Each Format is Independently Testable

PdfExporter gets its own unit tests with a mocked IPdfRenderer. ExcelExporter has separate tests — no PDF library needed. The Creator is testable too: mock the repository and verify the workflow without any real exporter.

Shared Workflow, Customized Product

Data fetching, validation, logging, and file naming are all in the Creator — written once, tested once. The factory method is the only customization point. "All exports must log the file size" is a one-line change in one place.

.NET 8 Keyed Services for Runtime Resolution

Instead of manually mapping format strings to creators, Keyed ServicesA .NET 8 feature: register multiple implementations of the same type with different string keys. Then resolve by key at runtime: sp.GetRequiredKeyedService<ReportService>("pdf"). No factory class needed — the DI container IS the factory. handle it. The DI container resolves the right ReportService subclass by format key — no switch statement, no manual factory.

Junior code crams all creation logic into one switch statement — untestable, violates OCP, no shared workflow. Senior code uses Factory Method: each format is its own class, the Creator handles shared logic, and adding new formats means writing new classes without touching existing code. .NET 8 Keyed Services eliminate even the factory class.
Section 7

Evolution & History

Factory Method didn't appear out of thin air. It evolved alongside the languages and frameworks that use it. Understanding that journey helps you see why the pattern looks different in a modern .NET 8 codebase versus a 2002-era .NET 1.0 project — and why both approaches are still the same fundamental idea underneath.

1994 GoF Book 2002 .NET 1.0–2.0 2007 .NET 3.5 / LINQ 2016 .NET Core 2023 .NET 8+

In 1994, the "Gang of Four" published Design Patterns. Their Factory Method chapter described a world of C++ and Smalltalk — languages where creating the right object type was genuinely painful. The idea was elegant: instead of calling new ConcreteClass() directly, you call a method that subclasses override to return whatever type they want. The calling code never changes.

Back then, this was revolutionary. Most codebases had massive switch statements deciding which class to instantiate. Factory Method replaced those with polymorphismThe ability of different classes to respond to the same method call in different ways. Instead of a switch statement asking "what type?", you let each class define its own behavior. — each subclass knows what to create, so no central switch is needed.

// Imagine it's 1994 — C++ heritage, abstract classes everywhere public abstract class Document { public abstract void Open(); public abstract void Save(); } public abstract class Application { // THE factory method — subclasses decide which Document to create public abstract Document CreateDocument(); // Shared workflow — same for every application type public void NewDocument() { Document doc = CreateDocument(); // subclass decides doc.Open(); // shared behavior } }

Notice how Application.NewDocument() doesn't know or care what kind of document it's working with. That's the entire power of the pattern — the workflow is fixed, but the object type is pluggable.

When .NET arrived, C# brought the same ideas into a managed runtime. Early .NET factories looked almost identical to the GoF book — abstract base classes with virtual or abstract methods. But .NET 2.0 added genericsA language feature that lets you write code that works with any type, decided at compile time. Instead of writing separate factories for each product, you can write one generic factory: Factory<T>., which eliminated a lot of boilerplate.

Before generics, you'd cast everything to object and back — risky and ugly. After generics, the factory could be IFactory<T> and return strongly-typed products with zero casts.

// .NET 2.0 — generics make factories type-safe public interface IFactory<T> where T : class { T Create(); } // Each factory knows its product type at compile time public class SqlConnectionFactory : IFactory<IDbConnection> { private readonly string _connStr; public SqlConnectionFactory(string connStr) => _connStr = connStr; public IDbConnection Create() => new SqlConnection(_connStr); // strongly typed, no casting } // Usage — the caller never mentions SqlConnection IFactory<IDbConnection> factory = new SqlConnectionFactory(connStr); IDbConnection conn = factory.Create(); // could be SQL, Postgres, anything

LINQ and lambda expressions changed everything. Suddenly you didn't need a whole class just to say "here's how to create an object." A Func<T> delegate could serve as a tiny, inline factory — no inheritance hierarchy needed.

This was a pivotal shift. For simple creation logic — where the factory method is just "call a constructor with these arguments" — a full class hierarchy felt like overkill. A one-line lambda does the same job with less ceremony. The pattern is still Factory Method (something decides what to create), but the mechanism shrank from a class to a function.

// No need for a class — a Func<T> IS the factory Func<INotification> emailFactory = () => new EmailNotification(smtpClient); Func<INotification> smsFactory = () => new SmsNotification(twilioGateway); // Pick the right factory at runtime var factories = new Dictionary<string, Func<INotification>> { ["email"] = emailFactory, ["sms"] = smsFactory, ["push"] = () => new PushNotification(firebaseClient), }; // Usage — still Factory Method, just without class inheritance INotification notifier = factories[channel](); await notifier.SendAsync(user.Email, message);

The downside? When creation logic is complex (needs configuration, logging, validation), a delegate gets unwieldy fast. That's when you still want the class-based approach.

.NET Core made dependency injectionA technique where objects receive their dependencies from the outside (usually a "container") instead of creating them internally. Instead of a class calling new EmailService(), the DI container hands it an IEmailService automatically. a first-class citizen. The built-in IServiceProvider is essentially a giant factory — you register types at startup, and the container creates the right concrete class whenever someone asks for an interface.

This didn't kill Factory Method — it absorbed it. Instead of writing your own factory classes, you register creation logic with the DI container, and it handles instantiation, lifetime management, and disposal. The pattern is still there, just hiding inside the framework.

// Program.cs — register factories with the DI container builder.Services.AddTransient<INotification, EmailNotification>(); builder.Services.AddSingleton<ILoggerFactory, LoggerFactory>(); // For named/keyed creation, use a manual factory builder.Services.AddTransient<Func<string, INotification>>(sp => channel => channel switch { "email" => sp.GetRequiredService<EmailNotification>(), "sms" => sp.GetRequiredService<SmsNotification>(), "push" => sp.GetRequiredService<PushNotification>(), _ => throw new ArgumentException($"Unknown channel: {channel}") }); // Controller — just ask for the factory, DI does the rest public class AlertController(Func<string, INotification> factory) { public async Task<IActionResult> Send(string channel, string msg) { var notifier = factory(channel); // factory method via DI await notifier.SendAsync(user, msg); return Ok(); } }

The newest addition: keyed servicesA .NET 8 feature that lets you register multiple implementations of the same interface, each identified by a unique key (string, enum, etc.). The DI container returns the right implementation based on the key you request — no manual factory class needed.. Before .NET 8, if you had multiple implementations of the same interface and needed to pick one by name, you had to write manual factory logic (like the Func<string, INotification> workaround above). Keyed services make this a first-class DI feature — register by key, resolve by key, done.

This is the most concise the Factory Method pattern has ever been in .NET. The [FromKeyedServices] attribute on a constructor parameter tells the DI container "give me the implementation registered under this key." No factory class, no delegate, no switch statement — the container handles it all.

// Program.cs — register each implementation under a string key builder.Services.AddKeyedTransient<INotification, EmailNotification>("email"); builder.Services.AddKeyedTransient<INotification, SmsNotification>("sms"); builder.Services.AddKeyedTransient<INotification, PushNotification>("push"); // Controller — resolve by key directly in the constructor! public class AlertController( [FromKeyedServices("email")] INotification emailNotifier, [FromKeyedServices("sms")] INotification smsNotifier) : ControllerBase { public async Task<IActionResult> SendEmail(string msg) { await emailNotifier.SendAsync(user, msg); // already the right type return Ok(); } } // Or resolve dynamically at runtime public class DynamicAlertService(IServiceProvider sp) { public async Task SendAsync(string channel, string to, string msg) { var notifier = sp.GetRequiredKeyedService<INotification>(channel); await notifier.SendAsync(to, msg); } }

The evolution is clear: from verbose abstract class hierarchies (1994) to a single attribute on a constructor parameter (2023). The idea never changed — "let something else decide which concrete type to create." The syntax got dramatically simpler.

Factory Method evolved across five eras: GoF's abstract class approach (1994), .NET generics for type-safe factories (2002), Func<T> delegates as one-line factories (2007), DI container absorbing factory logic (.NET Core 2016), and keyed services eliminating manual factories entirely (.NET 8, 2023). The core idea — "let something else decide which type to create" — never changed; only the syntax got simpler.
Section 8

Factory Method in .NET Core

.NET is practically a museum of Factory Methods. Once you know the pattern, you'll spot it in every namespace — logging, HTTP, DI scoping, options, database contexts. These aren't academic examples; they're production code you'll call every day.

Let's walk through the most important Factory Method implementations that ship with .NET itself. For each one, we'll see what it does, why it uses Factory Method (instead of just new), and how to use it in your own code.

Factory Method in .NET Core ILoggerFactory IHttpClientFactory WebApplication.Create IServiceScopeFactory IOptionsFactory<T> IDbContextFactory<T> Each "Create" method is a Factory Method — the caller says what they need, the implementation decides how.

If you've ever written logger.LogInformation("...") in a .NET app, you've benefited from Factory Method. ILoggerFactory has a single method: CreateLogger(string categoryName). You ask for a logger by name, and the factory decides how to build it — which sinks to attach (console, file, Seq), what minimum log level, what formatting rules.

Why Factory Method here? Because the logger you need depends on configuration, not code. The same CreateLogger("Orders") call might produce a console-only logger in development and a structured-JSON-to-Elasticsearch logger in production. The calling code is identical in both environments — only the factory registration changes.

// Program.cs — configure WHAT the factory produces builder.Logging.ClearProviders(); builder.Logging.AddConsole(); // dev: human-readable console builder.Logging.AddJsonConsole(); // prod: structured JSON // OrderService.cs — use the factory, never touch concrete loggers public class OrderService(ILoggerFactory loggerFactory) { // Factory Method: returns a logger configured for "OrderService" private readonly ILogger _log = loggerFactory.CreateLogger("OrderService"); public async Task PlaceOrderAsync(Order order) { _log.LogInformation("Placing order {OrderId}", order.Id); // ... business logic _log.LogInformation("Order {OrderId} placed successfully", order.Id); } } // You never know (or care) whether _log writes to console, file, or cloud. // Change the factory registration in Program.cs — zero changes here.

Raw new HttpClient() is a famously bad idea in .NET — it leads to socket exhaustionWhen you create and dispose HttpClient instances too frequently, the underlying TCP connections linger in TIME_WAIT state. After enough instances, your OS runs out of available sockets and new HTTP requests start failing — even though you "disposed" the client properly. under load. IHttpClientFactory solves this by managing a pool of HttpMessageHandler instances. But it's also a Factory Method: you call CreateClient("github") and get back an HttpClient pre-configured with the right base URL, headers, timeout, and retry policy.

Why Factory Method? Because each external API needs different configuration. Your GitHub client needs an OAuth token and a specific base URL. Your payment gateway client needs client certificates and custom retry logic. The factory lets you define these configurations once at startup and stamp out correctly-configured clients on demand.

// Program.cs — configure named clients builder.Services.AddHttpClient("github", client => { client.BaseAddress = new Uri("https://api.github.com"); client.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json"); client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0"); }) .AddHeaderPropagation() // forward correlation IDs .AddTransientHttpErrorPolicy(p => p.RetryAsync(3)); // auto-retry builder.Services.AddHttpClient("stripe", client => { client.BaseAddress = new Uri("https://api.stripe.com/v1/"); client.Timeout = TimeSpan.FromSeconds(10); }); // GitHubService.cs — request a client by name public class GitHubService(IHttpClientFactory factory) { public async Task<List<Repo>> GetReposAsync(string user) { // Factory Method: returns a fully configured HttpClient using var client = factory.CreateClient("github"); var response = await client.GetAsync($"users/{user}/repos"); return await response.Content.ReadFromJsonAsync<List<Repo>>(); } // The client already has base URL, auth, retry policy — we just use it }

The very first line of every modern .NET app is a factory method: WebApplication.CreateBuilder(args). It creates a WebApplicationBuilder pre-configured with logging, configuration sources (appsettings.json, environment variables, command-line args), and the DI container.

Why Factory Method? Because the builder needs to set up dozens of defaults — Kestrel server config, JSON settings, environment detection — and those defaults differ between web apps, worker services, and minimal APIs. A constructor couldn't handle this complexity cleanly. The static factory method encapsulates all that setup and returns a ready-to-customize builder.

// This IS a factory method — static, returns a configured builder var builder = WebApplication.CreateBuilder(args); // The builder already has: // - Configuration from appsettings.json + env vars + CLI args // - Logging providers (console in dev) // - Kestrel server defaults // - DI container ready for registrations // You just ADD your stuff on top builder.Services.AddControllers(); builder.Services.AddScoped<IOrderService, OrderService>(); var app = builder.Build(); // another factory: Build() creates WebApplication app.MapControllers(); app.Run();

In .NET's DI system, scoped servicesServices that live for the duration of a "scope" — typically one HTTP request. A scoped DbContext means each request gets its own database connection and transaction. Different requests never share scoped instances. exist for the lifetime of a scope (usually one HTTP request). But sometimes you need to create a scope manually — in a background service, a message handler, or a hosted service. That's where IServiceScopeFactory.CreateScope() comes in.

Why Factory Method? Because each scope is an isolated container that tracks its own disposable objects. The factory ensures proper setup and teardown — when the scope is disposed, all scoped services within it get disposed too. You can't just new a scope; the factory needs to wire it into the DI container's lifetime management.

// Background service — no HTTP request, so no automatic scope public class OrderExpirationWorker( IServiceScopeFactory scopeFactory, ILogger<OrderExpirationWorker> logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { // Factory Method: creates an isolated DI scope using var scope = scopeFactory.CreateScope(); // Resolve scoped services WITHIN this scope var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var expiredOrders = await db.Orders .Where(o => o.Status == "Pending" && o.CreatedAt < DateTime.UtcNow.AddHours(-24)) .ToListAsync(ct); foreach (var order in expiredOrders) order.Status = "Expired"; await db.SaveChangesAsync(ct); logger.LogInformation("Expired {Count} orders", expiredOrders.Count); // scope is disposed here — DbContext connection is released await Task.Delay(TimeSpan.FromMinutes(5), ct); } } }

The Options pattern.NET's built-in pattern for reading typed configuration. Instead of reading raw strings from appsettings.json, you define a class (e.g., SmtpOptions) and .NET automatically maps the JSON properties into that class. Clean, typed, and validatable. binds configuration sections to strongly-typed classes. Under the hood, IOptionsFactory<T>.Create() is a factory method that reads configuration, applies validation, and returns a fully-constructed options object.

Why Factory Method? Because options construction is surprisingly complex: read from multiple sources (JSON, env vars, Azure Key Vault), apply named options, run IValidateOptions<T> validators, and apply post-configuration. All of this happens inside the factory, invisible to the consumer who just injects IOptions<SmtpOptions>.

// appsettings.json // { "Smtp": { "Host": "smtp.gmail.com", "Port": 587, "UseTls": true } } // SmtpOptions.cs — the "product" the factory creates public class SmtpOptions { public string Host { get; set; } = ""; public int Port { get; set; } = 587; public bool UseTls { get; set; } = true; } // Program.cs — register the options binding builder.Services.Configure<SmtpOptions>( builder.Configuration.GetSection("Smtp")); // Add validation — the factory runs this on Create() builder.Services.AddOptionsWithValidateOnStart<SmtpOptions>() .ValidateDataAnnotations() .Validate(o => o.Port > 0, "Port must be positive"); // EmailService.cs — consumer never sees the factory public class EmailService(IOptions<SmtpOptions> options) { // IOptionsFactory<SmtpOptions>.Create() ran behind the scenes: // read config → apply overrides → validate → return typed object private readonly SmtpOptions _smtp = options.Value; }

Entity Framework Core's DbContext is not thread-safe — you can't share one across concurrent operations. In a Blazor ServerA hosting model where the Blazor UI runs on the server and communicates with the browser over SignalR. Because the connection is long-lived (not request-scoped), the normal "one DbContext per request" approach doesn't work — you need to create and dispose contexts manually. app or a background service, where there's no HTTP request scope, you need to create DbContext instances on demand. IDbContextFactory<T> does exactly that.

Why Factory Method? Because creating a DbContext involves connection string lookup, provider selection (SQL Server, Postgres, SQLite), migration check, and interceptor setup. The factory encapsulates all of this. You call CreateDbContext(), get a fully configured context, use it, and dispose it. Simple.

// Program.cs — register the factory builder.Services.AddDbContextFactory<AppDbContext>(opt => opt.UseNpgsql(builder.Configuration.GetConnectionString("Default"))); // Blazor component — create contexts on demand @inject IDbContextFactory<AppDbContext> DbFactory @code { private List<Order> orders = new(); protected override async Task OnInitializedAsync() { // Factory Method: creates a fresh, thread-safe DbContext await using var db = await DbFactory.CreateDbContextAsync(); orders = await db.Orders .Where(o => o.Status == "Active") .OrderByDescending(o => o.CreatedAt) .Take(50) .ToListAsync(); // db is disposed here — connection returned to pool } }
.NET is filled with Factory Methods: ILoggerFactory for configurable logging, IHttpClientFactory for pooled HTTP clients, WebApplication.CreateBuilder for app bootstrapping, IServiceScopeFactory for DI scopes, IOptionsFactory for typed configuration, and IDbContextFactory for on-demand database contexts. In every case, the caller says what they need and the factory decides how to build it — the core of Factory Method.
Section 9

When To Use / When Not To

Factory Method is powerful, but it's not always the right tool. Using it where a simple new would suffice adds needless complexity. Using new where a factory is needed creates rigid, untestable code. Here's how to make the right call.

Decision Flowchart

Not sure? Walk through this flowchart. Start at the top and follow the path.

Multiple implementations of the same interface? No Use new Yes Complex creation logic (config, deps, validation)? No Func<T> delegate Yes Shared workflow around creation (template method)? No DI Keyed Services Yes Factory Method (full pattern) Use Factory Method when you have multiple product types, complex creation logic, and a shared workflow around object creation. If only one type exists, use new. If creation is simple, a Func<T> delegate suffices. If you need named resolution without workflow, .NET 8 keyed services are simpler. The flowchart helps you pick the right level of abstraction.
Section 10

Comparisons

Factory Method lives in a neighborhood of patterns that all deal with object creation and behavioral delegation. If you've ever wondered "should I use Factory Method or Abstract Factory?" or "isn't this just Strategy?" — this section untangles those overlaps.

Factory Method vs Abstract Factory

When to pick which: If you're creating one kind of thing (notifications, exporters, loggers), Factory Method is enough. If you're creating sets of things that must match (a UI theme where buttons, checkboxes, and menus all need to be "dark mode" together), that's Abstract Factory. Think of Factory Method as a single creation point, Abstract Factory as a coordinated creation suite.

Factory Method vs Strategy

When to pick which: Factory Method answers "which object do I create?" Strategy answers "which algorithm do I run?" Sometimes they overlap — a factory might create strategy objects. But if the object itself is the point (it has state, multiple methods, a lifecycle), use Factory Method. If you just need to swap one chunk of behavior, Strategy is leaner.

Factory Method vs Simple Factory (Static Method)

When to pick which: Simple Factory (a static method like Notification.Create("email")) is fine for small projects where the product list rarely changes. Factory Method shines when you need extensibility — new product types added by new classes, not by editing a switch statement. If you find yourself constantly editing a switch to add new types, it's time to graduate to Factory Method.

Factory Method vs Builder

When to pick which: Factory Method is "give me an object" — one call, one result. Builder is "let me configure this object piece by piece" — multiple steps, then finalize. If the complexity is in which type to create, use Factory Method. If the complexity is in how to configure a single type (many optional parameters, conditional steps), use Builder.

Visual: Factory Method vs Abstract Factory

Factory Method Creator + CreateProduct() CreatorA CreatorB ProductA ProductB One method → one product type Abstract Factory AbstractFactory + CreateButton() + CreateCheckbox() DarkFactory LightFactory DarkBtn DarkChk LightBtn LightChk Multiple methods → families of products Factory Method creates one product type per method via inheritance. Abstract Factory creates families of related products. Strategy swaps algorithms, not object types. Simple Factory uses a static switch (easy but not extensible). Builder constructs objects step-by-step. Choose based on whether you need type variation (Factory Method), family coordination (Abstract Factory), algorithm swapping (Strategy), or step-by-step configuration (Builder).
Section 11

SOLID Connections

Factory Method is one of those rare patterns that supports all five SOLID principles. That's not a coincidence — the pattern was designed to make code flexible and maintainable, which is exactly what SOLID principles aim for. Let's see how each connection works specifically for Factory Method.

Principle Verdict How Factory Method Connects
SRPSingle Responsibility Principle: A class should have only one reason to change. If your class both processes orders AND decides which payment gateway to use, it has two responsibilities — and two reasons to break. Supports Creation logic lives in the factory (one responsibility), while the business workflow lives in the creator's template method (a different responsibility). The ReportService doesn't know how to build a PdfExporter — it just calls CreateExporter() and uses the result. If PDF generation changes, only the PdfReportService subclass changes.
OCPOpen/Closed Principle: Software should be open for extension but closed for modification. You should be able to add new behavior without editing existing, tested code. Supports This is Factory Method's headline feature. Adding a new product type (say, MarkdownExporter) means creating a new MarkdownReportService subclass. You never touch ReportService, PdfReportService, or any existing code. The existing code is closed for modification; the system is open for extension via new subclasses.
LSPLiskov Substitution Principle: Any subclass should be usable wherever its parent class is expected, without breaking behavior. If code works with a base Creator, it must work with ANY ConcreteCreator. Supports Every ConcreteCreator must be substitutable for the base Creator. The caller works with the Creator type and never knows which subclass is active. If PdfReportService behaves differently than ExcelReportService in ways the caller doesn't expect, LSP is violated — and the pattern breaks. Factory Method requires LSP to function correctly.
ISPInterface Segregation Principle: Clients shouldn't be forced to depend on methods they don't use. Keep interfaces small and focused — one purpose per interface. Supports The Product interface (INotification, IDocumentExporter) should be focused and lean. Factory Method encourages this: if the interface is bloated, some concrete products will have dummy methods they don't need — a clear ISP violation. The pattern nudges you toward small, cohesive product interfaces.
DIPDependency Inversion Principle: High-level modules should depend on abstractions, not concrete classes. Don't import PdfExporter directly — depend on IDocumentExporter and let the factory provide the concrete type. Supports The Creator depends on the IProduct interface, not any concrete product. The calling code depends on the Creator abstraction, not any concrete creator. All dependencies point toward abstractions. This is why Factory Method makes testing so easy — you can substitute any implementation without the caller knowing.

The takeaway: Factory Method doesn't just happen to align with SOLID — it was designed around these principles. If you find your Factory Method implementation violating one of these (e.g., a bloated product interface, or modifying existing creators to add new types), something is off with the implementation, not the pattern.

Section 12

Bug Case Studies

Factory Method bugs are sneaky. The pattern itself is simple, but the ways it can go wrong in production are subtle — null returns, memory leaks, circular dependencies, missing registrations, and race conditions. Each bug below comes from real-world patterns we've seen in .NET codebases. Study them so you don't repeat them.

The Incident: A notification service started throwing NullReferenceException in production after a new "push" notification channel was added to the database by the marketing team. The factory's switch statement didn't have a case for "push," so it hit the default branch — which returned null. The null propagated through three method calls before finally crashing in an unrelated-looking line. The error message pointed to notification.SendAsync(), not the factory — making it look like a sending bug, not a creation bug. Root cause: the factory silently returned null instead of failing loudly. Impact: 2,400 push notifications silently dropped over 3 hours before anyone noticed.

Time to diagnose: 45 minutes. The stack trace pointed to SendAsync(), not the factory. The team searched the sending logic for 30 minutes before someone added a null-check upstream and traced it back to the factory.

channel = "push" switch (channel) no case for "push" → default: return null null NullReferenceException at notification.SendAsync() looks like a sending bug! The crash happens far from the real cause — the factory's missing case public class NotificationFactory { public INotification? Create(string channel) { return channel switch { "email" => new EmailNotification(smtp), "sms" => new SmsNotification(gateway), // "push" was never added here! _ => null // ← THE BUG: silently returns null }; } } // Caller — no null check, crashes later public async Task SendAlertAsync(string channel, string message) { var notification = factory.Create(channel); // notification is null when channel = "push" await notification.SendAsync(user, message); // 💥 NullReferenceException }

The default branch returns null instead of throwing. The caller doesn't check for null because it expects the factory to always return a valid object. When "push" arrives, null silently flows through until something tries to call a method on it.

public class NotificationFactory { public INotification Create(string channel) // no nullable return type! { return channel switch { "email" => new EmailNotification(smtp), "sms" => new SmsNotification(gateway), "push" => new PushNotification(firebase), // Fail LOUDLY on unknown types — don't return null _ => throw new ArgumentOutOfRangeException( nameof(channel), channel, $"Unknown notification channel: '{channel}'. " + $"Valid channels: email, sms, push.") }; } } // Now the error message tells you EXACTLY what's wrong: // "Unknown notification channel: 'push'. Valid channels: email, sms, push." // And it happens AT THE FACTORY, not 3 calls downstream.

Two fixes: (1) add the missing case, and (2) make the default branch throw with a descriptive message. The return type is now non-nullable (INotification, not INotification?), so the compiler enforces that every path returns a real object.

Lesson Learned: A factory method should NEVER return null. If it can't create the requested type, throw an exception with a clear message that names the unsupported input and lists the valid options. Fail at the creation point, not downstream. How to Spot This: Search your codebase for factory methods that return nullable types (T?) or have default: return null. Also look for // TODO: add new types here comments — they mean someone knew the switch was incomplete and left a trap for the next developer.
The Incident: A report generation service started consuming 4 GB of memory and eventually crashed with OutOfMemoryException after running for about 6 hours. The factory was creating a new PdfExporter on every request — and each PdfExporter loaded a 12 MB font library into memory on construction. At 50 requests per minute, that's 600 MB per hour of unreferenced font data waiting for garbage collection. Root cause: the factory treated stateless, reusable objects as if they needed fresh instances. Impact: service restarted 3 times in one day before the team found the leak.

Time to diagnose: 2 hours. Memory profiling showed thousands of identical PdfExporter instances on the heap, each holding the same font data. The trail led straight to the factory's Create() method.

Requests Req #1 Req #2 Req #N Factory new every call PdfExporter #1 (12MB) PdfExporter #2 (12MB) PdfExporter #N (12MB) HEAP MEMORY Memory grows with every request → OOM crash public class ExporterFactory { public IDocumentExporter Create(string format) { return format switch { "pdf" => new PdfExporter(), // ← 12 MB font lib loaded each time! "excel" => new ExcelExporter(), // ← template loaded each time! "csv" => new CsvExporter(), // ← this one is lightweight, but still wasteful _ => throw new ArgumentException($"Unknown format: {format}") }; } } // Called 50 times/minute in report generation pipeline // 50 × 12 MB = 600 MB/hour of PdfExporter instances on the heap

Every call to Create("pdf") allocates a brand new PdfExporter that loads fonts, templates, and rendering config from scratch. Since the exporter is stateless (same output for same input), there's zero reason to create a new one each time.

public class ExporterFactory { // Cache stateless exporters — they're reusable private readonly ConcurrentDictionary<string, IDocumentExporter> _cache = new(); private readonly IServiceProvider _sp; public ExporterFactory(IServiceProvider sp) => _sp = sp; public IDocumentExporter Create(string format) { return _cache.GetOrAdd(format, key => key switch { "pdf" => _sp.GetRequiredService<PdfExporter>(), "excel" => _sp.GetRequiredService<ExcelExporter>(), "csv" => _sp.GetRequiredService<CsvExporter>(), _ => throw new ArgumentException($"Unknown format: {key}") }); } } // Or even simpler — register as Singleton in DI: builder.Services.AddSingleton<PdfExporter>(); // one instance, reused forever builder.Services.AddSingleton<ExcelExporter>(); // Now 50 requests/minute all share the SAME PdfExporter instance // Memory: 12 MB once, not 12 MB × 50 × 60 × hours

Two approaches: cache inside the factory with ConcurrentDictionary, or register the products as singletons in DI. The key insight: if a product is stateless (no per-request data stored in fields), it can be safely shared across all callers.

Lesson Learned: Before writing new in a factory, ask: "Is this product stateless?" If yes, cache it. If it holds per-request state, create fresh — but make sure the expensive parts (font loading, template parsing) are shared or lazy-loaded. How to Spot This: In a memory profiler (dotMemory, Visual Studio diagnostic tools), look for many identical objects on the heap. If you see 500 PdfExporter instances that are all equivalent, the factory is over-creating. Also search for new HeavyObject() inside factory methods — if the object is heavy and stateless, it should be cached.
The Incident: An e-commerce app crashed on startup with a StackOverflowException. The DI container tried to create OrderService, which needed INotificationFactory, which needed IOrderService to look up order details for notification templates. Neither could be created without the other — an infinite construction loop. Root cause: the factory depended on a service that depended on the factory. Impact: deployment failed completely; the app couldn't start. Rolled back to previous version while the team untangled the dependency graph.

Time to diagnose: 20 minutes. The StackOverflowException stack trace showed the same two constructors alternating endlessly. Once you see that pattern, you know it's circular DI.

OrderService needs INotificationFactory NotificationFactory needs IOrderService depends on depends on Neither can be constructed → StackOverflowException on startup // OrderService needs the factory public class OrderService( INotificationFactory notificationFactory, // ← needs factory IOrderRepository repo) : IOrderService { public async Task PlaceOrderAsync(Order order) { await repo.SaveAsync(order); var notification = notificationFactory.Create(order.Channel); await notification.SendAsync(order.Email, "Order confirmed!"); } } // NotificationFactory needs the service (to get order details for templates) public class NotificationFactory( IOrderService orderService) : INotificationFactory // ← needs service! { public INotification Create(string channel) { // Why does it need IOrderService? To look up order data for templates return channel switch { "email" => new EmailNotification(orderService), // passes service in "sms" => new SmsNotification(orderService), _ => throw new ArgumentException($"Unknown: {channel}") }; } } // DI tries to create OrderService → needs NotificationFactory → needs OrderService → ∞

The factory constructor takes IOrderService, and OrderService takes the factory. The DI container can't resolve either without the other — classic circular dependency.

// Fix: factory should NOT depend on the service. // Instead, pass the data the notification needs, not the whole service. public class NotificationFactory(ISmtpClient smtp, ISmsGateway sms) : INotificationFactory { public INotification Create(string channel) { return channel switch { "email" => new EmailNotification(smtp), "sms" => new SmsNotification(sms), _ => throw new ArgumentException($"Unknown: {channel}") }; } } // Notification gets the data it needs via method parameters, not constructor public class EmailNotification(ISmtpClient smtp) : INotification { public async Task SendAsync(string to, string message) { // Order data passed in via 'message' parameter — no need for IOrderService await smtp.SendEmailAsync(to, "Order Update", message); } } // OrderService composes the message before calling the factory public class OrderService( INotificationFactory factory, IOrderRepository repo) : IOrderService { public async Task PlaceOrderAsync(Order order) { await repo.SaveAsync(order); var notification = factory.Create(order.Channel); // Pass the DATA, not the service var message = $"Order {order.Id} confirmed. Total: {order.Total:C}"; await notification.SendAsync(order.Email, message); } }

The fix: the factory depends on infrastructure (SMTP client, SMS gateway), not on business services. The order data that notifications need is passed as method parameters — no circular dependency.

Lesson Learned: Factories should depend on infrastructure (clients, gateways, configuration), not on business services. If your factory needs data from another service, pass that data as a method parameter instead of injecting the entire service. Keep the dependency graph a tree, not a cycle. How to Spot This: Draw your dependency graph. If you see any cycles (A → B → A), you have a circular dependency. In .NET, the DI container will throw at startup — so at least it fails fast. The fix is almost always "pass data, not services."
The Incident: A payment service worked perfectly in development but threw InvalidOperationException: No service for type 'StripeGateway' has been registered in production on the first Stripe payment attempt. The developer had added the StripeGateway class and factory case but forgot to register it in Program.cs. Root cause: the factory resolved types from the DI container, but the new type wasn't registered. Impact: all Stripe payments failed for 40 minutes until a hotfix was deployed.

Time to diagnose: 5 minutes (the error message was clear). Time to deploy the fix: 35 minutes (CI/CD pipeline). Total downtime: 40 minutes for a one-line fix.

PaymentFactory Create("stripe") resolve DI Container PayPalGateway ✓ StripeGateway ✗ (missing!) InvalidOperationException "No service for type 'StripeGateway' registered" // Program.cs — developer forgot to register StripeGateway builder.Services.AddTransient<PayPalGateway>(); // builder.Services.AddTransient<StripeGateway>(); ← MISSING! // PaymentFactory.cs — resolves from DI container public class PaymentFactory(IServiceProvider sp) : IPaymentFactory { public IPaymentGateway Create(string provider) { return provider switch { "paypal" => sp.GetRequiredService<PayPalGateway>(), // ✓ works "stripe" => sp.GetRequiredService<StripeGateway>(), // ✗ not registered! _ => throw new ArgumentException($"Unknown provider: {provider}") }; } } // Works in dev because tests mock the factory. // Crashes in prod on first Stripe payment — 40 min outage.

The factory code is correct — it asks the DI container for StripeGateway. But the type was never registered. This compiles fine and passes unit tests (which mock the factory). It only fails at runtime when the real DI container is asked for a type it doesn't know about.

// Fix 1: Register the type (obviously) builder.Services.AddTransient<PayPalGateway>(); builder.Services.AddTransient<StripeGateway>(); // Fix 2: Add a startup validation check — catch missing registrations BEFORE // the first request arrives public static class FactoryValidation { public static void ValidateFactoryRegistrations(this IServiceProvider sp) { // Try resolving every type the factory supports var requiredTypes = new[] { typeof(PayPalGateway), typeof(StripeGateway), // Add new types here as they're added to the factory }; foreach (var type in requiredTypes) { var service = sp.GetService(type); if (service is null) throw new InvalidOperationException( $"Factory dependency {type.Name} is not registered in DI. " + $"Add builder.Services.AddTransient<{type.Name}>() in Program.cs."); } } } // Program.cs — validate at startup, not at first request var app = builder.Build(); app.Services.ValidateFactoryRegistrations(); // fails FAST, before any traffic app.Run();

The real fix isn't just adding the registration — it's adding a startup validation that checks all factory dependencies are registered before the app starts serving traffic. This turns a runtime surprise into a boot-time failure with a clear error message.

Lesson Learned: When a factory resolves types from the DI container, add startup validation that checks all expected types are registered. Fail at boot time, not at the first customer request. Integration tests should also exercise the real DI container, not just mocked factories. How to Spot This: Search for GetRequiredService or GetService calls inside factory methods. For each resolved type, verify it has a corresponding AddTransient/AddScoped/AddSingleton registration in Program.cs. Better yet, write a test that boots the real DI container and resolves every factory product.
The Incident: A caching layer used a factory to lazy-initialize expensive analyzer objects. Under load testing, two threads hit the factory simultaneously. Both saw the cache as empty, both created an Analyzer instance, and both tried to store it. One thread's instance was silently lost, and — worse — the analyzer had a one-time initialization step (loading ML model weights) that wasn't idempotent. The second initialization corrupted shared state, causing incorrect analysis results for 12 hours before QA caught the data drift. Root cause: non-thread-safe lazy initialization in a singleton factory. Impact: subtle data corruption — the most dangerous kind of bug because it doesn't crash, just produces wrong results.

Time to diagnose: 12+ hours. No crashes, no exceptions. QA noticed analysis scores were slightly off compared to the previous week. The team eventually traced it to two concurrent initializations of the ML model weights, where the second one partially overwrote the first.

Thread A Thread B cache == null? Yes (empty) cache == null? Yes (still empty!) new Analyzer() #1 new Analyzer() #2 cache = ??? (race!) corrupted shared state Both threads see empty cache → both create → second write corrupts first public class AnalyzerFactory // registered as SINGLETON { private IAnalyzer? _cached; // no thread safety! public IAnalyzer GetOrCreate() { if (_cached is null) // ← Thread A checks: null → enters { // ← Thread B checks: STILL null → also enters _cached = new HeavyAnalyzer(); // Thread A creates instance #1 _cached.LoadModelWeights(); // Thread A starts loading weights // Thread B ALSO creates instance #2, overwrites _cached // Thread A's LoadModelWeights() is now operating on a LOST instance // Thread B calls LoadModelWeights() on its instance — partial load } return _cached; } }

The check-then-create sequence is not atomic. Two threads can both see _cached is null, both create an instance, and the second _cached = assignment overwrites the first. If LoadModelWeights() has side effects on shared state, it gets called twice on different objects — causing subtle corruption.

public class AnalyzerFactory { // Lazy<T> handles thread safety for you — only ONE thread creates private readonly Lazy<IAnalyzer> _analyzer = new(() => { var analyzer = new HeavyAnalyzer(); analyzer.LoadModelWeights(); // runs exactly ONCE, guaranteed return analyzer; }, LazyThreadSafetyMode.ExecutionAndPublication); public IAnalyzer GetOrCreate() => _analyzer.Value; } // Or use ConcurrentDictionary for keyed factories: public class AnalyzerFactory { private readonly ConcurrentDictionary<string, Lazy<IAnalyzer>> _cache = new(); public IAnalyzer GetOrCreate(string modelName) { // GetOrAdd + Lazy ensures only ONE thread creates per key var lazy = _cache.GetOrAdd(modelName, name => new Lazy<IAnalyzer>(() => { var analyzer = new HeavyAnalyzer(); analyzer.LoadModelWeights(name); return analyzer; }, LazyThreadSafetyMode.ExecutionAndPublication)); return lazy.Value; // first caller creates, all others wait and reuse } }

Lazy<T> with ExecutionAndPublication mode ensures that only one thread ever executes the creation logic. All other threads that call .Value concurrently will block and wait for the first thread to finish, then reuse the same instance. For keyed caches, wrap each value in Lazy<T> inside a ConcurrentDictionary.

Lesson Learned: If your factory caches instances and is used from a singleton or shared context, use Lazy<T> or ConcurrentDictionary<K, Lazy<V>> for thread-safe initialization. Never use a bare if (cache == null) check in concurrent code — it's a race condition waiting to happen. How to Spot This: Search for if (_field is null) or if (_field == null) patterns inside singleton or static classes. If the field is assigned inside that if block without locking, you have a race condition. Replace with Lazy<T> — it's simpler and correct by construction.
Section 13

Pitfalls & Anti-Patterns

Factory Method is one of the most intuitive patterns, but that ease of understanding leads to some sneaky mistakes. Most of these pitfalls don't cause compile errors — they cause design rot: code that works today but becomes a nightmare to extend tomorrow. Let's walk through the seven most common mistakes, why they happen, and how to fix them.

Mistake: You create one giant factory class that can produce every type of object your app needs — notifications, payments, reports, users, everything. Sounds convenient at first. After six months you have a 1,200-line factory with 30 switch cases.

Why This Happens: It starts innocently. You add a factory for notifications. Then someone needs a payment factory, and instead of creating a separate class, they add another method to the existing factory. The factory grows because it’s easy to extend an existing file than think about design.

GodFactory CreateNotification() / CreatePayment() EmailNotifier SmsNotifier PayPalGateway StripeGateway PdfReport One class, too many responsibilities — violates SRP // ❌ BAD — one factory does everything public class AppFactory { public INotifier CreateNotifier(string type) => type switch { "email" => new EmailNotifier(), "sms" => new SmsNotifier(), _ => throw new ArgumentException(type) }; public IPaymentGateway CreatePayment(string provider) => provider switch { "paypal" => new PayPalGateway(), "stripe" => new StripeGateway(), _ => throw new ArgumentException(provider) }; public IReport CreateReport(string format) => format switch { "pdf" => new PdfReport(), "xlsx" => new ExcelReport(), _ => throw new ArgumentException(format) }; // ... 15 more methods ... } // ✅ GOOD — separate factory per product family public class NotifierFactory : INotifierFactory { public INotifier Create(string type) => type switch { "email" => new EmailNotifier(), "sms" => new SmsNotifier(), _ => throw new ArgumentException(type) }; } public class PaymentGatewayFactory : IPaymentGatewayFactory { public IPaymentGateway Create(string provider) => provider switch { "paypal" => new PayPalGateway(), "stripe" => new StripeGateway(), _ => throw new ArgumentException(provider) }; }

Fix: One factory = one product family. If the products are unrelated (notifications, payments, reports), they get separate factories. Each factory has one reason to change, which is exactly what the Single Responsibility Principle asks for.

Mistake: When the factory gets a type it doesn't recognize, it quietly returns null. The caller doesn't check for null, and you get a NullReferenceException three method calls later — far from the actual bug.

Why This Happens: Developers think returning null is "safe" because it doesn't crash immediately. But null is not safe — it's a delayed crash. The real cause (unknown type) and the symptom (NRE) are in completely different places, making debugging a nightmare.

Factory returns null Service.Process() passes null along Handler.Run() passes null along renderer.Format() NRE! 💥 The bug is at the factory. The crash is at the renderer. Good luck debugging. // ❌ BAD — null travels silently through the call chain public INotifier? Create(string type) => type switch { "email" => new EmailNotifier(), "sms" => new SmsNotifier(), _ => null // "safe" — actually a ticking time bomb }; // ✅ GOOD — fail immediately with a clear message public INotifier Create(string type) => type switch { "email" => new EmailNotifier(), "sms" => new SmsNotifier(), _ => throw new ArgumentException( $"Unknown notifier type '{type}'. " + $"Valid types: email, sms. " + $"Did you forget to register a new type?") };

Fix: Throw immediately with a descriptive message that names the bad input and lists valid options. The stack trace points exactly to the factory, not to some random downstream method. If you genuinely need a "maybe" return, use a TryCreate pattern that returns bool with an out parameter — making the caller explicitly handle the failure case.

Mistake: Your factory returns INotifier, which is great. But then the caller immediately casts it to EmailNotifier to access some email-specific property like .SmtpServer. You've just defeated the entire purpose of the factory — the caller is now tightly coupled to the concrete type again.

Why This Happens: The concrete type has extra capabilities that aren't on the interface. Instead of enriching the interface, developers take the "quick fix" of casting. Over time, half the codebase is littered with (EmailNotifier) casts that break as soon as you swap implementations.

INotifierFactory returns INotifier Caller Code var email = (EmailNotifier)notifier; email.SmtpServer = "..."; Coupling restored — factory is pointless now The whole point was to NOT know the concrete type. Casting undoes it. // ❌ BAD — casting back to concrete type defeats the factory INotifier notifier = factory.Create("email"); var email = (EmailNotifier)notifier; // tight coupling is back email.SmtpServer = "smtp.company.com"; // only EmailNotifier has this // ✅ GOOD — put shared capabilities on the interface public interface INotifier { Task SendAsync(string to, string message); } public interface IConfigurableNotifier : INotifier { void Configure(IDictionary<string, string> settings); } // Caller uses the interface, never the concrete type: INotifier notifier = factory.Create("email"); if (notifier is IConfigurableNotifier configurable) configurable.Configure(new Dictionary<string, string> { ["smtp_server"] = "smtp.company.com" });

Fix: If callers need extra capabilities, extend the interface hierarchy. Use is pattern matching for optional features, not hard casts. The concrete type should stay hidden behind the interface boundary.

Mistake: You create a DateTimeFactory, a StringBuilderFactory, a ListFactory — factories for types that have perfectly good constructors and never vary. Every new in the codebase gets wrapped in a factory "just in case."

Why This Happens: Pattern enthusiasm. You just learned Factory Method and it clicks, so you apply it everywhere. The result is indirection with no benefit: extra files, extra interfaces, extra DI registrations — all wrapping a simple new List<T>().

Unnecessary Factory IStringBuilderFactory StringBuilderFactory DI registration 3 files, 0 value vs new StringBuilder() 0 files, same result // ❌ BAD — factory for a type that never varies public interface IStringBuilderFactory { StringBuilder Create(); } public class StringBuilderFactory : IStringBuilderFactory { public StringBuilder Create() => new StringBuilder(); } // Registered in DI, injected into 12 services... // All to avoid writing "new StringBuilder()" // ✅ GOOD — just use `new` when the type is simple and stable var sb = new StringBuilder(); // No factory needed. No interface. No DI registration. Done.

Fix: Use Factory Method when you have at least two product variants (or strong reason to expect them soon), when creation involves real logic (configuration, pooling, validation), or when the type is chosen at runtime. For simple value types and stable utility classes, new is the right answer.

Mistake: Your NotificationFactory calls a ChannelFactory, which calls a TransportFactory, which calls a ConnectionFactory. Four layers of factories to send an email. Nobody knows where objects actually get created, and debugging requires stepping through factories all the way down.

Why This Happens: Each layer of "abstraction" felt reasonable in isolation. A factory for notifications makes sense. A factory for channels makes sense. But chaining them creates a Rube Goldberg machine where every creation goes through four levels of indirection.

NotificationFactory ChannelFactory TransportFactory ConnectionFactory 4 factories deep just to send an email. Where does the object actually get created? // ❌ BAD — factory chain 4 levels deep public class NotificationFactory(IChannelFactory channelFactory) { public INotifier Create(string type) { var channel = channelFactory.Create(type); // layer 2 // ChannelFactory internally calls TransportFactory (layer 3) // TransportFactory internally calls ConnectionFactory (layer 4) return new Notifier(channel); } } // ✅ GOOD — flatten the chain, let DI compose the pieces public class NotificationFactory(IServiceProvider sp) : INotifierFactory { public INotifier Create(string type) => type switch { "email" => sp.GetRequiredService<EmailNotifier>(), "sms" => sp.GetRequiredService<SmsNotifier>(), _ => throw new ArgumentException($"Unknown: {type}") }; } // DI container handles wiring EmailNotifier's dependencies (channel, transport, connection). // One factory, one level of indirection, clear ownership.

Fix: Let the DI container compose deep dependency chains. Your factory should make one decision: which concrete type to create. The container handles wiring the rest. If you need nested creation, consider whether it's really one factory that takes a few parameters, not a chain of four factories.

Mistake: Your factory creates objects that hold database connections, file handles, or HTTP connections (they implement IDisposable). The caller uses the object but never disposes it. Connections leak, handles exhaust, and the app dies under load.

Why This Happens: When you call new DbConnection() directly, you naturally wrap it in a using block because you see the type. When a factory returns IDataProcessor, you don't see that the concrete type holds a connection — the abstraction hides the disposal requirement.

Factory.Create() IDataProcessor (holds DbConnection) (holds FileStream) Method exits No .Dispose()! LEAK The factory hides that the product is disposable. The caller never calls Dispose(). // ❌ BAD — factory creates IDisposable product, caller doesn't dispose public IDataProcessor Create(string source) => source switch { "sql" => new SqlProcessor(connectionString), // holds DbConnection "csv" => new CsvProcessor(filePath), // holds FileStream _ => throw new ArgumentException(source) }; // Caller code — no using block! var processor = factory.Create("sql"); processor.Process(data); // connection leaked when method exits // ✅ GOOD — make the product interface extend IDisposable public interface IDataProcessor : IDisposable { void Process(DataSet data); } // Caller uses `using` naturally: using var processor = factory.Create("sql"); processor.Process(data); // Dispose() called automatically // Alternative: async disposal with IAsyncDisposable await using var processor = factory.Create("sql"); await processor.ProcessAsync(data);

Fix: If any concrete product implements IDisposable, make the product interface extend IDisposable (or IAsyncDisposable). This signals to every caller: "you must dispose what you get." The using keyword then kicks in naturally.

Mistake: You inject IServiceProvider into every class and call sp.GetService<INotifier>() wherever you need a notifier. You call it a "factory" because it creates objects, but it's actually the Service Locator anti-pattern — your class depends on the entire container instead of the specific things it needs.

Why This Happens: IServiceProvider is easy to inject and can resolve anything. It feels like a universal factory. But it hides your class's real dependencies behind a generic "give me anything" interface, making dependencies invisible and testing painful.

Service Locator (Anti-Pattern) class OrderService(IServiceProvider sp) sp.GetService<INotifier>() sp.GetService<ILogger>() sp.GetService<IPayment>() Dependencies hidden — which ones does it REALLY need? Factory Method (Correct) class OrderService( INotifierFactory notifiers, ILogger logger, IPaymentGateway payment) Dependencies explicit — easy to test and understand // ❌ BAD — Service Locator disguised as "factory usage" public class OrderService(IServiceProvider sp) { public async Task PlaceOrder(Order order) { var notifier = sp.GetRequiredService<INotifier>(); // hidden dependency var payment = sp.GetRequiredService<IPaymentGateway>(); // hidden dependency var logger = sp.GetRequiredService<ILogger>(); // hidden dependency // What does OrderService ACTUALLY depend on? Nobody knows without reading the code. } } // ✅ GOOD — explicit dependencies via constructor injection public class OrderService( INotifierFactory notifierFactory, IPaymentGateway payment, ILogger<OrderService> logger) { public async Task PlaceOrder(Order order) { var notifier = notifierFactory.Create(order.NotificationPreference); await payment.ChargeAsync(order.Total); logger.LogInformation("Order {Id} placed", order.Id); // Constructor signature IS the dependency list. Instantly readable. } }

Fix: Inject specific factory interfaces, not IServiceProvider. The only places where IServiceProvider is acceptable are: (1) inside the factory implementation itself, (2) in middleware/framework plumbing, and (3) in IHostedService that needs to create scopes. Application code should never touch the container directly.

Seven factory pitfalls to avoid: (1) God Factory that creates everything violates SRP — split by product family. (2) Returning null instead of throwing creates delayed NREs — throw with descriptive messages. (3) Leaking concrete types via casting defeats the factory's purpose — extend the interface. (4) Overusing factories for trivial types adds pointless indirection — use `new` when it's simple. (5) Nested factory chains are undebuggable — let DI compose deep graphs. (6) Not disposing factory-created products leaks resources — make the product interface extend IDisposable. (7) Service Locator disguised as factory hides dependencies — inject specific factory interfaces.
Section 14

Testing Strategies

Factory Method is one of the most testable patterns out there, because it's built on interfaces. The factory returns an interface, the caller depends on an interface, and interfaces are trivially mockable. But there are four distinct testing angles you should cover, and each one catches a different category of bugs.

E2E Factory + DI + real services Integration Factory + DI container (Strategy 3) Unit Tests Strategy 1: Correct type returned Strategy 2: Mock/fake products Strategy 4: Shared workflow logic Most tests here Some tests here Few tests here

The simplest test: call the factory with a known input and assert the returned type is correct. This catches typos in switch expressions, missing registrations, and wrong mappings. It's fast, deterministic, and should be your first line of defense.

Notice we test three things: (1) known inputs return the right type, (2) unknown inputs throw the right exception, and (3) the returned object actually implements the interface. These three checks catch 90% of factory bugs.

public class NotifierFactoryTests { private readonly NotifierFactory _sut = new(); [Theory] [InlineData("email", typeof(EmailNotifier))] [InlineData("sms", typeof(SmsNotifier))] [InlineData("push", typeof(PushNotifier))] public void Create_KnownType_ReturnsCorrectConcreteType( string type, Type expectedType) { // Act INotifier result = _sut.Create(type); // Assert — correct concrete type Assert.IsType(expectedType, result); // Also verify it implements the interface (catches accidental changes) Assert.IsAssignableFrom<INotifier>(result); } [Fact] public void Create_UnknownType_ThrowsArgumentException() { // Act & Assert var ex = Assert.Throws<ArgumentException>( () => _sut.Create("carrier_pigeon")); // Verify the exception message is helpful (not just "Parameter is not valid") Assert.Contains("carrier_pigeon", ex.Message); Assert.Contains("Valid types", ex.Message); } [Fact] public void Create_AllSupportedTypes_NoneReturnNull() { var types = new[] { "email", "sms", "push" }; foreach (var type in types) { var result = _sut.Create(type); Assert.NotNull(result); // Catches the "return null" pitfall from S13 } } }

Sometimes you don't want to test the factory itself — you want to test the code that uses the factory. In that case, you create a fake factory that returns a mock product, isolating the consumer from the real creation logic.

This is where Factory Method really shines for testability. Because the consumer depends on INotifierFactory (an interface), you can swap in a test double that returns whatever you want — a fake that records calls, a mock that throws, or a stub that returns a canned response.

public class OrderServiceTests { [Fact] public async Task PlaceOrder_SendsNotificationToCustomer() { // Arrange — fake notifier that records what it was asked to send var fakeNotifier = new FakeNotifier(); var fakeFactory = new FakeNotifierFactory(fakeNotifier); var sut = new OrderService(fakeFactory, NullLogger<OrderService>.Instance); // Act await sut.PlaceOrder(new Order { CustomerId = "C42", Total = 99.99m }); // Assert — the order service asked the factory for a notifier and used it Assert.Single(fakeNotifier.SentMessages); Assert.Contains("C42", fakeNotifier.SentMessages[0].To); } [Fact] public async Task PlaceOrder_FactoryThrows_LogsErrorAndDoesNotCrash() { // Arrange — factory that always throws (simulating a configuration error) var brokenFactory = new BrokenNotifierFactory(); var logger = new FakeLogger<OrderService>(); var sut = new OrderService(brokenFactory, logger); // Act — should not throw await sut.PlaceOrder(new Order { CustomerId = "C42", Total = 99.99m }); // Assert — error was logged, not swallowed silently Assert.Contains(logger.Entries, e => e.Level == LogLevel.Error); } } // Test doubles — simple fakes, no mocking framework needed public class FakeNotifier : INotifier { public List<(string To, string Message)> SentMessages { get; } = new(); public Task SendAsync(string to, string msg) { SentMessages.Add((to, msg)); return Task.CompletedTask; } } public class FakeNotifierFactory(INotifier notifier) : INotifierFactory { public INotifier Create(string type) => notifier; } public class BrokenNotifierFactory : INotifierFactory { public INotifier Create(string type) => throw new InvalidOperationException("Factory misconfigured"); }

Unit tests verify logic in isolation. But what if the factory is wired to the DI container and the registration is wrong? Integration tests spin up a real IServiceProvider and verify that the factory can actually resolve its dependencies end-to-end.

This catches the "Missing Registration" bug from S12 — where the factory code is correct but the DI wiring is incomplete. You're testing the composition root, not the factory logic.

public class FactoryDiIntegrationTests { private readonly IServiceProvider _sp; public FactoryDiIntegrationTests() { // Build a real DI container with the same registrations as Program.cs var services = new ServiceCollection(); services.AddTransient<EmailNotifier>(); services.AddTransient<SmsNotifier>(); services.AddTransient<PushNotifier>(); services.AddSingleton<INotifierFactory, NotifierFactory>(); _sp = services.BuildServiceProvider(); } [Theory] [InlineData("email")] [InlineData("sms")] [InlineData("push")] public void Factory_WithRealDI_ResolvesAllSupportedTypes(string type) { // Arrange var factory = _sp.GetRequiredService<INotifierFactory>(); // Act — this will throw if a dependency is missing from the container var notifier = factory.Create(type); // Assert Assert.NotNull(notifier); Assert.IsAssignableFrom<INotifier>(notifier); } [Fact] public void AllFactoryTypes_AreRegisteredInContainer() { // Verify that every type the factory supports is registered // This catches the "added to factory switch, forgot to register" bug var factory = _sp.GetRequiredService<INotifierFactory>(); var supportedTypes = new[] { "email", "sms", "push" }; var exceptions = new List<string>(); foreach (var type in supportedTypes) { try { factory.Create(type); } catch (Exception ex) { exceptions.Add($"{type}: {ex.Message}"); } } Assert.Empty(exceptions); // If any fail, the error shows which types are broken } }

Factory Method has a dual role: it creates objects and it often lives inside a Creator class that has shared workflow logic (the Template Method aspect). You need to test that workflow separately from the factory resolution.

The key insight: create a TestableCreator subclass that overrides the factory method to return a fake product. Now you can test the workflow logic in isolation, without any real products being created.

// The Creator has a workflow: validate → create product → process → log public abstract class DocumentExporter { public ExportResult Export(Document doc) { if (doc is null) throw new ArgumentNullException(nameof(doc)); if (doc.Pages.Count == 0) return ExportResult.Empty; var renderer = CreateRenderer(); // Factory Method — subclass decides the type var bytes = renderer.Render(doc); return new ExportResult(bytes, renderer.FileExtension); } protected abstract IDocumentRenderer CreateRenderer(); } // Test the workflow without any real rendering public class DocumentExporterWorkflowTests { // Testable subclass — plugs in a fake renderer private class TestableExporter(IDocumentRenderer renderer) : DocumentExporter { protected override IDocumentRenderer CreateRenderer() => renderer; } [Fact] public void Export_NullDocument_ThrowsArgumentNull() { var sut = new TestableExporter(new FakeRenderer()); Assert.Throws<ArgumentNullException>(() => sut.Export(null!)); } [Fact] public void Export_EmptyDocument_ReturnsEmptyResult() { var sut = new TestableExporter(new FakeRenderer()); var doc = new Document { Pages = new List<Page>() }; var result = sut.Export(doc); Assert.Equal(ExportResult.Empty, result); } [Fact] public void Export_ValidDocument_CallsRendererAndReturnsBytes() { var fakeRenderer = new FakeRenderer(); var sut = new TestableExporter(fakeRenderer); var doc = new Document { Pages = { new Page("Hello") } }; var result = sut.Export(doc); Assert.True(fakeRenderer.RenderWasCalled); Assert.Equal(".fake", result.Extension); Assert.NotEmpty(result.Bytes); } } public class FakeRenderer : IDocumentRenderer { public bool RenderWasCalled { get; private set; } public string FileExtension => ".fake"; public byte[] Render(Document doc) { RenderWasCalled = true; return new byte[] { 0x46, 0x41, 0x4B, 0x45 }; } }
Four testing strategies cover all angles of Factory Method: (1) test the factory itself to verify correct type mapping, (2) use fake factories to test consumer code in isolation, (3) integration-test with a real DI container to catch missing registrations, and (4) use testable subclasses to verify the Creator's shared workflow independently of the product types.
Section 15

Performance Considerations

Let’s address the elephant in the room: Factory Method adds a virtual method call and an interface dispatch. Should you worry about that? Almost certainly not. A virtual method call costs about 2–5 nanoseconds on modern hardware — the same time it takes light to travel one meter. Unless your factory is called millions of times per second in a tight loop, the pattern’s overhead is invisible next to real work like database queries (5–50 ms) or HTTP calls (50–500 ms).

That said, certain factory implementations can introduce performance problems. Not from the pattern itself, but from what you do inside the factory. Here’s a breakdown of the common costs and how to manage them.

Concern Typical Cost When It Matters Mitigation
Virtual method dispatch 2–5 ns Never in practice None needed — JIT often devirtualizes
Object allocation per call ~20–50 ns + GC pressure Hot loops creating thousands of objects Object pooling (ObjectPool<T>)
Reflection-based type resolution ~500–2000 ns Factory resolves types via Activator.CreateInstance Cache compiled delegates; use FrozenDictionary
Dictionary lookup for type mapping ~10–30 ns (O(1)) Watch for GC from captured closures Use static lambdas or FrozenDictionary
Lazy initialization (first call) ~100–300 ns (once) First request latency spike Warm up in IHostedService at startup
DI container resolution ~50–200 ns Transient services with deep dependency graphs FrozenDictionary for .NET 8+ immutable lookups
Time (nanoseconds, log scale) ~3 ns Virtual dispatch ~20 ns Dictionary lookup ~35 ns Object allocation ~100 ns DI resolution ~1000 ns Reflection activation 1000 ns 500 ns 0 ns

The takeaway from the chart: virtual dispatch and dictionary lookups are noise. Allocation matters only at high volume. DI resolution is fast for shallow graphs. Reflection is the only one that can genuinely hurt in hot paths — and it's avoidable.

Here's a BenchmarkDotNet benchmark that measures the real cost of different factory implementation styles. Run it yourself to see exact numbers on your hardware.

using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; using System.Collections.Frozen; [MemoryDiagnoser] [SimpleJob(warmupCount: 3, iterationCount: 5)] public class FactoryBenchmarks { private readonly Dictionary<string, Func<INotifier>> _dict = new() { ["email"] = () => new EmailNotifier(), ["sms"] = () => new SmsNotifier(), }; private readonly FrozenDictionary<string, Func<INotifier>> _frozen = new Dictionary<string, Func<INotifier>> { ["email"] = static () => new EmailNotifier(), ["sms"] = static () => new SmsNotifier(), }.ToFrozenDictionary(); // 1. Direct new — baseline [Benchmark(Baseline = true)] public INotifier DirectNew() => new EmailNotifier(); // 2. Switch expression — compiler-optimized [Benchmark] public INotifier SwitchFactory() => "email" switch { "email" => new EmailNotifier(), "sms" => new SmsNotifier(), _ => throw new ArgumentException("Unknown") }; // 3. Dictionary lookup [Benchmark] public INotifier DictionaryFactory() => _dict["email"](); // 4. FrozenDictionary (.NET 8+) [Benchmark] public INotifier FrozenDictFactory() => _frozen["email"](); // 5. Reflection — avoid in hot paths! [Benchmark] public INotifier ReflectionFactory() => (INotifier)Activator.CreateInstance(typeof(EmailNotifier))!; }

Typical results on an Intel i7-12700K (.NET 8):

MethodMeanAllocatedNotes
DirectNew~3 ns24 BBaseline — just allocation
SwitchFactory~5 ns24 BNear-baseline, JIT optimizes the switch
DictionaryFactory~22 ns24 BHash + closure invoke
FrozenDictFactory~12 ns24 BFaster lookup, static lambda = no closure GC
ReflectionFactory~800 ns48 B160x slower — avoid in hot paths
Bottom Line: Factory Method's pattern overhead (virtual dispatch + interface call) is negligible — single-digit nanoseconds. The real performance cost comes from what happens inside the factory: reflection is 100x+ slower than a switch, and unnecessary allocations add GC pressure. For hot paths, use switch expressions or FrozenDictionary with static lambdas. For everything else (99% of factory usage), don't optimize — clarity beats nanoseconds. Factory Method's pattern overhead is negligible (~3-5 ns for virtual dispatch). Real performance costs come from implementation choices: reflection is 100x+ slower than switch expressions, closures add GC pressure, and Lazy<T> has a one-time first-call cost. For hot paths, use switch expressions or FrozenDictionary with static lambdas. For the other 99% of factories, optimize for readability — the database query your factory product will execute takes 1,000,000x longer than the factory itself.
Section 16

Interview Pitch

When an interviewer asks "Tell me about Factory Method," you have about 90 seconds before they either move on or follow up. Here's a script that covers all the bases without sounding rehearsed.

90-Second Pitch:

Opening (What it is): "Factory Method is a creational pattern where you define a method for creating objects, but let subclasses or implementations decide which concrete class to instantiate. Instead of hard-coding new SpecificClass() everywhere, you call a method that returns an interface — and the actual type is chosen behind the scenes."

Core (How it works): "You have two abstractions: a Creator that declares the factory method, and a Product interface for what gets created. Each ConcreteCreator overrides the factory method to return a different ConcreteProduct. The caller works with the Creator and Product interfaces — never the concrete types. This means you can add new product types without changing any existing code."

Example (.NET): "In .NET, ILoggerFactory.CreateLogger() is a textbook Factory Method. You call it with a category name, and it returns an ILogger. Whether that logger writes to the console, a file, or Application Insights depends on how the factory was configured at startup — your code never knows or cares. IHttpClientFactory.CreateClient() follows the exact same pattern for HTTP clients."

When to use: "I reach for Factory Method when the type of object I need is decided at runtime, when creation involves non-trivial logic like configuration or pooling, or when I want to follow the Open/Closed Principle so adding new types doesn't require modifying existing classes."

Close (SOLID connection): "It supports Open/Closed because new types are new classes, not changes. It supports Dependency Inversion because high-level code depends on interfaces, not concrete types. And it makes testing easy because you can swap the factory with a fake in tests."

Tip: If the interviewer follows up with "How is it different from Abstract Factory?", the short answer is: Factory Method creates one product at a time via a single method, while Abstract Factory creates families of related products via multiple methods. Factory Method uses inheritance (subclass overrides a method); Abstract Factory uses composition (inject a factory object).
Section 17

Q&As

29 questions organized by difficulty. Click any question to reveal the answer. Each one starts with a "Think First" prompt — try to answer before peeking. The hard questions are the ones that come up in senior/staff-level interviews.

Easy Foundations (Q1—Q6)

Think First Imagine ordering food at a restaurant. You say "I want a burger," but you don't go into the kitchen. Who decides how it's made?

Factory Method is a design where you ask for an object through a method, and something else decides which specific class to create. You work with the result through an interface — you don't know (or care) what concrete class was instantiated behind the scenes.

Think of it like this: you call factory.Create("email") and get back an INotifier. Whether that's an EmailNotifier, a SendGridNotifier, or a MockNotifier depends on how the factory is configured. Your code just calls notifier.Send() and it works.

The formal GoF definition: "Define an interface for creating an object, but let subclasses decide which class to instantiate." In modern .NET, "subclasses" often becomes "the DI container" or "a delegate," but the core idea is identical: the caller doesn't pick the type.

Think First What happens to every file that uses new EmailNotifier() when you need to swap to SendGridNotifier?

When you write new EmailNotifier(), you've hard-coded the concrete type. Every file that has that line is now tightly coupled to EmailNotifier. If you switch to SendGrid, you find-and-replace across the entire codebase.

With Factory Method, the concrete type is decided in one place (the factory). Every other file just calls factory.Create() and gets an INotifier. Switching from EmailNotifier to SendGridNotifier means changing one factory class — zero changes everywhere else.

The trade-off: new is simpler. If the type never changes and creation is trivial, new is the right choice. Factory Method earns its keep when you have multiple implementations, runtime type selection, or complex creation logic.

Think First Think about .NET APIs where you call a Create...() method and get back an interface.

1. ILoggerFactory.CreateLogger(categoryName) — returns ILogger. The factory decides whether it's a console logger, file logger, or Application Insights logger based on configuration.

2. IHttpClientFactory.CreateClient(name) — returns HttpClient. The factory configures the client with the right base URL, headers, retry policies, and handler pipeline based on the registered name.

3. IDbContextFactory<T>.CreateDbContext() — returns a fresh DbContext instance. Essential in Blazor Server and background services where a single DbContext can't be shared across threads.

Think First What goes wrong when 50 files all say new ConcreteClass() and you need to change the concrete class?

It solves the tight coupling between creation code and consuming code. Without Factory Method, every place that creates an object knows exactly which concrete class to instantiate. That means:

  • Adding a new variant requires modifying every creation site
  • Switching implementations is a find-and-replace nightmare
  • Testing requires the real concrete class (can't swap in fakes)
  • You violate the Open/Closed Principle every time a new type appears

Factory Method centralizes the "which type?" decision into one place, so the rest of the codebase works through interfaces and never knows which concrete class it's using.

Think First What's wrong with creating an IStringBuilderFactory? (Hint: see Pitfall 4.)

Don't use Factory Method when:

  • Only one implementation exists and you don't expect more. A factory for StringBuilder is overhead with zero benefit.
  • Creation is trivial — no configuration, no pooling, no conditional logic. Just new Thing().
  • The type is known at compile time and never varies. No need for runtime indirection.
  • .NET 8 keyed services already solve your problem. If you just need named resolution without workflow logic, use [FromKeyedServices("name")] — no factory class needed.

The rule of thumb: if you can delete the factory and replace all usages with new ConcreteType() without any downside, the factory was unnecessary.

Think First Two roles are about creating, two are about the thing being created. Can you name all four?

1. Product (interface) — the shared contract for the objects being created. Example: INotifier.

2. ConcreteProduct (class) — a specific implementation. Example: EmailNotifier, SmsNotifier.

3. Creator (abstract class/interface) — declares the factory method. May also contain shared workflow logic. Example: NotificationService with an abstract CreateNotifier().

4. ConcreteCreator (class) — overrides the factory method to return a specific ConcreteProduct. Example: EmailNotificationService that returns new EmailNotifier().

Medium Applied Knowledge (Q7—Q16)

Think First Factory Method has ONE create method. Abstract Factory has MANY. What does that imply about what each one creates?
Factory Method One method → one product type CreateNotifier() → INotifier Uses: inheritance (subclass overrides) Scope: single product variation Abstract Factory Many methods → product family CreateButton() + CreateCheckbox() + ... Uses: composition (inject factory object) Scope: coordinated product families

Factory Method is about one creation point: a single method that returns one product type. Subclasses override that method to change which concrete type is returned. It uses inheritance.

Abstract Factory is about families of related products: a factory interface with multiple Create...() methods that all produce related types (e.g., CreateButton(), CreateCheckbox(), CreateTextBox() for a UI toolkit). It uses composition — you inject the factory object.

In practice, Abstract Factory is often implemented with Factory Methods. Each Create...() method in the Abstract Factory is a Factory Method.

Think First DI gives you a pre-built object. Factory Method creates one on demand. When do you need each?

DI and Factory Method are complementary, not competing:

  • DI gives you a pre-built object at construction time. You register INotifierEmailNotifier and the container injects it when the class is constructed.
  • Factory Method creates objects on demand at runtime, often based on runtime data. "Give me the right notifier for this specific order's notification preference."

Use DI when the type is known at configuration time. Use Factory Method when the type depends on runtime input. In modern .NET, factories themselves are typically registered in DI: services.AddSingleton<INotifierFactory, NotifierFactory>().

Think First Static methods can't be overridden. What does that mean for the "subclass decides" part of Factory Method?

Technically, no — a static method can't be overridden by subclasses, which is central to the GoF Factory Method pattern. If the method is static, you have a Simple Factory (also called Static Factory Method), which is useful but isn't the GoF pattern.

That said, C# has a convention of static factory methods on the type itself: TimeSpan.FromSeconds(30), Task.FromResult(value), IPAddress.Parse("..."). These are excellent API design but serve a different purpose: they're alternative constructors, not polymorphic creation points.

Use static factory methods for convenience constructors on a single type. Use the GoF Factory Method when you need polymorphic creation (subclass decides the type).

Think First Returning null leads to NREs (S12 Bug 1). What's better?

Three options, ranked best to worst:

  1. Throw ArgumentException with a message that names the bad input AND lists valid options. This is the default choice. The stack trace points right at the factory.
  2. TryCreate pattern — return bool + out parameter, like int.TryParse(). Use when the caller legitimately doesn't know if the type is supported.
  3. Return a default/null-object (a do-nothing implementation). Only use if "do nothing" is genuinely acceptable behavior.

Never return null. It moves the crash from the factory (easy to debug) to somewhere downstream (nightmare to debug).

Think First What state does a factory typically hold? If none, what about caching?

The factory method itself is typically thread-safe because it usually holds no mutable state — it just creates a new object and returns it. A pure switch expression with new calls is inherently thread-safe.

Thread-safety problems appear when factories cache instances:

  • Mutable dictionary as cache — two threads calling Create("email") simultaneously can corrupt a Dictionary<K,V>. Use ConcurrentDictionary.
  • Lazy initialization without locks — two threads see the cache as empty, both create an instance, one overwrites the other. Use Lazy<T> or ConcurrentDictionary.GetOrAdd().
  • Singleton products that aren't thread-safe — if the factory caches and returns the same instance to all callers, that instance must be thread-safe.

Rule: if your factory creates a new object each time, it's thread-safe. If it caches, use ConcurrentDictionary<K, Lazy<V>>.

Think First If the consumer depends on INotifierFactory, what can you inject in tests?

Three approaches (see S14 for full code):

  1. Fake factory — create a test implementation of INotifierFactory that returns a fake/mock product. No mocking framework needed.
  2. Moq / NSubstitutevar mockFactory = new Mock<INotifierFactory>(); mockFactory.Setup(f => f.Create(It.IsAny<string>())).Returns(fakeNotifier);
  3. Testable subclass — if using the GoF inheritance approach, create a TestableCreator that overrides the factory method to return a fake.

The key point: because the consumer depends on an interface (not a concrete factory), swapping in test doubles is trivial. This is one of Factory Method's biggest practical benefits.

Think First Strategy swaps algorithms. Factory Method creates objects. What if the algorithms are... created by a factory?

Absolutely — and it's a very common combination. The factory creates the strategy at runtime based on some input, and the context uses the strategy without knowing which one it got.

// The factory creates strategies at runtime public class PricingStrategyFactory : IPricingStrategyFactory { public IPricingStrategy Create(CustomerTier tier) => tier switch { CustomerTier.Standard => new StandardPricing(), CustomerTier.Premium => new PremiumPricing(), // 10% discount CustomerTier.VIP => new VipPricing(), // 25% discount _ => throw new ArgumentException($"Unknown tier: {tier}") }; } // The context doesn't know which strategy it's using public class OrderService(IPricingStrategyFactory pricingFactory) { public decimal CalculateTotal(Order order) { var strategy = pricingFactory.Create(order.Customer.Tier); return strategy.CalculatePrice(order.Items); } }

Factory Method handles which strategy to use. Strategy handles how the algorithm works. Clean separation of concerns.

Think First Simple Factory is a static method with a switch. Factory Method uses inheritance. Which one is extensible without modification?

Simple Factory is a static method (or a class with a non-virtual method) that uses a switch to pick the concrete type. It's not a GoF pattern — it's just a helper. To add a new type, you modify the switch.

Factory Method uses a virtual/abstract method that subclasses override. Adding a new type means adding a new subclass — existing code stays untouched (Open/Closed Principle).

Simple Factory is fine for small, stable sets of types. Factory Method earns its keep when types change frequently or the creation decision needs to be customizable per use case.

Think First "Open for extension, closed for modification." How does a new ConcreteCreator achieve both?

Open/Closed says: you should be able to add new behavior without changing existing code.

With Factory Method, adding a new product type means:

  1. Write a new ConcreteProduct class (implements the Product interface)
  2. Write a new ConcreteCreator class (overrides the factory method)
  3. Register it in the DI container (one line in Program.cs)

None of the existing Creator, Product, or consumer code is modified. The new type is purely additive. Compare this to a switch statement where every new type requires editing the switch — that's a modification, which OCP tries to avoid.

Think First Keyed services let you register multiple implementations with string keys. Does that eliminate the need for a factory class?

.NET 8 Keyed Services let you register multiple implementations of the same interface, each with a unique key:

builder.Services.AddKeyedTransient<INotifier, EmailNotifier>("email"); builder.Services.AddKeyedTransient<INotifier, SmsNotifier>("sms"); // Resolve by key — no factory class needed public class OrderController( [FromKeyedServices("email")] INotifier emailNotifier) { }

This eliminates the factory class when all you need is named resolution with no workflow logic. But Factory Method is still valuable when:

  • The key comes from runtime data (not known at injection time)
  • The Creator has shared workflow around the creation
  • You need custom creation logic (pooling, validation, configuration)

Think of keyed services as a simple case optimization. They cover 70% of factory use cases with zero boilerplate.

Hard Expert Level (Q17—Q29)

Think First In microservices, you can't just new an object that lives in another service. What does "creating" a product mean across service boundaries?
Order Service IPaymentFactory Factory Creates HTTP client proxy Payment Service Stripe / PayPal / etc. Create("stripe") HTTP call Factory creates a local proxy that talks to the remote service over HTTP

In microservices, the factory doesn't create the real object — it creates a proxy (client) that communicates with the remote service via HTTP/gRPC. The Product interface stays the same (IPaymentGateway), but the ConcreteProduct is a client wrapper:

  • Factory picks the right HTTP client based on provider name ("stripe", "paypal")
  • Product is a typed HTTP client that wraps API calls behind the IPaymentGateway interface
  • Registration uses IHttpClientFactory for connection pooling and resilience
  • Discovery may use a service registry (Consul, Kubernetes DNS) to find the right endpoint

The pattern is identical — only the transport changes from in-process method calls to network calls.

Think First Activator.CreateInstance() uses reflection. How much slower is that than new?

Reflection-based factories (using Activator.CreateInstance or Type.GetConstructors()) are ~100-300x slower than direct new:

  • new EmailNotifier(): ~3 ns — compiler-generated IL, JIT-optimized
  • Activator.CreateInstance(type): ~800-2000 ns — type lookup, constructor lookup, boxing, security checks
  • Compiled lambda via Expression.Compile(): ~5-10 ns after initial compile — first call is expensive (~50 ms), subsequent calls match new

If you must use reflection (e.g., plugin systems that discover types at startup), compile a delegate at startup and cache it. The FrozenDictionary<string, Func<IProduct>> pattern gives you O(1) lookup + direct-call speed at runtime, paying the reflection cost only once during boot.

Think First What if creating a product requires an async operation, like opening a database connection or calling an external API?

The classic Factory Method returns a synchronous object. But what if creation requires async work — like connecting to a database, fetching configuration from a vault, or warming up a cache?

// Async factory interface public interface IConnectionFactory { Task<IDatabaseConnection> CreateAsync(string dbName, CancellationToken ct = default); } public class PostgresConnectionFactory(string connString) : IConnectionFactory { public async Task<IDatabaseConnection> CreateAsync( string dbName, CancellationToken ct = default) { var conn = new NpgsqlConnection($"{connString};Database={dbName}"); await conn.OpenAsync(ct); // async I/O await conn.ValidateAsync(ct); // health check return new PostgresConnection(conn); } } // Usage — caller awaits the factory await using var db = await connectionFactory.CreateAsync("orders", ct); var orders = await db.QueryAsync<Order>("SELECT * FROM orders");

Key design decisions:

  • Return Task<IProduct>, not IProduct — the caller knows creation is async
  • Accept CancellationToken — creation might be slow, callers need a way to abort
  • Make the product IAsyncDisposable so await using works
  • Consider ValueTask<IProduct> if the common path is cached (avoids allocation)
Think First Factory Method supports OCP and DIP. But can it violate SRP, LSP, or ISP?

Factory Method is usually presented as a SOLID champion, but it can violate SOLID when misapplied:

  • SRP violation: The Creator has two responsibilities — its own workflow logic and the factory method. If the factory decision is complex, extract it into a separate factory class.
  • LSP violation: If a ConcreteCreator's factory method returns a product that doesn't fully satisfy the Product interface contract (e.g., throws NotSupportedException for some methods), consumers that expect the full interface will break.
  • ISP violation: If the Product interface is fat (20 methods) and some ConcreteProducts only implement half, the factory is handing callers an interface that's too broad. Split the interface.
  • OCP violation: A switch-based factory that requires modification for each new type violates OCP. True OCP compliance requires the inheritance-based or plugin-based approach.

The pattern enables SOLID but doesn't guarantee it. You still need to apply the principles consciously.

Think First Plugin systems need to discover implementations without hard-coding them. How would you scan assemblies and register found types?
// Marker attribute for discoverable products [AttributeUsage(AttributeTargets.Class)] public class FactoryKeyAttribute(string key) : Attribute { public string Key { get; } = key; } // Concrete product — self-registers via attribute [FactoryKey("email")] public class EmailNotifier : INotifier { /* ... */ } [FactoryKey("sms")] public class SmsNotifier : INotifier { /* ... */ } // Discovery at startup — scan once, cache forever public static class NotifierRegistration { public static IServiceCollection AddNotifiers( this IServiceCollection services) { var types = typeof(INotifier).Assembly.GetTypes() .Where(t => t.IsClass && !t.IsAbstract && typeof(INotifier).IsAssignableFrom(t) && t.GetCustomAttribute<FactoryKeyAttribute>() is not null); var map = new Dictionary<string, Type>(); foreach (var type in types) { var key = type.GetCustomAttribute<FactoryKeyAttribute>()!.Key; services.AddTransient(type); // register in DI map[key] = type; } // Build an immutable lookup — zero overhead at runtime var frozen = map.ToFrozenDictionary(); services.AddSingleton<INotifierFactory>(sp => new DiscoveredNotifierFactory(sp, frozen)); return services; } } public class DiscoveredNotifierFactory( IServiceProvider sp, FrozenDictionary<string, Type> typeMap) : INotifierFactory { public INotifier Create(string type) { if (!typeMap.TryGetValue(type, out var concreteType)) throw new ArgumentException( $"Unknown type '{type}'. Available: {string.Join(", ", typeMap.Keys)}"); return (INotifier)sp.GetRequiredService(concreteType); } }

Reflection runs once at startup, building a FrozenDictionary that provides O(1) lookups at runtime with zero reflection cost. Adding a new notifier type means adding a class with the [FactoryKey] attribute — no factory code changes needed. True open/closed.

Think First A Func<string, INotifier> is a one-line factory. When does that stop being enough?

A Func<T> delegate is a factory — one that fits in a single line. Use it when:

  • The creation logic is simple (no configuration, no caching, no validation)
  • You don't need to unit test the factory independently
  • There's no shared workflow around the creation

Switch to a full Factory Method class when:

  • Creation involves configuration, caching, pooling, or validation
  • The factory needs its own dependencies (injected via DI)
  • You want to test the factory in isolation
  • The Creator has workflow logic around the creation (template method aspect)
  • You need a discoverable, named abstraction in your codebase (an interface, not an opaque Func)

Func<T> is the quick version. Factory Method is the robust version. Start with Func<T> and upgrade when you feel the pain.

Think First Your API has v1 and v2 of a payment gateway. Both must work simultaneously. How does the factory handle this?

In systems that run for years, you often need to support multiple versions of the same product simultaneously. The factory becomes version-aware:

public class PaymentGatewayFactory(IServiceProvider sp) : IPaymentGatewayFactory { public IPaymentGateway Create(string provider, int version = 2) => (provider, version) switch { ("stripe", 1) => sp.GetRequiredService<StripeV1Gateway>(), ("stripe", 2) => sp.GetRequiredService<StripeV2Gateway>(), ("paypal", _) => sp.GetRequiredService<PayPalGateway>(), _ => throw new ArgumentException( $"Unknown: {provider} v{version}") }; } // Callers can request specific versions: var gateway = factory.Create("stripe", version: 1); // legacy clients var gateway2 = factory.Create("stripe"); // defaults to v2

Key design choices: default to the latest version, allow explicit version requests for legacy clients, and use the factory as the single point where version routing is decided.

Think First Your factory throws because a type is unknown. In production, should the caller crash or degrade gracefully?

It depends on whether the factory failure is recoverable or fatal:

  • Fatal (missing DI registration, configuration error): Let it crash. Fail fast at startup with a clear error. Don't catch and swallow.
  • Recoverable (user requests unsupported type): Return a meaningful error response (HTTP 400), log a warning, don't crash the process.
  • Degradable (preferred type unavailable, fallback exists): Use a chain-of-responsibility factory that tries the preferred type, then falls back to a default.

The anti-pattern is catching the exception and returning a "default" product silently. The caller thinks it got what it asked for, but it's using a completely different implementation. This causes subtle, hard-to-diagnose bugs.

Think First Records are immutable. Value types are structs. Does Factory Method still apply?

Yes, but with nuances:

Records work perfectly with Factory Method. Records can implement interfaces, so the factory returns IPaymentResult and the concrete type is a record. Bonus: records are immutable, so there are no concerns about thread safety or state mutation after creation.

Value types (structs) are trickier. The factory returns an interface, but structs implementing interfaces get boxed (allocated on the heap, negating the struct's performance benefit). If you're using Factory Method with structs for performance, consider returning a concrete struct type via generics: T Create<T>() where T : struct, IProduct.

Think First All three create objects. What's the selection criteria?
Constructor Type known at compile time Few parameters (1-3) No configuration complexity new EmailNotifier(config) Factory Method Type chosen at runtime Multiple product variants Polymorphic creation factory.Create("email") Builder Complex configuration Many optional parameters Step-by-step construction .WithRetry(3).Build()
  • Constructor: The type is known, creation is simple, few parameters. Use new.
  • Factory Method: The type varies at runtime. You need polymorphic creation.
  • Builder: The type is known, but configuration is complex — many optional parameters, step-by-step setup.

They can combine: a factory that returns a builder (factory picks the product family, builder configures the instance), or a builder that uses a factory internally for component creation.

Think First A factory that takes IServiceProvider and resolves anything looks like a factory but acts like a Service Locator. Where's the line?

The line is: does the class resolve ONE specific product type, or can it resolve ANYTHING?

  • Factory: INotifierFactory.Create(string type) → INotifier. It creates exactly one thing: notifiers. It's focused and its purpose is clear from the interface.
  • Service Locator: IServiceProvider.GetService(Type type) → object. It can resolve anything. Injecting it hides what a class actually depends on.

A factory may use IServiceProvider internally (that's fine — it's an implementation detail). But a factory should never expose IServiceProvider to its consumers. The factory interface should be narrow and typed: INotifier Create(string type), not object GetService(Type type).

Think First What if you want to add logging, caching, or validation to every product the factory creates — without changing the factory?

Wrap the factory in a decorator that adds cross-cutting behavior:

// Original factory — unchanged public class NotifierFactory : INotifierFactory { public INotifier Create(string type) => type switch { "email" => new EmailNotifier(), "sms" => new SmsNotifier(), _ => throw new ArgumentException(type) }; } // Decorator: adds logging to every creation public class LoggingNotifierFactory( INotifierFactory inner, ILogger<LoggingNotifierFactory> logger) : INotifierFactory { public INotifier Create(string type) { logger.LogInformation("Creating notifier: {Type}", type); var sw = Stopwatch.StartNew(); var notifier = inner.Create(type); logger.LogInformation("Created {ConcreteType} in {Ms}ms", notifier.GetType().Name, sw.ElapsedMilliseconds); return notifier; } } // DI registration — decorator wraps the original services.AddSingleton<NotifierFactory>(); services.AddSingleton<INotifierFactory>(sp => new LoggingNotifierFactory( sp.GetRequiredService<NotifierFactory>(), sp.GetRequiredService<ILogger<LoggingNotifierFactory>>()));

This is the Decorator pattern applied to factories. You can stack them: logging → caching → validation → actual creation. Each layer is independently testable.

Think First Keyed services, source generators, and Native AOT are reshaping .NET. How do they affect factory patterns?

Factory Method is evolving, not disappearing:

  • Keyed Services (.NET 8+) eliminate simple factory classes for named resolution. ~70% of "factory" use cases become one-liner DI registrations.
  • Source Generators can auto-generate factory code from attributes at compile time, eliminating reflection entirely while keeping the discovery pattern from Q21.
  • Native AOT limits reflection (no Activator.CreateInstance in trimmed builds), pushing factories toward compile-time resolution via source generators or manual registration.
  • Primary Constructors (C# 12) make writing ConcreteCreators shorter — less boilerplate, same pattern.
  • Static abstract interface members (C# 11) enable type-level factory contracts: static abstract IProduct Create() on the Product interface itself.

The core idea — "something decides which type to create so the caller doesn't have to" — is permanent. Only the mechanics simplify with each C# release.

Section 18

Practice Exercises

Five hands-on exercises, ordered from easy to hard. Try to solve each one before peeking at the solution. The timer is optional but helps build interview-speed coding muscle.

Build a ShapeFactory that creates Circle, Square, and Triangle. Each shape implements IShape with a Draw() method that returns a string like "Drawing circle with radius 5". The factory takes a shape name (string) and returns IShape.

  • Define IShape with string Draw()
  • Create three classes: Circle, Square, Triangle
  • The factory should use a switch expression
  • Throw ArgumentException for unknown shapes
  • Write a test that verifies all three shapes are created correctly
public interface IShape { string Draw(); double Area(); } public class Circle(double radius) : IShape { public string Draw() => $"Drawing circle with radius {radius}"; public double Area() => Math.PI * radius * radius; } public class Square(double side) : IShape { public string Draw() => $"Drawing square with side {side}"; public double Area() => side * side; } public class Triangle(double baseLen, double height) : IShape { public string Draw() => $"Drawing triangle with base {baseLen} and height {height}"; public double Area() => 0.5 * baseLen * height; } public interface IShapeFactory { IShape Create(string type, params double[] dimensions); } public class ShapeFactory : IShapeFactory { public IShape Create(string type, params double[] dimensions) => type.ToLower() switch { "circle" => new Circle(dimensions[0]), "square" => new Square(dimensions[0]), "triangle" => new Triangle(dimensions[0], dimensions[1]), _ => throw new ArgumentException( $"Unknown shape '{type}'. Valid shapes: circle, square, triangle.") }; } // Test public class ShapeFactoryTests { [Theory] [InlineData("circle", typeof(Circle))] [InlineData("square", typeof(Square))] [InlineData("triangle", typeof(Triangle))] public void Create_KnownType_ReturnsCorrectShape(string type, Type expected) { var factory = new ShapeFactory(); var shape = factory.Create(type, 5, 3); Assert.IsType(expected, shape); } }

Build a notification system with three channels: Email, SMS, and Push. Each implements INotifier with Task SendAsync(string to, string message). Create a factory, register everything in the DI container, and write an integration test that verifies all three channels resolve correctly.

  • Define INotifier and INotifierFactory
  • Each notifier can just log the message (no real sending needed)
  • Register all three notifiers as transient in DI
  • The factory takes IServiceProvider and uses GetRequiredService
  • Integration test: build a real ServiceProvider and verify each type resolves
// Product interface public interface INotifier { string Channel { get; } Task SendAsync(string to, string message); } // Concrete products public class EmailNotifier(ILogger<EmailNotifier> logger) : INotifier { public string Channel => "email"; public Task SendAsync(string to, string message) { logger.LogInformation("EMAIL to {To}: {Msg}", to, message); return Task.CompletedTask; } } public class SmsNotifier(ILogger<SmsNotifier> logger) : INotifier { public string Channel => "sms"; public Task SendAsync(string to, string message) { logger.LogInformation("SMS to {To}: {Msg}", to, message); return Task.CompletedTask; } } public class PushNotifier(ILogger<PushNotifier> logger) : INotifier { public string Channel => "push"; public Task SendAsync(string to, string message) { logger.LogInformation("PUSH to {To}: {Msg}", to, message); return Task.CompletedTask; } } // Factory public interface INotifierFactory { INotifier Create(string channel); } public class NotifierFactory(IServiceProvider sp) : INotifierFactory { public INotifier Create(string channel) => channel.ToLower() switch { "email" => sp.GetRequiredService<EmailNotifier>(), "sms" => sp.GetRequiredService<SmsNotifier>(), "push" => sp.GetRequiredService<PushNotifier>(), _ => throw new ArgumentException( $"Unknown channel '{channel}'. Valid: email, sms, push.") }; } // DI Registration (Program.cs) // builder.Services.AddTransient<EmailNotifier>(); // builder.Services.AddTransient<SmsNotifier>(); // builder.Services.AddTransient<PushNotifier>(); // builder.Services.AddSingleton<INotifierFactory, NotifierFactory>(); // Integration Test public class NotifierFactoryIntegrationTests { [Theory] [InlineData("email", typeof(EmailNotifier))] [InlineData("sms", typeof(SmsNotifier))] [InlineData("push", typeof(PushNotifier))] public void Create_WithRealDI_ResolvesCorrectly(string channel, Type expected) { var sp = new ServiceCollection() .AddLogging() .AddTransient<EmailNotifier>() .AddTransient<SmsNotifier>() .AddTransient<PushNotifier>() .AddSingleton<INotifierFactory, NotifierFactory>() .BuildServiceProvider(); var factory = sp.GetRequiredService<INotifierFactory>(); var notifier = factory.Create(channel); Assert.IsType(expected, notifier); } }

You have a DocumentParser class with a giant switch statement that parses PDF, DOCX, and CSV files differently. Refactor it to use Factory Method so adding a new format (e.g., XLSX) requires zero changes to existing code.

  • Extract the interface: IDocumentParser with Document Parse(Stream content)
  • Create three parsers: PdfParser, DocxParser, CsvParser
  • Build IDocumentParserFactory that takes a file extension
  • The factory maps ".pdf" → PdfParser, ".docx" → DocxParser, ".csv" → CsvParser
  • Verify that adding XLSX means adding just a new class + one DI line
// Before: monolithic switch (violates OCP) // public Document Parse(string filePath) => Path.GetExtension(filePath) switch { // ".pdf" => ParsePdf(File.OpenRead(filePath)), // ".docx" => ParseDocx(File.OpenRead(filePath)), // ".csv" => ParseCsv(File.OpenRead(filePath)), // _ => throw new NotSupportedException(...) // }; // After: Factory Method — each format is its own class public record Document(string Title, List<string> Content); public interface IDocumentParser { string SupportedExtension { get; } Document Parse(Stream content); } public class PdfParser : IDocumentParser { public string SupportedExtension => ".pdf"; public Document Parse(Stream content) { // Use a PDF library (PdfPig, iText, etc.) return new Document("PDF Document", new List<string> { "...parsed PDF content..." }); } } public class DocxParser : IDocumentParser { public string SupportedExtension => ".docx"; public Document Parse(Stream content) { return new Document("Word Document", new List<string> { "...parsed DOCX content..." }); } } public class CsvParser : IDocumentParser { public string SupportedExtension => ".csv"; public Document Parse(Stream content) { using var reader = new StreamReader(content); var lines = new List<string>(); while (reader.ReadLine() is { } line) lines.Add(line); return new Document("CSV Data", lines); } } // Factory — maps extension to parser public interface IDocumentParserFactory { IDocumentParser Create(string fileExtension); } public class DocumentParserFactory(IServiceProvider sp) : IDocumentParserFactory { private static readonly FrozenDictionary<string, Type> _map = new Dictionary<string, Type> { [".pdf"] = typeof(PdfParser), [".docx"] = typeof(DocxParser), [".csv"] = typeof(CsvParser), // Adding XLSX? Just add one line here: // [".xlsx"] = typeof(XlsxParser), }.ToFrozenDictionary(); public IDocumentParser Create(string fileExtension) { if (!_map.TryGetValue(fileExtension.ToLower(), out var parserType)) throw new ArgumentException( $"Unsupported format '{fileExtension}'. " + $"Supported: {string.Join(", ", _map.Keys)}"); return (IDocumentParser)sp.GetRequiredService(parserType); } } // Usage var parser = parserFactory.Create(Path.GetExtension(filePath)); using var stream = File.OpenRead(filePath); var document = parser.Parse(stream);

Build an async factory that creates database connections with health checks. The factory should support PostgreSQL and SQL Server, open the connection asynchronously, run a health check query (SELECT 1), and return the connection only if the health check passes. Include a CancellationToken and proper IAsyncDisposable support.

  • Define IDatabaseConnection : IAsyncDisposable with QueryAsync<T>
  • The factory method signature: Task<IDatabaseConnection> CreateAsync(string dbType, CancellationToken ct)
  • Open the connection and run SELECT 1 before returning
  • If health check fails, dispose the connection and throw
  • Use await using in the caller
public interface IDatabaseConnection : IAsyncDisposable { string ProviderName { get; } Task<List<T>> QueryAsync<T>(string sql, CancellationToken ct = default); bool IsHealthy { get; } } public interface IConnectionFactory { Task<IDatabaseConnection> CreateAsync( string dbType, CancellationToken ct = default); } public class DatabaseConnectionFactory( IConfiguration config, ILogger<DatabaseConnectionFactory> logger) : IConnectionFactory { public async Task<IDatabaseConnection> CreateAsync( string dbType, CancellationToken ct = default) { var connString = config.GetConnectionString(dbType) ?? throw new ArgumentException($"No connection string for '{dbType}'"); IDatabaseConnection conn = dbType.ToLower() switch { "postgres" => new PostgresConnection(connString), "sqlserver" => new SqlServerConnection(connString), _ => throw new ArgumentException( $"Unknown DB type '{dbType}'. Supported: postgres, sqlserver.") }; try { // Open connection asynchronously await conn.OpenAsync(ct); // Health check — verify the connection actually works var result = await conn.QueryAsync<int>("SELECT 1", ct); if (result.Count == 0 || result[0] != 1) throw new InvalidOperationException("Health check failed"); logger.LogInformation("Connected to {DbType}: healthy", dbType); return conn; } catch { // CRITICAL: dispose on failure to prevent connection leak await conn.DisposeAsync(); throw; } } } // Usage await using var db = await connectionFactory.CreateAsync("postgres", ct); var users = await db.QueryAsync<User>("SELECT * FROM users WHERE active", ct);

Build a factory that discovers INotifier implementations at startup by scanning assemblies for classes decorated with [FactoryKey("...")]. The factory should compile the type map into a FrozenDictionary so runtime lookups have zero reflection overhead. Write an extension method AddNotifiers() for IServiceCollection that does the scanning and registration.

  • Create a [FactoryKey(string)] attribute
  • Decorate your notifier classes with [FactoryKey("email")], etc.
  • In AddNotifiers(), use Assembly.GetTypes() to find all INotifier classes with the attribute
  • Build a Dictionary<string, Type> and freeze it with .ToFrozenDictionary()
  • Register both the types (transient) and the factory (singleton) in DI
using System.Collections.Frozen; using System.Reflection; // 1. Marker attribute [AttributeUsage(AttributeTargets.Class, Inherited = false)] public class FactoryKeyAttribute(string key) : Attribute { public string Key { get; } = key; } // 2. Product interface public interface INotifier { Task SendAsync(string to, string message); } // 3. Concrete products — self-registering via attribute [FactoryKey("email")] public class EmailNotifier : INotifier { public Task SendAsync(string to, string message) { Console.WriteLine($"EMAIL > {to}: {message}"); return Task.CompletedTask; } } [FactoryKey("sms")] public class SmsNotifier : INotifier { public Task SendAsync(string to, string message) { Console.WriteLine($"SMS > {to}: {message}"); return Task.CompletedTask; } } [FactoryKey("push")] public class PushNotifier : INotifier { public Task SendAsync(string to, string message) { Console.WriteLine($"PUSH > {to}: {message}"); return Task.CompletedTask; } } // 4. Factory public interface INotifierFactory { INotifier Create(string key); IReadOnlyCollection<string> SupportedKeys { get; } } public class DiscoveredNotifierFactory( IServiceProvider sp, FrozenDictionary<string, Type> typeMap) : INotifierFactory { public IReadOnlyCollection<string> SupportedKeys => typeMap.Keys; public INotifier Create(string key) { if (!typeMap.TryGetValue(key, out var type)) throw new ArgumentException( $"Unknown notifier key '{key}'. " + $"Available: {string.Join(", ", typeMap.Keys)}"); return (INotifier)sp.GetRequiredService(type); } } // 5. Extension method — scan + register in one call public static class NotifierServiceExtensions { public static IServiceCollection AddNotifiers( this IServiceCollection services, params Assembly[] assemblies) { if (assemblies.Length == 0) assemblies = new[] { Assembly.GetCallingAssembly() }; var map = new Dictionary<string, Type>(); foreach (var assembly in assemblies) { var notifierTypes = assembly.GetTypes() .Where(t => t.IsClass && !t.IsAbstract && typeof(INotifier).IsAssignableFrom(t)); foreach (var type in notifierTypes) { var attr = type.GetCustomAttribute<FactoryKeyAttribute>(); if (attr is null) continue; if (map.ContainsKey(attr.Key)) throw new InvalidOperationException( $"Duplicate factory key '{attr.Key}': " + $"{map[attr.Key].Name} and {type.Name}"); services.AddTransient(type); map[attr.Key] = type; } } var frozen = map.ToFrozenDictionary(); services.AddSingleton<INotifierFactory>(sp => new DiscoveredNotifierFactory(sp, frozen)); return services; } } // 6. Usage in Program.cs // builder.Services.AddNotifiers(); // scans calling assembly // builder.Services.AddNotifiers(typeof(ExternalPlugin).Assembly); // scan specific assemblies
Section 19

Cheat Sheet

Pin this to your desk. Six cards covering everything you need to recall about Factory Method in 60 seconds flat.

  • A class can't anticipate which concrete type to create
  • You need a shared workflow around object creation
  • Adding new product types shouldn't touch existing code
  • You want unit-testable creation logic
  • Product — the interface all created objects share
  • ConcreteProduct — one specific implementation
  • Creator — declares the factory method
  • ConcreteCreator — decides which product to build
  • ILoggerFactory.CreateLogger()
  • IHttpClientFactory.CreateClient()
  • IDbContextFactory<T>.CreateDbContext()
  • .NET 8 Keyed Services
  • Returning null — throw instead
  • God factory creating 20+ types
  • Leaking concrete types to callers
  • Forgetting IDisposable on products
  • Assert factory output type is correct
  • Mock products to test Creator workflow
  • Integration test DI wiring
  • Test "unknown key" error path
  • S — Creator has one reason to change ✓
  • O — New products without modifying code ✓
  • L — Subclass creators substitutable ✓
  • I — Product interface kept lean ✓
  • D — Depend on abstractions ✓
  • Section 20

    Deep Dive

    The textbook stops at "subclass overrides a factory method." Real systems push further. These three advanced techniques show how Factory Method adapts when your requirements outgrow the basics.

    Instead of one-subclass-per-product, pass a key and let a single factory decide.

    Classic Factory Method creates one ConcreteCreator per product type. That works when you have 3–5 types, but with 20+ it becomes a class explosion. The parameterized variant accepts a key (a string, enum, or type token) and resolves the right product from a map — one factory class, unlimited products.

    .NET 8 introduced keyed services that do exactly this at the framework level. You register each implementation under a key, and the DI container becomes your parameterized factory:

    // Registration — map keys to concrete types builder.Services.AddKeyedTransient<INotifier, EmailNotifier>("email"); builder.Services.AddKeyedTransient<INotifier, SmsNotifier>("sms"); builder.Services.AddKeyedTransient<INotifier, SlackNotifier>("slack"); // Resolution — one factory, any product public class NotificationService(IServiceProvider sp) { public async Task SendAsync(string channel, string to, string msg) { // The DI container IS the parameterized factory var notifier = sp.GetRequiredKeyedService<INotifier>(channel); await notifier.SendAsync(to, msg); } } // Adding "teams" later? One line of registration. Zero factory changes. builder.Services.AddKeyedTransient<INotifier, TeamsNotifier>("teams");

    The beauty is that the factory (DI container) never needs to change. Adding a new channel means adding a class and a registration line. The NotificationService doesn't even know how many channels exist — it just asks for one by key.

    The Creator's workflow IS a template method. The factory method is just one step in it.

    Look closely at the Creator class in any real Factory Method implementation and you'll spot something familiar: a fixed sequence of steps where one step is overridable. That's the Template Method pattern.

    In fact, the GoF book itself says Factory Method is often called by Template Methods. The Creator defines the workflow (validate → create → configure → log), and the CreateProduct() step is the factory method that subclasses override:

    public abstract class ReportGenerator { // Template Method — fixed workflow public async Task<byte[]> GenerateReportAsync(ReportRequest request) { Validate(request); // Step 1: shared var exporter = CreateExporter(); // Step 2: FACTORY METHOD var data = await FetchDataAsync(request); // Step 3: shared var report = exporter.Export(data); // Step 4: shared await LogAsync(request, report.Length); // Step 5: shared return report; } // The factory method — subclasses decide the exporter protected abstract IExporter CreateExporter(); // Shared steps — not overridable private void Validate(ReportRequest r) { /* ... */ } private Task<ReportData> FetchDataAsync(ReportRequest r) { /* ... */ } private Task LogAsync(ReportRequest r, int size) { /* ... */ } } // Concrete Creator — only decides WHAT to create public class PdfReportGenerator : ReportGenerator { protected override IExporter CreateExporter() => new PdfExporter(); } public class ExcelReportGenerator : ReportGenerator { protected override IExporter CreateExporter() => new ExcelExporter(); }

    This is why Factory Method is so powerful in practice. It's not just about creating objects — it's about plugging a creation decision into a larger, shared workflow. The Template Method gives you the workflow reuse; the Factory Method gives you the flexibility point within it.

    When creating a product requires I/O, the factory method returns a Task<T>.

    Sometimes building a product isn't instant. Maybe you need to fetch configuration from a database, call an external API for credentials, or establish a network connection. A synchronous factory would block the calling thread — a disaster in ASP.NET Core where every blocked thread is a request that can't be served.

    The fix is straightforward: make the factory method return Task<IProduct> instead of IProduct:

    public interface IDatabaseConnectionFactory { Task<IDatabaseConnection> CreateAsync( string provider, CancellationToken ct = default); } public class DatabaseConnectionFactory(IConfiguration config) : IDatabaseConnectionFactory { public async Task<IDatabaseConnection> CreateAsync( string provider, CancellationToken ct = default) { var connStr = config.GetConnectionString(provider) ?? throw new ArgumentException($"No connection string for '{provider}'"); var conn = provider switch { "postgres" => new NpgsqlConnection(connStr) as IDatabaseConnection, "sqlserver" => new SqlServerConnection(connStr), _ => throw new ArgumentException($"Unknown provider: {provider}") }; // I/O-bound creation — this is WHY the factory is async await conn.OpenAsync(ct); await conn.ValidateSchemaAsync(ct); return conn; } }

    Key design rules for async factories:

    • Always accept a CancellationToken — callers need a way to bail out if creation takes too long
    • If creation fails after partial setup (connection opened but schema invalid), dispose the partial object before rethrowing
    • Never use .Result or .Wait() on async factories — that defeats the entire purpose and risks deadlocks
    Section 21

    Mini-Project

    Build a plugin-based notification system from scratch — and see how each refactoring step makes the codebase more flexible.
    This project walks through three stages of evolution. Build each attempt, observe its limitations, then refactor to the next level. By the end, you'll have a production-quality notification system that discovers new channels at startup without any factory code changes. Stage 1: Naive switch/case in one file Every new channel = edit factory Violates Open/Closed Tight coupling Stage 2: Factory Method Interface + DI registration New channel = new class + 1 DI line Testable, SOLID-compliant Loose coupling Stage 3: Plugin Reflection-based discovery New channel = add a DLL Zero code changes anywhere Zero coupling ATTEMPT 1 ATTEMPT 2 ATTEMPT 3

    Most developers start here. You need to send notifications via email, SMS, and Slack, so you write one class with a switch statement. It works — until the fourth channel arrives and you realize every change means editing this same file.

    public class NotificationService { public async Task SendAsync(string channel, string to, string message) { switch (channel.ToLower()) { case "email": var smtp = new SmtpClient("smtp.company.com"); await smtp.SendMailAsync(new MailMessage("noreply@co.com", to, "Alert", message)); break; case "sms": var http = new HttpClient(); await http.PostAsJsonAsync("https://api.twilio.com/send", new { To = to, Body = message }); break; case "slack": var slack = new HttpClient(); await slack.PostAsJsonAsync("https://hooks.slack.com/xxx", new { text = $"{to}: {message}" }); break; default: throw new ArgumentException($"Unknown channel: {channel}"); } } }
    • Open/Closed violation — every new channel means editing this file
    • Untestable — real SMTP/HTTP calls are baked in, can't mock them
    • No shared workflow — retry logic, logging, and validation must be duplicated per case
    • Disposable leakHttpClient and SmtpClient created per call, never disposed

    Extract a INotifier interface, create one class per channel, and wire them through DI with keyed services. The factory method resolves the right implementation by key — no switch, no concrete types in caller code.

    // Product interface public interface INotifier { Task SendAsync(string to, string message, CancellationToken ct = default); } // Concrete Products — each in its own file public class EmailNotifier(SmtpClient smtp) : INotifier { public async Task SendAsync(string to, string message, CancellationToken ct) { var mail = new MailMessage("noreply@co.com", to, "Alert", message); await smtp.SendMailAsync(mail, ct); } } public class SmsNotifier(HttpClient http) : INotifier { public async Task SendAsync(string to, string message, CancellationToken ct) { await http.PostAsJsonAsync("https://api.twilio.com/send", new { To = to, Body = message }, ct); } } // Factory — parameterized via DI keyed services builder.Services.AddKeyedTransient<INotifier, EmailNotifier>("email"); builder.Services.AddKeyedTransient<INotifier, SmsNotifier>("sms"); builder.Services.AddKeyedTransient<INotifier, SlackNotifier>("slack"); // Creator — shared workflow public class NotificationService(IServiceProvider sp) { public async Task SendAsync(string channel, string to, string msg, CancellationToken ct = default) { var notifier = sp.GetRequiredKeyedService<INotifier>(channel); // Shared workflow: validate, send, log — applies to ALL channels ArgumentException.ThrowIfNullOrWhiteSpace(to); await notifier.SendAsync(to, msg, ct); Console.WriteLine($"[{channel}] Sent to {to} at {DateTime.UtcNow:O}"); } }
    • Open/Closed — new channel = new class + one DI registration line
    • Testable — mock INotifier to test the workflow without real HTTP
    • Shared workflow — validation, logging, and retry live in one place
    You still edit Program.cs to add a registration line for each new channel. Can we eliminate even that?

    The final evolution. Instead of manually registering each notifier, we scan assemblies at startup to find every class implementing INotifier that has a [FactoryKey] attribute. Adding a new channel means dropping a new class file (or even a new DLL) — zero changes to any existing code, including DI registration.

    // Marker attribute — self-describing factories [AttributeUsage(AttributeTargets.Class, Inherited = false)] public class FactoryKeyAttribute(string key) : Attribute { public string Key { get; } = key; } // Products self-register by wearing the attribute [FactoryKey("email")] public class EmailNotifier(SmtpClient smtp) : INotifier { /* ... */ } [FactoryKey("sms")] public class SmsNotifier(HttpClient http) : INotifier { /* ... */ } [FactoryKey("slack")] public class SlackNotifier(HttpClient http) : INotifier { /* ... */ } // Discovery extension — runs once at startup public static class NotifierRegistration { public static IServiceCollection AddNotifiers( this IServiceCollection services, params Assembly[] extra) { var assemblies = new[] { Assembly.GetCallingAssembly() }.Concat(extra); var types = assemblies .SelectMany(a => a.GetTypes()) .Where(t => t is { IsClass: true, IsAbstract: false } && t.IsAssignableTo(typeof(INotifier)) && t.GetCustomAttribute<FactoryKeyAttribute>() is not null); var map = new Dictionary<string, Type>(); foreach (var type in types) { var key = type.GetCustomAttribute<FactoryKeyAttribute>()!.Key; services.AddTransient(type); map[key] = type; } var frozen = map.ToFrozenDictionary(); services.AddSingleton<INotifierFactory>(sp => new DiscoveredNotifierFactory(sp, frozen)); return services; } } // One-line setup in Program.cs: builder.Services.AddNotifiers();
    • Zero coupling — the factory never references any concrete notifier by name
    • Plugin-capable — pass external assemblies to AddNotifiers() for third-party plugins
    • Fast at runtime — reflection runs once at startup; FrozenDictionary gives O(1) lookups
    • DiscoverabletypeMap.Keys tells you exactly which channels are available
    Section 22

    Migration Guide

    You have a 500-line switch statement that creates objects. Here's how to safely refactor it into a Factory Method — one step at a time, with tests passing at every stage.
    BEFORE: Tangled Switch switch (type) { case "pdf": return new PdfExporter(); case "csv": return new CsvExporter(); case "xlsx": return new ExcelExporter(); // 15 more cases... growing weekly Every change = risk to all cases AFTER: Clean Factory IExporter PdfExporter CsvExporter ExcelExporter DI resolves by key — no switch needed New type = new file, zero risk

    Find the common operations across all branches of the switch statement. These become the interface. Don't add anything that not all branches share — keep it lean.

    // BEFORE: every case returns a different concrete type // AFTER: extract what they all share public interface IExporter { string Format { get; } byte[] Export(ReportData data); string ContentType { get; } } // This interface represents the "product" in Factory Method terms. // Every exporter must provide a format name, export logic, and MIME type. This step is purely additive — you're creating a new file, not changing existing code. Tests should still pass.

    Take the logic from each case branch and move it into its own class that implements the interface. One class per case. Keep the original switch working alongside the new classes during this transition.

    public class PdfExporter : IExporter { public string Format => "PDF"; public string ContentType => "application/pdf"; public byte[] Export(ReportData data) { // Move the PDF-specific logic from the switch case HERE using var doc = new PdfDocument(); doc.AddPage(data.Title, data.Rows); return doc.ToBytes(); } } public class CsvExporter : IExporter { public string Format => "CSV"; public string ContentType => "text/csv"; public byte[] Export(ReportData data) { var sb = new StringBuilder(); sb.AppendLine(string.Join(",", data.Headers)); foreach (var row in data.Rows) sb.AppendLine(string.Join(",", row)); return Encoding.UTF8.GetBytes(sb.ToString()); } } // Repeat for ExcelExporter, HtmlExporter, etc. You're extracting logic into new files. Write a unit test for each new class before deleting the switch case code. If the new class tests pass and the old integration tests pass, you're safe.

    Register each concrete product in the DI container using keyed services (or a dictionary-based factory if you're on older .NET). Then replace the switch statement with a single factory resolution call.

    // Program.cs — register exporters under their format key builder.Services.AddKeyedTransient<IExporter, PdfExporter>("pdf"); builder.Services.AddKeyedTransient<IExporter, CsvExporter>("csv"); builder.Services.AddKeyedTransient<IExporter, ExcelExporter>("xlsx"); // The factory — replaces the entire switch statement public class ExporterFactory(IServiceProvider sp) : IExporterFactory { public IExporter Create(string format) => sp.GetRequiredKeyedService<IExporter>(format.ToLower()); } This is the critical step. The switch statement disappears. Run all integration tests. If any caller was depending on concrete types (e.g., casting to PdfExporter), those callers will break — fix them to use the IExporter interface.

    Search the entire codebase for any remaining references to concrete product types. Replace them with the interface. Then delete the old switch statement class (if it still exists) and remove the using statements.

    // BEFORE — caller tightly coupled to concrete type public class ReportController { public IActionResult Export(string format, ReportData data) { IExporter exporter = format switch // the old switch { "pdf" => new PdfExporter(), "csv" => new CsvExporter(), _ => throw new ArgumentException("Unknown format") }; return File(exporter.Export(data), exporter.ContentType); } } // AFTER — caller depends on factory abstraction public class ReportController(IExporterFactory factory) { public IActionResult Export(string format, ReportData data) { var exporter = factory.Create(format); // one line, zero concrete types return File(exporter.Export(data), exporter.ContentType); } } At this point, all logic is delegated to the factory. Run the full test suite. If everything passes, the migration is complete. Celebrate with a clean commit.
    Section 23

    Code Review Checklist

    Use this checklist every time you review (or write) Factory Method code. Ten checks that catch the most common mistakes before they reach production.

    # Check Why It Matters Red Flag
    1 Factory returns an interface, not a concrete type Callers should never know the concrete type — that's the whole point of the pattern public PdfExporter Create() instead of IExporter
    2 No null return — throw on unknown key Null returns cause NullReferenceException far from the factory, making debugging painful default: return null; in a switch
    3 Products implement IDisposable if they hold resources Connections, streams, and HTTP clients leak without proper disposal Product wraps HttpClient but isn't disposable
    4 Factory is registered with correct DI lifetime Singleton factory + transient product = products live forever (captive dependency) Singleton factory creating scoped DbContexts
    5 Factory method accepts CancellationToken (if async) Without it, callers can't cancel long-running creation (DB connections, HTTP calls) Task<IProduct> CreateAsync() with no cancellation parameter
    6 Error messages include available keys When resolution fails, the error should guide the developer to valid options throw new Exception("Unknown type") with no list of valid types
    7 No concrete product references outside the factory If callers reference PdfExporter directly, the factory is being bypassed using MyApp.Exporters; in a controller file
    8 Factory doesn't grow beyond ~10 products A factory with 20+ products is a god factory — split by domain (exporters, notifiers, etc.) Single ServiceFactory creating every type in the system
    9 Unit tests cover each product type independently Each concrete product should have its own test class with focused assertions One mega test file testing all products through the factory
    10 DI integration test verifies wiring Runtime resolution failures (missing registrations) aren't caught by unit tests No test calling provider.GetRequiredService<IFactory>()
    Run grep -rn "new PdfExporter\|new CsvExporter\|new ExcelExporter" --include="*.cs" src/ to find concrete product usage outside factory code. Any hits in controller, service, or handler files are red flags that the factory is being bypassed.