Securing the Agent Harness

Start with a number. An agent that opens 1,000 pull requests a week at a 1% vulnerability rate ships 10 new vulnerabilities every week — quietly, confidently, indefinitely. That’s the uncomfortable arithmetic of autonomy: a small per-action error rate, multiplied by machine-speed volume, becomes a steady stream of security holes. A human writing ten PRs a day can’t produce vulnerabilities fast enough to matter at that scale. An agent fleet can.

This reframes the whole problem. Security for AI agents isn’t a model-quality question you can solve with a better prompt. It’s a systems question, and the answer to “how do I keep this safe?” is the same answer we’ve used for every other dangerous-at-scale system: build the controls into the workflow so they can’t be skipped, and contain the blast radius for when — not if — something goes wrong. As the field’s bluntest formulation puts it: autonomy without security is an automated vulnerability.

You’re securing a harness, not a model

The single most useful mental shift in this entire topic: you cannot fully secure the model, so secure the harness instead.

The “harness” is everything around the model — the runtime scaffold that turns a text predictor into something that acts: its tools, its permissions, its credentials, its sandbox, its network access, the context you feed it, and the gates between it and the real world. The model is the part you can’t make trustworthy (more on why in a moment). The harness is the part you can engineer, and it’s where all real agent security lives.

Diagram illustrating the concept of securing the harness rather than the model, highlighting tools, sandbox, credentials, context & data, network, and human & policy gates.

Why can’t you just secure the model? Because of a result every agent builder should internalise. A 2026 joint study by OpenAI, Anthropic, and Google DeepMind — titled, ominously, “The Attacker Moves Second” — found that under adaptive attack conditions, every published prompt-injection defence was bypassed with success rates above 90%. You cannot prompt-harden your way to safety. The only robust posture is to assume the model will be compromised and build controls outside it that contain what a compromised model can do. This is the same shift the security industry made with malware a decade ago: stop trying to prevent every execution, assume breach, and focus on segmentation, least privilege, and blast-radius containment.

The blast radius principle

Here’s the principle that makes the harness concrete: an agent’s potential damage equals that of an employee holding the same credentials. If the agent can merge to main, it can ship a vulnerability. If it holds a production database token, it can drop a table. If it can make outbound network calls, it can exfiltrate data. The blast radius scales precisely with the privileges you hand it — and in 2026 that’s not hypothetical. In April 2026, a coding agent reportedly wiped a startup’s production database. The damage wasn’t exotic; it was simply an over-privileged agent doing a destructive thing with credentials it should never have held.

So the first question for any agent isn’t “is it smart enough?” — it’s “what’s the worst thing it can do with what we’ve given it?”

Prompt injection: the attack that makes it real

The mechanism that turns “the model might be wrong” into “the model is now working for an attacker” is prompt injection — OWASP’s #1 LLM vulnerability, present in an estimated 73% of production deployments. It comes in two flavours:

  • Direct injection: malicious instructions in the user’s own message.
  • Indirect injection: malicious instructions hidden in external content the agent processes — web pages, documents, code comments, dependency docstrings, issue and PR bodies, API responses. This is the dangerous one for coding agents, because a coding agent’s entire job is to ingest untrusted content: the repo, retrieved docs, web search results, package documentation. Every one of those is an attacker-controllable input channel.

In agentic systems this gets worse, not better. The OWASP Top 10 for Agentic Applications 2026 introduces ASI01: Agent Goal Hijack — a single injected instruction doesn’t just corrupt one output, it reprograms the agent’s plan, redirecting its multi-step behaviour, triggering privileged tool calls, and persisting across the workflow. A real example landed in May 2026: a CVSS-10 vulnerability in Gemini CLI where a malicious package injected prompts through code comments and docstrings, causing the agent to execute arbitrary commands it believed were legitimate — indirect injection delivered straight through the software supply chain.

The lethal trifecta

The cleanest model for when prompt injection becomes a breach is Simon Willison’s lethal trifecta. An agent is structurally exploitable when it has all three of:

  1. Access to private data (your repo, your secrets, your database).
  2. Exposure to untrusted content (anything an attacker can influence — issue bodies, web pages, dependencies).
  3. The ability to communicate externally (an outbound channel — an API call, a curl, a public PR comment).
Venn diagram illustrating the concept of 'The lethal trifecta' with three circles labelled 'Private data', 'Untrusted content', and 'External comms', highlighting 'EXFILTRATION' in the overlapping centre.

When all three overlap, a single poisoned input becomes an exfiltration pipeline: the agent reads the malicious instruction, pulls your secrets, and ships them out the door — a confused deputy moving faster than your monitoring can react. Remove any one leg and the attack collapses.

And here’s the catch that makes this urgent for developers: a CI/CD pipeline contains the entire trifecta by default. Workflow secrets sit in the runner’s environment, every public issue and PR body is attacker-controlled input, and tools like gh or curl are ready-made exfiltration channels. The Gemini CLI exploit chained exactly this — injection through a public issue, credentials lifted from .git/config on disk, then a pivot to a token with write access. If you’ve added an AI triager or reviewer to your CI, you may have shipped a trifecta without noticing.

Why bolt-on security fails

Put the pieces together and the conclusion is forced. At 10 vulnerabilities a week, a quarterly pentest is laughably out of phase — you’d accumulate ~130 vulnerabilities between assessments. When the model can be compromised >90% of the time by an adaptive attacker, an after-the-fact review of its output misses the architectural problem entirely. And prompt injection, the field’s consensus holds, needs architectural fixes, not bolt-on filters. The numbers say teams know this and aren’t acting on it: an independent 2026 assessment of 100 production agents found only 11% passed a baseline security assessment, and 57% of organisations lacked the visibility to even audit their agents for the trifecta.

Security that’s added after the agent works is security that’s always behind the agent. The only posture that keeps pace is one where the controls are part of the harness from the first commit.

Building the Secure Harness

We’ve previously reached a conclusion, a design principle: assume the model is compromised, and contain the blast radius with controls outside it. Now, we’ll turn that principle into concrete architecture. The unifying idea is defence in depth — stack independent layers so an attack has to defeat every one of them in series, not just slip past a single clever filter. No layer is sufficient alone (adaptive attacks bypass even good classifiers >85% of the time); stacking them is how production contains risk.

The organising constraint: the Rule of Two

Before the individual controls, the design rule that ties them together. Meta’s “Agents Rule of Two” (October 2025) is the most actionable security constraint in agent design: within a single session, an agent should have at most two of these three properties:

  • (A) processes untrusted input,
  • (B) has access to sensitive systems or private data,
  • (C) can change state or communicate externally.
Diagram illustrating 'The Rule of Two' concept for managing input and access levels, featuring sections A (Untrusted input), B (Sensitive access), and C (External/write), with safety combinations and a warning about the lethal trifecta.

This is the lethal trifecta restated as a build rule. Keep all three apart and the trifecta never assembles. In practice that often means splitting one risky agent into two safe ones — a read-only agent that ingests untrusted content but holds no secrets and can’t reach the network, handing structured results to a write-capable agent that never touches untrusted input. Separating read and write capability is one of the highest-leverage architectural moves you can make.

With that frame, here are the layers.

Layer 1 — Least privilege and least autonomy

The blast-radius principle from before has a direct corollary: give the agent the minimum tools, data, and credentials its task requires, and no more. Treat an agent exactly like a service account — narrowly scoped, audited, and reviewed. Start each new agent with the narrowest possible capability set and a small, well-defined task where the blast radius of failure is contained, then expand deliberately. “What’s the maximum access available” is the wrong default; “what’s the minimum this task needs” is the right one.

Layer 2 — Sandboxing and isolation

Assume the agent will, at some point, run hostile code — generated by a poisoned dependency, an injected instruction, or its own mistake. So run everything it does in an isolated, ephemeral environment with hard resource limits and the ability to roll back.

The 2026 consensus on strength: default to microVMs for untrusted code (Firecracker — the technology behind AWS Lambda, exposed by platforms like E2B — gives each sandbox its own kernel and network namespace, so a guest-kernel vulnerability can’t reach the host), and relax to gVisor or plain containers only when your threat model justifies it. Make sandboxes per-session and disposable with clean teardown so nothing persists between runs, and set hard memory, disk, and CPU limits to stop resource-exhaustion attacks. For coding agents this dovetails with the isolation you already want for parallelism: each agent gets its own throwaway workspace.

Layer 3 — Network egress control

This is the layer that directly kills the trifecta’s exfiltration leg. Agents should run on a zero-trust network: default-deny all outbound traffic, and allowlist only the specific endpoints the task requires. Add DNS restrictions to prevent command-and-control lookups, segment the agent’s network from production systems and sensitive data stores, and watch for anomalies (connections to low-reputation domains, unusually large outbound POSTs). If the agent can only reach three approved hosts, a stolen secret has nowhere to go.

