The Problem: Psychology of Technical Debt

Teams fall into a vicious cycle not due to lack of knowledge, but because of psychological barriers. When a codebase is large and fragile, every change feels risky. Developers start "programming from fear" — making minimal changes to avoid breaking something else.

This fear creates a self-reinforcing cycle: the worse the code becomes, the less people want to improve it, making it even worse. Psychologists call this learned helplessness — a state where people stop trying to change a situation, even when opportunities exist.

Signs of learned helplessness in code: Phrases like "it's always worked this way", "better not touch it", "rewriting would be easier than understanding", workarounds instead of fixes.

Broken Windows Theory in Code

In criminology, the broken windows theory states that visible signs of disorder encourage further disorder. The same happens with code.

When a developer sees a file with poor tests, they subconsciously think: "standards are lower here, I can write something quick and dirty". One hack leads to another, and soon the entire module becomes "that legacy part nobody touches".

Example evolution of a "broken window":

1// Day 1: "Quick fix"
2function calculatePrice(item) {
3  return item.price * 1.2; // TODO: extract tax rate to config
4}
5
6// A month later: "Well, everything's already bad here"
7function calculatePrice(item) {
8  return item.price * 1.2; // TODO: extract tax rate to config
9  // HACK: special logic for VIP clients
10  if (item.customerId === "vip_123") return item.price * 1.1;
11}
12
13// Six months later: "Who even wrote this?"
14function calculatePrice(item) {
15  var tax = 1.2; // TODO: extract tax rate to config
16  if (item.customerId === "vip_123") tax = 1.1;
17  if (item.customerId === "vip_456") tax = 1.05; // another VIP
18  // temporary solution for Black Friday
19  if (new Date().getMonth() === 10) tax = 1.0;
20  return item.price * tax;
21}

The Rule: New Code Must Be Better

Incremental constraints work because they fight the psychology of fear with small, achievable steps. The core principle: every change should leave the code in a better state than before the change.

This doesn't mean "make the code perfect". It means "make the code slightly better". The difference seems minor, but psychologically it's a huge shift: from "all or nothing" to "small steps forward".

The Boy Scout Rule

Robert Martin (Uncle Bob) formulated this as "the boy scout rule": leave the campsite cleaner than you found it. In code: every file you touch should become slightly better.

Specific rules for implementation:

  • New files: Minimum quality standard (e.g., basic tests for public methods)
  • Modified files: Coverage cannot decrease, preferably increase by 5-10%
  • Critical bugs: Must add a regression test
  • Refactoring: Every simplification must be accompanied by a test proving behavioral equivalence

How to Implement: Step-by-Step Plan

Stage 1: Measurement without judgment

Start with honest measurement of the current state. Do this without blame or judgment. The goal is to understand where you are now so you can plan where to go.

1# Test coverage analysis
2npm run test -- --coverage --verbose
3
4# Code complexity analysis
5npx eslint src/ --format json > complexity-report.json
6
7# Duplication analysis
8npx jscpd src/ --reporters json --output ./reports/

Stage 2: Choose the first constraint

Choose one measurable constraint that's easy to automate. Test coverage is a good choice because:

  • Easy to measure automatically
  • Results are immediately visible
  • Forces thinking about code design (testability)
  • Creates a safety net for future changes

Stage 3: Setup automation

Rules must be checked automatically, otherwise they become "suggestions". Set up CI/CD checks to block merges when rules are violated.

1// jest.config.js - incremental improvement setup example
2module.exports = {
3  collectCoverageFrom: ['src/**/*.{js,ts,tsx}'],
4  coverageThreshold: {
5    global: {
6      branches: 25, // Current level - don't decrease
7      functions: 30,
8      lines: 35,
9      statements: 35
10    },
11    // New files (created after implementing rules)
12    'src/features/**/*.ts': {
13      branches: 70,
14      functions: 80,
15      lines: 80,
16      statements: 80
17    }
18  }
19}

Resistance and How to Overcome It

Resistance to change is a natural reaction. It's important to understand the root causes and work with them, not against them.

Common objections and responses:

"We don't have time for tests"

Root problem: Short-term thinking, fear of deadlines.
Response: Start by measuring time spent debugging bugs. Usually it's 30-50% of development time. Tests are an investment that pays off in 2-3 weeks.

"Tests don't find real bugs"

Root problem: Experience with bad tests that test the wrong things.
Response: Focus on testing behavior, not implementation. A test should answer "what should happen", not "how it happens".

"Legacy code can't be tested"

Root problem: Code designed without testability in mind.
Response: Use the "golden master" technique — find one entry point to the module and write an integration test. Then gradually break it into smaller pieces.

Automation: Tools and Setup

Git hooks for immediate feedback

