PrefixedVersion: un Value Object para versiones con fecha
Qué es PrefixedVersion, qué problema resuelve (identificar y ordenar versiones tipo PREFIJO.AAAA.MM.N), cómo se implementa como Value Object en C# con el tiempo inyectado y cómo se prueba.
Hay datos que parecen un string pero tienen reglas: un formato, rangos válidos, un orden y operaciones propias. Una versión como NEON.2026.03.2 es uno de ellos. Si la dejás como string, cualquier cosa entra, nadie sabe cómo se ordena y la lógica de "pasar a la próxima versión" termina copiada en cinco lugares.
PrefixedVersion es un Value Object que encapsula ese dato. Este artículo explica para qué sirve, qué función cumple cada parte y cómo se prueba.
#Para qué sirve
Representa un identificador de versión con fecha y secuencia, con este formato:
PREFIJO.AAAA.MM.N
NEON .2026.03.2
| Parte | Significado |
|---|---|
Prefix |
Quién o qué versiona (un cliente, un documento, un módulo). Siempre en mayúsculas y sin puntos |
Year / Month |
Período en que se generó la versión (UTC) |
Version |
Contador dentro de ese mes. Arranca en 0 |
Es una variante de versionado por calendario (CalVer): la versión te dice cuándo nació y la secuencia te dice cuántas hubo ese mes.
Dónde encaja: cualquier cosa que se regenere con el tiempo y necesite un identificador legible y ordenable: versiones de un documento, de un esquema de integración, de una lista de precios, de una plantilla o de un lote de datos publicado.
#Qué función cumple (y por qué un Value Object)
Un Value Object se define por sus valores, no por una identidad. Dos PrefixedVersion con el mismo prefijo, año, mes y número son la misma versión. Por eso esta clase tiene las características típicas:
- Inmutable: las propiedades son solo
gety el constructor es privado.IncrementVersion()no modifica la instancia: devuelve una nueva. - Siempre válida: no hay forma de construir una versión con mes 13 o año 1850. Las reglas se validan en
CreateyParse; si tenés una instancia, es válida. - Igualdad por valor: implementa
IEquatable<T>y sobrecarga==,!=yGetHashCode. - Ordenable: implementa
IComparable<T>y los operadores<,>,<=y>=. - Con comportamiento propio:
IncrementVersion()yDecrementVersion()viven en el tipo, no regadas por los servicios.
Es el problema que ataca la obsesión por primitivos (primitive obsession): usar
stringpara algo que tiene reglas.
#Cómo funciona cada pieza
#El tiempo se inyecta: TimeProvider
Todo lo que depende de la fecha actual (Create, Parse, TryParse e IncrementVersion) recibe un TimeProvider por parámetro, en lugar de llamar a DateTimeOffset.UtcNow. Esto trae dos ventajas:
- Tests deterministas: con
FakeTimeProviderfijás la fecha y probás el cambio de mes o de año sin esperar ni depender del reloj real. - Dependencia explícita: la firma deja claro que la operación depende del tiempo.
El TimeProvider no se guarda dentro del Value Object: un valor se define por sus datos, no por los servicios que usó para crearse. Lo recibe quien lo llama (un servicio de aplicación) y lo pasa cuando hace falta.
#Creación: Create, Parse y TryParse
Create(timeProvider, prefix)arma la versión actual: año y mes de hoy (UTC) conVersion = 0.Parse(texto, timeProvider)reconstruye una versión desde un texto y valida cada parte: cuatro segmentos, prefijo no vacío, año entre 2000 y el año actual, mes entre 1 y 12. Si el texto es inválido lanzaFormatException, comoint.Parse.TryParse(texto, timeProvider, out resultado)hace lo mismo sin excepciones: pensado para entradas externas, donde un texto inválido es esperable.- Los números se parsean de forma estricta (solo dígitos, cultura invariante):
" +5"o"-1"se rechazan, así lo que entra siempre hace ida y vuelta conToString(). - El prefijo se normaliza (
Trim+ mayúsculas):neonyNEONson la misma versión. No puede contener., porque rompería el formato.
#Operaciones de dominio
IncrementVersion(timeProvider)suma 1 si seguimos en el mismo mes. Si el mes (o el año) ya pasó, reinicia la secuencia en el período actual:NEON.2026.03.9pasa aNEON.2026.04.0. Nunca retrocede: si la versión es de un período posterior al reloj (por ejemplo, por un desfase entre servidores), sigue en su período y suma 1.DecrementVersion()resta 1 con piso en0. No cruza de mes a propósito: el tipo no conoce cuál fue la última versión del mes anterior, así que no puede inventarla. Si en el futuro hace falta comparar contra la versión inmediata anterior, ese dato tiene que venir del repositorio, no del Value Object.
#Comparación
El orden es año → mes → versión. Comparar versiones de prefijos distintos lanza excepción, porque no tienen un orden significativo entre sí (ordenar NEON contra ACME no quiere decir nada).
#Conversión a string
Hay una conversión implícita hacia string (nunca falla). No existe la inversa: parsear necesita un TimeProvider y puede fallar, así que se hace de forma explícita con Parse o TryParse.
#El código
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
}