Layer 4 — Scoped credentials and the broker pattern

Credentials are the crown jewels, and there are two distinct goals: keep them out of the model’s context (so the LLM provider never sees them) and out of the agent runtime entirely (so a compromised agent can’t read them). The pattern that achieves both is a credential broker: the agent never holds a real secret — it calls a tool, and a separate broker process, outside the agent’s reach, attaches the short-lived, narrowly-scoped credential and makes the actual API call.

# The agent asks the broker to act; it never sees the credential.
class CredentialBroker:
def call(self, tool: str, args: dict, session: Session) -> Result:
policy.authorize(tool, args, session) # per-tool PERMIT/DENY
cred = self.vault.mint(tool, ttl_seconds=300, # short-lived, scoped
scope=min_scope_for(tool))
try:
return execute(tool, args, cred) # broker makes the call
finally:
self.vault.revoke(cred) # gone after one use

Credentials are minted just-in-time, scoped to the single operation, and expire in minutes. Even a fully hijacked agent can’t exfiltrate a secret it never possessed — and a stolen short-lived token is worthless minutes later.

Layer 5 — The tool gateway

The agent’s reasoning must never be the thing that decides whether a dangerous action is allowed — because we’ve previously established that its reasoning can be hijacked. So put all tool access behind a single policy gate that enforces authorisation, consent, filtering, and audit centrally, so nothing bypasses policy. Per-tool PERMIT/DENY rules (the Cedar policy engine and the cedar-for-agents pattern are built for this) are evaluated against a declared rule — principal, action, resource, conditions — independent of the agent’s reasoning. Expose tools with fixed schemas so the agent can’t improvise novel calls, and wrap your most sensitive integrations as sealed tools running in a separate container. An MCP gateway is a natural home for this — it’s the chokepoint where you sanitise context and enforce allowlists between the agent and everything it can touch.

def gateway(call: ToolCall, session: Session) -> Result:
if not schema_valid(call): # fixed schema — no improvising
return deny("schema violation")
decision = cedar.evaluate(call, session) # policy, not model judgment
if decision != "PERMIT":
return deny(decision)
if call.is_high_impact: # irreversible / sensitive
require_human_approval(call) # tiered gate
audit_log.record(call, session) # decision, not just output
return broker.call(call.tool, call.args, session)

Notice the gateway is also where the later layers attach: human approval for high-impact actions, and an audit record of every decision.

Putting the harness together

Stacked, these layers form a harness where a compromise stays contained:

A flowchart illustrating a secure harness system for defence in depth, featuring components such as a microVM sandbox, human approval, a tool gateway, a credential broker, and an egress allowlist.

Trace an attack through it. A poisoned dependency injects an instruction. The hijacked agent tries to exfiltrate secrets — but it holds none, because the broker does (Layer 4). It tries to call a destructive tool — but the gateway’s policy denies it regardless of the agent’s “reasoning” (Layer 5). It tries to phone home — but egress is default-deny (Layer 3). It tries to corrupt the host — but it’s in a disposable microVM (Layer 2). Every layer the attack defeats, another stands behind it. That’s defence in depth, and it’s the difference between a bad session and a production incident.

Security in the Workflow

A secure harness is necessary but not sufficient. 10 vulnerabilities a week from a 1%-error agent fleet is a flow problem, and flow problems need controls in the flow. A harness you configure once protects the runtime; it doesn’t catch the vulnerability in PR #847 on Tuesday afternoon. For that, security has to live in the workflow itself, running on every action the agent takes, automatically. This is the “built-in, not bolted-on” thesis made operational.

Gate every agent PR like untrusted code

The agent’s output is untrusted code — we’ve already established the model can be hijacked, so its commits deserve the same suspicion as a pull request from an anonymous outside contributor:

Diagram illustrating security gates in the agent workflow, including agent PR, deterministic security gates with various checks, risk routing based on security impact, and audit logging for decisions.

The deterministic gates run on every agent PR and block on failure:

  • SAST (static application security testing) for vulnerability patterns — the direct defence against the 10-vulns-a-week problem.
  • Secret scanning so a credential never lands in a commit.
  • Dependency and CVE scanning, plus a hallucinated-package check — does every new import actually exist and is it the real package, not a slopsquatted look-alike registered to catch exactly the names models invent (the supply-chain attack).
  • IaC scanning for misconfigured infrastructure.

Because these are deterministic, they make trustworthy hard gates — no human time spent on a leaked key or a known-vulnerable dependency. This is shift-left security applied to a contributor that never sleeps.

Tier human oversight so it survives contact with volume

Human approval is the control that stops a hijacked agent’s high-impact action — but naive human review doesn’t scale to agent volume, and worse, it breeds approval fatigue: ask a human to rubber-stamp 200 trivial changes and they’ll rubber-stamp the one dangerous one too. The fix is risk-based tiering, the same Green/Yellow/Red routing, applied to security impact:

  • Low-risk (docs, tests, isolated non-sensitive code) → auto-merge with notification.
  • High-impact / irreversible (auth, payments, data migrations, production deploys, anything touching the paths from your risk map) → mandatory human approval, every time.

Tiering concentrates scarce human attention exactly where the blast radius is largest, and keeps it sharp by not wasting it everywhere else. Even a lightweight approval step — a Slack confirm, a required reviewer on protected paths — is a meaningful gate. The goal is that no irreversible action happens without a human, without drowning humans in reversible ones.

Audit decisions, not just outputs

You detect a hijacked agent not by reading its final answer but by watching its behavior. So log the agent’s decisions — every tool call, every delegation, every retrieval, every hand-off — not just its outputs. Baseline what a normal tool-call sequence looks like, and alert on deviations: a triage agent that suddenly reads .git/config and opens a network connection is mid-exploit, and only a decision-level trace shows it.

This audit trail does double duty. It’s your incident-response timeline, and it’s your compliance evidence — the record of which agent accessed which data, under which policy, authorized by which human, at what time. (Treat memory writes as security events too: a poisoned memory entry is a backdoor that reloads every session.) The 900+ agent gateways found exposed on the public internet in early 2026 — plaintext credentials, no authentication — failed precisely because there was no governance layer recording and enforcing any of this. Audit is the floor of that governance layer.

The trifecta audit: a gate before production

The lethal trifecta becomes a concrete pre-deployment gate. Before any new agent ships, audit it explicitly:

TRIFECTA AUDIT (run before every agent goes to production)
[ ] Does it access private data? (repo, secrets, DB, customer data)
[ ] Does it process untrusted content? (issues, PRs, web, dependencies)
[ ] Can it communicate externally? (network, public comments, APIs)

All three present? → DO NOT SHIP without compensating controls:
• break it with the Rule of Two (split read-only from write-capable), OR
• content inspection + strict tool scoping + mandatory human gates on egress
Assign an explicit owner to each leg. "Logging" is not "prevention."

Make this audit a required, version-controlled step — not tribal knowledge. Only 11% of production agents passed a baseline security assessment in 2026, and 57% of organizations couldn’t even answer these three questions for their agents. Being able to answer them, on every agent, before it ships, already puts you ahead of the overwhelming majority.

Govern from day one

Tie it together with a governance layer that exists from the first commit, not after the first incident. Version-control your agent security policy and risk map alongside the code; align your controls to a recognised framework — the OWASP Top 10 for Agentic Applications 2026, MITRE ATLAS for adversary techniques, and the NIST AI Risk Management Framework for lifecycle governance — so you’re not inventing controls from scratch; and gate the installation of new tools and plugins behind review (CODEOWNERS plus CI schema validation), since a new tool is a new capability and a new piece of attack surface. Governance isn’t a launch-day checklist; it’s the substrate the agent runs on.

The whole picture

Assemble the three parts and the architecture is coherent. You secure the harness, not the model, because the model can be compromised — and a coding agent’s blast radius at machine speed makes that compromise expensive. The harness is defence in depth organised by the Rule of Two — least privilege, ephemeral sandboxing, default-deny egress, a credential broker, and a policy gateway that doesn’t trust the model’s reasoning. Security lives in the workflow — deterministic gates on every PR, risk-tiered human approval, decision-level audit, and a trifecta gate before production — all sitting on a governance layer present from day one.

The thread through every layer is the same: a single compromised agent should stay a single bad session, never a production-wide incident. You get there not by trusting the agent more, but by engineering the workflow so that trust is never required — the controls run whether the agent is behaving or not. That’s what “built into the workflow, not bolted on” actually means, and at 10 vulnerabilities a week, it’s the only thing that keeps pace.

Extension Methods and Extension Members in C#

Why This Matters When AI Can Just Write the Code

