The Problem with "New" Everywhere
It's tempting to instantiate objects with new wherever you need them. It works—until your codebase grows and you're hunting down dozens of scattered constructors to change how objects are created. When you need to swap implementations or add configuration logic, that convenience becomes a maintenance nightmare with tight coupling throughout your application.
Look at code that creates database connections, document processors, or configuration managers directly. Each new keyword creates a hard dependency on the concrete class. Testing becomes difficult because you can't easily substitute mock implementations. Changing construction logic means updating multiple locations, increasing the risk of bugs and inconsistencies.
Creational design patterns solve these problems by controlling how objects are instantiated. You'll learn three essential patterns: Factory for encapsulating creation logic, Singleton for managing single instances, and Builder for constructing complex objects step-by-step. Each pattern addresses specific challenges you'll face when building maintainable .NET applications.
// Tight coupling and scattered object creation
public class ReportService
{
public void GenerateReport(string reportType)
{
// Creating objects directly - hard to test, hard to change
if (reportType == "pdf")
{
var pdfReport = new PdfReport();
pdfReport.Title = "Monthly Report";
pdfReport.PageSize = "A4";
pdfReport.Generate();
}
else if (reportType == "excel")
{
var excelReport = new ExcelReport();
excelReport.Title = "Monthly Report";
excelReport.SheetName = "Report";
excelReport.Generate();
}
}
}
// Same creation logic duplicated elsewhere
public class ScheduledJobService
{
public void RunDailyReports()
{
// Duplicating the same instantiation logic
var pdfReport = new PdfReport();
pdfReport.Title = "Daily Report";
pdfReport.Generate();
}
}
This code has several issues. You're repeating construction logic in multiple places, making changes difficult. Testing requires instantiating concrete classes. And adding new report types means modifying existing code. Creational patterns eliminate these problems.
Factory Pattern: Encapsulating Object Creation
The Factory pattern moves object creation into dedicated factory classes or methods. Instead of calling new directly, your code asks a factory to create objects. This abstraction lets you change implementations, add logic during creation, and keep client code decoupled from concrete classes.
You'll typically use an interface or abstract class that defines what objects the factory creates. The factory decides which concrete class to instantiate based on parameters or configuration. This makes your code more flexible—adding new types just means extending the factory, not modifying existing code.
In .NET, factories work perfectly with dependency injection. You can register factories as services and let the DI container manage their lifetime. This gives you centralized control over object creation while maintaining clean separation of concerns.
// Define the interface for documents
public interface IDocument
{
string Title { get; set; }
void Generate();
byte[] GetContent();
}
// Concrete implementations
public class PdfDocument : IDocument
{
public string Title { get; set; }
public string PageSize { get; set; } = "A4";
public void Generate()
{
Console.WriteLine($"Generating PDF: {Title} ({PageSize})");
}
public byte[] GetContent() => new byte[0]; // Simplified
}
public class ExcelDocument : IDocument
{
public string Title { get; set; }
public string SheetName { get; set; } = "Sheet1";
public void Generate()
{
Console.WriteLine($"Generating Excel: {Title} (Sheet: {SheetName})");
}
public byte[] GetContent() => new byte[0]; // Simplified
}
public class WordDocument : IDocument
{
public string Title { get; set; }
public string Template { get; set; } = "Standard";
public void Generate()
{
Console.WriteLine($"Generating Word: {Title} (Template: {Template})");
}
public byte[] GetContent() => new byte[0]; // Simplified
}
// Factory interface
public interface IDocumentFactory
{
IDocument CreateDocument(string type);
}
// Concrete factory implementation
public class DocumentFactory : IDocumentFactory
{
public IDocument CreateDocument(string type)
{
return type.ToLower() switch
{
"pdf" => new PdfDocument(),
"excel" => new ExcelDocument(),
"word" => new WordDocument(),
_ => throw new ArgumentException($"Unknown document type: {type}")
};
}
}
Now your client code depends on IDocumentFactory and IDocument interfaces, not concrete classes. You can easily test by providing a mock factory. Adding new document types means creating a new class and updating the factory—existing code stays untouched. This separation makes your architecture more maintainable and extensible.
Singleton Pattern: Ensuring Single Instance
Some objects should exist only once in your application—configuration managers, logging services, or connection pools. The Singleton pattern guarantees a class has exactly one instance and provides global access to it. This prevents resource duplication and ensures consistent state across your application.
Thread safety is critical for singletons. Multiple threads might try to create the instance simultaneously, potentially creating multiple instances. The classic approach uses double-check locking, but .NET provides Lazy<T> which handles thread-safe lazy initialization automatically. This is the recommended approach for modern .NET applications.
While singletons provide convenience, use them judiciously. They introduce global state which can make testing harder. Dependency injection often provides better alternatives for managing single instances through registered services with singleton lifetime.
// Thread-safe Singleton using Lazy<T>
public sealed class DocumentRegistry
{
// Lazy initialization with thread-safety built-in
private static readonly Lazy<DocumentRegistry> _instance =
new Lazy<DocumentRegistry>(() => new DocumentRegistry());
// Private constructor prevents external instantiation
private DocumentRegistry()
{
RegisteredTypes = new Dictionary<string, Type>();
Console.WriteLine("DocumentRegistry initialized");
}
// Public property to access the instance
public static DocumentRegistry Instance => _instance.Value;
// Registry to store document type mappings
private Dictionary<string, Type> RegisteredTypes { get; }
// Register a new document type
public void RegisterDocumentType(string key, Type documentType)
{
if (!typeof(IDocument).IsAssignableFrom(documentType))
{
throw new ArgumentException(
$"Type must implement IDocument: {documentType.Name}");
}
RegisteredTypes[key] = documentType;
Console.WriteLine($"Registered document type: {key} -> {documentType.Name}");
}
// Get registered document type
public Type GetDocumentType(string key)
{
if (RegisteredTypes.TryGetValue(key, out var type))
{
return type;
}
throw new KeyNotFoundException($"Document type not found: {key}");
}
// Get all registered types
public IReadOnlyDictionary<string, Type> GetAllTypes()
{
return RegisteredTypes;
}
}
// Usage example
public class RegistryExample
{
public void ConfigureRegistry()
{
// All calls return the same instance
var registry1 = DocumentRegistry.Instance;
registry1.RegisterDocumentType("pdf", typeof(PdfDocument));
var registry2 = DocumentRegistry.Instance;
registry2.RegisterDocumentType("excel", typeof(ExcelDocument));
// registry1 and registry2 are the same object
Console.WriteLine($"Same instance: {ReferenceEquals(registry1, registry2)}");
// Output: Same instance: True
}
}
The Lazy<T> approach provides thread-safe initialization without manual locking code. The instance is created only when first accessed, and the CLR guarantees thread safety. The sealed class prevents inheritance, and the private constructor ensures no external code can create instances. This pattern works perfectly for application-wide registries or configuration managers.
Builder Pattern: Constructing Complex Objects
When objects require many parameters or complex initialization, constructors become unwieldy. The Builder pattern lets you construct objects step-by-step using a fluent interface. Each method configures one aspect of the object and returns the builder, allowing you to chain method calls for readable object construction.
Builders shine when creating immutable objects or objects requiring validation. You can enforce required properties, validate combinations of values, and provide sensible defaults for optional properties. The fluent syntax makes construction code self-documenting, clearly showing what's being configured.
In .NET, builders often separate construction from representation. The builder handles complexity while keeping the target class clean. This is especially useful for configuration objects, query builders, or domain objects with intricate initialization requirements.
// Complex document with many configuration options
public class DocumentConfiguration
{
public string Title { get; set; }
public string Author { get; set; }
public string Format { get; set; }
public bool IncludeTableOfContents { get; set; }
public bool IncludePageNumbers { get; set; }
public string HeaderText { get; set; }
public string FooterText { get; set; }
public int MarginTop { get; set; }
public int MarginBottom { get; set; }
public string FontFamily { get; set; }
public int FontSize { get; set; }
}
// Builder provides fluent interface for construction
public class DocumentConfigurationBuilder
{
private readonly DocumentConfiguration _config;
public DocumentConfigurationBuilder()
{
// Initialize with sensible defaults
_config = new DocumentConfiguration
{
Format = "pdf",
IncludeTableOfContents = false,
IncludePageNumbers = true,
MarginTop = 20,
MarginBottom = 20,
FontFamily = "Arial",
FontSize = 12
};
}
// Required properties
public DocumentConfigurationBuilder WithTitle(string title)
{
if (string.IsNullOrWhiteSpace(title))
throw new ArgumentException("Title is required");
_config.Title = title;
return this;
}
public DocumentConfigurationBuilder WithAuthor(string author)
{
_config.Author = author;
return this;
}
// Optional formatting
public DocumentConfigurationBuilder AsFormat(string format)
{
var validFormats = new[] { "pdf", "word", "html" };
if (!validFormats.Contains(format.ToLower()))
throw new ArgumentException($"Invalid format. Must be: {string.Join(", ", validFormats)}");
_config.Format = format.ToLower();
return this;
}
// Optional features
public DocumentConfigurationBuilder WithTableOfContents()
{
_config.IncludeTableOfContents = true;
return this;
}
public DocumentConfigurationBuilder WithoutPageNumbers()
{
_config.IncludePageNumbers = false;
return this;
}
public DocumentConfigurationBuilder WithHeader(string headerText)
{
_config.HeaderText = headerText;
return this;
}
public DocumentConfigurationBuilder WithFooter(string footerText)
{
_config.FooterText = footerText;
return this;
}
public DocumentConfigurationBuilder WithMargins(int top, int bottom)
{
if (top < 0 || bottom < 0)
throw new ArgumentException("Margins must be non-negative");
_config.MarginTop = top;
_config.MarginBottom = bottom;
return this;
}
public DocumentConfigurationBuilder WithFont(string family, int size)
{
if (size <= 0)
throw new ArgumentException("Font size must be positive");
_config.FontFamily = family;
_config.FontSize = size;
return this;
}
// Build method validates and returns the configured object
public DocumentConfiguration Build()
{
if (string.IsNullOrWhiteSpace(_config.Title))
throw new InvalidOperationException("Title is required before building");
return _config;
}
}
// Usage with fluent syntax
public class BuilderUsageExample
{
public void CreateDocument()
{
var config = new DocumentConfigurationBuilder()
.WithTitle("Quarterly Financial Report")
.WithAuthor("Finance Team")
.AsFormat("pdf")
.WithTableOfContents()
.WithHeader("Q3 2025 Report")
.WithFooter("Confidential")
.WithMargins(25, 25)
.WithFont("Times New Roman", 11)
.Build();
Console.WriteLine($"Created document config: {config.Title} by {config.Author}");
}
}
The fluent interface reads like natural language, making the construction intent clear. Each method returns the builder, enabling method chaining. Required properties throw exceptions if missing when you call Build(), ensuring valid objects. This approach scales much better than constructors with dozens of parameters or multiple constructor overloads.
Knowing When to Apply Each Pattern
Each creational pattern solves different problems. Understanding when to use each one helps you build more maintainable systems without over-engineering.
Use Factory when: You need to create objects that implement a common interface but have different concrete types. Factories work great when creation logic is complex or might change, when you're adding new types frequently, or when client code shouldn't depend on concrete classes. They're essential in plugin architectures and systems requiring runtime type selection.
Use Singleton when: You need exactly one instance of a class shared across your application. Configuration managers, logging services, and caching systems are common candidates. However, consider dependency injection with singleton lifetime as a modern alternative—it provides the same single-instance behavior while improving testability and avoiding global state issues.
Use Builder when: Objects require many parameters, especially optional ones. If you're writing constructors with more than 3-4 parameters, consider a builder. They're perfect for immutable objects needing validation, domain objects with complex rules, or configuration objects. The fluent interface improves code readability significantly compared to long parameter lists.
Refactoring Playbook: From Naive to Production-Ready
Let's take the document factory from earlier and evolve it through three stages. Understanding this progression helps you recognize when simple solutions are sufficient and when added complexity pays off.
// Works for small, stable sets of types
public class SimpleDocumentFactory
{
public IDocument CreateDocument(string type)
{
return type.ToLower() switch
{
"pdf" => new PdfDocument(),
"excel" => new ExcelDocument(),
"word" => new WordDocument(),
_ => throw new ArgumentException($"Unknown type: {type}")
};
}
}
This naive version works fine for applications with a fixed set of document types. It's simple and readable. The problem emerges when you need to add types frequently or support plugin-based extensions. Every new type requires modifying the factory, violating the Open-Closed Principle.
// Supports runtime registration of new types
public class RegistryDocumentFactory : IDocumentFactory
{
private readonly Dictionary<string, Func<IDocument>> _creators;
public RegistryDocumentFactory()
{
_creators = new Dictionary<string, Func<IDocument>>(StringComparer.OrdinalIgnoreCase);
// Register default types
Register("pdf", () => new PdfDocument());
Register("excel", () => new ExcelDocument());
Register("word", () => new WordDocument());
}
public void Register(string type, Func<IDocument> creator)
{
_creators[type] = creator;
}
public IDocument CreateDocument(string type)
{
if (_creators.TryGetValue(type, out var creator))
{
return creator();
}
throw new ArgumentException($"Unknown document type: {type}");
}
}
The idiomatic version uses a registry of factory functions. You can register new types at startup or runtime without modifying the factory class. This supports plugin architectures and makes testing easier—you can register mock creators. It's more flexible but introduces slight complexity with the dictionary lookup.
// Full DI integration with service resolution
public class DiDocumentFactory : IDocumentFactory
{
private readonly IServiceProvider _serviceProvider;
private readonly Dictionary<string, Type> _typeRegistry;
public DiDocumentFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
_typeRegistry = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);
// Register types that will be resolved from DI
RegisterType("pdf", typeof(PdfDocument));
RegisterType("excel", typeof(ExcelDocument));
RegisterType("word", typeof(WordDocument));
}
public void RegisterType(string key, Type documentType)
{
if (!typeof(IDocument).IsAssignableFrom(documentType))
throw new ArgumentException($"Type must implement IDocument");
_typeRegistry[key] = documentType;
}
public IDocument CreateDocument(string type)
{
if (!_typeRegistry.TryGetValue(type, out var documentType))
{
throw new ArgumentException($"Unknown document type: {type}");
}
// Resolve from DI container - supports constructor injection
var document = (IDocument)ActivatorUtilities.CreateInstance(
_serviceProvider,
documentType);
return document;
}
}
The production version integrates with dependency injection. Documents can have their own dependencies injected through constructors. You get the benefits of DI lifetime management and the flexibility of runtime registration. This handles complex scenarios like documents needing database access or external services. Choose this approach for large applications where documents have significant dependencies.
Try It Yourself: Complete Document Processing System
Let's build a complete document processing system that combines all three patterns. You'll create a console app where the Factory creates different document types, the Singleton manages document registry, and the Builder configures complex documents. This demonstrates how patterns work together in real applications.
First, create a new console project and set up the necessary files:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
using System;
using System.Collections.Generic;
// Document interface
public interface IDocument
{
string Title { get; set; }
void Generate();
}
// Concrete document types
public class PdfDocument : IDocument
{
public string Title { get; set; } = string.Empty;
public string PageSize { get; set; } = "A4";
public void Generate() => Console.WriteLine($" [PDF Generated] {Title} ({PageSize})");
}
public class ExcelDocument : IDocument
{
public string Title { get; set; } = string.Empty;
public string SheetName { get; set; } = "Sheet1";
public void Generate() => Console.WriteLine($" [Excel Generated] {Title} (Sheet: {SheetName})");
}
// Singleton Registry
public sealed class DocumentRegistry
{
private static readonly Lazy<DocumentRegistry> _instance = new(() => new DocumentRegistry());
private DocumentRegistry() => RegisteredTypes = new Dictionary<string, Type>();
public static DocumentRegistry Instance => _instance.Value;
private Dictionary<string, Type> RegisteredTypes { get; }
public void RegisterType(string key, Type type)
{
RegisteredTypes[key] = type;
Console.WriteLine($"Registered: {key} -> {type.Name}");
}
public Type GetDocumentType(string key) => RegisteredTypes.TryGetValue(key, out var t)
? t : throw new KeyNotFoundException($"Type not found: {key}");
}
// Factory
public class DocumentFactory
{
public IDocument CreateDocument(string type)
{
var docType = DocumentRegistry.Instance.GetDocumentType(type);
return (IDocument)(Activator.CreateInstance(docType)
?? throw new InvalidOperationException($"Cannot create {type}"));
}
}
// Builder for complex configuration
public class ReportConfiguration
{
public string Title { get; set; } = string.Empty;
public string DocumentType { get; set; } = "pdf";
public bool IncludeSummary { get; set; }
public List<string> Sections { get; set; } = new();
}
public class ReportConfigurationBuilder
{
private readonly ReportConfiguration _config = new();
public ReportConfigurationBuilder WithTitle(string title)
{
_config.Title = title;
return this;
}
public ReportConfigurationBuilder AsDocumentType(string type)
{
_config.DocumentType = type;
return this;
}
public ReportConfigurationBuilder WithSummary()
{
_config.IncludeSummary = true;
return this;
}
public ReportConfigurationBuilder AddSection(string section)
{
_config.Sections.Add(section);
return this;
}
public ReportConfiguration Build()
{
if (string.IsNullOrWhiteSpace(_config.Title))
throw new InvalidOperationException("Title required");
return _config;
}
}
// Main program demonstrating all patterns
class Program
{
static void Main()
{
Console.WriteLine("=== Document Processing System ===\n");
// 1. Initialize Singleton Registry
Console.WriteLine("1. Setting up Document Registry (Singleton):");
var registry = DocumentRegistry.Instance;
registry.RegisterType("pdf", typeof(PdfDocument));
registry.RegisterType("excel", typeof(ExcelDocument));
Console.WriteLine();
// 2. Use Factory to create documents
Console.WriteLine("2. Creating Documents (Factory Pattern):");
var factory = new DocumentFactory();
var pdfDoc = factory.CreateDocument("pdf");
pdfDoc.Title = "Monthly Report";
pdfDoc.Generate();
var excelDoc = factory.CreateDocument("excel");
excelDoc.Title = "Sales Data";
excelDoc.Generate();
Console.WriteLine();
// 3. Use Builder for complex configuration
Console.WriteLine("3. Building Complex Report (Builder Pattern):");
var reportConfig = new ReportConfigurationBuilder()
.WithTitle("Q3 Financial Report")
.AsDocumentType("pdf")
.WithSummary()
.AddSection("Revenue Analysis")
.AddSection("Expense Breakdown")
.AddSection("Projections")
.Build();
Console.WriteLine($" Report: {reportConfig.Title}");
Console.WriteLine($" Type: {reportConfig.DocumentType}");
Console.WriteLine($" Include Summary: {reportConfig.IncludeSummary}");
Console.WriteLine($" Sections: {string.Join(", ", reportConfig.Sections)}");
Console.WriteLine();
// 4. Combine all patterns
Console.WriteLine("4. Combining All Patterns:");
var complexDoc = factory.CreateDocument(reportConfig.DocumentType);
complexDoc.Title = reportConfig.Title;
complexDoc.Generate();
Console.WriteLine();
Console.WriteLine("=== All Patterns Working Together! ===");
}
}
To run this example:
- Create a new directory:
mkdir DocumentPatterns && cd DocumentPatterns
- Save the .csproj file above
- Save the Program.cs file above
- Run:
dotnet build
- Execute:
dotnet run
Expected output:
=== Document Processing System ===
1. Setting up Document Registry (Singleton):
Registered: pdf -> PdfDocument
Registered: excel -> ExcelDocument
2. Creating Documents (Factory Pattern):
[PDF Generated] Monthly Report (A4)
[Excel Generated] Sales Data (Sheet: Sheet1)
3. Building Complex Report (Builder Pattern):
Report: Q3 Financial Report
Type: pdf
Include Summary: True
Sections: Revenue Analysis, Expense Breakdown, Projections
4. Combining All Patterns:
[PDF Generated] Q3 Financial Report (A4)
=== All Patterns Working Together! ===
Choosing the Right Pattern for Your Scenario
Deciding between patterns comes down to understanding your specific needs. Don't apply patterns just because they're available—use them when they solve actual problems you're facing.
Choose Factory when you're creating families of related objects or need to hide complex instantiation logic. If you find yourself writing conditional logic to decide which class to instantiate, that's a factory waiting to happen. Factories work particularly well when you're building extensible systems where new types get added over time. You'll see immediate benefits when testing because you can inject mock factories that return test doubles.
Choose Singleton when a single instance truly needs coordination across your application. Configuration managers are perfect candidates—having multiple instances with different values would create inconsistency. However, modern .NET applications often use dependency injection with singleton lifetime instead of the classic Singleton pattern. This gives you the same single-instance behavior while maintaining better testability. Consider whether DI might serve your needs before implementing a traditional singleton.
Choose Builder when object construction becomes unwieldy. If you're writing methods with four or more parameters, especially optional ones, builders dramatically improve readability. They're essential for immutable objects where you need to validate the entire state before construction. Builders also shine in testing scenarios where you need to create many similar objects with slight variations—the fluent interface makes test data construction clean and maintainable.
Migration tips: You don't need to refactor everything at once. Start by wrapping existing object creation in a simple factory interface. As complexity grows, evolve toward registry-based or DI-integrated factories. For singletons, consider moving to DI singleton lifetime as you modernize your codebase. Introduce builders incrementally for classes as you add features requiring more configuration options. Patterns should emerge naturally from your needs, not be forced onto simple scenarios.