DEV Community

Cover image for Abstract Classes in C#
Rhuturaj Takle
Rhuturaj Takle

Posted on

Abstract Classes in C#

Abstract Classes in C

A deep-dive walkthrough of abstract classes in C# — covering why they can't be instantiated directly, the mix of implemented and abstract members that defines them, constructors and initialization in an abstract hierarchy, the Template Method pattern as their most natural use case, how they differ from and combine with interfaces, and the trade-offs that determine when an abstract class is genuinely the right tool versus composition or interfaces.


Table of Contents

  1. Introduction
  2. What an Abstract Class Actually Is
  3. Why Abstract Classes Cannot Be Instantiated
  4. Abstract Members: The Required Gaps
  5. Concrete Members: The Shared Implementation
  6. Virtual Members: Optional Overrides
  7. Constructors in Abstract Classes
  8. The Template Method Pattern
  9. Abstract Properties, Indexers, and Events
  10. Abstract Classes and Polymorphism
  11. Abstract Classes vs. Interfaces: A Direct Comparison
  12. Combining Abstract Classes with Interfaces
  13. Sealed Overrides: Locking Down Further Specialization
  14. When an Abstract Class Is the Wrong Tool
  15. Common Pitfalls
  16. Quick Reference Table
  17. Conclusion

Introduction

An abstract class in C# is a base class that's deliberately incomplete — it can define real, working implementation just like any ordinary class, but it can also declare members with no implementation at all, forcing every concrete subclass to supply one. That combination is what makes an abstract class fundamentally different from both an ordinary class (which must be fully implemented) and an interface (which, in its classical form, has no implementation at all): an abstract class sits deliberately in between, sharing genuine code across a family of related types while still enforcing that each one fills in the specific gaps that make it distinct. This guide walks through the language mechanics in depth, then covers the Template Method pattern — the design pattern abstract classes are most naturally suited to — and the judgment calls that separate a well-designed abstract class from an unnecessary one.

abstract class PaymentProcessor
{
    concrete method:  LogTransaction()      →  every subclass gets this FOR FREE, identical
    abstract method:  ProcessPayment()      →  every subclass MUST supply its OWN implementation
    virtual method:   Refund()              →  every subclass gets a DEFAULT, but MAY override it
}
Enter fullscreen mode Exit fullscreen mode

1. What an Abstract Class Actually Is

Declared with the abstract keyword, on both the class and its incomplete members

public abstract class Shape
{
    public abstract double GetArea(); // no body — every concrete subclass MUST provide one

    public void PrintDescription() // a normal, fully-implemented method
    {
        Console.WriteLine($"This shape has an area of {GetArea():F2}");
    }
}
Enter fullscreen mode Exit fullscreen mode

The abstract modifier on the class itself is what triggers the "cannot be instantiated" rule (Section 2); the abstract modifier on GetArea() is what marks that specific member as a required gap rather than an actual method — the two are related but distinct: a class can be abstract even if it happens to declare zero abstract members (Section 13 covers why you might still do this), and a member can only be abstract if the class containing it is also abstract.

A blend, not a pure form — this is the defining characteristic

Ordinary class: every member is fully implemented. Can be instantiated directly.
Interface (classical): no member has implementation. Can never be instantiated.
Abstract class: SOME members implemented, SOME not. Can never be instantiated directly,
  but unlike an interface, genuinely holds shared, reusable code.
Enter fullscreen mode Exit fullscreen mode

This middle position is the entire reason abstract classes exist as a distinct language feature rather than being redundant with either ordinary classes or interfaces — they're for exactly the situation where a family of types shares substantial, real implementation and has one or more places where each type must diverge and supply its own logic.


2. Why Abstract Classes Cannot Be Instantiated

The compiler enforces this directly

public abstract class Shape
{
    public abstract double GetArea();
}

// var shape = new Shape(); // ❌ compile error: "Cannot create an instance of the abstract class 'Shape'"
Enter fullscreen mode Exit fullscreen mode

Attempting new Shape() fails to compile, not at runtime — this is a compile-time guarantee, not a convention or a runtime check you'd need to write yourself.

The reason this restriction exists: an abstract class is inherently incomplete

public abstract class Shape
{
    public abstract double GetArea(); // has NO body — there is no code to execute here
}
Enter fullscreen mode Exit fullscreen mode

