Mid-level 7 min · March 06, 2026

Polymorphism in C# — Method Hiding Broke Payment Fees

Method hiding in C# cost a payment system 25p fees instead of 1.5%.

N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Everything here is grounded in real deployments.

Follow
Production
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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 new instead of override — 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.
✦ Definition~90s read
What is Polymorphism in C#?

Polymorphism is the ability of objects of different types to respond to the same method call in type-specific ways. In C#, this isn't just about virtual methods — it's a fundamental design tool that lets you write code against abstractions rather than concrete implementations.

Imagine a TV remote.

The core idea is that a single interface (method signature) can have multiple implementations, and the correct one is selected either at compile time (method overloading) or at runtime (virtual dispatch, interface methods). This is what enables patterns like dependency injection, strategy, and command — where you can swap behavior without touching the calling code.

C# gives you two distinct flavors. Compile-time polymorphism (overloading) is straightforward: same method name, different parameters, resolved by the compiler. Runtime polymorphism is where the power lives — virtual methods, abstract methods, and interface implementations.

When you call a virtual method on a base class reference, the CLR looks up the actual object's type at runtime and dispatches to the most derived override. This is what makes frameworks like ASP.NET Core's middleware pipeline or Entity Framework's query providers work — you write code against DbContext or IMiddleware, and the runtime calls the concrete implementation.

The trap most developers hit is the new keyword for method hiding. It looks like polymorphism but isn't — it creates a separate method that only gets called when the variable is typed as the derived class. If you call through a base class reference, the base method runs instead.

This is why payment fee calculations break when someone uses new instead of override: the base CalculateFee() runs even though the object is actually a PremiumAccount. True polymorphism requires virtual/override or interface implementation — anything else is just name reuse.

Polymorphism is also the engine behind the Open/Closed Principle. By designing your system around interfaces or abstract base classes, you can add new behaviors (new derived classes) without modifying existing code. Real-world example: a reporting system that processes IReport implementations — adding a PdfReport or CsvReport requires zero changes to the report runner.

This is why dependency injection containers, plugin architectures, and strategy patterns all rely on runtime polymorphism. Without it, every new feature would require hunting down switch statements and if-else chains.

Plain-English First

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.

new vs override — Not Interchangeable
Method hiding (new) is a compile-time decision; overriding (virtual/override) is runtime. They solve different problems — don't use new when you mean override.
Production Insight
Payment gateway integration where a derived PayPalPayment class hides CalculateFee instead of overriding it. When the system iterates over a List<PaymentProcessor> and calls CalculateFee, every item returns the base fee — PayPal transactions are undercharged by 2.9% + $0.30. Rule: if you intend polymorphic behavior, always use virtual/override; never use new to 'replace' a base method.
Key Takeaway
Polymorphism in C# requires virtual/override for dynamic dispatch; new performs static hiding.
Calling through a base reference always invokes the base method for hidden members, never the derived one.
In production, method hiding silently breaks business logic — always verify that overrides are truly overrides, not hides.
Polymorphism in C#: Method Hiding vs True Override THECODEFORGE.IO Polymorphism in C#: Method Hiding vs True Override How the 'new' keyword breaks polymorphic behavior in fee calculations Virtual Method Declaration Base class defines virtual CalculateFee() Override in Derived Class Derived class uses 'override' for true polymorphism Method Hiding with 'new' Derived class uses 'new' to hide base method Runtime Dispatch via Base Ref Base type reference calls overridden method Hiding Breaks Dispatch Base ref calls base method, not derived Unexpected Fee Calculation Wrong fee applied due to method hiding ⚠ Method hiding with 'new' bypasses polymorphism Always use 'override' for virtual methods to ensure correct dispatch THECODEFORGE.IO
thecodeforge.io
Polymorphism in C#: Method Hiding vs True Override
Polymorphism Csharp

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.

