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
- What problem does the Prototype pattern solve in C#?
- Why is ICloneable considered broken in .NET?
- How do records with with expressions express the Prototype pattern?
- When should you keep an explicit Clone method?
- How does deep cloning work safely in .NET 10?
- What does a real C# / .NET 10 example look like?
- 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:
- Configuration variants. A base
DiscountCampaignwith 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. - Expensive-to-construct templates. An
HttpClientconfigured with retry policies, default headers, and a base address. Cloning the configured client per call is cheaper than re-building it. - Pre-loaded test fixtures. A
Customerwith 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:
- The return type is
object. Every caller has to cast. The compiler cannot help you when the cast is wrong. - The cloning semantics are unspecified.
ICloneable.Clone()does not say whether the clone is shallow or deep. Different implementations chose differently. A consumer readingsomeCustomer.Clone()cannot tell whether the customer's address list is shared with the original. - It cannot be sealed by the implementer. Subclasses inherit a
Clone()they probably did not test.
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:
- The
withexpression is shallow.ApplicableSkusis reused by reference. That is correct as long as the list is immutable — andIReadOnlyList<string>plus anArray.Empty<string>()default is exactly that. - No
Clone()method anywhere. The pattern is in the language; the noise is not. - The compiler enforces required fields. A typo deleting a required field on a variant fails compilation, not at runtime.
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:
- Deep cloning of nested mutable state. If the object contains a
mutable
List<T>and the clone must not share it with the original,withwill not save you — both sides keep pointing at the same list. An explicitDeepClone()does the recursion. - Polymorphic cloning. A
Shapebase class withCircleandSquaresubclasses needsClone()that returns the runtime type. C# records support inheritance withrecord class, but you may want to centralise the deep-copy logic in a virtual method. - Cloning across an interface boundary. A plugin returns
IConfigurationand the plugin code does not know the concrete type. An interface methodClone()is the only path; the plugin's implementation chooses shallow or deep.
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:
- JSON round-trip.
JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(x)). Works for any DTO that is JSON-serialisable. Cheap, robust, slow for hot paths. Watch for cycles — they will throw. - Hand-written recursive
DeepClone(). Slower to write, faster to run. Worth it when the type is small and clone happens in a tight loop. MemberwiseClonefor polymorphic shallow base. Inside an override,(MyType)MemberwiseClone()gives the runtime type a shallow copy. Combine with manual deep-copy of the mutable fields the base does not know about.
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.
Where should you read next in this series?
- Previous: Builder — when you assemble a new object from independent inputs.
- Next: Adapter — the first Structural pattern, about wiring classes together without welding them.
- Cross-reference: Singleton — the Prototype template is often registered as a singleton, with each request cloning a fresh variant.
- Cross-reference: Factory Method — if the choice is "which template to start from", you want both patterns: Factory Method to pick, Prototype to clone.
- Decision tree: How to choose the right design pattern.
- Series map: Introduction.
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#?
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?
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?
How do records with with expressions implement the Prototype pattern?
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.