Skip to content

Escaping the Version Snowball

Published: at 03:22 PM

I think many of us have faced situations in medium and large projects where we have to maintain several versions of [insert your case here…]. These could be:

Not in every project have I seen such transitions go smoothly. Sometimes, the migration from one version to another can take quite a while, and as a result, you end up with a “pile” of mixed code. People leave the project, and you turn from a developer into a sapper, trying to guess which piece will break which version—or all of them at once.

I wouldn’t say there is a universal approach that solves all migration problems. It’s almost always expensive, painful, and tedious. But at the very least, we can maintain “project hygiene” while going through this swamp so we don’t spread the mess everywhere later.

So let’s look at different cases and approaches.

Preparation

Take it easy

First of all—breathe, relax, and try to look at the piece of code you’ll be working on “from a bird’s-eye view.” We need to understand:

Also, analyze your code: very often our problems are similar to ones already solved before, but the devil is in the details. These details are what you need to account for to play it safe.

Case Analysis and Versioning Strategies

When we talk about multiple versions of the same module, it’s important not only how they will live in the code but also how we will manage their lifecycle. Depending on how the project is structured, strategies may differ. We’ll mostly cover working with monolithic code and internal modules, but you shouldn’t ignore common system-wide versioning approaches—because you can easily borrow a method from one scope to another (and who’s going to stop you?).

Modules split into separate packages

This is probably the most comfortable case. For example, our UI Kit or SDK is split into separate packages, and each module is built and released independently in CI. This approach gives full control:

The best versioning strategies for this setup:

SemVer — the classic

Format MAJOR.MINOR.PATCH. Break the API — MAJOR, add features — MINOR, fix bugs — PATCH.

Examples: React (18.2.0 → 18.3.0 → 19.0.0), Lodash.

📎 semver.org
📎 semantic-release — automatic releases and changelog

Date-based versioning

Format: 2024.08 or 2024.08.10

Convenient if releases are on a schedule and it’s more important to know when a version was released than its number, since the SemVer approach for a complex, multi-layered product can’t fully describe the logic of changes.

Examples: Ubuntu (22.04, 24.04), JetBrains IDE (2024.2)

Branch-based versioning

Format: release/1.x, release/2.x

Good for LTS support of old versions alongside new ones.

Examples: Node.js (18.x, 20.x LTS), AngularJS (1.x and 2+)

📎 Git Flow
📎 Trunk Based Development

Automation for this scenario:

API and external contracts

If you have an API used by external clients, the rules are even stricter: breaking an API is expensive.

Here you can use:

Examples:

📎 Microsoft API Versioning Guidelines
📎 Stripe API versioning

Monolith with dozens of modules

Here it gets trickier: modules live in a single codebase, there are many versions (10, 15, 20…), and endless branches quickly turn the project into a mess.

In this case, it’s important to first agree on rules and contracts for versioning.

What works:

Feature flags and configs

Turn logic on/off without creating new branches.

Examples: Facebook (rolls out features to 1% of users), Spotify (A/B UI tests)

📎 LaunchDarkly
📎 Unleash

Feature encapsulation

Don’t scatter flags all over the code, wrap them in a convenient API so you can change conditions without rewriting the whole project.

Partial duplication

Sometimes it’s easier to duplicate a specific scope of a module (core, adapters, external API) in a separate folder than maintain a mess of conditions.

We’ll take a closer look at the monolith with dozens of modules scenario, as the other approaches are already well-covered. In such projects, the main enemy is chaos from parallel changes and conditions.

Working Practices

Once you’ve chosen an approach — document it and discuss it with the team. A common problem is that every developer adds their own “flair” or reinvents the wheel. That’s normal, but without clear boundaries, chaos wins.

Duplication isn’t always bad

If you have many small changes in a module’s API or a UI component — study the logic boundaries: separate core, auxiliary parts, adapters, and the external API. Identify exactly what changes and duplicate that scope, splitting it into two versions.

This makes maintenance easier, improves code readability, and gives room for automation.

Example:

// v1/Button.jsx
import React from 'react';

export function Button({ children, onClick, variant = 'primary' }) {
  const baseClasses = 'px-4 py-2 rounded font-medium';
  const variantClasses = {
    primary: 'bg-blue-500 text-white hover:bg-blue-600',
    secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300'
  };
  
  return (
    <button 
      className={`${baseClasses} ${variantClasses[variant]}`}
      onClick={onClick}
    >
      {children}
    </button>
  );
}

// v2/Button.jsx
import React from 'react';

export function Button({ 
  children, 
  onClick, 
  variant = 'primary', 
  size = 'medium',
  disabled = false,
  icon 
}) {
  const baseClasses = 'rounded font-medium transition-all duration-200 flex items-center gap-2';
  
  const sizeClasses = {
    small: 'px-3 py-1.5 text-sm',
    medium: 'px-4 py-2',
    large: 'px-6 py-3 text-lg'
  };
  
  const variantClasses = {
    primary: 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-blue-300',
    secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 disabled:bg-gray-100',
    danger: 'bg-red-500 text-white hover:bg-red-600 disabled:bg-red-300'
  };
  
  return (
    <button 
      className={`${baseClasses} ${sizeClasses[size]} ${variantClasses[variant]}`}
      onClick={onClick}
      disabled={disabled}
    >
      {icon && <span className="icon">{icon}</span>}
      {children}
    </button>
  );
}

Yes, we could add a version parameter, add a couple of conditional lines, but when your code starts growing with version parameters, wrappers around them, unexpected additions of several more versions, and eventually changes to one version that are completely incompatible with another — that’s when you’ll thank yourself for this approach.

Encapsulate feature flags

Flags are a simple tool, but if version selection depends on a combination of conditions (region, language, user type) — create an abstraction.

Example:

// Feature Version Controller - centralized flag logic
class FeatureVersionController {
  private config = {
    checkout_v1: { rollout: 100, conditions: ['region:US'] },
    checkout_v2: { rollout: 50, conditions: ['region:EU', 'user_type:premium'] },
    checkout_v3: { rollout: 10, conditions: ['feature:beta_tester'] }
  };
  
  getCheckoutVersion(user: User, context: RequestContext): string {
    if (this.shouldUseVersion('checkout_v3', user, context)) return 'v3';
    if (this.shouldUseVersion('checkout_v2', user, context)) return 'v2';
    return 'v1'; // Default fallback
  }
  
  private shouldUseVersion(flag: string, user: User, context: RequestContext): boolean {
    const config = this.config[flag];
    if (!config) return false;
    
    // Check conditions (region, user type, features, etc.)
    const meetsConditions = config.conditions.every(condition => 
      this.evaluateCondition(condition, user, context)
    );
    
    // Check rollout percentage with consistent user hashing  
    const inRollout = this.hashUser(user.id) % 100 < config.rollout;
    
    return meetsConditions && inRollout;
  }
  
  private evaluateCondition(condition: string, user: User, context: RequestContext): boolean {
  }
  // code
}

// Usage
class CheckoutService {
  constructor(private versionController: FeatureVersionController) {}
  
  async processCheckout(user: User, request: CheckoutRequest) {
    const version = this.versionController.getCheckoutVersion(user, request.context);
    
    switch (version) {
      case 'v3': return this.processAdvancedCheckout(request);
      case 'v2': return this.processEUCompliantCheckout(request);
      default: return this.processBasicCheckout(request);
    }
  }
}

Temporary adapters

If you need to support old and new logic for a while, create an adapter that translates data between formats. Especially useful when frontend and backend update at different times.

Example:

// Adapter bridges old and new API formats
class UserDataAdapter {
  // New backend expects nested user object
  static toNewFormat(legacyUser: LegacyUser): NewUser {
    return {
      profile: {
        id: legacyUser.userId,
        name: `${legacyUser.firstName} ${legacyUser.lastName}`,
        email: legacyUser.emailAddress
      },
      preferences: {
        theme: legacyUser.theme || 'light',
        language: legacyUser.lang || 'en'
      },
      metadata: {
        createdAt: legacyUser.registrationDate,
        lastActive: legacyUser.lastLogin
      }
    };
  }
  
