In today’s software development world, the demand for designing applications that are both robust and easy to maintain is more pressing than ever. Many developers encounter the architectural chaos left behind in older codebases, leading to frustration and a longing for more manageable approaches.

A rise in the popularity of frameworks over the last few years has seen some complex parts of building software abstracted away or simplified with built-in methods and approaches, but it has meant that it is now all-too-common for developers to reach for frameworks in situations where a simple HTML file might have sufficed.

Instead, programming should be intentional, focusing on solutions that truly serve their purpose.

This principle applies to software and teams of all sizes, from complex codebases with hundreds of contributors, to simple one-person side projects, and in this article we’ll discuss how complexity occurs, and how to avoid the pitfalls and apply the principle of intentional coding in your workflow.

How Does Over-Engineering Happen?

We’ll save the debate about whether we even need frameworks at all for another article, but even frameworks fans must admit that they’re very rarely as lightweight as the copy on their landing page suggests.

By simply installing a framework you’ll see they come with a huge amount of dependencies, making your application reliant on an relatively complex architecture that is largely abstracted away, and therefore outside of your control if things go awry.

However, even if you create an application that is simple in its architecture (let’s say an HTML file with a .js file referenced in the <script> tag, like the good old days), the code within it can be easily become unweildy, tough to follow and unnecessarily complex.

Software is a living thing. It changes over time, it adapts to changes in the landscape, and develops in minor - sometimes imperceptible ways through refactorings and tweaks - and major, more dramatic ways when architecture is changed or third-party integrated services are switched to new providers.

Over-engineering rarely happens over night. It occurs gradually, but with some of these tips you can catch it before it’s too late.

Find the Balance

Before we dive in, let’s just make it clear that removing complexity doesn’t always mean deleting as much code as possible, or even make it as concise can be. Instead, the secret is in finding the balance.

Let’s take the following snippet as an example, where we create a Formatter class, to capitalise the first letter of the string passed to the constructor.

class Formatter {
  constructor(text) {
    this.text = text;
  }

  format() {
    return this.capitalizeFirstLetter(this.text);
  }

  capitalizeFirstLetter(str) {
    return str.charAt(0).toUpperCase() + str.slice(1);
  }
}

const text = new Formatter("hello world").format();
console.log(text);

But wait, couldn’t we just use some built-in JavaScript methods directly on the string, like this?

const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1)

I mean it works, and it’s less complex, but if I wanted to use this throughout my application, I should probably move it into a function (with an export):

export function capitalizeString(str) {
  str.charAt(0).toUpperCase() + str.slice(1);
}

capitalizeString("hello worldd");

So my original approach was too complex. My fix was concise, but not maintainable, and now my solution is somewhere between the two; a few lines long but available through my application.

Hold on… can’t I just do this with CSS instead?! By using ::first-letter pseduo element, I could cut out the need for JavaScript entirely.

p::first-letter {
  text-transform: uppercase
}

Now map this back onto your application. Is there a simpler solution you’ve overlooked? Do you prioritise concide code over approaches that are longer but more readable?

A Framework for Simplification

Principles to Remember

John K. Usterhout’s “A Philosophy of Software Design” suggests that complexity is anything that burdens modification or understanding.

Avoiding this begins with a mindset focused on reducing what isn’t necessary, preserving clarity in the system.

Consider implementing a new feature into your application:

Write code with intention - Ask yourself if each part of your software design has a clear purpose. Aim to reduce noise by streamlining code and avoiding unnecessary dependencies.

Make your code modular - Break down functionality into smaller, manageable pieces where interfaces are simple and clearly defined. In the context of a framework this will mean breaking code down into components rather than 1000-line monolithic files.

Regularly ealuate best practices - Scrutinise established “best practices” to see if they’re relevant to your current project, and adapt your project wherever possible to keep it in line with these.

Not Sure? Don’t Delete

An often overlooked principle in software development is the Chesterton’s fence principle: “Never remove a fence until you know why it was put up in the first place.

This encourages developers to understand deeply why a piece of code or structure exists before deciding to alter or remove it. This could mean examining outdated portions of a framework; understanding them before modifying them, to avoid larger-scale issues beign caused by seemingly small tweaks.

Imagine a scenario where you’ll working on a password validation function, and remove a line you don’t fully understand:

  // Original legacy code
function validatePassword(password) {
  // Seemingly odd validation that might be important
  if (password.length < 8 || password.includes('admin')) {
    return false;
  }
  return true;
}

// Naive refactor that removes security measures without understanding why
function naiveRefactor(password) {
  return password.length >= 8; // Removes the 'admin' check without understanding its purpose
}

// Proper refactor after understanding security implications
function properRefactor(password) {
  // Now we understand: 
  // 1. Minimum length for basic security
  // 2. 'admin' check prevents common security exploit attempts
  const isLongEnough = password.length >= 8;
  const containsBlockedWord = password.includes('admin');
  
  return isLongEnough && !containsBlockedWord;
}

Don’t Let AI Steer the Ship

AI has become such a integral part of how we write code each day, that it’s easy to become reliant on it.

However, as we’ve discussed in other articles, AI does not understand the overall architecture of your project. This means it will often offer up code snippets that will solve an immediate issue, but could actually be created more than it solves.

In a simple sense, it’s common to see IDE-integrated AI create a function within a file, and then an identical one in another file, and overlook the fact this could be imported from a central location within the application (a /utuils folder, for example).

Sure, use AI to help you write code, but carefully consider the code it writes for you will really work at scale.

In conclusion, implementing intentionality in software architecture and software design involves careful decisions that prioritise simplicity and clarity, enhancing both developer productivity and understanding.

More on this Topic


For more on the topic of writing intentional code, consider exploring David Whitney’s session from the World Congress 2024.

Other interesting articles:
JavaScript
See all articles
Jobs with related skills
Senior Frontend Developer (m/f/x)
ALDI DX
·
1 month ago
Mülheim an der Ruhr, Germany
Hybrid
Software Engineer Cloud (m/w/d)
syracom AG
·
22 days ago
München, Germany
+2
Hybrid
Senior Frontend Developer JavaScript (m/f/d)
EXTEDO
·
2 months ago
Ottobrunn, Germany
Hybrid
Software System Engineer / Software Architect (m/w/d)
KRÜSS GmbH
·
1 month ago
Hamburg, Germany
Hybrid