SOLID Principle

Open/Closed Principle

Software entities should be open for extension, but closed for modification β€” Bertrand Meyer, 1988

29 Q&As 6 Bug Studies 10 Pitfalls 4 Testing Strategies C# / .NET
Section 1

TL;DR

Most abused SOLID principle: OCP does NOT mean "never modify existing code." It means design your code so that new behavior can be added without changing existing, tested code. If adding a new payment method requires editing a switch statement in 5 files, OCP is violated.

What: Design modulesAny unit of code organization β€” classes, functions, assemblies, or even microservices. OCP applies at every level: you should be able to extend behavior without rewriting what already works. so they can be extended with new behavior without modifying their source code. New requirements = new code, not changed code.

Why: Every modification to existing code risks breaking what already works. If adding "PayPal support" means editing PaymentProcessor.cs, you might break the credit card flow that's been working for 2 years. OCP eliminates this risk.

Modern .NET: ASP.NET Core lives and breathes OCP β€” middleware pipelineASP.NET Core's request processing chain. Each middleware is a separate class that can be added or removed without modifying any existing middleware. Adding authentication doesn't change how logging works. (add new middleware without touching existing ones), DI containerASP.NET Core's built-in dependency injection. Register a new IPaymentProcessor implementation and the existing code automatically picks it up β€” no switch statements, no if/else chains. (register new implementations without modifying consumers), and MediatR handlersEach new feature is a new handler class. Adding a new command doesn't touch any existing handler. The mediator routes requests to handlers by convention β€” zero modification to existing code. (new feature = new handler file, zero changes to existing code).

Quick Code:

OCP-at-a-glance.cs
// βœ— CLOSED for extension β€” adding a new discount type requires editing this method
public decimal CalculateDiscount(Order order) => order.Type switch
{
    "Regular" => 0,
    "Premium" => order.Total * 0.1m,
    "VIP" => order.Total * 0.2m,
    // Adding "Employee" means MODIFYING this file
    _ => 0
};

// βœ“ OPEN for extension β€” add new discounts without touching existing code
public interface IDiscountStrategy { decimal Calculate(Order order); bool AppliesTo(Order order); }
public class RegularDiscount : IDiscountStrategy { /* ... */ }
public class PremiumDiscount : IDiscountStrategy { /* ... */ }
public class VipDiscount     : IDiscountStrategy { /* ... */ }
// Adding "Employee" = new class, ZERO changes to existing code

public class DiscountCalculator(IEnumerable<IDiscountStrategy> strategies)
{
    public decimal Calculate(Order order) =>
        strategies.FirstOrDefault(s => s.AppliesTo(order))?.Calculate(order) ?? 0;
}
Section 2

Prerequisites

Before reading this, you should understand:
SRP β€” OCP builds on SRP. If a class has multiple responsibilities, it's impossible to extend one without affecting the other. Interfaces & polymorphismThe mechanism that makes OCP work. An interface defines the contract; multiple implementations provide different behaviors. The consumer codes against the interface, never knowing which implementation it gets. β€” the core mechanism that enables OCP Dependency InjectionA technique where a class receives its dependencies through its constructor. DI makes OCP practical β€” you can swap implementations without modifying the consumer class. β€” how new implementations get wired without touching existing classes Strategy patternDefine a family of algorithms, encapsulate each one, and make them interchangeable. Strategy is the most common way to achieve OCP β€” each new algorithm is a new class. basics β€” the pattern that most directly implements OCP
Section 3

Real-World Analogies

The Power Outlet Analogy

A wall power outlet is the perfect OCP example. The outlet (interface) never changes β€” it was designed once in the 1900s. But you can "extend" it with infinite new devices: phone chargers, laptops, blenders, devices that didn't exist when the outlet was designed. You never need to modify the outlet to support a new device β€” you just plug in a new implementation.

Power OutletCode Equivalent
Outlet (fixed shape, never changes)IPaymentProcessor (interface β€” closed for modification)
Phone charger (plugs in)StripeProcessor (new implementation)
Laptop adapter (plugs in)PayPalProcessor (another implementation)
Future device (plugs in)CryptoProcessor (added without changing anything)
The outlet doesn't know what's plugged inThe consumer depends on the interface, not the implementation
LEGO Bricks

LEGO bricks connect via a standard stud pattern. You can build anything new (extension) without modifying the bricks themselves (closed). The stud pattern is the interface.

Guitar Pedals

A guitarist adds new effects by plugging in new pedals β€” not by rewiring the guitar. Each pedal is a new class implementing the same input/output interface.

Browser Extensions

Chrome's extension API is closed for modification. But thousands of extensions add new behavior without modifying Chrome's source code. That's OCP at scale.

Section 4

Core Principle & Visualization

Bertrand MeyerFrench computer scientist who coined the Open/Closed Principle in his 1988 book "Object-Oriented Software Construction." Meyer's original version used inheritance; Robert Martin later reframed it using polymorphism and interfaces. originally stated OCP in 1988: "Software entities (classes, modules, functions) should be open for extension, but closed for modification." Robert C. MartinSoftware engineer who reinterpreted OCP in the 1990s-2000s using interfaces and polymorphism instead of Meyer's inheritance-based approach. Martin's version is what modern developers mean by OCP. later refined it to focus on polymorphism rather than inheritance.

The Two Halves of OCP

Open for extension: You can add new behavior. This is typically done by creating new classes that implement an existing interface.
Closed for modification: Adding new behavior doesn't require changing existing, tested source code. The existing code doesn't even know new implementations exist.

βœ— OCP VIOLATION PaymentProcessor + Process(type: string) if (type == "stripe") ... if (type == "paypal") ... if (type == "crypto") ... ← EDIT! // Must MODIFY to add new type βœ“ OCP COMPLIANT Β«interfaceΒ» IPaymentProcessor + ProcessAsync(Payment) StripeProcessor Existing βœ“ PayPalProcessor Existing βœ“ CryptoProcessor NEW β€” no edits! βœ“ CheckoutService Uses IPaymentProcessor CheckoutService NEVER changes New payment methods = new classes, not edits

The Three Extension Mechanisms

Polymorphism (Martin's OCP)

Define an interface, implement it multiple times. Consumer depends on the abstraction. Most common in modern .NET.

Inheritance (Meyer's OCP)

Extend a base class with a subclass. Override methods to change behavior. Original 1988 formulation. Less preferred today β€” composition over inheritance.

Higher-Order Functions

Pass behavior as Func<T> or Action<T> parameters. LINQ's .Where(predicate) is OCP via functions β€” the method never changes, the predicate does.

Section 5

Code: Violations & Refactored

Violation 1: The Switch Statement Magnet

Every new notification channel requires editing this class. The switch statement is a modification magnetA code location that attracts changes from many different features. Every new feature requires modifying the same switch/if-else block. OCP violations are modification magnets β€” they centralize change instead of distributing it. β€” a single point that every feature must touch.

NotificationService.cs β€” Violation
// βœ— Every new channel = edit this class
public class NotificationService
{
    public async Task SendAsync(string channel, string message, string recipient)
    {
        switch (channel)
        {
            case "email":
                using var smtp = new SmtpClient("smtp.company.com");
                await smtp.SendMailAsync(new MailMessage("noreply@co.com", recipient, "Alert", message));
                break;

            case "sms":
                using var http = new HttpClient();
                await http.PostAsJsonAsync("https://api.twilio.com/send",
                    new { To = recipient, Body = message });
                break;

            case "slack":
                // TODO: add Slack β€” requires editing THIS class
                break;

            case "teams":
                // TODO: add Teams β€” requires editing THIS class AGAIN
                break;

            default:
                throw new ArgumentException($"Unknown channel: {channel}");
        }
    }
}

// Problems:
// 1. Adding Slack means MODIFYING a class that handles email + SMS
// 2. A bug fix in email logic could accidentally break SMS
// 3. Testing email requires mocking SMS dependencies (and vice versa)
// 4. The class grows unboundedly as channels are added

Violation 2: The Type-Checking If/Else

Checking the type of an object with is or GetType() to decide behavior is a classic OCP violation. Each new type requires editing the consumer.

ShippingCalculator.cs β€” Violation
// βœ— Every new shipping method = edit this calculator
public class ShippingCalculator
{
    public decimal Calculate(Order order)
    {
        if (order.ShippingMethod is StandardShipping)
            return order.Weight * 0.5m;

        if (order.ShippingMethod is ExpressShipping)
            return order.Weight * 1.5m + 10m;

        if (order.ShippingMethod is OvernightShipping)
            return order.Weight * 3.0m + 25m;

        // Adding DroneDelivery = must edit this class
        throw new NotSupportedException($"Unknown shipping: {order.ShippingMethod.GetType().Name}");
    }
}

// Problems:
// 1. Adding DroneDelivery requires modifying ShippingCalculator
// 2. The calculator knows about every concrete shipping type
// 3. If you forget to add a case, it throws at runtime β€” no compile-time safety
// 4. Unit testing requires creating instances of ALL shipping types

OCP-Compliant: Interface + DI

Each notification channel is its own class. Adding a new channel means creating a new file and registering it in DI β€” zero changes to existing code.

OCP-Notification.cs β€” Refactored
// ── Interface (CLOSED for modification) ──
public interface INotificationChannel
{
    string ChannelType { get; }
    Task SendAsync(string message, string recipient);
}

// ── Implementations (OPEN for extension β€” add new ones freely) ──
public class EmailChannel(IEmailSender smtp) : INotificationChannel
{
    public string ChannelType => "email";
    public async Task SendAsync(string message, string recipient) =>
        await smtp.SendAsync(recipient, "Alert", message);
}

public class SmsChannel(ISmsSender sms) : INotificationChannel
{
    public string ChannelType => "sms";
    public async Task SendAsync(string message, string recipient) =>
        await sms.SendAsync(recipient, message);
}

// βœ“ Adding Slack = NEW FILE, zero changes to existing code
public class SlackChannel(ISlackClient client) : INotificationChannel
{
    public string ChannelType => "slack";
    public async Task SendAsync(string message, string recipient) =>
        await client.PostMessageAsync(recipient, message);
}

// ── Consumer (NEVER changes when new channels are added) ──
public class NotificationRouter(IEnumerable<INotificationChannel> channels)
{
    private readonly Dictionary<string, INotificationChannel> _map =
        channels.ToDictionary(c => c.ChannelType);

    public async Task SendAsync(string channel, string message, string recipient)
    {
        if (!_map.TryGetValue(channel, out var handler))
            throw new ArgumentException($"Unknown channel: {channel}");

        await handler.SendAsync(message, recipient);
    }
}

// ── DI Registration (the ONLY place that knows about all implementations) ──
// Program.cs
builder.Services.AddScoped<INotificationChannel, EmailChannel>();
builder.Services.AddScoped<INotificationChannel, SmsChannel>();
builder.Services.AddScoped<INotificationChannel, SlackChannel>();
// Adding TeamsChannel: register here + create new class. That's it.
Why This Works

NotificationRouter depends on IEnumerable<INotificationChannel> β€” it doesn't know or care how many implementations exist. The DI container injects all registered implementations. Adding a new channel is: 1) create class, 2) register in DI. The router, the email channel, and the SMS channel are all untouched.

Section 6

Jr vs Sr Implementation

Problem Statement

Build a discount engine for an e-commerce platform. Requirements: support percentage discounts, fixed-amount discounts, buy-one-get-one-free, and the ability to stack discountsApply multiple discounts to the same order. For example, a 10% member discount + a $5 coupon. The engine must handle ordering, caps, and conflicts between stacked discounts.. New discount types will be added quarterly by the marketing team.

How a Junior Thinks

"I'll use an enum for discount types and a switch statement. It's simple and I can see all the logic in one place."

DiscountService.cs β€” Junior
public enum DiscountType { Percentage, FixedAmount, BuyOneGetOneFree }

public class DiscountService
{
    public decimal Apply(Order order, DiscountType type, decimal value)
    {
        return type switch
        {
            DiscountType.Percentage =>
                order.Total * (1 - value / 100),

            DiscountType.FixedAmount =>
                Math.Max(0, order.Total - value),

            DiscountType.BuyOneGetOneFree =>
                order.Items.Count > 1
                    ? order.Total - order.Items.Min(i => i.Price)
                    : order.Total,

            _ => order.Total
        };
    }

    // βœ— Stacking = even messier
    public decimal ApplyAll(Order order, List<(DiscountType Type, decimal Value)> discounts)
    {
        var total = order.Total;
        foreach (var (type, value) in discounts)
        {
            total = type switch
            {
                DiscountType.Percentage => total * (1 - value / 100),
                DiscountType.FixedAmount => Math.Max(0, total - value),
                // βœ— BOGO doesn't work on running total β€” needs item-level access
                DiscountType.BuyOneGetOneFree => total,
                _ => total
            };
        }
        return total;
    }
}

// βœ— Marketing wants "Tiered" discount β€” must edit DiscountService
// βœ— Marketing wants "Bundle" discount β€” must edit DiscountService AGAIN
// βœ— Every quarter: same class, same switch, same risk of breaking existing discounts
Modification Magnet

Every new discount type forces edits to the same class. Eventually it becomes a 500-line monster.

Coupled Testing

Testing BOGO requires loading the entire discount service, including percentage and fixed amount logic.

Merge Conflicts

Two devs adding different discount types both edit the same switch statement β†’ guaranteed conflicts.

How a Senior Thinks

"Marketing adds new discount types every quarter. If I use a switch, I'll be editing this class forever. I need a design where new discount = new file."

IDiscount.cs
// The abstraction β€” CLOSED for modification
public interface IDiscount
{
    string Name { get; }
    int Priority { get; }  // Lower = applied first
    bool CanApply(Order order);
    OrderTotal Apply(Order order, OrderTotal currentTotal);
}

// Immutable result β€” prevents discounts from mutating order
public record OrderTotal(decimal Subtotal, decimal DiscountAmount, decimal FinalTotal)
{
    public static OrderTotal From(Order order) =>
        new(order.Total, 0, order.Total);
}
Discounts/ β€” Each is a separate file
// ── File: Discounts/PercentageDiscount.cs ──
public class PercentageDiscount(decimal percent) : IDiscount
{
    public string Name => $"{percent}% Off";
    public int Priority => 10;
    public bool CanApply(Order order) => order.Total > 0;
    public OrderTotal Apply(Order order, OrderTotal current) =>
        current with
        {
            DiscountAmount = current.DiscountAmount + current.FinalTotal * (percent / 100),
            FinalTotal = current.FinalTotal * (1 - percent / 100)
        };
}

// ── File: Discounts/FixedAmountDiscount.cs ──
public class FixedAmountDiscount(decimal amount) : IDiscount
{
    public string Name => $"${amount} Off";
    public int Priority => 20;
    public bool CanApply(Order order) => order.Total >= amount;
    public OrderTotal Apply(Order order, OrderTotal current) =>
        current with
        {
            DiscountAmount = current.DiscountAmount + amount,
            FinalTotal = Math.Max(0, current.FinalTotal - amount)
        };
}

// ── File: Discounts/BuyOneGetOneFreeDiscount.cs ──
public class BuyOneGetOneFreeDiscount : IDiscount
{
    public string Name => "BOGO";
    public int Priority => 5;  // Apply before percentage discounts
    public bool CanApply(Order order) => order.Items.Count > 1;
    public OrderTotal Apply(Order order, OrderTotal current)
    {
        var cheapest = order.Items.Min(i => i.Price);
        return current with
        {
            DiscountAmount = current.DiscountAmount + cheapest,
            FinalTotal = current.FinalTotal - cheapest
        };
    }
}

// βœ“ Adding TieredDiscount next quarter = new file, zero changes above
DiscountEngine.cs β€” NEVER changes
// The engine β€” CLOSED for modification
// It doesn't know about specific discount types.
// It just applies whatever IDiscount implementations are registered.
public class DiscountEngine(IEnumerable<IDiscount> discounts)
{
    public OrderTotal Calculate(Order order)
    {
        var applicable = discounts
            .Where(d => d.CanApply(order))
            .OrderBy(d => d.Priority);

        var total = OrderTotal.From(order);
        foreach (var discount in applicable)
            total = discount.Apply(order, total);

        return total;
    }
}
Program.cs
// DI: the ONLY place that knows about all discount types
builder.Services.AddScoped<IDiscount>(_ => new PercentageDiscount(10));
builder.Services.AddScoped<IDiscount, BuyOneGetOneFreeDiscount>();
builder.Services.AddScoped<IDiscount>(_ => new FixedAmountDiscount(5));
builder.Services.AddScoped<DiscountEngine>();

// Next quarter, marketing wants TieredDiscount:
// 1. Create Discounts/TieredDiscount.cs (implements IDiscount)
// 2. Add one line here: builder.Services.AddScoped<IDiscount, TieredDiscount>();
// 3. Done. DiscountEngine, PercentageDiscount, etc. are UNTOUCHED.
Design Decisions

1. IDiscount.Priority controls application order without hardcoding it in the engine.
2. CanApply() lets each discount decide its own eligibility rules β€” the engine doesn't need to know.
3. OrderTotal is immutable (a record) β€” discounts can't accidentally mutate the order.
4. IEnumerable<IDiscount> in the constructor leverages .NET DI's multi-registrationASP.NET Core's DI container supports registering multiple implementations of the same interface. When you inject IEnumerable<T>, you get ALL registered implementations β€” perfect for strategy-based OCP designs. feature.

Section 7

Evolution of OCP Thinking

1988: Meyer's Original OCP (Inheritance-Based)

Bertrand MeyerCreator of the Eiffel programming language and author of "Object-Oriented Software Construction" (1988, 2nd ed. 1997). He introduced the Open/Closed Principle, Design by Contract, and the Command-Query Separation principle. defined OCP in Object-Oriented Software Construction (1988). His version was inheritance-based: a class is "closed" once published, but can be "opened" by creating a subclass that overrides behavior.

Meyer1988.cs
// Meyer's OCP: extend via INHERITANCE
public class Shape
{
    public virtual double Area() => 0;
}

public class Circle : Shape
{
    public double Radius { get; init; }
    public override double Area() => Math.PI * Radius * Radius;
}

// "Open" β€” extend Shape with a new subclass
public class Triangle : Shape
{
    public double Base { get; init; }
    public double Height { get; init; }
    public override double Area() => 0.5 * Base * Height;
}

// Problem: deep inheritance hierarchies become fragile
// What if Shape needs both Area() AND Perimeter()? LSP risks.
Limitation

Meyer's inheritance-based OCP creates fragile base class problemsWhen modifying a base class accidentally breaks all subclasses. The more levels of inheritance, the more fragile the hierarchy. This is why modern OCP prefers interfaces + composition over inheritance.. Changing the base class affects all subclasses. Modern OCP uses interfaces and composition instead.

Robert C. Martin reinterpreted OCP in his 1996 article "The Open-Closed Principle" (published in The C++ ReportA technical magazine for C++ developers published from 1989 to 2002. Martin's column "Engineering Notebook" introduced many of the SOLID principles to a wider audience.). Martin shifted the focus from inheritance to abstraction and polymorphism: depend on interfaces, not concrete classes.

Martin1996.cs
// Martin's OCP: extend via INTERFACES + POLYMORPHISM
public interface IShape
{
    double Area();
}

public class Circle(double radius) : IShape
{
    public double Area() => Math.PI * radius * radius;
}

public class Triangle(double @base, double height) : IShape
{
    public double Area() => 0.5 * @base * height;
}

// Consumer is CLOSED for modification
public class AreaCalculator(IEnumerable<IShape> shapes)
{
    public double TotalArea() => shapes.Sum(s => s.Area());
    // Adding Hexagon? Create new class, implements IShape.
    // AreaCalculator NEVER changes.
}

In Agile Software Development (2003) and Clean Architecture (2017), Martin formalized OCP as part of SOLID and connected it to the Dependency Inversion PrincipleHigh-level modules should not depend on low-level modules. Both should depend on abstractions. DIP is OCP's enforcement mechanism β€” by depending on interfaces, you can add new implementations without changing consumers.: "If component A should be protected from changes in component B, then component B should depend on component A."

CleanArchitecture.cs
// Clean Architecture: OCP at the ARCHITECTURAL level
// Domain layer (innermost) β€” CLOSED for modification
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(int id);
    Task SaveAsync(Order order);
}

// Infrastructure layer (outermost) β€” OPEN for extension
public class SqlOrderRepository(AppDbContext db) : IOrderRepository { /* ... */ }
public class CosmosOrderRepository(CosmosClient client) : IOrderRepository { /* ... */ }
public class InMemoryOrderRepository : IOrderRepository { /* for tests */ }

// Swapping from SQL to Cosmos = change DI registration.
// Domain layer, application layer, controllers β€” ALL untouched.

ASP.NET Core's entire architecture is built on OCP. The middleware pipelineASP.NET Core processes HTTP requests through a chain of middleware components. Each middleware is independent β€” adding rate limiting doesn't affect authentication, adding CORS doesn't change compression. Pure OCP., DI containerASP.NET Core's built-in IoC container supports interface-based registration, making OCP the default. You register IFoo β†’ Foo, and consumers get Foo without knowing its type. Swapping to FooV2 is a one-line DI change., MediatRA popular .NET library where each feature is a separate handler class. Adding a new feature = adding a new handler file. No existing handler is modified. The mediator routes by convention., and minimal APIsLightweight ASP.NET Core endpoints defined as lambdas or method groups. Each endpoint is independently defined β€” adding a new endpoint doesn't touch existing ones. all embody OCP. C# features like pattern matchingC# switch expressions with pattern matching (is, switch, when). While powerful, pattern matching on types can VIOLATE OCP if used as a dispatch mechanism β€” prefer polymorphism for extensible behavior. can help or hurt OCP depending on how they're used.

ModernDotNet.cs
// Modern .NET: OCP everywhere

// 1. Middleware pipeline β€” add without modifying
app.UseAuthentication();   // Existing
app.UseRateLimiting();     // NEW β€” doesn't touch auth
app.UseResponseCaching();  // NEW β€” doesn't touch rate limiting

// 2. Endpoint routing β€” add without modifying
app.MapGet("/orders", GetOrders);       // Existing
app.MapGet("/invoices", GetInvoices);   // NEW β€” doesn't touch orders

// 3. DI multi-registration
builder.Services.AddScoped<IValidator<Order>, PriceValidator>();     // Existing
builder.Services.AddScoped<IValidator<Order>, StockValidator>();     // NEW β€” no edits

// 4. MediatR β€” new feature = new handler file
public record GetInvoiceQuery(int Id) : IRequest<Invoice>;  // NEW file
public class GetInvoiceHandler : IRequestHandler<GetInvoiceQuery, Invoice> { /* ... */ }
// Zero changes to any existing handler
Section 8

OCP in .NET Core

ASP.NET Core is arguably the best OCP showcase in any major framework. Here are 6 places where the framework itself follows OCP β€” and how you can leverage each one.

Middleware Pipeline

Each middlewareA component in the ASP.NET Core request pipeline. It receives a request, optionally processes it, and passes it to the next middleware via next(). Each middleware is independent β€” adding or removing one doesn't affect others. handles one concern. Adding rate limiting doesn't touch authentication. Adding CORS doesn't change compression. The pipeline is extended by adding middleware, never by modifying existing ones.

MiddlewarePipeline.cs
// Each app.Use*() call ADDS behavior without modifying existing middleware
var app = builder.Build();

app.UseExceptionHandler("/error");     // Existing β€” untouched
app.UseHttpsRedirection();             // Existing β€” untouched
app.UseAuthentication();               // Existing β€” untouched
app.UseAuthorization();                // Existing β€” untouched

// βœ“ New requirement: rate limiting β€” just ADD, don't modify
app.UseRateLimiter();

// βœ“ New requirement: response compression β€” just ADD
app.UseResponseCompression();

// βœ“ Custom middleware: same pattern
app.UseMiddleware<RequestTimingMiddleware>();

// The pipeline is OPEN for extension (add new middleware)
// and CLOSED for modification (existing middleware unchanged)

ASP.NET Core's DI container supports multi-registrationRegistering multiple implementations of the same interface. When you inject IEnumerable<IFoo>, you get ALL registered IFoo implementations. This is the backbone of OCP in .NET β€” add implementations without touching consumers. β€” register multiple implementations of the same interface, then inject IEnumerable<T> to get all of them. This is OCP's killer feature in .NET.

DIMultiRegistration.cs
// Register multiple validators for the same type
builder.Services.AddScoped<IValidator<Order>, PriceValidator>();
builder.Services.AddScoped<IValidator<Order>, StockValidator>();
builder.Services.AddScoped<IValidator<Order>, FraudValidator>();  // NEW β€” no edits above

// Consumer gets ALL validators automatically
public class OrderService(IEnumerable<IValidator<Order>> validators)
{
    public async Task<Result> PlaceOrderAsync(Order order)
    {
        // Runs every registered validator β€” doesn't know which or how many
        var errors = validators
            .SelectMany(v => v.Validate(order).Errors)
            .ToList();

        if (errors.Count > 0)
            return Result.Fail(errors);

        // ... process order ...
        return Result.Ok();
    }
}
// Adding WeightValidator: register in DI + create class. OrderService? Untouched.

FluentValidationA popular .NET library for building strongly-typed validation rules. Each validator class handles one DTO/model. New validators are discovered automatically via assembly scanning β€” pure OCP. follows OCP perfectly. Each model gets its own validator class. Adding validation for a new DTO = new file. The validation pipeline discovers validators by convention β€” zero modification needed.

FluentValidation.cs
// Each validator is a separate class β€” adding one doesn't touch others
public class CreateOrderValidator : AbstractValidator<CreateOrderDto>
{
    public CreateOrderValidator()
    {
        RuleFor(x => x.CustomerId).NotEmpty();
        RuleFor(x => x.Items).NotEmpty().WithMessage("Order must have items");
        RuleFor(x => x.Items).Must(items => items.All(i => i.Quantity > 0));
    }
}

// NEW: Adding validation for UpdateOrderDto = new file, zero changes above
public class UpdateOrderValidator : AbstractValidator<UpdateOrderDto>
{
    public UpdateOrderValidator()
    {
        RuleFor(x => x.OrderId).GreaterThan(0);
        RuleFor(x => x.Status).IsInEnum();
    }
}

// Auto-discovery: validators registered by assembly scanning
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
// New validators are picked up automatically β€” zero DI registration needed

MediatRA mediator pattern library for .NET. Requests are dispatched to handlers by type matching. Pipeline behaviors intercept ALL requests (like middleware for business logic). Both handlers and behaviors are pure OCP β€” new ones don't affect existing ones.'s pipeline behaviors are OCP at the application logic level. Add cross-cutting concerns (validation, logging, caching) without modifying any handler.

PipelineBehaviors.cs
// Validation behavior β€” wraps ALL commands without modifying them
public class ValidationBehavior<TReq, TRes>(IEnumerable<IValidator<TReq>> validators)
    : IPipelineBehavior<TReq, TRes>A MediatR interface that intercepts every command/query before it reaches its handler. Think of it as middleware for business logic. Register it once, and it applies to ALL requests β€” zero modification to individual handlers. where TReq : IRequest<TRes>
{
    public async Task<TRes> Handle(
        TReq request, RequestHandlerDelegate<TRes> next, CancellationToken ct)
    {
        var failures = validators
            .Select(v => v.Validate(request))
            .SelectMany(r => r.Errors)
            .Where(f => f is not null)
            .ToList();

        if (failures.Count > 0)
            throw new ValidationException(failures);

        return await next(); // Call next behavior or handler
    }
}

// NEW: Add caching behavior β€” no existing behavior or handler changes
public class CachingBehavior<TReq, TRes>(IMemoryCache cache)
    : IPipelineBehavior<TReq, TRes> where TReq : IRequest<TRes>, ICacheable
{
    public async Task<TRes> Handle(
        TReq request, RequestHandlerDelegate<TRes> next, CancellationToken ct)
    {
        var key = request.CacheKey;
        if (cache.TryGetValue(key, out TRes? cached)) return cached!;
        var result = await next();
        cache.Set(key, result, TimeSpan.FromMinutes(5));
        return result;
    }
}

// Registration β€” behaviors apply to ALL requests automatically
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>));

