Good Abstraction, Bad Abstraction

Abstractions, design patterns, DRY, WET, and boring architecture.

Issue 2 /

Hey there,

Can you believe it's almost August? That's correct; month 8 out of 12. Where is time even going in such a hurry?

In this week's issue, we'll talk about abstractions, design patterns, and the WET codebase (you read that right, wet.) Let's jump in.


Good Abstraction, Bad Abstraction

Early on in my career, I thought creating abstractions was all about keeping things DRY (Don’t Repeat Yourself.) Whenever I saw two blocks of code that looked alike, I'd do my best to remove the duplication by creating a single function that could handle all use cases.

But there’s a problem with this approach—it doesn’t scale very well.

As requirements change and the codebase evolves, this model begins to fall apart. If we start to change our functions to accommodate completely different use cases, our well-intentioned attempt at code reuse quickly turns into the wrong abstraction.

So what’s the secret to creating a good abstraction? In a nutshell, it’s about focusing less on reusability and more on what abstractions are truly for—managing and encapsulating complexity. And a great way to do that is to look out for these three fundamental qualities of any good abstraction:

  1. Good Abstractions Are Made of Deep Modules
  2. Good Abstractions Hide Unimportant Details
  3. Good Abstractions Operate at a Single Level

Let’s talk about them.

Good Abstractions Are Made of Deep Modules

A deep module is one that provides powerful functionality via a simple interface. We can think of an interface as the cost of an abstraction and the functionality as its benefit. Deep modules make for better abstractions because they provide more functionality at a lower cost.

By contrast, a shallow module is one with a relatively complex interface compared to its functionality. If a function does too little (e.g., a “pass-through” method), this could mean that we’re dealing with a shallow abstraction.

Preferring deep modules doesn’t mean that we should aim for large functions. If a function only has a few lines of code but encapsulates more complexity than it exposes via its interface, we still consider it deep.

You might remember the concept of deep modules from the book A Philosophy of Software Design which we talked about in the previous issue. If you missed it, you can read it here.

Good Abstractions Hide Unimportant Details

An abstraction should hide unimportant implementation details, but it should reveal the important ones.

Here’s an example:

You’ll notice that this function takes an axios config object as a parameter. There’s nothing inherently wrong about that, but in this particular case, the fact that the function uses axios to make a network request is an implementation detail that users of the abstraction shouldn’t concern themselves with.

Another thing you might have noticed is that this function behaves asynchronously when the user is logged in but synchronously when they’re not. This is also a detail of the implementation, but it’s an important one that should be more obvious to users of the abstraction.

Why is that detail important? Imagine adding an item to the cart and then immediately calculating the cart's total. You'll get two completely different results depending on whether the sync or async behavior was called.

An alternative version of that function could look something like this:

We're constructing the axios config object within the same function here for simplicity. You'd probably want to handle this separately in a real use case, but that's beside the point.

The important thing to note is that we’ve fixed the leak in our abstraction by specifying the exact configuration options available. Now we could change the implementation of this function to use fetch instead of axios, and it wouldn’t change its interface at all. Additionally, now that the function always works asynchronously, we’re making its behavior more consistent and evident to its users.

Good Abstractions Operate at a Single Level

Mixing levels of abstraction makes your modules more unpredictable and harder to work with.

A function that updates your database shouldn’t also update the DOM. These are two different responsibilities that should be split accordingly. Even if your framework of choice allows for these two behaviors to coexist in the same module, it’s always a good idea to separate the concerns of each abstraction by splitting the logic into smaller, more focused functions.

The Single Responsibility Principle is worth calling out as well. A great way to ensure we're operating at the same level of abstraction is to give each module only one reason to change.

Does this mean that all of your functions need to tick every box in the “good abstraction checklist”? Not necessarily.

These should be treated as guidelines, not rules. And my hope is that you’ll use them to make your abstractions better, not perfect.

After all, we’re not in the business of creating perfect abstractions (there isn’t such a thing, anyway.) We’re in the business of solving problems—and if your existing abstractions let you do that with a manageable level of complexity, then you’re already doing the important things right.


The WET Codebase

Dan Abramov sharing a not-so-great-looking abstraction

I know we just came from an entire essay about abstractions, and you might be ready for a change of topic. But hang on for a few more; I promise this one is worth it.

Dan Abramov gave this talk at Deconstruct 2019 (which I know feels like decades ago), but the ideas he shares in it are genuinely timeless.

Dan talks about the dangers of focusing too much on keeping things DRY without considering whether the abstractions we create still make sense, and he shares some key aspects to keep in mind when dealing with any abstraction:

  • Benefits of abstractions: focusing on intent, code reuse, and avoiding bugs.
  • Costs of abstractions: accidental coupling, extra indirection, and inertia (for your team.)
  • Tips for abstracting responsibly: test concrete code, delay adding layers, and always be ready to inline your abstractions when necessary.

Dan ends the talk with his recommendations for what to watch next for a deeper dive into this topic. As a big fan of all of these talks, I +1 Dan's picks and encourage you to add them to your watch list:


Links Worth Checking Out

Diagram credit: Vercel blog

Learn how concurrent features like Transitions, Suspense, and React Server Components improve application performance in this beautifully illustrated article by Lydia Hallie.

If you're used to working by yourself or in small teams, you'll find this overview of the typical software development lifecycle (SDLC) of larger companies very insightful.

Building your app with the latest libraries and frameworks is always tempting, but if you care about delivering value to your users, sticking to tried and tested (i.e., boring) technology gives you the best chance of success.

If the client-side routing logic of your React app has gotten out of hand, this article can give you some ideas for managing its complexity with a routing layer.

Nuxt takes a different approach to server components and islands architecture than some of the other frameworks out there. If you're unfamiliar with how these features work in the Vue meta-framework, this article is a great overview.


Learning JavaScript Design Patterns

Finding the right abstraction for the problem you're trying to solve often comes down to choosing the right design pattern. But with so many patterns to choose from, and implementations that vary widely depending on the programming language, this is easier said than done.

That's why I was so excited to learn that Addy Osmani's classic book, Learning JavaScript Design Patterns, was getting a second edition. The new edition has been updated to use the latest features of the language and includes some of the new patterns that have emerged over the past decade.

In about 260 pages, you'll explore:

  • The Classics: the patterns you might be familiar with from the Gang of Four book—creational, structural, and behavioral patterns.
  • New and Updated: patterns for dealing with asynchronous JavaScript, plus updated sections for MV* and Modular patterns.
  • Rendering and React-specific: including patterns for organizing React codebases and modern rendering patterns like Islands Architecture and React Server Components with Next.js.

That’s all for today, friends! Thank you for making it all the way to the end. If you enjoyed the newsletter, it would mean the world to me if you’d share it with your friends and coworkers. (And if you didn't enjoy it, why not share it with an enemy?)

Do you have any feedback for me? Feel free to reach out on Twitter or reply to this email directly. I read and appreciate all of your comments.

Did someone forward this to you? First of all, tell them how awesome they are, and then consider subscribing to the newsletter to get the next issue right in your inbox.

Have a great week 👋

– Maxi

Is frontend architecture your cup of tea? 🍵

Level up your skills with Frontend at Scale—your friendly software design and architecture newsletter. Subscribe to get the next issue right in your inbox.

    “Maxi's newsletter is a treasure trove of wisdom for software engineers with a growth mindset. Packed with valuable insights on software design and curated resources that keep you at the forefront of the industry, it's simply a must-read. Elevate your game, one issue at a time.”

    Addy Osmani
    Addy Osmani
    Engineering Lead, Google Chrome