An AI assistant can generate an extension method for you in seconds — a one-liner with this in front of the first parameter, done. So why does the underlying concept deserve real understanding rather than just trusting the generated snippet? Because extension methods are one of the easiest C# features to use incorrectly while still having it compile and appear to work, and the failure modes are exactly the kind that only show up once real usage patterns emerge — not in the small demo an AI tool shows you.

Here’s a concrete version of the problem: extension methods can never actually override or replace behaviour on the type they extend — they can only add the appearance of new members. If an AI tool generates an extension method with the same name as a real instance method that gets added to the target type later (by you, by a library update, or because you misjudged which member already existed), the real instance method silently wins every single time, with zero warning, zero error, and no visible sign in your code that anything changed. You need to already understand the resolution rules covered in this post — that instance members always beat extension methods, silently — to even suspect this is happening when a call stops behaving the way you expect after an unrelated update.

There’s a second, more subtle reason this topic rewards real understanding: knowing when an extension method or extension member is the right tool — versus when it’s papering over a design problem you should actually be solving with an interface, a wrapper type, or a genuine change to a type you do own — is a judgement call, not a syntax question. An AI tool asked to “add a helper to this type” will readily generate an extension method whether or not that’s actually the right long-term choice, because generating a working answer is different from generating the right answer for your codebase’s shape. Being able to read a codebase full of extension methods (your own, a teammate’s, or a library’s) and understand exactly what’s really happening — a static method dressed up to look like an instance member, nothing more — is a reading-comprehension skill that pays off every time you touch a real project, regardless of who wrote the extension in the first place.

Why This Post Exists

Sometimes you need a type to have a method, property, or operator that it doesn’t currently have — and you either can’t modify the type’s source code (it belongs to the .NET framework, or a third-party library), don’t want to modify it (adding an inheritance relationship or a wrapper just for one helper feels heavy-handed), or structurally can’t modify it in the way you’d like (you can’t retroactively add an interface implementation to a sealed class you don’t own). C# solves this with extension methods, a feature that’s existed since C# 3.0, and — as of C# 14 — a considerably more powerful evolution of the same idea called extension members, which finally allow properties, static members, and operators to be added the same way methods always could.

By the end of this post you should be able to explain precisely what an extension method actually compiles down to and why that explains every rule governing how they behave; write both the classic (this-parameter) syntax and the modern C# 14 extension block syntax; correctly predict member resolution when an extension and a real instance member share a name, and when two unrelated extensions collide with each other; understand how extension methods interact with generics, interfaces, value types, and null; recognise LINQ as the single most consequential real-world application of this feature; and know exactly which new capabilities C# 14’s extension blocks unlock and which fundamental limitation — no genuine new fields — still applies to both syntaxes, and always will.

The Core Idea: Extension Methods Are a Compiler Illusion

Here is the single fact that explains almost everything else in this post, so it’s worth establishing first, very precisely: an extension method is an ordinary static method, and the compiler is simply allowed to call it using instance-method syntax as a special case. Nothing about the target type changes at all — not its fields, not its actual member list, not anything visible via reflection on the type itself. The “extension” only exists from the caller’s point of view, as a trick the compiler performs while translating your source code into the method calls that actually get compiled.

Here’s the classic syntax, which has worked since C# 3.0:

public static class StringExtensions
{
public static int WordCount(this string str) =>
str.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
}

Two things make this an extension method rather than an ordinary static helper: it lives in a static class (a hard requirement — extension methods cannot be declared anywhere else, and the class itself cannot be generic or nested), and its first parameter has the this modifier, which tells the compiler “this parameter is the thing being extended; let people call this method as if it belonged to that type.”

string sentence = "The quick brown fox";
int count = sentence.WordCount(); // looks like an instance method call

This line looks exactly like calling a genuine instance method — but the compiler is silently rewriting it, behind the scenes, into an ordinary static method call:

int count = StringExtensions.WordCount(sentence); // what actually gets compiled

Both forms are available to you as the caller — you can write either one, and they mean exactly the same thing. sentence.WordCount() and StringExtensions.WordCount(sentence) compile to identical IL (the intermediate code the .NET runtime actually executes) — the dot-syntax version is purely a convenience the compiler offers you, with zero runtime difference between the two forms. This is worth sitting with, because nearly every rule and limitation covered in the rest of this post follows directly from this one fact: you are not actually adding a member to the type. You are writing a static method, and asking the compiler to let you call it using nicer syntax.

A direct consequence: string doesn’t actually gain a WordCount member

Because nothing about string itself changed, reflection-based code, other languages targeting the CLR without the same extension-method sugar, and anything that inspects string’s actual member list will never see WordCount there at all — it doesn’t exist on string. It exists only as a static method that C# is willing to let you call with instance syntax, and only when the extension method’s containing namespace is in scope via a using directive:

using MyProject.StringExtensions; // required — without this, sentence.WordCount() won't compile at all

If you don’t using the namespace containing the extension method, sentence.WordCount() simply fails to compile — the method genuinely isn’t visible, because as far as the language is concerned outside that using scope, it doesn’t exist as a callable member of string at all.

A second direct consequence: no access to private members

Because an extension method is genuinely just a static method living outside the target type entirely, it only ever has access to the target type’s public (or otherwise externally visible) members — exactly the same access any other unrelated piece of code would have. It cannot reach into private or protected fields the way a real instance method defined inside the class could.

class BankAccount
{
private decimal _balance;
public decimal Balance => _balance;
}

public static class BankAccountExtensions
{
public static void AddInterest(this BankAccount account, decimal rate)
{
// account._balance += ...; // ILLEGAL — _balance is private, and this is just an outside static method
}
}

This is a genuine, and often clarifying, limitation: it means extension methods can only ever build on top of a type’s existing public surface — they can combine, reformat, or compute from what’s already exposed, but they can never reach in and manipulate private internal state the way a genuine member of the class could. If you find yourself wanting an “extension” that needs private access, that’s a strong signal you actually need a real member on the type itself, not an extension.

Extension methods and null: a real behavioural difference from instance methods

Because an extension method call is secretly just a static method call, it follows the rules of an ordinary static call — including a rule that’s genuinely different from how real instance methods behave: you can call an extension method on a null reference without an exception being thrown, as long as the extension method itself is written to handle that gracefully.

public static class StringExtensions
{
public static bool IsNullOrEmpty(this string? str) => string.IsNullOrEmpty(str);
}

string? name = null;
bool result = name.IsNullOrEmpty(); // does NOT throw! prints/evaluates to true

This looks alarming at first — calling a method on null normally throws a NullReferenceException immediately, as covered in any discussion of null handling. But remember: name.IsNullOrEmpty() isn’t really calling a method on name at all in the way a genuine instance method call would; it’s calling StringExtensions.IsNullOrEmpty(name), passing name as an ordinary argument. Passing null as an argument to a static method is completely unremarkable and doesn’t throw anything by itself — whether an exception happens at all depends entirely on what the method’s body actually does with that argument. This is precisely why string.IsNullOrEmpty(str) (a real static method in the .NET standard library, not even an extension) has always been safely callable with null — and it’s a useful, idiomatic pattern: writing null-safe extension methods that let callers skip an explicit null check beforehand, as long as you clearly document and rely on the extension itself handling null correctly rather than assuming its caller already ruled that out.

Member Resolution: Real Members Always Win, Silently

Because an extension method is only ever a fallback the compiler reaches for when it can’t find a genuine matching instance member, there’s a strict, important priority order: if a type already has an instance member with a matching name and signature, that real member is always called instead of any extension method with the same name — with no warning, no ambiguity error, nothing. The extension method simply never gets a chance to run.

class Greeter
{
public string Greet() => "Hello from the real method!";
}

public static class GreeterExtensions
{
public static string Greet(this Greeter g) => "Hello from the extension!";
}

var g = new Greeter();
Console.WriteLine(g.Greet()); // "Hello from the real method!" — the extension is completely ignored

This is the exact scenario flagged in the introduction to this post: if Greeter didn’t originally have a Greet() method, and you wrote the extension expecting it to be called, then a later change that adds a real Greet() method to Greeter — even one with a different implementation, added by someone who’s never heard of your extension — silently and permanently shadows your extension method for every caller, forever, with no compiler error anywhere to flag that anything changed. This is a genuinely realistic bug in evolving codebases and library upgrades, and it’s a direct, unavoidable consequence of what an extension method fundamentally is: a fallback, never a true member, always losing to the real thing.

What happens when two different extensions collide

The “silent, no-error” behaviour above is specific to a real instance member competing with an extension — a real member always wins outright, and the compiler doesn’t even consider it a conflict worth mentioning. The situation is different when two separate extension methods, from two different static classes, both apply to the same type with the same name and signature, and both are in scope via using directives at the same time. Here, the compiler generally does flag the situation, because there’s no real member to automatically defer to:

namespace LibraryA { public static class Ext { public static string Describe(this int i) => "From A"; } }
namespace LibraryB { public static class Ext { public static string Describe(this int i) => "From B"; } }

