PrefixedVersion: a Value Object for dated versions
What PrefixedVersion is, the problem it solves (identifying and ordering PREFIX.YYYY.MM.N versions), how it is implemented as a C# Value Object with injected time, and how it is tested.
Some data looks like a string but has rules: a format, valid ranges, an order and operations of its own. A version like NEON.2026.03.2 is one of them. Leave it as a string and anything gets in, nobody knows how it sorts, and the "move to the next version" logic ends up copied in five places.
PrefixedVersion is a Value Object that encapsulates it. This article explains what it is for, what each part does and how it is tested.
#What it is for
It represents a version identifier with date and sequence, in this format:
PREFIX.YYYY.MM.N
NEON .2026.03.2
| Part | Meaning |
|---|---|
Prefix |
Who or what is versioned (a customer, a document, a module). Always uppercase and without dots |
Year / Month |
Period when the version was generated (UTC) |
Version |
Counter within that month. Starts at 0 |
It is a variant of calendar versioning (CalVer): the version tells you when it was born and the sequence tells you how many there were that month.
Where it fits: anything regenerated over time that needs a readable, sortable identifier: versions of a document, an integration schema, a price list, a template or a published data batch.
#What it does (and why a Value Object)
A Value Object is defined by its values, not by an identity. Two PrefixedVersion with the same prefix, year, month and number are the same version. That is why the class has the typical traits:
- Immutable: properties are
get-only and the constructor is private.IncrementVersion()does not modify the instance: it returns a new one. - Always valid: there is no way to build a version with month 13 or year 1850. Rules are validated in
CreateandParse; if you hold an instance, it is valid. - Value equality: implements
IEquatable<T>and overloads==,!=andGetHashCode. - Ordered: implements
IComparable<T>and the<,>,<=and>=operators. - Own behavior:
IncrementVersion()andDecrementVersion()live in the type, not scattered across services.
It tackles primitive obsession: using
stringfor something that has rules.
#How each piece works
#Time is injected: TimeProvider
Everything that depends on the current date (Create, Parse, TryParse and IncrementVersion) receives a TimeProvider as a parameter instead of calling DateTimeOffset.UtcNow. This brings two advantages:
- Deterministic tests: with
FakeTimeProvideryou pin the date and test a month or year change without waiting or relying on the real clock. - Explicit dependency: the signature makes it clear the operation depends on time.
The TimeProvider is not stored inside the Value Object: a value is defined by its data, not by the services used to create it. The caller (an application service) holds it and passes it when needed.
#Creation: Create, Parse and TryParse
Create(timeProvider, prefix)builds the current version: today's year and month (UTC) withVersion = 0.Parse(text, timeProvider)rebuilds a version from text and validates every part: four segments, non-empty prefix, year between 2000 and the current year, month between 1 and 12. Invalid text throwsFormatException, likeint.Parse.TryParse(text, timeProvider, out result)does the same without exceptions: meant for external input, where invalid text is expected.- Numbers are parsed strictly (digits only, invariant culture):
" +5"or"-1"are rejected, so whatever gets in always round-trips withToString(). - The prefix is normalized (
Trim+ uppercase):neonandNEONare the same version. It cannot contain., since that would break the format.
#Domain operations
IncrementVersion(timeProvider)adds 1 if we are still in the same month. If the month (or year) has passed, it resets the sequence in the current period:NEON.2026.03.9becomesNEON.2026.04.0. It never goes backwards: if the version belongs to a period later than the clock (for example, due to clock skew between servers), it stays in its period and adds 1.DecrementVersion()subtracts 1 with a floor of0. It does not cross periods on purpose: the type does not know the last version of the previous month, so it cannot invent it. If you later need to compare against the immediate previous version, that data must come from the repository, not from the Value Object.
#Comparison
The order is year → month → version. Comparing versions with different prefixes throws, because they have no meaningful order between them (ordering NEON against ACME means nothing).
#Conversion to string
There is an implicit conversion to string (it never fails). There is no inverse: parsing needs a TimeProvider and can fail, so it is done explicitly with Parse or TryParse.
#The code
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
namespace DomainExample.Domain.ValueObjects;
public sealed class PrefixedVersion : IEquatable<PrefixedVersion>, IComparable<PrefixedVersion>
{
private const string DefaultPrefix = "VER";
private const int MinYear = 2000;
#region Properties
public string Prefix { get; }
public int Year { get; }
public int Month { get; }
public int Version { get; }
#endregion
#region Constructors
private PrefixedVersion(string prefix, int year, int month, int version)
{
Prefix = prefix;
Year = year;
Month = month;
Version = version;
}
#endregion
#region Factory Methods
public static PrefixedVersion Create(TimeProvider timeProvider, string prefix = DefaultPrefix)
{
ArgumentNullException.ThrowIfNull(timeProvider);
ArgumentException.ThrowIfNullOrWhiteSpace(prefix);
string normalizedPrefix = NormalizePrefix(prefix);
if (normalizedPrefix.Contains('.'))
throw new ArgumentException("Prefix cannot contain '.'.", nameof(prefix));
DateTimeOffset now = timeProvider.GetUtcNow();
return new PrefixedVersion(normalizedPrefix, now.Year, now.Month, 0);
}
public static PrefixedVersion Parse(string text, TimeProvider timeProvider)
{
ArgumentException.ThrowIfNullOrWhiteSpace(text);
ArgumentNullException.ThrowIfNull(timeProvider);
return TryParseCore(text, timeProvider, out PrefixedVersion? value, out string? error)
? value
: throw new FormatException(error);
}
public static bool TryParse(
string? text, TimeProvider timeProvider, [NotNullWhen(true)] out PrefixedVersion? result)
{
ArgumentNullException.ThrowIfNull(timeProvider);
return TryParseCore(text, timeProvider, out result, out _);
}
#endregion
#region Domain Operations
/// <summary>
/// Devuelve una nueva versión incrementada en 1.
/// Si era: NEON.2026.03.1, ahora será: NEON.2026.03.2.
/// Si ya pasó el mes (o el año), reinicia la secuencia en el período actual.
/// Nunca retrocede: si la versión es de un período posterior al reloj, sigue en su período.
/// </summary>
public PrefixedVersion IncrementVersion(TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(timeProvider);
DateTimeOffset now = timeProvider.GetUtcNow();
bool isPastPeriod = Year < now.Year || (Year == now.Year && Month < now.Month);
return isPastPeriod
? new PrefixedVersion(Prefix, now.Year, now.Month, 0)
: new PrefixedVersion(Prefix, Year, Month, Version + 1);
}
/// <summary>
/// Devuelve una nueva versión decrementada en 1 (mínimo 0).
/// Si era: NEON.2026.03.2, ahora será: NEON.2026.03.1.
/// No cruza de período: no se conoce cuál fue la última versión del mes anterior.
/// </summary>
public PrefixedVersion DecrementVersion() =>
new(Prefix, Year, Month, Math.Max(0, Version - 1));
#endregion
#region Equality
public bool Equals(PrefixedVersion? other) =>
other is not null &&
Prefix == other.Prefix &&
Year == other.Year &&
Month == other.Month &&
Version == other.Version;
public override bool Equals(object? obj) => Equals(obj as PrefixedVersion);
public override int GetHashCode() => HashCode.Combine(Prefix, Year, Month, Version);
public static bool operator ==(PrefixedVersion? left, PrefixedVersion? right)
{
if (left is null && right is null) return true;
if (left is null || right is null) return false;
return left.Equals(right);
}
public static bool operator !=(PrefixedVersion? left, PrefixedVersion? right) => !(left == right);
#endregion
#region Conversions
// Solo hacia string: nunca falla. No hay conversión implícita desde string
// porque parsear necesita un TimeProvider y puede fallar.
[return: NotNullIfNotNull(nameof(prefixedVersion))]
public static implicit operator string?(PrefixedVersion? prefixedVersion) => prefixedVersion?.ToString();
#endregion
#region Overrides
public override string ToString() => $"{Prefix}.{Year}.{Month:00}.{Version}";
#endregion
#region Private Helpers
private static string NormalizePrefix(string prefix) => prefix.Trim().ToUpperInvariant();
private static bool TryParseNumber(string text, out int number) =>
int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out number);
private static bool TryParseCore(
string? text,
TimeProvider timeProvider,
[NotNullWhen(true)] out PrefixedVersion? value,
[NotNullWhen(false)] out string? error)
{
value = null;
if (string.IsNullOrWhiteSpace(text))
{
error = "Value cannot be null or whitespace.";
return false;
}
string[] parts = text.Split('.');
if (parts.Length != 4)
{
error = "Invalid format. Expected format: PREFIX.YYYY.MM.VER";
return false;
}
string prefix = NormalizePrefix(parts[0]);
if (prefix.Length == 0)
{
error = "You must set a valid prefix.";
return false;
}
if (!TryParseNumber(parts[1], out int year))
{
error = "Invalid year format.";
return false;
}
int currentYear = timeProvider.GetUtcNow().Year;
if (year < MinYear || year > currentYear)
{
error = $"Year must be between {MinYear} and the current year.";
return false;
}
if (!TryParseNumber(parts[2], out int month))
{
error = "Invalid month format.";
return false;
}
if (month is < 1 or > 12)
{
error = "Month must be between 1 and 12.";
return false;
}
if (!TryParseNumber(parts[3], out int version))
{
error = "Invalid version format.";
return false;
}
value = new PrefixedVersion(prefix, year, month, version);
error = null;
return true;
}
#endregion
#region Comparison
public int CompareTo(PrefixedVersion? other)
{
if (other is null) return 1;
if (ReferenceEquals(this, other)) return 0;
if (Prefix != other.Prefix)
throw new InvalidOperationException(
$"Versions with different prefixes are not comparable: '{Prefix}' vs '{other.Prefix}'.");
int byYear = Year.CompareTo(other.Year);
if (byYear != 0) return byYear;
int byMonth = Month.CompareTo(other.Month);
if (byMonth != 0) return byMonth;
return Version.CompareTo(other.Version);
}
public static bool operator <(PrefixedVersion? left, PrefixedVersion? right) =>
left is null ? right is not null : left.CompareTo(right) < 0;
public static bool operator >(PrefixedVersion? left, PrefixedVersion? right) =>
left is not null && left.CompareTo(right) > 0;
public static bool operator <=(PrefixedVersion? left, PrefixedVersion? right) =>
left is null || left.CompareTo(right) <= 0;
public static bool operator >=(PrefixedVersion? left, PrefixedVersion? right) =>
left is null ? right is null : left.CompareTo(right) >= 0;
#endregion
}