  // Old frontend still expects flat structure
  static toLegacyFormat(newUser: NewUser): LegacyUser {
    return {
      userId: newUser.profile.id,
      firstName: newUser.profile.name.split(' ')[0],
      lastName: newUser.profile.name.split(' ').slice(1).join(' ')
      // ...code
    };
  }
}

// Usage during migration period
class UserService {
  async getUser(id: string) {
    const userData = await this.fetchFromNewAPI(id);
    
    // Return format based on client version
    if (this.isLegacyClient()) {
      return UserDataAdapter.toLegacyFormat(userData);
    }
    
    return userData;
  }
}

Compatibility layers

Instead of changing calls throughout the project, create a layer that translates new calls into old ones. This layer can be removed after a couple of releases.

Test matrices

Set up CI to run tests in combinations like “module version + feature config.” This helps catch if a new version breaks an old one.

Automatic old code cleanup

Add tasks to CI/CD that show which module versions aren’t used, or remove them automatically if they’re already disabled in the config.

Automation Tools

In addition to wrappers and encapsulation, we have many ways to make the project watch its own version and contract hygiene. Here are some really practical approaches:

Linters with custom rules

You can write rules to prevent version mixing and enforce clean boundaries between different module versions.

Example (.eslintrc.js):

module.exports = {
  overrides: [
    {
      // Rules for v1 modules - prevent importing from newer versions
      files: ["src/**/v1/**/*.{js,ts}"],
      rules: {
        "no-restricted-imports": [
          "error",
          {
            patterns: [
              {
                group: ["**/v2/**", "**/v3/**"],
                message: "v1 modules cannot import from newer versions"
              }
            ]
          }
        ]
      }
    }
  ]
};

This prevents accidentally importing newer version code into older versions, maintaining clean version boundaries.

📎 Tools: ESLint, Custom ESLint Rules

Bash scripts for finding and cleaning old versions

If your project has many v1, v2 directories, run a script periodically to check them and output a report:

#!/bin/bash
echo "Current version: $(cat .current-versions)"
echo "Searching for outdated modules..."
find ./src -type d -name "v*" | grep -v "v$(cat .current-versions)"

How it works:

This is a primitive, non-working implementation, but I think the principle is clear.

Example output:

Current version: 3
Searching for outdated modules...
./src/components/Button/v1
./src/components/Button/v2
./src/services/payment/v1
./src/api/auth/v2
./src/utils/validation/v1

Found 5 outdated module versions (v1, v2) that can be cleaned up.
Current version v3 is kept.

📎 Can be integrated into a pre-commit hook via Husky

CI scripts for version checks

Run tests in multiple module version combinations:

strategy:
  matrix:
    module_version: [v1, v2, v3]
steps:
  - run: npm test -- --module=${{ matrix.module_version }}

How it works:

This ensures all supported versions work correctly and helps identify breaking changes early.

📎 GitHub Actions, GitLab CI, CircleCI

Dangling feature flag detection

Script to check that feature flags for outdated versions are removed:

// typescript code

/* feature-name old_checkout start */
// code
/* feature-name old_checkout end */


```bash
#!/bin/bash
echo "Scanning for deprecated feature flags..."

# List of flags scheduled for removal
DEPRECATED_FLAGS="old_checkout|legacy_ui|beta_experiment_2023"

# Find usage patterns
grep -r --include="*.ts" --include="*.js" \
  -E "featureFlag\(.*(${DEPRECATED_FLAGS})" ./src
....

💡 Tip: automation is only useful when it’s integrated into the process — pre-commit, CI/CD, release pipelines. A script sitting “in a folder for looks” won’t save you from a mess.

Documentation

With a large number of versions, documentation is a must-have. Which module version is 1, 2, or 24 might only be clear to you, and this versioning doesn’t necessarily match the business description.