Table of Contents

Trellis Value Object Taxonomy

Packages: Trellis.Core (DDD primitives Aggregate<T>, Entity<T>, ValueObject, Specification<T>, IDomainEvent, plus VO base classes RequiredString<TSelf>, RequiredGuid<TSelf>, RequiredInt<TSelf>, RequiredLong<TSelf>, RequiredDecimal<TSelf>, RequiredBool<TSelf>, RequiredDateTime<TSelf>, RequiredDateTimeOffset<TSelf>, RequiredEnum<TSelf>); Trellis.Primitives (concrete VOs only — EmailAddress, Money, etc.) | Namespaces: Trellis, Trellis.Primitives | Purpose: canonical category map for Trellis value-like types: scalar, symbolic, structured, and optionality wrappers.

Patterns Index

Use this table to pick the right base class before reading the per-type signatures below.

Goal Canonical base / type See
Wrap a single primitive (string, Guid, int, long, decimal, bool, date/time, enum) into a typed value object RequiredString<TSelf>, RequiredGuid<TSelf>, RequiredInt<TSelf>, RequiredLong<TSelf>, RequiredDecimal<TSelf>, RequiredBool<TSelf>, RequiredDateTime<TSelf>, RequiredDateTimeOffset<TSelf>, RequiredEnum<TSelf> (one base per primitive family) RequiredString<TSelf>, RequiredGuid<TSelf>, RequiredInt<TSelf>, RequiredDecimal<TSelf>, RequiredEnum<TSelf>
Define a custom-validated scalar with no source-generated infrastructure ScalarValueObject<TSelf, T> + IScalarValue<TSelf, T> ScalarValueObject<TSelf, T>
Compose multiple value-typed fields into a structural value object ValueObject (override GetEqualityComponents) ValueObject
Wrap an entity with identity (mutable through methods) Entity<TId> See trellis-api-core.md → Domain-Driven Design
Wrap a consistency boundary with domain events Aggregate<TId> See trellis-api-core.md → Domain-Driven Design
Express expected absence of a value Maybe<T> See trellis-api-core.md → Maybe
Move a query predicate out of a repository Specification<T> See trellis-api-core.md → Domain-Driven Design
Pick a built-in concrete value object instead of writing your own EmailAddress, Money, CountryCode, Url, etc. See trellis-api-primitives.md

Required base defaults

Required*<TSelf> bases are lenient by default: rejects only null. Use [NotDefault] to opt into sentinel rejection and [Trim] to opt into string trimming.

Base Default rejects Opt-in to reject sentinel
RequiredString<TSelf> null only (accepts "", whitespace; no auto-trim) [NotDefault] rejects ""; [Trim] enables trimming; combine for strict trim-then-reject-empty
RequiredGuid<TSelf> null only (accepts Guid.Empty) [NotDefault] rejects Guid.Empty
RequiredInt<TSelf> / RequiredLong<TSelf> / RequiredDecimal<TSelf> null only (accepts 0) [NotDefault] rejects the zero sentinel
RequiredDateTime<TSelf> / RequiredDateTimeOffset<TSelf> null only (accepts MinValue) [NotDefault] rejects MinValue
RequiredBool<TSelf> null none (false is valid)
RequiredEnum<TSelf> null, undeclared names none (smart-enum lookup)

Types

This section is a selection guide — one or two distinguishing facts per type, not a signature reference. For complete signatures (members, overloads, operators, generated factories) see the per-type entries in trellis-api-core.md for the base classes and interfaces below, and trellis-api-primitives.md for the concrete built-in primitives. Default rejection behavior for every Required*<TSelf> base is the authoritative Required base defaults table above and is not restated per type here.

ValueObject

Base for structured value objects: override GetEqualityComponents(ref EqualityComponents) to define identity from multiple fields. Provides structural equality, hashing, and ordering (IComparable); added components must be IComparable? — components.Add(maybe) (or MaybeComponent<T>(...)) adds an optional field.

ScalarValueObject<TSelf, T>

Base for a single-primitive value object with custom validation and no source generation. Exposes Value : T, implicit unwrap to T, and a throwing Create(T). Prefer a Required*<TSelf> base unless you need a primitive family they don't cover.

IScalarValue<TSelf, TPrimitive>