1#!/bin/sh
2# .git/hooks/pre-commit
3
4echo "🔍 Checking quality of changed code..."
5
6# Get list of changed files
7CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|ts|tsx)$')
8
9if [ -z "$CHANGED_FILES" ]; then
10  echo "No changed JS/TS files"
11  exit 0
12fi
13
14# Check each file
15for file in $CHANGED_FILES; do
16  echo "Checking $file..."
17  
18  # Run tests only for this file
19  npm test -- --testPathPattern="$file" --passWithNoTests --silent
20  
21  if [ $? -ne 0 ]; then
22    echo "Tests for $file failed"
23    exit 1
24  fi
25done
26
27echo "All checks passed"
28exit 0

CI/CD checks with smart analysis

1# .github/workflows/quality-gate.yml
2name: Quality Gate
3on: [pull_request]
4
5jobs:
6  quality-check:
7    runs-on: ubuntu-latest
8    steps:
9      - uses: actions/checkout@v3
10        with:
11          fetch-depth: 0  # Needed for diff analysis
12      
13      - name: Setup Node.js
14        uses: actions/setup-node@v3
15        with:
16          node-version: '18'
17          cache: 'npm'
18      
19      - name: Install dependencies
20        run: npm ci
21      
22      - name: Check test coverage for changed files
23        run: |
24          # Get files changed in this PR
25          CHANGED_FILES=$(git diff --name-only origin/main...HEAD | grep -E '\.(js|ts|tsx)$')
26          
27          if [ ! -z "$CHANGED_FILES" ]; then
28            echo "Checking coverage for: $CHANGED_FILES"
29            npm run test:coverage -- --testPathIgnorePatterns="<rootDir>/src/__tests__/old/"
30          fi
31      
32      - name: Comment PR with results
33        uses: actions/github-script@v6
34        with:
35          script: |
36            github.rest.issues.createComment({
37              issue_number: context.issue.number,
38              owner: context.repo.owner,
39              repo: context.repo.repo,
40              body: 'Quality gate passed! New code meets our standards.'
41            })

Smart ESLint setup for incremental improvement

1// .eslintrc.js
2module.exports = {
3  extends: ['eslint:recommended', '@typescript-eslint/recommended'],
4  overrides: [
5    {
6      // Strict rules for new files
7      files: ['src/features/**/*.ts', 'src/components/new/**/*.tsx'],
8      rules: {
9        'complexity': ['error', 8],
10        'max-lines-per-function': ['error', 30],
11        '@typescript-eslint/explicit-function-return-type': 'error',
12        'prefer-const': 'error'
13      }
14    },
15    {
16      // Soft rules for legacy code
17      files: ['src/legacy/**/*.js'],
18      rules: {
19        'complexity': ['warn', 15],
20        'max-lines-per-function': ['warn', 100],
21        '@typescript-eslint/explicit-function-return-type': 'off'
22      }
23    }
24  ]
25}

Real Implementation Examples

Example 1: Refactoring with behavior preservation

1// BEFORE: Complex function without tests
2function processUserData(userData) {
3  if (!userData) return null;
4  
5  let result = {};
6  if (userData.firstName && userData.lastName) {
7    result.fullName = userData.firstName + ' ' + userData.lastName;
8  }
9  
10  if (userData.birthDate) {
11    const today = new Date();
12    const birth = new Date(userData.birthDate);
13    const age = today.getFullYear() - birth.getFullYear();
14    if (today.getMonth() < birth.getMonth() || 
15        (today.getMonth() === birth.getMonth() && today.getDate() < birth.getDate())) {
16      age--;
17    }
18    result.age = age;
19  }
20  
21  if (userData.email && userData.email.includes('@')) {
22    result.email = userData.email.toLowerCase();
23  }
24  
25  return result;
26}
27
28// AFTER: Broken into testable functions
29function createFullName(firstName, lastName) {
30  if (!firstName || !lastName) return undefined;
31  return `${firstName} ${lastName}`;
32}
33
34function calculateAge(birthDate) {
35  if (!birthDate) return undefined;
36  
37  const today = new Date();
38  const birth = new Date(birthDate);
39  let age = today.getFullYear() - birth.getFullYear();
40  
41  if (today.getMonth() < birth.getMonth() || 
42      (today.getMonth() === birth.getMonth() && today.getDate() < birth.getDate())) {
43    age--;
44  }
45  
46  return age;
47}
48
49function normalizeEmail(email) {
50  if (!email || !email.includes('@')) return undefined;
51  return email.toLowerCase();
52}
53
54function processUserData(userData) {
55  if (!userData) return null;
56  
57  return {
58    fullName: createFullName(userData.firstName, userData.lastName),
59    age: calculateAge(userData.birthDate),
60    email: normalizeEmail(userData.email)
61  };
62}
63
64// Tests that became easy to write
65describe('processUserData', () => {
66  it('should create full name from first and last name', () => {
67    expect(createFullName('John', 'Doe')).toBe('John Doe');
68    expect(createFullName('John', '')).toBeUndefined();
69  });
70  
71  it('should calculate age correctly', () => {
72    const birthDate = new Date('1990-01-01');
73    const age = calculateAge(birthDate);
74    expect(age).toBeGreaterThan(30);
75  });
76})