OverloadedLogger.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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"));
    }
}
Output
[INFO] 14:22:05 — User login successful
[WARNING] 14:22:05 — Disk space below 10%
[ERROR] 14:22:05 — InvalidOperationException: Payment gateway timed out
Pro Tip:
Don't overload just to avoid writing descriptive method names. If the concepts are genuinely different, separate names are cleaner. Overloading shines when it's the same operation applied to different input shapes — like Log() above, not CreateUser() vs CreateAdmin().
Production Insight
Overloading can hide a bug when an implicit conversion exists — the compiler picks an overload you didn't expect.
For example, passing an int to a method expecting long vs object can silently resolve to the wrong signature.
Rule: if you overload with both value and reference types, test the call site with explicit casts.
Key Takeaway
Overloading is resolved at compile time, not runtime.
Use it for the same operation on different input shapes — never to hide different concepts under one name.
The compiler catches wrong calls, so risk is low, but overuse makes code harder to read.
When to Use Method Overloading vs Separate Methods
IfSame logical operation, different input types (e.g., Log(string) vs Log(Exception))
UseUse method overloading — cleaner API, single conceptual method name.
IfOperations are conceptually different (e.g., CreateUser vs CreateAdmin)
UseUse separate method names — overloading hides intent and confuses callers.
IfYou have more than 3–4 overloads for the same method
UseConsider using optional parameters or a parameter object — too many overloads hurt maintainability.

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.

PaymentProcessor.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
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);
        }
    }
}
Output
Standard Credit Card (ref: VISA-4242)
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
Interview Gold:
Interviewers love asking: 'What happens if you don't mark a method virtual but you try to override it?' The answer: you can use 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.
Production Insight
Virtual dispatch disables method inlining — the JIT cannot inline a call it resolves at runtime.
For hot paths (tight loops, high-throughput APIs), this adds measurable CPU overhead.
Rule: if performance is critical and the call is on a sealed class, consider a non-virtual method or use a struct with generic interfaces (which can be devirtualized by RyuJIT).
Key Takeaway
Runtime polymorphism lets you add new behaviour without modifying existing consuming code.
Use abstract for contracts, virtual for optional overrides, non-virtual for fixed behaviour.
The vtable adds tiny cost — but don't let it scare you; the flexibility gain is enormous.
Choosing Between Virtual, Abstract, and Non-Virtual Methods
IfEvery derived class must provide its own implementation — no sensible default exists
UseMake the method abstract in an abstract class. Forces override, cannot be called on base.
IfThere is a sensible default, but derived classes may want to change behaviour
UseMark the method virtual in the base class. Derived classes use override to replace it.
IfThe method behaviour should never change — same for all deriving types
UseDo not mark as virtual. Non-virtual methods are final and cannot be overridden (only hidden with 'new').

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.

ReportExporter.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
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();
        }
    }
}
Output
Running monthly report using: PDF
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
Pro Tip:
When you use an interface as a constructor parameter (like 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.
Production Insight
Interfaces with many members (10+) often indicate a violation of Interface Segregation Principle.
Splitting IReportExporter into IExporter and IExportSummary prevents consumers from depending on methods they don't use.
Rule: if you see a class implementing an interface by throwing NotSupportedException for some members — split the interface.
Key Takeaway
Interfaces decouple your code from concrete types — enabling DI, mocking, and the Strategy pattern.
Prefer interfaces over abstract classes for cross-cutting contracts.
Default interface methods are a migration tool, not a reason to put logic in interfaces.
Abstract Class vs Interface Decision Tree
IfTypes share common state (fields, properties) and partial behaviour
UseUse an abstract class with shared implementation and abstract methods for variability.
IfTypes are unrelated but need to adhere to a common contract
UseUse an interface — no shared state, just a contract. Enables multiple inheritance of behaviour.
IfYou need to add a method to an existing interface without breaking all implementors
UseUse a C# 8+ default interface method, but only as a migration aid — not as a design pattern.

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.

MethodHidingDemo.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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());
    }
}
Output
=== Called on derived type directly ===
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
Watch Out:
The compiler warning 'Method hides inherited member — use the new keyword if hiding was intended' is C#'s way of saying 'are you sure?'. Suppress it with new only when hiding is genuinely what you want. If you're confused, you almost certainly want virtual + override instead.
Production Insight
A common production bug: a third-party library base class adds a virtual method in a new version, but your derived class has a new method with the same signature — now your code breaks silently.
This is called the 'brittle base class' problem.
Rule: never use new on a method that could become virtual in a future release; the override will not kick in.
Key Takeaway
New keyword hides, override replaces — they are not the same.
Test polymorphic behaviour using a base-type variable, not the derived type.
If the compiler warns you about hiding, do not suppress it with 'new' unless you truly understand the consequence.
Hiding vs Overriding Decision Tree
IfYou want the derived method to be called whenever the base type reference is used
UseUse 'override' — requires the base method to be marked 'virtual' or 'abstract'.
IfYou want the derived method to be called ONLY when using the derived type reference
UseUse 'new' (hiding) — but be aware that this is rarely the correct design; reconsider.
IfThe base method is NOT virtual and you cannot change the base class
UseYou cannot use 'override'. Use 'new' if you must, but know that polymorphic dispatch won't work. Prefer composition or a different method name.

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.

