Senior 6 min · March 06, 2026

Abstract Classes in C# — Compile-Time Contract Enforcement

Virtual methods with empty bodies let missing overrides ship silently.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Abstract classes are partially built types — they share concrete logic while mandating that subclasses complete the unfinished parts before the type is usable
  • The abstract keyword does two things at once: prevents instantiation and unlocks abstract member declarations that the compiler will enforce
  • Abstract classes can hold fields, constructors, and concrete methods — interfaces cannot (default interface methods in C# 8 notwithstanding, they still cannot hold instance state)
  • The Template Method Pattern is the killer use case — define the algorithm order once in the abstract base, let subclasses vary only the individual steps
  • If your abstract class has zero fields, no constructor logic, and every member is abstract, you have accidentally written a verbose interface — refactor it
  • Abstract classes enforce correctness at compile time — the build itself refuses to compile any half-built subclass, which means the broken code never reaches production
Plain-English First

Imagine a generic Vehicle blueprint at a car factory. The blueprint says every vehicle MUST have an engine and MUST be able to move — but it does not build any actual cars itself, because a 'vehicle' is too vague to manufacture on its own. Abstract classes work exactly like that blueprint: they define rules that every specific type (Car, Truck, Motorbike) must follow, while also providing shared parts like a fuel gauge that all of them can reuse without rewriting. You can never park a raw Vehicle in your driveway — you need a real, concrete thing built from it. The abstract class is the factory blueprint, not the finished vehicle.

Every non-trivial C# codebase eventually hits the same wall: you have a family of related types that share some behaviour, but each one also needs to do something uniquely its own. If you duplicate the shared code across each class you are one bug-fix away from a maintenance nightmare where the same logic exists in four places and three of them are out of date. If you stick everything in a plain base class, nothing stops a developer from instantiating it half-finished and shipping broken logic to production. Abstract classes exist precisely to close that gap — they are the language's built-in way of saying 'here is the shared foundation, but you must finish the job before this is usable.'

The problem they solve is a dual one. First, they prevent accidental instantiation of incomplete types — the compiler will not let you call new Shape() if Shape is abstract, full stop. Second, they let you mix concrete, reusable logic with abstract, must-override contracts inside a single type hierarchy. That combination is something a plain interface cannot offer (interfaces carry no implementation state) and a regular base class cannot safely enforce (nothing stops someone from using it directly, and virtual methods with empty bodies fail silently at runtime rather than loudly at build time).

By the end of this article you will know exactly when to reach for an abstract class instead of an interface or a plain base class, how to design one that actually improves your architecture rather than just adding complexity, and the subtle traps that bite even experienced C# developers. You will also walk away with two real-world examples — a payment processing system and a report generation pipeline — that you can adapt immediately in your own work.

What Abstract Classes Actually Are — And What They Are Not

An abstract class is a class marked with the abstract keyword. That one keyword does two things simultaneously: it prevents the class from being instantiated directly with new, and it unlocks the ability to declare abstract members — methods, properties, and indexers that have a signature but no body. Any non-abstract class that inherits from an abstract class is contractually obligated to provide that body, or the code will not compile. The contract is enforced by the C# compiler itself, not by unit tests, not by code review, and not by documentation.

The important mental model: an abstract class is a partially built thing. Think of it as a house with some rooms fully furnished (the concrete members) and others deliberately left as bare concrete walls (the abstract members) for the future owner to finish. The abstract class owns the floor plan and the structural foundation. The subclasses decide what goes on the walls.

What an abstract class is NOT: it is not an interface. An interface is a pure contract — no instance state, no constructor, no concrete implementation prior to C# 8 default interface methods (and even then, default interface methods still cannot hold instance fields). An abstract class can have fields, constructors, concrete methods, and access modifiers on every member. That is a fundamentally different tool. Choosing between them is an architectural decision based on whether shared state and partial implementation matter to your design — not a style preference.

Also worth stating clearly: abstract classes enforce single inheritance. A class can implement any number of interfaces but can only extend one abstract class. That constraint shapes how you model deep type hierarchies and is a key reason why the interface-plus-abstract-base pattern (defining the contract as an interface and providing the optional shared implementation as a separate abstract class) is so common in well-designed C# libraries.

PaymentProcessorBase.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
using System;

// Abstract class — cannot be instantiated with 'new PaymentProcessor()'.
// Models the shared skeleton of every payment processor in the system.
abstract class PaymentProcessor
{
    // Concrete field — shared state every processor needs.
    // Protected so subclasses can read it; private to external callers.
    protected string processorName;

    // Constructor — abstract classes CAN and SHOULD have constructors
    // to initialise shared state. Subclasses invoke this via : base(...).
    // The constructor itself is never called directly from outside.
    protected PaymentProcessor(string name)
    {
        if (string.IsNullOrWhiteSpace(name))
            throw new ArgumentException("Processor name cannot be empty.", nameof(name));
        processorName = name;
    }

    // ABSTRACT method — no body. Every concrete subclass MUST implement this.
    // The compiler refuses to build any subclass that omits it.
    // There is no sensible default for 'how to process a payment' at this level.
    public abstract bool ProcessPayment(decimal amount);

    // ABSTRACT method — every processor validates differently.
    // Card processors check Luhn numbers; PayPal checks account status.
    public abstract bool ValidatePaymentDetails();

    // CONCRETE method — shared logic every processor reuses as-is.
    // Receipt formatting is identical regardless of payment method.
    public void PrintReceipt(decimal amount)
    {
        Console.WriteLine($"[{processorName}] Receipt: ${amount:F2} processed successfully.");
    }

    // CONCRETE method — shared audit logging every processor must emit.
    protected void LogTransaction(decimal amount, bool success)
    {
        string status = success ? "SUCCESS" : "FAILED";
        Console.WriteLine($"[AUDIT] {processorName} | Amount: ${amount:F2} | Status: {status}");
    }
}

// Concrete class — MUST implement every abstract member or the build breaks.
// CS0534 fires at compile time with the exact missing method name.
class CreditCardProcessor : PaymentProcessor
{
    private readonly string cardLastFour;

    public CreditCardProcessor(string cardLastFour)
        : base("Credit Card")  // Calls the abstract class constructor
    {
        this.cardLastFour = cardLastFour;
    }

    public override bool ValidatePaymentDetails()
    {
        // Real implementation: Luhn algorithm check, expiry validation etc.
        bool isValid = !string.IsNullOrWhiteSpace(cardLastFour) && cardLastFour.Length == 4;
        Console.WriteLine($"Validating card ending in {cardLastFour}: {(isValid ? "Valid" : "Invalid")}");
        return isValid;
    }

    public override bool ProcessPayment(decimal amount)
    {
        if (!ValidatePaymentDetails()) return false;
        Console.WriteLine($"Charging ${amount:F2} to card ending {cardLastFour} via Stripe gateway...");
        LogTransaction(amount, true);
        PrintReceipt(amount);
        return true;
    }
}

class PayPalProcessor : PaymentProcessor
{
    private readonly string accountEmail;

    public PayPalProcessor(string email) : base("PayPal")
    {
        accountEmail = email;
    }

    public override bool ValidatePaymentDetails()
    {
        bool isValid = accountEmail.Contains('@');
        Console.WriteLine($"Validating PayPal account {accountEmail}: {(isValid ? "Valid" : "Invalid")}");
        return isValid;
    }

    public override bool ProcessPayment(decimal amount)
    {
        if (!ValidatePaymentDetails()) return false;
        Console.WriteLine($"Redirecting ${amount:F2} to PayPal checkout for {accountEmail}...");
        LogTransaction(amount, true);
        PrintReceipt(amount);
        return true;
    }
}

class Program
{
    static void Main()
    {
        // PaymentProcessor p = new PaymentProcessor(); -- CS0144: cannot instantiate abstract type.

        // Polymorphism in action — same variable type, completely different behaviour.
        PaymentProcessor[] processors =
        {
            new CreditCardProcessor("4242"),
            new PayPalProcessor("user@example.com")
        };

        foreach (PaymentProcessor processor in processors)
        {
            Console.WriteLine("---");
            processor.ProcessPayment(49.99m);
            Console.WriteLine();
        }
    }
}
Output
---
Validating card ending in 4242: Valid
Charging $49.99 to card ending 4242 via Stripe gateway...
[AUDIT] Credit Card | Amount: $49.99 | Status: SUCCESS
[Credit Card] Receipt: $49.99 processed successfully.
---
Validating PayPal account user@example.com: Valid
Redirecting $49.99 to PayPal checkout for user@example.com...
[AUDIT] PayPal | Amount: $49.99 | Status: SUCCESS
[PayPal] Receipt: $49.99 processed successfully.
Abstract Class as a Partially Built House
  • The foundation, plumbing, and wiring are done — those are the concrete methods and fields that every subclass inherits.
  • The kitchen layout and bathroom finish are left to the buyer — those are the abstract methods each subclass must implement.
  • You cannot move into a half-built house — the compiler prevents instantiation of the abstract class.
  • Every buyer (subclass) must complete all unfinished rooms before moving in — or the compiler refuses to let the building certificate through.
  • The architect (abstract class author) controls the floor plan and structural rules; the buyer controls the finishes. The architect does not care which tiles you choose, only that you tile the bathroom.
Production Insight
Virtual methods with empty bodies are a code smell — they move contract enforcement from compile time to runtime, where failures are expensive.
If a subclass must implement the method with its own logic, make it abstract. If the base class provides a real working default that subclasses may optionally improve, make it virtual with a real body.
Rule: abstract = must override (compiler enforced). Virtual = may override (developer's choice). Never use virtual with an empty body when abstract is what you actually mean.
Key Takeaway
The abstract keyword does two things: prevents direct instantiation and enables abstract member declarations that the compiler enforces.
Abstract classes can hold fields, constructors, and concrete methods — this is what distinguishes them from interfaces.
Single inheritance is the constraint — model your hierarchy depth carefully before committing, because changing it later is expensive.

Abstract Properties and the Template Method Pattern — Where Abstract Classes Really Shine

Most developers stop at abstract methods after their first encounter with abstract classes. Abstract properties are equally powerful and solve a different problem: they force subclasses to expose specific data without dictating how that data is stored or computed. One subclass might return a hardcoded string. Another might read from configuration. A third might compute the value dynamically at runtime. The abstract property enforces that the data exists and is accessible — the implementation detail is entirely the subclass's concern.

Beyond individual abstract members, there is a design pattern that abstract classes are practically made for: the Template Method Pattern. The idea is elegantly simple — define the skeleton of an algorithm once in the abstract class (the sequence of steps and how they connect), then let each subclass provide its own implementation of each individual step. The abstract class owns the sequence and can never be overridden out of order. The subclasses own the specifics of each step.

This pattern eliminates an entire category of bugs that appears in teams that share algorithm logic through copy-paste. Someone copies the algorithm, reorders two steps, introduces a subtle race condition or a dependency on uninitialized state, and the bug does not surface for weeks. When the algorithm lives in exactly one place in the abstract class and subclasses only override the individual steps, the sequence is physically impossible to drift.

The report generation pipeline is the archetypal example: every report follows the same pipeline — fetch the data, validate and format it, render it to the output format, then dispatch it to the destination. Each of those four steps varies dramatically by report type, but the pipeline order must never change. Define the pipeline once in the abstract base. Let subclasses own each step.

ReportGeneratorTemplate.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
using System;

// Abstract base — owns the ALGORITHM (the fixed pipeline sequence).
// Subclasses own the STEPS (the domain-specific implementations).
abstract class ReportGenerator
{
    // Abstract property — forces each subclass to declare its own report title.
    // No backing field here. The subclass decides: hardcoded, config-driven, or computed.
    public abstract string ReportTitle { get; }

    // Abstract property — forces each subclass to declare its destination.
    public abstract string OutputDestination { get; }

    // TEMPLATE METHOD — this is the algorithm skeleton.
    // It is concrete and NOT virtual — the pipeline sequence must never change.
    // Marking it sealed in subclasses prevents accidental override further down.
    public void GenerateReport()
    {
        Console.WriteLine($"=== Starting: {ReportTitle} ===");
        Console.WriteLine($"    Destination: {OutputDestination}");

        FetchData();       // Step 1 — abstract, must be provided by subclass
        ValidateData();    // Step 2 — abstract, must be provided by subclass
        FormatData();      // Step 3 — abstract, must be provided by subclass
        RenderOutput();    // Step 4 — abstract, must be provided by subclass

        LogCompletion();   // Step 5 — concrete shared logic, runs for every report

        Console.WriteLine($"=== Complete: {ReportTitle} ===\n");
    }

    // Abstract steps — each subclass provides its own domain-specific implementation.
    // Protected because they are implementation details, not public API surface.
    protected abstract void FetchData();
    protected abstract void ValidateData();
    protected abstract void FormatData();
    protected abstract void RenderOutput();

    // CONCRETE method — shared infrastructure every report type needs.
    // Subclasses inherit this without writing a single line.
    private void LogCompletion()
    {
        Console.WriteLine($"  [LOG] {ReportTitle} pipeline completed at {DateTime.UtcNow:HH:mm:ss} UTC.");
    }
}

class SalesReport : ReportGenerator
{
    // Computed property — title is derived from the report type, not stored.
    public override string ReportTitle => "Monthly Sales Summary";
    public override string OutputDestination => "finance-team@company.com";

    protected override void FetchData()
        => Console.WriteLine("  [Sales] Querying orders table from SQL Server (last 30 days)...");

    protected override void ValidateData()
        => Console.WriteLine("  [Sales] Asserting no null revenue rows and positive unit counts...");

    protected override void FormatData()
        => Console.WriteLine("  [Sales] Aggregating totals by region, product category, and rep...");

    protected override void RenderOutput()
        => Console.WriteLine("  [Sales] Rendering to .xlsx with pivot tables and conditional formatting.");
}

class SecurityAuditReport : ReportGenerator
{
    public override string ReportTitle => "Weekly Security Audit Log";
    public override string OutputDestination => "compliance@company.com (encrypted)";

    protected override void FetchData()
        => Console.WriteLine("  [Audit] Streaming from distributed CloudWatch event log...");

    protected override void ValidateData()
        => Console.WriteLine("  [Audit] Checking for gaps in event sequence and tamper indicators...");

    protected override void FormatData()
        => Console.WriteLine("  [Audit] Filtering to WARN/ERROR/CRITICAL severity only...");

    protected override void RenderOutput()
        => Console.WriteLine("  [Audit] Encrypting PDF and attaching digital signature for compliance.");
}

class Program
{
    static void Main()
    {
        // Different types — same pipeline entry point.
        // The abstract base guarantees the order. Subclasses guarantee the content.
        ReportGenerator[] scheduledReports =
        {
            new SalesReport(),
            new SecurityAuditReport()
        };

        foreach (ReportGenerator report in scheduledReports)
        {
            report.GenerateReport();
        }
    }
}
Output
=== Starting: Monthly Sales Summary ===
Destination: finance-team@company.com
[Sales] Querying orders table from SQL Server (last 30 days)...
[Sales] Asserting no null revenue rows and positive unit counts...
[Sales] Aggregating totals by region, product category, and rep...
[Sales] Rendering to .xlsx with pivot tables and conditional formatting.
[LOG] Monthly Sales Summary pipeline completed at 14:22:07 UTC.
=== Complete: Monthly Sales Summary ===
=== Starting: Weekly Security Audit Log ===
Destination: compliance@company.com (encrypted)
[Audit] Streaming from distributed CloudWatch event log...
[Audit] Checking for gaps in event sequence and tamper indicators...
[Audit] Filtering to WARN/ERROR/CRITICAL severity only...
[Audit] Encrypting PDF and attaching digital signature for compliance.
[LOG] Weekly Security Audit Log pipeline completed at 14:22:07 UTC.
=== Complete: Weekly Security Audit Log ===
Seal the Template Method in Subclasses If the Hierarchy Goes Deep
If your abstract class hierarchy has more than one level — meaning an intermediate abstract class sits between the base and the concrete implementations — consider marking the template method sealed in the intermediate class. sealed on an override says 'this method cannot be overridden any further down the chain.' It signals clearly that the algorithm pipeline is fixed, prevents accidental breakage in leaf classes, and makes the design intent explicit to the next engineer who reads the code. For shallow hierarchies (one abstract base, concrete subclasses only), sealed on the template method in the base is redundant since concrete classes cannot override non-virtual methods. It becomes meaningful and valuable once you have intermediate abstract classes that add shared logic between the base and the leaves.
Production Insight
The Template Method Pattern prevents algorithm drift across teams — the pipeline lives in one file, in one method, and cannot be accidentally reordered by a developer who copies and modifies a subclass.
Abstract properties are the right mechanism when each subclass must declare a configuration value but the abstract class cannot know how that value is produced.
Rule: define the pipeline once in the abstract base. Enforce the sequence with a non-virtual template method. Let subclasses own the steps. Keep the shared infrastructure (logging, error handling, timing) in concrete methods on the base.
Key Takeaway
Abstract properties force subclasses to declare data without dictating how it is produced — field, config, or computed value is the subclass's choice.
The Template Method Pattern is the abstract class's most powerful use case: fixed pipeline sequence in one place, variable step implementations in many places.
Keep the template method non-virtual (or sealed in derived classes for deep hierarchies) to prevent the pipeline sequence from ever drifting.

Abstract Class vs Interface — Choosing the Right Tool for the Right Job

This is the question every C# interview panel will ask, and the honest answer is more nuanced than most tutorials admit. The short version: use an interface when you are defining a capability that unrelated types might share. Use an abstract class when you are defining a family of related types that share both a contract and some real, concrete implementation.

Consider IDisposable. A FileStream, a SqlConnection, and a custom TempFileManager can all implement it. They are completely unrelated types that happen to share one capability — the ability to release unmanaged resources. An abstract class would be wrong here because these types share no common ancestry, no common state, and no common constructor logic. The interface is the right model.

Now consider a payment processing system. A CreditCardProcessor, a PayPalProcessor, and a CryptoProcessor are all payment processors — they share a processor name field, a constructor that validates the name, a receipt printing method, and an audit logging method. They share a family relationship. The abstract class is the right model because it carries that shared state and shared logic. The interface is still useful alongside it — defining IPaymentProcessor for the pure contract that external code depends on, while the abstract class provides the shared infrastructure that all implementations benefit from.

C# 8 default interface methods blurred this line, but they are best used for backward-compatible API evolution (adding a new method to a public interface without breaking existing implementors), not as a replacement for abstract class design. Default interface methods still cannot hold instance fields or constructors. They cannot maintain per-instance state. The moment you need state shared across methods on an object, you need an abstract class.

A practical heuristic: if you are writing the word Base in your class name — ControllerBase, DbContext, HttpMessageHandler — and that class has concrete methods with real working logic, you almost certainly want it to be abstract. The Base suffix is the convention signaling 'this is meant to be extended, not used directly.'

AbstractVsInterface.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
using System;
using System.Collections.Generic;

// INTERFACE — defines a capability.
// Any type can be ITaxable regardless of family or hierarchy.
// No shared state, no constructors, pure contract.
interface ITaxable
{
    decimal CalculateTax(decimal baseAmount);
    string TaxJurisdiction { get; }
}

// ABSTRACT CLASS — defines a family.
// All products share a name, price, category, and pricing formula.
// Shared logic lives here. Unique logic is abstract.
abstract class Product : ITaxable
{
    // Shared fields — every product has these.
    public string ProductName { get; }
    public decimal BasePrice { get; }
    public string Category { get; }

    // Constructor — initialises shared state for every product.
    // Subclasses call this via : base(...). The base validates common rules.
    protected Product(string name, decimal price, string category)
    {
        if (price <= 0) throw new ArgumentException("Price must be positive.", nameof(price));
        ProductName = name;
        BasePrice = price;
        Category = category;
    }

    // ABSTRACT — every product type calculates its own category discount differently.
    // Electronics get percentage discounts; groceries get fixed promotional amounts.
    public abstract decimal GetDiscount();

    // ABSTRACT — ITaxable contract, but the implementation varies by product type.
    // Some products are VAT-exempt; others attract import duties on top of VAT.
    public abstract decimal CalculateTax(decimal baseAmount);

    // ABSTRACT property from ITaxable — subclass declares its tax jurisdiction.
    public abstract string TaxJurisdiction { get; }

    // CONCRETE — the final price formula is identical for every product.
    // We own this method in the abstract class so it can never drift.
    public decimal GetFinalPrice()
    {
        decimal afterDiscount = BasePrice - GetDiscount();
        decimal tax = CalculateTax(afterDiscount);
        return afterDiscount + tax;
    }

    public override string ToString()
        => $"{ProductName,-25} Base: ${BasePrice,7:F2} | "
         + $"Discount: -${GetDiscount(),5:F2} | "
         + $"Tax ({TaxJurisdiction}): ${CalculateTax(BasePrice - GetDiscount()),6:F2} | "
         + $"Final: ${GetFinalPrice(),7:F2}";
}

class ElectronicProduct : Product
{
    public ElectronicProduct(string name, decimal price)
        : base(name, price, "Electronics") { }

    // 10% loyalty discount on all electronics.
    public override decimal GetDiscount() => BasePrice * 0.10m;

    // 20% VAT — standard UK rate.
    public override decimal CalculateTax(decimal baseAmount) => baseAmount * 0.20m;

    public override string TaxJurisdiction => "UK VAT 20%";
}

class GroceryProduct : Product
{
    public GroceryProduct(string name, decimal price)
        : base(name, price, "Grocery") { }

    // Fixed £2 promotional discount.
    public override decimal GetDiscount() => 2.00m;

    // Most groceries are VAT-zero rated in the UK.
    public override decimal CalculateTax(decimal baseAmount) => 0.00m;

    public override string TaxJurisdiction => "UK VAT 0% (Zero-rated)";
}

class Program
{
    static void Main()
    {
        var inventory = new List<Product>
        {
            new ElectronicProduct("Wireless Headphones", 120.00m),
            new ElectronicProduct("USB-C Docking Station", 89.99m),
            new GroceryProduct("Organic Coffee Beans", 18.50m),
            new GroceryProduct("Sourdough Bread", 4.75m)
        };

        Console.WriteLine("=== Product Pricing Summary ===");
        foreach (Product item in inventory)
            Console.WriteLine(item);

        // The abstract class satisfies the ITaxable interface — so this works.
        // Any code that expects ITaxable can receive any Product subtype.
        Console.WriteLine("\n=== ITaxable Interface Polymorphism ===");
        ITaxable taxableItem = new ElectronicProduct("Smart Watch", 249.99m);
        Console.WriteLine($"Smart Watch tax ({taxableItem.TaxJurisdiction}): "
            + $"${taxableItem.CalculateTax(249.99m):F2}");
    }
}
Output
=== Product Pricing Summary ===
Wireless Headphones Base: $ 120.00 | Discount: -$ 12.00 | Tax (UK VAT 20%): $ 21.60 | Final: $ 129.60
USB-C Docking Station Base: $ 89.99 | Discount: -$ 9.00 | Tax (UK VAT 20%): $ 16.20 | Final: $ 97.19
Organic Coffee Beans Base: $ 18.50 | Discount: -$ 2.00 | Tax (UK VAT 0% (Zero-rated)): $ 0.00 | Final: $ 16.50
Sourdough Bread Base: $ 4.75 | Discount: -$ 2.00 | Tax (UK VAT 0% (Zero-rated)): $ 0.00 | Final: $ 2.75
=== ITaxable Interface Polymorphism ===
Smart Watch tax (UK VAT 20%): $50.00
The Answer That Shows Architectural Thinking
When an interviewer asks 'abstract class versus interface', do not just list feature differences. That is the junior answer. The senior answer sounds like this: 'I use an interface when I need a capability contract across unrelated types — types that share a behaviour but not a family relationship or shared state. I use an abstract class when I am modelling a family of related types that share real implementation logic, constructors, and state. In practice I often combine them: define the interface for the capability, provide an optional abstract base class that implements the shared infrastructure, and let consumers choose whether to use the base or implement the interface directly from their own hierarchy. That pattern gives you the flexibility of interfaces and the convenience of shared implementation without forcing either.'
Production Insight
An abstract class with zero fields, no constructor logic, and every member abstract is a verbose interface that burns the single-inheritance slot for no benefit — refactor it to an interface immediately.
Combining abstract class and interface (Product : ITaxable) is a powerful pattern: the interface defines the capability contract for external consumers, the abstract class provides the family implementation.
Rule: interface = capability across unrelated types. Abstract class = shared implementation within a family. When in doubt, start with an interface and add an abstract base class only when shared state and concrete logic appear.
Key Takeaway
Interface defines a capability contract for unrelated types. Abstract class defines a family contract that carries shared state and implementation.
C# 8 default interface methods do not replace abstract classes — they cannot hold instance fields or constructors, so they cannot maintain per-object state.
If your class name ends in Base and it has concrete methods with real logic, it should almost certainly be marked abstract.
● Production incidentPOST-MORTEMseverity: high

Half-Built Report Generator Ships to Production — NullReferenceException in Nightly Batch at 3 AM

Symptom
Nightly batch job fails at 3:17 AM with NullReferenceException deep in the report rendering pipeline. The ExcelReport class was instantiated without any compiler or runtime error because ReportGenerator was a plain base class, not an abstract class. The crash only occurred when the missing FetchData() step left the data source as null, and the next step in the pipeline tried to call methods on it.
Assumption
The on-call engineer assumed a database connectivity issue or a timeout in the ETL pipeline. They spent four hours checking network logs, database connection pool exhaustion, disk space on the report server, and firewall rules between the application tier and the data warehouse — none of which was relevant. The database was available the entire time.
Root cause
The ReportGenerator base class was not marked abstract. It defined FetchData(), FormatData(), RenderOutput(), and GenerateHeader() as virtual methods with empty bodies — they compiled fine, ran fine, and returned void while doing absolutely nothing. The developer who created ExcelReport overrode RenderOutput() and FormatData() but forgot FetchData() and GenerateHeader(). The compiler accepted this because virtual methods never require an override — they are optional by definition. At runtime, the empty FetchData() left the internal data source reference as null. The pipeline moved to the next step, which tried to call Count() on that null reference, and NullReferenceException was thrown in a location that looked completely unrelated to the missing override three levels up.
Fix
1. Marked ReportGenerator as abstract and converted FetchData(), FormatData(), RenderOutput(), and GenerateHeader() to abstract methods. Any concrete subclass that forgets to implement any of these now fails to compile — the build breaks with CS0534 naming the exact missing method, before the code ever reaches the CI runner's test phase. 2. Added a sealed template method GenerateReport() that calls the four steps in a fixed, enforced order. Sealed means no subclass can override the pipeline sequence — only the individual steps. 3. Added a CI build step using a Roslyn analyzer that fails the pipeline if any concrete class in the assembly has unresolved abstract member obligations. 4. Added unit tests that instantiate every concrete ReportGenerator subclass with mocked dependencies and call GenerateReport(), asserting that all four steps execute and produce non-null output.
Key lesson
  • Virtual methods with empty bodies are a code smell that shifts contract enforcement from compile time to runtime. If a subclass must implement the method, make it abstract. If it is optional, make it virtual with a real, working default implementation.
  • Abstract classes enforce contracts at compile time — the build itself catches missing implementations before any tests run, before any deployment, and certainly before any nightly batch job executes at 3 AM.
  • Always test concrete subclass instantiation in CI. Do not trust that developers will read the documentation or spot missing overrides in code review. Make the compiler do that job.
Production debug guideCommon symptoms when abstract classes are misused or absent in production C# systems. Most of these have a clear compiler error — pay attention to it rather than working around it.5 entries
Symptom · 01
CS0144: Cannot create an instance of the abstract type or interface
Fix
You are trying to call new directly on an abstract class. You need to instantiate a concrete subclass instead. If no concrete subclass exists yet, you need to create one. If you genuinely need to remove the constraint, rethink whether the type should be abstract at all — but usually the right answer is to create the concrete type.
Symptom · 02
CS0534: MyClass does not implement inherited abstract member BaseClass.MethodName()
Fix
The concrete subclass is missing one or more abstract member implementations. Check the base class for every member marked abstract — methods, properties, and indexers. Either implement all of them in the subclass or mark the subclass abstract itself to defer the obligation further down the chain. The compiler error names the exact missing member, so start there.
Symptom · 03
NullReferenceException at runtime in a method that should have been overridden by a subclass
Fix
The base class almost certainly defined the method as virtual with an empty body instead of abstract. The subclass forgot to override it, the empty base implementation ran silently, and the output it was supposed to produce was never created — leaving some downstream reference as null. Convert the virtual method to abstract to shift this error from runtime to compile time. This is always the right fix when a subclass override is not optional.
Symptom · 04
Deep inheritance chain where intermediate abstract classes each add abstract members, and the final concrete class has fifteen abstract methods to implement
Fix
The hierarchy has grown too deep and the abstract contract has become unmanageable. This is a design problem, not a compiler problem. Consider composition over inheritance — extract shared logic into injected service classes and use interfaces for the contract. Abstract classes work cleanly at one or two levels of depth. Beyond that, the complexity cost typically exceeds the benefit.
Symptom · 05
A developer marks a method override as sealed in a subclass, but another developer later tries to override it further down the hierarchy and gets CS0239
Fix
This is the sealed keyword working exactly as intended. Sealed prevents further overrides on a specific method. If you need to allow further customization, remove sealed from the intermediate class. If you need to prevent it (for example, to protect the template method pattern's sequence), the sealed is correct and the design should not be changed.
★ Abstract Class Issues Quick DiagnosisSymptom-to-fix commands for production C# abstract class failures.
CS0144 compile error — cannot instantiate abstract type
Immediate action
Find which class is abstract and locate all concrete subclasses to instantiate instead.
Commands
grep -rn 'abstract class' src/ | grep -v '//\|partial'
grep -rn ': PaymentProcessor\|: ReportGenerator' src/ --include='*.cs'
Fix now
Instantiate the concrete subclass instead of the abstract base. If no concrete subclass exists yet, create one. If every intended use is through a concrete type, verify you have at least one concrete implementation registered in your DI container.
CS0534 compile error — subclass does not implement abstract member+
Immediate action
List all abstract members in the base class and compare against the overrides in the subclass.
Commands
grep -n 'abstract ' src/PaymentProcessorBase.cs
grep -n 'override' src/CreditCardProcessor.cs
Fix now
Implement every abstract member from the base class in the concrete subclass. The diff between the two grep outputs shows exactly what is missing. Alternatively, mark the subclass abstract to defer the obligation to the next concrete class.
Runtime NullReferenceException in a step that should have been provided by a subclass override+
Immediate action
Check whether the base class method is virtual with an empty body rather than abstract.
Commands
grep -n 'virtual' src/ReportGenerator.cs
grep -n 'override' src/ExcelReport.cs
Fix now
Compare the two outputs. Any virtual method in the base that does not appear as an override in the subclass is the risk. Convert virtual methods with empty bodies to abstract methods. This converts a runtime NullReferenceException into a compile-time CS0534 — always a better trade.
Abstract Class vs Interface vs Regular Base Class in C#
Feature / ConcernAbstract ClassInterfaceRegular Base Class
Can be instantiated directly?No — compiler error CS0144 if you tryNo — interfaces are never instantiated directlyYes — nothing prevents new BaseClass() unless you add a protected constructor
Can hold instance fields?Yes — shared state across all subclassesNo — interfaces cannot have instance fields in any C# versionYes — same as abstract class
Can have a constructor?Yes — called by subclasses via : base(...) to initialise shared stateNo — interfaces have no constructorsYes — but nothing prevents direct instantiation unless you use protected
Can have concrete methods?Yes — freely mixed with abstract membersYes in C# 8+ via default interface methods, but no instance state accessYes — all methods are concrete by default
Contract enforcement?At compile time — CS0534 if any abstract member is missing in a concrete subclassAt compile time — CS0535 if any interface member is missingNone — missing overrides of virtual methods are silently ignored
Supports multiple implementation?No — a class can extend only one abstract classYes — a class can implement any number of interfacesNo — single inheritance applies to all C# classes
Best suited forA family of related types sharing state and a partial implementationA capability that unrelated types might shareSharing concrete implementation without enforcing a contract
Typical naming conventionAppendBase suffix: ReportGeneratorBase, PaymentProcessorBasePrefix with I: IDisposable, IPaymentProcessor, ITaxableNo specific convention — but often signals missing abstract keyword

Key takeaways

1
An abstract class is a partially built type
it contributes shared state, shared constructors, and concrete logic while mandating that subclasses complete the unfinished parts before the type is usable. Use it when you have a family of related types, not just a shared capability.
2
The Template Method Pattern is the abstract class's most powerful use case
define the algorithm sequence once in a non-virtual template method, make every step abstract, and let subclasses provide domain-specific implementations. The sequence can never drift. The implementations can evolve freely.
3
If your abstract class has no fields, no constructor logic, and every member is abstract, you have written a verbose interface that burns the single-inheritance slot
refactor it to an actual interface immediately.
4
Abstract classes enforce correctness at compile time. The moment you mark a class abstract, the compiler becomes your quality gate
CS0534 catches missing implementations before any test runs, before any deployment, and certainly before any nightly batch job fails at 3 AM.

Common mistakes to avoid

4 patterns
×

Using virtual methods with empty bodies instead of abstract methods

Symptom
The subclass compiles fine without overriding the method. At runtime the empty base implementation runs silently, leaves shared state in an invalid condition (often null), and a NullReferenceException fires several calls later in a location that looks completely unrelated to the missing override. The on-call engineer debugs the wrong component for hours.
Fix
If a subclass must implement the method with its own domain-specific logic, make it abstract. Empty virtual methods provide no value and actively mislead the compiler into thinking the class is complete. Virtual should only be used when the base class provides a real, working default that most subclasses will use as-is — and some may optionally specialise.
×

Writing an abstract class with zero fields, no constructor logic, and every member abstract

Symptom
The abstract class is a verbose interface that also consumes the single-inheritance slot. Consumers cannot implement it alongside another base class, and you gain none of the abstract class benefits because there is no shared state or concrete logic to share.
Fix
Refactor to an interface. If you also want to provide optional shared implementation, add a separate abstract base class that implements the interface with shared logic. Consumers can then choose: implement the interface from their own hierarchy, or extend the abstract base to get the shared implementation for free.
×

Leaving an intermediate abstract class in the hierarchy without implementing some abstract members, expecting the compile error to surface in the right place

Symptom
An intermediate abstract class that does not implement all parent abstract members compiles fine because it is itself abstract. The obligation is deferred. A developer adds a new concrete class inheriting from the intermediate class, forgets about the unimplemented grandparent abstracts, and gets a CS0534 error that names methods they did not know existed.
Fix
Document explicitly which abstract methods an intermediate abstract class defers to its subclasses. A comment above the class listing the inherited abstract members it does not implement is not bureaucracy — it is the single most useful piece of information for the next developer who creates a concrete subclass. Consider reducing hierarchy depth as an alternative.
×

Overusing abstract classes for deep inheritance hierarchies instead of using composition

Symptom
Three or four levels of abstract classes where the leaf concrete class has fifteen or twenty abstract methods to implement. Adding a new concrete type requires understanding the entire chain. Tests are difficult to set up because the constructor chain is long and every level may have mandatory dependencies.
Fix
Abstract classes work cleanly at one to two levels of depth. Beyond that, the maintenance cost typically exceeds the benefit. Consider extracting shared behaviour into injected service classes and using interfaces for the contracts. The Strategy and Decorator patterns solve many problems that deep abstract hierarchies attempt to solve, with far lower coupling.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Can an abstract class have a constructor, and if so, what is it used for...
Q02SENIOR
What is the difference between an abstract method and a virtual method i...
Q03SENIOR
If a class inherits from an abstract class but does not implement all th...
Q04SENIOR
You are designing a plugin system where third-party developers write rep...
Q01 of 04JUNIOR

Can an abstract class have a constructor, and if so, what is it used for since you cannot instantiate the abstract class directly?

ANSWER
Yes, abstract classes can and should have constructors when they hold shared state. The constructor is called by concrete subclasses via the : base(...) initialiser when they are instantiated. The abstract class constructor is responsible for initialising shared fields that every subclass needs — things like a processor name, a logger instance, or a configuration object that all implementations depend on. For example, a PaymentProcessor abstract class might have a constructor that takes the processor name, validates it is not empty, and stores it in a protected field. Every concrete processor — CreditCard, PayPal, Crypto — calls that constructor through their own : base(name) call. The abstract class itself is never instantiated directly, but its constructor executes as part of every subclass instantiation chain. This guarantees shared state is always initialised correctly, regardless of which concrete type is being created.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
Can an abstract class have concrete non-abstract methods in C#?
02
Can an abstract class implement an interface in C#?
03
What happens if I mark a class abstract but add no abstract members?
04
Can I use sealed with abstract class members in C#?
🔥

That's OOP in C#. Mark it forged?

6 min read · try the examples if you haven't

Previous
Polymorphism in C#
5 / 10 · OOP in C#
Next
Properties in C#