DEV Community

Cover image for Practical Versioning Considerations for .NET APIs and Packages
Eelco Los
Eelco Los

Posted on

Practical Versioning Considerations for .NET APIs and Packages

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 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Rolling Minor Versions (for continuous development):

5.35.0 → 5.36.0-beta.1 → 5.36.0-beta.2 → 5.36.0
Enter fullscreen mode Exit fullscreen mode

Build Number Strategy (when assembly version clarity matters):

4.0.3.1-beta → 4.0.3.2-beta → 4.0.4.0
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Minimal APIs (default camelCase):

{
  "productId": 123,
  "productName": "Widget",
  "createdDate": "2024-01-15T10:30:00Z"
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Benefits of this approach:

  1. v3.0.0 upgrade path: Consumers can upgrade to the major version and see exactly what needs to be fixed
  2. Emergency override: Teams can temporarily suppress the error (#pragma warning disable CS0618) while they migrate
  3. Clear timeline: Everyone knows the method will be completely gone in the next minor release
  4. 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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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))
    };
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"));
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Assuming minor changes aren't breaking - JSON casing, default values, and behavior changes can break consumers
  2. Not testing with real consumer scenarios - Your breaking change detector might miss runtime issues
  3. Inadequate deprecation periods - Give consumers time to migrate
  4. 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!

References

Top comments (0)