code-quality
maintenance
tech-dept
processes
stories

There is no official guide for tracking, maintaining, or reducing technical debt. We're exploring ideas and approaches I've encountered throughout my career. It's not a binary, one-size-fits-all solution.

Dealing With Tech Debt In Applications

Technical debt is a nightmare if not tracked and maintained. Don't get me wrong - every app will accumulate technical debt; it's just a matter of time. In the JavaScript ecosystem, you only need to wait a few days and then run npm install to see vulnerabilities and issues with packages that will be flagged.

That's why it's crucial to understand technical debt, how to manage it, and how to prioritize certain updates in the codebase to avoid dealing with an overwhelming mess after several months of development.

Why is it so important? Because technical debt can kill your project. Ignoring it is like ignoring cancer - you might not notice symptoms at first, but over time, it will start to cause serious problems. To implement a new feature, you might need to modify existing code - or, more likely, completely rewrite it. To fix a bug, you might have to touch unrelated parts of the codebase.

Technical debt can take many forms, and managers, especially project owners, often don't realize how crucial it is to keep it under control to prevent the project from collapsing.

So, let's dive into this cancerous topic.

By the way, technical debt is a bit like drinking alcohol at a party - you need to know when to stop and recognize your limits.

Technical Debt Symptoms

I've tried to categorize the symptoms and provide context for each. Of course, we could delve even deeper, but I've focused on the most significant and visible ones to help you grasp the concept.

"I Can't Do That" Because We Need to Rewrite

One of the worst scenarios is hearing, "I can't do that because we need to refactor or rewrite something." Imagine being a project owner, typically non-technical, and hearing that a critical feature cannot be developed without a major "refactor/rewrite." It’s hard to understand why something seemingly straightforward requires extensive changes.

Interestingly, in some projects, terms like rewrite are taboo. It's often better to say "refactor" or "improve."

Developer Frustration

This can be easy to spot. If you're on a one-on-one call with a developer and they seem frustrated or use negative language when describing the code, it's a red flag. I recall a project with a 1,100-line React class component. The component had a convoluted, generic if-statement-based mechanism. Early in the project, one developer decided to create a custom routing system instead of using a well-established, community-supported one.

Potential problems? Infinite redirections, content jumping, performance issues, and more. When the time came to add a "new route" with some additional logic, the entire frontend team was terrified. The risk of breaking half the app was enormous.

Moreover, the code was completely untestable and was a prime candidate for a complete rewrite. It was necessary to replace this mechanism with something like react-router or another well-supported solution from the React ecosystem.

Do you see the point? During calls with the lead developer - often the one maintaining the code due to their experience - the frustration was evident. This underscores another critical lesson: strategic decisions in a project need to be made carefully.

And what's really bad is that you can't accurately estimate the time and effort required to fix it! It's incredibly complex, with numerous edge cases, hidden gotchas, and unpredictable problems.

Visible at First Glance

Technical debt is often glaringly visible in the code, especially when it becomes overwhelming. It's like cancer that's reached a stage where symptoms are obvious - 1,100 lines of a React class component? That’s a clear symptom, like a visible tumor.

class HugeComponent extends Component {
  state = {
    // Imagine there are hundreds of state variables here...
    isLoading: false,
    data: [],
    // ...
  };

  componentDidMount() {
    // Hundreds of lines of code...
  }

  componentDidUpdate(prevProps, prevState) {
    // More lines of code...
  }

  handleButtonClick = () => {
    // Dozens of methods for various actions.
  };

  render() {
    return (
      <div>
        {/* Hundreds of lines of JSX... */}
      </div>
    );
  }
}

Too many nested if statements?

function processUser(user) {
  if (user) {
    if (user.isActive) {
      if (user.role === 'admin') {
        if (user.permissions.includes('read')) {
        } else {
        }
      } else {
      }
    } else {
    }
  } else {
  }
}

Or maybe hard-to-maintain logic?

function updateUserProfile(user, updates) {
  const updatedUser = { ...user };
  if (user.status === 'active' && updates.role) {
    updatedUser.role = updates.role;
    if (updates.permissions) {
      updatedUser.permissions = updates.permissions;
      if (user.role === 'admin') {
        // Special case for admins.
        updatedUser.adminSettings = updates.adminSettings || user.adminSettings;
      }
    }
  } else if (user.status === 'inactive') {
    // Handle inactive user case.
    updatedUser.status = 'pending';
  } else {
    // Fallback case.
    updatedUser.status = 'unknown';
  }
  return updatedUser;
}