ASP.NET Core's Options patternA pattern using IOptions<T>, IOptionsSnapshot<T>, and IOptionsMonitor<T> to bind configuration sections to strongly-typed classes. Adding a new configuration section doesn't affect existing option bindings. lets you add new configuration sections without modifying existing ones. Each options class is independent β€” adding CacheOptions doesn't touch SmtpOptions.

OptionsPattern.cs
// Each options class is independent β€” adding new ones doesn't touch existing
builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp"));
builder.Services.Configure<StripeOptions>(builder.Configuration.GetSection("Stripe"));

// NEW: add cache configuration β€” no existing code changes
builder.Services.Configure<CacheOptions>(builder.Configuration.GetSection("Cache"));

// Post-configure: modify options after binding, without touching the options class
builder.Services.PostConfigure<SmtpOptions>(opts =>
{
    if (string.IsNullOrEmpty(opts.FromAddress))
        opts.FromAddress = "noreply@company.com";
});

The Decorator patternWrap an object with another that adds behavior. In .NET DI, register a decorator that wraps the real implementation. The consumer only sees the interface β€” it doesn't know it's getting a decorated version. is OCP's best friend. Add caching, logging, or retry behavior to any service without modifying the original. The ScrutorA .NET library that adds assembly scanning and decoration support to Microsoft's DI container. Its .Decorate<TService, TDecorator>() method wraps existing registrations β€” perfect for OCP-compliant cross-cutting concerns. library makes this one line of code.

Decorator.cs
// Original service β€” NEVER modified
public class OrderRepository(AppDbContext db) : IOrderRepository
{
    public async Task<Order?> GetByIdAsync(int id) => await db.Orders.FindAsync(id);
    public async Task SaveAsync(Order order) { db.Orders.Update(order); await db.SaveChangesAsync(); }
}

// Decorator: adds caching WITHOUT modifying OrderRepository
public class CachedOrderRepository(IOrderRepository inner, IMemoryCache cache) : IOrderRepository
{
    public async Task<Order?> GetByIdAsync(int id) =>
        await cache.GetOrCreateAsync($"order:{id}", async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
            return await inner.GetByIdAsync(id);
        });
    public async Task SaveAsync(Order order) { cache.Remove($"order:{order.Id}"); await inner.SaveAsync(order); }
}

// DI: wrap the original with the decorator
builder.Services.AddScoped<OrderRepository>();
builder.Services.AddScoped<IOrderRepository>(sp =>
    new CachedOrderRepository(
        sp.GetRequiredService<OrderRepository>(),
        sp.GetRequiredService<IMemoryCache>()));

// With Scrutor (one line):
// builder.Services.Decorate<IOrderRepository, CachedOrderRepository>();
Section 9

When To Use / When Not To

Apply OCP When

  • Frequent new variants: Your business adds new payment methods, discount rules, notification channels, or report formats regularly. Each addition should be a new class, not a code change.
  • Third-party integrations: You'll add more providers over time (Stripe, PayPal, Square). An interface + DI lets you add providers without touching existing code.
  • Pipeline/middleware architectures: ASP.NET middleware, MediatR pipeline behaviorsClasses that wrap MediatR request handling like middleware β€” used for logging, validation, caching, and authorization. Each behavior is a separate class registered in DI., validation chains β€” designed to be extended by adding new components.
  • Plugin systems: Features loaded at runtime. OCP is the foundation β€” the host is closed, plugins extend it.
  • Multiple teams: Teams can add features independently by implementing interfaces, without merge conflicts on a shared class.

Skip OCP When

  • Stable logic: If your pricing algorithm hasn't changed in 2 years, wrapping it in a strategy interface adds complexity for zero benefit.
  • Internal utilities: A private helper method used in one place doesn't need an interface. Just change it directly.
  • MVP/prototype: On day one with 3 users, a switch statement is perfectly fine. Extract to OCP when the third variant arrives.
  • Performance-critical paths: Virtual dispatch through interfaces has a cost. In hot loops processing millions of items, a direct method call may be necessary.
  • Configuration-only changes: If the "extension" is just changing a value (timeout, retry count), use IOptions<T>A .NET abstraction for typed configuration. Instead of reading appsettings.json directly, you bind a section to a POCO class and inject IOptions<T>. Changes don't require code modifications., not a new class.
Rule of Three

Don't apply OCP on the first variant. Don't even apply it on the second. When the third variant arrives and you find yourself editing the same switch statement for the third time β€” that's when you extract an interface. Premature abstraction is worse than no abstraction.

Section 10

OCP vs Other Principles

DimensionOCPSRP
FocusHow to extend behaviorHow to organize responsibilities
Question"Can I add a new feature without modifying existing code?""Does this class have one reason to change?"
Violation signalYou edit an existing switch/if-else to add a featureYou change a class for a reason unrelated to its purpose
FixExtract an interface, implement new variantSplit class by actor/responsibility
RelationshipSRP makes classes small enough that OCP interfaces are obvious. OCP often requires SRP first β€” a God class can't be "closed" because it changes for every reason.
DimensionOCPDIP (Dependency Inversion)
FocusExtensibility without modificationDepend on abstractions, not concretions
MechanismPolymorphism β€” new class implements interfaceInterface injection β€” high-level doesn't know low-level
Without the otherOCP without DIP: you create the interface but new up implementations directly β€” still coupledDIP without OCP: you inject abstractions but still modify them to add features
TogetherDIP provides the mechanism (interfaces + DI) that makes OCP possible. In .NET, builder.Services.AddScoped<IPayment, StripePayment>() is where DIP enables OCP.
DimensionOCPStrategy Pattern
What it isA principle β€” a design goalA pattern β€” a concrete implementation technique
ScopeApplies to classes, modules, packages, servicesApplies to interchangeable algorithms in one context
RelationshipStrategy is the most common way to implement OCP. But OCP can also be achieved via Decorator, Template Method, Observer, or even higher-order functions. Strategy is one tool; OCP is the goal.
Section 11

Design Patterns That Support OCP

Strategy

Define a family of algorithms, encapsulate each one, and make them interchangeable. The primary OCP pattern.

Strategy.cs
// Add new shipping methods without modifying ShippingService
public interface IShippingCalculator
{
    string Carrier { get; }
    decimal Calculate(Package package);
}

public class FedExCalculator : IShippingCalculator { /* ... */ }
public class UpsCalculator : IShippingCalculator { /* ... */ }
// NEW: just add a class β€” no existing code modified
public class DroneDeliveryCalculator : IShippingCalculator { /* ... */ }

public class ShippingService(IEnumerable<IShippingCalculator> calculators)
{
    public decimal GetRate(string carrier, Package pkg) =>
        calculators.First(c => c.Carrier == carrier).Calculate(pkg);
}

Decorator

Wrap an object to add behavior without changing its interface. Used for cross-cutting concernsResponsibilities that span multiple layers of an application β€” logging, caching, authentication, error handling. They don't belong to any single class but affect many classes. Decorators are ideal for implementing cross-cutting concerns without violating OCP..

Decorator.cs
// Add logging without modifying the repository
public class LoggingOrderRepository(
    IOrderRepository inner, ILogger<LoggingOrderRepository> log) : IOrderRepository
{
    public async Task<Order?> GetByIdAsync(int id)
    {
        log.LogInformation("Fetching order {Id}", id);
        var order = await inner.GetByIdAsync(id);
        log.LogInformation("Fetched order {Id}: {Found}", id, order != null);
        return order;
    }
    public async Task SaveAsync(Order order) { log.LogInformation("Saving order {Id}", order.Id); await inner.SaveAsync(order); }
}
// Chain: Logging β†’ Caching β†’ Actual Repository
// Each layer is "closed" β€” adding logging didn't modify caching or data access

Template Method