using LibraryA;
using LibraryB;

int x = 5;
// x.Describe(); // Compile ERROR — ambiguous call between LibraryA.Ext.Describe and LibraryB.Ext.Describe

When this happens, you resolve the ambiguity by falling back to the fully-qualified static method call syntax — calling LibraryA.Ext.Describe(x) directly bypasses the ambiguity entirely, since you’re no longer asking the compiler to search for a matching extension; you’re naming the exact static method you want. This is precisely why extension members still need to live inside a named static class even under the modern C# 14 syntax, as we’ll in more detail later — that container name is your escape hatch out of exactly this kind of collision.

There’s one more wrinkle worth knowing: if the two competing extensions are visible at different levels of scope — for example, one brought in by a using at the top of your file, and another available because it’s declared in the same namespace your code is already in, with no using needed — C# does have a preference order (closer, more specific scopes win over using-imported ones). But when both candidates are equally “close” (as in the two-using-directives example above), the result is a genuine compile-time ambiguity error, not a silent pick of one over the other — which is a meaningfully different, safer outcome than the silent shadowing that happens when a real instance member is involved.

Generic Extension Methods

Extension methods can be generic, and this is, in practice, one of their most powerful and common uses — it’s exactly how a huge portion of the .NET standard library’s most useful helpers (LINQ chief among them) are able to work across virtually any collection type at once, rather than needing a separate hand-written version for every possible element type.

public static class EnumerableExtensions
{
public static T? SecondOrDefault<T>(this IEnumerable<T> source)
{
using var enumerator = source.GetEnumerator();
if (enumerator.MoveNext() && enumerator.MoveNext())
return enumerator.Current;
return default;
}
}

List<int> numbers = [10, 20, 30];
int second = numbers.SecondOrDefault(); // 20 — works for List<int>, and equally for any IEnumerable<T>
string[] words = ["a", "b", "c"];
string? secondWord = words.SecondOrDefault(); // "b" — the exact same method, now working on strings instead

Notice that the caller never has to specify explicitly — the compiler infers it from the actual type of numbers/words being passed as the receiver, exactly the same type inference that happens for any other generic method call. You can also constrain the type parameter exactly as you would on any generic method, which is useful when your extension genuinely needs a capability the type parameter must guarantee:

public static class ComparableExtensions
{
public static T Clamp<T>(this T value, T min, T max) where T : IComparable<T>
{
if (value.CompareTo(min) < 0) return min;
if (value.CompareTo(max) > 0) return max;
return value;
}
}

int clamped = 150.Clamp(0, 100); // 100 — works because int implements IComparable<int>

This pattern — a generic extension constrained to an interface — is an extremely common and idiomatic way to add a single, reusable piece of behaviour across every type that satisfies some capability, without needing to touch any of those types individually.

Extension Methods on Interfaces

You can write an extension method whose receiver type is an interface rather than a concrete class or struct — and this turns out to be one of the single most important applications of the entire feature, because it lets you add functionality that automatically becomes available to every type that implements that interface, all at once, without touching any of them.

interface IShape { double Area(); }

public static class ShapeExtensions
{
public static bool IsLargerThan(this IShape shape, IShape other) => shape.Area() > other.Area();
}

class Circle : IShape { public double Radius; public double Area() => Math.PI * Radius * Radius; }
class Square : IShape { public double Side; public double Area() => Side * Side; }

var c = new Circle { Radius = 2 };
var s = new Square { Side = 3 };
Console.WriteLine(c.IsLargerThan(s)); // works on Circle, Square, or any future IShape implementer, automatically

IsLargerThan was written once, against the interface, and immediately works for Circle, Square, and any type anyone writes in the future that implements IShape — including types that don’t exist yet at the moment this extension was written. This is a genuinely powerful multiplier: extending an interface effectively extends every current and future implementer of that interface simultaneously, which is a much larger reach than extending one specific concrete class.

A subtlety: which members are visible depends on the static type of the expression

Because member resolution (including extension method resolution) in C# happens at compile time based on an expression’s declared type — not its actual runtime type — an extension written for a concrete class won’t be found if you’re holding a reference to that object through a less specific interface or base type, unless the extension itself was also written against that broader type (or the object’s actual compile-time type at the call site).

public static class CircleExtensions
{
public static double Diameter(this Circle c) => c.Radius * 2;
}

IShape shape = new Circle { Radius = 2 };
// shape.Diameter(); // Compile ERROR — Diameter() only extends Circle, but 'shape' is statically typed as IShape

Even though the object really is a Circle at runtime, shape.Diameter() fails to compile, because the compiler only ever considers the extensions available for the expression’s declared type (IShape here), never the object’s actual runtime type. This is a direct, if easy-to-forget, consequence of extension resolution being a purely compile-time, static-typing-based mechanism — there’s no runtime lookup happening at all, unlike genuine virtual method dispatch.

Extension Methods and Value Types: Copies, ref, and in

Extension methods work perfectly well on struct/record struct receivers too, but it’s worth understanding precisely what gets passed, because it directly affects both correctness and performance.

By default, just like an ordinary method parameter, a value-type receiver is passed by value — meaning the extension method receives an independent copy of the struct, and any mutation performed inside the extension method has no effect on the original value the caller passed in:

struct Counter { public int Value; }

public static class CounterExtensions
{
public static void Increment(this Counter c) => c.Value++; // mutates a COPY, not the caller's original
}

var counter = new Counter { Value = 0 };
counter.Increment();
Console.WriteLine(counter.Value); // still 0 — the extension mutated its own local copy, not 'counter'

This is exactly the same “value types copy on pass” behaviour that applies to any ordinary method call — the extension-method call syntax doesn’t change that underlying rule at all, which is easy to forget given how much the dot-syntax makes it look like you’re operating on the original instance.

If you genuinely need the extension to mutate the caller’s original struct, you can mark the receiver parameter ref, exactly as you would for any ordinary method:

public static class CounterExtensions
{
public static void Increment(this ref Counter c) => c.Value++; // now mutates the CALLER's actual struct
}

var counter = new Counter { Value = 0 };
counter.Increment();
Console.WriteLine(counter.Value); // 1 — the extension mutated the real, original struct this time

Conversely, if the struct being extended is large and you want to avoid the performance cost of copying it on every call, but you don’t need to mutate it, marking the receiver in (or, in the modern extension block syntax, using an in receiver) passes the struct by reference for efficiency while still preventing the extension from modifying it — giving you the performance benefit of ref without giving up the safety of read-only access.

What C# 14 Changes: From “Methods Only” to “Extension Everything”

For its entire history prior to C# 14, the extension method feature had a real, sharp limitation: it only worked for methods. You could make something look like sentence.WordCount(), but you could never make something look like a genuine property (sentence.WordCount, no parentheses), a static member on the type itself (string.SomeHelper), or an operator (p1 + p2 for some type p1/p2 you don’t own). Workarounds existed — writing GetWordCount() instead of a true property, for instance — but they always looked and felt like methods pretending to be something else, because that’s exactly what they were.

C# 14 (shipped with .NET 10, November 2025) introduces a genuinely new syntax — the extension block — that removes this limitation entirely, while keeping every classic this-parameter extension method you’ve already written fully working, unchanged, forever. The two syntaxes coexist deliberately; you never need to migrate existing code, and you can freely mix both styles within the same static class.

Here’s the same WordCount example, rewritten using the new extension block syntax:

public static class StringExtensions
{
extension(string str)
{
public int WordCount() =>
str.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
}
}

The extension(string str) block declares, once, that everything inside it extends string, with str available as the “receiver” — the instance being extended — throughout every member in the block, without needing to repeat a this string str parameter on each individual method the way the classic syntax requires. This alone is a real ergonomic improvement once you’re defining several related members for the same type, but the much bigger unlock is what kinds of members you’re now allowed to put inside that block.

Extension Properties

The clearest, most immediately useful addition in C# 14 is genuine extension properties — something with no parentheses, accessed exactly like a real property, computed on demand:

public static class StringExtensions
{
extension(string str)
{
public bool IsEmpty => string.IsNullOrEmpty(str);
public int WordCount => str.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
}
}

string title = "Hello World";
Console.WriteLine(title.IsEmpty); // False
Console.WriteLine(title.WordCount); // 2 — accessed as a property, no parentheses at all

This matters more than it might first appear: before C# 14, the only way to expose something as a property-feeling piece of information via an extension was to write a method and simply accept that it would always need parentheses (str.WordCount()), which is a real, if small, readability cost for anything that’s conceptually a computed attribute of the value rather than an action being performed. IsEmpty, WordCount, and similar “describe a characteristic of this value” concepts read far more naturally as properties than as zero-argument methods, and now they finally can be.

Under the hood, this is still ultimately compiled down to a method (a property, extension or not, is always a getter/setter method pair at the IL level) — the underlying “it’s really just a static method with special call syntax” reality hasn’t changed at all. What’s changed is that the compiler now offers property-style call syntax as an option, not just method-style syntax, for extensions. Extension properties can also declare a setter, not just a getter — as long as the extension block has some way to actually apply the change, which in practice usually means the receiver type has a real, settable property or field of its own that the extension property’s setter delegates to underneath.

Static Extension Members

Before C# 14, every extension member — regardless of the syntax — always extended an instance of a type: you needed an actual string value in hand to call .WordCount() on. There was no way to add something that looked like a static member on the type itself, the way string.Empty or Guid.NewGuid() are static members you call on the type, not on an instance of it. C# 14 removes this restriction with static extension members, declared inside an extension block whose receiver has no parameter name (just the bare type):

public static class GuidExtensions
{
extension(Guid) // note: no parameter name — this block declares STATIC members on Guid itself
{
public static Guid Empty2 => Guid.Empty;

public static Guid CreateDeterministic(string input)
{
var hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(input));
return new Guid(hash.AsSpan(0, 16));
}
}
}

Guid deterministic = Guid.CreateDeterministic("some-stable-key"); // called on the TYPE, not an instance

Notice extension(Guid) here, rather than extension(Guid someGuid) — omitting the parameter name signals that this block declares members that appear directly on the type itself, not on instances of it. This is genuinely new ground for extensions: you can now make a sealed, third-party type appear to have its own static factory methods or constant-like properties, without ever touching its source, and without needing any instance of the type to access them.

A single static class can freely mix an instance-style extension block and a static-style extension block for the same target type, side by side, letting you organise both kinds of extension together:

public static class StringExtensions
{
extension(string str) // instance members
{
public bool IsEmpty => string.IsNullOrEmpty(str);
}

extension(string) // static members — note: no parameter name
{
public static string Repeat(string pattern, int count) =>
string.Concat(Enumerable.Repeat(pattern, count));
}
}

You can also declare a generic extension block, with type parameters and constraints living on the extension(…) declaration itself rather than repeated on every member inside it — a direct generalisation of the generic extension methods, now available for properties and static members too, not just ordinary methods.

Extension Operators

The next major new capability is the ability to define operators as extension members — something that was flatly impossible before C# 14, because operator overloads have always had to be declared as static members of one of the types directly involved in the operation, and you can’t add a static member to a type you don’t own using the classic this-parameter syntax at all.

public static class PointExtensions
{
extension(Point p)
{
public static Point operator +(Point a, Point b) => new Point(a.X + b.X, a.Y + b.Y);
}
}

var p1 = new Point(1, 2);
var p2 = new Point(3, 4);
var sum = p1 + p2; // Point(4, 6) — the + operator now works on a type you didn't write and can't modify

This is a meaningful capability unlock: previously, if you wanted + to work between two instances of a Point-like type from a library you don’t control, your only real option was to define your own wrapper type with its own + operator, and convert back and forth — a real amount of ceremony for what conceptually is a small addition. Extension operators let you add this kind of natural, domain-appropriate syntax directly to types you don’t own, the same way extension methods have always let you add natural-feeling method calls to types you don’t own. Just as with a normal operator overload, at least one of the operator’s parameters generally has to match the receiver type being extended, for the same reason ordinary operator overloading requires this: without that requirement, the compiler would have no principled way to decide which type “owns” the operator.

What’s Still Off-Limits: No Extension Fields, Ever

With properties, static members, and operators all newly available, it’s natural to wonder whether extension fields are coming too — real, honest-to-goodness storage slots added to an existing type. The answer is no, and it’s worth understanding why, because the reason is structural, not a temporary gap the language team just hasn’t gotten to yet.

Recall the foundational fact this entire post rests on: an extension member is, underneath everything, a static method (or a property, which is itself a getter/setter method pair). It doesn’t change the target type’s actual memory layout in any way — the type’s real, physical fields are exactly what they always were, unaffected by any extension you write. A genuine field requires the type itself to set aside actual storage for it at the moment each instance is created — and an extension block, being nothing but syntax sugar around static methods added after the type already exists and already has its layout fixed, has no mechanism to reach back in time and add storage to every existing and future instance of that type. This isn’t a missing feature — it’s a direct, unavoidable consequence of what “extending” a type without modifying its source can possibly mean.

public static class StringExtensions
{
extension(string str)
{
// public int CallCount; // ILLEGAL — extension blocks cannot declare fields, in any C# version
}
}

If you need genuine, persistent, per-instance storage attached to a type you don’t own, an extension member cannot provide it — you need a different tool entirely, such as a ConditionalWeakTable (which associates extra data with an object’s lifetime without modifying the object itself) or a wrapper type that holds both the original object and your additional state. It’s worth being explicit about this limitation precisely because it’s easy to assume, once you’ve seen properties and static members added in C# 14, that fields might follow in some future version — multiple official sources are explicit that this is not planned, because the limitation is structural, not a matter of not having gotten around to it yet.

Alongside fields, C# 14’s extension blocks also don’t support events, nested types, or constructors — extension blocks focus specifically on methods, properties (including indexer-style get/set), and operators.

Disambiguation: Why the Containing Static Class Still Matters

One detail worth knowing, especially once you’re organising a larger codebase: even though the new extension(…) block syntax no longer requires you to repeat the receiver type on every single member, you still need to wrap your extension blocks in an ordinary named static class, exactly as classic extension methods always required:

public static class StringExtensions // this name still matters!
{
extension(string str)
{
public bool IsEmpty => string.IsNullOrEmpty(str);
}
}

The reason this container class still matters, even though extension blocks feel almost like a language-level “reopen this type” mechanism, is disambiguation. If two different libraries each define an extension member with the same name for the same target type — a genuinely realistic scenario once extension members become widely used — the only way to resolve the resulting ambiguity in your calling code is to refer to one specific extension explicitly by its containing static class name (much like you’d disambiguate between two same-named classes in different namespaces). If extension members could exist with no named container at all, this escape hatch simply wouldn’t exist, and a naming collision between two unrelated libraries would become unresolvable rather than merely inconvenient.

LINQ: The Single Most Consequential Real-World Use of Extension Methods

It’s worth pausing on a concrete, large-scale example, because it’s easy to underestimate just how much of C#’s everyday feel actually rests on this one feature: LINQ (Language Integrated Query) — .Where(…), .Select(…), .OrderBy(…), .FirstOrDefault(…), and dozens of similar methods you use constantly — is, almost entirely, just a large, carefully designed set of generic extension methods on IEnumerable.

List numbers = [1, 2, 3, 4, 5, 6];
var evens = numbers.Where(n => n % 2 == 0).Select(n => n * 10);

Where and Select are not real members of List, or of IEnumerable itself — they’re extension methods, defined in System.Linq.Enumerable: they’re generic (working across any element type T), and they extend an interface (IEnumerable), which is exactly why .Where(…) works identically whether you call it on a List, an array, a HashSet, or any other type that implements IEnumerable — including custom collection types you write yourself, which get every LINQ method automatically, for free, the instant they implement that one interface.

This is the single best real-world illustration of why interface-based extension methods are so powerful: the entire LINQ library was written once, against IEnumerable, and it now works — with zero additional code from anyone — on every collection type that has ever implemented that interface, including types written years after LINQ itself first shipped. It’s also a useful lens for revisiting a previous core claim: every single LINQ call you write is secretly a static method call from Enumerable, dressed up with instance syntax, exactly like the small examples earlier in this post — LINQ isn’t a special case the language treats differently; it’s simply the most famous and most heavily used example of the exact mechanism this entire post has been explaining from the very first section.

When to Reach for Extension Members — and When Not To

Extension methods and extension members are the right tool specifically when you want to add behaviour to a type you don’t own (a .NET built-in type, a third-party library type) or when you want to add a natural-feeling helper to a type you do own, but where the helper doesn’t conceptually belong as a core responsibility of the type itself and would clutter its primary definition. Good, common uses include: LINQ-style query helpers, as just covered; formatting/validation/computed-shorthand helpers on built-in types (someString.IsValidEmail(), someDateTime.IsWeekend); and adding ergonomic syntax (properties, operators) to third-party domain types you use heavily but can’t modify.

They’re the wrong tool when what you actually need is genuine shared state (extension members can never hold fields), when the “extension” is really trying to override or replace behaviour a type already has (which we’ve previously showed silently doesn’t work the way you’d expect), or when you find yourself writing dozens of extensions that would be far better expressed as an interface implemented by a real wrapper type you control — extension members are a targeted convenience for adding syntax, not a general substitute for proper object-oriented design when you actually do have the ability to design the type relationships involved.

Summary

