Mert Özen Further With Every Line
Software Design Design Patterns — #06

Prototype Pattern: record and with, Shallow vs Deep Copy and Why ICloneable Is Discouraged

Mert Özen Nov 7, 2026 16 min 5 views
Prototype Pattern: record and with, Shallow vs Deep Copy and Why ICloneable Is Discouraged

Prototype creates a new object by copying a configured sample instead of building it from scratch; in C#, the with expression on records does this while preserving the runtime type, but the copy is shallow. A prototype registry, controlled deep copies with a copy constructor, ICloneable's vague contract, the measured cost of copying through JSON and the with traps that skip validation.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

Bookstore's corporate sales team sends schools the same quote every September: two classic novels, thirty copies each, a 10 percent discount. The team prepared the quote once and keeps it as a template; for every new school they take a copy of the template, change the customer name and add lines if needed. The quote is a record and the copy is made with with:

Quote template = new("Template: school year start", [
    new QuoteLine("978-0141439518", 30, 9.50m),
    new QuoteLine("978-0547928227", 30, 11.90m)
], DiscountRate: 0.10m);

Quote forHillside = template with { Customer = "Hillside High School" };
forHillside.Lines.Add(new QuoteLine("978-0061120084", 25, 8.75m));

Console.WriteLine($"Template lines: {template.Lines.Count}");
Console.WriteLine($"Hillside lines: {forHillside.Lines.Count}");

public sealed record QuoteLine(string Isbn, int Quantity, decimal UnitPrice);

public sealed record Quote(string Customer, List<QuoteLine> Lines, decimal DiscountRate);

A line was added to Hillside High School's quote. How many lines does the template have? When you copy the next school's quote from this template, how many lines will it start with?

Creating by Copying: What Prototype Is Really About

The output:

Template lines: 3
Hillside lines: 3

The template has three lines too. The next school will see the book added for Hillside in its quote even though it never asked for it. We'll get to the reason shortly; first let's look at which pattern this code is trying to apply.

GoF (Gang of Four: the 1994 book Design Patterns: Elements of Reusable Object-Oriented Software by Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides) lists Prototype among the creational patterns. Its intent, in short: specify the kind of object to create with a sample instance, and create new objects by copying that sample. The book recommends this route in three situations. First, when the class to instantiate is only known at runtime. Second, to avoid building a factory hierarchy that parallels the product hierarchy. Third, when objects have only a few different combinations of state: rather than setting up the right state from scratch every time, it's easier to keep ready-made prototypes and copy them. Bookstore's quote templates are exactly that third case.

The pattern has two parts. The prototype is an object that can copy itself. The client asks for a new object without knowing the concrete class of the one being copied. GoF adds a prototype manager (registry) on top: a small catalog that stores prototypes under a key and hands out a copy on every request. In C#, the with expression on records builds this contract into the language, because the copy is produced according to the object's runtime type, not the variable's compile-time type. Let's split the templates into two types, for schools and corporate customers, and put them in a registry:

QuoteTemplates templates = new();
templates.Register("school", new SchoolQuote("(template)", [
    new QuoteLine("978-0141439518", 30, 9.50m),
    new QuoteLine("978-0547928227", 30, 11.90m)
], DiscountRate: 0.10m, AcademicYear: "2026-2027"));
templates.Register("corporate", new CorporateQuote("(template)", [
    new QuoteLine("978-0132350884", 10, 39.90m)
], DiscountRate: 0.05m, PaymentTermDays: 60));

Quote hillside = templates.Create("school", "Hillside High School");
hillside.Lines.Add(new QuoteLine("978-0061120084", 25, 8.75m));
Quote riverside = templates.Create("corporate", "Riverside Logistics");

Console.WriteLine($"{hillside.GetType().Name}: {hillside.Customer}, lines = {hillside.Lines.Count}");
Console.WriteLine($"{riverside.GetType().Name}: {riverside.Customer}, lines = {riverside.Lines.Count}");
Console.WriteLine($"School template still has {templates.Create("school", "x").Lines.Count} lines");

public sealed record QuoteLine(string Isbn, int Quantity, decimal UnitPrice);