Define the skeleton in a base class; subclasses override specific steps. OCP via inheritanceTemplate Method uses inheritance-based OCP (Meyer's original formulation). The base class is "closed" β€” its algorithm skeleton doesn't change. Subclasses "extend" by overriding virtual methods. Prefer composition (Strategy) when possible..

TemplateMethod.cs
public abstract class ReportGenerator
{
    // Template method β€” closed for modification
    public string Generate(ReportData data)
    {
        var header = BuildHeader(data);
        var body = BuildBody(data);       // ← open for extension
        var footer = BuildFooter(data);
        return $"{header}\n{body}\n{footer}";
    }
    protected abstract string BuildBody(ReportData data); // Subclasses extend
    protected virtual string BuildHeader(ReportData data) => $"Report: {data.Title}";
    protected virtual string BuildFooter(ReportData data) => $"Generated: {DateTime.UtcNow}";
}
public class PdfReport : ReportGenerator { /* override BuildBody */ }
public class CsvReport : ReportGenerator { /* override BuildBody */ }

Observer

When something happens, notify all subscribersObjects that register interest in an event. When the event fires, all subscribers are notified automatically. In .NET, this is implemented via C# events, IObservable<T>, or MediatR's INotificationHandler<T>.. New subscribers extend behavior without modifying the publisher.

Observer.cs
// OrderService is CLOSED β€” adding new side effects doesn't change it
public class OrderService(IMediator mediator)
{
    public async Task PlaceAsync(Order order)
    {
        // ... save order ...
        await mediator.Publish(new OrderPlacedEvent(order)); // Fire & forget
    }
}
// Add new handlers without modifying OrderService:
public class SendConfirmationEmail : INotificationHandler<OrderPlacedEvent> { /* ... */ }
public class UpdateInventory : INotificationHandler<OrderPlacedEvent> { /* ... */ }
// NEW β€” just add a class:
public class NotifyWarehouse : INotificationHandler<OrderPlacedEvent> { /* ... */ }
Section 12

Bug Case Studies

The Incident

2023, .NET 7 e-commerce platform. A developer added cryptocurrency payments to the PaymentProcessor class that handled all payment logic in a single method. While adding the new "crypto" case, they accidentally changed the PayPal currency parameter from "USD" to "BTC" on a line 3 lines above their new code -- a copy-paste error while referencing the crypto block they were writing. All PayPal transactions started processing with the wrong currency code, causing payment gateway rejections.

Picture a busy kitchen where every chef shares the same recipe book. Chef A is writing a new sushi recipe and accidentally smudges the pasta recipe on the page above. That's exactly what happened here -- one developer's new code accidentally changed someone else's working code, because everything lived in the same method.

The team had a single PaymentProcessor class with a switch statement that handled every payment method: Stripe, PayPal, and now crypto. When the developer added the crypto case, they copied the PayPal line as a starting point and changed "USD" to "BTC". But they forgot to undo the change on the PayPal line itself. The result? Every PayPal transaction now sent "BTC" as the currency code -- and PayPal's API immediately rejected it.

The scariest part: nobody noticed during code review. The PR had 40+ lines of changes across one big method, and the accidental edit was buried three lines above the intentional changes. Reviewers focused on the new crypto logic and glossed over the PayPal line, assuming it hadn't changed.

Customers started seeing "Payment Failed" errors on checkout. The support team assumed it was a PayPal outage. It took 4 hours before someone ran the old test suite against production and traced the failure to the wrong currency code.

Time to Diagnose

4 hours -- the test suiteA collection of automated tests that verify software behavior. When tests only cover individual components in isolation but miss integration scenarios (like shared method modifications), bugs slip through to production. only tested each payment method in isolation with mocked gateways that accepted any currency. The bug only manifested against the real PayPal API in production.

PaymentProcessor.cs (One Big Class) "stripe" => ChargeStripe("USD") "paypal" => ChargePayPal("BTC") "crypto" => ChargeCrypto("BTC") NEW Copy-paste changed PayPal line! All methods in one class = one edit can accidentally break unrelated code OCP Fix: Separate Handler Classes StripeHandler.cs (untouched) PayPalHandler.cs (untouched) CryptoHandler.cs (NEW file) Each handler is its own file. Adding crypto can't touch PayPal. The copy-paste bug is impossible.
PaymentProcessor.cs -- OCP Violation
public class PaymentProcessor
{
    public async Task<PaymentResult> Process(Order order, string method)
    {
        return method switch
        {
            "stripe" => await ChargeStripe(order, "USD"),
            "paypal" => await ChargePayPal(order, "BTC"), // Was "USD" -- copy-paste error!
            "crypto" => await ChargeCrypto(order, "BTC"), // NEW -- dev copied from here
            _ => throw new NotSupportedException($"Unknown: {method}")
        };
    }
    // Each new payment method = edit this class = risk breaking others
    // The PayPal line was 3 lines away from the new code -- easy to mis-edit
}

Walking through the buggy code: Everything lives in one method. The developer added line 9 (crypto) by copying line 8 (PayPal) as a template. While editing, they accidentally left "BTC" on the PayPal line too. Because all payment methods share the same method body, one careless edit ripples across unrelated payment flows. The switch statement is a modification magnet -- every new payment method forces you to open this file and edit next to lines that are already working.

OcpPayment.cs -- Closed for Modification
public interface IPaymentHandler
{
    string Method { get; }
    Task<PaymentResult> ProcessAsync(Order order);
}

public class StripeHandler : IPaymentHandler { /* self-contained */ }
public class PayPalHandler : IPaymentHandler { /* self-contained */ }
public class CryptoHandler : IPaymentHandler { /* NEW -- no existing code touched */ }

public class PaymentProcessor(IEnumerable<IPaymentHandler> handlers)
{
    public async Task<PaymentResult> ProcessAsync(Order order, string method)
    {
        var handler = handlers.FirstOrDefault(h => h.Method == method)
            ?? throw new NotSupportedException($"No handler for: {method}");
        return await handler.ProcessAsync(order);
    }
    // PaymentProcessor is CLOSED -- adding crypto didn't touch this class
}

Why the fix works: Each payment method is now its own class in its own file. The PaymentProcessor doesn't know about Stripe, PayPal, or Crypto -- it just loops through whatever handlers are registered. When someone adds CryptoHandler, they create a brand new file. They never open PayPalHandler.cs, so they physically cannot introduce a copy-paste error there. The PR for "add crypto payments" contains only new code -- zero modifications to existing files. Reviewers don't need to check whether existing behavior was broken, because it wasn't touched.

How to Spot This in Your Code

Look for switch statements where adding a new case means editing lines near existing cases. If your PR has modifications to lines you didn't intend to change, that's the red flag. Also watch for shared helper methods inside the same class -- if one case uses ChargePayPal() and another uses ChargeCrypto(), and they sit right next to each other, copy-paste accidents are almost guaranteed over time.

Lesson Learned

When adding a new variant means editing existing switch/if-else blocks, you risk breaking existing behavior. OCP eliminates this risk: adding CryptoHandler is a new file -- existing handlers can't be affected.

The Incident

2024, .NET 8 subscription platform. A DiscountCalculator class calculated discounts for all customer types in one method: student, corporate, employee, and seasonal. The marketing team asked for a new "loyalty tier" discount. The developer added it -- and accidentally changed the >= to > on the corporate discount threshold. Corporate customers who were exactly at the $10,000 annual spend boundary stopped receiving their 15% discount. Angry enterprise clients called within hours.

Think of a filing cabinet where every department's rules are stored in the same drawer. The marketing intern opens the drawer to add a new loyalty program flyer, and while shuffling papers around, accidentally bends the corner of the corporate pricing sheet, covering up the "or equal to" part of "$10,000 or more." Now corporate clients at exactly $10,000 don't qualify anymore -- and nobody notices because the intern was only supposed to be adding a new page, not changing existing ones.

That's what happened in code. The DiscountCalculator class had one massive method with if blocks for every discount type: student, corporate, employee, seasonal. When the developer scrolled through this 200-line method to add the loyalty tier logic at the bottom, their cursor accidentally landed on the corporate line and changed >= to >. It's the kind of typo that happens when you're navigating a long file -- one accidental keystroke while your cursor is on the wrong line.

The change was invisible in the PR diff. The reviewer saw 40+ changed lines and focused on the new loyalty logic at the bottom. The corporate line looked "normal" -- it still had an if check with 10_000. The difference between >= and > is a single character, and it was buried among lines the developer wasn't supposed to touch.

Enterprise clients who spent exactly $10,000 per year -- the boundary case -- suddenly lost their 15% discount. These were the platform's biggest accounts. The first angry call came within 2 hours. It took 6 more hours to trace the problem because everyone assumed "we only added a loyalty discount, we didn't change corporate pricing."

Time to Diagnose

6 hours -- because the change was buried in a 200-line method with 8 nested if blocks. The PR reviewerA team member who reviews pull requests before merging. When a PR modifies existing code (vs adding new code), reviewers must verify no existing behavior was broken -- a much harder task than reviewing pure additions. missed it because the diff showed 40+ changed lines for "just adding a loyalty discount."

One Method, All Discounts -- One Typo Breaks Everything Student Discount order > 50 => 10% Corporate Discount spend > 10K => 15% Employee Discount isEmployee => 25% Loyalty (NEW) years >= 3 => 3% All crammed into DiscountCalculator.Calculate() -- one 200-line method >= changed to > (one character!) Customers at exactly $10,000 lost their discount OCP Fix: Each discount is its own class file Adding LoyaltyDiscount.cs never opens CorporateDiscount.cs
DiscountCalculator.cs -- Monolithic
public class DiscountCalculator
{
    public decimal Calculate(Order order, Customer customer)
    {
        decimal discount = 0;

        // Student discount
        if (customer.Type == "student" && order.Total > 50)
            discount = order.Total * 0.10m;

        // Corporate -- someone changed >= to > while adding loyalty tier
        if (customer.Type == "corporate" && customer.AnnualSpend > 10_000)
            discount = order.Total * 0.15m;

        // Employee discount
        if (customer.IsEmployee)
            discount = order.Total * 0.25m;

        // Seasonal
        if (DateTime.Now.Month == 12)
            discount += order.Total * 0.05m;

        // NEW: Loyalty tier -- this addition caused the bug above
        if (customer.LoyaltyYears >= 3)
            discount += order.Total * 0.03m;

        return discount;
    }
}

Walking through the buggy code: Every discount rule shares one method. The corporate discount on line 12 uses > instead of >=. This means a customer who spends exactly $10,000 per year falls through the crack -- 10_000 > 10_000 is false, so they get no discount. The developer only intended to add the loyalty block at line 24, but because they had to scroll through and edit inside this same method, they accidentally bumped the operator on line 12. One single character. Millions of dollars in lost corporate discounts.

OcpDiscounts.cs -- Each Discount Isolated
public interface IDiscountRule
{
    bool Applies(Order order, Customer customer);
    decimal Calculate(Order order, Customer customer);
}

public class CorporateDiscount : IDiscountRule
{
    public bool Applies(Order o, Customer c) =>
        c.Type == "corporate" && c.AnnualSpend >= 10_000; // Safe in its own file
    public decimal Calculate(Order o, Customer c) => o.Total * 0.15m;
}

// NEW: adding this class doesn't touch CorporateDiscount
public class LoyaltyDiscount : IDiscountRule
{
    public bool Applies(Order o, Customer c) => c.LoyaltyYears >= 3;
    public decimal Calculate(Order o, Customer c) => o.Total * 0.03m;
}

public class DiscountEngine(IEnumerable<IDiscountRule> rules)
{
    public decimal Calculate(Order order, Customer customer) =>
        rules.Where(r => r.Applies(order, customer))
             .Sum(r => r.Calculate(order, customer));
}

Why the fix works: Each discount rule lives in its own file. CorporateDiscount.cs contains exactly one thing: the corporate discount logic with its >= 10_000 threshold. When someone creates LoyaltyDiscount.cs, they never open the corporate file. The DiscountEngine loops through all registered rules using LINQ -- it doesn't know which specific rules exist. The PR for "add loyalty discount" contains exactly one new file and one line in the DI registration. Zero risk of accidentally changing corporate, student, or any other discount.

How to Spot This in Your Code

If a single method handles more than two or three business rules with separate if blocks, that's a modification magnet. The telltale sign: PRs that are supposed to "just add one new thing" but show modifications to lines they shouldn't need to touch. Run git log --follow on the file -- if it appears in multiple unrelated PRs, each adding "one more rule," it's time to apply OCP and split each rule into its own class.

Lesson Learned

When all discount rules live in one method, adding a new rule creates a diff that touches lines you shouldn't need to touch. With OCP, adding LoyaltyDiscount is a new file -- the PR only shows new code, making review trivial.

The Incident

2023, .NET 7 API. A RequestValidator class validated all request types in a single method using type checks. A developer added validation for a new TransferRequest type, but the else if ordering caused TransferRequest (which inherited from PaymentRequest) to match the parent's validation rules instead of its own. Transfers were approved without the required recipient validation.

Imagine a security checkpoint at an airport. There's one guard who checks everyone. The guard has a checklist: "If the person looks like a tourist, check their passport. If they look like a business traveler, check their passport AND their business visa." The problem? Every business traveler also looks like a tourist -- so the guard matches them to "tourist" first and waves them through without checking the business visa. That's exactly what happened with this type-checking bug.

The RequestValidator class had a single method that used if/else if chains to check what type of request it was dealing with. The PaymentRequest check came first. Then TransferRequest -- but here's the problem: TransferRequest inherits from PaymentRequest. In C#, when you write if (request is PaymentRequest), it matches any object that IS a PaymentRequest or any of its children. Since TransferRequest is a child of PaymentRequest, every transfer got caught by the payment check first, and the transfer-specific validation (checking for a recipient) never ran.

The result was terrifying: money transfers were being approved without verifying who the money was going to. The recipient field was empty on some transfers, meaning the system processed payments to nowhere. The bug was "intermittent" from the team's perspective because transfers that happened to have a valid amount passed the parent's simple amount check -- they looked fine. But the crucial RecipientId check was silently skipped.

Unit tests didn't catch it because they tested each request type directly: new TransferRequest() passed through its own test validator. Nobody tested the scenario where a TransferRequest was passed to the shared Validate(object request) method, which is exactly how it worked in production.

Time to Diagnose

2 days -- the bug was intermittent because it only affected TransferRequest instances that happened to pass the parent PaymentRequest validation. Unit tests passed because they tested each type independently, not through the shared validator.

Type-Check Ordering: Child Matches Parent's Check First TransferRequest (inherits PaymentRequest) is Payment? (checked FIRST) YES! Payment validation only Amount > 0? Pass! is Transfer? (NEVER reached) Transfer validation RecipientId check SKIPPED OCP Fix: Each type has its own validator class No type-check chain. No ordering issues. Each validator matched by generic type.
RequestValidator.cs -- Type-Check Hell
public class RequestValidator
{
    public ValidationResult Validate(object request)
    {
        // PaymentRequest check matches BEFORE TransferRequest
        if (request is PaymentRequest payment)
        {
            if (payment.Amount <= 0) return Fail("Invalid amount");
            return Success();
        }
        // TransferRequest inherits PaymentRequest -- this never executes!
        else if (request is TransferRequest transfer)
        {
            if (string.IsNullOrEmpty(transfer.RecipientId))
                return Fail("Recipient required"); // Never reached
            return Success();
        }
        // ... 10 more type checks
    }
}

Walking through the buggy code: The method takes an object and checks its type with is. On line 6, it checks is PaymentRequest. Since TransferRequest inherits from PaymentRequest, every transfer also passes this check -- C# inheritance means a child "is" its parent. So the code enters the payment block, checks Amount > 0 (which passes), and returns Success(). The else if on line 11 for TransferRequest is dead code -- it can never execute because the parent check already matched. The recipient validation is silently skipped for every single transfer.

FluentValidation.cs -- OCP via Assembly Scanning
// Each request type gets its OWN validator -- no type-check ordering issues
public class PaymentRequestValidator : AbstractValidator<PaymentRequest>
{
    public PaymentRequestValidator()
    {
        RuleFor(x => x.Amount).GreaterThan(0);
    }
}

public class TransferRequestValidator : AbstractValidator<TransferRequest>
{
    public TransferRequestValidator()
    {
        Include(new PaymentRequestValidator()); // Inherits parent rules
        RuleFor(x => x.RecipientId).NotEmpty(); // Adds own rules
    }
}

// DI: auto-discover ALL validators in the assembly
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
// New request type? Add a validator class. No existing validators touched.

Why the fix works: FluentValidation uses generics to match validators to request types. AbstractValidator<TransferRequest> only matches TransferRequest -- there's no ambiguity, no ordering issue. The Include() method on line 14 explicitly pulls in the parent's rules, so transfer requests get both the amount check AND the recipient check. Each validator is its own class, auto-discovered by assembly scanning. Adding a new request type means adding a new validator class -- no existing validators are touched, and there's no shared if/else chain where ordering can go wrong.

How to Spot This in Your Code

Any time you see if (x is BaseType) followed by else if (x is DerivedType), you've found this bug waiting to happen. The derived check will never execute because inheritance means the base check always matches first. Also watch for methods that take object and use type-checking chains -- these are a sign that each type should have its own handler or validator class instead.

Lesson Learned

FluentValidationA popular .NET validation library that uses a fluent API to define validation rules. Each request type gets its own validator class, and validators are auto-discovered via assembly scanning -- a textbook OCP implementation.'s "one validator per type" design is OCP by default. Adding a new validator for a new request type is a new class -- no risk of accidentally affecting existing validation logic through type-check ordering bugs.

The Incident

2024, .NET 8 notification service. A NotificationSender method iterated through channels using a foreach loop with early return. When a developer added push notifications as the first channel in the list, the method returned after sending the push -- skipping email and SMS for all subsequent users. Critical order confirmations stopped going out via email.

Think of a mail carrier who delivers letters, packages, and newspapers. One day the post office adds a new service: urgent telegrams. The rule says "deliver the telegram first, then continue your route." But someone wrote the instruction as "deliver the telegram, then go home." So the carrier delivers the telegram and heads back to the office, leaving all the letters and packages undelivered. Nobody notices until customers start calling about missing mail.

That's what return does in a loop. The NotificationSender had a foreach loop that iterated through the user's preferred channels (push, email, SMS). Each case in the switch sent the notification and then called return -- meaning the method exited completely after the first channel. The original developer probably intended break (exit the switch, continue the loop) but wrote return (exit the entire method).

When the system only had email and SMS, this bug technically existed but was hidden. Most users had only one preference, so the early return after the first channel didn't matter. But when push notifications were added as the first preference for many users, the return after SendPush() killed the loop before email and SMS could fire.

The deceptive part: push notifications worked perfectly. Everyone got their push alerts. The team celebrated the successful launch. It took 8 hours before a customer called about a missing email receipt -- and another 2 hours to realize it wasn't an email server issue but a code flow problem. The push feature had silently disabled email and SMS for every user who had push as their first preference.

Time to Diagnose

8 hours -- the push notifications worked perfectly, masking the fact that email/SMS stopped. Only noticed when a customer complained about missing email receipts.

Buggy: Early Return OCP Fix: Parallel Dispatch Notification Push Sent OK return; Email SKIPPED SMS SKIPPED Dispatcher Task.WhenAll() Push Email SMS All channels fire independently in parallel. No early return.
NotificationSender.cs -- Fragile Loop
public class NotificationSender
{
    public async Task SendAsync(Notification notification)
    {
        // All channels in one method -- order matters!
        foreach (var pref in notification.User.Preferences)
        {
            switch (pref)
            {
                case "push":
                    await SendPush(notification);
                    return; // BUG: early return skips remaining channels!
                case "email":
                    await SendEmail(notification);
                    return;
                case "sms":
                    await SendSms(notification);
                    return;
            }
        }
    }
}

Walking through the buggy code: The method loops through the user's channel preferences. When it hits "push", it sends the push notification and then hits return on line 12 -- which exits the entire method, not just the switch block. The loop never reaches the next preference (email or SMS). The developer probably meant to use break to exit the switch, but even break wouldn't be great here because this design fundamentally couples all channels into one method. The real problem is that adding a new channel means adding a new case to this switch, and every channel's control flow can accidentally interfere with every other channel's.

OcpNotification.cs -- Each Channel Independent
public interface INotificationChannel
{
    string ChannelType { get; }
    Task SendAsync(Notification notification);
}

public class EmailChannel : INotificationChannel { /* self-contained */ }
public class SmsChannel : INotificationChannel { /* self-contained */ }
public class PushChannel : INotificationChannel { /* NEW -- no existing code changed */ }

public class NotificationDispatcher(IEnumerable<INotificationChannel> channels)
{
    public async Task DispatchAsync(Notification notification)
    {
        var userPrefs = notification.User.Preferences;
        var tasks = channels
            .Where(c => userPrefs.Contains(c.ChannelType))
            .Select(c => c.SendAsync(notification));
        await Task.WhenAll(tasks); // All matching channels fire in parallel
    }
}

Why the fix works: Each notification channel is its own class -- EmailChannel, SmsChannel, PushChannel. The NotificationDispatcher doesn't have a switch statement at all. It uses LINQ to filter channels matching the user's preferences, then fires them all in parallel with Task.WhenAll. There's no loop body where an accidental return can kill other channels. Adding a new channel (say, Slack) is a new class registered in DI -- the dispatcher automatically picks it up. No existing channel code is touched, and no control flow can interfere between channels.

How to Spot This in Your Code

Watch for return statements inside foreach loops with switch statements -- it's almost always a bug. If a method handles multiple "channels" or "handlers" in sequence and uses early returns or breaks, that's fragile. The safer pattern is to separate each channel into its own class and dispatch to all of them from an orchestrator that uses Task.WhenAll or a simple loop with no early exits.

Lesson Learned

With OCP, each notification channel is independent. Adding push notifications is a new class registered in DI -- it can't accidentally silence email or SMS because it never touches their code. The dispatcher uses Task.WhenAll to fire all channels in parallel.

The Incident

2023, .NET 7 analytics dashboard. A ReportExporter generated CSV, PDF, and Excel formats in one class. When adding JSON export, the developer refactored the shared FormatValue() helper to handle JSON string escaping. This change added backslash escaping to values containing quotes -- which corrupted CSV output for all reports containing customer addresses with quotes (e.g., 123 "Main" St).

Imagine a translation office where all translators share the same dictionary. The French translator adds a note next to "chat" -- "in French, this means cat." Now the Spanish translator looks up "chat" and gets confused, because in Spanish it means something else entirely. The shared dictionary worked fine when only one person used it, but the moment a second language needed different rules for the same word, the shared resource became a liability.

That's exactly what happened with the FormatValue() helper. CSV files and JSON files both deal with quotes in text, but they handle them completely differently. In CSV, you escape a quote by doubling it: " becomes "". In JSON, you escape it with a backslash: " becomes \". The developer needed JSON escaping, so they changed the shared FormatValue() method to use backslash escaping. But that same method was also called by the CSV export code.

The result: CSV files with customer addresses like 123 "Main" St now contained 123 \"Main\" St -- backslash-escaped quotes that broke every CSV parser trying to import the data. Excel showed garbage. Accounting tools rejected the files. Customer data imports failed silently, with addresses being split across wrong columns.

The bug went unnoticed for 3 days because CSV exports happened on a nightly batch schedule. By the time the accounting team noticed the corrupted files on Monday morning, three days of reports were bad. Nobody connected "we launched JSON export on Friday" with "CSV reports are broken on Monday" because they seemed like completely unrelated features. The detective work took hours of git blame before someone found the shared helper method.

Time to Diagnose

3 days -- CSV exports had been working for 2 years. Nobody suspected the JSON feature caused CSV corruption because they were "unrelated features." But they shared a helper method.

Shared Helper = Hidden Coupling Between Formats ExportCsv() ExportPdf() ExportExcel() ExportJson() NEW FormatValue() -- shared helper Modified for JSON: " becomes \" (broke CSV!) CSV now gets wrong escaping! OCP Fix: Each exporter has its own FormatValue() CsvExporter uses "" escaping. JsonExporter uses \ escaping. No sharing.
ReportExporter.cs -- Shared Helpers
public class ReportExporter
{
    public byte[] Export(ReportData data, string format)
    {
        return format switch
        {
            "csv" => ExportCsv(data),
            "pdf" => ExportPdf(data),
            "excel" => ExportExcel(data),
            "json" => ExportJson(data),  // NEW
            _ => throw new NotSupportedException(format)
        };
    }

    // Shared helper -- modified for JSON, broke CSV
    private string FormatValue(object value)
    {
        var str = value?.ToString() ?? "";
        str = str.Replace("\"", "\\\""); // Added for JSON, corrupts CSV!
        return str;
    }
    // All export methods call FormatValue -- change affects ALL formats
}

Walking through the buggy code: The FormatValue() helper on line 17 is used by every export method -- CSV, PDF, Excel, and now JSON. The developer added str.Replace("\"", "\\\"") on line 20 to handle JSON's backslash-escape requirement. But ExportCsv() also calls FormatValue(), and CSV doesn't use backslash escaping. CSV wraps quoted values in double-quotes and escapes internal quotes by doubling them (""). Now CSV output contains \" instead of "", which breaks every standard CSV parser.

OcpExporters.cs -- Each Format Self-Contained
public interface IReportExporter
{
    string Format { get; }
    byte[] Export(ReportData data);
}

public class CsvExporter : IReportExporter
{
    public string Format => "csv";
    public byte[] Export(ReportData data)
    {
        // Own FormatValue -- CSV-specific escaping (double-quote wrapping)
        string FormatValue(object val) => $"\"{val?.ToString()?.Replace("\"", "\"\"")}\"";
        // ... CSV generation with its own formatting logic
    }
}

