From Alan Watts' lecture titled "The Silent Mind"
"To think, we use a series of images or very simple grasps of the world, and these are abstractions."
Even though I studied object-oriented programming in university, I still struggled with it once I got out into the real world and tried using it to develop software.
I think this has to do with the fact that I was unaware that object design is actually comprised of three parts:
Only learning the programming aspect, I found myself confused about what good design looked like, how to turn requirements into objects, and when and why to use concepts like abstraction, encapsulation, inheritance, and polymorphism. Yes, I could somewhat remember what these concepts were, but knowing when and why to use them was unclear to me.
In this post, I want to revisit these four main ideas — these principles of object-oriented programming — discuss why they’re beneficial and explain them with simple, relatable, and practical explanations.
Are you really aware of how your TV turns on when you press the ON button on the remote? Do you, as a user, need to know the specific sequence of 0’s and 1’s that your remote control emits to signal to the television’s receiver that it should turn on? Or is pressing the ON button good enough?
Are you really aware of how your car starts when you turn your key in the ignition? Do you, as a driver, need to know about the ignition switch, how the voltage from the battery hits the ignition coil, how an engine spark gets directed to the spark plugs, and how that ignites the fuel to make the car run? Or is turning the key and hearing a ding-ding enough?
It should be clear that we maneuver the world using abstractions — conceptual models that give us a good enough understanding of inventions that are much more complex behind the scenes. And in object-oriented programs, the inventions that we aim to simplify and make easy to use are objects.
Abstraction is a key principle of design. Since object-oriented programs can get quite large, it’s just not humanly possible to expect others to be aware of the inner-workings of each and every class or component within them. Therefore, we strive to abstract objects in a way that makes them intention-revealing, simple to use, and simple to understand.
Consider the abstraction of a washing machine.
// Options for the wash cycle
type WashOptions = {
dryLevel: 'low' | 'medium' | 'high'
temperature: 'cold',
duration: 'hour',
ecoEnabled: false
}
// The abstraction
class WashingMachine {
// Private instance variables
...
public startCycle (options: WashOptions): void {
// Parse the options
// Get access to the physical layer
// Convert options into commands
// Lots of low-level code
// And so on...
...
}
// More methods
...
}
Assuming that the only public
method right now is the startCycle
method, what else does the client need to know? Does it need to know how startCycle
works? Does it need to know about the private
instance variables within the class? Does it need to know about any other private
methods?
No, absolutely not. Just like the power-button on the TV remote, it’s simplified for public use. For someone to use this abstraction, all they need to know is the existence of the startCycle
method and how to call it. That’s it. All other details are abstracted away within the class.
// Obtain access to the abstraction
import { WashingMachine } from '../washer'
// Create an instance
const washer = new WashingMachine();
// Usage
washer.startCycle({
dryLevel: 'medium',
temperature: 'cold',
duration: 'hour',
ecoEnabled: false
});
By keeping what’s not necessary for the client to know abstracted away, we reduce confusion and make it easy for other developers to know how to use our code and extend it.
Abstraction has many uses — for both the designer and the client. There is much more that can be said on abstraction and abstraction tools like interfaces, abstract classes and types, such as the fact that they give us:
But for now, I’ll say that it’s the root principle of design and a central point of focus for many other important topics like design patterns, principles, responsibility-driven design and more.
Abstraction is the macro-level principle. Here are a few micro-level topics which are directly related.
Wouldn’t it be nice if we could reuse code or extend it for more specific use cases? That’s what inheritance is about.
To go about code reuse, in TDD, we have this technique called the Rule of Three which specifies that upon seeing duplication three times, we should refactor it into an abstraction.
The classic example I use to demonstrate is one where we rely on several different API endpoints from a front-end application but we duplicate the fetching logic across each API adapter (think /users
, /forum
, /billing
, etc).
Instead, what we could do is refactor the data-fetching logic into a common place. The abstract class is a good tool for this use case.
// Base API
export abstract class API {
protected baseUrl: string;
private axiosInstance: AxiosInstance;
constructor (baseUrl: string) {
this.baseUrl = baseUrl
this.axiosInstance = axios.create({})
this.enableInterceptors();
}
private enableInterceptors () { // Here's where you can define common refetching logic }
// Common "get" logic
protected get<T> (url: string, requestConfig?: RequestConfig): Promise<AxiosResponse<T>>{ return this.axiosInstance.get<T>(`${this.baseUrl}/${url}`, { headers: requestConfig?.headers ? requestConfig.headers : {}, params: requestConfig.params ? requestConfig.params : null, }); }
// Common "post" logic
protected post<T> (url: string, requestConfig?: RequestConfig): Promise<AxiosResponse<T>>{ return this.axiosInstance.post<T>(`${this.baseUrl}/${url}`, { headers: requestConfig?.headers ? requestConfig.headers : {}, params: requestConfig.params ? requestConfig.params : null, }); }}
And then, for each API adapter we need, we can merely subclass the base API abstract class to get access to the common functionality (public
or protected
methods and properties).
// Users API
export class UsersAPI extends API {
constructor () {
super('http://example.com/users');
}
// High-level functionality (extending it)
async getUsers (): Promise<Users> {
let response = await this.get('/'); // Use the common logic from the base class return response.data.users as Users
}
...
}
// Forum API
export class ForumAPI extends API {
constructor () {
super('http://example.com/forum');
}
// High-level functionality (extending it)
async getPosts (): Promise<Posts> {
let response = await this.get('/'); // Here, as well return response.data.posts as Posts;
}
...
}
// Billing API
export class BillingAPI extends API {
constructor () {
super('http://example.com/billing');
}
// High-level functionality (extending it)
async getPayments (): Promise<Payments> {
let response = await this.get('/'); // And here ;) return response.data.payments as Payments;
}
...
}
Inheritance relies on the principle of abstraction; with it, we gain the ability to abstract away (duplicated) low-level details to the base class (API
) so that the subclasses can focus on the (unique) high-level details (UsersAPI
, ForumAPI
, BillingAPI
).
When most developers learn about inheritance, they’re presented with trivial Animal-Dog or Person-Staff-Teacher examples which map nicely to the real world but manifest real problems with the complex hierarchies they create.
It is very important to note that concepts which exist in the real world often do not translate into useful software objects. For example, it’s probably not important to model the hierarchy of a car’s speed, dimensions, upgrades, parts, and window tint within the context of an online oil change booking form. However, in the design of a multiplayer racing game, they may very well be useful and necessary.
Why do we invent abstractions anyway? What's the point? To help us express what we need to express to meet the requirements, and to do so in a maintainable way — that’s it. As I've written about in solidbook.io, the goal of software design is to write code that:
- Meet the needs of the customer and
- Is cost-effectively changed by developers
Inheritance used properly helps #2. Therefore, inheritance is a tool we use upon finding opportunities for reuse, not to express real-world hierarchical similarities.
"At the heart of object-oriented software development there is a violation of real-world physics. We have a license to reinvent the world, because modeling the real world in our machinery is not our goal."
— Rebecca Wirfs-Brock via Object Design [2002]
If inheritance is really just about reuse (and not about world-building), then that means we can skip the hierarchy entirely and use the techniques of composition and delegation to implement inheritance.
That means we could also refactor the duplication of the HTTP logic like this.
class UsersAPI {
// Compose using dependency injection
constructor (http: API) {
this.http = http;
this.baseURL = 'https://example.com/users'
}
getUsers () {
return this.http.get(this.baseURL)
}
...
Therefore, if we encounter duplication, we have a lot of refactoring techniques:
Rule of thumb — only inherit from contracts, not concretions: If you’re going to use inheritance the classic OO way, I generally advise that you only inherit or extend from contracts (interfaces, abstract classes, types) and not from concretions (classes). There’s a design method which explains why this rule makes sense (see Responsibility-Driven Design), but as a quick rule of thumb, you’ll know if you’re using inheritance poorly if you find yourself needing to subclass a concrete class.
Objects contain state, make decisions based on that state, and relay messages to other objects asking them for help to fetch or compute things, like a network of routers or a web of atoms.
Because objects make decisions (and enact business logic) based on their state, it’s important that each object manage its own state and protect against mutations from outside classes. Failure to do so can lead to strange behavior, bugs, and an anemic domain model.
Encapsulation is the technique of making state private
. Encapsulation means that the class which owns the state decides the degree to which state can be accessed and changed. This is done through the use of public
methods.
Let’s continue to demonstrate with the washing machine example. Depending on the washing machine’s current state, some behaviours are valid while others are invalid.
For example, if the machine is currently ON
, it is valid to call turnOff()
to turn the machine OFF
. If the machine is in the WASH
state, it’s OK to call pause()
to put the machine into the PAUSED
state. But if the machine is OFF
(or ON
for that matter) it would not be valid to call PAUSE
.
Why? Because a washing machine can’t go from being OFF
to PAUSED
. It doesn’t make sense.
Encapsulation is the mechanism that governs these rules (also known as class invariants) for us.
type WashState = 'OFF' | 'ON' | 'WASH' | 'PAUSED' | 'DONE';
type Cycle = undefined | 'COOL' | 'WARM';
type MachineState = {
washState: WashState;
currentCycle: Cycle;
}
class WashingMachine {
// Encapsulation of state is achieved here by making
// state private and inaccessible directly.
private state: MachineState;
// Access is provided via public methods
public getWashState () : WashState {
return this.state.washState;
}
public getCurrentCycle (): Cycle {
return this.state.currentCycle;
}
// State is changed only through the use of public
// mutator methods
public pause (): void {
// Note this important business logic which is encapsulated
// to the correct place. The related data and behavior live
// together.
// We ONLY do the pause logic if the current state is
// 'WASH'
if (this.state.washState === 'WASH') {
// Stop washing
...
// Cue pause sound
...
// Set new state
this.state.washState = 'PAUSED'
}
}
...
}
Given this, client usage may look as follows:
washer.startCycle({
dryLevel: 'medium',
temperature: 'cold',
duration: 'hour',
ecoEnabled: false
});
console.log(washer.getWashState()); // 'WASH'
washer.pause();
console.log(washer.getWashState()); // 'PAUSED'
Encapsulation combines together the data and related behavior which — like abstraction — simplifies usage, but also acts as a single place for us to put relevant logic. With this technique, we invent single sources of truth and obtain appropriate places to locate logic once and for all.
Polymorphism is about “taking on many shapes”. It's about designing your use cases and algorithms in such a way that we always get the same high-level behavior, but we allow for dynamic low-level behavior at runtime.
I still think the best real-world examples I can provide are either:
Regardless, let's demonstrate with an example that will let me introduce you to the idea of roles from Responsibility-Driven Design (the actual way to do OO correctly as far as I know). Think about the role of a clerk at a grocery store. What’s a clerk supposed to do? Answer questions, scan products, bag your items, and prompt you for payment, right? So you could say those are the responsibilities of a clerk.
Now let’s say that there are 4 different employees that work at this grocery store. Each one can take on the role of a clerk. There’s James who is generally pretty snappy, Mariah who can be somewhat brooding, Max who is always on time (and the friendliest person you’ve ever met), and Mark who is actually the manager but will sometimes fill in when they’re short staffed.
It’s clear that each of the employees does their job slightly differently, but they still perform the responsibilities that a clerk should perform. That is, they are concrete classes which implement the contract of a Clerk
.
Let's represent the contract using an interface.
interface Clerk {
answerQuestion (question: string): Answer;
scanProduct (product: Product): void;
bagProduct (product: Product): void;
promptForPayment (customer: Customer): Promise<PaymentResult>
}
Then, we could realize the concretions by implementing the Clerk
interface, handling things with slighly different behavior if we wish.
class ClerkJames implements Clerk {
answerQuestion (question: string): Answer {
... // Implement uniquely to James
}
scanProduct (product: Product): void {
... // Implement uniquely to James
}
bagProduct (product: Product): void {
... // Implement uniquely to James
}
promptForPayment (customer: Customer): Promise<PaymentResult> {
... // Implement uniquely to James
}
}
class ClerkMariah implements Clerk {
answerQuestion (question: string): Answer {
... // Implement uniquely to Mariah
}
scanProduct (product: Product): void {
... // Implement uniquely to Mariah
}
bagProduct (product: Product): void {
... // Implement uniquely to Mariah
}
promptForPayment (customer: Customer): Promise<PaymentResult> {
... // Implement uniquely to Mariah
}
}
// Implement the others
And then finally, in the code that relies on a Clerk
, we'd provide a Clerk
and a Customer
to check out.
function checkoutCustomer (clerk: Clerk, customer: Customer): Promise<PaymentResult> {
for (let question of customer.getQuestions()) {
clerk.answer(question);
}
for (let product of customer.getProductsInCart()) {
clerk.scanProduct(product);
clerk.bagProduct(product);
}
await clerk.promptForPayment(customer);
}
Note that this function relies on a Clerk
, not a ClerkJames
or a ClerkMariah
(or so on). Therefore, it doesn’t matter who shows up to work to fill the role. It could be James, Mariah, Max, or Mark — doesn’t matter. All that matters is that some object with the role of clerk is supplied and that fully implements the requirements of the contract for a Clerk
:
let customer: Customer = { ... };
let clerkJames: ClerkJames = new ClerkJames();
let clerkMariah: ClerkMariah = new ClerkMariah();
checkoutCustomer(clerkJames, customer); // Valid
checkoutCustomer(clerkMariah, customer); // Also valid since it's a `Clerk`
Do you see how this works? By relying on a contract instead of a concretion, we gain the ability to sub in for different possible implementations.
This is what polymorphism is about. Dynamic runtime behavior. Substitutability.
What I want for you to take away from this example is that the idea of relying on roles/contracts can be very effective. Within the context of a clean/hexagonal/ports and adapters architecture, the most common reasons you'll do this is to:
StripeAPI
, PaypalAPI
) with a test double (MockPaymentAPI
) so that we don't make real calls to external services or infrastructure during testing.React components are an example of polymorphism: I've mentioned it before, but in React, the Component class is an example of polymorphism. By extending the component class and implementing the render() method, we create dynamic runtime behavior (where the dynamic behavior is how our components get rendered). Read more here.
Rule of thumb — switch statements (conditionals) can be refactored to polymorphism: Most of the time, if we’re dealing with three or more conditionals in a particular block (and we expect to add more conditions), it could be a good idea to refactor to polymorphism. Extract the generic/common behavior to a contract (abstract class or interface) and put the specific behavior in subclasses. Then program against the contract and allow dynamic runtime binding to work its magic.
Abstraction, encapsulation, inheritance, and polymorphism are four of the main principles of object-oriented programming.
Join 15000+ value-creating Software Essentialists getting actionable advice on how to master what matters each week. 🖖
View more in Object-Oriented Programming