Maintain a Single Layer of Abstraction at a Time | Object-Oriented Design Principles w/ TypeScript
Some people think programming is similar to story-telling. If that's the case, then good code is an incredibly boring and one-dimensional story.
Here are two stories.
Be honest. Which of those two stories did you finish reading? The first one, right?
But why?
Well, the first story is quick and succinct. We get a birds-eye view of what's going on, and if we're curious to know more, we can ask and go a level deeper. The second story gives us everything. And it's pretty fatiguing.
The more complex your system becomes, the more you need to lean on abstractions to keep code readable and maintainable.
Since much of software development is reading code, using encapsulation and information-hiding to abstract away complexity and lower-level details becomes critical.
Used correctly, the plus side of this is that we end up with code that's easier to read, easier to ramp up and learn the domain, and it can — along with tests, even act as the primary form of documentation.
Used incorrectly, or not at all, we end up with leaky abstractions, poor cohesion, and (since "we're always building an API") poor APIs for our fellow teammates and future maintainers to build on top of.
Therefore, the object-oriented design principle in question is:
Maintain a Single Layer of Abstraction at a Time (or in other words, Don't Mix Different Levels of Abstractions)
By maintaining a single layer of abstraction at a time, we write more cohesive and loosely coupled functions, methods, classes, and modules, which in turn improves the readability and maintainability of your code.
Let's take a closer look at this and how it works.
Prerequisite reading
Before reading this article, I highly recommend the following. I've noted the relevant takeaways from each, but I recommend reading them if you find yourself confused or you don't find yourself aggressively nodding your head in agreement.
- Coupling, Cohesion & Connascence — Entropy (complexity) is at the heart of what makes it hard to write software. To tame complexity, we decompose the problem into smaller parts. But software is only useful when those parts are connected. Coupling and cohesion are the two best measurements of the quality of our decomposition attempts. We should strive for loose coupling and high cohesion.
- (Abstraction) Layers | Wiki — We use layers (domain, application, infrastructure, and adapter) to decompose a web application's complexity into separate concerns. The introduction of layers means more classes (and evidently, the coupling between layers of classes — though we can use Dependency Inversion to mitigate this). Still, it can bring the benefit of higher cohesion within classes.
- Leaky abstractions — If we need to know about an object's internals to use it correctly, depending on the layer of abstraction, we may be leaking implementation details. This leads to lower cohesion and tighter coupling between components.
Another real-world example
Let me walk you through a real-world example.
Take my initial attempt at a use case (application service) that syncs my Notion Habits dashboard to my Google Calendar.
import { UseCase } from "../../../../shared/logic/UseCase";
import { Calendar } from "../../domain/calendar";
import { CalendarService } from "../../domain/calendarService";
import { Habits } from "../../domain/habit";
import { googleCalendar } from "../../services";
import { GoogleCalendar } from "../../services/googleCalendar";
import { HabitsPage } from "../../services/habitsPage";
export class SyncHabitsToCalendar implements UseCase<void, void> {
private habitsPage: HabitsPage;
private googleCalendar: GoogleCalendar;
constructor (
habitsPage: HabitsPage,
googleCalendar: GoogleCalendar
) {
this.habitsPage = habitsPage;
this.googleCalendar = googleCalendar;
}
public async execute () : Promise<void> {
console.log('Loading habits... 5 seconds');
await this.habitsPage.load(5000);
const habits: Habits = this.habitsPage.getAllHabits();
const calendar: Calendar = await this.googleCalendar
.getCalendarForCurrentMonth();
const { creates, updates, deletes } = new CalendarService()
.planSync(habits, calendar);
await Promise.all(creates.map((c) => googleCalendar.create(c));
await Promise.all(updates.map((u) => googleCalendar.update(u));
await Promise.all(deletes.map((d) => googleCalendar.delete(d));
await this.habitsPage.cleanup();
return;
}
}
What's happening here?
The habitsPage
is an abstraction over a Puppeteer instance that loads and stores habits from my Notion habits page. We then create a CalendarService
(domain service) by passing in both a habits
collection and a calendar
object.
We then run a series of Promise.all
statements, passing the create, update, and delete actions to the correct googleCalendar
method.
Finally, we tell the habitsPage
to clean up — and by this, we're really telling the Puppeteer instance to destroy itself to free up resources.
Problems?
There are some subtle issues.
Application layer responsibilities
Let's first remember the responsibilities of a use case in the application layer in a layered (clean, hexagonal, etc.) architecture. Regarding the application layer:
- It contains the features — the use cases, of our application
- It is concerned with the rules that govern the application itself. For example, in a pet grooming application, a domain layer business rule might state that a pet must have an owner. Such invariants could be enforced within entities, value objects, and aggregates within the domain layer. Conversely, an application layer rule might enforce the fact that you can only edit a grooming appointment if you're one of many owners of the pet (authorization logic).
And use cases (one of the main implementation patterns from the application layer) primarily exist to:
- Retrieve domain objects from IO (using repositories to databases or adapters to external APIs) so that it orchestrate the execution domain objects' encapsulated business rules and persist any events or state changes they create.
With that in mind, we can call out a few leaking abstractions in the first attempt of this design.
Problem #1: [Leaking abstraction] Calling load
on the habits page
Notice that the first thing we do in the execute
method of the SyncHabitsToCalendar
use case is to call load(waitTimeInMilliseconds: number)
on the habitsPage
object?
export class SyncHabitsToCalendar implements UseCase<void, void> {
...
public async execute () : Promise<void> {
console.log('Loading habits... 5 seconds');
await this.habitsPage.load(5000);
const habits: Habits = this.habitsPage.getAllHabits();
const calendar: Calendar = await this.googleCalendar
.getCalendarForCurrentMonth();
const { creates, updates, deletes } = new CalendarService()
.planSync(habits, calendar);
await Promise.all(creates.map((c) => googleCalendar.create(c));
await Promise.all(updates.map((u) => googleCalendar.update(u));
await Promise.all(deletes.map((d) => googleCalendar.delete(d));
await this.habitsPage.cleanup();
return;
}
}
Why should the use case need to know that we need to load
the habitsPage
before fetching something from it? If we didn't call load
and instead just called getAllHabits
, what would happen?
We'd always get an empty list back. Behind that load
method is the process of:
- starting up a puppeteer browser instance
- going to the page to load the habits
- using cheerio.js to scrape the habits and parse them into domain objects
Needing to call load
before we can call getAllHabits
is an example of a leaky abstraction. We appear to abstract away the complex details of getting all the habits from my Notion page. Instead, we've shifted the knowledge required to utilize this object's API onto the user (which, in the real world, could be a coworker or a future maintainer). That's not great.
A leaky abstraction like this can lead to a lot of bugs, and evidently — frustration.
Bonus (aside): Mark Phillips (Supreme Dreams) has a really funny video that almost perfectly demonstrates the idea of a leaky abstraction in real life. Check it out.
To fix this problem, we could relocate the logic to a better place: inside the HabitsPage
class. Here, we encapsulate knowing when to call load
in HabitsPage
with the following:
export class HabitsPage {
...
public async getAllHabits (): Promise<Habits> {
if (!this.hasLoadedHabits()) { await this.load(5000); }
return this.habits;
}
}
Client usage in the use case looks like this now:
export class SyncHabitsToCalendar implements UseCase<void, void> {
...
public async execute () : Promise<void> {
const habits: Habits = await this.habitsPage.getAllHabits(); const calendar: Calendar = await this.googleCalendar
.getCalendarForCurrentMonth();
const { creates, updates, deletes } = new CalendarService()
.planSync(habits, calendar);
await Promise.all(creates.map((c) => googleCalendar.create(c));
await Promise.all(updates.map((u) => googleCalendar.update(u));
await Promise.all(deletes.map((d) => googleCalendar.delete(d));
await this.habitsPage.cleanup();
return;
}
}
Much better. Moving on.
Problem #2: [Mixed levels of abstraction] There's persistence logic in our use case!
The next thing that needs attention is the fact that we've put persistence logic in our use case.
As a reminder, use cases are only supposed coordinate the interaction between objects.
export class SyncHabitsToCalendar implements UseCase<void, void> {
...
public async execute () : Promise<void> {
const habits: Habits = await this.habitsPage.getAllHabits();
const calendar: Calendar = await this.googleCalendar
.getCalendarForCurrentMonth();
const { creates, updates, deletes } = new CalendarService()
.planSync(habits, calendar);
await Promise.all(creates.map((c) => googleCalendar.create(c)); await Promise.all(updates.map((u) => googleCalendar.update(u)); await Promise.all(deletes.map((d) => googleCalendar.delete(d));
await this.habitsPage.cleanup();
return;
}
}
This isn't the worst thing. Typically when we want to save entities to a repository, we merely pass the new entity off to a save
method, which does all the hard work behind the scenes.
However, our current implementation could be a little more cohesive. This is a bit of a detour from what we should be doing here. It also couples our use case to the persistence implementation details (run create first, then update, then delete — perhaps the sequence is important persistence logic). This could make testing this use case more complex.
Instead of decomposing the syncPlan
that comes back from the planSync
method, let's pass this off to a new object. The new object will contain the knowledge necessary for taking the syncPlan
and running the persistence strategy.
import { UseCase } from "../../../../shared/logic/UseCase";
import { Calendar } from "../../domain/calendar";
import { CalendarService } from "../../domain/calendarService";
import { Habits } from "../../domain/habit";
import { googleCalendar } from "../../services";
import { GoogleCalendar } from "../../services/googleCalendar";
import { HabitsPage } from "../../services/habitsPage";
import { SyncService } from "../../services/syncService";
export class SyncHabitsToCalendar implements UseCase<void, void> {
private habitsPage: HabitsPage;
private googleCalendar: GoogleCalendar;
constructor (
habitsPage: HabitsPage,
googleCalendar: GoogleCalendar
) {
this.habitsPage = habitsPage;
this.googleCalendar = googleCalendar;
}
public async execute () : Promise<void> {
const habits: Habits = await this.habitsPage.getAllHabits();
const calendar: Calendar = await this.googleCalendar
.getRecurringEventsCalendar();
const syncPlan = new CalendarService() .planSync(habits, calendar);
const syncService = new SyncService(googleCalendar); await syncService.sync(syncPlan);
await this.habitsPage.cleanup();
return;
}
}
The story in our SyncHabitsToCalendar
use case is looking a lot more cohesive now.
Not only that, but once I moved the logic to this new object, I realized that there was a performance issue. Google was rate-limiting my API calls. I'd need to add some delay between each operation I run. More complexity started peeking its head out at me. Luckily, we encapsulated this complexity within a new domain service.
import { LogicUtils } from '../../../shared/utils/LogicUtils';
import { SyncPlan } from '../domain/calendarService'
import { GoogleCalendar } from './googleCalendar';
export class SyncService {
private googleCalendar: GoogleCalendar;
constructor (
googleCalendar: GoogleCalendar
) {
this.googleCalendar = googleCalendar;
}
private async executeSyncActions (
milliseconds: number,
elements: any[],
func: Function
): Promise<void> {
let index = 1;
try {
for (const element of elements) {
console.log(` => On action ${index} of ${elements.length}`)
console.log(' => Waiting', milliseconds / 1000, 'seconds first...')
await LogicUtils.delay(milliseconds);
console.log(' => Executing action...')
await func(element);
console.log(' => Done');
index = index + 1;
}
} catch (err) {
console.log("Hiccup executing action", func)
}
}
public async sync (syncPlan: SyncPlan): Promise<void> {
const { creates, updates, deletes } = syncPlan
console.log(`Creating ${creates.length} events.`)
await this.executeSyncActions(5000, creates, this.googleCalendar.create.bind(this.googleCalendar))
console.log(`Updating ${updates.length} events.`)
await this.executeSyncActions(5000, updates, this.googleCalendar.update.bind(this.googleCalendar))
console.log(`Deleting ${deletes.length} events.`)
await this.executeSyncActions(5000, deletes, this.googleCalendar.delete.bind(this.googleCalendar))
}
}
Problem #3: [Leaking abstraction] Cleaning up the habits page
There was one final thing I considered doing to improve the design. And that's to make it so that the use case doesn't know that it needs to call cleanup
on the habitsPage
after we've finished the sync.
Why? It could lead to memory issues if someone created a new use case involving this object and forgot to call cleanup
at the end.
I haven't come up with a great approach yet — perhaps we could ensure that the encapsulated browser instance is a singleton. That way, we'd never have more than one instance of it running.
In the end, I've decided that I'm OK with this statement living in the use case.
Again, we're always building APIs for other developers, and at the moment, this is going to be one piece of knowledge within the abstraction that will become necessary for others to know about if they're to use the habitsPage
API. I'll think about improving that later.
Summary
Here's a summary of the objects involved in this use case, which abstraction layer they belong to, and what their responsibilities are.
The important takeaways here are:
- Write more cohesive code by maintaining a single level of abstraction at a time.
- Know which layer you're working in and the responsibilities of that layer.
- Learn implementation patterns (like value objects, repos, domain services, etc) and which layer they belong to.
- Remember that you're always building an API for other developers. Assume that someone else is going to have to know how to use the objects you design. Make the public interfaces foolproof.
Bonus exercise: Refactoring a controller
Given what we've just learned, take a look at this controller.
export class UserController extends BaseController {
private db: DbConnection;
constructor (db: DbConnection) {
this.db = db;
}
public async createUser (
req: Express.request,
res: Express.response
): Promise<CreateUserHTTPResult> {
const { body } = this.req;
const { username, email, password } = body;
// Request validation
if (!username | !email | !password) {
return this.clientError("Username, email, or password not provided.")
}
// Entity/value validation
const isUsernameValid = username.length > 4 && hasOnlyBasicChars(username);
const isPasswordValid = password.length > 4 && validatePassword(password);
const isEmailValid = isValidEmail(email);
if (!isUsernameValid) return this.clientError("Username not valid")
if (!isPasswordValid) return this.clientError("Password not valid")
if (!isEmailValid) return this.clientError("Email not valid")
// App logic
const existingUsernameUser = await this.db.Users.findOne({ where: { username } });
if (existingUsernameUser) return this.conflict("User already has that username")
const existingEmailUser = await this.db.Users.findOne({ where: { email } });
if (existingEmailUser) return this.conflict("Account already exists! Sign in.");
await this.db.models.Users.create({
username,
email,
password,
emailVerificationState: 'INITIAL'
});
return this.ok({ message: 'Successfully created new user' })
}
}
How would you improve this? Remember that a controller is a part of the infrastructure layer. What are controller concerns and what are concerns that likely belong elsewhere?
Also related
- Separation of Concerns - The introduction of abstraction layers is an exercise of the separation of concerns design principle. It's a way to introduce loose coupling between modules.
- Outside-in TDD — Outside-in TDD (Mockist, London-style) is a more exploratory way to perform TDD that involves starting at the top of the system's boundary (like a controller or a use case) and gradually building an understanding of how things can work before committing to building out concrete implementations of classes. I think this topic is related because you can write the public APIs of classes before they exist, designing abstractions in the most human-friendly way possible (while mocking out collaborators for the time being).
Stay in touch!
Join 20000+ value-creating Software Essentialists getting actionable advice on how to master what matters each week. 🖖
View more in Design Principles