Example 2: Adding characterization tests

When code is too complex for immediate refactoring, start with characterization tests — tests that document current behavior, whatever it may be.

1// Legacy code that's scary to touch
2function calculateDiscount(user, items, promoCode) {
3  // 200 lines of tangled logic
4  // Multiple edge cases and special rules
5  // Nobody knows exactly how this works
6}
7
8// Characterization test - document what happens NOW
9describe('calculateDiscount - current behavior', () => {
10  it('should handle VIP user with promo code XYZ123', () => {
11    const user = { type: 'VIP', id: 'vip_123' };
12    const items = [{ price: 100, category: 'electronics' }];
13    const result = calculateDiscount(user, items, 'XYZ123');
14    
15    // We don't know WHY the result is exactly this,
16    // but we're fixing the current behavior
17    expect(result.discount).toBe(0.25);
18    expect(result.reason).toBe('VIP_SPECIAL_PROMO');
19  });
20  
21  // Add tests for all found combinations
22  it('should handle regular user without promo', () => {
23    // ...fix current behavior
24  });
25});

After creating characterization tests, you can safely refactor while ensuring behavior doesn't change.

Scaling to Other Metrics

After successfully implementing testing constraints, teams usually suggest extending the approach to other quality aspects. It's important to add new constraints gradually, not overloading the process.

Performance

Rule:

New components should load no slower than similar existing ones.

1// performance.test.js
2const { performance } = require('perf_hooks');
3
4describe('Component Performance', () => {
5  it('new dashboard loads within acceptable time', async () => {
6    const start = performance.now();
7    await loadDashboard();
8    const end = performance.now();
9    
10    // No slower than existing by 20%
11    expect(end - start).toBeLessThan(BASELINE_LOAD_TIME * 1.2);
12  });
13});

Security

Rule:

Every new API endpoint must pass automatic security scan.

1# .github/workflows/security.yml
2- name: Security scan for new endpoints
3  run: |
4    # Find new API routes
5    NEW_ROUTES=$(git diff --name-only origin/main | grep -E 'routes|controllers')
6    
7    if [ ! -z "$NEW_ROUTES" ]; then
8      npm run security:scan -- --paths="$NEW_ROUTES"
9      npm run dependency:audit
10    fi

Accessibility

Rule:

New pages should pass automatic a11y checks with 90+ score.

1// a11y.test.js
2import { axe } from 'jest-axe';
3
4describe('Accessibility', () => {
5  it('new user profile page meets a11y standards', async () => {
6    const { container } = render(<NewUserProfile />);
7    const results = await axe(container);
8    
9    expect(results).toHaveNoViolations();
10  });
11});

Cultural Shift: From Hacks to Habits

The real success of incremental constraints isn't in the metrics, but in changing the team's mindset. Instead of asking "how to write code faster", developers start asking "how to write code that will be easy to maintain".

Signs of cultural shift:

  • Code reviews became more meaningful: Discussing architectural decisions, not just syntax
  • Newcomers contribute quality code from day one: High standards become natural
  • Fear of refactoring disappears: Having tests gives confidence in changes
  • Proactive improvements: Developers suggest new constraints and tools themselves

Story from practice:

In one team, a junior developer in their third week suggested adding automatic cyclomatic complexity checks because they "noticed functions were getting too long". This happened not because they were forced to, but because code quality had become part of the team culture.

How to maintain quality culture:

  • Celebrate improvements: Acknowledge metric achievements in the team
  • Share knowledge: Regular internal talks about best practices
  • Teach by example: Code review as a learning tool, not control
  • Encourage experiments: Let the team propose new rules

Tools and Resources

Code quality analysis tools

Jest

JavaScript testing with built-in coverage analysis. Supports coverage thresholds for different folders.

SonarQube

Comprehensive code quality analysis: coverage, complexity, duplication, vulnerabilities.

ESLint

Configurable linter with complexity and code quality rules. Supports different rules for different folders.

JSCPD

Code duplication detection with configurable thresholds and exclusions.

Automation and CI/CD

Husky

Git hooks for running checks before commit and push.

lint-staged

Run linters only on staged files for fast feedback.

Danger.js

Automate code review with ability to block PRs when rules are violated.

GitHub Actions

CI/CD with rich ecosystem of actions for code quality analysis.

Useful articles and books

Conclusion: The Power of Small Consistent Improvements

Incremental constraints work not because of metric magic, but because of mindset change. They transform code quality from "a big task for later" into "a small habit today".

The most successful teams aren't those who wrote perfect code from the start, but those who learned to make code slightly better every day. Start with one rule, one metric, one week — and see what happens.

Action for today: Measure one quality metric in your project. Not to immediately improve it, but just to know where you stand. Sometimes the biggest journeys begin with understanding the starting point.

Ready to turn technical debt into daily improvements?
Want to implement incremental constraints in your team? I'll help design rules and automation that will actually stick and improve your development culture.