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:
- Different versions of UI kit elements
- Multiple versions of an entire repository
- Various API versions for different clients
- Refactoring of the current module that changes entirely inside, affecting everything it possibly can
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:
- Which parts might break
- Where we need to safeguard
- What auxiliary code we’ll have to write to temporarily put “crutches” in vulnerable points that might crack under heavy changes and load
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:
- You can version modules independently
- Roll back a specific module without touching the others
- Develop in parallel without conflicts
- Roll out changes to different teams at their own pace
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:
- auto — changelogs, release cycles, package publishing
- Changesets — version automation in monorepos
- monorepo tools — independent or fixed package versions
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:
- Version in URL (
/api/v1/) - Version in headers (
Accept-Version) - Different GraphQL schemas for different clients
Examples:
- Stripe — the client chooses which API version to use
- Google Maps API — fixed versions in the URL
📎 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:
- A clear versioning strategy for the logic (SemVer or your own format)
- Team alignment so no one invents “their own” system
- Automation — even simple linters and bash scripts that track changes
- A plan to remove old versions
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:
- File
.current-versioncontains modules versions config - Script finds all
v*directories - Excludes directories matching current versions
- Shows older versions that can be safely removed
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:
- CI creates separate jobs for each version (
v1,v2,v3) - Each job runs tests with specific module version
- Helps catch if new version breaks compatibility with old ones
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.