Maintain a Single Layer of Abstraction at a Time | Object-Oriented Design Principles w/ TypeScript

Last updated Feb 8th, 2021
The more complex your system gets, the more we need to lean on the correct abstractions to keep things maintainable. This principle helps you keep your methods and functions focused, cohesive, and easy to understand in a system with several abstractions.

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.

Two stories - mixed abstractions

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.

useCases/syncHabitsToCalendar.ts
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.

Application layer

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?

useCases/syncHabitsToCalendar.ts
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:

services/HabitsPage.ts
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:

useCases/syncHabitsToCalendar.ts
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.

useCases/syncHabitsToCalendar.ts
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.

useCases/syncHabitsToCalendar.ts
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.

services/syncService.ts
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.

Mixing levels of abstraction

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.

infra/userController.ts
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?

  • 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).


Discussion

Liked this? Sing it loud and proud 👨‍🎤.


1 Comment

Commenting has been disabled for now. To ask questions and discuss this post, join the community.

pjotr
2 years ago

Great post Khalil, I'm a big fan of your blog and I've been refreshing the page daily so it made my day (bought the book as well of course).


I have a question about the last sentence here

  • 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 entitiesvalue 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).

is this rule to edit a grooming appointment really an app layer rule? I thought that it would be a method of some Appointment aggregate that needs data from User and Pet (as we need all this information to make the decision, to satisfy all invariants, in this case e.g. checking if the user is an owner). we can do this check in application service and then return an error to the controller. maybe I miss something, it'd be nice if you could elaborate on this.

Khalil Stemmler
2 years ago

@pjotr, thank you for your support and your comment!


I've thought about this a lot, and perhaps this topic is deserving of its own blog post to compare Business and Application logic.


I'd like to share a comment that I think I resonate with from a post called, "What The Heck is Business Logic Anyway?". Here's the comment:


"In my head, the divide between Business and Application logic is this: Business logic manages data, Application logic manages users. If your system was never touched by users (such as an automated system, like an ETL process) there would be little or no Application logic. If your data had no structure and no rules (think JSON data store) there would be little to no Business logic. Sure, there will sometimes be overlap, but the key is to figure out if you're trying to keep the user from doing something dumb/unauthorized (application), or if you're trying to ensure your data doesn't degenerate into useless entropy (domain). I also find that Application logic may involve state ("the user must be logged-in and an admin", "this request should be cached") whereas Business logic usually doesn't. (Unless you want to be pedantic and call setters "state mutators", but that's just being obtuse.)"


Khalil Stemmler
2 years ago

I tend to agree with the comment author. It's data vs. users, where data = domain, application = users (authentication, authorization).


Let's think through an `EditPetOwner` use case.

  1. Authenticate the user (application - authentication)
  2. Determine if user is allowed to perform this action (application - authorization)
  3. Get the new owner if exists, if not - fail (application - use case, fetch entities with repo)
  4. Get the pet (application - use case, fetch entities with repo)
  5. Set the pet's owner (domain - aggregate, perform validation logic)
  6. Save the pet (application - save the changes)


We haven't explored authentication/authorization best practices, and I think at this point, that's where the confusion lies.


One approach congruent with what we've done so far is to create an abstraction to handle the task of authorizing use cases. The rules themselves could actually end up being domain layer, but it could be up to the application layer to implement & enforce them (we'd use dependency inversion here).


Consider a use case class with this signature:


interface CanEditPet {
  // confirms that the user is allowed to perform action
  isPermitted (): Promise<boolean>;
}

class EditPetOwner extends UseCase<Request, Result> implements CanEditPet {}


We could think of some clean abstractions to handle this.


Stay in touch!



About the author

Khalil Stemmler,
Software Essentialist ⚡

I'm Khalil. I turn code-first developers into confident crafters without having to buy, read & digest hundreds of complex programming books. Using Software Essentialism, my philosophy of software design, I coach developers through boredom, impostor syndrome, and a lack of direction to master software design and architecture. Mastery though, is not the end goal. It is merely a step towards your Inward Pull.



View more in Design Principles



You may also enjoy...

A few more related articles

Command Query Separation | Object-Oriented Design Principles w/ TypeScript
CQS (Command-Query Separation) is a design principle that states that a method is either a COMMAND that performs an action OR a QU...
Object Stereotypes
The six object stereotypes act as building blocks - stereotypical elements - of any design.
Non-Functional Requirements (with Examples)
Non-functional requirements are quality attributes that describe how the system should be. They judge the system as a whole based ...
Responsibility-Driven Design
Responsibility-Driven Design is the influential object-oriented design method that presents an effective way to turn requirements ...

Want to be notified when new content comes out?

Join 15000+ other Software Essentialists learning how to master The Essentials of software design and architecture.

Get updates