Virtual, Override and Sealed — Customising Inherited Behaviour

By default, methods in a base class cannot be changed by derived classes — a derived class can add new methods but not alter the base’s existing ones. The virtual keyword on a base method says “this can be replaced.” The override keyword on a derived method says “this replaces the virtual.” This is the mechanism that lets you write code against a base type, pass in a derived type at runtime, and have the derived type’s overridden behaviour execute — that is polymorphism.

Virtual and Override

public class Notification
{
    public string RecipientId { get; }
    public string Message     { get; }

    public Notification(string recipientId, string message)
    {
        RecipientId = recipientId;
        Message     = message;
    }

    // virtual — derived classes MAY override this
    public virtual async Task SendAsync()
    {
        Console.WriteLine($"[Base] Sending to {RecipientId}: {Message}");
        await Task.CompletedTask;
    }

    // virtual with base implementation — derived class can call it with base.
    public virtual string FormatMessage()
        => $"Notification for {RecipientId}: {Message}";
}

// ── Email notification ────────────────────────────────────────────────────
public class EmailNotification : Notification
{
    public string EmailAddress { get; }

    public EmailNotification(string recipientId, string email, string message)
        : base(recipientId, message)
    {
        EmailAddress = email;
    }

    // override — replaces the base implementation
    public override async Task SendAsync()
    {
        Console.WriteLine($"[Email] Sending to {EmailAddress}: {Message}");
        await Task.Delay(10);  // simulate async email send
    }

    // Override and CALL the base implementation first
    public override string FormatMessage()
        => $"EMAIL: {base.FormatMessage()}";   // base. calls the parent version
}

// ── SMS notification ──────────────────────────────────────────────────────
public class SmsNotification : Notification
{
    public string PhoneNumber { get; }

    public SmsNotification(string recipientId, string phone, string message)
        : base(recipientId, message) => PhoneNumber = phone;

    public override async Task SendAsync()
    {
        var shortened = Message.Length > 160 ? Message[..157] + "..." : Message;
        Console.WriteLine($"[SMS] {PhoneNumber}: {shortened}");
        await Task.Delay(5);
    }
}

// Usage
Notification email = new EmailNotification("u1", "a@b.com", "Welcome!");
Notification sms   = new SmsNotification("u2", "+447700900123", "Your code: 1234");

await email.SendAsync();   // "[Email] Sending to a@b.com: Welcome!"
await sms.SendAsync();     // "[SMS] +447700900123: Your code: 1234"
Note: The override keyword changes the method for polymorphic dispatch — when you call a virtual method through a base-type reference, the runtime looks at the actual object type and calls the most-derived override it finds. Without virtual/override, C# uses static dispatch — the method called depends on the variable’s declared type, not the actual object type. This distinction is critical: it is what makes polymorphism work, and it is the difference between override (runtime dispatch) and new (hide, compile-time dispatch).
Tip: Use base.MethodName() when you want to extend (not completely replace) the base behaviour. The pattern is: call base first, then add derived-specific work. Or: do derived-specific preparation, then call base. This is how ASP.NET Core’s OnActionExecuting filters work — you call base.OnActionExecuting(context) to ensure the framework’s own filter logic runs alongside yours.
Warning: Never call a virtual method from a constructor. If a derived class overrides the virtual method and the base constructor calls it, the override runs before the derived class’s constructor body has executed — meaning the derived class’s fields may not be initialised yet. This is a subtle but real source of bugs: NullReferenceException in the override because derived fields are still at their default values. Call virtual methods only after construction is complete.

Sealed — Preventing Further Overriding

public class CriticalAlertNotification : EmailNotification
{
    public CriticalAlertNotification(string recipientId, string email, string message)
        : base(recipientId, email, $"🚨 CRITICAL: {message}") { }

    // sealed override — no further class can override this
    public sealed override async Task SendAsync()
    {
        Console.WriteLine("[CRITICAL ALERT] Sending with high priority...");
        await base.SendAsync();   // still calls EmailNotification.SendAsync
    }
}

// sealed class — the entire class cannot be inherited
public sealed class PasswordHasher
{
    public string Hash(string password) => BCrypt.Net.BCrypt.HashPassword(password);
    public bool   Verify(string password, string hash) => BCrypt.Net.BCrypt.Verify(password, hash);
    // No one can inherit from PasswordHasher and override the hash algorithm
}

Hiding vs Overriding — new Keyword

public class Animal
{
    public virtual string Sound() => "...";
}

// HIDING with new — replaces the method only for the derived declared type
public class Cat : Animal
{
    public new string Sound() => "Meow";   // hides Animal.Sound (not polymorphic!)
}

// OVERRIDING with override — polymorphic replacement
public class Dog : Animal
{
    public override string Sound() => "Woof";   // replaces for ALL references
}

Animal cat = new Cat();
Animal dog = new Dog();

Console.WriteLine(cat.Sound());  // "..." — Cat.Sound() is HIDDEN, not overridden!
Console.WriteLine(dog.Sound());  // "Woof" — Dog.Sound() is overridden — polymorphic

Common Mistakes

Mistake 1 — Using new instead of override (breaks polymorphism)

❌ Wrong — hiding does not participate in polymorphic dispatch:

public new string FormatMessage() => "...";  // hides — not polymorphic

✅ Correct for polymorphism:

public override string FormatMessage() => "...";  // overrides — polymorphic

Mistake 2 — Calling a virtual method in a constructor

❌ Wrong — derived override may run before derived fields are initialised:

public Base() { Log(); }        // Log() is virtual — dangerous!
public override void Log() { Console.WriteLine(_derivedField); }  // _derivedField is null!

🧠 Test Yourself

What is the difference between using new and override to define a method with the same name in a derived class?