Or coupling unrelated application modules?

// Component A
import React from 'react';
import ComponentB from './ComponentB';

function ComponentA() {
  const handleEvent = () => {
    // Component A directly manipulates Component B's state or behavior.
    ComponentB.someFunction();
  };

  return (
    <div>
      <button onClick={handleEvent}>Trigger B's Function</button>
    </div>
  );
}

export default ComponentA;

Or inconsistency?

function inconsistentFunction(data) {
  if (data) {
    let result;
    result = data.filter((item) => item.active);
    if (result.length > 0) {
      return result[0];
    } else {
      return null;
    }
  } else {
    var defaultResult = "No data";
    return defaultResult;
  }
}

Okay, I think that's enough examples. You can clearly see where this is going. The symptoms of technical debt are visible to a mid-level developer, but the problem is that not everyone has enough experience to recognize them.

We could do a fun experiment where junior, mid-level, and senior developers are asked to "improve" this code. The junior might create something equally complex, the mid-level developer might solve the problem, and the senior might make it even harder (just kidding).

Visible to Users

If a feature doesn’t work well due to technical debt - affecting UX, performance, or the overall feel of the app - it usually means you're in trouble. What will happen? The project owner will demand fixes, and you’ll end up spending enormous amounts of time rewriting core application logic, then testing it again. Inevitably, you’ll introduce new bugs, and all of this will be done under stress and in chaos.

You can't let technical debt reach this point. You need to keep it under control and reduce the risk constantly.

Ugly Code Emotion The feeling after long hours of working with ugly code

Stack and Versions Lock-In

Imagine this common scenario: you're using a framework like Next or something similar. You need to update packages due to vulnerabilities, new features, or just to stay up-to-date. You run npm install and suddenly face hundreds of warnings and errors. Yes, you're likely frustrated at this point.

This happens because avoiding regular updates to the technologies you’re using - even just to maintain stable and supported versions - creates technical debt. This kind of debt can often be even more problematic than poorly written code. But why?

Let's consider the Next example. They've introduced the app router feature but still support the pages router. They’re gradually encouraging the community to adopt the new system, subtly preparing everyone for a potential phase-out of the old pages router. Maybe it won’t happen soon, but you never know.

Now, imagine you have a large Next app, and in a future version, they remove support for the pages router. Suddenly, you’re faced with the daunting task of migrating everything from the pages router to the app router, which has a completely different syntax, uses use server or use client, and generally involves a totally different mechanism for fetching data when generating pages.

This is a much bigger problem than it seems. Any bugs you encounter in the old framework version are now unfixable because the only way to get those fixes is by migrating all your code to the new system.

I’m not saying this is inherently bad or unusual - changes in the codebase are necessary - but it’s important to recognize that this can happen and be prepared for it. One way to prepare is by using techniques like porting when working with frameworks like Next or Gatsby. This approach helps ensure a smoother transition during major updates.

How Tech Debt Is Created?

I could write a book on this topic, but here are some of the key factors I've observed.

One-Man Army Behavior

A developer writes complex code that only they understand. Due to their long tenure and the client's trust, they’re allowed to create and maintain this "spaghetti" code, leading to significant technical debt.

No Rules

The project starts without essential setups like ESLint, strict TypeScript settings, Prettier, performance benchmarks, or design specifications. This lack of structure leads to inconsistent and error-prone code.

Ego

Collaboration becomes difficult when certain individuals let their egos get in the way. This results in poor teamwork, a lack of cooperation, and an avoidance of providing constructive feedback in PRs due to the fear of lengthy, unproductive debates.

Management Ignorance

The constant push for new features leads to ignoring other important aspects like code quality, refactoring, and addressing technical debt.

No Strong Leadership

A lack of authority and soft skills in leadership means that critical issues are not communicated effectively, leading to unresolved problems and growing technical debt.

Do-and-Forget Mentality

Often seen in projects maintained or created by less experienced developers, this mindset leads to quick fixes without considering the long-term impact, resulting in accumulating debt.

Lack of Strategic Thinking

Focusing on short-term gains rather than long-term solutions often exacerbates technical debt.

Lack of Tests

Without proper testing, it becomes impossible to refactor or add new features safely and quickly, increasing the risk of introducing bugs and further debt.

Mixing Responsibilities

Developers focused on backend work writing frontend code, and vice versa, can lead to suboptimal solutions and technical debt.

Loose Git Rules

