Readability through Conceptual Compression

· 6 min read

Code is more read than written. I see programming as a two-fold task: giving machines instructions and making those instructions clear to people. You can tell a difference when someone wrote code to help you read it, they use semantic variable names, define the right boundaries, it’s clean, basically a joy. But, what makes it feel this way? Let me explore in this post what I think makes code a pleasure to read:

What takes to easily understand a piece of code

For programmers, the key lies in the last three: how well your code bridges the business domain and the problem. The real challenge is to explain a solution via abstractions and their interactions, in other words, expressing intent. Your code needs to speak, and as a real book, it should be a consistent, balanced, and focused story.

Conceptual compression: Intro

One resource I use to express intent is Conceptual Compression. Conceptual compression is about reducing complexity through abstraction. It allows multiple elements of a concept to be encapsulated in a more manageable form so they can be reused without explaining it again. Like the term “Quidditch” in the Harry Potter, reusing the book analogy.

My argument is that compressing portions of code into concepts, and making then interact in the right abstraction level, helps to write code that it’s easy to understand.

How can it be achieved with different language constructs?

Variable naming: A quick win

With variables, it’s easy to create abstractions in small fractions of code.

The following piece of code uses some implementation details to compose an important business logic through a (complex) conditional:

const user =  /* ... */;
if (project.ownerId === user.userId || user.role === 'MANAGE_PROJECTS') { /* ... */ }

And here’s an improvement using better abstractions:

const currentUser = /* ... */;
const canManageProjects = currentUser.role === 'MANAGE_PROJECTS';
const isProjectOwner = project.ownerId === currentUser.userId;

if (isProjectOwner || canManageProjects) { /* ... */ }

In the second version, the compressed concepts optimize the clarity of the conditional, making it look almost in plain English. This is powerful to make things easier to understand.

Functions for clarity

When abstractions need to travel across function boundaries, functions can also work for encapsulating them. Continuing with the previous example, what if now the code is part of an endpoint?

The following example makes use of variable concept compression, but it’s not enough, there’s a mix of abstraction levels making the whole function difficult to understand:

app.put('/project/:projectId', async ({ request, response }) => {
    const user = /* ... */;
    const project = /* ... */;
    const canManageProjects = user.role === 'MANAGE_PROJECTS';
    const isProjectOwner = project.ownerId === user.userId;

    if (isProjectOwner || canManageProjects) { /* ... */ }
});

My improved version:

function getCurrentUser(request) {
    /* ... */
}

function canManageProjects(user) {
    return user.role === 'MANAGE_PROJECTS';
}

function isProjectOwner(user, project) {
    return project.ownerId === user.userId;
}

app.put('/project/:projectId', async ({ request, response }) => {
    const user = getCurrentUser(request);
    const project = /* ... */;

    if (isProjectOwner(user, project) || canManageProjects(user)) { 
        /* ... */ 
    }
});

It’s much better now! The endpoint implementation it’s simpler, and it clearly expresses its purpose with unneeded details.

Modules for scalability

Encapsulating concepts in different files starts becoming a more complex problem. There are different levels in the strength of the connection between different files and their concepts (topic for a different post). Regardless of that, the need to compress similar concepts remains to define what goes together and what not.

Different languages have different constructs to abstract or encapsulate functionality in files, like packages, classes, or modules. I’ll call them Modules for simplicity.

Here’s a continuation of the hypothetical use case I analyzed before using modules.

// File: src/projects/policies/canUpdateProjectPolicy
function canManageProjects(actor) {
    return actor.role === 'MANAGE_PROJECTS';
}

function isProjectOwner(actor, project) {
    return project.ownerId === actor.userId;
}

export function canUpdateProjectPolicy({ actor, project }) {
    return isProjectOwner(actor, project) || canManageProjects(actor)) { 
}

//----------------------------------------------------------------
    
// File: src/projects/use-cases/update-project.use-case.js
import canUpdateProjectPolicy from 'src/projects/policies/can-update-project.policy';
import ProjectManager from 'src/projects/...<not-relevant-now>';

export async function updateProjectUseCase({ projectId, payload, actor }) {
  const project = ProjectManager.find(projectId);
  if (canUpdateProjectPolicy({ actor, project }) {
      return ProjectManager.update(id, params);
  }
  /* ... */;
};

//----------------------------------------------------------------

// File: src/projects/web/index.js
import updateProjectUseCase from 'src/projects/use-cases/update-project.use-case';
import { getCurrentUser } from 'src/shared/http/helpers';

app.put('/project/:projectId', async ({ request, response }) => {
  const currentUser = getCurrentUser(request);
  const projectUpdatePayload = /* ... */;
  const projectId = /* ... */;
  const updatedProject = await updateProjectUseCase({ 
    projectId, 
    payload: projectUpdatePayload, 
    actor: currentUser
  });
  /* ... */
});

In the example there’s a project module containing all related functionality, it is composed of some smaller modules called policies, web, use-cases, and I hint that there could be different files inside them. The point is that to apply conceptual compression at module level, there are many layers of abstraction to deal with and this becomes a new challenge. Having a good balance of abstraction layers is essential.

Taking for example the `can-update-project.policy`, which main goal is to define when a user can update a project, if this function would have to deal with SQL queries it would’ve made it more complex and harder to understand.

Closing thoughts

Navigating the complexities of business logic often leads to intricate coding challenges. Conceptual compression serves as a powerful tool in this regard, helping to simplify how programs are structured by creating effective abstractions, making a better bridge between the problem and its solution, creating that joy in readers. This practice not only enhances code readability but also reduces complexity and promotes maintainability and scalability of your codebase.

But it also brings big challenges, such as deciding what concepts belong together, or what’s the right connection strength that will break module’s apart. Regardless of that, I argue that those challenges are there anyway in Software Development, applying Conceptual Compression it’s only making them explicit. Feels like a good way to approach Software Design and architectural decisions in general.

New posts in your inbox. Unsubscribe anytime.