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:

CSHARP
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 get y 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 Create y Parse; si tenés una instancia, es válida.
  • Igualdad por valor: implementa IEquatable<T> y sobrecarga ==, != y GetHashCode.
  • Ordenable: implementa IComparable<T> y los operadores <, >, <= y >=.
  • Con comportamiento propio: IncrementVersion() y DecrementVersion() viven en el tipo, no regadas por los servicios.

Es el problema que ataca la obsesión por primitivos (primitive obsession): usar string para 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 FakeTimeProvider fijá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) con Version = 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 lanza FormatException, como int.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 con ToString().
  • El prefijo se normaliza (Trim + mayúsculas): neon y NEON son 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.9 pasa a NEON.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 en 0. 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

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
}

#Los tests

Necesitan el paquete Microsoft.Extensions.TimeProvider.Testing, que aporta FakeTimeProvider. Cubren los comportamientos que definen al tipo: parseo estricto, creación, incremento (mismo mes, cambio de mes, cambio de año y reloj atrasado), decremento, igualdad por valor y orden.

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
}

#Cómo se usa

Se registra el reloj una vez:

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

Y lo recibe el servicio que genera las versiones:

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;
    }
}

Comparar y parsear:

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.");

// Prefijos distintos: no son comparables
PrefixedVersion other = PrefixedVersion.Parse("ACME.2026.10.1", timeProvider);
_ = next > other;                                           // InvalidOperationException

Persistirlo es simple: se guarda como string (ToString()) y se reconstruye con Parse. En EF Core se mapea con un value converter.


#Cuándo usarlo (y cuándo no)

Sí, cuando el identificador tiene formato, orden y reglas de negocio, y se usa en varios lugares.

No, si es un dato que solo se guarda y se muestra sin lógica: un string validado en un solo borde alcanza. Un Value Object es útil cuando concentra reglas; si no tiene reglas, es ceremonia.

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