Open/Closed Principle
Shows how to design components and modules that are open for extension but closed for modification using practical TypeScript patterns.
Introduction
The Open/Closed Principle (OCP) states: a class should be open for extension but closed for modification. At first glance, this seems contradictory. How can something be both open and closed?
The answer lies in abstraction. OCP means you should add new behavior without rewriting existing code. When a new requirement arrives, you extend the system through new implementations, not by editing classes that already work.
This principle is powerful because every time you modify existing code, you risk introducing bugs and breaking tests. By designing for extension, you keep proven logic sealed off from change.
In this article, we examine OCP through real scenarios: image storage systems and session export formats. We show how to take a violation and refactor it using the Strategy pattern.
The Problem: Modification-Driven Design
A Simple Example: Payment Processing
Before diving into a real application, let's see a simple, classic example: processing payments with different methods. This illustrates OCP without needing complex context.
Without OCP: Switch Statements for Each Payment Method
1public class PaymentProcessor
2{
3 public decimal ProcessPayment(string paymentMethod, decimal amount)
4 {
5 switch (paymentMethod.ToLower())
6 {
7 case "creditcard":
8 return ProcessCreditCard(amount);
9
10 case "paypal":
11 return ProcessPayPal(amount);
12
13 case "cryptocurrency":
14 // New payment method? Modify this class
15 throw new NotSupportedException("Crypto not yet supported");
16
17 default:
18 throw new NotSupportedException(
19 $"Payment method '{paymentMethod}' not supported");
20 }
21 }
22
23 private decimal ProcessCreditCard(decimal amount)
24 {
25 // Validate card, charge, return fee
26 return amount * 0.029m; // 2.9% fee
27 }
28
29 private decimal ProcessPayPal(decimal amount)
30 {
31 // Call PayPal API, process, return fee
32 return amount * 0.034m; // 3.4% fee
33 }
34}The problem: Every new payment method requires modifying PaymentProcessor. Each modification risks breaking existing methods. Testing is complex because you test all methods together.
With OCP: Strategy Pattern
1// Define the contract
2public interface IPaymentMethod
3{
4 decimal ProcessPayment(decimal amount);
5}
6
7// Each payment method is its own class
8public class CreditCardPaymentMethod : IPaymentMethod
9{
10 public decimal ProcessPayment(decimal amount)
11 {
12 // Credit card logic isolated here
13 return amount * 0.029m;
14 }
15}
16
17public class PayPalPaymentMethod : IPaymentMethod
18{
19 public decimal ProcessPayment(decimal amount)
20 {
21 // PayPal logic isolated here
22 return amount * 0.034m;
23 }
24}
25
26// New payment method - NO modification to existing code
27public class CryptocurrencyPaymentMethod : IPaymentMethod
28{
29 public decimal ProcessPayment(decimal amount)
30 {
31 // Crypto logic isolated here
32 return amount * 0.001m;
33 }
34}
35
36// The processor is closed for modification
37public class PaymentProcessor
38{
39 private readonly IPaymentMethodFactory _paymentMethodFactory;
40
41 public decimal ProcessPayment(string paymentMethod, decimal amount)
42 {
43 var method = _paymentMethodFactory.GetPaymentMethod(paymentMethod);
44
45 if (method == null)
46 throw new NotSupportedException(
47 $"Payment method '{paymentMethod}' not supported");
48
49 return method.ProcessPayment(amount);
50 }
51}Now adding a new payment method (Bitcoin, Apple Pay, Google Pay) is just a new class. PaymentProcessor never changes.
Case Study: Image Storage in Gathering
The Gathering application (a Community of Practice management system) needs to store images (session banners, community logos). Initially, Azure Blob Storage is the choice. But scaling to multiple regions and cloud providers requires AWS S3, Google Cloud Storage, or local storage options.
Without OCP, you end up modifying the storage service repeatedly. Let's see how Gathering solves this with the Strategy pattern.
The Violation: Storage-Specific Logic Mixed Together
1public class ImageStorageService
2{
3 private readonly ILogger _logger;
4
5 // This method must be modified for each new provider
6 public async Task<Result<string>> UploadImageAsync(
7 string storageProvider, Stream imageStream, string fileName)
8 {
9 switch (storageProvider.ToLower())
10 {
11 case "azure":
12 return await UploadToAzureAsync(imageStream, fileName);
13
14 case "aws":
15 // New provider? Modify this class
16 _logger.LogError("AWS S3 not yet implemented");
17 return Result.Failure<string>("Not supported");
18
19 case "gcs":
20 // Another provider? Modify again
21 _logger.LogError("GCS not yet implemented");
22 return Result.Failure<string>("Not supported");
23
24 default:
25 throw new NotSupportedException(
26 $"Storage provider '{storageProvider}' not supported");
27 }
28 }
29
30 private async Task<Result<string>> UploadToAzureAsync(
31 Stream imageStream, string fileName)
32 {
33 var blobClient = new BlobClient(
34 new Uri("https://account.blob.core.windows.net/images/file.jpg"));
35 await blobClient.UploadAsync(imageStream);
36 return Result.Success(blobClient.Uri.AbsoluteUri);
37 }
38}The problem: Every time you need a new storage provider, you must:
- Open the ImageStorageService class
- Add a new case to the switch statement
- Implement the provider logic inline
- Test the entire class again
- Risk regression in existing providers
This violates OCP: the class is closed for modification in theory but open in practice. Every new feature forces you to reopen and change it.
The Right Approach: Define an Abstraction
Gathering solves this elegantly by making storage pluggable. The key is defining an abstraction that all storage providers implement.
Step 1: Define the Contract
1// Every storage provider implements this interface
2public interface IImageStorageService
3{
4 Task<Result<string>> UploadImageAsync(
5 Stream imageStream,
6 string fileName,
7 CancellationToken cancellationToken = default);
8
9 Task<Result> DeleteImageAsync(
10 string imageUrl,
11 CancellationToken cancellationToken = default);
12}Step 2: Implement for Azure
1public sealed class AzureBlobStorageService : IImageStorageService
2{
3 private const string ContainerName = "images";
4 private readonly BlobServiceClient _blobServiceClient;
5
6 public AzureBlobStorageService(BlobServiceClient blobServiceClient)
7 {
8 _blobServiceClient = blobServiceClient;
9 }
10
11 public async Task<Result<string>> UploadImageAsync(
12 Stream imageStream, string fileName, CancellationToken cancellationToken = default)
13 {
14 try
15 {
16 var containerClient = _blobServiceClient
17 .GetBlobContainerClient(ContainerName);
18 await containerClient.CreateIfNotExistsAsync(
19 cancellationToken: cancellationToken);
20
21 var extension = Path.GetExtension(fileName).ToLower();
22 var blobName = $"{Guid.NewGuid()}{extension}";
23 var blobClient = containerClient.GetBlobClient(blobName);
24
25 imageStream.Seek(0, SeekOrigin.Begin);
26
27 await blobClient.UploadAsync(imageStream, cancellationToken);
28 return Result.Success(blobClient.Uri.AbsoluteUri);
29 }
30 catch (Exception ex)
31 {
32 return Result.Failure<string>($"Upload failed: {ex.Message}");
33 }
34 }
35
36 public async Task<Result> DeleteImageAsync(
37 string imageUrl, CancellationToken cancellationToken = default)
38 {
39 try
40 {
41 var uri = new Uri(imageUrl);
42 var blobClient = new BlobClient(uri);
43 await blobClient.DeleteIfExistsAsync(
44 cancellationToken: cancellationToken);
45 return Result.Success();
46 }
47 catch (Exception ex)
48 {
49 return Result.Failure($"Delete failed: {ex.Message}");
50 }
51 }
52}Step 3: Add AWS S3 - No Modification of Existing Code!
1// New implementation - existing code never changes
2public sealed class AwsS3StorageService : IImageStorageService
3{
4 private readonly IAmazonS3 _s3Client;
5 private readonly string _bucketName;
6
7 public AwsS3StorageService(IAmazonS3 s3Client, string bucketName)
8 {
9 _s3Client = s3Client;
10 _bucketName = bucketName;
11 }
12
13 public async Task<Result<string>> UploadImageAsync(
14 Stream imageStream, string fileName, CancellationToken cancellationToken = default)
15 {
16 try
17 {
18 var key = $"{Guid.NewGuid()}{Path.GetExtension(fileName).ToLower()}";
19
20 var putRequest = new PutObjectRequest
21 {
22 BucketName = _bucketName,
23 Key = key,
24 InputStream = imageStream
25 };
26
27 await _s3Client.PutObjectAsync(putRequest, cancellationToken);
28 return Result.Success($"https://{_bucketName}.s3.amazonaws.com/{key}");
29 }
30 catch (Exception ex)
31 {
32 return Result.Failure<string>($"Upload failed: {ex.Message}");
33 }
34 }
35
36 public async Task<Result> DeleteImageAsync(
37 string imageUrl, CancellationToken cancellationToken = default)
38 {
39 try
40 {
41 var uri = new Uri(imageUrl);
42 var key = uri.LocalPath.TrimStart('/');
43
44 await _s3Client.DeleteObjectAsync(_bucketName, key, cancellationToken);
45 return Result.Success();
46 }
47 catch (Exception ex)
48 {
49 return Result.Failure($"Delete failed: {ex.Message}");
50 }
51 }
52}Step 4: The Handler Depends on the Abstraction
Notice: the handler never changes, even when new storage providers are added.
1public class CreateSessionCommandHandler : ICommandHandler<CreateSessionCommand>
2{
3 private readonly IImageStorageService _imageStorageService;
4
5 public async Task<Result> HandleAsync(CreateSessionCommand request,
6 CancellationToken cancellationToken = default)
7 {
8 string? imageUrl = null;
9 if (request.ImageStream is not null)
10 {
11 // Works with ANY provider - Azure, AWS, GCS, local file, whatever
12 var uploadResult = await _imageStorageService.UploadImageAsync(
13 request.ImageStream,
14 request.ImageFileName,
15 cancellationToken);
16
17 if (uploadResult.IsFailure)
18 return Result.Failure(uploadResult.Error);
19
20 imageUrl = uploadResult.Value;
21 }
22
23 // Creates session with imageUrl
24 return Result.Success();
25 }
26}✓ Benefits of OCP Here:
- Closed for modification: CreateSessionCommandHandler never changes.
- Open for extension: Add GoogleCloudStorageService and register it. No existing code touched.
- Easy to test: Test each provider independently with mocks.
- No risk: A bug in AWS code cannot affect Azure.
Another Example: Session Export Formats
The same pattern applies anywhere you have variation. Here is another scenario: exporting session data in multiple formats (CSV, JSON, XML, PDF).
Without OCP
1public class SessionExportService
2{
3 public async Task<Result<string>> ExportSessionsAsync(
4 Guid communityId, string format)
5 {
6 var sessions = await _sessionRepository.GetByCommunityAsync(communityId);
7
8 switch (format.ToLower())
9 {
10 case "csv":
11 return GenerateCsv(sessions);
12 case "json":
13 return GenerateJson(sessions);
14 case "xml":
15 // New format? Modify this class
16 return Result.Failure<string>("XML not supported");
17 case "pdf":
18 // Another format? Modify again
19 return Result.Failure<string>("PDF not supported");
20 default:
21 throw new NotSupportedException();
22 }
23 }
24}With OCP
1// Define the abstraction
2public interface ISessionExportFormatter
3{
4 string FormatName { get; }
5 Task<Result<string>> ExportAsync(IEnumerable<Session> sessions);
6}
7
8// Each format is its own class
9public class CsvSessionExportFormatter : ISessionExportFormatter
10{
11 public string FormatName => "csv";
12
13 public Task<Result<string>> ExportAsync(IEnumerable<Session> sessions)
14 {
15 var csv = "Title,Speaker,Date";
16 foreach (var session in sessions)
17 {
18 csv += session.Title + "," + session.Speaker + "," +
19 session.Schedule;
20 }
21 return Task.FromResult(Result.Success(csv));
22 }
23}
24
25public class JsonSessionExportFormatter : ISessionExportFormatter
26{
27 public string FormatName => "json";
28
29 public Task<Result<string>> ExportAsync(IEnumerable<Session> sessions)
30 {
31 var json = JsonConvert.SerializeObject(sessions, Formatting.Indented);
32 return Task.FromResult(Result.Success(json));
33 }
34}
35
36public class XmlSessionExportFormatter : ISessionExportFormatter
37{
38 public string FormatName => "xml";
39
40 public Task<Result<string>> ExportAsync(IEnumerable<Session> sessions)
41 {
42 var doc = new XDocument(
43 new XElement("Sessions",
44 sessions.Select(s => new XElement("Session",
45 new XElement("Title", s.Title),
46 new XElement("Speaker", s.Speaker)
47 ))
48 )
49 );
50 return Task.FromResult(Result.Success(doc.ToString()));
51 }
52}
53
54// The service is closed for modification
55public class SessionExportService
56{
57 private readonly ISessionRepository _sessionRepository;
58 private readonly ISessionExportFormatterFactory _formatterFactory;
59
60 public async Task<Result<string>> ExportSessionsAsync(
61 Guid communityId, string format)
62 {
63 var formatter = _formatterFactory.GetFormatter(format);
64
65 if (formatter == null)
66 return Result.Failure<string>("Format not supported");
67
68 var sessions = await _sessionRepository.GetByCommunityAsync(communityId);
69 return await formatter.ExportAsync(sessions);
70 }
71}To add PDF export: create PdfSessionExportFormatter, register it in the factory. SessionExportService never changes.
Understanding the Open/Closed Principle
OCP has two dimensions:
- Closed for modification: Changes to existing behavior should not require editing existing code.
- Open for extension: New behavior is added by creating new code, not modifying old code.
The mechanism is abstraction. By defining an interface, you create a contract. New implementations satisfy the contract without changing the original code.
The Pattern: How OCP Works
- Identify variation points: Where do different implementations differ?
- Extract an abstraction: Define an interface all implementations must satisfy.
- Create implementations: Each variation becomes its own class.
- Depend on the abstraction: Your handler depends on the interface, not concrete classes.
- Wire via DI: Register implementations at startup.
TypeScript Example
The same pattern applies in TypeScript. Here is image storage in Node.js/Express:
1// The abstraction
2interface IImageStorageService {
3 uploadImage(
4 stream: NodeJS.ReadableStream,
5 fileName: string
6 ): Promise<Result<string>>;
7
8 deleteImage(imageUrl: string): Promise<Result<void>>;
9}
10
11// Azure implementation
12class AzureImageStorageService implements IImageStorageService {
13 constructor(private blobServiceClient: BlobServiceClient) {}
14
15 async uploadImage(
16 stream: NodeJS.ReadableStream,
17 fileName: string
18 ): Promise<Result<string>> {
19 const containerClient = this.blobServiceClient
20 .getContainerClient("images");
21 const extension = fileName.slice(fileName.lastIndexOf("."));
22 const blobName = crypto.randomUUID() + extension;
23 const blobClient = containerClient.getBlockBlobClient(blobName);
24
25 await blobClient.uploadStream(stream);
26 return success(blobClient.url);
27 }
28
29 async deleteImage(imageUrl: string): Promise<Result<void>> {
30 const blobClient = new BlobClient(imageUrl);
31 await blobClient.delete();
32 return success(undefined);
33 }
34}
35
36// AWS implementation - new file, no changes to existing code
37class AwsS3ImageStorageService implements IImageStorageService {
38 constructor(private s3Client: S3Client, private bucketName: string) {}
39
40 async uploadImage(
41 stream: NodeJS.ReadableStream,
42 fileName: string
43 ): Promise<Result<string>> {
44 const extension = fileName.slice(fileName.lastIndexOf("."));
45 const key = crypto.randomUUID() + extension;
46
47 const command = new PutObjectCommand({
48 Bucket: this.bucketName,
49 Key: key,
50 Body: stream,
51 });
52
53 await this.s3Client.send(command);
54 const url = "https://" + this.bucketName + ".s3.amazonaws.com/" + key;
55 return success(url);
56 }
57
58 async deleteImage(imageUrl: string): Promise<Result<void>> {
59 const url = new URL(imageUrl);
60 const key = url.pathname.slice(1);
61
62 const command = new DeleteObjectCommand({
63 Bucket: this.bucketName,
64 Key: key,
65 });
66
67 await this.s3Client.send(command);
68 return success(undefined);
69 }
70}
71
72// Handler depends on the abstraction
73class CreateSessionHandler {
74 constructor(private imageStorageService: IImageStorageService) {}
75
76 async handle(request: CreateSessionRequest): Promise<Result<Session>> {
77 let imageUrl: string | undefined;
78
79 if (request.imageStream) {
80 const result = await this.imageStorageService.uploadImage(
81 request.imageStream,
82 request.imageFileName
83 );
84
85 if (!result.isSuccess) {
86 return failure("Image upload failed");
87 }
88
89 imageUrl = result.value;
90 }
91
92 return success(session);
93 }
94}Benefits of Applying OCP
1. Lower Risk of Regressions
By adding new classes instead of modifying existing ones, you keep proven logic sealed off. Azure storage continues to work because you never touched that code.
2. Easier Testing
Test each implementation independently. An AwsS3StorageService test does not need to know about Azure. Tests are smaller and focused.
3. Scalability
As the system grows, the core service stays small. Only the number of implementations grows, each in its own file with its own tests.
4. Parallel Development
Developers can work on different implementations simultaneously. The interface clearly defines what must be implemented.
5. Runtime Flexibility
With dependency injection, you can swap implementations without code changes. Use Azure in production, local storage in tests.
Real-World Pitfalls
Pitfall 1: Over-Abstraction
Not everything needs an interface. Ask: “Will this have multiple implementations?” If no, keep it simple. Only abstract variation points.
Pitfall 2: Leaky Abstractions
If your interface exposes implementation details, subclasses struggle. Keep the interface focused and clear.
Pitfall 3: Factory Explosion
Keep the factory simple: it maps strings to implementations. Use dependency injection to inject implementations, not instantiate them inside the factory.
Checklist: Detecting OCP Violations
If you answer “yes” to any of these, OCP is likely being violated.
Conclusion
The Open/Closed Principle transforms how you think about adding features. Instead of modifying existing code (risky, fragile), you extend through new implementations (safe, isolated).
The mechanism is abstraction: define a clear interface, then create new implementations. Existing code never changes. Tests never need re-running. Risk is minimized.
OCP enables parallel development, easier testing, and lower regressions. It scales naturally: adding the tenth storage provider is as safe as adding the second.
The key insight: design for extension through abstraction, not for modification through inspection.
In the next article, we explore the Liskov Substitution Principle, which ensures that derived types are safe to use in place of their base types, a crucial property for OCP to work reliably.