public abstract record Quote(string Customer, List<QuoteLine> Lines, decimal DiscountRate)
{
    // Copy constructor: with and every derived record use it
    protected Quote(Quote original)
    {
        Customer = original.Customer;
        Lines = [.. original.Lines];
        DiscountRate = original.DiscountRate;
    }
}

public sealed record SchoolQuote(string Customer, List<QuoteLine> Lines, decimal DiscountRate, string AcademicYear)
    : Quote(Customer, Lines, DiscountRate);

public sealed record CorporateQuote(string Customer, List<QuoteLine> Lines, decimal DiscountRate, int PaymentTermDays)
    : Quote(Customer, Lines, DiscountRate);

// Prototype registry: stores configured instances by key and hands out a copy on every request
public sealed class QuoteTemplates
{
    private readonly Dictionary<string, Quote> _prototypes = [];

    public void Register(string key, Quote prototype) => _prototypes[key] = prototype;

    // The caller doesn't know the concrete type; with copies while preserving the runtime type
    public Quote Create(string key, string customer) => _prototypes[key] with { Customer = customer };
}
SchoolQuote: Hillside High School, lines = 3
CorporateQuote: Riverside Logistics, lines = 1
School template still has 2 lines

QuoteTemplates.Create only knows it holds a Quote reference; it never mentions SchoolQuote or CorporateQuote. Still, the copies come out with the right type and carry subtype-specific values like AcademicYear and PaymentTermDays. What makes this work is the hidden <Clone>$ method the compiler generates for every record: because it's virtual, with always copies the most derived type. Adding a new kind of quote means writing a record and registering it; QuoteTemplates doesn't change and no separate factory class is opened for each kind. The parallel hierarchy we saw in this series' Factory Method and Abstract Factory posts never forms here.

The template no longer gets corrupted either: the line added to Hillside lives only in Hillside's quote. The difference comes from a single constructor we wrote in the base record. To understand why it's needed, we have to look at how deep the copy goes.

Shallow Copy and Deep Copy

A shallow copy copies an object's fields one-to-one into a new object. If a field is a value type (int, decimal), the value itself is copied. If it's a reference type (List<T>, another class), only the reference is copied; both objects point at the same list. A deep copy also copies the objects those references point to, and their contents too if needed.

The copy constructor the compiler generates for a record is shallow; the Microsoft Learn page on the with expression says so explicitly. In the opening example forHillside.Lines and template.Lines were the same List<QuoteLine> object, so Add grew both. With records, the way to change this is to write the copy constructor yourself: with calls it, and because the compiler-generated copy constructors of derived records call the base one, writing it in one place is enough. If it's not a record, you do the same job with the protected MemberwiseClone inherited from object:

OrderDraft draft = new() { Customer = "Ada", ShippingCity = "Izmir" };
draft.Items.Add("978-0141439518");

OrderDraft shallow = draft.ShallowCopy();
OrderDraft deep = draft.DeepCopy();
draft.Items.Add("978-0547928227");

Console.WriteLine($"shallow items: {shallow.Items.Count}, deep items: {deep.Items.Count}");

public sealed class OrderDraft
{
    public required string Customer { get; set; }
    public required string ShippingCity { get; set; }
    public List<string> Items { get; private set; } = [];

    // MemberwiseClone is protected: copies fields as-is, shares references
    public OrderDraft ShallowCopy() => (OrderDraft)MemberwiseClone();

    public OrderDraft DeepCopy()
    {
        OrderDraft copy = (OrderDraft)MemberwiseClone();
        copy.Items = [.. Items];
        return copy;
    }
}
shallow items: 2, deep items: 1

The thing to notice is that "deep" is a matter of degree. QuoteLine is an immutable record; since it can't change, two quotes sharing the same line object is harmless. The only thing that has to be copied is the mutable container, the list. Copying everything recursively is both expensive and usually unnecessary. The right question isn't "deep or shallow" but "which parts of this object can change and must not be shared with the copy".

Why ICloneable Is Discouraged

.NET already has a copying contract: System.ICloneable, whose single method is object Clone(). The problem is that the contract promises nothing. The API (Application Programming Interface) page on Microsoft Learn says the implementation doesn't specify whether it performs a deep copy, a shallow copy or something in between, and doesn't even require copying every property, and for that reason recommends that ICloneable not be implemented in public APIs. Framework Design Guidelines (Krzysztof Cwalina and Brad Abrams), which collects the .NET library design rules, gives the same advice. The object return type also means a cast on every call.