If new Shape() were allowed and you called shape.GetArea(), there would be nothing for the runtime to actually execute — GetArea() was never given an implementation. The instantiation restriction isn't an arbitrary language rule; it's the compiler preventing you from creating an object that's guaranteed to be broken by construction. Only a class that has filled in every abstract member — making the type genuinely complete — can be instantiated.

Concrete subclasses are what you actually instantiate

public class Circle : Shape
{
    public double Radius { get; set; }
    public override double GetArea() => Math.PI * Radius * Radius; // fills the gap
}

var circle = new Circle { Radius = 5 }; // ✅ this compiles — Circle is fully implemented
Enter fullscreen mode Exit fullscreen mode

Circle is a complete, concrete type — it inherited Shape's abstract contract and satisfied it, so there's no longer any gap for the runtime to stumble over, and new Circle() is entirely valid.


3. Abstract Members: The Required Gaps

Declaring an abstract member: signature only, no body, terminated with a semicolon

public abstract class Employee
{
    public abstract decimal CalculatePay(); // note: semicolon, no { }
}
Enter fullscreen mode Exit fullscreen mode

An abstract member declares its return type, name, and parameters — exactly like an interface member — but appears inside a class rather than an interface, and can be mixed freely with fully-implemented members in that same class (Section 4).

Every non-abstract subclass must override every abstract member

public class SalariedEmployee : Employee
{
    public decimal AnnualSalary { get; set; }
    public override decimal CalculatePay() => AnnualSalary / 12; // required — compile error without it
}

public class HourlyEmployee : Employee
{
    public decimal HourlyRate { get; set; }
    public decimal HoursWorked { get; set; }
    public override decimal CalculatePay() => HourlyRate * HoursWorked;
}
Enter fullscreen mode Exit fullscreen mode

This is the compiler-enforced part of the contract — just as with an interface's members, forgetting to override CalculatePay() in a concrete subclass is a compile error, not a runtime surprise. Note the override keyword is mandatory here (unlike implementing an interface member, where the keyword is implicit) — this is because abstract members participate in the same virtual/override mechanism covered in Section 5, and the compiler wants that participation to be explicit and visible in the code.

An abstract subclass can leave abstract members unimplemented — and defer the requirement further down

public abstract class Employee
{
    public abstract decimal CalculatePay();
}

public abstract class Contractor : Employee
{
    // Contractor is STILL abstract — it doesn't implement CalculatePay(), it just adds more structure
    public abstract string ContractTerms();
}

public class FreelanceContractor : Contractor
{
    public override decimal CalculatePay() => 5000m; // must implement BOTH inherited abstract members
    public override string ContractTerms() => "Net 30";
}
Enter fullscreen mode Exit fullscreen mode

An intermediate class in the hierarchy doesn't have to resolve every abstract member itself — it can remain abstract and pass the obligation further down, adding its own abstract members along the way, as long as some concrete class eventually implements everything before it can be instantiated.


4. Concrete Members: The Shared Implementation

The entire reason abstract classes offer something interfaces (classically) don't

public abstract class Employee
{
    public string Name { get; set; }
    public DateTime HireDate { get; set; }

    // Fully implemented, identical for EVERY subclass — no reason to make each one rewrite this
    public int YearsOfService() => (DateTime.Today - HireDate).Days / 365;

    public abstract decimal CalculatePay(); // the one thing that genuinely varies
}
Enter fullscreen mode Exit fullscreen mode

YearsOfService() is a completely ordinary method with a real implementation — every subclass of Employee gets it automatically, identically, without writing a single line of code for it. This is the concrete reuse benefit an abstract class provides that a pure interface cannot: genuinely shared logic, computed once, inherited everywhere.

Fields, too — something interfaces cannot hold at all

public abstract class Employee
{
    protected decimal _baseDeductions = 500m; // a real field, inherited by every subclass
    public abstract decimal CalculatePay();
}
Enter fullscreen mode Exit fullscreen mode

Unlike an interface, which can never declare instance fields, an abstract class can hold real state — protected fields subclasses can read and use directly, exactly like any other base class. This is often the deciding factor in choosing an abstract class over an interface: if the shared thing you need to reuse is actual state, not just a method signature, only a class (abstract or otherwise) can provide it.


5. Virtual Members: Optional Overrides

A third category, distinct from both abstract and fully-fixed concrete members

public abstract class Employee
{
    public abstract decimal CalculatePay(); // MUST be overridden

    public virtual string GetPayStub() => $"Pay stub for {Name}: {CalculatePay():C}"; // MAY be overridden

