Versioning in .NET development goes beyond simply incrementing numbers. In my maintenance of a business critical service and NuGet package I had to migrate to a higher major version on both the API and package. I noticed subtle compatibility considerations when upgrading/migrating.
In this post, we'll explore those versioning challenges and solutions across these key areas:
- The Hidden Complexity of Package Versioning - NuGet vs assembly version discrepancies and pre-release strategies
- API Versioning and Compatibility Challenges - URL versioning approaches and the subtle breaking change of JSON casing
- Breaking Changes You Might Miss - Default behavior changes, dependency bumps, and configuration modifications
- Practical Implementation Guidelines - Concrete strategies for package authors and API designers
- Testing Your Versioning Strategy - Package compatibility and API contract testing approaches
- Version Communication Strategy - How to effectively communicate changes to internal teams and public consumers
- Common Pitfalls to Avoid - Key mistakes to watch out for when versioning
- Conclusion
The Hidden Complexity of Package Versioning
NuGet vs Assembly Version Discrepancy
One often overlooked issue is the disconnect between NuGet package versions and assembly versions. When you create a pre-release like 4.0.3-beta
, the assembly version remains 4.0.3.0
. Developers using decompilers see no indication it's a pre-release, leading to confusion.
The Solution: Use the build number strategically:
<!-- Instead of this -->
<Version>4.0.3-beta</Version>
<!-- Do this -->
<Version>4.0.3.1-beta</Version>
This ensures both NuGet and decompilers show version progression correctly.
Pre-Release Versioning Strategies
Building on this approach, you can choose different strategies depending on your workflow:
Semantic Pre-Release (for major versions):
6.0.0-beta.1 → 6.0.0-beta.2 → 6.0.0-rc.1 → 6.0.0
Rolling Minor Versions (for continuous development):
5.35.0 → 5.36.0-beta.1 → 5.36.0-beta.2 → 5.36.0
Build Number Strategy (when assembly version clarity matters):
4.0.3.1-beta → 4.0.3.2-beta → 4.0.4.0
Key insight: Use beta.1
not beta1
for proper sorting on NuGet.org, and consider build numbers when assembly version visibility is important for your consumers.
API Versioning and Compatibility Challenges
When evolving REST APIs, you'll encounter several versioning strategies and subtle breaking changes that can catch you off guard.
URL Versioning Strategies
// Path-based versioning
[Route("api/v1/products")]
[Route("api/v2/products")]
// Header-based versioning
[HttpGet]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
// Query parameter versioning
/api/products?version=2.0
The Subtle Breaking Change: JSON Casing
Here's a compatibility issue many developers overlook: property casing changes between API frameworks. I encountered this one in migrating my business critical service when changing one of the properties from boolean
to DateTime?
Traditional .NET Controllers (default PascalCase):
{
"ProductId": 123,
"ProductName": "Widget",
"CreatedDate": "2024-01-15T10:30:00Z"
}
Minimal APIs (default camelCase):
{
"productId": 123,
"productName": "Widget",
"createdDate": "2024-01-15T10:30:00Z"
}
This seemingly minor change can break client applications that expect specific casing. Consider this when:
- Migrating from Controllers to Minimal APIs
- Moving between different API frameworks
- Updating serialization configurations
Solutions:
// Explicit casing control
services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.PropertyNamingPolicy =
JsonNamingPolicy.CamelCase;
});
// Or maintain compatibility
services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.PropertyNamingPolicy = null; // PascalCase
});
Breaking Changes You Might Miss
These are the changes that seem harmless but can completely break your consumers' applications.
Default Behavior Changes
// Version 1.0 - throws exceptions
public User GetUser(int id) => userRepository.GetById(id);
// Version 2.0 - returns null (breaking!)
public User GetUser(int id) =>
userRepository.FirstOrDefault(u => u.Id == id);
Dependency Version Bumps
<!-- Your library targets .NET 6 -->
<TargetFramework>net6.0</TargetFramework>
<!-- Upgrading to .NET 8 might break consumers still on .NET 6 -->
<TargetFramework>net8.0</TargetFramework>
Configuration Changes
// Version 1.x
services.AddMyService(options => {
options.EnableFeature = true; // Default was false
});
// Version 2.x - default changed to true
// Consumers expecting false behavior will break
Practical Implementation Guidelines
Success in versioning comes down to clear strategies for both package authors and API designers.
For Package Authors
Package maintainers need strategies that balance innovation with stability.
Version Your Interfaces Explicitly
public interface IMyService
{
Task<string> ProcessAsync(string input);
}
// When adding parameters, create a new interface
public interface IMyServiceV2 : IMyService
{
Task<string> ProcessAsync(string input,
CancellationToken cancellationToken);
}
Use Obsolete Attributes Effectively
// Phase 1: Warning only (default behavior)
[Obsolete("Use ProcessV2Async instead. " +
"This method will be removed in v3.0.0")]
public async Task<string> ProcessAsync(string input)
{
return await ProcessV2Async(input, CancellationToken.None);
}
// Phase 2: Force compilation error in next major version
[Obsolete("Use ProcessV2Async instead. " +
"This method has been removed.", error: true)]
public async Task<string> ProcessAsync(string input)
{
throw new NotSupportedException(
"This method has been removed. Use ProcessV2Async instead.");
}
// Planned obsolescence with clear timeline
[Obsolete("Use ProcessV2Async instead. " +
"This method will become a compilation error in v3.0.0 (June 2024)")]
public async Task<string> ProcessAsync(string input)
{
return await ProcessV2Async(input, CancellationToken.None);
}
Obsolete Progression Strategy:
v2.0.0: Introduce new method
v2.1.0: Mark old method [Obsolete] with warning
v2.2.0: Update message with removal timeline
v3.0.0: Set [Obsolete(error: true)] - forces compilation errors
but method still exists
v3.0.1: Actually remove the obsolete method entirely
Strategic Migration Approach:
// v2.1.0 - Soft warning
[Obsolete("Use ProcessV2Async instead. " +
"This method will cause compilation errors in v3.0.0")]
public async Task<string> ProcessAsync(string input)
{
return await ProcessV2Async(input, CancellationToken.None);
}
// v3.0.0 - Hard error, but method still exists for emergency fallback
[Obsolete("Use ProcessV2Async instead. " +
"This method has been removed.", error: true)]
public async Task<string> ProcessAsync(string input)
{
// Still functional for those who suppress the error temporarily
return await ProcessV2Async(input, CancellationToken.None);
}
// v3.0.1 or v3.1.0 - Complete removal
// Method is entirely deleted from codebase
Benefits of this approach:
- v3.0.0 upgrade path: Consumers can upgrade to the major version and see exactly what needs to be fixed
-
Emergency override: Teams can temporarily suppress the error (
#pragma warning disable CS0618
) while they migrate - Clear timeline: Everyone knows the method will be completely gone in the next minor release
- Safer major version adoption: Teams aren't afraid to upgrade to v3.0.0 knowing they can still compile
Advanced Migration Pattern:
// v3.0.0 - Compilation error with escape hatch
[Obsolete("Use ProcessV2Async instead. " +
"Method will be removed in v3.1.0. " +
"Suppress CS0618 if you need temporary compatibility.",
error: true)]
public async Task<string> ProcessAsync(string input)
{
// Log usage for monitoring migration progress
logger?.LogWarning(
"Obsolete method ProcessAsync called. Migrate to ProcessV2Async.");
return await ProcessV2Async(input, CancellationToken.None);
}
This approach gives consumers a much better migration experience and reduces the fear of upgrading major versions!
Document Breaking Changes
## Breaking Changes in v2.0.0
- JSON response casing changed from PascalCase to camelCase
- `GetUser()` now returns null instead of throwing for missing users
- Minimum .NET version requirement increased to .NET 8
For API Designers
API versioning requires balancing backward compatibility with the need to evolve and improve.
Plan for Backward Compatibility
// Good: Additive changes
public class ProductResponse
{
public int Id { get; set; }
public string Name { get; set; }
public DateTime CreatedDate { get; set; }
// New in v1.1 - doesn't break existing clients
public string? Description { get; set; }
}
Use Content Negotiation
[HttpGet]
public IActionResult GetProduct(int id,
[FromHeader] string? apiVersion = "1.0")
{
return apiVersion switch
{
"2.0" => Ok(productService.GetProductV2(id)),
_ => Ok(productService.GetProduct(id))
};
}
Graceful Deprecation
[HttpGet("api/v1/products")]
[Obsolete("Use /api/v2/products instead")]
public IActionResult GetProductsV1()
{
Response.Headers.Add("X-API-Deprecated", "true");
Response.Headers.Add("X-API-Sunset", "2024-12-31");
return GetProductsV2();
}
Testing Your Versioning Strategy
Testing isn't just about functionality—it's about ensuring your versioning strategy actually works in practice.
Package Compatibility Testing
# Test your package with different framework versions
dotnet pack
dotnet test --framework net6.0
dotnet test --framework net8.0
API Contract Testing
[Test]
public async Task ApiShouldMaintainJsonCasingAsync()
{
var response = await client.GetAsync("/api/v1/products/1");
var json = await response.Content.ReadAsStringAsync();
// Ensure backward compatibility
Assert.That(json, Contains.Substring("ProductId"));
Assert.That(json, Does.Not.Contain("productId"));
}
Version Communication Strategy
Clear communication is just as important as technical implementation when it comes to versioning.
For Internal Teams
- Changelog: Document every change, no matter how small
- Migration guides: Provide step-by-step upgrade instructions
- Deprecation timeline: Give consumers time to adapt
For Public APIs
- API documentation: Version-specific endpoint documentation
- SDKs: Maintain SDK versions aligned with API versions
- Support policy: Clear support timelines for each version
Common Pitfalls to Avoid
- Assuming minor changes aren't breaking - JSON casing, default values, and behavior changes can break consumers
- Not testing with real consumer scenarios - Your breaking change detector might miss runtime issues
- Inadequate deprecation periods - Give consumers time to migrate
- Inconsistent versioning across related packages - Keep related packages in sync
Conclusion
Effective versioning requires thinking beyond semantic version numbers. Consider the entire ecosystem: package consumers, API clients, deployment scenarios, and migration paths. The goal isn't just to communicate changes—it's to enable smooth evolution while maintaining trust with your consumers.
Remember: every change is potentially breaking to someone. The key is understanding its impact and communicating clearly.
What versioning challenges have you encountered in your .NET projects? Share your experiences in the comments below!
Top comments (0)