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!