    public string Name { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

virtual sits between abstract (no implementation, mandatory override) and an ordinary method (full implementation, cannot be overridden at all) — it provides a sensible default implementation while explicitly permitting a subclass to replace it if it needs different behavior.

Overriding a virtual member is optional, unlike an abstract one

public class SalariedEmployee : Employee
{
    public decimal AnnualSalary { get; set; }
    public override decimal CalculatePay() => AnnualSalary / 12; // required
    // GetPayStub() is NOT overridden here — SalariedEmployee simply uses Employee's default version
}

public class ContractEmployee : Employee
{
    public override decimal CalculatePay() => 5000m; // required
    public override string GetPayStub() => $"Contractor payment: {CalculatePay():C} (no benefits)"; // chose to override
}
Enter fullscreen mode Exit fullscreen mode

SalariedEmployee compiles fine without ever touching GetPayStub() — it simply inherits Employee's version as-is. ContractEmployee chose to override it because contractors need a genuinely different pay stub format. This is the practical distinction between abstract and virtual: use abstract when there's no sensible shared default and every subclass genuinely must decide for itself; use virtual when there is a sensible default, but some subclasses may reasonably need to deviate from it.


6. Constructors in Abstract Classes

Abstract classes can — and often should — have constructors, despite never being instantiated directly

public abstract class Employee
{
    public string Name { get; }
    public DateTime HireDate { get; }

    protected Employee(string name, DateTime hireDate) // note: protected, not public
    {
        Name = name;
        HireDate = hireDate;
    }

    public abstract decimal CalculatePay();
}
Enter fullscreen mode Exit fullscreen mode

This looks contradictory at first — why give a constructor to a class you can never call new on directly? — but the constructor isn't there to be called by outside code; it's there to be called by a subclass's constructor via base(...), guaranteeing that shared initialization logic (setting Name and HireDate here) happens consistently for every concrete type in the hierarchy, no matter which one is actually instantiated.

protected, not public, is the idiomatic access level

protected Employee(string name, DateTime hireDate) { ... } // protected — only reachable via base()
Enter fullscreen mode Exit fullscreen mode

Marking the constructor protected (rather than public) reinforces the actual intent — it's only ever meant to be invoked from within the class hierarchy itself, via a derived class's constructor, never called directly from outside code (which couldn't call it anyway, since you can't new an abstract class, but protected documents the intent explicitly rather than relying on that separate rule).

Subclass constructors calling into the abstract base via base()

public class SalariedEmployee : Employee
{
    public decimal AnnualSalary { get; set; }

    public SalariedEmployee(string name, DateTime hireDate, decimal annualSalary)
        : base(name, hireDate) // ensures Employee's constructor runs first
    {
        AnnualSalary = annualSalary;
    }

    public override decimal CalculatePay() => AnnualSalary / 12;
}
Enter fullscreen mode Exit fullscreen mode

Every concrete subclass's constructor explicitly (or implicitly, if the base has a compatible parameterless constructor) invokes the abstract base class's constructor — this guarantees Name and HireDate are set correctly and identically, regardless of which specific subclass is being constructed, which is exactly the shared-initialization reuse Section 4 describes, just happening during construction rather than during a later method call.


7. The Template Method Pattern

The pattern abstract classes are most naturally suited to expressing

public abstract class ReportGenerator
{
    // The TEMPLATE METHOD: defines the overall algorithm's structure, fixed and shared
    public string GenerateReport()
    {
        var header = BuildHeader();
        var body = BuildBody();      // the step that genuinely varies per report type
        var footer = BuildFooter();
        return $"{header}\n{body}\n{footer}";
    }

    private string BuildHeader() => $"Report generated on {DateTime.Today:d}"; // shared, fixed
    protected abstract string BuildBody();                                     // varies — MUST override
    private string BuildFooter() => "--- End of Report ---";                   // shared, fixed
}

public class SalesReport : ReportGenerator
{
    protected override string BuildBody() => "Sales data goes here...";
}

public class InventoryReport : ReportGenerator
{
    protected override string BuildBody() => "Inventory data goes here...";
}
Enter fullscreen mode Exit fullscreen mode

This is the Template Method pattern, and it's the single most common, idiomatic reason to reach for an abstract class rather than an interface or composition: GenerateReport() defines the overall algorithm — the fixed sequence of steps — once, in the base class, while delegating exactly one step (BuildBody()) to whichever concrete subclass is running. Callers only ever call GenerateReport(); they never need to know or care about BuildBody()'s existence directly. This directly combines Section 4's concrete-member reuse with Section 3's abstract-member enforcement into a single, cohesive structure.

Why this is meaningfully different from an interface-based Strategy pattern

Template Method (abstract class): the ALGORITHM'S STRUCTURE is shared and fixed;
  only specific STEPS within it vary — subclassing IS the mechanism.
Strategy (interface, per this series' Interfaces guide): the ENTIRE algorithm
  is swappable as one unit — composition (injecting a different IStrategy) IS the mechanism.
Enter fullscreen mode Exit fullscreen mode

Worth being explicit about this distinction, since both patterns solve a superficially similar "let behavior vary" problem: Template Method is the right fit when most of a process is genuinely identical across variants and only a well-defined piece changes — reaching for composition here would mean re-implementing the shared header/footer logic in every strategy, duplicating exactly what the abstract class was meant to centralize.


8. Abstract Properties, Indexers, and Events

Properties can be abstract too, not just methods

public abstract class Vehicle
{
    public abstract int MaxSpeed { get; }          // abstract read-only property
    public abstract string LicensePlate { get; set; } // abstract read-write property
}

public class Car : Vehicle
{
    public override int MaxSpeed => 130;
    public override string LicensePlate { get; set; } // auto-implemented override, fully valid
}
Enter fullscreen mode Exit fullscreen mode

An abstract property declares which accessors (get, set, or both) exist without providing their bodies — a subclass overriding it can supply a computed expression (as MaxSpeed does above) or a full auto-implemented property (as LicensePlate does), as long as it honors exactly the accessors the abstract declaration specified.

Abstract indexers and events follow the same pattern

public abstract class Collection<T>
{
    public abstract T this[int index] { get; set; } // abstract indexer
    public abstract event EventHandler ItemAdded;    // abstract event
}
Enter fullscreen mode Exit fullscreen mode

These are less commonly abstracted in practice than methods and properties, but the language supports it consistently — anywhere a member can be virtual, it can generally also be abstract, following the same required-override rule Section 3 covers.


9. Abstract Classes and Polymorphism

The classic use case: a collection of the abstract base type, holding many concrete subtypes

List<Shape> shapes = new() { new Circle { Radius = 2 }, new Square { Side = 3 }, new Triangle { Base = 4, Height = 5 } };

foreach (var shape in shapes)
{
    shape.PrintDescription(); // calls the SHARED concrete method
    // internally, PrintDescription() calls GetArea(), which dispatches to
    // whichever concrete subclass's implementation actually applies
}
Enter fullscreen mode Exit fullscreen mode

This is where an abstract class's design pays off at the call site — code holding a List<Shape> never needs to know or check which concrete shape each element actually is; calling PrintDescription() (Section 1's fully-implemented method) transparently invokes the correct polymorphic GetArea() underneath, exactly the same virtual-dispatch mechanism that applies to any virtual/override pair, abstract or not.

is and pattern matching, for the occasions polymorphism alone doesn't cover

foreach (var shape in shapes)
{
    if (shape is Circle circle)
    {
        Console.WriteLine($"This circle has radius {circle.Radius}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Most well-designed code using an abstract base class shouldn't need much of this — needing frequent type checks against concrete subclasses is often a sign the abstraction is missing a member that should have been made abstract or virtual in the base class instead. It's a legitimate escape hatch for genuinely subclass-specific behavior that doesn't belong in the shared contract, but reaching for it constantly is worth treating as a design smell.


10. Abstract Classes vs. Interfaces: A Direct Comparison

Side-by-side, on the dimensions that actually matter for choosing between them

                          Abstract Class              Interface
Instantiation             Never directly                Never directly
Implementation            Can hold both abstract        Traditionally none; default methods
                           AND fully-implemented members  (C# 8+) allow some, but no real STATE
Fields / state            Yes — real instance fields     No instance fields, ever
Multiple inheritance      NO — single base class only    YES — a class can implement many
Constructors               Yes (protected, via base())    No — interfaces have no constructors
Access modifiers           Full range (private,           Implicitly public only
                            protected, etc.)
Represents                "IS-A", with real shared        "CAN-DO" — a capability contract,
                            implementation                 usually with no shared implementation
Enter fullscreen mode Exit fullscreen mode

This table is worth returning to directly whenever the choice feels ambiguous — the two features overlap in what they enable (both provide a base type multiple concrete classes can be treated through polymorphically) but diverge sharply on multiple inheritance and genuine state, which are usually the deciding factors in practice.

The decisive question, restated plainly

Do the types genuinely share real, non-trivial IMPLEMENTATION or STATE,
  not just a method signature? → abstract class
Do the types need to implement MULTIPLE, independent contracts at once,
  or do they have no meaningful shared implementation at all? → interface
Enter fullscreen mode Exit fullscreen mode

Employee's subclasses genuinely sharing Name, HireDate, and YearsOfService() is a clear abstract-class case; a Duck and an Airplane both merely being able to Fly(), with zero shared implementation or state, is a clear interface case — the wrong choice in either direction tends to surface later as either unnecessary duplication (should have used an abstract class) or an artificial, overly-rigid hierarchy (should have used an interface).


11. Combining Abstract Classes with Interfaces

The two are not mutually exclusive — a common, idiomatic combination

public interface IPayable { decimal CalculatePay(); }

public abstract class Employee : IPayable
{
    public string Name { get; set; }
    public DateTime HireDate { get; set; }
    public int YearsOfService() => (DateTime.Today - HireDate).Days / 365;

    public abstract decimal CalculatePay(); // satisfies IPayable's contract abstractly
}

public class Contractor : IPayable // NOT an Employee — no shared state with the Employee hierarchy
{
    public decimal DailyRate { get; set; }
    public int DaysWorked { get; set; }
    public decimal CalculatePay() => DailyRate * DaysWorked;
}

// Code that only cares about "can this be paid" works uniformly across BOTH hierarchies
List<IPayable> payables = new() { new SalariedEmployee(...), new Contractor { ... } };
foreach (var p in payables) Console.WriteLine(p.CalculatePay());
Enter fullscreen mode Exit fullscreen mode

This is a genuinely common, powerful pattern: Employee uses an abstract class for the real shared state and behavior among its own subclasses, while IPayable is a separate, narrower interface that lets entirely unrelated types (Contractor, which has no reason to inherit Employee's fields at all) still participate in the same polymorphic "can be paid" behavior — exactly the kind of situation where a single abstract-class hierarchy alone couldn't reach, but an interface layered alongside it can.


12. Sealed Overrides: Locking Down Further Specialization

sealed override: this override is final, no further subclass may change it again

public abstract class Employee
{
    public abstract decimal CalculatePay();
}

public class SalariedEmployee : Employee
{
    public sealed override decimal CalculatePay() => AnnualSalary / 12; // no further overriding allowed
    public decimal AnnualSalary { get; set; }
}

public class ExecutiveEmployee : SalariedEmployee
{
    // public override decimal CalculatePay() => ...; // ❌ compile error — CalculatePay() was sealed
}
Enter fullscreen mode Exit fullscreen mode

sealed can be applied to an individual overridden member (distinct from sealing the whole class, covered in this series' OOP guide) — this is a deliberate design decision that says "this specific implementation is correct and final for this branch of the hierarchy, and no further subclass should be allowed to change it," which is a genuinely useful guardrail once a hierarchy grows several levels deep and a maintainer wants to prevent a specific, easily-misused override point from being touched again.


13. When an Abstract Class Is the Wrong Tool

Reaching for it purely to get polymorphism, with no genuine shared implementation

// ❌ Duck and Airplane share NOTHING except the ability to fly —
//    forcing them under one abstract base class is an artificial relationship
public abstract class Flyable
{
    public abstract void Fly();
}
public class Duck : Flyable { public override void Fly() { /* ... */ } }
public class Airplane : Flyable { public override void Fly() { /* ... */ } } // an airplane "is-a" Flyable??
Enter fullscreen mode Exit fullscreen mode

If there's no real state or implementation to share, an abstract class adds coupling (single inheritance is now spent on this relationship) without a genuine reuse benefit — an interface (IFlyable) expresses exactly the same polymorphic capability without forcing an artificial, empty base class into the hierarchy, and per this series' Interfaces guide, doesn't cost either type its one available base-class slot.

A "has-a" relationship being modeled as "is-a" purely for convenience

Per this series' OOP guide's composition-over-inheritance discussion: if the
  relationship between a base and derived class isn't a genuinely stable
  "is-a" — if it's really "this type USES that behavior" rather than
  "this type FUNDAMENTALLY IS a specialization of that type" — an abstract
  class (or any inheritance) is very likely the wrong tool, and composition
  (injecting the varying behavior as a dependency) usually models it better.
Enter fullscreen mode Exit fullscreen mode

This is the same caution this series' OOP guide raises about inheritance generally, applied specifically here: an abstract class is still inheritance, with all the same tight-coupling risk a deep or ill-fitting hierarchy carries — the Template Method pattern (Section 7) is a strong, legitimate use case specifically because the relationship really is "these are all variations on the same fixed algorithm," which is a much narrower and more defensible claim than "these types are generally similar."


14. Common Pitfalls

Pitfall Why it hurts Better approach
Trying to instantiate an abstract class directly Doesn't compile — a common early confusion for those new to the feature Instantiate a concrete subclass instead; the abstract class exists to be inherited from, never constructed
Forgetting the override keyword when implementing an abstract member Doesn't compile — unlike interface implementation, override is mandatory here Always pair an abstract member with an explicit override in the concrete subclass
Making a constructor public on an abstract class Misleading — implies the class can be constructed directly by outside code, which it can't Use protected, signaling the constructor exists only for base() calls from subclasses
Reaching for an abstract class when types share no real implementation Creates an artificial "is-a" relationship and burns the type's single-inheritance slot for no reuse benefit Use an interface instead when the relationship is purely a shared capability, not shared state/behavior
Overusing virtual where abstract was actually intended A subclass can silently fail to override a method that genuinely needed subclass-specific logic, since there's a "default" masking the omission Use abstract when there's no sensible shared default and every subclass must supply its own logic
Frequent is/type-checking against concrete subclasses of an abstract base Usually signals the abstraction is missing a member that should have been abstract or virtual in the base class Add the varying behavior as an abstract or virtual member in the base class instead of branching on concrete type externally
Building a deep, multi-level abstract class hierarchy for convenience Tight coupling across many levels; a base class change can ripple unpredictably through several subclasses Keep hierarchies shallow; prefer composition for relationships that aren't a genuinely stable, single "is-a"
Not using sealed override where a specific implementation should never be touched again A later subclass can silently override and change behavior a maintainer assumed was fixed and correct Seal an override deliberately when its correctness for that branch of the hierarchy is meant to be final

Quick Reference Table

Concept C# Syntax Purpose
Declaring an abstract class public abstract class Shape { ... } A base type that can never be instantiated directly
Abstract member public abstract double GetArea(); A required gap every concrete subclass must fill
Concrete member public void PrintDescription() { ... } Fully shared, reusable implementation inherited by every subclass
Virtual member public virtual string GetPayStub() => ...; A sensible default a subclass may optionally override
Overriding public override double GetArea() => ...; Mandatory for abstract members, optional for virtual ones
Constructor + base() protected Employee(...) { ... } / : base(...) Guarantees shared initialization across every concrete subclass
Template Method pattern A concrete method calling an internal abstract "step" method Fixes an algorithm's structure while letting one step vary per subclass
Sealed override public sealed override decimal CalculatePay() => ...; Locks a specific implementation against further overriding down the hierarchy
Abstract class + interface combo class Employee : IPayable (abstract) Real shared state within a family, plus a narrower capability contract across unrelated types

Conclusion

An abstract class exists for exactly one situation: a family of types that genuinely shares real implementation and state, but also has one or more places where each member of that family must supply its own, distinct behavior — and the "cannot be instantiated" rule isn't an arbitrary restriction, it's the compiler protecting you from a type that's incomplete by definition until every abstract gap is filled. The Template Method pattern is where this shows up most naturally and most often: a fixed algorithm, defined once, with a well-defined seam where subclasses genuinely need to diverge.

The recurring judgment call, much like with interfaces, is knowing when an abstract class is earning its place versus when it's inheritance reached for out of habit rather than genuine fit: real shared state and implementation, not just a shared method signature, is the signal that justifies it; a relationship that's really "has-a" or "can-do" rather than a stable, fundamental "is-a" is the signal that it's the wrong tool, and either an interface or composition would model the relationship more honestly. Used well, abstract classes eliminate real duplication across a genuinely related family of types; used reflexively, they add the same tight coupling and hierarchy fragility any inheritance can, without necessarily earning the reuse benefit that's supposed to justify it.


Found this useful? Feel free to star the repo, open an issue with corrections, or share the Template-Method-pattern-cleaned-up-five-duplicated-classes refactor that made the case for abstract classes better than any definition ever could.

Top comments (0)