Learnixo

SOLID Principles in C# · Lesson 2 of 6

Open/Closed Principle — Extension Without Modification

What OCP Means

Open/Closed Principle:
  Software entities should be OPEN for extension
  but CLOSED for modification.

Open for extension:    you can add new behavior
Closed for modification: without changing existing code

Why it matters:
  Changing existing code risks breaking existing behavior.
  Adding new code (implementing an interface, adding a class) is safer.
  OCP pushes toward design where adding a feature = adding a class,
    not modifying ten existing files.

OCP Violation

C#
// Violates OCP: adding a new report type requires modifying GenerateReport
public sealed class ReportGenerator
{
    public byte[] GenerateReport(string reportType, ReportData data)
    {
        if (reportType == "PDF")
        {
            // PDF generation logic
            return GeneratePdf(data);
        }
        else if (reportType == "Excel")
        {
            // Excel generation logic
            return GenerateExcel(data);
        }
        else if (reportType == "CSV")
        {
            // CSV generation logic  
            return GenerateCsv(data);
        }
        // Adding "HTML" requires modifying this method ← OCP violation
        throw new ArgumentException($"Unknown report type: {reportType}");
    }
}

OCP Applied — Strategy Pattern

C#
// Interface: the extension point
public interface IReportGenerator
{
    string ReportType { get; }
    byte[] Generate(ReportData data);
}

// Each format is a separate class — no modification needed to add a new one
public sealed class PdfReportGenerator : IReportGenerator
{
    public string ReportType => "PDF";
    public byte[] Generate(ReportData data) { /* PDF logic */ return []; }
}

public sealed class ExcelReportGenerator : IReportGenerator
{
    public string ReportType => "Excel";
    public byte[] Generate(ReportData data) { /* Excel logic */ return []; }
}

public sealed class CsvReportGenerator : IReportGenerator
{
    public string ReportType => "CSV";
    public byte[] Generate(ReportData data) { /* CSV logic */ return []; }
}

// Dispatcher: closed for modification, open for extension (new generator = new class)
public sealed class ReportService
{
    private readonly IReadOnlyDictionary<string, IReportGenerator> _generators;

    public ReportService(IEnumerable<IReportGenerator> generators)
        => _generators = generators.ToDictionary(g => g.ReportType);

    public byte[] Generate(string reportType, ReportData data)
    {
        if (!_generators.TryGetValue(reportType, out var generator))
            throw new ArgumentException($"Report type '{reportType}' not supported.");

        return generator.Generate(data);
    }
}

// Adding HTML: create HtmlReportGenerator : IReportGenerator, register in DI.
// No existing code changes.

Clinical Example: Notification Channels

C#
// Prescription alert notifications — OCP violation:
// Every new channel requires modifying SendAlert
public class AlertService
{
    public void SendAlert(string channel, string patientMrn, string message)
    {
        if (channel == "SMS") { /* SMS logic */ }
        else if (channel == "Email") { /* Email logic */ }
        else if (channel == "Pager") { /* Pager logic */ }
        // Adding "Teams" or "PushNotification" modifies this ← violation
    }
}

// OCP-compliant: each channel is a separate implementation
public interface IAlertChannel
{
    string Channel { get; }
    Task SendAsync(PatientAlert alert, CancellationToken ct);
}

public sealed class SmsAlertChannel : IAlertChannel
{
    public string Channel => "SMS";
    public async Task SendAsync(PatientAlert alert, CancellationToken ct)
        => await _smsService.SendAsync(alert.PhoneNumber, alert.Message, ct);
}

public sealed class EmailAlertChannel : IAlertChannel
{
    public string Channel => "Email";
    public async Task SendAsync(PatientAlert alert, CancellationToken ct)
        => await _emailService.SendAsync(alert.EmailAddress, alert.Message, ct);
}

// AlertDispatcher is closed — adding Teams requires a new class, not a modification
public sealed class AlertDispatcher
{
    private readonly IEnumerable<IAlertChannel> _channels;

    public async Task SendAsync(PatientAlert alert, CancellationToken ct)
    {
        var channel = _channels.FirstOrDefault(c => c.Channel == alert.PreferredChannel);
        if (channel is not null)
            await channel.SendAsync(alert, ct);
    }
}

What OCP Is NOT

OCP is not:
  ✗ "Never change any code" — changing a bug fix or refactoring is fine
  ✗ "Always add abstractions" — don't add interfaces for everything
  ✗ An excuse for over-engineering — don't add extension points you don't need

OCP is:
  ✓ Identify where change is likely (new report types, new notification channels)
  ✓ Design extension points for those areas (interfaces, abstract base classes)
  ✓ Leave stable logic closed (don't change it when extensions are added)

Apply OCP where you expect extension.
Not everywhere — premature extension points are complexity without benefit.

Production issue I've seen: A clinical alert service had if (alertType == "CriticalINR") { ... } else if (alertType == "MedicationDue") { ... } else if (alertType == "FallRisk") { ... } — 14 else-if branches in one method. Every sprint added a new alert type, and every developer modified the same 200-line method, causing merge conflicts. Refactoring to IAlertTypeHandler with 14 separate classes eliminated the conflicts: each sprint's new alert type became a new file, never touching existing ones.


Key Takeaway

OCP means extending behavior by adding new code, not modifying existing code. The Strategy pattern is OCP in action: register implementations of an interface, add new behavior by adding a new class. Identify where your codebase has switch or if/else chains that grow with every new requirement — those are OCP violations waiting to cause merge conflicts and regression bugs.