DEV Community

Cover image for Writing Brain-Friendly Code: Principles of Extraction and Abstraction
kohii
kohii

Posted on

Writing Brain-Friendly Code: Principles of Extraction and Abstraction

AI-powered agentic coding has become the norm. It's not much of an exaggeration to say that development productivity now depends on how fully you can put AI to work.

It's easy to get caught up in AI coding techniques and tooling. But whether your codebase itself is ready for AI is a more fundamental question. What does an AI-ready codebase look like? Low cognitive load. Easy to change. Easy to test. Well-documented. In short, just good software design.

  • How do you structure code so that treating each piece as a black box doesn't cause things to break?
  • How do you get AI to write code that's easy for reviewers to understand at a glance?

Building a codebase like this requires you to make sound design decisions yourself and to give AI clear direction. I wrote about these design principles in Japanese two years ago. I think they might apply even more now, so I translated the article into English.

Original article (Japanese)


My code has a decent reputation for readability, or so I like to think. Here are the personal principles I follow to write code that's easy for the reader's brain to absorb.

About Me

  • Software engineer at a healthcare startup. I also build side projects.
  • Social accounts:
  • Side project:
    • SmoothCSV: The ultimate CSV editor you've been waiting for. Think "VS Code for CSV".

The Act of Extracting Functions and Classes

Why do we split code?

We split code because the overall scale and complexity of a codebase far exceeds the limits of human cognitive capacity. The fundamental strategy for handling something massive and complex is divide and conquer.

There are various ways to split code: functions, classes, components, packages, modules, microservices, etc. These are all essentially the same: mechanisms to hide details and procedures, presenting them in a different way.

Extracting a function or class shouldn't just be about deduplication or moving code around. It should be an exercise in creating understandable abstractions.

Code design is much like UI design

In UI design, we hide technical complexities that the user doesn't need to know. We present them as well-thought-out concepts that can be understood and operated intuitively.

Designing functions, classes, components, and modules is the same. It's about deciding what details to push behind which interface.

The fractal structure of abstraction

A car provides interfaces like the gas pedal and the steering wheel, which hide the internal machinery and give the driver a way to move the vehicle. From the driver's perspective, operating these controls feels like the objective itself. But from a higher-level perspective ("getting from point A to point B"), those operations are merely the means.

The systems we develop are also a means to the greater goal of providing value to users. Building a system means creating multiple layers of this Goal (Interface) → Means (Implementation) structure within it.

Layers of abstraction in action


How to Extract Functions and Classes in a Brain-Friendly Way

In this section, I'll use "functions and classes" as a general term for all units of abstraction, including components, modules, and so on.

If they need to read the implementation, you've lost

A well-designed API or library lets you understand what it does and how to use it just from its interface. This should be the ideal when you create your own functions and classes.

By extracting code and pushing it behind an interface, you are creating parts where the reader can say, "I don't need to read any further" or "I don't need to think about what happens past this point." This reduces cognitive load.

If a reader can't understand the code without diving into the implementation, then that extraction has failed.

// NG: You have to look inside to see what kind of filtering is happening
function filterUsers(users: User[]): User[]

// OK: The behavior can be inferred from the function name and signature
function filterActiveUsers(users: User[]): User[]
Enter fullscreen mode Exit fullscreen mode

If you abstract, hide the details completely

In nominal typing languages like Kotlin or C#, we often use value objects to wrap primitive values instead of using them directly.

For example, imagine an Email value object. Suppose we want to extract the domain name from this email.

class Email(val value: String)

val email: Email = ...
val domain = email.value.substringAfter("@") // Extracting the domain
Enter fullscreen mode Exit fullscreen mode

This might look fine at first glance, but it directly uses the internal value that the value object is supposed to hide. It's like a driver reaching into the car to manually manipulate the engine instead of using the pedals.

When you abstract something, the consumer should interact with the abstraction itself.

class Email(val value: String) {
  fun domain(): String {
    return this.value.substringAfter("@")
  }
}

val email: Email = ...
val domain = email.domain()
Enter fullscreen mode Exit fullscreen mode

Here's another example. Two different interfaces for a React component that displays a notification:

// A
<Alert onClickDismissButton={...}>Some notification</Alert>

// B
<Alert onDismiss={...}>Some notification</Alert>
Enter fullscreen mode Exit fullscreen mode

From the property name onClickDismissButton, Option A reveals that the Alert component has a button to close the notification and that clicking it triggers the callback. These are internal details the consumer doesn't need to know.

Option B hides these details. It leaves it up to the Alert component to decide how to handle the dismissal or whether to include a button at all. You could change it to trigger onDismiss via a keyboard shortcut without affecting the consumer.

Exposing details makes your code fragile to changes and undermines the stability of the interface.

Let the function or class define its own interface

There are two ways to think about the relationship between a function and its caller:

  • A. The signature is determined by the caller's needs.
  • B. The function defines its own appropriate signature, and the caller adapts to it.

With Option A, the function must fulfill its own responsibility while also catering to the caller's circumstances. This makes the Single Responsibility Principle hard to achieve. Option B is generally simpler and easier to understand. (For private functions, I often choose A for convenience. The further the distance from the caller, the more you should lean toward B.)

The bad example

Below is a use case for searching Pokémon in your possession, written in TypeScript:

type SearchMyPokemonRequest = {
  query: string;             // Search string
  limit: number;             // Number of items to fetch
  type: PokemonType | null;  // Pokémon type
  outputFormat: 'json' | 'xml' | 'csv'; // Output format
}

