Polymorphism in C# — Method Hiding Broke Payment Fees
Method hiding in C# cost a payment system 25p fees instead of 1.5%.
20+ years shipping production .NET services in enterprise systems. Everything here is grounded in real deployments.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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.
Imagine a TV remote. You press the power button and it works whether you're pointing it at a Samsung, a Sony, or an LG. You don't need a different remote for each brand — the same button does the right thing for whichever TV is in front of it. Polymorphism in C# is exactly that: one interface, one method call, but the right behaviour kicks in automatically depending on the actual object you're dealing with. That's the whole game.
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.
Why Polymorphism Is Not Just Virtual Methods
Polymorphism lets a derived object behave as its base type while retaining its own implementation. In C#, the runtime resolves calls via the vtable when methods are marked virtual/override — this is dynamic dispatch. Without virtual, the compiler binds to the base type at compile time, which is static dispatch.
Method hiding (new keyword) breaks the polymorphic contract. If a derived class hides a base method, calling through a base reference invokes the base version, not the derived one. This is not a bug — it's by design — but it surprises teams expecting override semantics.
Use polymorphism when callers should work with abstractions and the correct implementation must be chosen at runtime. In payment systems, fee calculation is a textbook case: a PaymentProcessor base class with a virtual CalculateFee method lets each payment type (CreditCard, PayPal) define its own logic. Hiding that method instead of overriding it silently returns the wrong fee.
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.
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.
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.
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.
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.
- 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.
When Polymorphism Breaks: The Covariance Curse in Generic Collections
You've got a List<Shape> full of Circles. You call Draw() in a foreach loop. Every shape draws itself correctly — that's covariance in action. But try to add a Square to that List<Shape> and you get a compile-time error. Why? Because List<T> is invariant by design. Covariance only works for reading, not writing. Microsoft got this right: allowing writes would let you push a Triangle into a collection you think holds only Circles. That's a runtime crash waiting to happen. Interfaces like IEnumerable<out T> grant safe covariance for reads. IReadOnlyList<out T> follows suit. But IList<T> is invariant. The junior mistake is assuming a derived collection behaves like a base collection. It doesn't. Always declare your collection type as the derived type and use covariant interfaces for polymorphism in collections. Production systems crash on this mismatch.
Sealed Methods: Why Explicit Override Prevention Saves Production
You inherit a base class and override a virtual method. Works great. Then some junior two years later inherits your class and overrides that same method. Now you have three layers of override. The call chain becomes spaghetti. Production bugs are born. C# gives you the sealed keyword for a reason. Apply it to an override when you want to stop further overrides. This isn't being mean — it's being clear. Open/Closed Principle means you extend behavior, not modify existing contracts. A sealed override tells the next dev: this method's behavior is locked. They can call new or create their own inheritance from your class, but they can't polymorphically hijack your implementation. The real-world payoff? Audit logs stay consistent. Security checks remain enforced. Performance improves because the JIT can devirtualize sealed calls. Always ask: does every override serve the system's stability? If not, seal it.
Polymorphism Without Inheritance: Duck Typing with dynamic
Classic polymorphism requires an interface or base class. But sometimes you get JSON from an API, and each payload has a Process() method with different signatures. You can't refactor their schema. C# gives you dynamic. It bypasses compile-time type checking and resolves method calls at runtime. This is duck typing: if it walks like a duck and quacks like a duck, treat it as a duck. The risk? A typo in method name throws RuntimeBinderException at runtime. No compile safety. Use dynamic when you own the caller and have no design-time control over the callee — like dealing with COM, dynamic languages, or polymorphic JSON deserialization from loosely typed sources. Best practice: wrap dynamic calls in try/catch blocks and log the binding failures. Never let a missing method crash production. Example: an event dispatcher that routes messages by method name. Dynamic is your last resort, but when used correctly, it saves weeks of interface refactoring.
Pattern Matching as Polymorphism Alternative
Pattern matching in C# offers a powerful alternative to traditional polymorphism, especially when dealing with disparate types that don't share a common base or interface. Instead of relying on virtual method dispatch, you can use switch expressions or statements to match on type and shape, enabling polymorphic behavior without inheritance. For example, consider a payment processing scenario where different payment methods have different fee calculations. Instead of creating an interface with a virtual method, you can use a switch expression:
``csharp public static decimal CalculateFee(Payment payment) => payment switch { CreditCardPayment c => c.Amount 0.02m, PayPalPayment p => p.Amount 0.03m + 0.30m, BankTransfer b => 0.50m, _ => throw new ArgumentException("Unknown payment type") }; ``
This approach is concise and avoids the need for a polymorphic hierarchy. It also supports more complex patterns like property matching, positional patterns, and recursive patterns. However, it violates the Open/Closed Principle if you need to add new types frequently, as you must modify the switch expression. For stable type sets, it's a clean and readable solution. Pattern matching also works well with discriminated unions (via records and nested types) to simulate algebraic data types. In production, use pattern matching when you have a finite, known set of types and want to keep logic centralized rather than scattered across classes.
Dynamic Polymorphism via DLR
The Dynamic Language Runtime (DLR) in C# allows you to bypass compile-time type checking and resolve method calls at runtime, enabling a form of polymorphism that works with objects that don't share a common type. By using the dynamic keyword, you can invoke methods on objects that may not be known until runtime, and the DLR will attempt to bind the call dynamically. This is particularly useful when interoperating with dynamic languages like Python or COM objects, or when you need to call methods on types that implement the same method signature but don't inherit from a common interface. For example:
dynamic payment = GetPayment(); // Could be CreditCardPayment or PayPalPayment
decimal fee = payment.CalculateFee(); // Resolved at runtime
If both CreditCardPayment and PayPalPayment have a CalculateFee method, the DLR will dispatch to the correct one. However, this comes with trade-offs: you lose compile-time type safety, IntelliSense, and performance (due to runtime binding). Exceptions like RuntimeBinderException can occur if the method doesn't exist. In production, use dynamic sparingly, typically for interop scenarios or when dealing with inherently dynamic data (e.g., JSON). For most polymorphism needs, virtual methods or interfaces are safer and faster. The DLR also supports ExpandoObject and DynamicObject for creating objects with dynamic members, enabling duck typing within C#.
dynamic to boundary layers (e.g., API deserialization, COM interop) and avoid it in performance-critical paths due to overhead.Generic Math Polymorphism with INumber
C# 11 introduced generic math support via static abstract interface methods, allowing you to write polymorphic code that works with numeric types like int, double, and decimal without boxing or runtime type checks. The INumber interface (from System.Numerics) defines static members for arithmetic operations, enabling generic algorithms that are type-safe and efficient. For example, you can create a method that calculates the sum of any numeric type:
``csharp public static T Sum``
This method works with int, double, decimal, and any type implementing INumber. The compiler generates specialized code for each type, avoiding runtime overhead. This is a form of compile-time polymorphism (generics) combined with static interface methods, enabling mathematical operations without virtual dispatch. In production, this is ideal for financial calculations where you need to support multiple numeric types without sacrificing performance. However, it requires .NET 7+ and C# 11. Use it for libraries that perform generic math operations, like statistics or linear algebra, ensuring your types implement the required interfaces.
Silent Financial Loss Due to Method Hiding in Payment Processing
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.- Never use
newon 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.
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.| File | Command / Code | Purpose |
|---|---|---|
| OverloadedLogger.cs | using System; | Compile-Time Polymorphism |
| PaymentProcessor.cs | using System; | Runtime Polymorphism |
| ReportExporter.cs | using System; | Interface-Based Polymorphism |
| MethodHidingDemo.cs | using System; | The `new` Keyword Trap |
| DiscountEngine.cs | using System; | Polymorphism and the Open/Closed Principle |
| CovarianceTrap.cs | var shapes = new List | When Polymorphism Breaks |
| SealedOverride.cs | public class PaymentProcessor | Sealed Methods |
| DynamicPolymorphism.cs | public class EmailSender | Polymorphism Without Inheritance |
| PatternMatchingExample.cs | public static decimal CalculateFee(Payment payment) => payment switch | Pattern Matching as Polymorphism Alternative |
| DynamicPolymorphismExample.cs | dynamic payment = GetPayment(); | Dynamic Polymorphism via DLR |
| GenericMathExample.cs | public static T Sum | Generic Math Polymorphism with INumber |
Key takeaways
Interview Questions on This Topic
What is the difference between method overriding and method hiding in C#, and how does using a base-type reference expose the difference?
virtual or abstract, and the derived class uses override. It participates in runtime polymorphic dispatch — calling the method through a base-type reference invokes the derived class's implementation. Method hiding uses the new keyword (or omits it, causing a compiler warning) and does NOT override the vtable slot. When called through a base-type reference, the base class method runs. The difference is exposed by storing the derived object in a variable of the base type: override calls the derived version, hiding calls the base version.Frequently Asked Questions
20+ years shipping production .NET services in enterprise systems. Everything here is grounded in real deployments.
That's OOP in C#. Mark it forged?
8 min read · try the examples if you haven't