An extension method is, underneath its convenient dot-syntax, nothing more than an ordinary static method — the target type itself never actually changes, gains no new member in its real definition, has no access to the type’s private members, and can even be called safely on a null receiver, because it’s really just an argument being passed to a static method. This single fact explains every rule in this post: extension methods only work when their namespace is in scope, a genuine instance member with a matching name always silently wins over an extension with the same name (while two competing extensions instead produce a compile-time ambiguity error), and resolution is based purely on an expression’s compile-time type, never its runtime type. Extension methods can be generic and can extend interfaces rather than concrete types — a combination that, taken to its logical conclusion, is exactly how LINQ works: a library of generic extension methods on IEnumerable that automatically applies to every collection type that has ever implemented that one interface. Value-type receivers are passed by value by default (so mutations inside an extension are silently lost unless the receiver is marked ref), which is a frequent source of confusion for anyone expecting dot-syntax to always mean “operating on the real object.” C# 14 introduces extension blocks — extension(Type receiver) { … } — which keep every classic extension method working unchanged while adding extension properties, static extension members (via a receiver block with no parameter name), and extension operators, none of which were possible before. What remains permanently off the table, in both the classic and modern syntax, is genuine extension fields, because an extension member never changes a type’s actual memory layout — a structural limitation, not a temporary one — and the containing static class still matters under the new syntax specifically as a disambiguation mechanism when two unrelated extensions collide. Used well, extension members let you add natural, readable syntax to types you don’t own or don’t want to clutter; used carelessly, they can silently do nothing at all once a real member with the same name appears, or silently mutate a throwaway copy instead of the value you meant to change — both of which are exactly the kind of bug that only becomes visible once you understand precisely what an extension member really is.

Interfaces, Inheritance, and Composition — How C# Types Share Behaviour

Why This Matters When AI Can Just Write the Code

An AI assistant can generate an interface, a base class, or a composed object graph in seconds — so why does understanding the underlying design tradeoffs still matter? Because generating code that compiles is not the same as generating code that will still be healthy to work with a year from now, and the choices this post covers — interface vs. base class, inheritance vs. composition — are exactly the kind of decision an AI tool cannot reliably make correctly on your behalf, because making it well requires knowing things about your system’s future that simply aren’t in the prompt.

Consider a concrete case: an AI assistant asked to “add logging to these employee classes” will very often reach for a shared base class, because it’s the shortest path to something that compiles, and inheritance-heavy examples dominate a huge amount of the training material such tools learn from. It has no way of knowing that your Employee hierarchy is about to need three more cross-cutting concerns — auditing, permissions, notifications — that don’t nest cleanly into a single chain of base classes. That’s a fact about your project’s trajectory, not something derivable from the code that exists today. The fragile base class problem this post walks through in detail doesn’t show up in the first version of the code at all — it shows up eighteen months later, when someone (quite possibly an AI assistant, asked to “optimise this base class method”) makes a locally reasonable change that silently breaks three derived classes it never saw and was never told about. Preventing that requires a human who understands why tight coupling to a base class’s implementation is risky, at the moment the original design decision gets made — not after the bug report arrives.

There’s also a much more immediate, mechanical trap this post covers: boxing. An AI tool asked to “store some shapes in a list and print their areas” might hand you a List<IShape> full of struct-based shapes without ever mentioning that every single element just got silently boxed onto the heap, or that mutating one of them through the interface reference won’t actually touch your original value. The code runs. It even produces correct output in a small demo. The performance cost and the mutation bug only show up later, under real load, or when someone tries to update a value and can’t figure out why the change isn’t sticking — and at that point you need to already understand what an interface-typed variable actually is under the hood to have any chance of diagnosing it.

The through line across all of this: AI tools are excellent at producing code that satisfies the request sitting in front of them right now. They are not a substitute for the judgement that decides what to ask for — whether a relationship is genuinely “is-a” and stable, whether a value is about to be boxed, whether a shared base class is going to become a maintenance liability. That judgement is something you bring to the tool; it isn’t something the tool can hand back to you.

Why This Post Exists

This post has two connected goals. The first is to properly introduce interfaces as a concept in their own right — not just “a thing you type after a colon,” but a fundamentally different kind of type from class, struct, record, and record struct, with its own rules and its own sharp edges (boxing, chief among them). The second goal builds directly on the first: once you understand what an interface is and how it differs from inheritance, you’re in a position to properly evaluate one of the oldest and most consequential design debates in object-oriented programming — when to reuse behaviour through inheritance, and when to reuse it through composition instead.

By the end of this post you should be able to explain, structurally, what an interface actually is and why it can be implemented identically by a reference type or a value type; predict exactly where boxing will silently occur when value types meet interfaces; explain the fragile base class problem in your own words, with a concrete example; build the same shared behaviour two different ways — via a base class and via composition — and compare the tradeoffs directly; and make a reasoned, justified choice between inheritance and composition for a specific design situation, rather than defaulting to either one out of habit.

What an Interface Actually Is, Structurally

Every type you might already be familiar with — class, struct, record, record struct — is something that holds data. When you create an instance of any of them, memory gets set aside (on the heap or inline, depending on the kind) to store actual values: an int X, a string Name, whatever fields or properties the type declares.

An interface is fundamentally different: it holds no data of its own, and, historically, contained no implementation at all. An interface doesn’t describe what a thing is made of — it describes what a thing can do. It’s a contract, a promise, a list of members that any implementing type agrees to provide:

interface IShape
{
double Area();
double Perimeter();
}

IShape has no fields, and — in its classic form — no method bodies. You cannot write new IShape(); there is nothing to construct, because an interface isn’t a blueprint for an object, it’s a checklist a type must satisfy. This is the single most important thing to internalise before anything else in this post: a class/struct/record/record struct answers the question “what data does this hold and how is it stored?” An interface answers a completely different question: “what operations can I guarantee this type supports?”

Because of that difference in purpose, an interface can’t stand alone the way the other four kinds can. IShape by itself doesn’t correspond to any actual object in memory — you always need some concrete type (a class, struct, record, or record struct) that implements IShape and supplies the actual fields and method bodies before you have something you can construct and use:

class Circle : IShape
{
public double Radius;
public double Area() => Math.PI * Radius * Radius;
public double Perimeter() => 2 * Math.PI * Radius;
}

IShape shape = new Circle { Radius = 3 }; // shape is typed as IShape, but the actual object is a Circle

Notice the variable shape is declared with type IShape, but there’s no such thing as “a bare IShape object” sitting in memory. What’s actually there is a Circle, and shape is simply a way of looking at that Circle through a narrower lens — one that only exposes the members IShape promises (Area() and Perimeter()), hiding everything else Circle might also have (like the public Radius field itself, which isn’t accessible through an IShape-typed reference without casting back to Circle).

An Interface Is Orthogonal to class/struct/record/record struct — Not a Fifth Option

This is the point students most often get subtly wrong: it’s tempting to mentally file “interface” as a fifth item in the same list as class/struct/record/record struct, as if you’re choosing between five options. That’s not the right mental model. Every one of those four type kinds can implement any number of interfaces, completely independently of which kind it is. The choice of “class vs struct vs record vs record struct” and the choice of “which interfaces does this type implement” are two entirely separate decisions that don’t constrain each other.

To make this concrete, here’s the exact same interface implemented by all four kinds:

interface IShape
{
    double Area();
}

class CircleClass : IShape
{
    public double Radius;
    public double Area() => Math.PI * Radius * Radius;
}

struct CircleStruct : IShape
{
    public double Radius;
    public double Area() => Math.PI * Radius * Radius;
}

record CircleRecord(double Radius) : IShape
{
    public double Area() => Math.PI * Radius * Radius;
}

record struct CircleRecordStruct(double Radius) : IShape
{
    public double Area() => Math.PI * Radius * Radius;
}

All four of these compile without complaint, and all four satisfy IShape identically as far as anything consuming the interface is concerned:

void PrintArea(IShape shape) => Console.WriteLine(shape.Area());

PrintArea(new CircleClass { Radius = 2 });        // works
PrintArea(new CircleStruct { Radius = 2 });        // works
PrintArea(new CircleRecord(2));                    // works
PrintArea(new CircleRecordStruct(2));               // works

PrintArea doesn’t know or care whether it was handed a reference type or a value type, a plain type or a record — it only knows it received something that has an Area() method, because that’s all IShape promises. This is the entire point of an interface: it lets you write code against a capability, without committing to a specific underlying representation.

You now have three genuinely independent axes describing any given type: reference vs. value, plain vs. record, and implements-this-interface vs. doesn’t. None of the three constrains the others. You could just as easily write a class that does not implement IShape, or a struct that implements three different interfaces at once, or a record that implements none — all of that is unrelated to whether the type is a class, struct, record, or record struct.

Interface Variables and Boxing