DiscountEngine.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
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}");
    }
}
Output
Order total: £200.00
Total discount: £37.50
Final amount: £162.50
Mental Model:
  • 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.
Production Insight
Be careful with strategy ordering — if multiple strategies apply, the order in which they are evaluated matters.
For example, a loyalty discount might be calculated before or after a seasonal discount, leading to different results.
Rule: either define a priority ordering explicitly, or ensure strategies are mathematically commutative (order-independent) by design.
Key Takeaway
Polymorphism is the primary enabler of the Open/Closed Principle.
Design your core logic to depend on abstractions, not concrete types.
When you see a switch statement that grows with every new feature — you're looking at a place where polymorphism should have been used.
When to Use the Strategy Pattern (Polymorphism for Behaviour)
IfYou have multiple algorithms/behaviours that should be interchangeable at runtime
UseUse the Strategy pattern with an interface or abstract class — consumer depends on abstraction.
IfYou find yourself adding new 'else if' branches for each new variant
UseRefactor into a polymorphic strategy — each variant becomes a separate class, no branching.
IfThe variants are mutually exclusive and selected based on a single condition
UseStrategy pattern works, but consider a factory or a simple enum + switch if the number is small and stable.

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.

CovarianceTrap.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// io.thecodeforge
var shapes = new List<Shape> { new Circle(), new Triangle() };

// Compiles — covariance via IEnumerable<out T>
IEnumerable<Shape> readOnly = shapes.Cast<Shape>();
foreach (var shape in readOnly)
    shape.Draw(); // Runtime calls Circle.Draw(), Triangle.Draw()

// Fails at compile-time — List<T> is invariant
// List<Shape> baseList = new List<Circle>(); // CS0266

// Safe pattern: declare with concrete type
var circles = new List<Circle> { new Circle(), new Circle() };
// Then convert only for read operations
IEnumerable<Shape> asShapes = circles;
Output
Drawing a circle
Drawing a triangle
Production Trap:
Never cast a List<Circle> to a List<Shape>. Code compiles only after you add unsafe casts, then blows up at runtime when someone adds a Triangle.
Key Takeaway
Polymorphism in collections works for reads only. Use IEnumerable<out T> for covariance, never IList<T>.

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.

SealedOverride.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// io.thecodeforge
public class PaymentProcessor
{
    public virtual bool Authorize(decimal amount)
    {
        // Base logic: log transaction
        Console.WriteLine($"Authorizing {amount:C}");
        return amount <= 10000;
    }
}

public sealed class SecurePaymentProcessor : PaymentProcessor
{
    public sealed override bool Authorize(decimal amount)
    {
        // Add audit, fraud check
        Console.WriteLine($"Secure check for {amount:C}");
        return base.Authorize(amount) && amount <= 5000;
    }
}

// Attempt to override sealed method — compile error
// public class HackedProcessor : SecurePaymentProcessor
// {
//     public override bool Authorize(decimal amount) { return true; } // CS0239
// }
Output
Compiles and runs with security guarantees
Design Decision:
Sealed overrides lock down polymorphic behavior. The JIT can devirtualize sealed calls, giving you zero-overhead dispatch.
Key Takeaway
Seal overrides when polymorphism must stop. It's not closure — it's contract enforcement.

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.

DynamicPolymorphism.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// io.thecodeforge
public class EmailSender
{
    public void Send(string message) => 
        Console.WriteLine($"Email: {message}");
}

public class SmsSender
{
    public void Send(string message) => 
        Console.WriteLine($"SMS: {message}");
}

dynamic sender = GetSender("email"); // returns EmailSender or SmsSender at runtime

// Duck typing — no compile-time check
sender.Send("Alert!");

// Safe approach: wrap in try/catch
try { sender.Send("Alert!"); }
catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException ex)
{
    Console.WriteLine($"Routing failed: {ex.Message}");
}

static object GetSender(string type) =>
    type == "email" ? new EmailSender() : new SmsSender();
