Module 2 · 2. Creational Patterns · Lesson 5 of 12
Builder and Prototype
Learning outcome
You will learn when to prefer the Builder or Prototype patterns in C#, how to implement a safe deep-clone for mutable nested state, and how to avoid common cloning pitfalls (shallow copies, ICloneable ambiguity). You'll be able to explain trade-offs in interviews and select an approach appropriate for complex construction or cloning semantics.
Intuition
Builder solves: "Many optional parameters, invariants and readable construction fluent API." Use Builder when constructors become combinatorial, or when building an object requires validation and multiple staged steps. Prototype solves: "I need copies quickly, especially when construction is expensive or the concrete type is only known at runtime." Prototype is powerful for runtime-polymorphic cloning and for caching/duplicating configured instances.
Simple alternatives (direct constructors, object initializers, record with with) are often fine. Prefer Builder when the construction process itself is non-trivial, requires validation or step-by-step assembly. Prefer Prototype when copying must preserve runtime type and/or copying a fully configured expensive-to-build instance is cheaper than re-creating it.
Deep dive
- Builder: fluent API, internal mutability in the builder, produces an immutable or controlled-mutable product. Validate in Build(). Avoid exposing partially-built objects.
- Prototype: provide a CloneDeep method on a well-defined interface (IPrototype<T>). Do not use ICloneable for public APIs — it returns object and has no cloning semantics documented (deep vs shallow).
- Cloning semantics: implement deep clone explicitly for fields that are mutable (lists, dictionaries, nested objects). Shallow clones (MemberwiseClone) copies references and can produce hard-to-find bugs.
Below are concrete code excerpts demonstrating both patterns and a cloning pitfall (shallow clone) plus the correct deep clone. Every multiline example is fenced.
Failure modes
- Using shallow clone by accident: mutations to the clone affect the original. Hard to debug in tests or production.
- Overusing Prototype: if construction is cheap, cloning adds maintenance overhead. Prototype helps when construction is expensive, or when you need runtime polymorphism.
- Exposing mutable internal collections from the built object breaks encapsulation. Prefer returning IReadOnlyList<T> or defensive copies.
- Returning
ICloneablein public API: ambiguous semantics. Define a strongly-typed clone interface with clear deep/shallow contract.
Interview drill
Questions to prepare:
- When would you prefer Builder over a record + with-expressions?
- How would you document clone semantics in a public API? What interface would you expose? Why not ICloneable?
- Show the consequences of a shallow clone and how you'd fix it.
- Given a heavy-weight object built via many steps and shared across threads, which pattern or combination would you choose?
Micro-tests (whiteboard or live coding):
- Implement a Product builder that validates required fields and fails fast.
- Show a shallow clone, mutate nested list on clone, and show the original changed. Then fix with deep clone.
Revision checklist
- Can you explain trade-offs between constructor overloading, builder, and object initializers?
- Can you implement and reason about deep vs shallow clones for nested mutable state?
- Do you avoid ICloneable in public APIs and instead use a strongly-typed clone method or interface?
- Do you return read-only views for internal collections, or make defensive copies when necessary?
Production code
The following production-like, deterministic example demonstrates:
- Builder pattern with validation and fluent API
- Prototype pattern with both shallow (pitfall) and deep clone
- A C#-14 style extension(...) block that exposes a convenient DeepClone extension method
- Comments that explain why the pattern is preferable to simpler alternatives and a misuse warning
(See the runnable Program.cs in the codeExamples array for a complete single-file console example.)
Code walkthrough
The runnable example demonstrates three phases: 1) Use ProductBuilder to construct a Product with validated fields and options. This is clearer and more maintainable than multiple constructor overloads or a very long parameter list. 2) Show a shallow clone via MemberwiseClone and how mutating the clone's Options list affects the original — the classic clone pitfall. 3) Show a correct DeepClone that copies nested mutable collections, and an extension(...) convenience wrapper. The extension makes calling ergonomically consistent with language extensions, while the clone contract remains explicit on the Product type.
Executable code examples
Builder and Prototype demo with deep vs shallow clone
Program.cs
using System;
using System.Collections.Generic;
using System.Linq;
// Demonstrates: Builder for validated construction; Prototype for cloning.
// Why preferable to simpler alternatives:
// - Builder centralizes optional parameters and validation; better than many ctor overloads.
// - Prototype (typed DeepClone) gives explicit, predictable cloning semantics.
// Misuse warning:
// - Shallow clones (MemberwiseClone) will copy references to mutable collections and can silently
// introduce shared-state bugs.
namespace BuilderPrototypeDemo
{
// Strongly-typed clone interface to avoid the ambiguity of ICloneable
public interface IPrototype<T>
{
T DeepClone();
}
public class Product : IPrototype<Product>
{
public string Name { get; }
public decimal Price { get; }
// Mutable nested state that requires careful cloning
public List<string> Options { get; }
public Product(string name, decimal price, List<string>? options = null)
{
Name = name;
Price = price;
Options = options ?? new List<string>();
}
// Shallow clone: demonstrates the pitfall (copies Options reference)
public Product ShallowClone()
{
// MemberwiseClone is protected; accessible inside the type
return (Product)this.MemberwiseClone();
}
// Explicit deep clone: copy nested mutable collections
public Product DeepClone()
{
return new Product(Name, Price, new List<string>(Options));
}
public override string ToString()
{
return $"Product(Name={Name}, Price={Price}, Options=[{string.Join(", ", Options)}])";
}
}
// Classic extension method wrapper (works on all supported C# versions)
public static class ProductExtensions
{
public static Product DeepCloneExt(this Product p)
{
// This simply delegates to the explicit DeepClone implementation.
return p.DeepClone();
}
}
// Builder pattern with fluent API and validation
public class ProductBuilder
{
private string? _name;
private decimal? _price;
private readonly List<string> _options = new();
public ProductBuilder SetName(string name)
{
_name = name ?? throw new ArgumentNullException(nameof(name));
return this;
}
public ProductBuilder SetPrice(decimal price)
{
if (price < 0) throw new ArgumentOutOfRangeException(nameof(price));
_price = price;
return this;
}
public ProductBuilder AddOption(string option)
{
if (string.IsNullOrWhiteSpace(option)) throw new ArgumentException("Option cannot be empty", nameof(option));
_options.Add(option);
return this;
}
// Build enforces invariants in one place
public Product Build()
{
if (string.IsNullOrEmpty(_name)) throw new InvalidOperationException("Name is required");
if (_price == null) throw new InvalidOperationException("Price is required");
// Return a product with a defensive copy of options to prevent external mutation
return new Product(_name, _price.Value, new List<string>(_options));
}
}
class Program
{
static void Main()
{
// 1) Builder usage
var phone = new ProductBuilder()
.SetName("Phone")
.SetPrice(699)
.AddOption("Case")
.AddOption("Charger")
.Build();
Console.WriteLine("Built product: " + phone);
// 2) Prototype shallow vs deep clone demonstration
var laptop = new Product("Laptop", 1299m, new List<string> { "Dock" });
Console.WriteLine("Original product before cloning: " + laptop);
// Shallow clone (pitfall): the Options list reference is shared
var shallow = laptop.ShallowClone();
shallow.Options.Add("Shared-Hack");
Console.WriteLine("After shallow clone mutation, original is: " + laptop);
// Shows that original was mutated because Options was shared.
// Reset laptop to clean state for deep clone demonstration
laptop = new Product("Laptop", 1299m, new List<string> { "Dock" });
// Deep clone (correct): Options copied
var deep = laptop.DeepClone();
deep.Options.Add("Deep-Safe");
Console.WriteLine("After deep clone mutation, original is: " + laptop);
Console.WriteLine("Deep clone is: " + deep);
// 3) Using extension convenience wrapper
var extClone = laptop.DeepCloneExt();
extClone.Options.Add("Ext-Clone");
Console.WriteLine("Using extension DeepCloneExt, original remains: " + laptop);
Console.WriteLine("Extension clone: " + extClone);
// Summary lines to make output deterministic and easy to assert in tests
Console.WriteLine("DEMO-END");
}
}
}