Assigning a value type to an interface-typed variable creates the exact same boxing situation as assigning it to object, and it’s worth walking through explicitly, because it’s a very easy trap to fall into without noticing.

struct CircleStruct : IShape
{
public double Radius;
public double Area() => Math.PI * Radius * Radius;
}

var s = new CircleStruct { Radius = 2 }; // lives on the stack, no allocation yet
IShape shape = s; // BOXING happens right here!

The moment you assign a value-type instance to a variable of interface type, the runtime has to box it — that is, copy the entire value onto the heap and hand you back a reference to that heap copy, because an interface-typed variable is, under the hood, a reference, and a value type has no “address” of its own to hand out. This happens completely silently; there’s no special syntax that flags it, no warning, nothing in the code that visually screams “allocation happening here.” The line IShape shape = s; looks just as innocuous as any other assignment, but it triggers a heap allocation that the equivalent line for a class-based IShape implementation never would.

This has a very concrete, easy-to-demonstrate consequence: boxing creates a copy, so mutating the boxed copy through the interface reference does not affect the original struct:

interface ICounter { void Increment(); }

struct Counter : ICounter
{
public int Value;
public void Increment() => Value++;
}

var c = new Counter { Value = 0 };
ICounter boxed = c; // boxing: a separate copy is made on the heap
boxed.Increment(); // mutates the BOXED COPY
Console.WriteLine(c.Value); // still 0 — the original struct never changed

This is a classic, very real source of confusing bugs: code that looks like it’s calling a mutating method on your object is actually calling it on a disposable heap copy that gets thrown away the moment the interface reference goes out of scope. class and record never have this problem, because they were reference types to begin with — assigning one to an interface-typed variable is just copying a reference to the same object that already existed, with no boxing and no hidden copy.

The practical lesson: if you’re storing value types (struct/record struct) in a collection typed by interface — a List<IShape> full of struct-based shapes, for instance — every single element got boxed on the way in, and you’re paying a heap allocation per element, exactly the cost you were probably trying to avoid by choosing a value type in the first place.

Multiple Interface Implementation vs. Single Base Class

Here’s a structural fact about interfaces that becomes the seed of the entire second half of this post, so it’s worth planting clearly now: a type can implement as many interfaces as you like, but a class or record can only inherit from one base class.

interface IFlyable { void Fly(); }
interface ISwimmable { void Swim(); }
interface IWalkable { void Walk(); }

class Duck : IFlyable, ISwimmable, IWalkable
{
public void Fly() => Console.WriteLine("Flying");
public void Swim() => Console.WriteLine("Swimming");
public void Walk() => Console.WriteLine("Walking");
}

Duck satisfies three completely independent contracts at once, with no conflict. Contrast this with inheritance, where C# only allows a single base class:

class Animal { }
// class Duck : Animal, SomeOtherBaseClass { } // ILLEGAL — cannot inherit from two classes

This asymmetry exists because inheritance from a base class brings along actual implementation and state — fields, method bodies, constructors — and the language (like most mainstream OOP languages) sidesteps the substantial complexity of “what happens when two base classes both define a field called X, or both implement a method the same way” by simply disallowing multiple base classes altogether. Interfaces sidestep this problem because, classically, they carry no state and no implementation to conflict — a type promising to satisfy IFlyable and a type promising to satisfy ISwimmable can’t possibly clash, because neither promise brings any actual code or data along with it.

This is exactly why interfaces are the tool of choice when a type needs to participate in several unrelated capabilities at once — a Duck being flyable, swimmable, and walkable are three orthogonal facts about it, and forcing that into a single inheritance chain (Duck : FlyingAnimal, but then where does swimming come from?) would quickly become awkward or impossible. Keep this example in mind — Part Two builds directly on this observation to explain why composing behaviour out of small, focused interfaces is often a better design than building deep inheritance trees.

Default Interface Members — Interfaces Aren’t Entirely Implementation-Free Anymore

Everything above described the classic, “pure contract” interface: no fields, no method bodies, only signatures. That was the whole story before C# 8, and it’s still the right mental model for the overwhelming majority of interfaces you’ll write. But since C# 8, interfaces have been allowed to provide a default implementation for a member, which a class doesn’t have to override unless it wants to:

interface IGreeter
{
string Name { get; }
void Greet() => Console.WriteLine($"Hello, {Name}!"); // default implementation
}

class Robot : IGreeter
{
public string Name => "R2D2";
// Robot doesn't implement Greet() at all — it inherits IGreeter's default body
}

IGreeter g = new Robot();
g.Greet(); // prints "Hello, R2D2!" using the interface's own default implementation

This is a genuinely useful escape hatch for library authors: it lets you add a new member to a widely-used interface after the fact, without breaking every existing type that already implements it, because any existing implementer that doesn’t define the new member simply falls back to the interface’s default body instead of failing to compile.

It’s important to be precise about what this does and doesn’t change, though. A default interface member is still only reachable through the interface-typed reference, not through the concrete type directly:

Robot r = new Robot();
// r.Greet(); // Compile error! Robot itself doesn't declare Greet() — only IGreeter does
IGreeter g = r;
g.Greet(); // Works — accessed through the interface

And an interface still cannot hold instance state — no fields — even with default members, so it remains fundamentally different from a base class, which can hold both fields and default behaviour. The takeaway isn’t “interfaces are basically abstract classes now” — they’re still not — but rather: don’t be surprised in real-world code when you see an interface member with a body, and know that it means “this is the fallback behaviour, which any implementer is free to override,” not “this interface secretly has state or a constructor.”

Two Different Ways to Reuse Code

Part One established two facts that this half of the post now builds directly on top of: struct and record struct cannot inherit from anything at all, while class and record can inherit from exactly one base type; and any of the four type kinds can implement as many interfaces as it likes, with no such “only one” restriction. Those two facts, side by side, raise an obvious design question: when you want two or more types to share behaviour, when should you reach for inheritance, and when should you reach for composition instead?

This isn’t just a C#-specific syntax question — it’s one of the oldest and most consequential design debates in object-oriented programming, usually summarised as the principle “favour composition over inheritance.” Suppose you’re building a small set of employee types, and every employee needs the ability to log a message somewhere. There are two structurally different ways to give every employee type that ability.

Inheritance says: create a common base class that contains the shared behaviour, and have every type that needs it inherit from that base class. This is often described as an “is-a” relationship — a Manager is a Employee, so it makes sense for Manager to inherit from Employee and receive everything Employee already knows how to do.

class Employee
{
public string Name;
public void Log(string message) => Console.WriteLine($"[{Name}] {message}");
}

class Manager : Employee
{
public void ApproveTimeOff(Employee e) => Log($"Approved time off for {e.Name}");
}

Manager gets Log for free, simply by inheriting from Employee. This works, and for a simple, genuinely hierarchical relationship like this one, it’s a completely reasonable choice.

Composition says something different: instead of inheriting the behaviour, hold a reference to an object that provides it, and delegate to that object when you need the behaviour. This is often described as a “has-a” relationship — an Employee has a logger, rather than is a logger.

interface ILogger
{
void Log(string message);
}

class ConsoleLogger : ILogger
{
public void Log(string message) => Console.WriteLine(message);
}

class Employee
{
private readonly ILogger _logger;
public string Name;

public Employee(ILogger logger) => _logger = logger;

public void LogActivity(string message) => _logger.Log($"[{Name}] {message}");
}

Here, Employee doesn’t inherit logging behaviour from anywhere — it holds an ILogger and delegates to it. Nothing about Employee’s type hierarchy needs to change to support logging; it just needs a reference to something that knows how to log.

Both examples achieve the same visible result (an employee that can log a message), but they achieve it through fundamentally different mechanisms, and those mechanisms have very different consequences as the codebase grows — which is the subject of the rest of this post.

Inheritance’s Real Cost: Tight Coupling and the Fragile Base Class Problem

Inheritance isn’t wrong — it’s genuinely the right tool in some situations, which we’ll cover later— but it comes with a cost that’s easy to underestimate the first time you use it, and expensive to discover later: a derived class is tightly coupled to its base class’s implementation, not just its public contract. When you inherit from a class, you don’t just gain access to its public members — you become dependent on how those members are implemented internally, in ways that aren’t always obvious from reading the derived class alone.

This is widely known as the fragile base class problem: a seemingly safe, reasonable change to a base class can silently break derived classes that depend on it, even though nothing about the base class’s public interface changed. Here’s a concrete demonstration:

class Collection
{
private List<int> _items = new();

public virtual void Add(int item)
{
_items.Add(item);
Console.WriteLine("Item added");
}

public void AddRange(IEnumerable<int> items)
{
foreach (var item in items)
Add(item); // calls the virtual Add — including any override!
}
}

class LoggingCollection : Collection
{
public int AddCount = 0;

public override void Add(int item)
{
base.Add(item);
AddCount++;
}
}