When everyone can merge code, sometimes bypassing established rules, it can result in inconsistent and unstable codebases.

Lack of Knowledge About Software Craftsmanship

Not understanding or applying principles like YAGNI, KISS, and SOLID - crucial for maintaining clean, maintainable code - adds to technical debt.

Team Conflicts

When team members are more focused on proving themselves as the "best developer" rather than collaborating, the overall quality of the project suffers, leading to increased technical debt.

Toxic Environment

In projects with a toxic culture, technical debt often becomes the norm. As developers become demotivated, they stop caring about the quality of the code, leading to further deterioration.

Nitpickers

The amount of time wasted, frustration caused, and burnout they create is overwhelming. The "I don't care" attitude they foster is detrimental. If people stop engaging in PRs because they know it's impossible to have a productive discussion with a nitpicker, they will simply give up on contributing.

No Knowledge Transfer Mentality

When developers don't share knowledge, it's easy for tech debt to accumulate. If the only person with key knowledge goes on holiday, everything can fall apart.

How to Reduce Technical Debt?

I'll give you context and my solution, but let me emphasize - this isn't a golden rule. Every technical debt case is unique. My aim here is to highlight the importance of strategic thinking.

Anyone who has dealt with technical debt deserves a salute. You're the boss, and you understand just how challenging it is. I recall my biggest challenge in my career: removing technical debt from an AngularJS project, migrating it to modern Angular, and improving the app’s performance without introducing any regressions.

Here’s some context:

  • The project was a medium-sized end-user/admin application.
  • It had almost 50 separate routes and features inside each.
  • There were references between end-user and admin features.
  • Typical sign-in mechanism.
  • SEO was required for the end-user application fragment.
  • Thankfully, there were some e2e tests.
  • New features were continuously being developed in the old, unsupported AngularJS.
  • There were many unit and integration tests in Jest, but they were mostly ineffective - tied too closely to the implementation.
  • The unit test coverage was minimal, at around 40% (it was requirement).

The client requested an estimate and plan for tackling the technical debt. I quickly realized I needed the expertise of team members familiar with the modules and features, along with their time and resources. To reallocate resources, I suggested reducing focus on code coverage metrics, explaining that coverage is just a statistic. Instead, I advocated for writing implementation-agnostic end-to-end tests that cover real features, which is more effective, especially during major migrations.

Next, we needed to ensure the team had a strong grasp of the new Angular framework. I requested permission to purchase some trainings for the team and allocated time for them to learn Angular thoroughly. My goal was to ensure everyone had a consistent understanding, enabling us to rewrite the app quickly and effectively.

To ensure that we didn’t break any features during the migration, we needed to increase our e2e tests and add tests for every new feature. This was crucial - without adequate testing, every refactor would be incredibly risky.

With these preparations in place, we began. First, we tackled the generic setup - new ESLint rules, design specs, Prettier, TypeScript, and core application logic/layout (auth, redirections, etc.). This was the stage where all e2e tests were failing.

We then selected the simplest end-to-end test case and rewrote the feature, applying the already defined selectors in the tests. Thankfully, someone had wisely used data-selectors to avoid binding selectors to the HTML/CSS structure, which made our work much easier.

Here is the guide How to avoid coupling with HTML/CSS in e2e selectors.

Once the first tests were passing, we applied the same process to every route. As parts of the app were rewritten, we gradually added redirections in the old AngularJS app to the new one, hosted on a subdomain (which required a bit of DevOps work, but nothing too complex).

This approach allowed us to gather feedback from the client and users while producing usable code incrementally. After several months of hard work, we completed the migration, and the old AngularJS project directory was entirely removed.

As I mentioned earlier, my goal isn’t to show off my skills or claim to be a top developer (I consider myself decent and not talented). Rather, I want to demonstrate the importance of having a well-thought-out strategy, measurements, and plan. Without these, attempting any large-scale refactoring or technical debt removal is pointless.

Additionally, we focused on dividing the work equally among developers to avoid overwhelming any one individual while others stayed in the background. The message was clear: we're all on the same ship, and as a team, we collectively take responsibility for the migration - not just a single developer. While this approach fosters shared responsibility, it requires vigilance to prevent creating another issue - blurry responsibility and team members hiding from work.

Prevention Strategies

Automation, design specifications, and rules are crucial. Without established rules, practices, standards, and automation to verify the "way" you want to craft your software, technical debt can arise from almost anything.

What I often see is a common GUIDE.md document in the repository, or something similar, that outlines practices not easily automated. For example, it might explain when to use the Context API versus Redux, or provide other specific guidelines.

