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.
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.
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:
virtual methodSendToNetwork a default body that just throws NotImplementedExceptionSendToNetwork — goodAn 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.
Charge() — logging, retry loop, transaction recordingSendToNetwork() — no default, no fallbackCharge() for free — supplies only SendToNetwork()Charge(), its own SendToNetwork()Charge(), its own SendToNetwork()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.
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."
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().
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:
Charge() is genuinely identical across every gateway — a textbook case for shared implementation, not duplication.GatewayName and SendToNetwork have no meaningful default — there's no "generic" way to talk to a payment network, so abstract correctly forces every subclass to decide.base parameters) means every gateway is guaranteed to have a logger and a transaction store — the abstract class establishes that invariant once.new PaymentGateway(...) is a compile error, not a runtime check — there's no possible IL that constructs a bare abstract classvirtual method — it just has no implementation entry in the abstract class itselfoverride fills that slot; the runtime dispatch mechanics are otherwise identical to ordinary polymorphism (lesson 074)PaymentGateway and does not implement every abstract member must itself be declared abstract — it simply cannot be a compilable, instantiable, concrete class otherwisenew directly, but a derived class's constructor still runs the abstract base's constructor first, exactly per lesson 072's base(...) chaining rulesSince 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.
// 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.
abstract when a sensible default actually existsForcing 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.
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.
NotImplementedException waiting to happen.abstract means "no default exists"; virtual means "here's a default, override if you must."
abstract class can never be instantiated and can mix fully-implemented members with abstract members that have no body at all.virtual member with a default that's easy to forget to override.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?
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?
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#?
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?
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.
dotnetmadeeasy.com — Learn C# and .NET, the right way.