// Use Case: Search my Pokémon (excluding those obtained via trade)
async function searchMyPokemons(
  request: SearchMyPokemonRequest,
  authContext: AuthContext, // Auth info (framework-dependent)
) {
  const pokemons = await findPokemons(request, authContext);
  return outputPokemons(pokemons, request);
}

// Data Access Layer
function findPokemons(
  input: SearchMyPokemonRequest,
  authContext: AuthContext,
): Promise<Pokemon[]> {
  return prisma.pokemon.findMany({
    where: {
      name: { contains: input.query },
      type: input.type,
      ownerUserId: authContext.userId,    // Owned by me
      capturedUserId: authContext.userId,  // Caught by me (= excludes trades)
    },
    take: input.limit,
  })
}
Enter fullscreen mode Exit fullscreen mode

There are several issues with this findPokemons function:

  • It knows too much: It receives the entire SearchMyPokemonRequest and the full AuthContext (even though it only needs userId). This stamp coupling makes the function's responsibility ambiguous.
  • It requires use-case knowledge to understand: The logic for "excluding traded Pokémon" is use-case-specific. The fact that query means a partial name match is also use-case-specific (or even UI-form-specific). These behaviors are hard to infer from the signature alone.

The improved example

The function defines the parameters it needs, regardless of the caller's convenience:

// Use Case: Search my Pokémon (excluding those obtained via trade)
async function searchMyPokemons(
  request: SearchMyPokemonsRequest,
  authContext: AuthContext,
) {
  // Call findPokemons according to its own signature.
  // The behavior is obvious from the signature, so no need to look inside.
  const pokemons = await findPokemons({
    nameContains: request.query,
    type: request.type,
    ownerUserId: authContext.userId,
    capturedUserId: authContext.userId,
  }, request.limit);
  return outputPokemons(pokemons, request);
}

// Data Access Layer
function findPokemons(
  // Define parameters based on the responsibility of "fetching Pokémon matching criteria"
  criteria: {
    nameContains?: string;
    type?: PokemonType | null;
    ownerUserId?: UserId;
    capturedUserId?: UserId;
  },
  limit?: number,
): Promise<Pokemon[]> {
  ...
}
Enter fullscreen mode Exit fullscreen mode

If you only think about the caller's convenience, it's tempting to pass existing variables directly to child functions. But this causes deeper layers of code to depend on unnecessary context, making the code harder to understand.

Minimize the context being pulled in

Functions and classes exist within a certain context:

  • The execution environment, infrastructure, or framework.
  • The caller, the caller's caller, and so on.
  • A specific business flow or use case.

Having a function define its own signature, as discussed above, is essentially stripping away the caller's context.

Functions and classes with minimal context have many advantages:

  • Self-contained understanding: Fewer context dependencies mean less extra information to hold in your head while reading.
  • High reuse value: Less context makes code more generic and easier to reuse or relocate.
  • Stability: Minimal context means fewer reasons for the code to change.
  • Ease of testing: Stable interfaces and minimal inputs make unit tests straightforward and test data easy to prepare.
  • AI-friendly: Less context means AI agents can generate, review, and refactor each piece more accurately. As agentic coding becomes the norm, this is becoming increasingly important.

Mind the overhead of extraction

Look at these two TypeScript snippets. Which is easier to read?

function dumpActiveUsers() {
  const users = findUsers();
  const activeUsers = filterActiveUsers(users);
  console.log(activeUsers);
}

function filterActiveUsers(users: User[]) {
  return users.filter((user) => user.isActive); // This function hides almost nothing
}
Enter fullscreen mode Exit fullscreen mode
function dumpActiveUsers() {
  const users = findUsers();
  const activeUsers = users.filter((user) => user.isActive); // Declarative enough. Directly expresses intent.
  console.log(activeUsers);
}
Enter fullscreen mode Exit fullscreen mode

I find the latter more readable. Extracting functions (or classes, or components) comes with overhead:

  • Increase in code volume (boilerplate to define the function).
  • Management burden for the extracted function.
  • The cognitive cost of jumping to the definition.

If the amount of information hidden by a function is negligible, it's often better not to extract it at all.

Write slightly generous comments

Some argue that good design doesn't need comments. I recommend erring on the side of "slightly generous" when in doubt, especially with documentation comments like JSDoc or JavaDoc.

  • Interfaces have limits. Sometimes a signature simply can't express everything.
  • Skill and mental models vary. People have different ideas of what "good design" looks like. The metaphors or vocabulary that click for one person might not work for another.
  • Save the reader's time. Good design makes meaning guessable, but guessing still takes effort. If you can reduce the reader's burden, do it.
  • Record the "why." Sometimes you write imperfect or tricky logic for specific reasons. Comments convey your design decisions to others and your future self.
  • Discover flaws. If you find a function hard to explain in a comment, it may be a sign that the design itself needs work.

Think twice

To judge whether an interface is appropriate, toggle between two perspectives:

  • From the function's perspective:
    • Is the context minimized?
    • What should it know, and what should it not know, to fulfill its responsibility?
  • From the caller's perspective:
    • Can I guess the meaning just from the interface?
    • Would I still think so if someone else wrote this?
    • Is there room for misinterpretation or misuse?

Good design is often discovered gradually as you go. When unsure, start with a simple, naive approach.

Where to invest effort

Designing everything to perfection is exhausting. Pick your battles.

Invest effort when:

  • The code is used in many places (including tests).
  • The code is called across different services, teams, layers, or features.
  • The code is expected to live long or belongs to the core domain.

Closing Thoughts

There is no single correct method for every situation. The best way to learn is to try, fail, and iterate. I hope these ideas help you in that process.

(Please try SmoothCSV if you ever need to work with CSV files)
(Cover art by my wife, a painter. It is unrelated to the text.)

Top comments (0)