public class JsonExporter : IReportExporter
{
    public string Format => "json";
    public byte[] Export(ReportData data)
    {
        // Own formatting -- JSON-specific escaping (backslash)
        return JsonSerializer.SerializeToUtf8Bytes(data);
    }
}
// Adding JsonExporter can't break CsvExporter -- different classes, different files

Why the fix works: Each export format is its own class with its own escaping logic. CsvExporter has a local FormatValue() that uses CSV-standard double-quote escaping. JsonExporter uses System.Text.Json's built-in serializer, which handles JSON escaping correctly. There's no shared helper that could accidentally cross-contaminate formats. Adding a new format (say, XML) is a new class file that implements IReportExporter -- it can't possibly affect CSV or JSON because they share no code.

How to Spot This in Your Code

Look for private helper methods inside classes that handle multiple formats, types, or strategies. If a helper is called by more than one branch of a switch statement, changing it for one format will affect all formats. The fix is to move each format into its own class where helpers are local and isolated. A quick check: search for private methods in any class that has a switch statement -- if those private methods are called from multiple cases, you have hidden coupling.

Lesson Learned

Shared helper methods in a class that handles multiple formats are a hidden coupling. When each format is its own class, format-specific logic (escaping, encoding, line endings) is encapsulated. Adding JSON export can't corrupt CSV because they share no code.

The Incident

2024, .NET 8 API. A developer added rate-limiting middleware to the ASP.NET Core pipeline. They placed UseRateLimiting() before UseAuthentication() in Program.cs. The rate limiter tracked requests by IP, not by authenticated user. Legitimate users behind a corporate proxy (shared IP) were rate-limited after 10 requests, while unauthenticated attackers from unique IPs had unlimited access.

Think of a building with security checkpoints. The plan is: first, show your ID badge (authentication), then pass the rate-limiting turnstile that allows 10 entries per person per hour. But someone installed the turnstile BEFORE the ID check. Now the turnstile doesn't know who you are, so it counts by appearance -- everyone entering through the same door (same IP address) shares the same counter. Ten employees from the same office building hit the limit, while a stranger who walks in through a different door gets unlimited access.

In ASP.NET Core, middleware runs in the order you register it in Program.cs. The developer added app.UseRateLimiting() at the top of the pipeline -- before app.UseAuthentication(). At that point in the pipeline, the request hasn't been authenticated yet. The HttpContext.User is empty. So the rate limiter falls back to tracking by IP address instead of by user identity.

This created a double problem. First, legitimate corporate users behind a shared proxy IP got rate-limited after just 10 requests combined -- one person's API calls ate into everyone else's quota. Second, attackers who used unique IPs (botnets, cloud functions) each got their own fresh quota of 10 requests, effectively bypassing the rate limiter entirely. The security feature was protecting against good users and letting bad actors through.

The team spent a full day debugging. Corporate customers reported "429 Too Many Requests" errors. The security team saw their rate limiter was active and working -- it was blocking requests. But it was blocking the wrong requests. The mismatch between "rate limiter is working" and "wrong people are being limited" made this incredibly confusing to diagnose.

Time to Diagnose

1 day -- corporate customers reported "429 Too Many Requests" errors while the security team didn't understand why their rate limiter wasn't stopping abuse.

Wrong Order Correct Order Request Rate Limiter User = ??? (by IP) Auth 10 users behind proxy = all blocked Attackers with unique IPs = unlimited Request Auth Rate Limiter User = known (by ID) Each user gets their own quota Unauthenticated = rejected before rate limit OCP Lesson: Middleware is "Open for Extension" but Order Matters Each middleware is a self-contained class (good OCP). Adding a new middleware doesn't modify existing ones (good OCP). But the composition order in Program.cs defines how they interact. OCP doesn't eliminate composition risks -- it relocates them to the composition root.
Program.cs -- Wrong Middleware Order
var app = builder.Build();

app.UseRateLimiting();    // Rate limits by IP -- auth hasn't run yet
app.UseAuthentication();  // Too late -- rate limit already applied
app.UseAuthorization();
app.MapControllers();

// Result: corporate proxy users (same IP) get rate-limited
// while attackers from unique IPs get unlimited access

Walking through the buggy code: Middleware runs top to bottom. On line 3, UseRateLimiting() fires first. At this point, the request hasn't been authenticated -- HttpContext.User is null. So the rate limiter defaults to tracking by IP address. All 50 employees behind a corporate proxy share one IP, so they collectively burn through the 10-request limit in seconds. Meanwhile, a botnet with 1,000 unique IPs gets 10 requests per IP = 10,000 total requests. Authentication on line 4 runs after, but the damage is done.

Program.cs -- Correct Middleware Order
var app = builder.Build();

app.UseAuthentication();  // Auth runs FIRST -- establishes user identity
app.UseAuthorization();   // Authz validates permissions
app.UseRateLimiting();    // Rate limits by authenticated user ID, not IP
app.MapControllers();

// Rate limiter can use HttpContext.User.Identity -- per-user limits
// Unauthenticated requests rejected before reaching rate limiter

Why the fix works: By moving authentication before rate limiting, the rate limiter now has access to the authenticated user's identity. It can track limits per user ID instead of per IP address. Each employee behind the corporate proxy gets their own 10-request quota. Unauthenticated requests are rejected by the auth middleware before they even reach the rate limiter, so attackers can't waste rate-limit capacity. The key insight: each middleware class is self-contained (good OCP), but the order you compose them in Program.cs determines how they interact. OCP guarantees that adding new middleware won't modify existing middleware code, but it doesn't guarantee the composition order is correct -- that's your responsibility in the composition root.

How to Spot This in Your Code

Review your Program.cs middleware order carefully. The general rule for ASP.NET Core is: exception handling first, then HTTPS redirection, then static files, then routing, then CORS, then authentication, then authorization, then rate limiting, then endpoints. If any middleware depends on information established by another middleware (like user identity), the dependency must come first. Write integration tests that verify middleware ordering by checking that rate-limited users are tracked by user ID, not IP.

Lesson Learned

OCP's middleware pipeline is "open for extension" -- you can add new middleware freely. But order matters. Each middleware is a self-contained class (good OCP), but the Program.cs registration order defines their interaction. OCP doesn't eliminate all composition risks -- it relocates them to the composition rootThe single place in the application where all dependencies are wired together -- typically Program.cs in .NET. This is where OCP's "extension point" lives: adding new services, middleware, or handlers without modifying existing ones..

Section 13

Pitfalls & Anti-Patterns

Mistake: Creating an interface with one implementation on day one "because we might need more later." You end up with IPaymentProcessor and exactly one StripePaymentProcessor -- forever.

Why This Happens: Developers learn about OCP and get excited. They start seeing every class as a potential extension point. "What if we need to swap out Stripe for PayPal?" So they create an interface on day one, before there's any evidence that a second implementation will ever exist. It feels like good engineering -- you're "planning ahead."

But planning ahead based on guesses is speculation, not design. The problem is that you're paying the cost of abstraction (extra files, indirection, harder debugging) today for a benefit that might never arrive. Two years later, you still have exactly one implementation. Meanwhile, every time someone clicks "Go to Definition" on the interface, they land on the interface declaration instead of the actual code. They have to take an extra step to find the real implementation. Multiply that by every developer, every day, and you've created a significant navigation tax for zero extensibility benefit.

SpeculativeGenerality.cs -- The Problem
// Day 1: "We might add more payment providers someday!"
public interface IPaymentProvider { Task Charge(decimal amount); }
public class StripeProvider : IPaymentProvider { /* only implementation */ }
// 2 years later: still only Stripe. The interface just added indirection.
WaitForTheSecondVariant.cs -- The Fix
// Day 1: Just use the concrete class directly
public class StripeProvider { public Task Charge(decimal amount) { /* ... */ } }

// Day 200: PayPal is actually needed! NOW extract the interface:
// Visual Studio: Ctrl+R, I => "Extract Interface" (30 seconds)
public interface IPaymentProvider { Task Charge(decimal amount); }
public class StripeProvider : IPaymentProvider { /* unchanged */ }
public class PayPalProvider : IPaymentProvider { /* new */ }

The connection is simple: start with a concrete class. When you actually need a second implementation, extract the interface. IDE refactoring toolsVisual Studio's "Extract Interface" (Ctrl+R, I) or Rider's equivalent automatically creates an interface from a class's public methods and updates all references. Extracting an interface retroactively is trivial -- no reason to create it prematurely. make this a 30-second operation. You lose nothing by waiting, and you save yourself from maintaining phantom abstractions.

Day 1 β€” Speculative IPaymentProvider StripeProvider (only ever) 2 years, still 1 implementation Day 200 β€” Justified IPaymentProvider StripeProvider PayPalProvider Real need = real interface

Mistake: Every class gets an interface "for OCP." Your project has 200 classes and 200 interfaces, most with a single implementation.

Why This Happens: This is pitfall #1 taken to the extreme. A team adopts a rule -- "every class must have an interface" -- usually because someone read about OCP or testability. The rule sounds reasonable in a meeting room, but in practice it means that for every OrderService, there's an IOrderService with identical methods. For every UserRepository, there's an IUserRepository. The project doubles in file count overnight.

The real cost is developer experience. When you Ctrl+Click on a method call, you land on the interface instead of the code. You then have to right-click and "Go to Implementation" to find the actual logic. Multiply this by hundreds of times a day across a team, and you've created a massive productivity drain. Worse, the 1:1 interface-to-class ratio signals to new developers that multiple implementations are expected everywhere -- but they never arrive.

InterfaceExplosion.cs -- The Problem
// 200 classes, 200 interfaces, 195 of them with only ONE implementation
public interface IOrderService { Task PlaceOrder(Order order); }
public class OrderService : IOrderService { /* only implementation, forever */ }

public interface IEmailFormatter { string Format(Email email); }
public class EmailFormatter : IEmailFormatter { /* only implementation, forever */ }
InterfacesAtSeams.cs -- The Fix
// Interfaces only at SEAMS: boundaries where you expect change or need mocking
public interface IPaymentGateway { /* multiple providers: Stripe, PayPal */ }
public interface IEmailSender { /* could swap SendGrid for Mailgun */ }
public interface IUserRepository { /* could swap SQL for Cosmos DB */ }

// Internal logic? No interface needed.
public class OrderService { /* single implementation, used directly */ }
public class EmailFormatter { /* no reason to ever swap this */ }

The fix is to only create interfaces at seamsA concept from Michael Feathers' "Working Effectively with Legacy Code." A seam is a place where you can change behavior without editing the code at that point. Interfaces at boundaries (external APIs, databases, third-party libs) are natural seams. -- boundaries where you genuinely expect multiple implementations or need to mock an external dependency for testing. Payment gateways, email providers, database repositories -- these are natural seams. Internal business logic classes that will never have alternatives don't need interfaces.

BAD: Interface per Class IOrderSvc OrderSvc IEmailFmt EmailFmt IMapper Mapper ... 200 interfaces, 195 with 1 impl GOOD: Interfaces at Seams IPaymentGateway IEmailSender IRepository OrderService (no interface) 5 seams, not 200 wrappers

Mistake: Designing an interface around the first implementation's specifics instead of the general contract.

Why This Happens: When you create an interface by extracting it from an existing class, it's natural to use that class's types and naming. You started with Stripe, so the interface uses StripeCharge as the return type and stripeCustomerId as the parameter name. It works perfectly for Stripe -- but it's impossible for PayPal to implement, because PayPal doesn't have "Stripe charges" or "Stripe customer IDs."

This defeats the entire purpose of OCP. You created an interface for extensibility, but the interface itself is locked to one vendor. It's like designing a "universal remote control" that only has buttons labeled with Sony's specific model numbers -- no other brand can use it.

LeakyAbstraction.cs -- The Problem
// Interface leaks Stripe-specific details
public interface IPaymentProcessor
{
    Task<StripeCharge> ChargeAsync(string stripeCustomerId, decimal amount);
    Task<StripeRefund> RefundAsync(string chargeId);
}
// Can't implement PayPal -- it doesn't have StripeCharge or stripeCustomerId!
GenericContract.cs -- The Fix
// Generic contract -- any payment provider can implement this
public interface IPaymentProcessor
{
    Task<PaymentResult> ChargeAsync(string customerId, decimal amount);
    Task<RefundResult> RefundAsync(string transactionId);
}

// PaymentResult is YOUR type, not Stripe's or PayPal's
public record PaymentResult(bool Success, string TransactionId, string? Error);

The fix is to design interfaces around what the consumer needs, not what the first provider gives you. Use your own return types (PaymentResult, RefundResult) that any provider can map to. Use generic parameter names (customerId not stripeCustomerId). The interface should describe the capability ("charge a customer") without revealing the implementation ("use Stripe's API").

BAD: Leaks Stripe Details IPaymentProcessor StripeCharge Β· stripeCustomerId Stripe OK PayPal CANT Locked to one vendor GOOD: Generic Contract IPaymentProcessor PaymentResult Β· customerId Stripe OK PayPal OK Any provider can implement

Mistake: Interpreting OCP as "never modify existing code, ever." This leads to bizarre workarounds where developers add layers of indirection to avoid touching a class that genuinely needs a bug fix.

Why This Happens: The word "closed" sounds absolute. Developers take it literally and refuse to edit any existing class, even when there's a genuine bug. Instead of fixing a null-check in PaymentHandler, they create a NullSafePaymentDecorator that wraps it. Instead of optimizing a slow query, they add a caching layer on top. The codebase accumulates workaround layers instead of direct fixes.

This misunderstanding creates "wrapper hell" -- classes decorated two or three levels deep because nobody wanted to touch the original. The debugging experience becomes a nightmare: you trace through LoggingPaymentDecorator to CachingPaymentDecorator to NullSafePaymentDecorator to finally reach PaymentHandler -- all because someone was afraid to add a null check to the original class.

FrozenMisunderstanding.cs -- The Problem
// "Can't modify PaymentHandler! OCP says it's closed!"
// So instead of fixing the bug directly...
public class NullSafePaymentDecorator : IPaymentHandler
{
    private readonly PaymentHandler _inner;
    public Task<PaymentResult> ProcessAsync(Order order)
    {
        if (order == null) return Task.FromResult(PaymentResult.Failed("Null order"));
        return _inner.ProcessAsync(order); // Just to add a null check!
    }
}
// Ridiculous -- just fix the null check in PaymentHandler directly!
ClosedMeansNoNewVariants.cs -- The Fix
// "Closed" means: don't add new behavioral variants by editing this class
// "Closed" does NOT mean: never fix bugs, never optimize, never refactor

// These are ALL fine to do inside existing classes:
// - Fix a null reference bug
// - Optimize a slow algorithm
// - Apply a security patch
// - Refactor internal methods for clarity
// - Update dependencies

// What you should NOT do: add a new "case" for a new feature type
// That's what OCP's "open for extension" is for.

"Closed for modification" means closed to adding new behavioral variants. You should absolutely fix bugs, improve performance, patch security holes, and refactor internals. The "closed" boundary is about the public contract and feature branches, not about making code read-only. If a class has a bug, fix the bug. Don't wrap it in a decorator.

BAD: Wrapper Hell LogDecorator > CacheDecorator > NullSafeDecor 3 wrappers to avoid a 1-line fix Debugging nightmare GOOD: Just Fix the Bug PaymentHandler + null check (1 line) Bug fix = fine. New feature = extend.

Mistake: Reflexively replacing every switch statement with a strategy pattern, even when the variants are stable and well-known.

Why This Happens: After learning about OCP violations in switch statements, developers start seeing every switch as a problem. They create IDayClassifier with WeekdayClassifier and WeekendClassifier implementations -- for classifying days of the week. They build an IHttpMethodHandler strategy for GET, POST, PUT, DELETE. But days of the week haven't changed in thousands of years, and HTTP methods are defined by an RFC standard. These switches will never get a new case.

The key question is: does this switch attract modifications? If a switch hasn't changed in 6 months and nobody expects new cases, it's stable. Replacing it with a strategy pattern adds files, classes, DI registrations, and cognitive load for zero extensibility benefit. OCP is medicine for modification magnets -- don't prescribe it for healthy code.

StableSwitch.cs -- This is FINE
// This switch is FINE -- days of the week won't change
public static string GetDayType(DayOfWeek day) => day switch
{
    DayOfWeek.Saturday or DayOfWeek.Sunday => "Weekend",
    _ => "Weekday"
};
// Don't create IDayClassifier with 2 implementations for this!
ModificationMagnet.cs -- This NEEDS OCP
// This switch is a problem -- new discount types added every quarter
public decimal CalculateDiscount(string type, decimal amount) => type switch
{
    "student" => amount * 0.10m,
    "corporate" => amount * 0.15m,
    "employee" => amount * 0.25m,
    // "loyalty" added Q1, "referral" added Q2, "seasonal" added Q3...
    _ => 0m
};
// This IS a modification magnet -- extract to IDiscountRule!

Apply OCP to modification magnets -- switch statements that get edited every sprint. Stable switchesSwitch statements where the cases are unlikely to change: HTTP methods (GET, POST, PUT, DELETE), days of the week, boolean flags, or well-defined enums. These don't need OCP because they don't attract modifications. (HTTP status codes, days of the week, enum mappings) are perfectly fine as-is. The litmus test: run git log on the file. If the switch hasn't been touched in months, leave it alone.

Stable Switch = FINE DayOfWeek: Sat/Sun = Weekend Hasn't changed in thousands of years Leave it alone Mod Magnet = REFACTOR DiscountType: new case every quarter student, corporate, loyalty, referral... Extract to IDiscountRule

Mistake: Creating one massive interface for extension instead of small, focused ones. New implementations must provide 20+ methods even if they only need 3.

Why This Happens: When a team creates their first OCP interface, they tend to throw everything into it. "We need payments, refunds, subscriptions, invoicing, and webhooks -- let's put it all in IPaymentProvider." The first implementation (Stripe) supports all 20 methods, so it works great. But when PayPal comes along and only supports charging and refunding (no invoicing), the developer is forced to write throw new NotSupportedException() for 15 methods. That's not OCP -- that's a trap.

A god interface also creates a coupling explosion. Every consumer depends on the full interface, even if they only use one method. If you add a method to the interface, every implementation must be updated -- even ones that don't care about the new capability. This is the exact opposite of "closed for modification."

GodInterface.cs -- The Problem
// Forces all payment providers to implement everything
public interface IPaymentProvider
{
    Task Charge(decimal amount);
    Task Refund(string txnId);
    Task Subscribe(string planId);       // Not all providers support subscriptions
    Task CreateInvoice(InvoiceData data); // Not all providers do invoicing
    Task HandleWebhook(string payload);
    // ... 15 more methods
}
// PayPal: throw NotSupportedException() for 15 methods. Yikes.
FocusedInterfaces.cs -- The Fix
// Small, focused interfaces (ISP + OCP together)
public interface IPaymentCharger { Task<ChargeResult> ChargeAsync(decimal amount); }
public interface IPaymentRefunder { Task<RefundResult> RefundAsync(string txnId); }
public interface ISubscriptionManager { Task SubscribeAsync(string planId); }

// Stripe supports all three:
public class StripeProvider : IPaymentCharger, IPaymentRefunder, ISubscriptionManager { }

// PayPal only supports two -- no dummy methods needed:
public class PayPalProvider : IPaymentCharger, IPaymentRefunder { }

Combine OCP with ISPInterface Segregation Principle -- clients should not be forced to depend on methods they don't use. Small, role-specific interfaces are better than one large interface. ISP and OCP work together: ISP keeps extension points focused, OCP keeps them extensible. (Interface Segregation Principle). Small, focused interfaces are easier to implement, easier to extend, and don't force providers to write dummy methods for capabilities they don't support. Each provider implements only the interfaces it can actually fulfill.

BAD: God Interface IPaymentProvider (20 methods) Charge Β· Refund Β· Subscribe Β· Invoice Β· Webhook... PayPal: 15x NotSupported! GOOD: Focused Interfaces ICharger IRefunder ISubscription PayPal implements only what it can No dummy methods needed

Mistake: Adding virtual methods, protected hooks, or configuration flags to a base class every time a new subclass needs different behavior. The base class grows endlessly.

Why This Happens: A team uses inheritance for OCP. The base class has a few virtual methods. Then a subclass needs a hook that doesn't exist, so someone adds a new virtual method to the base class. Another subclass needs access to internal state, so someone marks a field protected. A third subclass needs conditional behavior, so someone adds a bool flag. Over time, the base class accumulates hooks, flags, and protected state for every subclass's unique needs.

This is ironic: you're modifying the base class to "support extension." That's the exact opposite of OCP -- the base class is open for modification, not closed. Every change to the base class risks breaking all existing subclasses. This is called the fragile base classA problem where changes to a base class unexpectedly break derived classes. If you keep adding virtual methods or protected state to a base class, every subclass becomes increasingly fragile and coupled to the base class's implementation details. problem.

FragileBaseClass.cs -- The Problem
public abstract class BaseExporter
{
    protected bool IncludeHeaders = true;       // Added for CsvExporter
    protected virtual string Delimiter => ",";  // Added for TsvExporter
    protected virtual string LineEnding => "\n"; // Added for WindowsExporter
    protected virtual bool QuoteValues => false; // Added for SafeExporter

    // Base class keeps growing to support each new subclass!
    // Change any of these and ALL subclasses might break.
}
CompositionOverInheritance.cs -- The Fix
// Each exporter is independent -- no shared base class to break
public interface IExporter { byte[] Export(ReportData data); }
public class CsvExporter : IExporter { /* own logic, own config */ }
public class TsvExporter : IExporter { /* own logic, own config */ }
public class JsonExporter : IExporter { /* own logic, own config */ }
// Adding JsonExporter doesn't touch CsvExporter or any base class.

Prefer composition over inheritance. Use interfaces and inject implementations via DI. Each implementation is fully self-contained -- no shared base class to grow or break. If inheritance is genuinely necessary, design the extension points upfront and keep them stable. If you find yourself adding virtual or protected members to the base class for new subclasses, that's a signal to switch to interfaces.

BAD: Fragile Base Class BaseExporter + virtual Delimiter (for Tsv) + virtual LineEnding (for Windows) Base grows with every subclass Change base = break all subtypes GOOD: Independent Impls IExporter CsvExporter TsvExporter JsonExporter No shared base to break

Mistake: Using an enum as the branching mechanism and scattering switch statements across multiple classes -- all of which need updating when a new enum value is added.

Why This Happens: Enums feel clean and type-safe. OrderType.Express is nicer than a magic string. But the problem isn't the enum itself -- it's what happens when you switch on it in multiple places. The PricingService has a switch on OrderType. The ShippingService has another switch on OrderType. The NotificationService has yet another. When you add OrderType.SameDay, you need to update every single switch across the entire codebase. Miss one and you get a runtime exception -- or worse, silent wrong behavior.

This is a "shotgun surgery" anti-pattern: one logical change (add a new order type) requires modifications scattered across many files. The enum acts as a coordination point that couples every class that uses it. And the compiler won't always warn you about missing cases, especially if the switch has a default branch.

EnumBranching.cs -- The Problem
public enum OrderType { Standard, Express, Overnight, /* NEW: SameDay */ }

// Every class that switches on OrderType must be updated:
// PricingService: switch (orderType) { ... }   -- needs SameDay case
// ShippingService: switch (orderType) { ... }   -- needs SameDay case
// NotificationService: switch (orderType) { ... } -- needs SameDay case
// Adding SameDay? Update 3+ files. Miss one? Bug.
PolymorphicOrderType.cs -- The Fix
// Each order type carries its own behavior -- no switches anywhere
public interface IOrderStrategy
{
    decimal CalculatePrice(Order order);
    TimeSpan EstimateDelivery(Order order);
    string NotificationTemplate { get; }
}

public class SameDayOrder : IOrderStrategy
{
    public decimal CalculatePrice(Order o) => o.Total * 1.5m;
    public TimeSpan EstimateDelivery(Order o) => TimeSpan.FromHours(6);
    public string NotificationTemplate => "same-day-confirmation";
}
// ONE new class. ZERO modifications to PricingService, ShippingService, etc.

Replace enum-driven switches with polymorphismThe ability to treat objects of different types through a common interface. Instead of checking "what type is this?" and branching, you call a method on the interface and the runtime dispatches to the correct implementation. This eliminates switch statements entirely.. Each enum value becomes a class that implements the interface and carries all its own behavior. Adding a new order type is one new class file -- not N modifications across N files. No switches to miss, no scattered updates, no shotgun surgery.

BAD: Shotgun Surgery Add SameDay enum value = PricingSvc ShippingSvc NotifySvc edit edit edit 3+ files changed, miss one = bug GOOD: Polymorphism Add SameDay = SameDayOrder : IOrderStrategy Price + Delivery + Template 1 new file, 0 modifications

Mistake: Designing beautiful OCP interfaces but then new-ing up implementations directly in business logic instead of resolving them through DI.

Why This Happens: A developer creates a clean IPaymentProcessor interface with Stripe and PayPal implementations. Great OCP design. But then in the OrderService, they write new StripeProcessor() directly. The interface exists, but nobody's using DI to resolve it. The result? Adding a new payment method still requires opening OrderService and adding a new case to the switch that news up implementations. You have the abstraction but no composition root to wire it.

This is like buying a universal remote control (the interface) but then hardwiring each TV's power button to a physical switch on the wall (the new statements). The remote exists, but you're not using it. The wiring is still manual.

NewUpAntiPattern.cs -- The Problem
public class OrderService
{
    public void Process(Order order)
    {
        // "Closed for modification"? Not when you new up implementations!
        IPaymentProcessor processor = order.Method switch
        {
            "stripe" => new StripeProcessor(),
            "paypal" => new PayPalProcessor(),
            _ => throw new NotSupportedException()
        };
        processor.ProcessAsync(order);
    }
    // Adding a new payment method still requires modifying this class!
}
CompositionRoot.cs -- The Fix
// Program.cs: register implementations in the composition root
builder.Services.AddScoped<IPaymentProcessor, StripeProcessor>();
builder.Services.AddScoped<IPaymentProcessor, PayPalProcessor>();
// Add CryptoProcessor? One line here. OrderService never changes.

public class OrderService(IEnumerable<IPaymentProcessor> processors)
{
    public void Process(Order order)
    {
        var processor = processors.First(p => p.Method == order.Method);
        processor.ProcessAsync(order);
    }
    // This class is TRULY closed -- it never mentions concrete types.
}

OCP requires DI to actually work. The composition root (Program.cs) is the ONE place that knows about concrete types. Business logic receives implementations through constructor injection or IEnumerable<T> -- it never creates instances directly. Without this wiring, your interfaces are just decoration over the same old switch-and-new pattern.

BAD: new in Business Code OrderService new StripeProcessor() new PayPalProcessor() Interface exists but unused Still needs switch + edit for new provider GOOD: Composition Root Program.cs: Register<IProcessor, Stripe>() OrderService(IEnumerable<IProcessor>) Never mentions concrete types

Mistake: Building a Strategy pattern for something that should just be a configuration value. Creating ITimeoutStrategy with ShortTimeout, MediumTimeout, LongTimeout classes -- when you just need an int in appsettings.json.

Why This Happens: After learning OCP, developers start seeing every variable setting as a "strategy." Timeout values? Create ITimeoutStrategy. Retry counts? Create IRetryStrategy. Feature flags? Create IFeatureToggle with 20 implementations. The problem is that these aren't behavioral changes -- they're value changes. A timeout of 30 seconds vs. 60 seconds is just a number, not a different algorithm. You don't need polymorphism to change a number.

The result is a codebase with dozens of strategy interfaces that each have implementations containing a single hardcoded value. You end up navigating through ShortTimeoutStrategy, MediumTimeoutStrategy, and LongTimeoutStrategy when all you needed was "Timeout": 30 in a config file.

OverAbstraction.cs -- The Problem
// Over-engineered: 3 classes for a single integer value
public interface ITimeoutStrategy { TimeSpan GetTimeout(); }
public class ShortTimeout : ITimeoutStrategy
{
    public TimeSpan GetTimeout() => TimeSpan.FromSeconds(5); // Just a number!
}
public class MediumTimeout : ITimeoutStrategy
{
    public TimeSpan GetTimeout() => TimeSpan.FromSeconds(30); // Still just a number!
}
JustUseConfig.cs -- The Fix
// appsettings.json: { "HttpClient": { "TimeoutSeconds": 30 } }

public class MyService(IOptions<HttpClientOptions> options)
{
    private readonly TimeSpan _timeout =
        TimeSpan.FromSeconds(options.Value.TimeoutSeconds);

    // Change the timeout? Edit appsettings.json. No code changes. No redeployment.
    // THAT'S the real "open for extension" for value changes.
}

Ask yourself: "Is this a new behavior or a new value?" If you can express the change as a number, string, or boolean, it belongs in config (IOptions<T>, appsettings.json). If the change is fundamentally different logic -- a new algorithm, a new integration, a new workflow -- that's when OCP interfaces earn their keep. Not every change needs a class. Sometimes a config value is the right answer.

BAD: Strategy for a Number ShortTimeout = 5s MediumTimeout = 30s LongTimeout = 60s 3 classes for 1 integer value GOOD: Just Use Config appsettings.json "TimeoutSeconds": 30 Value change? Edit config. No code.
Section 14

Testing Strategies

1. Test Each Implementation in Isolation

With OCP, each implementation is a standalone class. Test it independently β€” no need to test through the orchestrator.

IsolationTest.cs
[Fact]
public void CorporateDiscount_AtExactThreshold_ReturnsDiscount()
{
    var rule = new CorporateDiscount();
    var customer = new Customer { Type = "corporate", AnnualSpend = 10_000 };
    var order = new Order { Total = 100m };

    Assert.True(rule.Applies(order, customer));
    Assert.Equal(15m, rule.Calculate(order, customer));
}

[Fact]
public void CorporateDiscount_BelowThreshold_DoesNotApply()
{
    var rule = new CorporateDiscount();
    var customer = new Customer { Type = "corporate", AnnualSpend = 9_999 };

    Assert.False(rule.Applies(new Order(), customer));
}
// Each discount rule tested in its own test class β€” zero mocking needed

2. Test the Orchestrator With Fakes

The orchestrator (e.g., DiscountEngine) should be tested with fake implementations to verify composition logic, not individual rules.

OrchestratorTest.cs
[Fact]
public void DiscountEngine_SumsAllApplicableRules()
{
    var rules = new IDiscountRule[]
    {
        new FakeRule(applies: true, discount: 10m),
        new FakeRule(applies: false, discount: 50m), // Should be skipped
        new FakeRule(applies: true, discount: 5m),
    };
    var engine = new DiscountEngine(rules);

    var total = engine.Calculate(new Order { Total = 100m }, new Customer());

    Assert.Equal(15m, total); // 10 + 5 (skipped the false one)
}

private class FakeRule(bool applies, decimal discount) : IDiscountRule
{
    public bool Applies(Order o, Customer c) => applies;
    public decimal Calculate(Order o, Customer c) => discount;
}

3. Contract Tests for Interface Compliance

When you have multiple implementations of the same interface, write a contract testA test that verifies all implementations of an interface obey the same behavioral contract. You write the test once and run it against every implementation using xUnit's Theory/MemberData or NUnit's TestCaseSource. that all implementations must pass.

ContractTest.cs
public class PaymentHandlerContractTests
{
    // Every IPaymentHandler must pass these tests
    public static IEnumerable<object[]> Handlers => new[]
    {
        new object[] { new StripeHandler() },
        new object[] { new PayPalHandler() },
        new object[] { new CryptoHandler() },
    };

    [Theory, MemberData(nameof(Handlers))]
    public async Task AllHandlers_ReturnSuccessForValidOrder(IPaymentHandler handler)
    {
        var order = new Order { Total = 50m, Currency = "USD" };
        var result = await handler.ProcessAsync(order);
        Assert.True(result.IsSuccess);
        Assert.NotNull(result.TransactionId);
    }

    [Theory, MemberData(nameof(Handlers))]
    public async Task AllHandlers_RejectZeroAmount(IPaymentHandler handler)
    {
        var order = new Order { Total = 0m };
        var result = await handler.ProcessAsync(order);
        Assert.False(result.IsSuccess);
    }
}
// When you add a new handler, add it to Handlers β€” the contract tests run automatically

4. Architecture Tests (Enforce OCP at Build Time)

Use NetArchTestA .NET library for writing architecture tests that run as unit tests. It uses reflection to verify architectural rules β€” like "all classes in the Services namespace must implement an interface" or "no class should depend directly on a concrete repository." to ensure no new code violates OCP patterns.

ArchitectureTests.cs
[Fact]
public void Services_ShouldNotDependOnConcreteRepositories()
{
    var result = Types.InAssembly(typeof(Program).Assembly)
        .That().ResideInNamespace("MyApp.Services")
        .ShouldNot().HaveDependencyOn("MyApp.Infrastructure.Repositories")
        .GetResult();
    Assert.True(result.IsSuccessful, "Services must depend on interfaces, not concrete repos");
}

[Fact]
public void AllDiscountRules_MustImplementIDiscountRule()
{
    var result = Types.InAssembly(typeof(Program).Assembly)
        .That().HaveNameEndingWith("Discount")
        .Should().ImplementInterface(typeof(IDiscountRule))
        .GetResult();
    Assert.True(result.IsSuccessful);
}
Section 15

Performance Considerations

Virtual Dispatch Cost

Interface calls use virtual dispatchThe runtime mechanism for resolving which method to call when using interfaces or virtual methods. Instead of a direct call to a known address, the CPU must look up the method pointer in a table (vtable) at runtime. This adds a small overhead (~1-2ns) per call. β€” the runtime looks up the correct method via the vtableA table of function pointers that the CLR uses to resolve virtual/interface method calls at runtime. Each interface call requires an indirect jump through this table instead of a direct call, adding ~1-2 nanoseconds per call.. This adds ~1-2 nanoseconds per call.

In practice: For 99% of .NET applications, this is irrelevant. A database query takes 1-50ms. An HTTP call takes 50-500ms. Interface dispatch at 2ns is noise.

When it matters: Inner loops processing millions of items (game engines, HFT, image processing). In these cases, use concrete types or sealed classes to enable devirtualizationA JIT compiler optimization that replaces virtual/interface method calls with direct calls when it can prove only one implementation exists. Sealed classes enable this because the JIT knows no subclass can override the method. This can improve performance by 5-6x for interface calls in hot paths..

IEnumerable<T> Resolution Cost

When you inject IEnumerable<IDiscountRule>, the DI container resolves ALL registered implementations. For 5 rules, this is instant. For 500, it adds measurable overhead at startup.

Mitigation:

  • Use Lazy<IEnumerable<T>> to defer resolution
  • Use a keyed serviceA .NET 8 DI feature where you register multiple implementations with string/enum keys. Resolve a specific one with [FromKeyedServices("key")] in controllers or sp.GetRequiredKeyedService<T>("key") in code. Avoids resolving all implementations when you only need one. (since .NET 8) to resolve only the needed implementation: [FromKeyedServices("stripe")] IPaymentHandler handler
  • Cache the resolved collection if it doesn't change per-request

Sealed Classes Enable Devirtualization

The JIT compilerThe Just-In-Time compiler in .NET that converts IL (Intermediate Language) to native machine code at runtime. It can optimize sealed classes by replacing virtual dispatch with direct calls (devirtualization) since it knows no subclass can override the method. can optimize sealed classes by replacing virtual dispatch with direct calls. If your OCP implementation classes will never be subclassed, mark them sealed.

SealedForPerf.cs
// βœ“ sealed enables JIT devirtualization
public sealed class StripeHandler : IPaymentHandler
{
    public string Method => "stripe";
    public async Task<PaymentResult> ProcessAsync(Order order) { /* ... */ }
}
// The JIT can inline this when it detects only one implementation is used

Benchmark: Direct vs Interface Call

BenchmarkResults.txt
BenchmarkDotNet v0.13.12, .NET 8.0.1

| Method             | Mean       | Allocated |
|------------------- |------------|-----------|
| DirectCall         |  0.31 ns   |       0 B |
| InterfaceCall      |  1.82 ns   |       0 B |
| SealedInterface    |  0.35 ns   |       0 B |  ← JIT devirtualized!
| IEnumerable_5items |  48.7 ns   |      40 B |
| IEnumerable_LINQ   | 127.3 ns   |     120 B |

Verdict: Use interfaces freely. The 1.5ns overhead per call
is invisible in any application that touches I/O.
For hot paths, seal your classes and avoid LINQ over IEnumerable.
Section 16

Interview Pitch

30-Second Pitch

"The Open/Closed Principle says software entities should be open for extension but closed for modification. In practice, this means when I need to add a new payment method or discount rule, I create a new class that implements an interface β€” I don't edit the existing switch statement. In .NET, I use DI with IEnumerable<T> to auto-discover all implementations. The orchestrator class stays untouched, so adding new features can't break existing ones. The key insight is that OCP requires DI to work β€” without a composition root, you'd still be new-ing up implementations and editing switch statements."

60-Second Deep Pitch

"OCP is the principle that enables plugin-style architecture. Bertrand Meyer coined it in 1988 with an inheritance focus, but Robert Martin reinterpreted it for polymorphism in 1996 β€” that's the version we use today.

In ASP.NET Core, OCP is everywhere: the middleware pipeline is open for extension (add new middleware) but closed for modification (the pipeline host doesn't change). DI's IEnumerable<T> lets me register multiple implementations and resolve all of them β€” adding a new one is just a builder.Services.AddScoped call.

My rule of three: I don't abstract on the first variant, or even the second. When I find myself editing the same switch for the third time, that's when I extract an interface. Premature OCP creates interface explosion β€” 200 interfaces with single implementations. Applied at the right time, OCP makes PRs smaller (new file, not a diff across existing code), reviews easier, and merge conflicts disappear.

The testing benefit is huge: with OCP, each implementation is tested in isolation, and I write contract tests to ensure all implementations honor the interface contract."

Interview Tips
  • Always give a concrete example β€” "discount rules" or "payment handlers" are immediately relatable
  • Mention the DI connection β€” shows you understand OCP isn't just theory; it needs infrastructure
  • Show you know the limits β€” "I don't create interfaces for stable code" proves seniority
  • Link to other SOLID principles β€” "OCP works with SRP (small classes to extend) and DIP (abstractions for extension points)"
Section 17

Interview Q&A

Easy

Think First Open for what? Closed for what?

Think of a power strip. It's open for extension -- you can plug in new devices anytime. But it's closed for modification -- you don't need to rewire the strip itself to add a new device. Software should work the same way: you should be able to add new features (plug in new code) without rewriting the existing code (rewiring the strip).

OCP states that software entities (classes, modules, functions) should be open for extension -- you can add new behavior -- but closed for modification -- you shouldn't need to edit existing code to do so. In practice, this means using interfaces and dependency injection so that adding a new payment method, discount rule, or notification channel is just creating a new class -- not editing an existing switch statement.

Bertrand Meyer coined OCP in 1988 with an inheritance focus. Robert Martin reinterpreted it in 1996 to use polymorphism and interfaces instead, which is the version used in modern software development.

Great Answer Bonus "In .NET, OCP manifests through interfaces + DI. When I need a new payment provider, I create a class implementing IPaymentProcessor and register it -- the existing processor classes and orchestrator never change."
Think First How did extension change from inheritance to interfaces?

Imagine two ways to add a sunroof to a car. Meyer's approach (1988) is like modifying the original car design -- you subclass Car into SunroofCar, which inherits everything and overrides the roof part. But now every change to the base Car might break SunroofCar. Martin's approach (1996) is like making the roof a plug-in module -- any roof type (sunroof, moonroof, hardtop) can be snapped in because they all implement the same IRoof interface.

Meyer (1988): Extension via inheritance. The base class is "closed" -- you extend by subclassing and overriding virtual methods. This led to deep inheritance hierarchies and the fragile base class problem where changing the parent breaks all children.

Martin (1996): Extension via polymorphism and interfaces. Define an abstraction, create independent implementations. No hierarchy needed -- each implementation stands alone. This is the modern standard used in .NET, Java, and virtually every modern framework.

Great Answer Bonus "The shift from inheritance to composition mirrors the broader industry trend. GoF said 'favor composition over inheritance' in 1994, and Martin's OCP reinterpretation aligned with that."
Think First Where in ASP.NET Core can you add behavior without modifying existing code?

The most visible example is the middleware pipeline. Think of it like a conveyor belt in a factory -- each station (middleware) does one job as the item (request) passes through. Want to add quality inspection? Drop a new station on the belt. You don't rebuild the existing stations. In code: app.UseRateLimiting() adds rate limiting without touching the authentication middleware.

Another built-in example is DI multi-registration with IEnumerable<T>. You can register multiple implementations of the same interface -- say, three INotificationChannel implementations (email, SMS, push). The consumer resolves all of them automatically. Adding a fourth channel (Slack) is one line in Program.cs plus one new class. No existing channels are modified.

ASP.NET Core was designed with OCP as a core architectural principle. Middleware, DI, filters, model binders, and health checks are all extension points where you add new behavior without modifying existing framework code.

Great Answer Bonus "MediatR's IPipelineBehavior is another example -- you add cross-cutting concerns (validation, logging, caching) as behaviors that wrap handlers, without modifying any handler."
Think First What code patterns scream "I'll need editing when a new variant arrives"?

Look for modification magnetsCode locations that must be edited every time a new variant is added. Classic examples: switch statements on type, if/else chains on category, enum-driven branching. They attract repeated modifications and are prime candidates for OCP refactoring.: switch statements or if/else if chains that branch on type, status, or category. If the PR adds a new case to an existing switch, that's a modification to add a feature β€” classic OCP violation.

Also look for: is/as type checks, enum-driven branching, and methods with names like ProcessByType().

Great Answer Bonus "I also check git blame: if the same switch statement appears in 5+ commits over 6 months, it's a modification magnet. History proves it needs OCP."
Think First Is Strategy the only way to achieve OCP?

Strategy is the most common implementation of OCP. You define an interface for the varying behavior, create concrete classes for each variant, and inject the right one via DI. The context class (orchestrator) is closed β€” it uses the interface, never the concrete types.

But Strategy isn't the only way. OCP can also be achieved via Decorator (adding behavior by wrapping), Observer (adding subscribers), Template Method (overriding steps), or even higher-order functions (passing lambdas).

Great Answer Bonus "Strategy replaces branching. Decorator adds layers. Observer adds side effects. Template Method varies steps. All achieve OCP through different mechanisms β€” pick based on the specific extension need."
Think First Can over-applying OCP be harmful?

Don't apply OCP when the code is stable (hasn't changed in months), the variants are known and fixed (days of the week, HTTP methods), or you're building an MVP where speed matters more than extensibility.

Premature OCP creates "speculative generality" β€” interfaces with one implementation that will never have a second. This adds indirection, file count, and cognitive load for zero benefit.

Great Answer Bonus "My rule of three: don't abstract until the third variant. Two variants might be coincidence. Three is a pattern. This prevents interface explosion."
Think First Can a God class follow OCP?

SRP is a prerequisite for OCP. A class with 10 responsibilities can't be "closed" β€” it changes for 10 different reasons. You need small, focused classes (SRP) before you can make them extensible (OCP).

Together: SRP tells you how to organize (one responsibility per class). OCP tells you how to extend (new class, not modified class). SRP creates the small units that OCP can compose.

Great Answer Bonus "In our codebase, we split the God discount calculator (SRP) into individual discount rule classes. Then OCP emerged naturally β€” adding a new discount is adding a new class implementing IDiscountRule."
Easy questions covered fundamentals. Medium questions go deeper into real-world application.

Medium

Think First What happens when you register multiple implementations of the same interface?

When you register multiple services for the same interface (AddScoped<IRule, RuleA>(), AddScoped<IRule, RuleB>()), injecting IEnumerable<IRule> resolves ALL of them. The orchestrator iterates over all implementations without knowing their concrete types.

Adding a new rule is one DI registration + one new class. The orchestrator, existing rules, and tests don't change. This is OCP's "composition root" β€” the single place where extensions are wired in.

Great Answer Bonus "Since .NET 8, keyed services add another dimension: AddKeyedScoped<IPayment, StripePayment>('stripe') lets you resolve a specific implementation by key, which is useful when you don't want all implementations."
Think First Can you do this incrementally without breaking existing code?

Step 1: Identify the varying behavior β€” what changes in each case? Extract that into an interface method.

Step 2: Create one implementation per case. Move the logic from each case block into the corresponding class.

Step 3: Register all implementations in DI. The switch becomes a lookup: implementations.First(i => i.CanHandle(type)).

Step 4: Delete the switch. The orchestrator now delegates to the resolved implementation.

Do this incrementally: extract one case at a time, keeping the switch as a fallback until all cases are migrated.

Great Answer Bonus "The Strangler Fig pattern works here: the switch gradually shrinks as each case becomes its own class. You can merge each case extraction as a separate PR."
Think First Why did the industry move away from inheritance-based OCP?

Inheritance (Meyer): Base class defines virtual methods. Subclasses override to extend. Problem: deep hierarchies, fragile base class, tight coupling. Changing the base class can break all subclasses.

Composition (Martin): Define an interface. Create independent implementations. Inject via DI. No hierarchy β€” classes are flat and independent. One implementation can change without affecting others.

Great Answer Bonus "In .NET, you still see inheritance-based OCP in Template Method patterns (e.g., ControllerBase), but the dominant approach is composition via DI. ASP.NET Core's middleware pipeline is pure composition β€” no inheritance involved."
Think First What does a PR look like when adding a feature with vs. without OCP?

Without OCP: Adding a payment method means editing PaymentProcessor (adding a case), PaymentValidator (adding validation), PaymentMapper (adding mapping) β€” the PR touches 5+ existing files. Reviewers must verify no existing behavior was broken.

With OCP: Adding a payment method is one new file (the implementation) + one line in DI registration. The PR is all green (additions, no modifications). Reviewers only need to verify the new code β€” existing behavior can't be affected.

Great Answer Bonus "OCP PRs have zero merge conflicts with other developers working on different implementations. Two developers can add two different payment methods simultaneously β€” their PRs don't overlap."
Think First When would you wrap an existing class vs. swap it?

Strategy: Swaps the entire implementation. PayPal replaces Stripe. The orchestrator picks one.

Decorator: Wraps an existing implementation to add behavior. Caching wraps the real repository. Logging wraps the real service. The original class is untouched β€” new behavior is layered on top.

Use Strategy when you need alternative behaviors. Use Decorator when you need additional behaviors on top of existing ones.

Great Answer Bonus "In .NET, Scrutor's Decorate<IRepo, CachedRepo>() adds caching as a layer. The real repo is closed β€” it didn't change. Caching is the extension. This is OCP via wrapping, not swapping."
Think First Are there extension mechanisms beyond polymorphism?

Yes, through several mechanisms:

Higher-order functions: Pass a Func<T, bool> to extend filtering behavior. The function signature is the "interface."

Events/delegates: C# events let you extend behavior by subscribing. button.Click += handler; extends without modifying the button class.

Configuration: IOptions<T> lets you change behavior via config without code changes β€” though this only works for value-level extension.

Great Answer Bonus "LINQ's Where, Select, OrderBy are OCP via higher-order functions. You extend query behavior by passing lambdas β€” the LINQ operators themselves never change."
Think First Can you automate OCP compliance checks?

