Polymorphism in C# — Method Hiding Broke Payment Fees
- Polymorphism isn't syntax — it's a design decision. The payoff is that adding a new type (payment method, exporter, notification channel) means writing one new class, not editing existing ones.
- Method hiding with 'new' compiles and runs but silently breaks polymorphism the moment you use a base-type reference — always check whether 'virtual' + 'override' was what you actually meant.
- Interface-based polymorphism is the workhorse of real production code: it decouples consumers from implementations, makes unit testing with mocks possible, and enables dependency injection.
- Polymorphism is one interface, many implementations — the right method runs based on the actual object type.
- Compile-time polymorphism (method overloading) is resolved by the compiler using parameter signatures.
- Runtime polymorphism (virtual/override) is resolved by the CLR via vtables — behaviour swaps without changing calling code.
- Interface polymorphism decouples consumers from implementations — the foundation of DI and mocking in production C#.
- Biggest mistake: using
newinstead ofoverride— it compiles, but base-type references call the base method, not yours. - Performance overhead: virtual dispatch adds ~1–3 ns per call; inlining is disabled for virtual methods.
Production Incident
List<PaymentMethod> were charged the base flat fee (25p) instead of the correct 1.5% fee. Invoices were wrong, customers were undercharged for months.new would be called polymorphically — same as override.new) does NOT participate in runtime polymorphism. When the list held base-type references, the base class method ran instead of the derived one.virtual to the base method and use override in all derived classes. Re-run all payment calculations to reconcile the undercharges.new on a method that is meant to be overridden — it breaks polymorphic dispatch silently.Always test polymorphic behaviour through base-type references, not only through derived-type variables.Add a unit test that exercises all payment types via a list of the base type to catch hiding early.Production Debug GuideSymptom → Action guide for the three most common polymorphism failures in production
new instead of override. Look for compiler warning CS0108. Remove new and add virtual to the base method, then override in the derived class.void IExporter.Export()). If the call is through the concrete type (not the interface), explicit implementations are hidden. Cast to the interface or make the implementation public/implicit.Most C# developers can tell you what polymorphism is. Far fewer can tell you why it exists or when it actually saves you from a mess. That gap — knowing the word but not feeling it — is what turns junior code into a tangle of if/else chains and switch statements that grow without mercy every time a new requirement lands. Polymorphism is what keeps that from happening.
Compile-Time Polymorphism: Method Overloading and Why It's the Simpler Half
Compile-time polymorphism — also called static polymorphism — is resolved by the compiler before your program even runs. In C#, you get this through method overloading: multiple methods sharing the same name but with different parameter signatures. The compiler looks at the arguments you pass and picks the right version. No guessing at runtime.
This is useful when you want one logical operation to handle different input types or different numbers of arguments without forcing the caller to remember six different method names. Think of a logging utility that can accept a plain string, a formatted message with arguments, or an exception object — same concept, different inputs.
The key rule: the methods must differ in the number or type of parameters. Return type alone is not enough — the compiler won't be able to distinguish them at the call site and you'll get a compile error.
using System; public class AuditLogger { // Overload 1: Simple message log public void Log(string message) { Console.WriteLine($"[INFO] {DateTime.Now:HH:mm:ss} — {message}"); } // Overload 2: Message with a severity level — same name, different signature public void Log(string message, string severity) { Console.WriteLine($"[{severity.ToUpper()}] {DateTime.Now:HH:mm:ss} — {message}"); } // Overload 3: Log an exception directly — compiler picks this when an Exception is passed public void Log(Exception exception) { Console.WriteLine($"[ERROR] {DateTime.Now:HH:mm:ss} — {exception.GetType().Name}: {exception.Message}"); } } class Program { static void Main() { var logger = new AuditLogger(); // Compiler resolves this to Overload 1 logger.Log("User login successful"); // Compiler resolves this to Overload 2 logger.Log("Disk space below 10%", "warning"); // Compiler resolves this to Overload 3 logger.Log(new InvalidOperationException("Payment gateway timed out")); } }
[WARNING] 14:22:05 — Disk space below 10%
[ERROR] 14:22:05 — InvalidOperationException: Payment gateway timed out
Log() above, not CreateUser() vs CreateAdmin().Runtime Polymorphism: Where the Real Magic Happens with Virtual and Override
Runtime polymorphism is resolved while the program is running, not at compile time. This is where C#'s virtual, override, and abstract keywords come in, and it's the type of polymorphism that genuinely changes how you architect software.
Here's the core idea: you write code against a base type, but at runtime the actual derived type's method runs. The base type acts like a contract; every derived class can fulfil that contract in its own way.
The classic mistake beginners make is thinking they need to know which derived type they're working with. You don't — and that's the entire point. When you add a new payment method, a new report format, or a new notification channel, you write one new class and slot it in. Nothing else changes.
Use virtual on the base class method to say 'this can be replaced'. Use override in the derived class to actually replace it. Mark a class abstract when the base version makes no sense on its own and every subclass must provide its own implementation.
using System; using System.Collections.Generic; // Abstract base — there's no such thing as a generic 'payment', so we make it abstract public abstract class PaymentMethod { public string AccountReference { get; } protected PaymentMethod(string accountReference) { AccountReference = accountReference; } // Abstract method — every payment type MUST implement this public abstract decimal CalculateProcessingFee(decimal orderTotal); // Virtual method — has a sensible default, but subclasses CAN override it public virtual string GetReceiptLabel() { return $"Payment via {GetType().Name} (ref: {AccountReference})"; } // Non-virtual — this behaviour never changes regardless of payment type public void PrintReceipt(decimal orderTotal) { decimal fee = CalculateProcessingFee(orderTotal); // polymorphic call Console.WriteLine(GetReceiptLabel()); // polymorphic call Console.WriteLine($" Order total : £{orderTotal:F2}"); Console.WriteLine($" Processing : £{fee:F2}"); Console.WriteLine($" Grand total : £{orderTotal + fee:F2}"); Console.WriteLine(); } } public class CreditCardPayment : PaymentMethod { private readonly bool _isPremiumCard; public CreditCardPayment(string cardReference, bool isPremiumCard) : base(cardReference) { _isPremiumCard = isPremiumCard; } // Credit cards charge 1.5% — 0.8% for premium cards public override decimal CalculateProcessingFee(decimal orderTotal) { decimal rate = _isPremiumCard ? 0.008m : 0.015m; return Math.Round(orderTotal * rate, 2); } public override string GetReceiptLabel() { string tier = _isPremiumCard ? "Premium" : "Standard"; return $"{tier} Credit Card (ref: {AccountReference})"; } } public class BankTransferPayment : PaymentMethod { public BankTransferPayment(string sortCodeRef) : base(sortCodeRef) { } // Bank transfers are flat fee — not percentage-based public override decimal CalculateProcessingFee(decimal orderTotal) { return 0.25m; // flat 25p regardless of order size } // Note: GetReceiptLabel() is NOT overridden — it uses the base default } public class CryptoPayment : PaymentMethod { private readonly string _networkName; public CryptoPayment(string walletRef, string networkName) : base(walletRef) { _networkName = networkName; } // Crypto fees vary by network congestion — simulated here as 2% public override decimal CalculateProcessingFee(decimal orderTotal) { return Math.Round(orderTotal * 0.02m, 2); } public override string GetReceiptLabel() { return $"Crypto ({_networkName}) — wallet: {AccountReference}"; } } class Program { static void Main() { // The list holds the BASE TYPE — we don't care which concrete type is inside var checkoutPayments = new List<PaymentMethod> { new CreditCardPayment("VISA-4242", isPremiumCard: false), new CreditCardPayment("AMEX-0001", isPremiumCard: true), new BankTransferPayment("20-44-53"), new CryptoPayment("0xA1B2C3", "Ethereum") }; decimal orderTotal = 150.00m; // Same call — PrintReceipt — but each payment type does its own thing foreach (PaymentMethod payment in checkoutPayments) { payment.PrintReceipt(orderTotal); } } }
Order total : £150.00
Processing : £2.25
Grand total : £152.25
Premium Credit Card (ref: AMEX-0001)
Order total : £150.00
Processing : £1.20
Grand total : £151.20
Payment via BankTransferPayment (ref: 20-44-53)
Order total : £150.00
Processing : £0.25
Grand total : £150.25
Crypto (Ethereum) — wallet: 0xA1B2C3
Order total : £150.00
Processing : £3.00
Grand total : £153.00
new to hide it, but you won't get polymorphic dispatch — calling through the base type reference will still call the base method. This is method hiding, not overriding, and it's a trap.Interface-Based Polymorphism: Decoupling Without Inheritance Chains
Inheritance-based polymorphism is powerful, but it chains you to a single parent. C# only allows one base class. Interfaces solve this by letting completely unrelated types share a common contract without being family.
Interface polymorphism is how most real production code achieves flexibility. Dependency injection, unit testing with mocks, the Strategy pattern, plugin architectures — they're all interface polymorphism wearing different hats.
The rule of thumb: if you find yourself thinking 'these types need to be interchangeable, but they don't share a logical ancestor', reach for an interface. A PDF report and a CSV export have nothing in common as objects, but they're both exportable. An EmailNotifier and an SMSNotifier aren't related, but they're both notifiers.
With C# 8+ you also get default interface methods, which let you add behaviour to an interface without breaking all existing implementations. Use this carefully — it's a migration tool, not a design tool.
using System; using System.Collections.Generic; // The contract — any type that implements this becomes 'exportable' public interface IReportExporter { string FormatName { get; } void Export(IEnumerable<string> reportRows, string destinationPath); // C# 8+ default interface method — provides fallback without breaking old implementors void PrintExportSummary(string destinationPath) { Console.WriteLine($" [{FormatName}] Export complete → {destinationPath}"); } } public class PdfExporter : IReportExporter { public string FormatName => "PDF"; public void Export(IEnumerable<string> reportRows, string destinationPath) { Console.WriteLine($"Generating PDF with {System.Linq.Enumerable.Count(reportRows)} rows..."); // Real implementation would use a PDF library like iTextSharp PrintExportSummary(destinationPath); } } public class CsvExporter : IReportExporter { public string FormatName => "CSV"; private readonly char _delimiter; public CsvExporter(char delimiter = ',') { _delimiter = delimiter; } public void Export(IEnumerable<string> reportRows, string destinationPath) { Console.WriteLine($"Writing CSV (delimiter: '{_delimiter}') with {System.Linq.Enumerable.Count(reportRows)} rows..."); PrintExportSummary(destinationPath); } } public class JsonExporter : IReportExporter { public string FormatName => "JSON"; public void Export(IEnumerable<string> reportRows, string destinationPath) { Console.WriteLine($"Serialising {System.Linq.Enumerable.Count(reportRows)} rows to JSON..."); // Overrides the default summary to add JSON-specific detail Console.WriteLine($" [JSON] Minified export → {destinationPath}"); } // Override the default interface method with a custom version public void PrintExportSummary(string destinationPath) { Console.WriteLine($" [JSON] Minified export → {destinationPath}"); } } // This class only knows about IReportExporter — it's completely decoupled from PDF/CSV/JSON public class ReportingService { private readonly IReportExporter _exporter; // depends on the interface, not the concrete type // The concrete exporter is injected — swap it without touching this class public ReportingService(IReportExporter exporter) { _exporter = exporter; } public void RunMonthlyReport() { var salesData = new List<string> { "January,North,£42,500", "January,South,£31,200", "January,East,£28,900" }; Console.WriteLine($"Running monthly report using: {_exporter.FormatName}"); _exporter.Export(salesData, "/reports/monthly-2024-01"); Console.WriteLine(); } } class Program { static void Main() { // Swap the exporter — ReportingService never needs to change var services = new List<ReportingService> { new ReportingService(new PdfExporter()), new ReportingService(new CsvExporter(delimiter: ';')), new ReportingService(new JsonExporter()) }; foreach (var service in services) { service.RunMonthlyReport(); } } }
Generating PDF with 3 rows...
[PDF] Export complete → /reports/monthly-2024-01
Running monthly report using: CSV
Writing CSV (delimiter: ';') with 3 rows...
[CSV] Export complete → /reports/monthly-2024-01
Running monthly report using: JSON
Serialising 3 rows to JSON...
[JSON] Minified export → /reports/monthly-2024-01
IReportExporter above), you've just made your class mockable. In a unit test, pass in a fake exporter that doesn't touch the file system. This is why interface polymorphism is the foundation of testable code — not just a design pattern.IReportExporter into IExporter and IExportSummary prevents consumers from depending on methods they don't use.The `new` Keyword Trap: Method Hiding vs True Polymorphism
This is the gotcha that trips up developers who think they're overriding but are actually hiding. When you use the new keyword on a derived class method, you're not participating in polymorphism — you're creating a completely separate method that shadows the base class version at compile time.
The dangerous part? It compiles without errors. It looks like it works when you test it directly on the derived type. But the moment you reference the derived object through a base type variable — which is exactly what polymorphism requires — the base class method runs instead of yours. The runtime ignores your new method entirely.
This almost always happens by accident when someone forgets to mark the base method virtual and the compiler warns you to add new to suppress the warning. Adding new silences the warning but gives you hiding, not overriding. If you need true polymorphic dispatch, go back and add virtual to the base.
using System; public class Notification { // NOT marked virtual — this is the problem public string GetDeliveryChannel() { return "Base: Generic channel"; } // This one IS virtual — safe for polymorphism public virtual string GetMessagePreview() { return "Base: Default message preview"; } } public class PushNotification : Notification { // 'new' hides the base method — does NOT participate in polymorphism public new string GetDeliveryChannel() { return "Derived: Push via APNs/FCM"; } // 'override' replaces the base method — true polymorphism public override string GetMessagePreview() { return "Derived: Push preview — tap to open"; } } class Program { static void Main() { PushNotification pushDirect = new PushNotification(); Notification pushedAsBase = new PushNotification(); // same object, base type reference Console.WriteLine("=== Called on derived type directly ==="); Console.WriteLine(pushDirect.GetDeliveryChannel()); // calls the 'new' hidden method Console.WriteLine(pushDirect.GetMessagePreview()); // calls the overridden method Console.WriteLine(); Console.WriteLine("=== Called through base type reference ==="); // GetDeliveryChannel: hiding — base type reference calls BASE version, ignores 'new' Console.WriteLine(pushedAsBase.GetDeliveryChannel()); // GetMessagePreview: override — base type reference still calls DERIVED version Console.WriteLine(pushedAsBase.GetMessagePreview()); } }
Derived: Push via APNs/FCM
Derived: Push preview — tap to open
=== Called through base type reference ===
Base: Generic channel
Derived: Push preview — tap to open
new only when hiding is genuinely what you want. If you're confused, you almost certainly want virtual + override instead.new method with the same signature — now your code breaks silently.new on a method that could become virtual in a future release; the override will not kick in.Polymorphism and the Open/Closed Principle — Designing for Extension Without Modification
The Open/Closed Principle states that your code should be open for extension but closed for modification. Polymorphism is the chief mechanism that makes this possible in object-oriented design. Without it, every new requirement forces you to add an if/else or switch to existing code — violating the 'closed for modification' side.
Here's how it plays out: you design a base type (abstract class or interface) with a set of methods. Consumer code depends on that base type. When a new variant appears, you write a new derived class that plugs into the existing consumer code. No existing class needs to change. That's the 'open for extension' part.
Real-world example: a pricing engine that calculates discounts. The base DiscountStrategy has a CalculateDiscount(Order order) method. New discount types (Black Friday, Loyalty, Employee) each become a new class. The engine that processes orders never changes — it just calls the strategy interface. Add a hundred discount types without touching a single existing class.
This pattern is so powerful that most enterprise C# codebases rely on it via dependency injection containers. You register new implementations in the DI container, and they're automatically available wherever the interface is used. No switch statements, no if/else chains, no modifications to existing code.
using System; using System.Collections.Generic; using System.Linq; // The contract — open for extension public interface IDiscountStrategy { decimal CalculateDiscount(Order order); bool IsApplicable(Order order); } public class Order { public decimal Total { get; set; } public string CustomerTier { get; set; } // "Standard", "Gold", "Platinum" public bool IsHolidaySeason { get; set; } public int ItemsCount { get; set; } } public class NoDiscount : IDiscountStrategy { public decimal CalculateDiscount(Order order) => 0m; public bool IsApplicable(Order order) => false; // fallback } public class LoyaltyDiscount : IDiscountStrategy { public decimal CalculateDiscount(Order order) { return order.CustomerTier switch { "Gold" => order.Total * 0.10m, "Platinum" => order.Total * 0.15m, _ => 0m }; } public bool IsApplicable(Order order) => order.CustomerTier != "Standard"; } public class SeasonalDiscount : IDiscountStrategy { public decimal CalculateDiscount(Order order) { return order.Total * 0.05m; } public bool IsApplicable(Order order) => order.IsHolidaySeason; } public class BulkDiscount : IDiscountStrategy { public decimal CalculateDiscount(Order order) { var discountPerItem = 0.5m; // 50p per item over 10 var eligibleItems = Math.Max(order.ItemsCount - 10, 0); return eligibleItems * discountPerItem; } public bool IsApplicable(Order order) => order.ItemsCount > 10; } // The engine — closed for modification public class DiscountEngine { private readonly IEnumerable<IDiscountStrategy> _strategies; public DiscountEngine(IEnumerable<IDiscountStrategy> strategies) { _strategies = strategies; } public decimal CalculateTotalDiscount(Order order) { // No need to change this method when new discounts are added return _strategies .Where(s => s.IsApplicable(order)) .Sum(s => s.CalculateDiscount(order)); } } class Program { static void Main() { var order = new Order { Total = 200.00m, CustomerTier = "Gold", IsHolidaySeason = true, ItemsCount = 15 }; // In production, these would be registered via DI var strategies = new List<IDiscountStrategy> { new NoDiscount(), new LoyaltyDiscount(), new SeasonalDiscount(), new BulkDiscount() }; var engine = new DiscountEngine(strategies); var discount = engine.CalculateTotalDiscount(order); Console.WriteLine($"Order total: £{order.Total}"); Console.WriteLine($"Total discount: £{discount:F2}"); Console.WriteLine($"Final amount: £{order.Total - discount:F2}"); } }
Total discount: £37.50
Final amount: £162.50
- The consumer (DiscountEngine) depends on the interface — not the concrete strategy.
- Each new discount is a separate class — no existing code changes.
- The DI container acts as a registry — you add new strategies without touching the engine.
- Switch statements are the opposite of OCP — they force you to modify existing code for every new case.
| Aspect | Compile-Time (Overloading) | Runtime (Override / Interface) |
|---|---|---|
| When resolved | At compile time by the compiler | At runtime by the CLR |
| Mechanism | Multiple methods, same name, different signatures | virtual/override or interface implementation |
| Flexibility | Fixed at compile time — no swapping at runtime | Behaviour swaps based on actual object type at runtime |
| Primary use case | Same operation, different input shapes (e.g. Log(string) vs Log(Exception)) | Interchangeable implementations (payment types, exporters, strategies) |
| Testability impact | Minimal — overloads are on concrete types | High — interfaces enable mocking and dependency injection |
| Risk of confusion | Low — compiler catches wrong calls | Higher — method hiding ('new') can silently break polymorphism |
| abstract keyword applicable? | No | Yes — forces every derived class to provide its own implementation |
🎯 Key Takeaways
- Polymorphism isn't syntax — it's a design decision. The payoff is that adding a new type (payment method, exporter, notification channel) means writing one new class, not editing existing ones.
- Method hiding with 'new' compiles and runs but silently breaks polymorphism the moment you use a base-type reference — always check whether 'virtual' + 'override' was what you actually meant.
- Interface-based polymorphism is the workhorse of real production code: it decouples consumers from implementations, makes unit testing with mocks possible, and enables dependency injection.
- Abstract classes and interfaces serve different purposes: use abstract when there's genuinely shared state or partial behaviour across related types; use interfaces when you need a contract across unrelated types or want multiple contracts on one class.
- The Open/Closed Principle is the real reason polymorphism exists — design your core logic to depend on abstractions, and new variants become new classes, not new branches in existing code.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat is the difference between method overriding and method hiding in C#, and how does using a base-type reference expose the difference?SeniorReveal
- QCan you explain how the CLR decides which method implementation to invoke at runtime when virtual dispatch is involved — and what role the vtable plays in that process?SeniorReveal
- QIf a class implements two interfaces that both declare a method with the same signature, how does C# resolve the conflict, and how would you provide separate implementations for each interface?Mid-levelReveal
Frequently Asked Questions
What is the difference between abstract and virtual in C#?
A virtual method has a default implementation in the base class that derived classes can override but don't have to. An abstract method has no implementation at all in the base class — every non-abstract derived class must override it or the code won't compile. You can only declare abstract methods inside abstract classes.
Can polymorphism work with interfaces in C# or only with inheritance?
Both work. Interface polymorphism is actually more common in modern C# because it doesn't lock you into a single inheritance chain. Any unrelated class can implement the same interface and be treated interchangeably. This is the foundation of dependency injection and the Strategy pattern.
Why does calling a virtual method in a constructor cause problems?
When a base class constructor calls a virtual method, the derived class's override runs — but the derived class constructor hasn't executed yet. Any fields the override depends on are still at their default values. This leads to NullReferenceExceptions or silently incorrect behaviour that's very hard to debug. Avoid virtual calls in constructors entirely.
What is the vtable and how does it affect performance?
The vtable (virtual method table) is a per-type table of function pointers used by the CLR for runtime dispatch of virtual methods. Each virtual call requires an extra indirection (look up the vtable, fetch the address, then call). This costs roughly 1–3 ns per call compared to a non-virtual call, and it prevents the JIT from inlining. In practice, this matters only for extremely hot code paths with millions of calls per second.
When should I use the Strategy pattern instead of a switch statement?
Use the Strategy pattern when the number of variants is open-ended (could grow), when variants are selected dynamically (runtime configuration, dependency injection), or when you want to test each variant in isolation. Use a switch only when the set of cases is small, stable, and unlikely to change — and even then, a dictionary of delegates can be cleaner.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.