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:

CSHARP
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 Create and Parse; if you hold an instance, it is valid.
  • Value equality: implements IEquatable<T> and overloads ==, != and GetHashCode.
  • Ordered: implements IComparable<T> and the <, >, <= and >= operators.
  • Own behavior: IncrementVersion() and DecrementVersion() live in the type, not scattered across services.

It tackles primitive obsession: using string for 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 FakeTimeProvider you 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) with Version = 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 throws FormatException, like int.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 with ToString().
  • The prefix is normalized (Trim + uppercase): neon and NEON are 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.9 becomes NEON.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 of 0. 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

CSHARP
quot;{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 =
quot;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(
quot;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 }" aria-label="Copiar código"> Copiar
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
}

#The tests

They need the Microsoft.Extensions.TimeProvider.Testing package, which provides FakeTimeProvider. They cover the behaviors that define the type: strict parsing, creation, increment (same month, month change, year change and a clock that is behind), decrement, value equality and ordering.

CSHARP
using DomainExample.Domain.ValueObjects;
using Microsoft.Extensions.Time.Testing;

namespace DomainExample.Domain.UnitTests;

public class PrefixedVersionTests
{
	private static readonly DateTimeOffset Now = new(2026, 10, 16, 12, 0, 0, TimeSpan.Zero);

	private readonly FakeTimeProvider _clock = new(Now);

	#region Parse / TryParse

	[Theory]
	[InlineData("NEON.2020.01.1000", "NEON", 2020, 1, 1000)]
	[InlineData("ACME.2021.12.9999", "ACME", 2021, 12, 9999)]
	[InlineData("ZERO.2026.10.16", "ZERO", 2026, 10, 16)]
	[InlineData("base.2018.03.5", "BASE", 2018, 3, 5)]
	public void PrefixedVersionMustParseCorrectly(string input, string expectedPrefix, int expectedYear, int expectedMonth, int expectedVersion)
	{
		PrefixedVersion prefixedVersion = PrefixedVersion.Parse(input, _clock);

		Assert.Equal(expectedPrefix, prefixedVersion.Prefix);
		Assert.Equal(expectedYear, prefixedVersion.Year);
		Assert.Equal(expectedMonth, prefixedVersion.Month);
		Assert.Equal(expectedVersion, prefixedVersion.Version);
	}

	[Theory]
	[InlineData("NEON.2026.10")]        // faltan segmentos
	[InlineData(".2026.10.1")]          // prefijo vacío
	[InlineData("NEON.20x6.10.1")]      // año no numérico
	[InlineData("NEON.1999.10.1")]      // año anterior al mínimo
	[InlineData("NEON.2027.01.1")]      // año futuro
	[InlineData("NEON.2026.13.1")]      // mes inválido
	[InlineData("NEON.2026.00.1")]      // mes inválido
	[InlineData("NEON.2026.10.-1")]     // versión negativa
	[InlineData("NEON.2026.10.+5")]     // signo no permitido
	[InlineData("NEON.2026.10. 5")]     // espacios no permitidos
	public void PrefixedVersionMustRejectInvalidText(string input)
	{
		Assert.Throws<FormatException>(() => PrefixedVersion.Parse(input, _clock));
		Assert.False(PrefixedVersion.TryParse(input, _clock, out PrefixedVersion? result));
		Assert.Null(result);
	}

	[Theory]
	[InlineData(null)]
	[InlineData("  ")]
	public void PrefixedVersionMustRejectNullOrWhitespaceOnParse(string? input)
	{
		Assert.ThrowsAny<ArgumentException>(() => PrefixedVersion.Parse(input!, _clock));
		Assert.False(PrefixedVersion.TryParse(input, _clock, out _));
	}

	[Fact]
	public void PrefixedVersionMustRoundTripThroughToStringAndParse()
	{
		PrefixedVersion original = PrefixedVersion.Parse("NEON.2026.03.7", _clock);

		PrefixedVersion roundTrip = PrefixedVersion.Parse(original.ToString(), _clock);

		Assert.Equal(original, roundTrip);
	}

	#endregion

	#region Create

	[Fact]
	public void PrefixedVersionMustCreateByDefaultCorrectly()
	{
		PrefixedVersion prefixedVersion = PrefixedVersion.Create(_clock);

		Assert.Equal("VER.2026.10.0", prefixedVersion.ToString());
	}

	[Fact]
	public void PrefixedVersionMustNormalizePrefixOnCreate()
	{
		PrefixedVersion prefixedVersion = PrefixedVersion.Create(_clock, "  neon ");

		Assert.Equal("NEON.2026.10.0", prefixedVersion.ToString());
	}

	[Theory]
	[InlineData("")]
	[InlineData("   ")]
	[InlineData("A.B")]
	public void PrefixedVersionMustRejectInvalidPrefixOnCreate(string prefix)
	{
		Assert.Throws<ArgumentException>(() => PrefixedVersion.Create(_clock, prefix));
	}

	#endregion

	#region Increment / Decrement

	[Fact]
	public void PrefixedVersionMustIncrementWithinTheSameMonth()
	{
		PrefixedVersion prefixedVersion = PrefixedVersion.Create(_clock);

		PrefixedVersion incremented = prefixedVersion.IncrementVersion(_clock);

		Assert.Equal("VER.2026.10.1", incremented.ToString());
		Assert.Equal("VER.2026.10.0", prefixedVersion.ToString()); // inmutable
	}

	[Fact]
	public void OldPrefixedVersionMustIncrementToCurrentMonthAndResetSequence()
	{
		PrefixedVersion old = PrefixedVersion.Parse("NEON.2025.08.56", _clock);

		PrefixedVersion incremented = old.IncrementVersion(_clock);

		Assert.Equal("NEON.2026.10.0", incremented.ToString());
	}

	[Fact]
	public void PrefixedVersionMustResetSequenceOnYearRollover()
	{
		PrefixedVersion december = PrefixedVersion.Parse("NEON.2026.12.7", new FakeTimeProvider(new DateTimeOffset(2026, 12, 20, 0, 0, 0, TimeSpan.Zero)));
		_clock.SetUtcNow(new DateTimeOffset(2027, 1, 5, 0, 0, 0, TimeSpan.Zero));

		PrefixedVersion incremented = december.IncrementVersion(_clock);

		Assert.Equal("NEON.2027.01.0", incremented.ToString());
	}

	[Fact]
	public void PrefixedVersionMustNeverMoveBackwardsWhenClockIsBehind()
	{
		PrefixedVersion ahead = PrefixedVersion.Parse("NEON.2026.12.3", new FakeTimeProvider(new DateTimeOffset(2026, 12, 1, 0, 0, 0, TimeSpan.Zero)));

		PrefixedVersion incremented = ahead.IncrementVersion(_clock); // el reloj marca octubre

		Assert.Equal("NEON.2026.12.4", incremented.ToString());
	}

	[Fact]
	public void PrefixedVersionMustDecrementCorrectly()
	{
		PrefixedVersion prefixedVersion = PrefixedVersion.Parse("NEON.2026.10.5", _clock);

		Assert.Equal("NEON.2026.10.4", prefixedVersion.DecrementVersion().ToString());
	}

	[Fact]
	public void PrefixedVersionMustNotDecrementBelowZeroNorChangeMonth()
	{
		PrefixedVersion prefixedVersion = PrefixedVersion.Parse("NEON.2026.10.0", _clock);

		Assert.Equal("NEON.2026.10.0", prefixedVersion.DecrementVersion().ToString());
	}

	#endregion

	#region Equality / Comparison

	[Theory]
	[InlineData("NEON.2020.01.1000", "NEON.2020.01.1000")]
	[InlineData("ACME.2021.12.9999", "ACME.2021.12.9999")]
	[InlineData("ZERO.2026.10.16", "zero.2026.10.16")]
	public void PrefixedVersionMustBeEqualWhenCreatedWithSameValues(string input1, string input2)
	{
		PrefixedVersion prefixedVersion1 = PrefixedVersion.Parse(input1, _clock);
		PrefixedVersion prefixedVersion2 = PrefixedVersion.Parse(input2, _clock);

		Assert.Equal(prefixedVersion1, prefixedVersion2);
		Assert.True(prefixedVersion1 == prefixedVersion2);
		Assert.Equal(prefixedVersion1.GetHashCode(), prefixedVersion2.GetHashCode());
	}

	[Theory]
	[InlineData("NEON.2020.01.1000", "NEON.2020.01.1001")]
	[InlineData("ACME.2021.12.9999", "ACME.2021.12.10000")]
	[InlineData("ZERO.2025.12.99", "ZERO.2026.01.0")]
	[InlineData("BASE.2018.03.5", "BASE.2018.04.0")]
	public void PrefixedVersionAMustBeLessThanPrefixedVersionB(string input1, string input2)
	{
		PrefixedVersion prefixedVersion1 = PrefixedVersion.Parse(input1, _clock);
		PrefixedVersion prefixedVersion2 = PrefixedVersion.Parse(input2, _clock);

		Assert.True(prefixedVersion1 < prefixedVersion2);
		Assert.True(prefixedVersion2 > prefixedVersion1);
	}

	[Fact]
	public void PrefixedVersionsWithDifferentPrefixesMustNotBeComparable()
	{
		PrefixedVersion neon = PrefixedVersion.Parse("NEON.2026.10.1", _clock);
		PrefixedVersion acme = PrefixedVersion.Parse("ACME.2026.10.1", _clock);

		Assert.Throws<InvalidOperationException>(() => neon.CompareTo(acme));
	}

	#endregion
}

#Usage

Register the clock once:

CSHARP
builder.Services.AddSingleton(TimeProvider.System);

And the service that generates versions receives it:

CSHARP
public sealed class DocumentVersionService(TimeProvider timeProvider, IDocumentRepository repository)
{
    public async Task<PrefixedVersion> PublishAsync(Guid documentId, CancellationToken ct)
    {
        PrefixedVersion? last = await repository.GetLastVersionAsync(documentId, ct);

        PrefixedVersion next = last is null
            ? PrefixedVersion.Create(timeProvider, "NEON")   // NEON.2026.10.0
            : last.IncrementVersion(timeProvider);           // NEON.2026.10.1

        await repository.SaveAsync(documentId, next, ct);
        return next;
    }
}

Comparing and parsing:

CSHARP
PrefixedVersion stored = PrefixedVersion.Parse("NEON.2026.09.14", timeProvider);
bool isNewer = next > stored;                               // true

if (!PrefixedVersion.TryParse(request.Version, timeProvider, out PrefixedVersion? parsed))
    return Results.BadRequest("Invalid version.");

// Different prefixes: not comparable
PrefixedVersion other = PrefixedVersion.Parse("ACME.2026.10.1", timeProvider);
_ = next > other;                                           // InvalidOperationException

Persisting it is simple: store it as a string (ToString()) and rebuild it with Parse. In EF Core, map it with a value converter.


#When to use it (and when not)

Yes, when the identifier has a format, an order and business rules, and is used in several places.

No, if it is data that is only stored and displayed with no logic: a string validated at a single edge is enough. A Value Object pays off when it concentrates rules; with no rules it is ceremony.

#value-object #ddd #csharp #domain-modeling #testing