Static-abstract contract every scalar value object implements (TryCreate primitive/string paths + Value). The source generator emits it for partial Required* types; you rarely implement it by hand.

IFormattableScalarValue<TSelf, TPrimitive>

IScalarValue plus a culture-aware TryCreate(string?, IFormatProvider?, ...) — used by the numeric and date/time scalar families.

RequiredString<TSelf>

Single-string value object. Adds EF-Core-translatable query helpers (StartsWith/Contains/EndsWith, Length). Apply [Trim, NotDefault, StringLength(n)] for the strict, database-mapped default.

RequiredGuid<TSelf>

Single-Guid identity value object. The generator adds NewUniqueV7() (time-ordered) and NewUniqueV4() — the idiomatic way to mint aggregate and entity IDs.

RequiredInt<TSelf>

Single-int value object. Bound the domain with [Range(min, max)]; add [NotDefault] only when 0 is itself invalid (otherwise 0 is a legal value).

RequiredDecimal<TSelf>

Single-decimal value object for ratios, rates, and quantities. For money use the dedicated MonetaryAmount (single-currency) or Money (multi-currency) types.

RequiredLong<TSelf>

Single-long value object; same shape as RequiredInt<TSelf> for 64-bit ranges.

RequiredBool<TSelf>

Single-bool value object. Both true and false are valid (no sentinel), so [NotDefault] does not apply.

RequiredDateTime<TSelf>

Single-DateTime value object; ToString() is an invariant ISO-8601 round-trip.

RequiredDateTimeOffset<TSelf>

Single-DateTimeOffset value object; prefer over RequiredDateTime<TSelf> when an offset matters (e.g. IDomainEvent.OccurredAt is a DateTimeOffset).

RequiredEnum<TSelf>

Symbolic smart-enum that replaces a C# enum; members are public static readonly singletons. Identity is the string Value (equality is case-insensitive); ordering (IComparable) is by Ordinal = declaration order, like the underlying enum it replaces. String-backed in JSON and EF Core. Use GetAll() / TryCreate(...) for lookup and [EnumValue("...")] to override a member's external name. Stands apart from ScalarValueObject<TSelf, T> architecturally, but — like the scalar primitives — provides an implicit unwrap to its string Value.

MonetaryAmount

Concrete single-currency amount (Value : decimal, no currency component). Arithmetic returns Result<>; TryCreate rejects negatives.

Percentage

Concrete percentage (Value : decimal) with FromFraction / AsFraction / Of(amount) helpers and % parsing.

Money

Concrete structured value object (Amount + Currency) for multi-currency; arithmetic enforces a currency match and supports Allocate(...).

GeoCoordinate

Concrete structured value object (Latitude + Longitude) for finite geographic coordinates. Component equality is exact; DistanceMetersTo is an approximate spherical great-circle calculation in memory, not a SQL spatial operation.

GeoBoundingBox and GeoBounds

Concrete structured query values for spherical nearby-search composition. GeoBoundingBox is one non-wrapping inclusive rectangle; GeoBounds combines a validated Center + RadiusMeters identity with one or two derived boxes. They are not scalar wire primitives and do not replace exact distance filtering.

WeeklyPeriod and WeeklySchedule

Concrete structured values for weekly local-clock availability. WeeklyPeriod holds a day, start/end times, and an explicit all-day marker. WeeklySchedule holds an IANA zone and an immutable sorted set of non-overlapping periods. Empty means always closed. Use DTOs for JSON and persistence, not the scalar or composite JSON converters.

Base class hierarchy

  • Scalar value objects
    • ValueObject -> ScalarValueObject<TSelf, T> -> concrete scalars
    • Required scalar bases:
      • RequiredString<TSelf>
      • RequiredGuid<TSelf>
      • RequiredInt<TSelf>
      • RequiredDecimal<TSelf>
      • RequiredLong<TSelf>
      • RequiredBool<TSelf>
      • RequiredDateTime<TSelf>
      • RequiredDateTimeOffset<TSelf>
    • Built-in scalar concretes:
      • Age
      • CountryCode
      • CurrencyCode
      • EmailAddress
      • Hostname
      • IpAddress
      • LanguageCode
      • MonetaryAmount
      • Percentage
      • PhoneNumber
      • Slug
      • Url
  • Symbolic value objects
    • RequiredEnum<TSelf> is separate from ScalarValueObject<TSelf, T> but still uses Value as its canonical public identity and implicitly unwraps to it.
  • Structured value objects
    • Money, GeoCoordinate, GeoBoundingBox, GeoBounds, WeeklyPeriod, WeeklySchedule -> ValueObject
  • Optionality wrappers
    • Maybe<T> belongs to Trellis.Core; it wraps presence/absence and is not a value object category peer to scalar/symbolic/structured types.

