Abstract
This article is the 4th of a series of seven articles I wrote (described
later on) about The Principles of Object Oriented Design, also known as SOLID.
This post describes the Open-Closed
Principle (OCP).
As a prerequisite, please read the
first 2 articles:
As architects and solution designers
we’d like to design and write robust & clean solutions.
We’d like our code to be stable,
maintainable, testable and scalable for as long as our product lives.
We’d like to implement and integrate
new features as smoothly as possible in our existing (already tested and
deployed) code, without any regression issues, and by maintaining dead-lines
and production deployments.
In addition, we’d like our code to
perform according to standard performance KPIs, and eventually, to serves its
purpose.
Such desire has many obstacles,
especially as our application evolves and our product become more complex.
More often than we’d like, we are
experiencing these obstacles which could be described as software design
smells.
The purpose of the SOLID principles
is to achieve these desires and to prevent software design smells.
In this article series we’ll
emphasize the importance of well-designed applications.
We’ll show how, by maintaining the
SOLID principles in our code, we’ll create better robust solutions, provide easier
code maintenance, faster bug fixing and the overall impact on the application
performance, especially while adding new features to existing design, as the
application evolves with new requirements.
The complete series:
Intro:
S.O.L.I.D
Content
- Introduction
- Preventing Design Smells
- OCP Implementation
- Case Study
- Object
Composition – Example
-
A design
that does NOT conform to the OCP
-
A design
that conforms to the OCP
2. Factory Method – Example
-
A design
that does NOT conform to the OCP
-
A design
that conforms to the OCP
- Summary
Introduction
The open-closed principle states
that:
Software entities
(classes, modules, functions, etc.) should be open for extension but closed
for modification.
This statement looks as a
contradiction, meaning how could any software entity be open for extension and,
at the same time, be closed for modification.
How could we extend our code without
modifying it?
Well, the answer is by using abstraction…!
The open-closed principle attacks most
of the software design smells, by guiding the developers to write code
that does NOT modify the existing code (which was already tested and
deployed), only by implementing new requirements as an extendable code.
This
could be done by using abstraction and by smartly designing our code to be
extended as the application evolves, right from the beginning.
Preventing
Design Smells
When we’ll get a new requirement,
whether it’s a new feature or a change to an existing feature, with a good
design that conform to the OCP, we could extend our code with almost never
modifying the existing code.
This design would prevent:
1.
Rigidity
– Since we don’t need to modify the existing code and won’t affect any other dependent
entities (or modules).
2.
Fragility
– Since we’ll only extend our code and won’t affect other areas that could be
broken otherwise.
3.
Immobility
– Since by extending our code, we are creating loosely coupled code, especially
to common components, which are much easier to reuse.
4.
Software Viscosity – Since we could preserve the existing design by
extending it, and won’t need to use ‘Hacks’ (new designs that are NOT compliant
to the existing design) to add new requirements.
5.
Needless Repetition – Since we’ll mostly use abstraction, and thus avoid
duplicate code.
6.
Opacity
– Since by using self-explanatory known ‘extendable designs’ we’ll provide
better, clear and relatively easy to understand code.
OCP
Implementation
As aforementioned, we could create
code that conforms to the open-closed principle by using abstraction in our
solutions designs.
In C# we could achieve abstraction by
using interfaces or abstract classes, depends on the requirement and on our
code styling.
Both ways are fine, as long as new
requirements could be extended by deriving from these abstractions, without
modifying the existing code.
Case
Study
There are numerous examples I could
use to illustrate the OCP, however in this article I’ll only present the
following two:
1.
Object
Composition - Example
-
A class is using another class (or a service) to perform
some business logic (BL) operation.
-
A new requirement indicates a change in the existing BL
operation.
-
I’ll use the following classes:
-
Person
-
PersonService
-
The Person
class uses the PersonService
class to get a list of Persons objects.
A design that does NOT conform to the OCP
using
SOLID_Examples.OCP.Services;
using
System.Collections.Generic;
namespace SOLID_Examples.OCP.BOs
{
public class Person
{
// Public properties
public int Id { get; set; }
public string Name { get; set; }
public PersonService PersonService { get; set; }
// Public methods
public List<Person> GetAllPersons()
{
return
PersonService?.GetPersonsList();
}
}
}
|
using SOLID_Examples.OCP.BOs;
using
System.Collections.Generic;
namespace
SOLID_Examples.OCP.Services
{
public class PersonService
{
public List<Person> GetPersonsList()
{
// TODO: Need to
implement.
return null;
}
}
}
|
Description:
-
We could see that the ‘Person’ class contains an object
of type ‘PersonService’.
-
And also uses its API in the GetAllPersons() method.
-
A new requirement arrives and we’ll need to change the
BL behavior.
-
For instance, we need to get all the persons-list from
another source (service).
-
This means that we’ll have to modify the ‘Person’ class
to use the new service.
-
And also to modify the GetAllPersons()
method to use the new
service API.
-
This of course would create undesired coupling and
dependency between the ‘Person’ class to the ‘PersonService’.
-
This design does NOT conform to the
open-closed principle, and in time would create software design smells in our
application, as described above.
A design
that conforms to the OCP
using
SOLID_Examples.OCP.Services;
using
System.Collections.Generic;
namespace SOLID_Examples.OCP.BOs
{
public class Person
{
// Public properties
public int Id { get; set; }
public string Name { get; set; }
public IPersonService PersonService { get; set; }
// Constructor
public Person(IPersonService personService)
{
PersonService = personService;
}
// Public methods
public List<Person> GetAllPersons()
{
return
PersonService?.GetPersonsList();
}
}
}
|
using SOLID_Examples.OCP.BOs;
using
System.Collections.Generic;
namespace
SOLID_Examples.OCP.Services
{
public interface IPersonService
{
List<Person> GetPersonsList();
}
}
|
using SOLID_Examples.OCP.BOs;
using System.Collections.Generic;
namespace
SOLID_Examples.OCP.Services
{
public class PersonService : IPersonService
{
public List<Person> GetPersonsList()
{
// TODO: Need to
implement.
return null;
}
}
}
|
using SOLID_Examples.OCP.BOs;
using
System.Collections.Generic;
namespace
SOLID_Examples.OCP.Services
{
public class PersonExternalService : IPersonService
{
public List<Person> GetPersonsList()
{
// TODO: Need to
implement.
return null;
}
}
}
|
using SOLID_Examples.OCP.BOs;
using
SOLID_Examples.OCP.Services;
namespace SOLID_Examples
{
class Program
{
static void Main(string[] args)
{
// Old requirement: Using
the PersonService.
//Person person = new
Person(new PersonService());
// New requirement: Using
the PersonExternalService.
Person person =
new Person(new PersonExternalService());
var allPersons =
person.GetAllPersons();
}
}
}
|
Description:
-
In this design we created the ‘Person’ class with the IPersonService interface, instead of a concrete
object PersonService.
-
Using the IPersonService interface, we in fact, created an
abstraction of this service.
-
This means that every service that implements this
interface could be used in the ‘Person’ class.
-
In this solution, we provided an easy way to change the
BL behavior, without modifying the ‘Person’ class nor the ‘PersonService’ class,
only by extending the code.
-
Meaning, when a new requirement arrives, we would create
a new proper service class and use it in the ‘Person’ class.
-
We created the new service PersonExternalService (that implements the IPersonService interface) to extract the
persons-list from another source.
-
We only need to modify the initialization of the
‘Person’ class to use the new service.
-
We don’t necessarily need to change all the ‘Persons’
invocations, only where needed in the code, based on the BL requirements.
-
Meaning, in some places we could use the PersonService and others the PersonExternalService service, and so on.
-
In addition, if needed, we could also set a default
service in the Person
class’s constructor.
Important:
In
this solution we also created a dependency-injection constructor to
inject the desired service into the ‘Person’ class before using it.
Any
other solution would be fine, as long as we’ll provide the desired service
before invoking the ‘person.GetAllPersons()’ method.
Examples:
-
Method injection:
var allPersons = person.GetAllPersons(new PersonExternalService());
|
-
Simple property assignment:
Person person = new Person();
person.PersonService = new PersonExternalService();
var allPersons = person.GetAllPersons();
|
-
Using configuration:
In
this example we used a configuration value (in app.config) to set the service
we’d like to use in the entire application.
If
a change in the requirement would arrive, we only need to create a new proper
service, and to modify the configuration file respectively.
using SOLID_Examples.OCP.Services;
using System;
using System.Collections.Generic;
using System.Configuration;
namespace SOLID_Examples.OCP.BOs
{
public class Person
{
// Public properties
public int Id { get; set; }
public string Name { get; set; }
public IPersonService PersonService { get; set; }
// Private static variables
private static string personServiceFullNameKey = "PersonServiceFullName";
private static string personServiceFullName;
// Constructor
public Person(IPersonService personService = null)
{
// In case the personService wasn't
// provided, create it based on configuration.
PersonService
= personService ?? CreatePersonService();
}
// Public methods
public List<Person> GetAllPersons()
{
return PersonService?.GetPersonsList();
}
// Private methods
private IPersonService CreatePersonService()
{
try
{
// Retrieve the desired service full-name from configuration.
if (personServiceFullName == null)
{
personServiceFullName =
ConfigurationManager.AppSettings[personServiceFullNameKey];
}
// Create the desired service.
return
Activator.CreateInstance(Type.GetType(personServiceFullName))
as IPersonService;
}
catch (Exception exception)
{
// TODO: Log relevant message and consider behavior.
throw new Exception(exception.Message);
}
}
}
}
|
2.
Factory
Method - Example
-
In this example I’ll create the ‘PersonServiceCreator’ class, which is a Factory-Method
Design Pattern, to decide which service to create at run-time.
-
We’ll illustrate an integration between Design
Patterns & Design Principles. (I’ll probably post another
article elaborating on this)
A design that does NOT
conform to the OCP
namespace
SOLID_Examples.OCP.Services
{
public enum PersonServiceType
{
PersonService,
PersonExternalService,
}
public static class PersonServiceCreator
{
public static IPersonService Create(PersonServiceType personServiceType)
{
IPersonService personService;
switch (personServiceType)
{
case PersonServiceType.PersonService:
personService = new PersonService();
break;
case PersonServiceType.PersonExternalService:
personService = new PersonExternalService();
break;
default:
personService = null;
break;
}
return personService;
}
}
}
|
Description:
-
We created the PersonServiceCreator class with a single ‘Create()’
method.
-
The ‘Create()’ method receives a PersonServiceType enumerator to indicate which service
to create.
-
In case a new requirement would arrive, we’ll have to
modify both the PersonServiceCreator class and the PersonServiceType enumerator.
-
This of course violates the OCP since such modification
to an already tested and deployed code could create design smells.
-
This is a very simple example, however in real-life
code, we could also experience additional dependencies, e.g. the PersonServiceCreator also performs additional dependent
operations that could be fragile when modifying this class.
-
In addition, if this creator factory is in a separate
common module, we’ll also have to build and redeploy to all other modules.
-
Thus, we need to design a solution that conforms to the
OCP and use it instead.
A design that conforms to the OCP
using System;
namespace
SOLID_Examples.OCP.Services
{
public static class PersonServiceCreator
{
public static IPersonService Create(string serviceFullName)
{
try
{
// TODO: Log relevant
message.
// TODO: Perform additional
common operations.
return
Activator.CreateInstance(Type.GetType(serviceFullName))
as IPersonService;
}
catch (Exception)
{
// TODO: Log relevant
exception and consider behavior.
throw;
}
}
}
}
|
string
personExternalServiceFullName =
"SOLID_Examples.OCP.Services.PersonExternalService";
var personService =
PersonServiceCreator.Create(personExternalServiceFullName);
var allPersons = personService.GetPersonsList();
|
Description:
-
We created the PersonServiceCreator class and the ‘Create()’ method.
-
The ‘Create()’ method receives a string of the full name
of the service we’d like to create.
-
Using the Activator.CreateInstance method we are creating the desired
service using reflection at run-time.
-
In this way we don’t need to modify this class when a
new service is required, we only need to provide its full name.
-
Meaning, whom ever is using this class is responsible to
provide the correct service full name, whether it was set as a constant or
extracted from configuration or database, it’s definitely not the
responsibility of the PersonServiceCreator class.
-
This way this class is open for extensions and closed
for modification.
Important:
-
This solution does not obligate every Factory Method
implementation.
-
Meaning, every design should be considered with respect
to the relevant requirements and to other constraints (e.g. performance).
-
This, of course, is also true for all the Design
Principles.
-
Meaning, when architecting & designing our
solutions, we should always consider different development elements and use
common-sense, and not follow any instructions blindly, even the Design
Principles.
Summary
It’s not always possible to foresee
the way our application would evolve, thus It’s not always possible to close
our design to all unpredictable changes.
Meaning, for some changes our design
would be closed, but for others, not necessarily.
Furthermore, we should NOT try
to close our design for all possible changes, since it would
result in needless complexity and over design, which in return would
burden the entire system, and we’ll achieve the exact opposite of the OCP
purpose.
Instead, we should try to estimate
the way a specific design would evolve and design the OCP accordingly.
Simply put, when designing a solution
with respect to the Open-Closed Principle, we should carefully estimate
(according to experience) the way it could evolve, and close it respectively.
In addition, we should always
remember to avoid over-engineering & needles complexity.
---
Next in the SOLID series is the Liskov Substitution Principle, which describes how
to create a proper inheritance between objects, in order to use polymorphism
correctly, without breaking our code, and in order to prevent software design
smells.
The End
Hope you enjoyed!
Appreciate your comments…
Yonatan Fedaeli
No comments:
Post a Comment