Architecture testsUnit tests that verify architectural rules at build time using reflection. Libraries like NetArchTest and ArchUnitNET scan your assembly to enforce rules like "services must not depend on concrete repositories" or "all handlers must implement ICommandHandler." using NetArchTest or ArchUnitNET. Write tests that verify: services depend on interfaces (not concrete classes), all discount rules implement IDiscountRule, no direct new of strategy implementations outside the composition root.

Git-based analysis: Track which files change together. If adding a new payment method always requires editing 5 existing files, OCP is being violated. Tools like CodeScene or simple git log analysis can reveal modification magnets.

Great Answer Bonus "I write a test that scans the assembly for all classes ending in 'Discount', asserts they implement IDiscountRule, and counts them against the DI registrations. If they don't match, someone bypassed the extension point."
Think First How do you identify code that attracts repeated changes?

A modification magnet is a code location that must be edited every time a new variant is added. The classic example: a switch statement in PaymentService.Process() that gets a new case every quarter.

Detection: Run git log --follow on the file. If it appears in PRs titled "Add X payment" repeatedly, it's a magnet.

Fix: Extract the varying behavior into an interface. Each variant becomes its own class. The magnet becomes an orchestrator that delegates to injected implementations.

Great Answer Bonus "Adam Tornhill's 'Your Code as a Crime Scene' calls these 'hotspots' β€” files with high change frequency and high complexity. OCP specifically targets hotspots by making them closed to modification."
Think First How does adding a new validator work in FluentValidation?

FluentValidation uses assembly scanning: AddValidatorsFromAssemblyContaining<Program>() discovers all classes that extend AbstractValidator<T>. Adding a new validator is creating a new class β€” the scanning picks it up automatically.

