Creational Intermediate 9 min read

Prototype Pattern in C#: with Expressions vs ICloneable

Prototype pattern in C# / .NET 10: clone existing objects via record with, why ICloneable is broken, and when to keep an explicit Clone method anyway.

Table of contents
  1. What problem does the Prototype pattern solve in C#?
  2. Why is ICloneable considered broken in .NET?
  3. How do records with with expressions express the Prototype pattern?
  4. When should you keep an explicit Clone method?
  5. How does deep cloning work safely in .NET 10?
  6. What does a real C# / .NET 10 example look like?
  7. Where should you read next in this series?

A marketing manager creates a "Black Friday" discount campaign in admin: 20% off, applies to all SKUs, runs for 48 hours, banner copy in English. The campaign needs to launch in twelve regions. Eleven of them inherit every setting except the locale-specific banner copy and a 1–3 percentage-point local override. Today, the engineer copies the constructor call twelve times, hand-edits the differences, and prays that nobody adds a thirteenth field to DiscountCampaign next quarter.

The Prototype pattern is the answer. Take a configured object, copy it, change the few fields that vary, get a fresh instance. The intent is short: create new objects by cloning an existing template, not by calling a constructor. In C# this is the pattern that has aged the best of all the creational ones — modern records make the textbook Prototype almost a one-liner. The interesting article-length topic is what to do when the textbook version is not enough.

We continue with the same e-commerce shop. Builder showed how to assemble an object from independent inputs. This article shows how to make many small variants of an object that already exists.

What problem does the Prototype pattern solve in C#?

The pattern earns its place when building a fresh instance from scratch costs more than copying an existing one and changing a few fields. Three concrete shapes:

  1. Configuration variants. A base DiscountCampaign with twelve fields plus eleven regional variants that differ on two fields each. Re-stating all twelve fields per variant is a copy-paste bug waiting to happen.
  2. Expensive-to-construct templates. An HttpClient configured with retry policies, default headers, and a base address. Cloning the configured client per call is cheaper than re-building it.
  3. Pre-loaded test fixtures. A Customer with a fully populated address, payment method, and three historical orders. Each test needs a near-copy with one field different.

What is not a Prototype problem: "I want to share one instance" (that is Singleton); "I want to assemble a fresh object from inputs" (that is Builder). Prototype starts from a template; the other two start from nothing.

Why is ICloneable considered broken in .NET?

The .NET BCL has shipped System.ICloneable since version 1. It is one of the most consistent recommendations in the framework's history that you should not implement it. Three reasons:

Microsoft's official documentation on ICloneable states the issue plainly: do not implement it; provide a typed clone method instead. The Prototype pattern is unaffected by this advice — its intent does not depend on the broken interface. We just give the intent a better implementation.

How do records with with expressions express the Prototype pattern?

The C# 9 record and the with expression compile down to exactly the Prototype pattern: a copy constructor that creates a new instance identical to the source, then applies the changes you specify.

public sealed record DiscountCampaign
{
    public required string Code      { get; init; }
    public required decimal Percent  { get; init; }
    public required DateTime StartsAt { get; init; }
    public required DateTime EndsAt  { get; init; }
    public string Region             { get; init; } = "global";
    public string BannerCopy         { get; init; } = "";
    public IReadOnlyList<string> ApplicableSkus { get; init; } = Array.Empty<string>();
    public bool   StackableWithCoupons { get; init; }
}

Producing the eleven regional variants from a single template is now one expression each:

var blackFriday = new DiscountCampaign
{
    Code = "BF24",
    Percent = 20m,
    StartsAt = new DateTime(2026, 11, 28, 0, 0, 0, DateTimeKind.Utc),
    EndsAt   = new DateTime(2026, 11, 30, 0, 0, 0, DateTimeKind.Utc),
    BannerCopy = "20% off everything",
};

var blackFridayEu = blackFriday with { Region = "EU", Percent = 22m,
    BannerCopy = "20 % auf alles" };

var blackFridayJp = blackFriday with { Region = "JP",
    BannerCopy = "全品20%オフ" };

The flow looks like this:

flowchart LR
    Template["DiscountCampaign<br>blackFriday"]
    EU["DiscountCampaign<br>EU variant"]
    JP["DiscountCampaign<br>JP variant"]
    BR["DiscountCampaign<br>BR variant"]

    Template -->|with { Region=EU, Percent=22 }| EU
    Template -->|with { Region=JP, BannerCopy=... }| JP
    Template -->|with { Region=BR, Percent=18 }| BR

Three things to notice:

For 80% of Prototype use cases in .NET 10, this is the entire pattern. The next two sections cover the cases that need more.

When should you keep an explicit Clone method?

The record with shape covers immutable, shallow-clone-safe types. Three cases need an explicit method instead:

The implementation of an explicit clone in modern C# is itself usually a with:

public sealed record CartLine
{
    public required string Sku { get; init; }
    public required int Quantity { get; init; }
    public List<string> Notes { get; init; } = new();   // mutable

    public CartLine DeepClone() => this with { Notes = new List<string>(Notes) };
}

The pattern is preserved; the Clone() method's body is now one expression that handles the parts with cannot.

How does deep cloning work safely in .NET 10?

When deep cloning is genuinely required (a graph of mutable objects, a domain object pulled from EF Core that may carry tracked entity references), three techniques fit a real codebase:

