The Problem
Managing versions across multiple components of a single service can be a headache. When you have a frontend, backend, and mobile app that work together, should they all share a single semantic version number for the entire service? This question came up during a recent architecture discussion, and it highlights a common dilemma in modern software development.
The core challenge is balancing management simplicity with deployment flexibility. While unified versioning seems clean from a system management perspective, it can lead to unnecessary updates when only one component changes. Conversely, managing separate versions for each component increases operational overhead.
Investigation
Let's examine the trade-offs of different versioning approaches:
Unified Versioning Pros:
- Simplified tracking: One version number represents the entire system state
- Easy rollbacks: All components can be reverted to a known working state together
- Clear deployment boundaries: Ensures all components are tested together
- Reduced cognitive load: Operations teams only need to track one version
Unified Versioning Cons:
- Unnecessary deployments: Backend bug fixes trigger frontend/app version bumps
- Deployment coupling: Independent component updates become impossible
- Version inflation: Rapid version increments even for minor changes
- Resource waste: Rebuilding and redeploying unchanged components
Separate Versioning Issues:
- Version drift: Components can become incompatible over time
- Complex tracking: Multiple version combinations to manage
- Integration challenges: Determining which versions work together
- Higher operational overhead: More moving parts to coordinate
Root Cause
The fundamental issue stems from conflicting requirements:
- System coherence: Need to treat the service as a unified product
- Component independence: Want to deploy updates without affecting other parts
- Operational simplicity: Don't want to manage complex version matrices
- Development velocity: Need to ship fixes and features quickly
These requirements seem mutually exclusive with traditional versioning approaches.
Solution
Here are three practical strategies that solve this problem:
Strategy 1: Major.Minor Lock with Component Patches
Use a shared major.minor version for system compatibility, but allow independent patch versions for components:
# System compatibility version: 2.3.x
System Version: "2.3"
# Component-specific versions
Backend: "2.3.0-backend.7" # 7th backend revision
Frontend: "2.3.1-frontend.2" # Frontend update with 2nd revision
Mobile App: "2.3.0" # No changes, stays at base versionImplementation example in package.json:
{
"name": "my-service-backend",
"version": "2.3.0-backend.7",
"systemVersion": "2.3",
"buildNumber": "7"
}Strategy 2: API-Driven Versioning
For API-centric architectures, version based on API compatibility:
# API version defines system compatibility
API Version: "v2.3"
# Components track revisions within API version
Frontend: "2.3-r5" # 5th revision for API v2.3
Backend: "2.3-r9" # 9th revision for API v2.3
Mobile: "2.3-r2" # 2nd revision for API v2.3Docker compose example:
version: '3.8'
services:
backend:
image: myapp/backend:2.3-r9
environment:
- API_VERSION=v2.3
frontend:
image: myapp/frontend:2.3-r5
environment:
- API_VERSION=v2.3Strategy 3: Version Mapping with Soft Constraints
Maintain a mapping table that tracks component versions under a unified system version:
// version-manifest.ts
export const VERSION_MANIFEST = {
"2.3.0": {
frontend: "2.3.5",
backend: "2.3.9",
mobile: "2.3.2"
},
"2.3.1": {
frontend: "2.3.6", // Frontend updated
backend: "2.3.9", // Backend unchanged
mobile: "2.3.2" // Mobile unchanged
},
"2.3.2": {
frontend: "2.3.6", // Frontend unchanged
backend: "2.3.10", // Backend updated
mobile: "2.3.2" // Mobile unchanged
}
};Automated deployment script:
#!/bin/bash
SYSTEM_VERSION="2.3.2"
# Get component versions from manifest
FRONTEND_VERSION=$(jq -r ".\"$SYSTEM_VERSION\".frontend" version-manifest.json)
BACKEND_VERSION=$(jq -r ".\"$SYSTEM_VERSION\".backend" version-manifest.json)
MOBILE_VERSION=$(jq -r ".\"$SYSTEM_VERSION\".mobile" version-manifest.json)
# Deploy only if version changed
if [[ "$FRONTEND_VERSION" != "$PREVIOUS_FRONTEND_VERSION" ]]; then
deploy_frontend $FRONTEND_VERSION
fi
if [[ "$BACKEND_VERSION" != "$PREVIOUS_BACKEND_VERSION" ]]; then
deploy_backend $BACKEND_VERSION
fiImplementation Recommendation
For most teams, I recommend Strategy 1 (Major.Minor Lock with Component Patches) because it:
- Provides clear compatibility boundaries
- Allows independent component updates
- Maintains semantic versioning principles
- Requires minimal tooling changes
Here's a complete implementation example:
// version-utils.js
class VersionManager {
constructor(systemMajor, systemMinor) {
this.systemMajor = systemMajor;
this.systemMinor = systemMinor;
}
generateComponentVersion(component, patchNumber) {
return `${this.systemMajor}.${this.systemMinor}.0-${component}.${patchNumber}`;
}
isCompatible(version1, version2) {
const [major1, minor1] = version1.split('.');
const [major2, minor2] = version2.split('.');
return major1 === major2 && minor1 === minor2;
}
incrementComponent(currentVersion) {
const match = currentVersion.match(/(\d+\.\d+\.\d+)-(\w+)\.(\d+)/);
if (match) {
const [, baseVersion, component, patch] = match;
return `${baseVersion}-${component}.${parseInt(patch) + 1}`;
}
throw new Error('Invalid version format');
}
}
// Usage
const versionManager = new VersionManager(2, 3);
const backendVersion = versionManager.generateComponentVersion('backend', 7);
console.log(backendVersion); // "2.3.0-backend.7"Lessons Learned
Prevention Tips
-
Establish versioning strategy early: Don't wait until you have multiple components to figure this out
-
Automate version management: Use tools to prevent human errors in version synchronization
-
Document compatibility matrices: Clearly specify which component versions work together
-
Implement version checks: Add runtime validation to catch incompatible component combinations
Key Takeaways
-
Unified versioning isn't always the answer: Consider your team's specific needs and deployment patterns
-
Flexibility costs complexity: Every versioning strategy involves trade-offs between simplicity and flexibility
-
API compatibility is king: Focus versioning strategy around breaking changes, not implementation details
-
Start simple, evolve as needed: Begin with basic unified versioning and adapt as your system grows
The goal isn't to find the perfect versioning strategy, but to choose one that aligns with your team's workflow and system architecture. As your service evolves, be prepared to adjust your approach based on real operational experience.
