Understanding Version Management
It's tempting to change your public API whenever you want. You realize a better method signature or find a cleaner design. It works until you deploy version 2.0 and every client application breaks because you removed a method they depend on.
Versioning prevents this chaos. By communicating changes through version numbers, you tell consumers whether an update is safe, adds features, or requires code changes. The CLR uses version numbers to load the correct assemblies. NuGet uses them to resolve dependencies. Your users use them to decide when to upgrade.
You'll learn how C# handles assembly versions, what semantic versioning means, and how to evolve your APIs without breaking existing code. We'll cover the different version attributes, compatibility strategies, and patterns for deprecating old functionality safely.
Assembly Version Attributes
C# assemblies carry multiple version numbers, each serving different purposes. The AssemblyVersion affects runtime binding, AssemblyFileVersion appears in file properties, and AssemblyInformationalVersion shows up in package metadata. Understanding these differences helps you version correctly.
You set these attributes in your project file or through assembly info. Modern .NET projects generate version information automatically from MSBuild properties, making version management cleaner than older framework approaches.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<!-- Version used for assembly binding (CLR) -->
<AssemblyVersion>2.0.0.0</AssemblyVersion>
<!-- Version shown in file properties -->
<FileVersion>2.1.5.0</FileVersion>
<!-- NuGet package version (SemVer format) -->
<Version>2.1.5</Version>
<!-- Informational version (can include metadata) -->
<InformationalVersion>2.1.5+build.123</InformationalVersion>
</PropertyGroup>
</Project>
AssemblyVersion is critical for strong-named assemblies. When you change it, all dependent assemblies must recompile because the runtime considers it a different assembly. FileVersion and InformationalVersion are metadata only and don't affect compatibility. Use AssemblyVersion conservatively and change FileVersion freely for tracking builds.
Semantic Versioning in Practice
Semantic versioning provides a standard for communicating changes through version numbers. The format is Major.Minor.Patch. Increment major for breaking changes, minor for new features that maintain compatibility, and patch for bug fixes. Optional pre-release labels like -beta or -rc indicate unstable versions.
Following this convention helps consumers understand update safety at a glance. Version 3.0.0 signals breaking changes. Version 2.5.0 adds features you can ignore. Version 2.4.1 fixes bugs without changing behavior.
// Version 1.0.0 - Initial release
public class Calculator
{
public int Add(int a, int b)
{
return a + b;
}
public int Subtract(int a, int b)
{
return a - b;
}
}
// Version 1.1.0 - Minor version: Added new features
public class Calculator
{
public int Add(int a, int b)
{
return a + b;
}
public int Subtract(int a, int b)
{
return a - b;
}
// New method - backward compatible
public int Multiply(int a, int b)
{
return a * b;
}
public double Divide(double a, double b)
{
if (b == 0)
throw new DivideByZeroException("Cannot divide by zero");
return a / b;
}
}
// Version 1.1.1 - Patch version: Bug fix
public class Calculator
{
public int Add(int a, int b)
{
return a + b;
}
public int Subtract(int a, int b)
{
return a - b;
}
public int Multiply(int a, int b)
{
return a * b;
}
// Fixed: Better error message
public double Divide(double a, double b)
{
if (b == 0)
throw new ArgumentException("Divisor cannot be zero", nameof(b));
return a / b;
}
}
// Version 2.0.0 - Major version: Breaking change
public class Calculator
{
// Breaking: Changed return type
public decimal Add(decimal a, decimal b)
{
return a + b;
}
// Breaking: Changed parameter types
public decimal Subtract(decimal a, decimal b)
{
return a - b;
}
public decimal Multiply(decimal a, decimal b)
{
return a * b;
}
public decimal Divide(decimal a, decimal b)
{
if (b == 0)
throw new ArgumentException("Divisor cannot be zero", nameof(b));
return a / b;
}
}
Notice how version 1.1.0 adds methods without changing existing ones. Version 1.1.1 improves error handling without changing the method signature. Version 2.0.0 changes parameter and return types, breaking any code that calls these methods. The version numbers communicate these change types without reading changelogs.
Maintaining API Compatibility
Backward compatibility lets consumers upgrade without code changes. You achieve this by adding rather than modifying. When you need to change an API, add a new version and mark the old one obsolete. This gives users time to migrate while keeping their code working.
The Obsolete attribute documents deprecated APIs and optionally generates compiler warnings or errors. You can specify a message guiding users to the replacement. This graceful deprecation path maintains compatibility while evolving your API.
public class DataService
{
// Old API - deprecated but still works
[Obsolete("Use GetDataAsync instead. This method will be removed in version 3.0.")]
public List GetData()
{
return GetDataAsync().GetAwaiter().GetResult();
}
// New API - async and better named
public async Task> GetDataAsync()
{
await Task.Delay(100); // Simulate async work
return new List { "Data1", "Data2", "Data3" };
}
// Version 1: Original method
public void ProcessData(string data)
{
Console.WriteLine($"Processing: {data}");
}
// Version 2: Overload with additional parameter (backward compatible)
public void ProcessData(string data, ProcessingOptions options)
{
Console.WriteLine($"Processing {data} with options: {options.Mode}");
}
// Marking as error after grace period
[Obsolete("This method is obsolete. Use ProcessDataWithValidation instead.", true)]
public void UnsafeProcessData(string data)
{
// Old implementation
}
public void ProcessDataWithValidation(string data)
{
if (string.IsNullOrEmpty(data))
throw new ArgumentException("Data cannot be empty", nameof(data));
Console.WriteLine($"Processing validated: {data}");
}
}
public class ProcessingOptions
{
public string Mode { get; set; } = "Standard";
public int Timeout { get; set; } = 30;
}
// Usage showing migration path
var service = new DataService();
// Old way still works but generates warning
#pragma warning disable CS0618
var oldData = service.GetData();
#pragma warning restore CS0618
// New way is preferred
var newData = await service.GetDataAsync();
// Both versions work
service.ProcessData("test");
service.ProcessData("test", new ProcessingOptions { Mode = "Fast" });
The obsolete attribute gives users a migration path. They can continue using the old method while planning their update. Setting the second parameter to true turns the warning into an error, useful when you're ready to remove the API completely. Method overloads add functionality without breaking existing calls.
NuGet Package Versioning
NuGet packages follow semantic versioning and support version ranges for dependencies. When you publish a package, the version number tells consumers what to expect. Pre-release versions use suffixes like -alpha, -beta, or -rc to indicate stability levels.
Version constraints in PackageReference control which versions NuGet can install. You can specify exact versions, minimum versions, or ranges. This flexibility lets you balance stability with getting updates.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>1.2.0</Version>
</PropertyGroup>
<ItemGroup>
<!-- Exact version -->
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<!-- Minimum version (accepts any newer) -->
<PackageReference Include="Serilog" Version="3.0.0" />
<!-- Version range [min, max) -->
<PackageReference Include="AutoMapper" Version="[12.0.0, 13.0.0)" />
<!-- Pre-release version -->
<PackageReference Include="MyLibrary" Version="2.0.0-beta.1" />
</ItemGroup>
</Project>
Exact versions provide reproducible builds but miss security patches. Minimum versions get updates but might introduce breaking changes. Version ranges balance these concerns by accepting patches and minor updates while preventing major version changes. Choose based on your tolerance for unexpected updates versus missing important fixes.
Try It Yourself
Here's a practical example showing version evolution. This library demonstrates adding features, deprecating APIs, and managing breaking changes across versions.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>2.1.0</Version>
</PropertyGroup>
</Project>
public class Logger
{
[Obsolete("Use LogMessageAsync for better performance")]
public void LogMessage(string message)
{
Console.WriteLine($"[OLD] {DateTime.Now}: {message}");
}
public async Task LogMessageAsync(string message)
{
await Task.Delay(1); // Simulate async operation
Console.WriteLine($"[NEW] {DateTime.Now:HH:mm:ss}: {message}");
}
// New feature in v2.1.0
public async Task LogWithLevelAsync(string message, LogLevel level)
{
await Task.Delay(1);
Console.WriteLine($"[{level}] {DateTime.Now:HH:mm:ss}: {message}");
}
}
public enum LogLevel
{
Info,
Warning,
Error
}
var logger = new Logger();
// Old method works but shows warning
#pragma warning disable CS0618
logger.LogMessage("Application started");
#pragma warning restore CS0618
// Preferred async method
await logger.LogMessageAsync("Processing data");
// New feature
await logger.LogWithLevelAsync("Operation completed", LogLevel.Info);
// Output:
// [OLD] 11/04/2025 2:30:45 PM: Application started
// [NEW] 14:30:45: Processing data
// [Info] 14:30:45: Operation completed
Run this with dotnet run to see version evolution in action. The obsolete attribute generates warnings for the old method while keeping it functional. The new async methods provide better performance, and the LogLevel enum adds features without breaking existing code.
Version Management Guidelines
Keep your AssemblyVersion stable for as long as possible. Only increment it when you make breaking changes that require dependent assemblies to recompile. Use FileVersion to track builds and InformationalVersion for detailed version information.
Document breaking changes clearly in release notes. When you increment the major version, list every breaking change and provide migration guidance. This helps consumers plan their upgrade and understand the impact.
Maintain a changelog following the Keep a Changelog format. Document all notable changes including new features, bug fixes, deprecated features, and removed features. Link version numbers to specific releases in your source control system.
Test backward compatibility with tools like Microsoft's API analyzer. These tools detect breaking changes by comparing assemblies across versions. They catch issues like removed members, changed signatures, or altered behavior before you ship.