What you should not do in .NET 10: use BinaryFormatter. It has been deprecated and removed for security reasons; treat any tutorial that suggests it as outdated.

For most Prototype use cases, JSON round-trip is the simplest answer that is correct. Performance optimisations come later.

What does a real C# / .NET 10 example look like?

A complete shape that ships in a real codebase. The campaign template is loaded from configuration once at startup as an AddSingleton, and each request that needs a regional variant clones it via with:

public sealed record DiscountCampaign
{
    public required string Code       { get; init; }
    public required decimal Percent   { get; init; }
    public required DateTime StartsAt { get; init; }
    public required DateTime EndsAt   { get; init; }
    public string Region              { get; init; } = "global";
    public string BannerCopy          { get; init; } = "";
    public IReadOnlyList<string> ApplicableSkus  { get; init; } = Array.Empty<string>();
    public bool StackableWithCoupons  { get; init; }
}

public interface ICampaignTemplates
{
    DiscountCampaign Get(string code);
    DiscountCampaign LocaliseFor(string code, string region, decimal? percentOverride = null, string? bannerCopy = null);
}

public sealed class CampaignTemplates : ICampaignTemplates
{
    private readonly IReadOnlyDictionary<string, DiscountCampaign> _templates;

    public CampaignTemplates(IConfiguration config)
    {
        _templates = config.GetSection("Campaigns")
            .Get<Dictionary<string, DiscountCampaign>>() ?? new();
    }

    public DiscountCampaign Get(string code) => _templates[code];

    public DiscountCampaign LocaliseFor(string code, string region,
        decimal? percentOverride = null, string? bannerCopy = null)
    {
        var template = _templates[code];
        return template with
        {
            Region     = region,
            Percent    = percentOverride ?? template.Percent,
            BannerCopy = bannerCopy ?? template.BannerCopy,
        };
    }
}

// Composition root
builder.Services.AddSingleton<ICampaignTemplates, CampaignTemplates>();

// Caller: a regional pricing service requests a localised variant
public sealed class RegionalPricing
{
    private readonly ICampaignTemplates _templates;
    public RegionalPricing(ICampaignTemplates templates) => _templates = templates;

    public DiscountCampaign GetForRegion(string code, string region)
        => region switch
        {
            "EU" => _templates.LocaliseFor(code, "EU", percentOverride: 22m, bannerCopy: "20 % auf alles"),
            "JP" => _templates.LocaliseFor(code, "JP", bannerCopy: "全品20%オフ"),
            _    => _templates.Get(code),
        };
}

// Test
[Fact]
public void Localised_variant_overrides_only_specified_fields()
{
    var template = new DiscountCampaign
    {
        Code = "BF24", Percent = 20m,
        StartsAt = new DateTime(2026,11,28), EndsAt = new DateTime(2026,11,30),
        BannerCopy = "20% off",
    };
    var eu = template with { Region = "EU", Percent = 22m, BannerCopy = "20 % auf alles" };

    Assert.Equal("BF24",   eu.Code);            // unchanged
    Assert.Equal(22m,      eu.Percent);          // overridden
    Assert.Equal("EU",     eu.Region);
    Assert.Equal("20 % auf alles", eu.BannerCopy);
    Assert.Equal(template.StartsAt, eu.StartsAt); // unchanged
}

What is missing from the code is the entire textbook Prototype pattern. There is no IPrototype interface, no Clone() method, no ICloneable. The pattern lives in the with expression. Recognising that is the actual value of learning the pattern.

A closing note for the Creational group: every pattern in this group shrank when modern C# arrived. Singleton became AddSingleton. Factory Method became AddKeyedScoped. Abstract Factory became per-scope DI registration. Builder became record plus optional fluent class. Prototype became with. The patterns survived because the intents were timeless; the implementations belonged to a slower language. As you move into Structural patterns next, watch for the same pattern of shrinkage — and the cases where the textbook implementation still wins.

Frequently asked questions

Is ICloneable still safe to use in modern C#?
No. ICloneable.Clone() returns object, never specifies whether the clone is shallow or deep, and forces every consumer to cast. Microsoft's own guidance recommends against it. Prefer a record with with, an explicit typed Clone() method, or System.Text.Json.Serialize/Deserialize for deep copies. Implement ICloneable only when an existing API forces you to.
What is the difference between shallow and deep cloning in .NET?
A shallow clone copies the outer object but reuses the same references for nested objects — modifying a nested list mutates both clones. A deep clone copies everything recursively. record with is shallow. For deep cloning, the safest approaches are JSON round-trip (JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(x))) or a hand-written DeepClone() that recursively with-clones every nested record.
When should I use Prototype instead of Builder or Factory Method?
Use Prototype when you already have a configured object and want a near-copy with a few fields different. Use Builder when you assemble from independent inputs. Use Factory Method when the question is which concrete type to instantiate. The split is about starting point: Prototype starts from a template, Builder from nothing, Factory Method from a key.
How do records with with expressions implement the Prototype pattern?
A record automatically defines a copy constructor and the with expression invokes it. var blackFridayEu = blackFriday with { Region = "EU", Percent = 22 }; is the textbook Prototype intent: take a configured object, change a few fields, get a new immutable instance. The CLR generates the boilerplate; you write the change-set.