Four Ways of Writing Thoughtful Code to Think Less

Ryan Brewer Opinion, Programming 3 Comments

I love Stack Overflow 🔗. It allows me to offload the minutia and move on to bigger things.

Sometimes.

And sometimes, my Codebase gently taps me on the shoulder… and piles the minutia right back on. 

C’mon Codebase 🎨“, I implore, “Don’t make me think about this!”

Long before I was offloading brainpower to Stack Overflow, I sought to offload it for my future self (ahem, and teammates of course). I have a book to thank for this. Steve Krug’s “Don’t Make Me Think” 🔗.  It’s a critique of complex user interfaces 🎨.

Steve likes them simple and intuitive: the users’ goals are evident and easily accomplished.

Steve’s adage–“don’t make me think”–also plays a fair critique of the code we write. We can take that adage and apply it as a “DX First” approach to writing code. (An approach that admittedly should be sacrificed to UX or Performance Gods as needed.)

In this article I illustrate four high-level ways of elevating the developer experience to the forefront, helping us grok more while thinking less.

Overview

The first two ways to put developer experience first, Writing Honestly and Getting to the Point, can be likened to the scientific concepts of accuracy and precision.

When we’re Writing Honestly, we’re naming functions that perform their expected operations, and we’re declaring variables that accurately represent the data they hold. When we’re Getting to the Pointwe’re being precise, and we’re minimizing the terrain others traverse when building mental maps of the code they’ve ventured into.

The third way of keeping mental overhead in check is to Keep With Conventions. All software has conventions. Sometimes a convention packs a magical punch. Other times it’s much more mundane. To thoughtlessly ignore these conventions is to leave others (including our future-selves) scratching our forehead.

Lastly, in the interest of developer experience, I argue our software products should Leverage Open-Source. With so many packages freely available and thousands of man-hours pumped into them, is it really worth reinventing the wheel?

**The following examples are in JavaScript, though the patterns could apply to many languages.**

Write Honest Code

Writing honest code is a great way to ensure developer experience is put first in your code base.

You may have heard the term radical candor. Someone at Google (Kim Scott) coined it a few years back in her talk on management 🔗.

In a nutshell, radical candor creates a workspace free of confusion and miscommunication. Managers are honest and frank. People know where they stand and what’s expected of them.

Now, imagine a radically candid codebase; code that tells you where it stands and what you can expect of it. Static typing can take you a long way, sure, but even better is simply naming things well.

Honest code describes itself accurately 🎨. Here are some tips for naming things more accurately.

1. Incorporate Common Verbs

First off, it’s important to remember that certain verbs carry built-in expectations and can help reduce cognitive overhead. It often makes sense to springboard off your language’s built-ins.

For example, JavaScript has an Array.find method, so when naming a method that figures out how to return something from an array, prefix it with the word “find”. Below are some other examples.

  • Is/Has – signals a Boolean description of something
    • form.isPristine or form.hasChanged
  • Should/Will – signals a side effect will occur
    • shouldShowTitle && <Title text={titleText} /> or if (willValidate) validate(form);
  • Find – finds an item in a collection
  • Get – expect a function that returns a synchronous computation
    • getFriendlyErrorMessage(error)
  • Fetch – expect an async `GET` network request
    • fetchAccounts(query)
  • Save – expect an async `POST/PUT/PATCH` network request
    • saveAccount(params, data)
  • Delete – expect an async `DELETE` network request
    • deleteAccount(params)

2. Create Context Chains

Secondly, name things so you form a link between where something gets made and where it gets used. It gives your fellow developer a quick heads up. Wield a context consistently enough, and you might forget you’re in a dynamically typed language!

/** Good **/
class PayrollTable {
  // Consumers will get a certain shape when invoking PayrollTable.getColumnNames()
  getColumnNames() {}
}

class PayrollReport {
  // Here--because it's well named--we naturally expect that same shape!
  getPayrollTableColumnNames() {}
}

/** Bad **/
class ShadyPayrollReport {
  // But here... maybe this returns payroll table column names? Hmm, let's dig in and see...
  // if it does, we've clearly missed the opportunity to indicate it.
  getLabels() {}
}

3. Be Descriptive and Brief

Third, try to be as concise but thorough as possible. Like both of my children, I love clarity & brevity equally–but I’ll admit, clarity can be easier to get along with.

/** Bad **/
const o = {/* ... */} // a little too brief

/** Good **/
const options = {/* ... */} // that's better

/** Bad **/
PayrollTable.getPayrollTableColumnNames = () => {/* ... */} // a bit too repetitive

/** Good **/
PayrollTable.getColumnNames = () => {/* ... */} // noice!

/** Chaotic Good **/
const benefitGroupSourceHierarchyManagerModel = {/* ... */} // long ...but helpful if other managers are nearby

4. Be Mindful of Grammar