Source-generated members

For a partial Required*<TSelf> type the primitive generator emits the IScalarValue<TSelf, T> implementation, the TryCreate primitive and string factories (plus a culture-aware IFormatProvider overload for the numeric and date/time families), Create, Parse / TryParse, an explicit cast operator, and a ValidateAdditional(...) extension hook. RequiredGuid<TSelf> additionally gets NewUniqueV4() / NewUniqueV7(); RequiredEnum<TSelf> creation routes through TryCreate. See the per-type generated-member entries in trellis-api-core.md for the exact emitted signatures.

Built-in primitives table

Type Category Canonical identity Wire/storage shape Notes
Age Scalar Value : int JSON number Numeric scalar.
CountryCode Scalar Value : string JSON string ISO alpha-2.
CurrencyCode Scalar Value : string JSON string ISO alpha-3.
EmailAddress Scalar Value : string JSON string Domain string scalar.
Hostname Scalar Value : string JSON string Domain string scalar.
IpAddress Scalar Value : string JSON string Domain string scalar.
LanguageCode Scalar Value : string JSON string ISO alpha-2.
MonetaryAmount Scalar Value : decimal JSON number Single-currency amount; no currency component.
Percentage Scalar Value : decimal JSON string output, e.g. "50%"; number or string input Supports fraction helpers and % parsing.
PhoneNumber Scalar Value : string JSON string Normalized E.164.
Slug Scalar Value : string JSON string URL-safe slug.
Url Scalar Value : string JSON string Absolute HTTP/HTTPS URL.
RequiredEnum<TSelf> derivatives Symbolic Value : string JSON string Finite symbolic set with behavior.
Money Structured Amount + Currency JSON object Use for multi-currency scenarios.
GeoCoordinate Structured Latitude + Longitude JSON object Validated decimal degrees; approximate distance in meters.
GeoBoundingBox Structured Four inclusive endpoints Application query value One immutable, non-wrapping conservative rectangle.
GeoBounds Structured Center + RadiusMeters Application query value Validated spherical search identity with one or two derived boxes.
WeeklyPeriod Structured Day + Start + End + IsAllDay Application DTO Half-open local-clock interval; explicit all-day factory.
WeeklySchedule Structured TimeZoneId + sorted Periods Application DTO IANA-zone weekly membership, including overnight and DST cases.

Money vs MonetaryAmount

  • Use MonetaryAmount when the bounded context has a single external currency policy and the semantic identity is only the numeric amount.
  • Use Money when currency is part of the semantic identity and must travel with the value.
  • MonetaryAmount is a scalar value object.
  • Money is a structured value object.

Code examples

using System.Globalization;
using Trellis;
using Trellis.Primitives;

namespace Demo;

public partial class OrderId : RequiredGuid<OrderId> { }

public partial class OrderStatus : RequiredEnum<OrderStatus>
{
    public static readonly OrderStatus Draft = new();

    [EnumValue("awaiting-payment")]
    public static readonly OrderStatus AwaitingPayment = new();
}

public static class Example
{
    public static Result<Money> Run()
    {
        // Scalar
        var orderId = OrderId.NewUniqueV4();
        var amountResult = MonetaryAmount.TryCreate("12.34", CultureInfo.InvariantCulture);
        if (!amountResult.TryGetValue(out var amount, out var amountError))
            return Result.Fail<Money>(amountError);

        // Symbolic
        var statusResult = OrderStatus.TryCreate("awaiting-payment");
        if (!statusResult.TryGetValue(out var status, out var statusError))
            return Result.Fail<Money>(statusError);
        var isOpen = status.Is(OrderStatus.Draft, OrderStatus.AwaitingPayment);

        // Structured
        var subtotal = Money.Create(12.34m, "USD");
        var shipping = Money.Create(2.00m, "USD");
        _ = (orderId, amount, isOpen);
        return subtotal.Add(shipping);
    }
}

Cross-references