Design Spec File The Design Spec File Overview

It's really important to have it in the repository and under version control. Additionally, developers can quickly access the rules without changing context in the IDE.

When a new practice was proposed, it was reviewed by the developers in a PR and, if accepted, merged into the core branch, such as develop. Once merged, it became an established practice. If someone violated that practice in a PR, they were required to fix it (though, in some cases, we had to proceed with the code as-is to avoid blocking progress, with the understanding that it would be corrected later). This approach ensures consistency and helps prevent the accumulation of technical debt while allowing flexibility when necessary.

Code reviews are another essential element. It's important to understand that reviewing code isn't just about looking at the code itself - it's about understanding the context of the change and how the feature should work from a broader perspective. For complex tasks, we often organized a call where the PR author would provide a knowledge transfer session, and we would conduct the review in real-time.

A crucial aspect of maintaining code quality and avoiding technical debt is preventing developers from adding risky or unrelated changes in a PR that aren’t part of the original ticket. While there’s a methodology called Scout, where you refactor parts of the code as you encounter them (if it’s not risky), it’s challenging to define what constitutes a "risky" change - everyone has their own interpretation.

To manage this, if a developer has a refactor idea, they should create a separate technical ticket and address it independently. This approach ensures that refactoring is intentional and doesn’t introduce unintended issues, helping to keep technical debt under control.

Lastly, it's crucial to have regular developer meetings with a clear agenda focused on specific, advanced tech debt issues. Meetings without an agenda are counterproductive. Equally important is appointing a meeting leader who ensures the agenda is followed and cuts off unrelated discussions.

How To Track Tech Dept?

Tech debt can be categorized based on its scope:

  1. Local (per file, function).
  2. Feature-based (per feature, module).
  3. Global (core application code).
  4. Third-party (outdated versions/tech stack).
  5. Process-related (affecting software craftsmanship).

I've often seen points 1-4 tracked by adding comments in the codebase, which are later converted into tickets. However, comments can quickly become outdated, making them hard to maintain.

To effectively track and categorize tech debt near the code, I suggest using "special" comments with a predefined template. These comments would be easily recognizable as tech debt, with a short description and category.

Once you have several tech debt comments, you can group them by priority and address them in a single PR if feasible (considering complexity). The template for these comments should be added to the repository, so anyone can easily create them using a template trigger.

These comments can be added to any feature you're working on to highlight potential issues. The team can then review and agree on whether it's a problem, its priority, or if it can be deferred.

With this approach, you can consistently track and prioritize tech debt, allowing you to focus on the most critical areas. The GIF below explains and visualizes this process (with 1 being the highest priority and 5 the lowest).

How To Track Tech Dept Tracking Tech Dept

You can then create a single ticket that addresses multiple similar issues, allowing for discussion in the PRs. Once the issue is resolved, the tech debt comment should be removed.

Searching Tech Debt Stuff Spotting Tech Debt at the Same Priority Level

When working on any feature, you may raise questions about the tech debt in the PR. It's crucial to find a compromise and avoid nitpicking to prevent wasting time. No process can save you if people are overly nitpicky, which is a common issue in modern software development.

What about process-related tech debt, such as using the wrong Git workflow or having an ineffective code review process? This type of debt can't and shouldn't be tracked in the code. Instead, create separate spike tickets or PoCs with diagrams, discuss them with team members, and test the proposed changes. As I've said before and will continue to emphasize - don't force solutions. Find what works best for your team; there is no one-size-fits-all rule for anything in life.

Summary

This article was a bit text-heavy (sorry about that). I usually focus on more "technical" content, but I felt it was important to address this topic. In every project I've worked on, I've encountered some of the issues I described here. And that's perfectly normal. Achieving zero technical debt is impossible.

I've mostly shared personal opinions and perspectives. I don't have the authority to dictate anything, but I want to emphasize the importance of working together as a team to create processes and mechanisms that work for you. Don’t be afraid to adapt and evolve these processes to prevent tech debt from accumulating over time.

The key is to keep it under control, like managing a team of Pokémon. Otherwise, you might get shocked by Pikachu and find yourself overwhelmed by the complexity of problems that you and your team have created.

Author avatar
About Authorpolubis

👋 Hi there! My name is Adrian, and I've been programming for almost 7 years 💻. I love TDD, monorepo, AI, design patterns, architectural patterns, and all aspects related to creating modern and scalable solutions 🧠.