Table of Contents

Create custom scalar value objects

Use a custom scalar value object when a single CLR value has domain meaning that a raw primitive cannot express. OrderId, ProductName, and Quantity may still store a Guid, string, and int, but they should not be interchangeable.

Install Trellis.Core directly, or use the transitive reference supplied by Trellis.Primitives:

dotnet add package Trellis.Core

Pick the matching base

Underlying value Generated base
string RequiredString
Guid RequiredGuid
int RequiredInt
long RequiredLong
decimal RequiredDecimal
bool RequiredBool
DateTime RequiredDateTime
DateTimeOffset RequiredDateTimeOffset
A finite symbolic set RequiredEnum and the symbolic values guide

Declare a partial class and place Trellis attributes on the class itself:

using Trellis;

[NotDefault]
public sealed partial class OrderId : RequiredGuid<OrderId>;

[Trim, NotDefault, StringLength(120, MinimumLength = 2)]
public sealed partial class ProductName : RequiredString<ProductName>;

[Range(1, 1_000)]
public sealed partial class Quantity : RequiredInt<Quantity>;

public sealed record AddOrderLine(OrderId OrderId, ProductName Product, Quantity Quantity)
{
    public static Result<AddOrderLine> TryCreate(string? product, int? quantity) =>
        Result.Combine(
                ProductName.TryCreate(product, nameof(product)),
                Quantity.TryCreate(quantity, nameof(quantity)))
            .Map((validProduct, validQuantity) =>
                new AddOrderLine(OrderId.NewUniqueV7(), validProduct, validQuantity));
}

The generator supplies the factories, scalar Value, parsing, equality, formatting, and JSON converter. Do not redeclare those members in the partial class.

Make the default as strict as your domain

The word "Required" means the input cannot be null; it does not reject every CLR sentinel automatically.

Base Accepted unless you opt out
RequiredString<TSelf> "" and whitespace, without trimming
RequiredGuid<TSelf> Guid.Empty
Numeric bases 0
Date/time bases MinValue
RequiredBool<TSelf> Both false and true are valid

Use TrimAttribute to normalize a string and NotDefaultAttribute to reject its sentinel. Combining [Trim, NotDefault] rejects whitespace after trimming. Add StringLengthAttribute or RangeAttribute for bounded values.

These are Trellis attributes, not similarly named System.ComponentModel.DataAnnotations attributes.

Add a domain-specific rule

Attributes cover common constraints. Implement the generated ValidateAdditional hook when the value has its own rule:

using Trellis;

[Trim, NotDefault, StringLength(12, MinimumLength = 8)]
public sealed partial class Sku : RequiredString<Sku>
{
    static partial void ValidateAdditional(
        string value,
        string fieldName,
        ref string? errorMessage,
        ref string? errorCode)
    {
        if (value.StartsWith("SKU-", StringComparison.Ordinal))
            return;

        errorMessage = $"{fieldName} must start with SKU-.";
        errorCode = "catalog.sku.prefix";
    }
}

Set both the message and a stable application-owned code. The code lets clients react without parsing prose.

Choose the right factory

Factory Use it when
TryCreate(value, fieldName) Input may be invalid. Keep the returned Result<T> in the pipeline.
Create(value) The value is a trusted constant or test fixture. Invalid input throws.
Parse / TryParse A .NET API requires IParsable<T>.
NewUniqueV7() You are creating a new RequiredGuid<TSelf> identity.

Pass the transport field name to TryCreate. ASP.NET Core can then return a validation pointer that matches the request instead of the type's default name.

Serialization and persistence

Generated scalar value objects carry ParsableJsonConverter, so their JSON shape is the underlying scalar rather than an object containing Value.

For request binding and validation responses, continue with ASP.NET Core integration. For EF Core mapping and runtime query rewriting, wire the conventions and interceptors. The single-argument StartsWith, Contains, and EndsWith helpers plus Length on RequiredString<TSelf> are translated after AddTrellisInterceptors() is registered; use the generated RequiredString API for their complete signatures and in-memory comparison semantics.

For the complete generated and inherited member lists, use the .NET API pages linked in the base-class table.