Migrate from another library
A migration can go one type at a time: a value object from this library sits next to a primitive or another library's type without either noticing. The wire format does not change either — every library on this page writes a single-value object as its bare underlying value — so API clients see nothing.
After converting a type, point the contract kit at it with the values your old tests used: it checks that the new type accepts and rejects what the old one did, and round-trips through text and JSON the same way.
From bare primitives
This is the common case, and the one the library exists for.
- Declare the type, moving the rules scattered through validators and controllers onto it: lengths and
patterns on the attribute, the rest in
ValidateValue, any trimming or upper-casing inNormalizeValue. - Change the entity and the contracts, property by property. JSON bodies, route segments and query strings keep the same shape.
- Map it in EF Core with
ConfigureValueObjects. Then read the next migration carefully: aMaxLengthon the type now sizes the column, so a column that was unbounded becomes bounded. That is usually the point, but it is a schema change, and existing rows must fit. - Delete what the type now guarantees: the format checks in validators, the
Trim()calls, the[MaxLength]and[RegularExpression]attributes, the OpenAPI annotations. For payloads that still carry raw text,MustParseAsreplaces them without restating the rules.
From hand-written value objects
Keep the rules, delete the plumbing.
- Move the checks into
NormalizeValueandValidateValue, and turn each thrown exception into a returnedValidationResultwith a code of your own. - Delete the constructor,
Equals,GetHashCode, the operators,ToString,Parse, and every hand-writtenJsonConverter,TypeConverter, EF Core converter and model binder: the generator writes all of them. - A
record structor a class becomes areadonly partial struct. Where a class could benull, useT?.
From Vogen
| Vogen | AdCodicem.ValueObjects |
|---|---|
[ValueObject<string>] partial class or struct | [ValueObject<string>] readonly partial struct |
private static string NormalizeInput(string input) | public static string NormalizeValue(string value), with IValueObjectNormalizer<string> |
private static Validation Validate(string input) | public static ValidationResult ValidateValue(in string value), with IValueObjectValidator<string> |
Validation.Ok / Validation.Invalid("…") | ValidationResult.Success / ValidationResult.Failure("code", "…") |
Iban.From(raw) | Iban.Create(raw) |
Iban.TryFrom(raw, out var iban) | Iban.TryCreate(raw, out var iban) |
ValueObjectOrError<Iban> result = Iban.TryFrom(raw) | Iban.TryCreate(raw, out var iban, out var validation) |
ValueObjectValidationException | ValueObjectException, which also carries ErrorCode |
| Explicit casts, both ways by default | Opt in with ExplicitConversionFromValue and ImplicitConversionToValue |
Conversions.EfCoreValueConverter, HasVogenConversion() | ConfigureValueObjects(assembly), once |
Conversions.DapperTypeHandler | ValueObjectDapper.AddValueObjectHandlers(assembly), once |
Conversions.NewtonsoftJson | ValueObjectConverter in the serializer settings |
new VogenTypesFactory() in the options of a source-generated context | [JsonSourceGenerationOptions(Converters = [typeof(ValueObjectJsonConverterFactory)])] |
A length or pattern check inside Validate | MinLength, MaxLength, Pattern on the attribute |
Side by side, a normalized and validated IBAN:
// Vogen
[ValueObject<string>(conversions: Conversions.Default | Conversions.EfCoreValueConverter)]
public partial class Iban
{
private static string NormalizeInput(string input) => input.Replace(" ", "").ToUpperInvariant();
private static Validation Validate(string input)
=> input.Length is >= 15 and <= 34
? Validation.Ok
: Validation.Invalid("An IBAN has between 15 and 34 characters.");
}
// AdCodicem.ValueObjects
[ValueObject<string>(MinLength = 15, MaxLength = 34)]
public readonly partial struct Iban : IValueObjectNormalizer<string>
{
public static string NormalizeValue(string value) => value.Replace(" ", "").ToUpperInvariant();
}
The length check needs no code any more, and it now sizes the column and documents the schema too.
Three differences to plan for:
- EF Core reads. Vogen validates values read from the database by default; this library does not. If other
systems write to your tables, keep that behaviour with
ConfigureValueObjects(strict: true, …). - Instances. A Vogen instance may deliberately hold a value that
Validatewould refuse, as a sentinel. There is no such escape hatch here: express absence asIban?, and name valid values with[KnownValue]. - Underlying types. Vogen wraps any type; this library supports 22. A value object over a
Urior a type of your own has no direct equivalent.
From StronglyTypedId
| StronglyTypedId | AdCodicem.ValueObjects |
|---|---|
[StronglyTypedId] partial struct OrderId (a Guid) | [ValueObject<Guid>] readonly partial struct OrderId |
[StronglyTypedId(Template.String)] | [ValueObject<string>] |
new OrderId(guid) | OrderId.Create(guid): the constructor is private, so the rules cannot be skipped |
OrderId.New() | A New() of your own, below — or an [EntityId] for an identifier clients see |
OrderId.Empty | OrderId? for absence; default(OrderId) is now a build error |
new OrderId.EfCoreValueConverter(), one per type | ConfigureValueObjects(assembly), once |
SqlMapper.AddTypeHandler(new OrderId.DapperTypeHandler()), one per type | ValueObjectDapper.AddValueObjectHandlers(assembly), once |
| Converters passed to a source-generated context's options | [JsonSourceGenerationOptions(Converters = [typeof(ValueObjectJsonConverterFactory)])] |
[ValueObject<Guid>]
public readonly partial struct OrderId : IValueObjectValidator<Guid>
{
public static OrderId New() => CreateUnchecked(Guid.CreateVersion7());
public static ValidationResult ValidateValue(in Guid value)
=> value == Guid.Empty
? ValidationResult.Required("An order identifier must not be empty.")
: ValidationResult.Success;
}
The validator is new: StronglyTypedId has no validation, so an empty Guid was a valid identifier. Once the
types are converted, remove the StronglyTypedId and StronglyTypedId.Templates packages and any .typedid
templates.
From Thinktecture.Runtime.Extensions
- The validation hook splits in two.
ValidateFactoryArguments(ref ValidationError? validationError, ref string value)both normalizes, by assigningvalue, and validates. Move the assignment intoNormalizeValueand the checks intoValidateValue, returning aValidationResultinstead of setting an error. - String comparison changes. Thinktecture compares string keys case-insensitively by default; this library
compares ordinally. To keep the behaviour, declare
Comparison = StringComparison.OrdinalIgnoreCase— or, better, normalize the case so the stored value is canonical. - A class becomes a
readonly partial struct.nullchecks becomeT?. - EF Core.
UseThinktectureValueConverters()becomesConfigureValueObjects(assembly)inConfigureConventions. Both skip validation on read by default. - Smart enums and unions stay where they are. Only single-value value objects have an equivalent here; a closed set of codes can become a value object with known values.