Last but not least, try to write with proper grammar. Turns out all those English classes in high school were worth it … at least somewhat.

/**
 * Bad.
 * "This 'shouldWillConfirm' prop is likely just bad grammar...
 * but did [git blame] expect something mysteriously-meta here?
 * Bah! Let's dig in and make sure."
 */
<ConfirmRouteChange shouldWillConfirm={/* ??? */} />

/**
 * Good.
 * "Clearly 'willConfirm' expects a Boolean."
 */
<ConfirmRouteChange willConfirm={formIsDirty} />

/** Bad. Type is a collection but the name is singular. **/
const selectedTableRow = [{ /* ... */ }];

/** Good. **/
const selectedTableRows = [{ /* ... */ }];

Get to the Point

Another way to put developer experience first is to strive to get to the point quickly and concisely.

It sounds harsh, but there are many ways codebases can ramble. A rambling codebase is harder to follow and tends to waste everyone’s time. No one likes it when an uninvited variable shows up at the party, and no one likes code indentation that resembles a HIIT workout. (And makes us sweat just as much!)

Here are a few tips to help you avoid creating a rambling codebase.

1. Guard Clauses

Guard clauses can immediately burn cognitive weight. Use them generously!

/**
 * Bad.
 * After reading the whole function you learn it might simply return true.
 */
const optionIncludesInputValue = (option) => {
  let isIncluded;

  if (this.inputValue) {
    const name = option.name.toLowerCase();
    const value = option.value.toLowerCase();
    const inputValue = this.inputValue.toLowerCase();

    isIncluded = name.includes(inputValue) || value.includes(inputValue);
  } else {
    isIncluded = true;
  }

  return isIncluded;
}

/**
* Good.
* The easy case is handled first. Plain and simple. And as an added bonus
* the rest of the function is no longer indented and flows more freely.
**/
const optionIncludesInputValue = (option) => {
  if (!this.inputValue) {
    return true;
  }

  const name = option.name.toLowerCase();
  const value = option.value.toLowerCase();
  const inputValue = this.inputValue.toLowerCase();

  return name.includes(inputValue) || value.includes(inputValue);
}

2. Keep Functions Short

If there are chunks of isolated logic in a function, consider extracting them into their own functions.

/** 
 * Bad. 
 * A guard and two observers hinder 
 * the overall clarity of "setup".
 */
class Collection {
  setup() {
    if (![DataState.ERROR, DataState.UNSYNCED].includes(this.dataState)
      || this.readyHandler) {
      return;
    }

    if (this.urlDependent) {
      this.readyHandler = observe(endpoints, 'ready', (isReady) => {
        if (isReady) {
          this.fetch();
        }
      }, true);
    } else {
      this.readyHandler = observe(url, 'params', (newParams) => {
        const { collectionId } = newParams;
        if (!isNil(collectionId) && collectionId !== this.id) {
          this.id = collectionId;
          this.fetch();
        }
      }, true);
    }
  }
}
/**
 * Good.
 * The "setup" implementation has been split into grokkable chunks.
 */
class Collection {
  setup() {
    if (this.hasFetchedData || this.readyHandler) {
      return;
    }

    this.readyHandler = this.urlDependent
      ? this.fetchOnUrlChanges()
      : this.fetchOnEndpointsReady();
  }

  get hasFetchedData() {
    return ![DataState.ERROR, DataState.UNSYNCED].includes(this.dataState);
  }

  fetchOnEndpointsReady() {
    return observe(endpoints, 'ready', (isReady) => {
      if (isReady) {
        this.fetch();
      }
    }, true);
  }

  fetchOnUrlChanges() {
    return observe(url, 'params', (newParams) => {
      const { collectionId } = newParams;
      if (!isNil(collectionId) && collectionId !== this.id) {
        this.id = collectionId;
        this.fetch();
      }
    }, true);
  }
}

3. Keep Conditional Logic Lean

Conditional logic can quickly become long and tedios. Try to keep it to a minimum.

/**
 * Bad.
 * The fetch function is called under both conditions.
 * Look closely!  Its parameters are the only things that vary.
 */
const fetchJobs = (params, query) => {
  if (query) {
    return fetchUrl(Url.JOBS, params, query);
  }
  return fetchUrl(Url.JOBS, params, params);
}

/**
 * Good.
 * Only the changing parts are within the conditional flow.
 * Since this also paired us down to a one-liner, we can
 * now leverage javascript's implicit return, leaving us with
 * even less code!
 */
const fetchJobs = (params, query) =>
  fetchUrl(Url.JOBS, params, query || params);

4. Colocate or Barrel things

There are two popular ways of organizing modern projects: by architecture or by the business domain.

Suppose you have a project architected with models, controllers, and views. A CLI tool might scaffold this project with the following (less than ideal) folder structure:

/** Bad. The business features of your site are spilled across the directory structure. */
- src
  |_ controllers
     |_ cart.js
     |_ product-listing.js
  |_ models
     |_ cart.js
     |_ product-listing.js
  |_ services
     |_ cart.js
  |_ views
     |_ cart.jsx
     |_ product-listing.jsx
     |_ splashPage.jsx

The structure above might seem nice at first, but ultimately there’s a more helpful way! Organize your code by the business domain. With all of your app’s features barreled together, it’s easy to find a whole thing. You might even snag a glimpse into its complexity.

- src
  |_ cart
     |_ cart.model.js
     |_ cart.controller.js
     |_ cart.service.js
     |_ cart.view.jsx
  |_ product-listing
     |_ product-listing.controller.js
     |_ product-listing.model.js
     |_ product-listing.view.jsx
  |_ splash-page
     |_ splash-page.view.js

In larger codebases, it can be helpful to use both approaches. High-level folder structures might organize files by feature and subfolders might then organize files by architecture.

Keep With Conventions

Along with Writing Honestly and Getting to the Point, Keeping with Conventions is another way to put developer experience first in your code.

Almost a decade ago I read an article titled Idiomatic jQuery, written by a core contributor to the project. (Not Ben’s article 🔗, but he is good too!) It convinced me that life is easier when we build things the way the author intended. Idiomatic programming is easier to grok, easier to explain, and easier to come back to.

Every platform is different, as are the conventions your app layers on top. The trick is to learn them–for the framework, for the library, and for the app.

For example, there’s a way the community writes Redux or Vuex. There’s probably a style (or two) your app uses to write a component. The more we adhere to our conventions, the easier it is for everyone to step in and help.

Like nifty outfits, conventions come in all shapes and sizes. Our best-dressed code will match our conventions. Try to resist breaking out that silly cowboy-coder hat we all keep.

Conventions can be automated or managed through code reviews. Some typical ones include:

  • Linting styles
  • A client-side app that favors models + components over just components, or vice versa
  • That framework you’re using probably has an idiomatic way of using it
  • Decide to prefer using built-ins to libraries (or vice versa)
    • Eg, using a library for async calls, instead of rolling your own

From time to time, you might be faced with the decision to onboard a paradigm shift. A few years back, I convinced my team to bring TypeScript to our very large, established codebase. (After all, we can just sprinkle it in, right?) In hindsight, 7/10 teammates felt this was a poor decision, myself included. In our particular case, the added complexity and inconsistent adoption eclipsed the overall payoff.

Fundamental shifts can introduce sustained drag on a team, and though often exciting, they might not be worth it.

Leverage Open-Source

Finally, a great way to keep developer experience at the forefront is to leverage the open-source software out there.

Writing software is fun, and it can be enticing to write a new, perfect low-level widget–even if it’s been written before. (After all, that other widget has cruft to it, and isn’t perfect for our needs!) Nevertheless, I encourage you to use open-source libraries instead.

There are several reasons why open-source is often the right choice. First, time and money aren’t spent reinventing the wheel and later, hardening it against defects. Popular open-source libraries can be readily trusted, having been pre-hardened by the community. Second, mature open-source libraries often accommodate a richer variety of implementation strategies, which in turn improves your own quality of life while working with them. Third, there’s a strong chance you and your teammates have experience with the library and can shorten or skip the ramp-up time.

When deciding what open-source to use, there is usually a tradeoff or two. Sometimes it’s a tradeoff between usefulness and cruft. There’s often an acceptable amount of uselessness everyone can live with.

At other times you’ll weigh utility against “hackiness.” If it feels a library would lead to building Frankenstein’s Monster, consider finding a lower-level abstraction to work with.

Finally, you might face tradeoffs of time–both time to develop and time to maintain. When assessing this, you might consider your team’s collective experience in one thing vs another or the impact of selecting a higher vs lower-level abstraction.

Fortunately, the open-source ecosystem is diverse, and we can often find something suitable. Make it your go-to.

Conclusion

Writing code that won’t make us think, unfortunately, requires some thought! In this article, I’ve outlined four approaches to help achieve this and put developer experience first in our code.

How can you offload mental overhead in more ways than skillful Googling? Maybe you’ll free up bandwidth by using an open-source library. Maybe you’ll extract logic into another method, or take a bit more time to name something really well. Even though it can be hard, it’s worth crafting something simple.

These initial investments and iterations in developer experience can lead to future happiness, for you and your team. Will our code be perfect and extensible to all potential futures? Nope! But will it be easier to maintain? You bet! You don’t need to think about that!

For more great tips on wrangling chaos, check out Gabe’s article on taking down God functions 🔗. (Spoiler, they can fall to mere gritty mortals like us.)

5 1 vote
Article Rating
Subscribe
Notify of
guest
3 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments