The five solid principles of object-oriented design help us think
about code in ways that increase its maintainability and extensibility.
Without good design principles, software development slows and gets more
expensive and more frustrating as projects age. In this presentation, I will
demonstrate each principle as a way to respond to design challenges in
object-oriented code. In order to talk about object-oriented design, we first
need to define it. In order to do that, I will briefly compare and contrast it
with another popular programming paradigm. So the first thing I want to
talk about in this presentation of object-oriented design is functional
programming. Functional design—not surprisingly—utilizes functions that
implement how a system changes state. Those functions may take arguments and
then return data that implements the desired output of a program. Consider a
lamp, maybe there's one sitting next to you right now. You interact with a lamp
by toggling a switch turning it on or off. If we were to write a program to
describe how a lamp works, it might look like this example. There is the data
describing the current state of the lamp and then two functions: one for turning
the lamp on and the other to turn it off. In this example, our functions are only
concerned with this on or off state. They use some features of JavaScript to make
sure it doesn't lose any of the other data associated with the lamp, and then
sets one property. It returns a copy of the lamp data as a sort of best practice
to minimize the possibility of our code having side effects. Contrast that with
an object-oriented example of that same lamp. We see the same data in the same
commands, but the key difference here is that both the state of the program and
its behavior are co-located inside a class. Switch on and switch off no longer
take arguments but modify the state property internally. Instead of talking
about functions, we now say that the lamp class accepts switch on and switch off
as messages. And the way it responds to those messages implements the behavior
of the program. This idea of message passing is the identifying feature
of object-oriented design. A class should have one purpose and therefore only one
reason to change. The single responsibility principle challenges us
to tend toward focused code that does one thing, and does it well. Over the
course of this presentation, we'll look at the client-side of a web service
implementation. We'll consider how to make the request, handle the response, and
how the program must grow as the requirements change. This first example
retrieves information about the user's profile the client makes a request,
parses the response, and creates a new instance of the profile upon receiving
the single message, get. This program has a problem though. Get takes an argument,
the path to the remote endpoint. Since this path need not be to a profile, the
decision to build a profile on line 13 is presumptive and likely incorrect. We
can imagine that there are more endpoints than just this one for the
profile, and we need to accommodate them. In light of that, the responsibility for
creating the profile now seems separate from making requests to the remote
system. We can move the responsibility for dealing with the response out of the
client and into a new class. The client is now only responsible for
communicating with the remote system, and the new webservice profile mapper is
responsible for turning that response into our profile. This incremental change
has made it easier to introduce new endpoints to our program. We started with
one class with all the necessary features to build the profile. Now we
have two classes each with a narrower scope than the original. Objects should
be open for extension but closed for modification. The open-closed principle
apply similarly to the single responsibility principle. But instead of
looking closely at the thing to be changed, we now look at how other parts
of the system can cause changes to be necessary elsewhere. Here's the code from
the previous example after some time had passed. Only in this scenario,
the developer didn't create the mapper class. As it turns out, there were more
endpoints. And now each time an endpoint is added, the client must be enhanced to
support that endpoint. Since things outside of the client's control can
require it to change, it has violated the open-closed principle. As a side note,
long if-else blocks are frequently indicative of open-closed problems like
what you see here. The fix here is slightly different, it doesn't need to be
but you'll see in later examples that the developer is anticipating other
problems that the mapper approach wasn't well suited to address. Instead each
endpoint now has its own complete request implementation. We can ignore the
specifics of how they work. What is important is that they can implement
anything they must in order for the request to succeed the entire request is
passed in as an argument. Its path method returns that path which was previously
passed in on its own. And its extract response method implements the logic for
building the necessary object or objects from the response. Adding new endpoints
no longer requires the client to change. As an added benefit, each new end point
might be isolated enough to allow multiple developers to work on each one
independently. Avoiding design errors of this type can be a real productivity
multiplier for a team. Every sub class should be substitutable for its parent
class. Liskov substitution is something to think about every time you
create a subclass. First described in a paper by Jeannette Wing and Barbara
Liskov, it is the only topic in this list with a purely mathematical definition.
"Let theta X be a property provable about objects X of type T. Then theta Y should
be true for objects Y of type S where S is a subtype of type T." Got that? Great.
Let's move on. Sometime later, it was decided to create a generic request
class to enable some code reuse. Requests for the user's profile were
different though. Instead of being able to reuse extract response, it was
necessary to override the parent implementation to gain access to the
response object. Our conscientious developers version to their API and the
impact of that versioning was thoughtfully accounted for in the client
implementation. This change introduced a subtle error though. At some point the
decision was made to log every response that the program received. It was
implemented as a side-effect of extracting the response. When profile
request was implemented, the developer did not carry forward this detail, and
now these responses will not be logged. Even worse is the likelihood that this
omission very likely will not be discovered until there is a need to go
searching through profile responses only to discover there are no logs. The team
could undertake a large refactor here. Side-effects are undesirable in code.
Maybe the team has time to do this, but there might be a simpler option. While
not ideal, sometimes the best decision is to carry
the side-effects forward by adding one line of code and checking anywhere else
extract response was overridden. The team can quickly return to implementing
valuable features rather than undertaking a large refactor on the spot.
Add this refactor to your issues tracker. Estimate it and prioritize it. One day it
may turn out to be a valuable exercise.
Dependencies should not be forced on objects that do not use them. Interface
segregation lets us isolate each piece of our application. Remember message
passing? If we've done our job then different parts of our program can pass
messages around and not be concerned with how those messages are handled.
Authentication has been added to this web service client—probably important
seeing is how we were retrieving that personal profile information the whole
time. But look carefully. Not every request requires authentication. The
shared request implementation simply returns null for authentication. That
null is driving this if statement above and fetch is called differently if
get authentication returned something other than null. This authentication
feature is part of an interface that is not shared among our requests;
therefore, the client cannot disregard how the request implementation works. We
can isolate authentication to specific requests by introducing a new type into
our system. In this case, we've reused the browser web API request type which
exposes every aspect of a request to our request builders. The client must change—
which is unfortunate—but the refactor here acknowledges that we did not fully
understand the scope of the feature at the beginning. The extensiveness of this
change illustrates how important interface segregation really is.
Improperly defined interfaces can impact large portions of a codebase. Objects
must depend on abstractions rather than concretions. Dependency inversion ensures
that specific implementations of the dependencies in your program can be
easily interchanged. We finish with a much simpler example. This could be a
convenience method somewhere in the app for retrieving profile information.
It creates instances of the web service client and the request then fires it off
and should return an instance of profile. But how would you test this? This code is
dependent on the real web service client. You probably don't want to make real
requests when running tests. Not only is it inconvenient to depend on an
application server, you also have very little control over test conditions. It
would be difficult or impossible to induce specific failures. False negatives
would occur anytime that test server went down. If your build process is gated
by your test suite—and it should be—you wouldn't be able to deploy as long as
that test server was offline. If this convenience method took the web service
client as an argument, than anything that behaved like our client could stand in
for it. Tests could use a fake implementation or a mocking framework to
mimic the success and failure modes of the client.
Is it a problem that we're using the real implementation of profile request?
Maybe. The dependency inversion principle would lead us to pass this in as an
argument too, but replacing it is more difficult since its constructor requires
an argument. This might be another instance where this is the tip of a very
large refactoring iceberg to do this perfectly. The ability to make
incremental changes and be cognizant of diminishing returns beats perfect
adherence to these principles. Let these five solid principles of object-oriented
design be more like signposts than lane markers for your code. They guide you
towards better implementations and help you better identify and respond to
architecture problems. I thank you for watching.