The validation pipeline is closed (the MediatR behavior or ASP.NET filter that triggers validation doesn't change). Each validator is open (new validators are added as new classes). The composition root (assembly scanning) is the extension point.

Great Answer Bonus "Assembly scanning is a DI pattern that makes OCP frictionless β€” you don't even need a manual registration. Just create the class in the right namespace and it's discovered. MediatR handlers work the same way."
Think First When is the right time to abstract?

Don't create an interface on the first implementation β€” that's speculative generality. Don't even on the second β€” it might be coincidence. When the third variant arrives and you're editing the same switch statement, extract the interface.

By the third time, you have enough examples to design a good abstraction. The first two implementations become concrete examples of what the interface should look like β€” not guesses about what it might need.

Great Answer Bonus "Sandi Metz: 'Duplication is far cheaper than the wrong abstraction.' The Rule of Three ensures your abstraction is based on evidence, not speculation."
Think First Is the middleware pipeline a Decorator or a Chain of Responsibility?

The middleware pipeline is a Chain of Responsibility where each middleware is a self-contained component. Adding rate limiting (UseRateLimiting()) doesn't modify the authentication middleware. Each middleware is closed β€” its behavior doesn't change when new middleware is added.

The extension point is Program.cs β€” that's where you compose the pipeline. The pipeline host is closed; new middleware classes are the extension.

Great Answer Bonus "Middleware demonstrates that OCP applies to the pipeline as a whole, but ordering still matters. UseAuthentication() before UseAuthorization() isn't optional β€” OCP says composition is safe, not that composition order is irrelevant."
Medium questions covered application. Hard questions tackle nuances, edge cases, and architecture.

Hard

Think First Can ANY class truly be 100% closed to modification?

No class can be 100% closed to all types of modification. OCP is about being closed to specific kinds of changes β€” the ones you anticipate. A DiscountEngine is closed for adding new discount types, but open for modification if you need to change how discounts are combined (sum vs max vs first-match).

The pragmatic approach: identify the axis of change that changes most frequently, and apply OCP there. Don't try to make everything extensible β€” just the modification magnets.

Great Answer Bonus "Robert Martin himself said 'no significant program can be 100% closed.' The goal is strategic closure β€” closed along the dimensions that matter, open where you need flexibility."
Think First Can you add a new payment method without changing the database?

OCP at the code level doesn't guarantee OCP at the data level. Adding a new payment handler class is clean OCP β€” but if that handler needs a new database column or table, you still need a migration.

Strategies for data-level OCP: JSON columnsDatabase columns that store JSON data (PostgreSQL's jsonb, SQL Server's nvarchar with JSON functions). Each implementation can store its own metadata without schema changes. EF Core 7+ supports mapping JSON columns to owned entity types. for extensible attributes (each payment method stores its own metadata), EAV (Entity-Attribute-Value)A database design pattern where attributes are stored as rows (entity_id, attribute_name, attribute_value) rather than columns. Allows unlimited extensibility without schema changes, but sacrifices query performance and type safety. Use sparingly β€” JSON columns are usually better. patterns for dynamic properties, or polyglot persistence (each handler uses its own data store).

Great Answer Bonus "PostgreSQL's jsonb or SQL Server's JSON support enables schema-level OCP. A PaymentMetadata JSON column lets each handler store its own data without schema changes. EF Core 7+ supports JSON column mapping."
Think First StripeHandler needs IStripeClient, PayPalHandler needs IPayPalClient β€” how does DI handle this?

Each implementation has its own constructor with its own dependencies. The DI container resolves each one independently. StripeHandler gets IStripeClient; PayPalHandler gets IPayPalClient. The orchestrator only sees IEnumerable<IPaymentHandler> β€” it doesn't know about Stripe or PayPal dependencies.

This is a key benefit: the interface is the abstraction boundary. Each implementation's dependency graph is hidden behind it.

Great Answer Bonus "This is where OCP and DIP meet. The orchestrator depends on the abstraction (IPaymentHandler). Each implementation depends on its own infrastructure. The DI container wires everything together β€” it's the only place that knows about concrete types."
Think First What if discount rules need to run in a specific order?

.NET's IEnumerable<T> resolves implementations in registration order. But relying on registration order is fragile. Better approaches:

Priority property: Add int Priority { get; } to the interface. The orchestrator sorts by priority before executing.

Chain of ResponsibilityA behavioral pattern where a request passes through a chain of handlers. Each handler decides whether to process the request or pass it to the next handler. ASP.NET Core middleware is a Chain of Responsibility β€” each middleware calls next() to pass to the next.: Each handler decides whether to handle the request or pass to the next. Order is defined by the chain wiring.

Explicit ordering: The composition root defines order explicitly β€” keeps the ordering logic in one place.

Great Answer Bonus "MediatR pipeline behaviors execute in registration order. For discount rules, I add an Order property and use .OrderBy(r => r.Priority) in the engine. The priority is encapsulated in each rule β€” the engine doesn't know or care about specific rules."
Think First Can you add features to a distributed system without modifying existing services?

At the service level, OCP manifests through event-driven architectureA design pattern where services communicate by publishing and subscribing to events. The publisher doesn't know or care who consumes the event. Adding new consumers (subscribers) doesn't require modifying the publisher β€” OCP at the service level.. An OrderService publishes an OrderPlaced event. To add a loyalty points feature, you deploy a new LoyaltyService that subscribes to the event β€” the OrderService is closed (never modified).

API-level OCP: new endpoints can be added to a service without modifying existing ones. GraphQL takes this further β€” clients "extend" their queries without backend changes.

Great Answer Bonus "Event-driven architecture is OCP at scale. The publisher is closed. Subscribers are the extension. This is why Amazon's internal mandate was 'all services must communicate via APIs/events' β€” it forces OCP at the organizational level."
Think First Is a plugin system the ultimate OCP implementation?

A plugin architecture is OCP taken to the extreme: extensions are loaded at runtime from separate assemblies. The host application is compiled and deployed. Plugins are compiled separately and dropped into a folder.

.NET supports this via AssemblyLoadContextA .NET API for loading assemblies (DLLs) at runtime into isolated contexts. Each context can have its own version of dependencies, and plugins can be unloaded when no longer needed. Used in tools like Visual Studio and Azure Functions for plugin isolation. for loading plugin DLLs at runtime. But most applications don't need this level of extensibility β€” compile-time DI registration is sufficient for internal OCP.

Great Answer Bonus "VS Code's extension system is a plugin architecture. Every .NET DI-based strategy pattern is a lightweight version of the same idea β€” just at compile time instead of runtime. The principle is identical; the deployment boundary differs."
Think First What happens if a new implementation doesn't truly substitute for the interface?

OCP says "add new implementations to extend behavior." LSP says "those implementations must be valid substitutes for the interface." Without LSP, OCP breaks: a CryptoHandler that throws NotImplementedException for RefundAsync() violates LSP and breaks the orchestrator that expects all handlers to support refunds.

LSP is OCP's safety net. It ensures that "open for extension" actually works β€” that new implementations don't surprise the consumer.

Great Answer Bonus "This is why ISP matters too. If IPaymentHandler includes RefundAsync but crypto can't refund, the interface is too wide. Split into IPaymentCharger and IPaymentRefunder. Then CryptoHandler implements only IPaymentCharger β€” LSP satisfied."
Think First What if the interface itself needs to change?

Adding a method to an interface breaks all existing implementations β€” this is modification, not extension. Strategies:

Default interface methodsA C# 8 feature that allows interfaces to provide method implementations. Existing implementations don't need to be updated when a new method with a default body is added. Solves the interface evolution problem without breaking backward compatibility. (C# 8+): Add the new method to the interface with a default implementation. Existing implementations compile without changes.

New interface: Create IPaymentHandlerV2 : IPaymentHandler with the new method. New implementations implement V2; old ones continue on V1. The orchestrator checks for V2 at runtime.

Extension interface: IRefundable as a separate interface that handlers optionally implement.

Great Answer Bonus "C# 8's default interface methods were specifically designed for this problem. Java 8 added the same feature (default methods) for the same reason β€” evolving published interfaces without breaking implementors."
Think First Is changing a feature flag "modification" or "configuration"?

Feature flags are a form of runtime OCP. The code has both the old and new behavior β€” the flag selects which one runs. The class isn't modified to change behavior; only the flag value changes.

But feature flags inside code (if (featureFlags.UseNewAlgo)) can become modification magnets themselves. Better approach: use the flag in the DI registration to select which implementation to inject. The business logic stays clean.

Great Answer Bonus "I register implementations conditionally: if (config.UseNewPricing) services.AddScoped<IPricer, NewPricer>() else services.AddScoped<IPricer, LegacyPricer>(). The flag lives in the composition root β€” business logic never sees it."
Think First How does OCP violation compound over years?

Merge conflicts: Every developer adding a new variant edits the same files β†’ constant merge conflicts β†’ wasted time.

Regression risk: Each modification to a shared switch can break existing behavior β†’ more bugs β†’ more testing β†’ slower delivery.

PR review burden: PRs that modify existing code require careful review of all unchanged lines too β†’ slower reviews β†’ longer cycle time.

Testing cost: When one method handles 10 variants, you need to retest all 10 for any change β†’ exponential test effort.

Great Answer Bonus "In one project, a 500-line payment processor had 47 commits in 6 months from 8 different developers. After applying OCP, each payment method was its own class β€” merge conflicts dropped to zero, PR size dropped by 80%, and bug rate fell by 60%."
Think First How do you handle channels (email, SMS, push), templates, priorities, and user preferences β€” all extensibly?

Layer 1 β€” Channels: INotificationChannel with EmailChannel, SmsChannel, PushChannel. Add new channels by adding classes.

Layer 2 β€” Templates: INotificationTemplate with implementations per event type. Each template knows how to render for each channel.

Layer 3 β€” Routing: INotificationRouter determines which channels a user prefers. New routing strategies (priority-based, quiet hours) are new classes.

Layer 4 β€” Pipeline: MediatR pipeline behaviors for rate limiting, deduplication, and batching. Add new behaviors without modifying existing ones.

Each layer is independently extensible. Adding a new channel doesn't affect routing. Adding a new template doesn't affect channels.

Great Answer Bonus "The key insight is multiple extension axes: channel, template, routing, and pipeline behaviors are four independent OCP axes. Each can be extended without affecting the others. This is OCP applied at the architecture level, not just the class level."
Section 18

Exercises

Easy Exercise 1: Identify OCP Violations

Find all OCP violations in this code. What would you change?

TaxCalculator.cs
public class TaxCalculator
{
    public decimal Calculate(string country, decimal amount)
    {
        return country switch
        {
            "US" => amount * 0.07m,
            "UK" => amount * 0.20m,
            "DE" => amount * 0.19m,
            "JP" => amount * 0.10m,
            // TODO: Add India, Brazil, Canada...
            _ => throw new NotSupportedException($"Country: {country}")
        };
    }
}

Violation: Every new country requires modifying this class. The TODO comment is the giveaway β€” it's a planned modification magnet.

OcpTaxCalculator.cs
public interface ITaxRule
{
    string CountryCode { get; }
    decimal Calculate(decimal amount);
}

public class UsTax : ITaxRule { public string CountryCode => "US"; public decimal Calculate(decimal amount) => amount * 0.07m; }
public class UkTax : ITaxRule { public string CountryCode => "UK"; public decimal Calculate(decimal amount) => amount * 0.20m; }
// Add new countries as new classes β€” TaxCalculator never changes

public class TaxCalculator(IEnumerable<ITaxRule> rules)
{
    public decimal Calculate(string country, decimal amount) =>
        rules.First(r => r.CountryCode == country).Calculate(amount);
}

Medium Exercise 2: Refactor Report Generator

Refactor this class to follow OCP. Requirements:

  • Support adding new report formats without modifying existing code
  • Each format should handle its own serialization
  • The caller should be able to request a specific format by name
ReportService.cs
public class ReportService
{
    public byte[] Generate(ReportData data, string format)
    {
        if (format == "pdf")
        {
            // 40 lines of PDF generation
            return PdfLibrary.Create(data);
        }
        else if (format == "csv")
        {
            var sb = new StringBuilder();
            sb.AppendLine(string.Join(",", data.Headers));
            foreach (var row in data.Rows)
                sb.AppendLine(string.Join(",", row));
            return Encoding.UTF8.GetBytes(sb.ToString());
        }
        else if (format == "excel")
        {
            // 30 lines of Excel generation
            return ExcelPackage.Create(data);
        }
        throw new NotSupportedException(format);
    }
}
OcpReportService.cs
public interface IReportFormatter
{
    string Format { get; }
    byte[] Generate(ReportData data);
}

public class PdfFormatter : IReportFormatter
{
    public string Format => "pdf";
    public byte[] Generate(ReportData data) => PdfLibrary.Create(data);
}

public class CsvFormatter : IReportFormatter
{
    public string Format => "csv";
    public byte[] Generate(ReportData data)
    {
        var sb = new StringBuilder();
        sb.AppendLine(string.Join(",", data.Headers));
        foreach (var row in data.Rows)
            sb.AppendLine(string.Join(",", row));
        return Encoding.UTF8.GetBytes(sb.ToString());
    }
}

public class ExcelFormatter : IReportFormatter
{
    public string Format => "excel";
    public byte[] Generate(ReportData data) => ExcelPackage.Create(data);
}

// Closed for modification β€” new formats don't touch this class
public class ReportService(IEnumerable<IReportFormatter> formatters)
{
    public byte[] Generate(ReportData data, string format) =>
        formatters.FirstOrDefault(f => f.Format == format)
            ?.Generate(data)
            ?? throw new NotSupportedException($"No formatter for: {format}");
}

// DI registration:
builder.Services.AddScoped<IReportFormatter, PdfFormatter>();
builder.Services.AddScoped<IReportFormatter, CsvFormatter>();
builder.Services.AddScoped<IReportFormatter, ExcelFormatter>();

Medium Exercise 3: Write Architecture Tests

Write NetArchTest rules that verify:

  • All classes in MyApp.Discounts namespace implement IDiscountRule
  • The DiscountEngine class doesn't reference any concrete discount class
  • No class in MyApp.Services has a dependency on concrete repositories
OcpArchTests.cs
using NetArchTest.Rules;

[Fact]
public void AllDiscounts_MustImplementIDiscountRule()
{
    var result = Types.InAssembly(typeof(Program).Assembly)
        .That().ResideInNamespace("MyApp.Discounts")
        .And().AreNotInterfaces()
        .Should().ImplementInterface(typeof(IDiscountRule))
        .GetResult();

    Assert.True(result.IsSuccessful,
        $"These classes don't implement IDiscountRule: {string.Join(", ", result.FailingTypeNames ?? [])}");
}

[Fact]
public void DiscountEngine_ShouldNotReferenceConcreteDiscounts()
{
    var concreteDiscounts = Types.InAssembly(typeof(Program).Assembly)
        .That().ResideInNamespace("MyApp.Discounts")
        .And().ImplementInterface(typeof(IDiscountRule))
        .GetTypes().Select(t => t.FullName!).ToArray();

    var result = Types.InAssembly(typeof(Program).Assembly)
        .That().HaveName("DiscountEngine")
        .ShouldNot().HaveDependencyOnAny(concreteDiscounts)
        .GetResult();

    Assert.True(result.IsSuccessful, "DiscountEngine references concrete discounts!");
}

[Fact]
public void Services_ShouldNotDependOnConcreteRepositories()
{
    var result = Types.InAssembly(typeof(Program).Assembly)
        .That().ResideInNamespace("MyApp.Services")
        .ShouldNot().HaveDependencyOn("MyApp.Infrastructure.Repositories")
        .GetResult();

    Assert.True(result.IsSuccessful);
}

Hard Exercise 4: Build an Extensible Pricing Engine

Design a pricing engine that supports:

  • Multiple discount types (percentage, fixed, buy-one-get-one)
  • Discount stacking rules (some discounts combine, some don't)
  • Priority ordering (employee discount before seasonal)
  • Adding new discount types without modifying existing code

Constraints: Max 2 dependencies per class. All discount rules must be independently testable. The engine must log which rules were applied.

PricingEngine.cs
public interface IDiscountRule
{
    int Priority { get; }           // Lower = runs first
    bool IsStackable { get; }       // Can combine with other discounts?
    bool Applies(OrderContext ctx);
    DiscountResult Calculate(OrderContext ctx);
}

public record DiscountResult(decimal Amount, string RuleName, string Reason);
public record OrderContext(Order Order, Customer Customer, DateTime Now);

public class PricingEngine(
    IEnumerable<IDiscountRule> rules,
    ILogger<PricingEngine> logger)
{
    public PricingResult Calculate(OrderContext ctx)
    {
        var applicable = rules
            .Where(r => r.Applies(ctx))
            .OrderBy(r => r.Priority)
            .ToList();

        var results = new List<DiscountResult>();
        var hasNonStackable = false;

        foreach (var rule in applicable)
        {
            if (hasNonStackable)
                break; // Non-stackable discount was applied β€” stop processing

            var result = rule.Calculate(ctx);
            results.Add(result);
            logger.LogInformation("Applied {Rule}: {Amount}", result.RuleName, result.Amount);

            if (!rule.IsStackable)
                hasNonStackable = true;
        }

        return new PricingResult(ctx.Order.Total, results.Sum(r => r.Amount), results);
    }
}

// Example rules β€” each is its own class, independently testable
public class PercentageDiscount : IDiscountRule
{
    public int Priority => 10;
    public bool IsStackable => true;
    public bool Applies(OrderContext ctx) => ctx.Customer.LoyaltyYears >= 1;
    public DiscountResult Calculate(OrderContext ctx) =>
        new(ctx.Order.Total * 0.05m, "Loyalty 5%", "1+ year customer");
}

public class EmployeeDiscount : IDiscountRule
{
    public int Priority => 1; // Highest priority
    public bool IsStackable => false; // Can't combine with others
    public bool Applies(OrderContext ctx) => ctx.Customer.IsEmployee;
    public DiscountResult Calculate(OrderContext ctx) =>
        new(ctx.Order.Total * 0.30m, "Employee 30%", "Internal employee");
}
Section 19

Cheat Sheet

OCP Signals (Doing It Right)

  • New features = new classes (not edited existing ones)
  • PRs are mostly additions, not modifications
  • Zero merge conflicts on feature additions
  • Each implementation is independently testable
  • DI registration is the only "modification" needed
  • Interface + IEnumerable<T> for multi-implementation
  • sealed implementations for performance
  • Contract tests verify all implementations

OCP Smells (Something's Wrong)

  • switch/if-else that gets a new case every sprint
  • Type-checking with is/as/typeof
  • Enum with switch in 3+ places across the codebase
  • 200 interfaces with 1 implementation each
  • "TODO: add new type" comments in switch blocks
  • new ConcreteClass() inside business logic
  • Shared helper methods that change when new variants are added
  • PRs that modify 5+ existing files to add one feature

Decision Framework

when-to-apply-ocp.txt
Is this a MODIFICATION MAGNET?
  β”œβ”€β”€ NO β†’ Leave it. Switch is fine.
  └── YES β†’ Has it changed 3+ times?
        β”œβ”€β”€ NO β†’ Wait. Rule of Three.
        └── YES β†’ Apply OCP:
              β”œβ”€β”€ Swapping behavior? β†’ Strategy pattern
              β”œβ”€β”€ Adding behavior?   β†’ Decorator pattern
              β”œβ”€β”€ Side effects?      β†’ Observer / Events
              └── Algorithm steps?   β†’ Template Method

Is this a VALUE change or BEHAVIOR change?
  β”œβ”€β”€ VALUE (timeout, count, flag) β†’ Use IOptions<T>
  └── BEHAVIOR (new algorithm)     β†’ Use interface + DI
Section 20

Deep Dive

Keyed servicesA .NET 8 DI feature that lets you register multiple implementations of the same interface with different string or enum keys. You resolve a specific one using [FromKeyedServices("key")] β€” useful when you need a specific strategy rather than all strategies. (new in .NET 8) solve a common OCP pain point: resolving a specific implementation from a multi-registration. Before keyed services, you needed a factory or IEnumerable<T> with a LINQ lookup.

KeyedServices.cs
// Registration β€” each implementation has a key
builder.Services.AddKeyedScoped<IPaymentHandler, StripeHandler>("stripe");
builder.Services.AddKeyedScoped<IPaymentHandler, PayPalHandler>("paypal");
builder.Services.AddKeyedScoped<IPaymentHandler, CryptoHandler>("crypto");

// Resolution β€” request a specific implementation by key
public class CheckoutEndpoint
{
    public static async Task<IResult> Process(
        [FromBody] CheckoutRequest req,
        [FromKeyedServices("stripe")] IPaymentHandler handler) // ← specific handler
    {
        var result = await handler.ProcessAsync(req.Order);
        return Results.Ok(result);
    }
}

// Dynamic resolution via IServiceProvider
public class PaymentService(IServiceProvider sp)
{
    public async Task<PaymentResult> ProcessAsync(Order order, string method)
    {
        var handler = sp.GetRequiredKeyedService<IPaymentHandler>(method);
        return await handler.ProcessAsync(order);
    }
}

Keyed services maintain OCP: adding a new handler is a new class + one DI registration. The PaymentService orchestrator never changes β€” it resolves by key dynamically.

MediatR's IPipelineBehavior<TRequest, TResponse> is a textbook OCP implementation for cross-cutting concerns. Each behavior wraps the handler β€” adding validation, logging, or caching without modifying handlers.

PipelineBehaviors.cs
// Validation behavior β€” runs BEFORE every handler, automatically
public class ValidationBehavior<TReq, TRes>(
    IEnumerable<IValidator<TReq>> validators) : IPipelineBehavior<TReq, TRes>
    where TReq : IRequest<TRes>
{
    public async Task<TRes> Handle(
        TReq request,
        RequestHandlerDelegate<TRes> next,
        CancellationToken ct)
    {
        var failures = validators
            .Select(v => v.Validate(request))
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

        if (failures.Count != 0)
            throw new ValidationException(failures);

        return await next(); // βœ“ Handler is untouched β€” validation is layered on
    }
}

// Logging behavior β€” runs AROUND every handler
public class LoggingBehavior<TReq, TRes>(
    ILogger<LoggingBehavior<TReq, TRes>> log) : IPipelineBehavior<TReq, TRes>
    where TReq : IRequest<TRes>
{
    public async Task<TRes> Handle(
        TReq request,
        RequestHandlerDelegate<TRes> next,
        CancellationToken ct)
    {
        log.LogInformation("Handling {Request}", typeof(TReq).Name);
        var sw = Stopwatch.StartNew();
        var response = await next();
        log.LogInformation("Handled {Request} in {Ms}ms", typeof(TReq).Name, sw.ElapsedMilliseconds);
        return response;
    }
}

// DI registration β€” add behaviors without changing handlers
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));

Adding a new behavior (caching, authorization, metrics) is adding a new class. Existing handlers and existing behaviors remain untouched. This is Decorator + Chain of Responsibility enabling OCP.

The Specification patternA pattern where business rules are encapsulated in specification objects that can be combined using AND, OR, NOT operators. Each specification answers "does this entity match?" β€” and they compose without modifying each other. achieves OCP for complex query logic. Each business rule is a specification that can be combined without modification.

Specifications.cs
public abstract class Specification<T>
{
    public abstract Expression<Func<T, bool>> ToExpression();

    // Combinators β€” AndSpec/OrSpec/NotSpec combine Expression trees
    public Specification<T> And(Specification<T> other) => new AndSpecification<T>(this, other);
    public Specification<T> Or(Specification<T> other) => new OrSpecification<T>(this, other);
    public Specification<T> Not() => new NotSpecification<T>(this);
}

// Combinator implementations (combine expressions using Expression.AndAlso, etc.)
internal class AndSpecification<T>(Specification<T> left, Specification<T> right) : Specification<T>
{
    public override Expression<Func<T, bool>> ToExpression()
    {
        var leftExpr = left.ToExpression();
        var rightExpr = right.ToExpression();
        var param = Expression.Parameter(typeof(T));
        var body = Expression.AndAlso(
            Expression.Invoke(leftExpr, param),
            Expression.Invoke(rightExpr, param));
        return Expression.Lambda<Func<T, bool>>(body, param);
    }
}
// OrSpecification and NotSpecification follow the same pattern

// Each business rule is a separate specification β€” independently testable
public class ActiveCustomerSpec : Specification<Customer>
{
    public override Expression<Func<Customer, bool>> ToExpression()
        => c => c.IsActive && c.LastOrderDate > DateTime.UtcNow.AddMonths(-6);
}

public class HighValueCustomerSpec : Specification<Customer>
{
    public override Expression<Func<Customer, bool>> ToExpression()
        => c => c.TotalSpend > 10_000;
}

// Compose specifications β€” neither original is modified
var targetCustomers = new ActiveCustomerSpec().And(new HighValueCustomerSpec());

// Use with EF Core β€” translates to SQL WHERE clause
var customers = await db.Customers
    .Where(targetCustomers.ToExpression())
    .ToListAsync();

New business rules are new specification classes. Existing specifications are never modified. Composition (And, Or, Not) creates new behavior from existing pieces. This is OCP at the query level.

Section 21

Mini-Project: Extensible Notification System

Build a notification system that supports email, SMS, and push β€” with OCP so adding new channels requires zero modifications to existing code.

Attempt1_Switch.cs
// βœ— Every new channel requires editing this class
public class NotificationService
{
    public async Task SendAsync(string channel, string userId, string message)
    {
        switch (channel)
        {
            case "email":
                var email = await GetUserEmail(userId);
                await smtpClient.SendAsync(email, "Notification", message);
                break;
            case "sms":
                var phone = await GetUserPhone(userId);
                await twilioClient.SendSmsAsync(phone, message);
                break;
            case "push":
                var token = await GetDeviceToken(userId);
                await firebaseClient.SendPushAsync(token, message);
                break;
            default:
                throw new NotSupportedException($"Channel: {channel}");
        }
    }
    // Problems: all channel logic in one class, can't test channels independently,
    // adding Slack/Teams/webhook means editing this method
}
Attempt2_OverEngineered.cs
// βœ— Too many abstractions for 3 channels
public interface INotificationChannel { /* ... */ }
public interface INotificationRouter { /* ... */ }
public interface INotificationTemplate { /* ... */ }
public interface INotificationThrottler { /* ... */ }
public interface INotificationLogger { /* ... */ }
public interface INotificationRetryPolicy { /* ... */ }
public interface INotificationBatcher { /* ... */ }
public interface INotificationDeduplicator { /* ... */ }

// 8 interfaces, 8+ implementations, 24+ files for 3 notification channels
// YAGNI: you have email, SMS, push. You don't need a throttler or batcher yet.
// Debugging requires navigating 8 layers. New developer onboarding: weeks.

// The rule of three: you have 3 channels, not 30.
// Start simple, add abstractions when pain demands it.
INotificationChannel.cs
public interface INotificationChannel
{
    string ChannelType { get; }  // "email", "sms", "push"
    Task<bool> SendAsync(NotificationMessage message);
}

public record NotificationMessage(
    string UserId,
    string Title,
    string Body,
    Dictionary<string, string>? Metadata = null);
Channels.cs
public sealed class EmailChannel(ISmtpClient smtp, IUserRepository users)
    : INotificationChannel
{
    public string ChannelType => "email";
    public async Task<bool> SendAsync(NotificationMessage msg)
    {
        var user = await users.GetByIdAsync(msg.UserId);
        if (user?.Email is null) return false;
        await smtp.SendAsync(user.Email, msg.Title, msg.Body);
        return true;
    }
}

public sealed class SmsChannel(ITwilioClient twilio, IUserRepository users)
    : INotificationChannel
{
    public string ChannelType => "sms";
    public async Task<bool> SendAsync(NotificationMessage msg)
    {
        var user = await users.GetByIdAsync(msg.UserId);
        if (user?.Phone is null) return false;
        await twilio.SendSmsAsync(user.Phone, msg.Body);
        return true;
    }
}

public sealed class PushChannel(IFirebaseClient firebase, IDeviceTokenStore tokens)
    : INotificationChannel
{
    public string ChannelType => "push";
    public async Task<bool> SendAsync(NotificationMessage msg)
    {
        var token = await tokens.GetTokenAsync(msg.UserId);
        if (token is null) return false;
        await firebase.SendAsync(token, msg.Title, msg.Body);
        return true;
    }
}

// βœ“ Adding SlackChannel or WebhookChannel = new file, no changes above
NotificationDispatcher.cs
// βœ“ CLOSED for modification β€” never changes when new channels are added
public sealed class NotificationDispatcher(
    IEnumerable<INotificationChannel> channels,
    IUserPreferenceService prefs,
    ILogger<NotificationDispatcher> logger)
{
    public async Task<DispatchResult> DispatchAsync(NotificationMessage message)
    {
        var userPrefs = await prefs.GetChannelsAsync(message.UserId);

        var matchingChannels = channels
            .Where(c => userPrefs.Contains(c.ChannelType))
            .ToList();

        var results = await Task.WhenAll(
            matchingChannels.Select(async c =>
            {
                try
                {
                    var sent = await c.SendAsync(message);
                    logger.LogInformation(
                        "Channel {Channel} for user {User}: {Status}",
                        c.ChannelType, message.UserId, sent ? "sent" : "skipped");
                    return (c.ChannelType, Success: sent);
                }
                catch (Exception ex)
                {
                    logger.LogError(ex, "Channel {Channel} failed", c.ChannelType);
                    return (c.ChannelType, Success: false);
                }
            }));

        return new DispatchResult(results.Where(r => r.Success).Select(r => r.ChannelType));
    }
}

public record DispatchResult(IEnumerable<string> SentChannels);
Program.cs
// The ONLY place that changes when adding a new channel
builder.Services.AddScoped<INotificationChannel, EmailChannel>();
builder.Services.AddScoped<INotificationChannel, SmsChannel>();
builder.Services.AddScoped<INotificationChannel, PushChannel>();
// NEW: builder.Services.AddScoped<INotificationChannel, SlackChannel>();

builder.Services.AddScoped<NotificationDispatcher>();
builder.Services.AddScoped<IUserPreferenceService, UserPreferenceService>();

// βœ“ Adding a channel: 1 new file + 1 line in Program.cs
// βœ“ Dispatcher untouched. Existing channels untouched.
// βœ“ Each channel testable in isolation with mocked dependencies.
Section 22

Migration Guide

How to incrementally refactor an OCP-violating switch statement in a production codebase.

Step 1: Identify the Modification Magnet

Use git log to find files that change frequently for the same reason ("add new X type").

Terminal
# Find files changed most often with "add" in the commit message
git log --oneline --all --name-only | grep -E "\.cs$" | sort | uniq -c | sort -rn | head -20

# If PaymentProcessor.cs appears in 15+ commits, it's a magnet
# Look at the commit messages:
git log --oneline -- src/Services/PaymentProcessor.cs
# "Add Stripe support", "Add PayPal support", "Add crypto support" β†’ OCP needed!

Step 2: Extract Interface + First Implementation

Extract the first case from the switch into its own class implementing a new interface. Keep the switch as a fallback.

Step2.cs
// NEW: Interface based on the varying behavior
public interface IPaymentHandler
{
    string Method { get; }
    Task<PaymentResult> ProcessAsync(Order order);
}

// NEW: First implementation extracted from the switch
public class StripeHandler : IPaymentHandler
{
    public string Method => "stripe";
    public async Task<PaymentResult> ProcessAsync(Order order) { /* moved from switch */ }
}

// MODIFIED: PaymentProcessor uses new handler OR falls back to switch
public class PaymentProcessor(IEnumerable<IPaymentHandler> handlers)
{
    public async Task<PaymentResult> ProcessAsync(Order order, string method)
    {
        var handler = handlers.FirstOrDefault(h => h.Method == method);
        if (handler != null) return await handler.ProcessAsync(order);

        // Fallback: remaining cases still in switch (temporary)
        return method switch
        {
            "paypal" => await ChargePayPal(order),
            "crypto" => await ChargeCrypto(order),
            _ => throw new NotSupportedException(method)
        };
    }
}

Step 3: Migrate Remaining Cases & Delete Switch

Over subsequent PRs, extract each remaining case into its own handler. When the switch is empty, delete it.

Step3.cs
// Final state: switch is gone. PaymentProcessor is CLOSED.
public class PaymentProcessor(IEnumerable<IPaymentHandler> handlers)
{
    public async Task<PaymentResult> ProcessAsync(Order order, string method)
    {
        var handler = handlers.FirstOrDefault(h => h.Method == method)
            ?? throw new NotSupportedException($"No handler for: {method}");
        return await handler.ProcessAsync(order);
    }
}

// DI: all handlers registered
builder.Services.AddScoped<IPaymentHandler, StripeHandler>();
builder.Services.AddScoped<IPaymentHandler, PayPalHandler>();
builder.Services.AddScoped<IPaymentHandler, CryptoHandler>();

// βœ“ Next quarter: adding Apple Pay is ONE new class + ONE DI line
Key Insight

The migration is incremental. Each PR extracts one case. The switch shrinks PR by PR. At no point does the system break β€” the fallback ensures backward compatibility. This is the Strangler Fig patternA migration strategy named after strangler fig trees that grow around existing trees. In software, you gradually replace old code with new code by routing traffic to the new implementation piece by piece, until the old code can be safely removed. Works at class, module, and service levels. applied at the class level.

Section 23

Code Review Checklist

  • ☐ Does the PR add a new case to an existing switch/if-else? If yes, should this be an OCP extraction?
  • ☐ Is there a new ConcreteClass() inside business logic? Should this be injected via DI?
  • ☐ Does the interface have too many members? Would smaller, role-specific interfaces (ISP) be better?
  • ☐ Is the interface designed around the first implementation? Check for leaky abstractions (Stripe-specific types in a generic payment interface).
  • ☐ Is this a premature abstraction? Does the interface have only one implementation with no concrete plans for more?
  • ☐ Are implementations sealed? If they won't be subclassed, sealing enables JIT optimization.
  • ☐ Does the orchestrator only depend on abstractions? It should never reference concrete implementation types.
  • ☐ Are there contract tests? All implementations of the same interface should pass the same behavioral tests.
Roslyn Analyzer

Use CA1052A .NET code analysis rule that flags static holder types (classes with only static members) that aren't sealed. Part of Microsoft's built-in analyzers that run at compile time to catch design issues automatically. (static holder types should be sealed) and custom analyzers to flag OCP violations at build time. Meziantou.AnalyzerA popular NuGet package by GΓ©rald BarrΓ© that adds 100+ Roslyn analyzers for .NET best practices. Includes rules for DI patterns, async/await, string handling, and security. Install via: dotnet add package Meziantou.Analyzer. includes rules for DI best practices that catch new-ing up services.