C# Abstract Classes: When to Use Them vs Interfaces
Sometimes you don't just want to define a contract — you want to hand out some of the implementation too. That's what an abstract class is for.
Picture a payment processing module with three gateways: Stripe, PayPal, and a bank-transfer ACH processor. All three need to log every attempt, retry on transient failure, and record a transaction record afterward — that part is identical, word for word, in all three. But the actual "send this to the payment network" step is completely different for each one. If you model this purely with an interface, you'll write the exact same logging-and-retry code three times:
// With just an interface, this exact block gets copy-pasted into
// StripeGateway, PayPalGateway, and AchGateway — three times, with
// three chances to drift out of sync when someone fixes a bug in one.
public class StripeGateway : IPaymentGateway
{
public PaymentResult Charge(PaymentRequest request)
{
_logger.Log($"Charging {request.Amount:C} via Stripe...");
for (int attempt = 0; attempt < 3; attempt++)
{
try { return SendToStripe(request); }
catch (TransientGatewayException) when (attempt < 2) { /* retry */ }
}
throw new PaymentFailedException("Stripe charge failed after retries.");
}
// ...
}
An abstract class solves exactly this: it lets you write the shared logging-and-retry code once, in one place, while still forcing every gateway to supply its own "send to the network" step. It's a base class with a piece deliberately left blank — and the compiler refuses to let anyone forget to fill it in.
In this lesson, you'll learn what abstract class and abstract methods actually guarantee, how they differ from a plain virtual method, when an abstract base class is the right call versus an interface, and — since interfaces are the very next lesson — a full, honest comparison between the two.
What Is an Abstract Class?
An abstract class is a class you can never instantiate directly — it exists purely to be inherited from. It can contain fully implemented methods and fields, just like a normal class, but it can also declare abstract members: methods (or properties) with a signature but no body at all, which every non-abstract subclass is required to implement.
public abstract class PaymentGateway
{
// Fully implemented — shared by every subclass, written once
protected readonly ILogger Logger;
protected PaymentGateway(ILogger logger) => Logger = logger;
// Abstract — NO body, and every concrete subclass MUST provide one
protected abstract PaymentResult SendToNetwork(PaymentRequest request);
}
// var gateway = new PaymentGateway(logger); // compiler error: cannot instantiate an abstract class
Two rules define the whole feature: an abstract class cannot be new'd up on its own, and an abstract member has no implementation at all in the class that declares it — only in a class that overrides it can it get one.
Why Does It Exist? — Shared Implementation + Enforced Contract
Without abstract classes, you're stuck with two unsatisfying choices when several related types need to share some logic but must each supply their own version of one specific piece:
Option A — a plain base class with a virtual method
- You could give
SendToNetworka default body that just throwsNotImplementedException - Nothing stops a new subclass from forgetting to override it — the class instantiates fine, compiles fine, and blows up only at runtime, on whatever code path first calls it
Option B — an interface only
- Forces every implementer to supply
SendToNetwork— good - But gives you nowhere to put the shared logging/retry logic without duplicating it in every implementation (default interface methods help here, but they can't hold instance state like fields — more on that below)
An abstract class is the tool built specifically to combine both: real, inherited implementation for the parts that are genuinely identical, plus a compiler-enforced guarantee — not a runtime hope — that every concrete subclass fills in the parts that aren't.
Big Picture
- Concrete (shared):
Charge()— logging, retry loop, transaction recording - Abstract (forced):
SendToNetwork()— no default, no fallback
- Inherits
Charge()for free — supplies onlySendToNetwork()
- Same inherited
Charge(), its ownSendToNetwork()
- Same inherited
Charge(), its ownSendToNetwork()
The logging-and-retry logic lives in exactly one place. Fix a bug in it once, and all three gateways get the fix automatically. Forget to implement SendToNetwork in a new gateway, and the code simply doesn't compile — there's no way to ship that mistake.
Abstract Methods vs. Virtual Methods
Both participate in the same runtime dispatch mechanism you learned in lesson 074 — but they make very different promises to a subclass author:
| Aspect | virtual method | abstract method |
|---|---|---|
| Has a body in the declaring class? | Yes — a real default implementation | No — none at all, ever |
| Can the declaring class be instantiated? | Yes, normally | No — the class itself must be abstract |
| Must a subclass override it? | No — overriding is optional | Yes — mandatory for any non-abstract subclass, enforced by the compiler |
| What if a subclass doesn't override it? | It silently uses the base's default behavior | Won't compile unless the subclass is itself declared abstract |
| Keyword used to override | override | override (identical syntax) |
This is the essential difference: virtual says "here's a reasonable default, override if you want something different." abstract says "there is no reasonable default — you must decide."
Simple Example
public abstract class ReportSection
{
public string Title { get; }
protected ReportSection(string title) => Title = title;
// Concrete: identical for every section type
public string Render() => $"## {Title}\n{RenderBody()}\n";
// Abstract: every section decides its own body content
protected abstract string RenderBody();
}
public sealed class SummarySection(string title, string summaryText) : ReportSection(title)
{
protected override string RenderBody() => summaryText;
}
public sealed class TableSection(string title, IReadOnlyList<string> rows) : ReportSection(title)
{
protected override string RenderBody() => string.Join("\n", rows);
}
// Usage
ReportSection[] sections =
[
new SummarySection("Overview", "Revenue grew 12% quarter over quarter."),
new TableSection("Top Products", ["Widget A - 1,204 units", "Widget B - 980 units"])
];
foreach (var section in sections)
Console.WriteLine(section.Render()); // Render() is inherited; RenderBody() is polymorphic
Code → Meaning → Result: Render() is written once and never repeated. RenderBody() has no default because there genuinely isn't one — a summary and a table don't share any sensible rendering logic. The compiler would refuse to compile SummarySection if it forgot to implement RenderBody().
Real-World Example — The Payment Gateway Hierarchy, in Full
public abstract class PaymentGateway(ILogger logger, ITransactionStore store)
{
protected readonly ILogger Logger = logger;
// Concrete — the algorithm's shape is fixed and shared by every gateway.
// Non-virtual on purpose: no subclass should be able to skip retry or logging.
public PaymentResult Charge(PaymentRequest request)
{
Logger.Log($"Charging {request.Amount:C} via {GatewayName}...");
for (int attempt = 1; attempt <= 3; attempt++)
{
try
{
var result = SendToNetwork(request);
store.Record(request, result);
return result;
}
catch (TransientGatewayException ex) when (attempt < 3)
{
Logger.Log($"Attempt {attempt} failed transiently: {ex.Message}. Retrying...");
}
}
throw new PaymentFailedException($"{GatewayName} charge failed after 3 attempts.");
}
// Abstract — every gateway MUST supply its own identity and network call.
protected abstract string GatewayName { get; }
protected abstract PaymentResult SendToNetwork(PaymentRequest request);
}
public sealed class StripeGateway(ILogger logger, ITransactionStore store, IStripeClient client)
: PaymentGateway(logger, store)
{
protected override string GatewayName => "Stripe";
protected override PaymentResult SendToNetwork(PaymentRequest request) =>
client.CreateCharge(request.Amount, request.Currency, request.CardToken);
}
public sealed class AchGateway(ILogger logger, ITransactionStore store, IAchClient client)
: PaymentGateway(logger, store)
{
protected override string GatewayName => "ACH";
protected override PaymentResult SendToNetwork(PaymentRequest request) =>
client.InitiateTransfer(request.Amount, request.BankAccountNumber);
// ACH transfers settle in 1-3 business days — SendToNetwork here returns
// a "pending" result rather than an immediate confirmation, and that's
// fine: the shared Charge() logic doesn't care HOW settlement happens.
}
Why this is the right shape for an abstract class:
- The retry-and-record algorithm in
Charge()is genuinely identical across every gateway — a textbook case for shared implementation, not duplication. GatewayNameandSendToNetworkhave no meaningful default — there's no "generic" way to talk to a payment network, soabstractcorrectly forces every subclass to decide.- A shared constructor (via the primary constructor's
baseparameters) means every gateway is guaranteed to have a logger and a transaction store — the abstract class establishes that invariant once. - Adding a new gateway (say, a wire-transfer processor) means writing one small class with two members — the retry/logging logic is never touched, never retested, never at risk.
Under the Hood
new PaymentGateway(...)is a compile error, not a runtime check — there's no possible IL that constructs a bare abstract class
- Under the hood, an abstract method still reserves a slot in the type's method table (vtable) — exactly like a
virtualmethod — it just has no implementation entry in the abstract class itself - Every concrete subclass's
overridefills that slot; the runtime dispatch mechanics are otherwise identical to ordinary polymorphism (lesson 074)
- Any class that inherits
PaymentGatewayand does not implement every abstract member must itself be declaredabstract— it simply cannot be a compilable, instantiable, concrete class otherwise
- They're never called via
newdirectly, but a derived class's constructor still runs the abstract base's constructor first, exactly per lesson 072'sbase(...)chaining rules
Common Confusion — Abstract Classes vs. Interfaces
Since C# 8 introduced default interface methods, an interface can also carry a body for some of its members — which makes the boundary look blurrier than it used to be. It isn't, once you look at what each can actually hold:
| Capability | Abstract class | Interface (incl. default methods) |
|---|---|---|
| Instance fields / state | Yes | No — an interface cannot declare instance fields |
| Constructors | Yes — runs on every derived instance | No |
| Multiple inheritance | A class may inherit from only one class | A class may implement many interfaces |
| Access modifiers on members | public, protected, private, etc. | Effectively public (with narrower support for others) |
| Meaning it expresses | "This IS-A specific kind of thing, sharing real implementation" | "This CAN-DO a specific capability" |
| Best use | A genuine, narrow, stable inheritance relationship with real shared code | A contract implemented by otherwise unrelated types (see lesson 077) |
The single-inheritance limit is the most consequential difference in practice: because a class can extend only one abstract class, choosing an abstract base "spends" that class's one inheritance slot. An interface never costs you that slot — you can implement a dozen of them.
Common Mistakes
Mistake 1 — Using an abstract class purely for the contract, with no shared implementation
// Nothing here is shared — every member is abstract. This "spends"
// the derived class's one inheritance slot for zero benefit.
public abstract class INotificationChannel_Wrong
{
public abstract string ChannelName { get; }
public abstract Task SendAsync(string recipient, string message);
}
If a base type has no real implementation to share, it should be an interface — INotificationChannel — precisely as seen in lesson 073's notification service. Reserve abstract classes for when there's genuine shared code to write once.
Mistake 2 — Marking a member abstract when a sensible default actually exists
Forcing every subclass to reimplement something that's genuinely the same 95% of the time creates needless repetition and gives each implementation a chance to diverge by accident.
Give it a virtual default implementation instead, and let the rare subclass that needs something different override it. Reserve abstract for members with no reasonable default at all.
Mistake 3 — Reaching for an abstract class when the real need is multiple capabilities on one class
abstract class AuditableEntity and abstract class CacheableEntity — a class that needs to be both auditable and cacheable can't inherit from both; C# doesn't allow multiple base classes.
Model each capability as its own interface (IAuditable, ICacheable) — lesson 078 covers exactly this pattern of implementing several interfaces on one class.
When Should I Use It?
- There's a genuine, stable "is-a" relationship and real, non-trivial code that every subclass would otherwise duplicate (a shared algorithm, shared state, a shared constructor invariant).
- You want to force every subclass to supply specific behavior, with a compile-time guarantee rather than a runtime
NotImplementedExceptionwaiting to happen. - The set of subclasses is reasonably closed and internal to your codebase — abstract classes work best when you control both the base and the derived types.
Mental Model
abstract method = a promise with no fallback: the compiler won't let a concrete class ship without keeping it.
Remember:
· A class can extend only one abstract class — that inheritance slot is precious.
· An abstract class with nothing shared to offer should have been an interface.
·
abstract means "no default exists"; virtual means "here's a default, override if you must."
Key Takeaway
- An
abstract classcan never be instantiated and can mix fully-implemented members withabstractmembers that have no body at all. - Every non-abstract subclass is compiler-enforced to implement every abstract member — unlike a
virtualmember with a default that's easy to forget to override. - Abstract classes exist for the combination of real shared implementation plus an enforced contract — if there's nothing to share, use an interface instead.
- A class can inherit from only one abstract class but implement many interfaces — that single-inheritance cost is the biggest practical trade-off between the two.
- Default interface methods narrow the gap but don't erase it: interfaces still can't hold instance fields or constructors, which abstract classes can.
Check Your Understanding
Let's confirm you can reason about when abstract classes earn their keep — and when they don't.
1. What happens if a class inherits from an abstract class but fails to implement one of its abstract members?
Show answer
Correct: B
Why B is correct: This is the entire point of abstract — the compiler enforces completeness. A class that doesn't implement every abstract member from its base either provides that implementation or is itself marked abstract, pushing the obligation further down the hierarchy; it can never silently compile as a concrete, instantiable type.
Why A is incorrect: Abstract members have no body at all — there's no "does nothing" implementation for the compiler to fall back on.
Why C is incorrect: This describes the failure mode of Option A in the "Why Does It Exist" section — a virtual method with a throwing default. Abstract members catch this at compile time instead, which is the whole advantage.
Why D is incorrect: The compiler never invents behavior on your behalf; it only checks that you supplied it.
Reinforcement: This compile-time guarantee is the main practical advantage abstract methods have over a throwing virtual default.
2. In the PaymentGateway example, why is Charge() written as a normal (non-abstract, non-virtual) method rather than something each gateway overrides?
Show answer
Correct: B
Why B is correct: Because the algorithm's shape (log, retry, record) is genuinely shared and shouldn't vary per gateway, keeping it non-virtual guarantees every gateway gets identical, guaranteed behavior — mirroring the "template method" reasoning from lesson 072's ShipmentProcessor example.
Why A is incorrect: There's no such requirement; an abstract class could in principle consist entirely of abstract members, though that would usually mean it should have been an interface (Mistake 1 in this lesson).
Why C is incorrect: Abstract classes routinely mix concrete and abstract members — that mixture is the entire feature.
Why D is incorrect: Constructor timing is unrelated to whether a method is virtual — Charge() is called by application code well after construction completes.
Reinforcement: Keep the parts that must never vary non-virtual; make abstract only the parts that genuinely have no shared default.
3. A class needs to be both IAuditable and ICacheable, and also needs to extend a shared DomainEntity abstract class for common ID/timestamp logic. Is this possible in C#?
Show answer
Correct: B
Why B is correct: C#'s single-inheritance rule applies only to classes (including abstract ones) — a class can extend one base class while implementing as many interfaces as it needs, side by side. This is one of the most common real-world shapes: one shared abstract base plus several capability interfaces.
Why A is incorrect: Extending a base class and implementing interfaces are entirely compatible and extremely common together.
Why C is incorrect: They mix constantly and are designed to work together — this lesson's comparison table exists precisely because both tools are usually used side by side, not as alternatives to pick exclusively.
Why D is incorrect: No conversion is necessary; the single-inheritance limit only ever applies to the one class you're extending, never to interfaces.
Reinforcement: The one-base-class limit is exactly why choosing an abstract base is a bigger commitment than implementing an interface — you only get to spend it once.
4. A team defines abstract class INotificationChannel_Wrong where every single member is abstract — there's no shared implementation of any kind. What's the best critique of this design?
Show answer
Correct: B
Why B is correct: This is exactly Mistake 1 from this lesson — when there's no real implementation to share, an abstract class provides no benefit over an interface, while still consuming the derived class's single-inheritance slot and blocking it from extending some other, more useful base class later.
Why A is incorrect: This compiles perfectly fine — it's a legal but poorly-chosen design, not a syntax error.
Why C is incorrect: There's no minimum member count for an abstract class in C#.
Why D is incorrect: It has a real, concrete downside — the wasted inheritance slot — which is precisely why the comparison table in this lesson calls out multiple inheritance as a key deciding factor.
Reinforcement: Choose an abstract class specifically because there's implementation to share — not merely to declare a contract.
You can now tell, in seconds, whether a base type belongs as an abstract class or an interface — which sets up sealed classes (076) and the deep dive into interfaces (077) perfectly.