Output
Email: Alert!
Production Risk:
dynamic throws RuntimeBinderException for missing methods. Always wrap dynamic calls in try/catch. Never let a duck quack kill your service.
Key Takeaway
Use dynamic as duck typing only when you cannot control the type hierarchy. Wrap it. Log it. Never trust it blindly.
● Production incidentPOST-MORTEMseverity: high

Silent Financial Loss Due to Method Hiding in Payment Processing

Symptom
Credit card payments processed through a List<PaymentMethod> were charged the base flat fee (25p) instead of the correct 1.5% fee. Invoices were wrong, customers were undercharged for months.
Assumption
The derived class method with new would be called polymorphically — same as override.
Root cause
Method hiding (new) does NOT participate in runtime polymorphism. When the list held base-type references, the base class method ran instead of the derived one.
Fix
Add virtual to the base method and use override in all derived classes. Re-run all payment calculations to reconcile the undercharges.
Key lesson
  • Never use 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 production3 entries
Symptom · 01
Derived method is never called when object is stored as base type or passed to method expecting base type
Fix
Check if the derived method uses new instead of override. Look for compiler warning CS0108. Remove new and add virtual to the base method, then override in the derived class.
Symptom · 02
NullReferenceException or default values when calling a virtual method inside a base class constructor
Fix
The derived class constructor hasn't executed yet — any fields the override depends on are still null/zero. Refactor: remove virtual calls from constructors. Use a factory method that calls initialisation after construction.
Symptom · 03
Interface method not invoked even though the class implements the interface
Fix
Check for explicit interface implementation (e.g., 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.
Compile-Time vs Runtime Polymorphism
AspectCompile-Time (Overloading)Runtime (Override / Interface)
When resolvedAt compile time by the compilerAt runtime by the CLR
MechanismMultiple methods, same name, different signaturesvirtual/override or interface implementation
FlexibilityFixed at compile time — no swapping at runtimeBehaviour swaps based on actual object type at runtime
Primary use caseSame operation, different input shapes (e.g. Log(string) vs Log(Exception))Interchangeable implementations (payment types, exporters, strategies)
Testability impactMinimal — overloads are on concrete typesHigh — interfaces enable mocking and dependency injection
Risk of confusionLow — compiler catches wrong callsHigher — method hiding ('new') can silently break polymorphism
abstract keyword applicable?NoYes — forces every derived class to provide its own implementation

Key takeaways

1
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.
2
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.
3
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.
4
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.
5
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

3 patterns
×

Using 'new' thinking it overrides

Symptom
Derived class method is never called when objects are stored in a base-type list or passed to a method accepting the base type. The base method always runs instead.
Fix
Add 'virtual' to the base class method and use 'override' in the derived class. The 'new' keyword creates method hiding, not polymorphic dispatch. Remove 'new' and ensure modifiers are correct.
×

Calling an overridden method in a base class constructor

Symptom
The overridden method fires before the derived class constructor has run, so derived fields are still at their default values (null, 0, false), causing NullReferenceExceptions or wrong output.
Fix
Avoid calling virtual methods from constructors. Use a separate initialization method or a factory pattern where the object is fully constructed before any virtual calls are made.
×

Overloading based solely on return type

Symptom
Compile error: 'Type already defines a member called X with the same parameter types'.
Fix
Overload resolution in C# uses the method signature (name + parameter types/count), never the return type. If you need different return types, use different method names, generics, or out parameters.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the difference between method overriding and method hiding in C#...
Q02SENIOR
Can you explain how the CLR decides which method implementation to invok...
Q03SENIOR
If a class implements two interfaces that both declare a method with the...
Q01 of 03SENIOR

What is the difference between method overriding and method hiding in C#, and how does using a base-type reference expose the difference?

ANSWER
Method overriding requires the base method to be marked 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.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between abstract and virtual in C#?
02
Can polymorphism work with interfaces in C# or only with inheritance?
03
Why does calling a virtual method in a constructor cause problems?
04
What is the vtable and how does it affect performance?
05
When should I use the Strategy pattern instead of a switch statement?
N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Everything here is grounded in real deployments.

Follow
Verified
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's OOP in C#. Mark it forged?

7 min read · try the examples if you haven't

Previous
Interfaces in C#
4 / 10 · OOP in C#
Next
Abstract Classes in C#