At first glance, LoggingCollection looks correct: every call to Add increments AddCount, so AddCount should always equal the number of items added. But watch what happens with AddRange:

var c = new LoggingCollection();
c.AddRange(new[] { 1, 2, 3 });
Console.WriteLine(c.AddCount); // 3 — correct, because AddRange calls the overridden Add for each item

This particular example happens to work, but only because Collection.AddRange was deliberately written to call the virtual Add method, so overrides get picked up. That’s precisely the danger: LoggingCollection’s correctness silently depends on an internal implementation detail of Collection — specifically, that AddRange routes through Add rather than, say, appending directly to a private list for efficiency. If a future maintainer of Collection “optimises” AddRange like this:

public void AddRange(IEnumerable<int> items)
{
_items.AddRange(items); // "optimisation": skip the virtual call overhead
Console.WriteLine($"{items.Count()} items added");
}

LoggingCollection.AddCount silently stops being accurate, and nothing about LoggingCollection’s own code changed at all. The bug was introduced entirely inside the base class, in a method LoggingCollection never even overrode. This is the fragile base class problem in miniature: the base class’s author has to think about every possible derived class’s assumptions before making almost any internal change, forever — because derived classes are coupled not just to the base class’s public signatures, but to its internal call patterns, which are usually invisible from outside.

The deeper this hierarchy grows — EmployeeSalariedEmployeeManagerSeniorManager, each layer adding assumptions about the layers below — the harder it becomes to change anything near the top without a ripple of subtle breakage further down, and the harder it becomes for someone reading SeniorManager in isolation to know which behaviours actually come from where. Composition sidesteps this problem structurally: when Employee merely holds an ILogger, nothing about ILogger’s internal implementation can leak into Employee’s correctness in this way, because the only thing Employee depends on is the interface’s public contract.

Composition in Practice: Building Behaviour Out of Small, Focused Interfaces

The Duck example from Part One — implementing IFlyable, ISwimmable, and IWalkable all at once — was already an example of composition-friendly thinking, just without a supporting object behind each interface yet. Let’s complete that picture. Rather than a Duck inheriting flying/swimming/walking behaviour from some deep, awkward hierarchy, it can instead hold small objects that each know how to do one thing, and delegate to them:

interface IMovementStrategy { void Move(); }

class FlyingMovement : IMovementStrategy
{
public void Move() => Console.WriteLine("Flapping wings and flying");
}

class SwimmingMovement : IMovementStrategy
{
public void Move() => Console.WriteLine("Paddling through water");
}

class Duck
{
private readonly IMovementStrategy _airMovement;
private readonly IMovementStrategy _waterMovement;

public Duck(IMovementStrategy airMovement, IMovementStrategy waterMovement)
{
_airMovement = airMovement;
_waterMovement = waterMovement;
}

public void Fly() => _airMovement.Move();
public void Swim() => _waterMovement.Move();
}

var duck = new Duck(new FlyingMovement(), new SwimmingMovement());
duck.Fly(); // "Flapping wings and flying"
duck.Swim(); // "Paddling through water"

Notice what this buys you that inheritance couldn’t easily offer: the specific way a Duck flies is now a swappable, independently testable piece, completely decoupled from what a Duck fundamentally is. If you later need a RoboDuck that swims the same way but “flies” via a jetpack, you don’t need to redesign an inheritance hierarchy at all — you just construct it with a different IMovementStrategy:

class JetpackMovement : IMovementStrategy
{
public void Move() => Console.WriteLine("Rocketing through the air");
}

var roboDuck = new Duck(new JetpackMovement(), new SwimmingMovement());
roboDuck.Fly(); // "Rocketing through the air" — no change to the Duck class itself

This is precisely why composition is often described as more flexible than inheritance: behaviour becomes something you plug in through a constructor (or property), rather than something baked permanently into a type’s position in a fixed hierarchy at compile time. It also makes unit testing considerably easier — in a test, you can hand Duck a fake IMovementStrategy that does nothing but record that it was called, without needing to construct any of the real flying/swimming logic at all, something that’s often awkward to do cleanly when behaviour comes from an inherited base class instead.

Composition and Value Types: Not a Workaround, but the Only Option

Above, we established that struct and record struct cannot inherit from anything at all — this isn’t a missing feature the language forgot to add, it’s a direct, deliberate consequence of what a value type is. Because of this, composition isn’t an optional alternative for value types the way it is for classes — it’s the only mechanism value types have for sharing or reusing behaviour beyond what interfaces alone provide.

readonly record struct Point(double X, double Y)
{
public double DistanceTo(Point other) =>
Math.Sqrt(Math.Pow(X - other.X, 2) + Math.Pow(Y - other.Y, 2));
}

readonly record struct Circle(Point Center, double Radius)
{
public double Area() => Math.PI * Radius * Radius;
public bool Contains(Point p) => Center.DistanceTo(p) <= Radius;
}

Circle doesn’t — and structurally can’t — inherit from Point to get distance calculations. Instead, it composes a Point as one of its own fields, and reuses Point’s DistanceTo method by calling it on that held instance. This is a completely natural, idiomatic way to build up richer value types out of smaller ones, and it’s worth explicitly noticing that this is exactly the same composition principle from the Duck example — just applied to small, immutable value objects instead of behaviour-swapping strategy classes. Small, focused, composable value objects like Point, Money, and Address are exactly the kind of thing record/record struct are designed to make easy to build and combine.

So When Does Inheritance Actually Make Sense?

None of this means inheritance is a mistake to avoid entirely — it means inheritance should be a deliberate choice made when its specific strengths genuinely apply, rather than a reflexive first instinct. Inheritance tends to be the right tool when:

  • The relationship is genuinely “is-a,” and is unlikely to need to change at runtime. A Circle really is a kind of Shape in a way that’s true for the entire lifetime of the object — it doesn’t stop being a circle and become a rectangle later. Contrast this with “a Duck can currently fly using wings, but that specific mechanism might reasonably need to be swapped” — that’s a “has-a, and might change” relationship, which favours composition.
  • You want to take advantage of polymorphism through a shared base type, especially when you have a closed, well-understood set of variants and want the compiler to help you handle them exhaustively — for example, an abstract record Shape with sealed record Circle/Rectangle subclasses, paired with a switch expression that handles each case.
  • The shared behaviour is small, stable, and unlikely to change underneath derived types. The fragile base class problem gets worse the more a base class’s internals change over its lifetime; if a base class is simple and essentially “done,” the risk that inheritance’s tight coupling will bite you is much lower.
  • You’re modelling a genuine hierarchy with more than one level of specialisation that the domain itself supports — for example, a UI framework’s ControlButtonBaseButton hierarchy reflects real, stable, structural relationships in how those types behave, not just convenient code reuse.

The practical decision process, then, isn’t “composition good, inheritance bad” — it’s closer to: default to composition and interfaces for sharing behaviour, especially across value types or unrelated capabilities, and reach for inheritance specifically when you have a genuine, stable “is-a” relationship where polymorphism is the actual goal, not just a convenient way to avoid retyping a method.

Summary

An interface is not a fifth alternative to class/struct/record/record struct — it’s an orthogonal contract that any of the four can promise to fulfil, completely independent of whether that type is a reference type or a value type, or a plain type or a record. All four type kinds can implement the same interface identically from the caller’s perspective. The place this orthogonality has a real, measurable cost is boxing: assigning a struct/record struct to an interface-typed variable silently allocates a heap copy, and mutating through that boxed copy never affects the original value — a class/record never has this problem, because it was already a reference type. Interfaces also don’t share inheritance’s “only one” restriction — a type can implement as many interfaces as it needs, which is precisely what makes them the natural tool for composing several independent capabilities onto one type. And since C# 8, interfaces can carry default implementations for their members, which softens but doesn’t erase the core distinction: an interface still holds no state of its own, and remains fundamentally a promise about behaviour, not a container for data.

Building on that: inheritance and composition are two different mechanisms for sharing behaviour across types, and they carry very different long-term costs. Inheritance expresses an “is-a” relationship and gives you polymorphism through a shared base type, but it comes with tight coupling to the base class’s implementation, not just its public contract — the fragile base class problem means a seemingly safe change deep inside a base class can silently break derived classes that never touched the changed code. Composition expresses a “has-a” relationship: instead of inheriting behaviour, a type holds a reference to an object (usually through an interface) and delegates to it, which keeps types decoupled from each other’s internals and makes behaviour swappable at runtime rather than fixed at compile time. This isn’t a purely stylistic preference for class/record — for struct/record struct, which structurally cannot inherit at all, composition is the only mechanism available for building richer value types out of smaller ones. The practical guidance is to default to composition and interfaces, and reach for inheritance specifically when you have a genuine, stable “is-a” relationship where polymorphism through a shared base type is the actual goal — not simply a shortcut to avoid writing a method twice.