Creating Families of Related Objects
Imagine you're building a UI library that needs to support multiple themes. Each theme provides buttons, text boxes, and checkboxes that share a consistent look. If you create a Windows button with a Mac text box, your interface looks broken. The Abstract Factory pattern solves this by ensuring all UI components come from the same theme family.
The pattern defines an interface for creating related objects without specifying their concrete classes. Each factory implementation produces a complete family of objects that work together. This guarantees consistency and makes swapping entire families simple, like changing from a light theme to a dark theme with one line of code.
You'll learn when Abstract Factory beats simpler patterns, how to structure factory interfaces and concrete implementations, and practical ways to integrate this pattern with dependency injection in modern .NET applications.
Understanding the Pattern Structure
The Abstract Factory pattern involves four key components: abstract product interfaces that define what each product can do, concrete product classes that implement these interfaces, an abstract factory interface declaring creation methods, and concrete factories that instantiate specific product families.
Let's see this with a document export system that creates headers and footers in different formats. Each format (PDF, HTML) has its own header and footer implementations that work together.
// Abstract product interfaces
public interface IDocumentHeader
{
string Render(string title);
}
public interface IDocumentFooter
{
string Render(int pageNumber);
}
// Concrete products for PDF
public class PdfHeader : IDocumentHeader
{
public string Render(string title)
{
return $"[PDF HEADER] {title}";
}
}
public class PdfFooter : IDocumentFooter
{
public string Render(int pageNumber)
{
return $"[PDF FOOTER] Page {pageNumber}";
}
}
// Concrete products for HTML
public class HtmlHeader : IDocumentHeader
{
public string Render(string title)
{
return $"<header><h1>{title}</h1></header>";
}
}
public class HtmlFooter : IDocumentFooter
{
public string Render(int pageNumber)
{
return $"<footer>Page {pageNumber}</footer>";
}
}
Each product family (PDF and HTML) has its own header and footer classes. The interfaces ensure both formats provide the same functionality, but each implementation renders content differently. This separation lets you add new formats without changing existing code.
Defining the Abstract Factory
The abstract factory interface declares methods for creating each product in the family. Concrete factory classes implement this interface to return specific product variants. Client code depends only on the abstract factory, never on concrete classes.
// Abstract factory interface
public interface IDocumentFactory
{
IDocumentHeader CreateHeader();
IDocumentFooter CreateFooter();
}
// Concrete factory for PDF documents
public class PdfDocumentFactory : IDocumentFactory
{
public IDocumentHeader CreateHeader()
{
return new PdfHeader();
}
public IDocumentFooter CreateFooter()
{
return new PdfFooter();
}
}
// Concrete factory for HTML documents
public class HtmlDocumentFactory : IDocumentFactory
{
public IDocumentHeader CreateHeader()
{
return new HtmlHeader();
}
public IDocumentFooter CreateFooter()
{
return new HtmlFooter();
}
}
Each concrete factory returns products from a single family. PdfDocumentFactory only creates PDF headers and footers, never mixing formats. This guarantees consistency. The factory interface makes it easy to add new document formats by implementing another concrete factory.
Using the Abstract Factory
Client code receives a factory through its constructor or method parameters. It calls factory methods to create products without knowing their concrete types. Swapping factories changes the entire product family.
public class DocumentGenerator
{
private readonly IDocumentFactory _factory;
public DocumentGenerator(IDocumentFactory factory)
{
_factory = factory;
}
public string GenerateDocument(string title, string content)
{
var header = _factory.CreateHeader();
var footer = _factory.CreateFooter();
var document = new StringBuilder();
document.AppendLine(header.Render(title));
document.AppendLine(content);
document.AppendLine(footer.Render(1));
return document.ToString();
}
}
// Usage with different factories
var pdfGenerator = new DocumentGenerator(new PdfDocumentFactory());
var pdfDoc = pdfGenerator.GenerateDocument("Quarterly Report", "Sales increased by 15%");
Console.WriteLine("PDF Document:");
Console.WriteLine(pdfDoc);
var htmlGenerator = new DocumentGenerator(new HtmlDocumentFactory());
var htmlDoc = htmlGenerator.GenerateDocument("Quarterly Report", "Sales increased by 15%");
Console.WriteLine("\nHTML Document:");
Console.WriteLine(htmlDoc);
Output:
PDF Document:
[PDF HEADER] Quarterly Report
Sales increased by 15%
[PDF FOOTER] Page 1
HTML Document:
<header><h1>Quarterly Report</h1></header>
Sales increased by 15%
<footer>Page 1</footer>
The DocumentGenerator class works with any factory. Changing from PDF to HTML only requires passing a different factory instance. The generator never imports PDF or HTML specific classes, making it easy to test and maintain.
Real-World UI Theme Example
A practical use case is creating themed UI components. Each theme provides buttons, inputs, and panels with matching styles. The Abstract Factory ensures all components belong to the same theme.
// Product interfaces
public interface IButton
{
void Render();
}
public interface ITextBox
{
void Render();
}
// Dark theme products
public class DarkButton : IButton
{
public void Render() => Console.WriteLine("[Dark Button] Black background, white text");
}
public class DarkTextBox : ITextBox
{
public void Render() => Console.WriteLine("[Dark TextBox] Gray background, white text");
}
// Light theme products
public class LightButton : IButton
{
public void Render() => Console.WriteLine("[Light Button] White background, black text");
}
public class LightTextBox : ITextBox
{
public void Render() => Console.WriteLine("[Light TextBox] White background, black text");
}
// Factory interface
public interface IThemeFactory
{
IButton CreateButton();
ITextBox CreateTextBox();
}
// Concrete factories
public class DarkThemeFactory : IThemeFactory
{
public IButton CreateButton() => new DarkButton();
public ITextBox CreateTextBox() => new DarkTextBox();
}
public class LightThemeFactory : IThemeFactory
{
public IButton CreateButton() => new LightButton();
public ITextBox CreateTextBox() => new LightTextBox();
}
// Application using theme factory
public class LoginForm
{
private readonly IThemeFactory _themeFactory;
public LoginForm(IThemeFactory themeFactory)
{
_themeFactory = themeFactory;
}
public void Display()
{
var usernameBox = _themeFactory.CreateTextBox();
var passwordBox = _themeFactory.CreateTextBox();
var loginButton = _themeFactory.CreateButton();
Console.WriteLine("Rendering Login Form:");
usernameBox.Render();
passwordBox.Render();
loginButton.Render();
}
}
// Switch themes easily
var darkForm = new LoginForm(new DarkThemeFactory());
darkForm.Display();
Console.WriteLine();
var lightForm = new LoginForm(new LightThemeFactory());
lightForm.Display();
Output:
Rendering Login Form:
[Dark TextBox] Gray background, white text
[Dark TextBox] Gray background, white text
[Dark Button] Black background, white text
Rendering Login Form:
[Light TextBox] White background, black text
[Light TextBox] White background, black text
[Light Button] White background, black text
The LoginForm creates all components through the factory, ensuring they match. Users can switch themes without the form knowing anything about specific button or text box implementations. This makes adding new themes straightforward.
Integrating with Dependency Injection
Modern .NET applications typically use dependency injection. You can register concrete factories in the DI container and let it resolve the appropriate factory based on configuration or user preferences.
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
// Register based on configuration
string theme = args.Length > 0 ? args[0] : "dark";
if (theme.Equals("dark", StringComparison.OrdinalIgnoreCase))
{
services.AddSingleton<IThemeFactory, DarkThemeFactory>();
}
else
{
services.AddSingleton<IThemeFactory, LightThemeFactory>();
}
// Register services that depend on the factory
services.AddTransient<LoginForm>();
var provider = services.BuildServiceProvider();
// Resolve and use
var form = provider.GetRequiredService<LoginForm>();
form.Display();
The DI container manages factory lifetimes and injects them where needed. Changing themes becomes a configuration change rather than a code change. This pattern works well in ASP.NET Core applications where you can switch factories based on user sessions or feature flags.
Try It Yourself
This example creates a notification system that sends messages via different channels. Each channel factory produces both a sender and a logger that work together.
Steps:
- Create the project:
dotnet new console -n AbstractFactoryDemo
- Open the directory:
cd AbstractFactoryDemo
- Update Program.cs as shown below
- Run the application:
dotnet run
// Product interfaces
interface IMessageSender
{
void Send(string recipient, string message);
}
interface ILogger
{
void Log(string message);
}
// Email products
class EmailSender : IMessageSender
{
public void Send(string recipient, string message)
=> Console.WriteLine($"Email to {recipient}: {message}");
}
class EmailLogger : ILogger
{
public void Log(string message)
=> Console.WriteLine($"[Email Log] {DateTime.Now:HH:mm:ss} - {message}");
}
// SMS products
class SmsSender : IMessageSender
{
public void Send(string recipient, string message)
=> Console.WriteLine($"SMS to {recipient}: {message}");
}
class SmsLogger : ILogger
{
public void Log(string message)
=> Console.WriteLine($"[SMS Log] {DateTime.Now:HH:mm:ss} - {message}");
}
// Factory interface
interface INotificationFactory
{
IMessageSender CreateSender();
ILogger CreateLogger();
}
// Concrete factories
class EmailNotificationFactory : INotificationFactory
{
public IMessageSender CreateSender() => new EmailSender();
public ILogger CreateLogger() => new EmailLogger();
}
class SmsNotificationFactory : INotificationFactory
{
public IMessageSender CreateSender() => new SmsSender();
public ILogger CreateLogger() => new SmsLogger();
}
// Client code
class NotificationService
{
private readonly INotificationFactory _factory;
public NotificationService(INotificationFactory factory)
{
_factory = factory;
}
public void SendNotification(string recipient, string message)
{
var sender = _factory.CreateSender();
var logger = _factory.CreateLogger();
logger.Log($"Sending notification to {recipient}");
sender.Send(recipient, message);
logger.Log("Notification sent successfully");
}
}
// Demo
Console.WriteLine("=== Email Notifications ===");
var emailService = new NotificationService(new EmailNotificationFactory());
emailService.SendNotification("user@example.com", "Your order has shipped!");
Console.WriteLine("\n=== SMS Notifications ===");
var smsService = new NotificationService(new SmsNotificationFactory());
smsService.SendNotification("+1-555-0123", "Your verification code is 123456");
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Run result:
=== Email Notifications ===
[Email Log] 14:30:45 - Sending notification to user@example.com
Email to user@example.com: Your order has shipped!
[Email Log] 14:30:45 - Notification sent successfully
=== SMS Notifications ===
[SMS Log] 14:30:45 - Sending notification to +1-555-0123
SMS to +1-555-0123: Your verification code is 123456
[SMS Log] 14:30:45 - Notification sent successfully
Each factory produces a matching sender and logger. The NotificationService doesn't know which channel it's using. You can add new channels (Slack, push notifications) by creating new factory implementations without modifying existing code.
When Not to Use This Pattern
Abstract Factory adds complexity through multiple interfaces and classes. Don't use it when you only need to create a single product type. A simple Factory Method or even direct instantiation works better for straightforward scenarios.
Avoid when product families rarely change: If you're building a report generator that only outputs PDF and you have no plans for other formats, the pattern's flexibility is wasted overhead. Start simple and refactor to Abstract Factory only when you actually need multiple families.
Skip when products aren't related: The pattern shines when products must work together (a dark button with a dark text box). If your products are independent, you're just adding indirection without benefits. Use separate factory methods or builders instead.
Consider alternatives for small projects: Modern dependency injection often eliminates the need for explicit factory classes. If you can register concrete types directly in your DI container and swap them with configuration, that's simpler than maintaining factory hierarchies.