Seeing how differently methods named Clone behave inside the BCL (Base Class Library, .NET's core library) makes that advice concrete:

using System.Data;
using System.Globalization;
using System.Text.Json.Nodes;
using System.Xml;

// string: Clone() makes no copy, it returns the same instance
string isbn = "978-0141439518";
Console.WriteLine($"string.Clone same instance: {ReferenceEquals(isbn, isbn.Clone())}");

// Array: shallow copy; the elements are the same objects
List<string>[] shelves = [["Emma"], ["Dune"]];
List<string>[] copy = (List<string>[])shelves.Clone();
copy[0].Add("Persuasion");
Console.WriteLine($"Array.Clone original shelf 0: {shelves[0].Count} books");

// NumberFormatInfo: shallow copy, but IsReadOnly always becomes false
NumberFormatInfo readOnly = CultureInfo.InvariantCulture.NumberFormat;
NumberFormatInfo nfiCopy = (NumberFormatInfo)readOnly.Clone();
Console.WriteLine($"NumberFormatInfo IsReadOnly: {readOnly.IsReadOnly} -> {nfiCopy.IsReadOnly}");

// XmlNode: the caller chooses the depth
XmlDocument doc = new();
doc.LoadXml("<order><item isbn='978-0141439518'/><item isbn='978-0547928227'/></order>");
XmlNode order = doc.DocumentElement!;
Console.WriteLine($"XmlNode.CloneNode(false) children: {order.CloneNode(false).ChildNodes.Count}");
Console.WriteLine($"XmlNode.CloneNode(true)  children: {order.CloneNode(true).ChildNodes.Count}");

// DataTable: Clone copies only the schema, Copy copies schema + data
DataTable books = new("Books");
books.Columns.Add("Isbn", typeof(string));
books.Rows.Add("978-0141439518");
books.Rows.Add("978-0547928227");
Console.WriteLine($"DataTable.Clone rows: {books.Clone().Rows.Count}, Copy rows: {books.Copy().Rows.Count}");

// JsonNode: not ICloneable, the name says it all
JsonNode cart = JsonNode.Parse("""{"items":[{"isbn":"978-0141439518"}]}""")!;
JsonNode cartCopy = cart.DeepClone();
cartCopy["items"]!.AsArray().Add(new JsonObject { ["isbn"] = "978-0547928227" });
Console.WriteLine($"JsonNode.DeepClone original items: {cart["items"]!.AsArray().Count}");
string.Clone same instance: True
Array.Clone original shelf 0: 2 books
NumberFormatInfo IsReadOnly: True -> False
XmlNode.CloneNode(false) children: 0
XmlNode.CloneNode(true)  children: 2
DataTable.Clone rows: 0, Copy rows: 2
JsonNode.DeepClone original items: 1
APIWhat it copies
string.Clone()Nothing; returns the same instance (string is immutable)
Array.Clone()Shallow; elements are shared
NumberFormatInfo.Clone()Shallow, but IsReadOnly is always false
XmlNode.CloneNode(bool deep)The caller chooses the depth
DataTable.Clone() / Copy()Schema only / schema and rows
JsonNode.DeepClone()The whole tree; the name states the contract

The same word means six different things. The APIs in the last three rows also show the way out of this ambiguity: make the depth explicit through a parameter or the method name. Do the same in your own code: instead of Clone, a name that says what it does, like CopyForCustomer or DeepCopy, and the concrete type as the return type. A nice BCL example is the copy constructor of JsonSerializerOptions: new JsonSerializerOptions(existing) takes a modifiable copy of a configured options object, the plainest form of a prototype.

Ways to Deep Copy and What They Cost

You'll see three ways to deep copy. The first is a hand-written copy constructor as above, or MemberwiseClone plus fix-ups: fast and explicit, but with the risk of forgetting to copy a field added later. The second is serializing and deserializing: writing the object graph to JSON (JavaScript Object Notation) and reading it back really recreates everything and requires no code. In older projects you'll also see code doing the same with BinaryFormatter; because of its security vulnerabilities, starting with .NET 9 the runtime no longer includes an implementation and every call throws PlatformNotSupportedException. The third is third-party libraries that copy generically with reflection or expression trees.

The first cost of the JSON route is correctness. The serializer only sees public, serializable state; private fields, computed values and circular references need extra settings. Polymorphism doesn't work on its own either. Let's try to copy the template from the registry example through its base type:

using System.Text.Json;

Quote template = new SchoolQuote("(template)", [new QuoteLine("978-0141439518", 30, 9.50m)], "2026-2027");

string json = JsonSerializer.Serialize(template);
Console.WriteLine(json);

try
{
    Quote copy = JsonSerializer.Deserialize<Quote>(json)!;
}
catch (NotSupportedException ex)
{
    Console.WriteLine(ex.Message);
}

public sealed record QuoteLine(string Isbn, int Quantity, decimal UnitPrice);

public abstract record Quote(string Customer, List<QuoteLine> Lines);

public sealed record SchoolQuote(string Customer, List<QuoteLine> Lines, string AcademicYear)
    : Quote(Customer, Lines);
{"Customer":"(template)","Lines":[{"Isbn":"978-0141439518","Quantity":30,"UnitPrice":9.50}]}
Deserialization of interface or abstract types is not supported. Type 'Quote'. Path: $ | LineNumber: 0 | BytePositionInLine: 1.

Two problems at once: the serialized JSON has no AcademicYear, because the serializer wrote the properties of the declared type Quote; and when reading it back it couldn't create an instance of an abstract type. You can solve this with the [JsonPolymorphic] and [JsonDerivedType] attributes, but now you're designing a serialization contract just to copy an object.

The second cost is performance. A measurement that copies a twenty-line quote in three ways (BenchmarkDotNet, .NET 10.0.13, a Mac with an M5 (Apple Silicon) processor):

using System.Text.Json;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<QuoteCopyBenchmarks>();

[MemoryDiagnoser]
public class QuoteCopyBenchmarks
{
    private readonly ShallowQuote _shallow;
    private readonly Quote _quote;

    public QuoteCopyBenchmarks()
    {
        List<QuoteLine> lines = [.. Enumerable.Range(1, 20).Select(i => new QuoteLine($"978-{i:D10}", 10 + i, 9.90m))];
        _shallow = new ShallowQuote("Hillside High School", lines, 0.10m);
        _quote = new Quote("Hillside High School", [.. lines], 0.10m);
    }

    [Benchmark(Baseline = true)]
    public ShallowQuote ShallowWith() => _shallow with { Customer = "Riverside Logistics" };

    [Benchmark]
    public Quote CopyConstructor() => _quote with { Customer = "Riverside Logistics" };

    [Benchmark]
    public Quote JsonRoundTrip()
    {
        string json = JsonSerializer.Serialize(_quote);
        return JsonSerializer.Deserialize<Quote>(json)! with { Customer = "Riverside Logistics" };
    }
}

public sealed record QuoteLine(string Isbn, int Quantity, decimal UnitPrice);

public sealed record ShallowQuote(string Customer, List<QuoteLine> Lines, decimal DiscountRate);

public sealed record Quote(string Customer, List<QuoteLine> Lines, decimal DiscountRate)
{
    // QuoteLine is immutable, so copying the list is "deep" enough
    private Quote(Quote original)
    {
        Customer = original.Customer;
        Lines = [.. original.Lines];
        DiscountRate = original.DiscountRate;
    }
}
| Method          | Mean         | Ratio    | Gen0   | Allocated | Alloc Ratio |
|---------------- |-------------:|---------:|-------:|----------:|------------:|
| ShallowWith     |     3.673 ns |     1.00 | 0.0057 |      48 B |        1.00 |
| CopyConstructor |    18.235 ns |     4.97 | 0.0316 |     264 B |        5.50 |
| JsonRoundTrip   | 4,523.479 ns | 1,232.86 | 1.0300 |    8632 B |      179.83 |

The table is trimmed; the measured values are exactly as in the BenchmarkDotNet report. The copy constructor is about five times slower than the shallow copy because it allocates a new twenty-element list; still, 18 nanoseconds. The JSON round trip is 4.5 microseconds and 8.6 KB of memory: about 250 times slower than the copy constructor. To be fair, the JSON route also recreates the QuoteLine objects; but for immutable lines that work was unnecessary anyway. On a screen that copies a few quotes a second this difference is irrelevant; in a copy that runs a million times in a batch job it matters. Don't decide without measuring; the C# Advanced series post "Performance in C#: Allocations, Structs and Measuring with BenchmarkDotNet" explains how to set up such a measurement.

Two Silent Traps of with

with isn't a constructor call. It first produces the copy with the copy constructor, then assigns only the members you list inside the braces. That means any code that runs once during construction won't run for the copy. Let's add a total amount and a limit on the discount rate to the quote:

Quote template = new("(template)", [new QuoteLine("978-0141439518", 30, 9.50m)], DiscountRate: 0.10m);
Console.WriteLine($"Template total: {template.Total}");

Quote bigger = template with { Lines = [new QuoteLine("978-0141439518", 60, 9.50m)] };
Console.WriteLine($"Copy total:     {bigger.Total}");

Quote generous = template with { DiscountRate = 0.90m };
Console.WriteLine($"Copy discount:  {generous.DiscountRate}");

try
{
    Quote invalid = new("Hillside High School", [], DiscountRate: 0.90m);
}
catch (ArgumentOutOfRangeException ex)
{
    Console.WriteLine(ex.Message);
}

public sealed record QuoteLine(string Isbn, int Quantity, decimal UnitPrice);

public sealed record Quote(string Customer, List<QuoteLine> Lines, decimal DiscountRate)
{
    // Computed once in the constructor and stored
    public decimal Total { get; } = Lines.Sum(l => l.Quantity * l.UnitPrice) * (1 - DiscountRate);

    // Validation runs only on the primary constructor path
    public decimal DiscountRate { get; init; } =
        DiscountRate is >= 0 and <= 0.30m ? DiscountRate : throw new ArgumentOutOfRangeException(nameof(DiscountRate));
}
Template total: 256.5000
Copy total:     256.5000
Copy discount:  0.90
Specified argument was out of the range of valid values. (Parameter 'DiscountRate')

The quote whose quantity doubled still totals 256.50: Total was computed and written to a field when the first object was created, and the copy constructor copied that field. The discount limit also protects only the path through the primary constructor; with { DiscountRate = 0.90m } produced a 90 percent discount quote without complaint. The with page on Microsoft Learn also recommends that computed properties be computed on access, not at creation.

The fix takes two steps. Total becomes an expression-bodied property computed on every access. Validation moves to the init accessor; thanks to the field keyword in C# 14 there's no need to declare the backing field by hand. There's one detail I found by measuring: the property initializer (= DiscountRate) writes straight to the backing field and doesn't go through the init accessor. If you put the validation only in the accessor, the constructor path is now unprotected. Both paths must call the same check:

Quote template = new("(template)", [new QuoteLine("978-0141439518", 30, 9.50m)], DiscountRate: 0.10m);

Quote bigger = template with { Lines = [new QuoteLine("978-0141439518", 60, 9.50m)] };
Console.WriteLine($"Copy total: {bigger.Total}");

Try("ctor", () => new Quote("Hillside High School", [], DiscountRate: 0.90m));
Try("with", () => template with { DiscountRate = 0.90m });

static void Try(string path, Func<Quote> create)
{
    try { create(); Console.WriteLine($"{path}: accepted"); }
    catch (ArgumentOutOfRangeException ex) { Console.WriteLine($"{path}: rejected {ex.ParamName}"); }
}

public sealed record QuoteLine(string Isbn, int Quantity, decimal UnitPrice);

public sealed record Quote(string Customer, List<QuoteLine> Lines, decimal DiscountRate)
{
    // Computed on every access, so it stays current in the copy
    public decimal Total => Lines.Sum(l => l.Quantity * l.UnitPrice) * (1 - DiscountRate);

    // with goes through the init accessor, the primary constructor through the initializer; both call the same check
    public decimal DiscountRate
    {
        get;
        init => field = ValidRate(value);
    } = ValidRate(DiscountRate);

    private static decimal ValidRate(decimal rate) =>
        rate is >= 0 and <= 0.30m ? rate : throw new ArgumentOutOfRangeException(nameof(DiscountRate));
}
Copy total: 513.0000
ctor: rejected DiscountRate
with: rejected DiscountRate

When Not to Use It

The justification for Prototype is that copying is cheaper or more correct than building from scratch. If the object is easy to create with a few parameters, creating a new instance and filling in its values is both more readable and never introduces the shallow-copy risk. In GoF's examples the prototypes were configured objects with expensive setup; setting up a registry for a three-property DTO (Data Transfer Object) is a needless layer.

The second case is immutable types. If state can't change, sharing is safe and no copy is needed. Make the quote's lines an ImmutableArray<T> and with stops being a trap:

using System.Collections.Immutable;

Quote template = new("(template)", [new QuoteLine("978-0141439518", 30, 9.50m)], DiscountRate: 0.10m);

// No copy: Add returns a new array and cannot touch the template
Quote hillside = template with
{
    Customer = "Hillside High School",
    Lines = template.Lines.Add(new QuoteLine("978-0061120084", 25, 8.75m))
};

Console.WriteLine($"Template lines: {template.Lines.Length}");
Console.WriteLine($"Hillside lines: {hillside.Lines.Length}");
Console.WriteLine($"Same line object shared: {ReferenceEquals(template.Lines[0], hillside.Lines[0])}");

public sealed record QuoteLine(string Isbn, int Quantity, decimal UnitPrice);

public sealed record Quote(string Customer, ImmutableArray<QuoteLine> Lines, decimal DiscountRate);
Template lines: 1
Hillside lines: 2
Same line object shared: True

Add doesn't change the existing array, it returns a new one; the line objects are shared too, but since neither can change, there's no harm in that. No copy constructor to write, no debate about how deep the deep copy should go. You'll find the basics of with in the C# OOP (Object-Oriented Programming) series post "C# record and record struct: Value Equality and the with Expression", and the cost of these collections in the Collections series post "Immutable Collections in C# and Choosing the Right Collection". If you design the record to be truly immutable, the Prototype pattern stops being a separate structure and turns into an ordinary language feature.

The third is services managed by dependency injection. If you want a "configured copy" of a service, what you're usually after isn't copying but having the container produce a new instance: a transient lifetime or a factory.

Common Pitfalls

Taking with for a deep copy. If the record holds a List<T>, a Dictionary or a mutable class, the copy and the original share it. Either write the copy constructor or make the inner type immutable.

Forgetting a field added to the copy constructor later. A hand-written copy silently goes incomplete when a new property is added. With records the problem is even sneakier: once you write your own copy constructor, the compiler no longer copies that type's properties for you. The test for copying can be as simple as copying an object with every property filled and comparing for equality.

Trusting validation in the constructor. As you saw above, with skips the primary constructor. If a rule is an invariant, protect it in the init accessor or in a method both paths call.

Copying the identity along with everything else. A quote produced from a template shouldn't inherit the template's Id, creation date or number. Provide them again explicitly in the with, or let the copy method assign the new values itself.

Copying everything with serialize/deserialize. This easy-looking route puts polymorphism, private state and performance at risk together. Acceptable for a quick prototype, not on a hot code path.

Exposing ICloneable in a public API. The caller can't know the depth of the copy and has to cast the result. A method that says what it does and returns the concrete type is more honest.

A Small Experiment

★☆☆ Make the template stay at two lines in the opening example by adding only a copy constructor. Then turn QuoteLine into a mutable class and change the Quantity of a line in the copy. Was the template affected? How would you have to change the copy constructor?

★★☆ Add a Remove and a Keys to QuoteTemplates; then write a test that copies every template and verifies none of them shares its line list with the original (using ReferenceEquals).

★★★ Add a fourth method to the benchmark: a quote using ImmutableArray<QuoteLine> and with. Run the measurement and compare the result with the copy constructor. Also add a JSON route that creates JsonSerializerOptions on every call and observe the difference.

With Prototype we've finished the creational patterns. In the next post we move on to structural patterns, and the first stop is fitting a class we already have to another interface without changing it: using the Adapter pattern to connect two different payment providers' SDKs (Software Development Kits) to Bookstore's own IPaymentGateway.