Abstract Classes — Enforcing Structure in Derived Types

An abstract class is a base class that cannot be instantiated directly — it exists only to be inherited. It can contain both abstract members (which derived classes must implement) and non-abstract members (shared implementation). Abstract classes are the right tool when you have a family of related types that share substantial common logic but differ in one or more specific operations. They are the backbone of the Template Method design pattern — the base class defines the structure of an algorithm, derived classes fill in the details.

Abstract Class — Template Method Pattern

// ── Abstract base — defines the structure, requires derived details ───────
public abstract class NotificationSender
{
    // ── Abstract members — MUST be implemented by derived classes ─────────
    protected abstract string SenderType { get; }
    protected abstract Task<bool> DeliverAsync(string recipient, string subject, string body);

    // ── Non-abstract shared logic — available to all derived classes ──────
    public async Task<NotificationResult> SendAsync(
        string recipient,
        string subject,
        string body)
    {
        if (string.IsNullOrWhiteSpace(recipient))
            return NotificationResult.Fail("Recipient is required");

        Console.WriteLine($"[{SenderType}] Sending to {recipient}...");

        bool success = await DeliverAsync(recipient, subject, body);

        var result = success
            ? NotificationResult.Ok()
            : NotificationResult.Fail($"Delivery failed via {SenderType}");

        Console.WriteLine($"[{SenderType}] Result: {result.Message}");
        return result;
    }

    // Non-abstract virtual — derived classes CAN override but don't have to
    public virtual string FormatSubject(string subject) => $"[{SenderType}] {subject}";
}

// ── Concrete derived class — must implement all abstract members ───────────
public class EmailSender : NotificationSender
{
    private readonly ISmtpClient _smtp;

    public EmailSender(ISmtpClient smtp) => _smtp = smtp;

    protected override string SenderType => "Email";   // abstract property

    protected override async Task<bool> DeliverAsync(
        string recipient, string subject, string body)
    {
        await _smtp.SendAsync(recipient, subject, body);
        return true;
    }
}

// ── Another concrete implementation ────────────────────────────────────────
public class SmsSender : NotificationSender
{
    private readonly ISmsGateway _sms;

    public SmsSender(ISmsGateway sms) => _sms = sms;

    protected override string SenderType => "SMS";

    protected override async Task<bool> DeliverAsync(
        string recipient, string subject, string body)
    {
        // SMS: combine subject and body, truncate to 160 chars
        string message = $"{subject}: {body}";
        if (message.Length > 160) message = message[..157] + "...";
        await _sms.SendAsync(recipient, message);
        return true;
    }

    // Override the optional virtual method
    public override string FormatSubject(string subject) => subject.ToUpper();
}

// var emailSender = new NotificationSender();   // compile error — abstract!
var email = new EmailSender(smtp);
var result = await email.SendAsync("alice@example.com", "Welcome", "Thanks for joining!");
Note: Abstract classes can have constructors even though you cannot directly instantiate them. The constructor is called by derived class constructors via : base(). This lets abstract classes enforce invariants at the base level — every concrete subclass that calls the abstract class’s constructor will be validated by it. This is the correct place to put validation logic that applies to every type in the hierarchy.
Tip: Use abstract classes when: (1) the derived types share a significant amount of code that you do not want to duplicate, (2) there is a natural “template” structure where the base defines the flow and derived types fill in steps, or (3) the base class needs to maintain state (fields/properties) that all derived types use. Use interfaces (Chapter 6) instead when you just want to define a contract with no shared implementation, or when a type needs to satisfy multiple unrelated contracts.
Warning: Do not put too much in an abstract base class — it creates fragile base class syndrome, where every change to the abstract base potentially breaks all derived classes. Abstract classes work best when the shared behaviour is stable and the variation is genuinely limited to a few abstract methods. If the abstract class grows to dozens of methods with complex interdependencies, consider splitting it into a smaller abstract base plus injectable helper services.

Abstract vs Non-Abstract Members

Member Type Can Be Abstract? Derived Class Must Implement?
Method Yes Yes — must override
Property Yes Yes — must override
Field No N/A
Constructor No N/A — called via base()
Static member No N/A
Event Yes Yes — must override

Common Mistakes

Mistake 1 — Trying to instantiate an abstract class

❌ Wrong — compile error:

var sender = new NotificationSender();   // Cannot create instance of abstract class!

✅ Correct — instantiate a concrete derived class:

NotificationSender sender = new EmailSender(smtp);   // ✓ derived type stored in base reference

Mistake 2 — Not implementing all abstract members in a derived class

❌ Wrong — compile error if any abstract member is not overridden:

public class PushSender : NotificationSender
{
    // forgot to implement abstract members — compile error!
}

✅ Correct — implement every abstract member, or mark the derived class abstract too.

🧠 Test Yourself

In the NotificationSender example, the SendAsync method is non-abstract but calls the abstract DeliverAsync. Why is this a good design?