This library provides infrastructure and ready-to-use implementations for primitive value objects with source code generation, eliminating boilerplate code and primitive obsession in domain-driven design applications.
- Installation
- Quick Start
- RequiredString
- RequiredGuid
- RequiredInt and RequiredDecimal
- RequiredEnum
- EmailAddress
- Additional Value Objects
- MonetaryAmount
- Money
- ASP.NET Core Integration
- Core Concepts
- Best Practices
- Related Packages
- License
Install both packages via NuGet:
dotnet add package Trellis.Primitives
dotnet add package Trellis.Primitives.GeneratorImportant: Both packages are required:
Trellis.Primitives- Provides base classes (RequiredString,RequiredGuid,RequiredInt,RequiredDecimal,RequiredEnum), built-in scalar value objects, and the structuredMoneyvalue objectTrellis.Primitives.Generator- Source generator that creates implementations forRequired*base class derivatives
Create strongly-typed string value objects using source code generation:
public partial class TrackingId : RequiredString<TrackingId>
{
}
// The source generator automatically creates:
// - IScalarValue<TrackingId, string> interface implementation
// - TryCreate(string) -> Result<TrackingId> (required by IScalarValue)
// - TryCreate(string?, string? fieldName = null) -> Result<TrackingId>
// - Parse(string, IFormatProvider?) -> TrackingId
// - TryParse(string?, IFormatProvider?, out TrackingId) -> bool
// - explicit operator TrackingId(string)
var result = TrackingId.TryCreate("TRK-12345");
if (result.IsSuccess)
{
var trackingId = result.Value;
Console.WriteLine(trackingId); // Outputs: TRK-12345
}
// With custom field name for validation errors
var result2 = TrackingId.TryCreate(input, "shipment.trackingId");
// Error field will be "shipment.trackingId" instead of default "trackingId"
// Supports IParsable<T>
var parsed = TrackingId.Parse("TRK-12345", null);
// Explicit cast operator (throws on failure)
var trackingId = (TrackingId)"TRK-12345";Optional: Add [StringLength] for length constraints:
[StringLength(50)]
public partial class FirstName : RequiredString<FirstName> { }
[StringLength(500, MinimumLength = 10)]
public partial class Description : RequiredString<Description> { }Optional: Add ValidateAdditional for custom validation (regex, format checks):
[StringLength(10)]
public partial class Sku : RequiredString<Sku>
{
static partial void ValidateAdditional(string value, string fieldName, ref string? errorMessage)
{
if (!Regex.IsMatch(value, @"^SKU-\d{6}$"))
errorMessage = "Sku must match pattern SKU-XXXXXX.";
}
}The hook is completely optional — if not implemented, the compiler removes the call (zero overhead). Available on RequiredString, RequiredInt, and RequiredDecimal.
Create strongly-typed GUID value objects. Use NewUniqueV7() for time-ordered, sortable identifiers — GUID V7 provides the same benefits as ULIDs (sequential, timestamp-embedded) while using the standard System.Guid type.
public partial class EmployeeId : RequiredGuid<EmployeeId>
{
}
// The source generator automatically creates:
// - IScalarValue<EmployeeId, Guid> interface implementation
// - NewUniqueV4() -> EmployeeId
// - NewUniqueV7() -> EmployeeId (time-ordered)
// - TryCreate(Guid) -> Result<EmployeeId> (required by IScalarValue)
// - TryCreate(Guid?, string? fieldName = null) -> Result<EmployeeId>
// - TryCreate(string?, string? fieldName = null) -> Result<EmployeeId>
// - Parse(string, IFormatProvider?) -> EmployeeId
// - TryParse(string?, IFormatProvider?, out EmployeeId) -> bool
// - explicit operator EmployeeId(Guid)
var employeeId = EmployeeId.NewUniqueV7(); // Create new time-ordered GUID
var result = EmployeeId.TryCreate(guid);
var result2 = EmployeeId.TryCreate("550e8400-e29b-41d4-a716-446655440000");
// With custom field name for validation errors
var result3 = EmployeeId.TryCreate(input, "employee.id");
// Supports IParsable<T>
var parsed = EmployeeId.Parse("550e8400-e29b-41d4-a716-446655440000", null);
// Explicit cast operator (throws on failure)
var employeeId = (EmployeeId)Guid.NewGuid();Create strongly-typed numeric value objects:
public partial class Quantity : RequiredInt<Quantity> { }
public partial class Price : RequiredDecimal<Price> { }
// Same features as RequiredGuid/RequiredString:
var qty = Quantity.TryCreate(10);
var price = Price.TryCreate(99.99m);
// Validates non-zero values
var invalid = Quantity.TryCreate(0);
// Returns: Error.Validation("Quantity cannot be empty.", "quantity")Use [Range] to constrain values (like [StringLength] for strings):
[Range(1, 999)]
public partial class LineItemQuantity : RequiredInt<LineItemQuantity> { }
[Range(0, 100)] // allows zero
public partial class StockQuantity : RequiredInt<StockQuantity> { }
var qty = LineItemQuantity.TryCreate(1000);
// Returns: Error.Validation("Line item quantity must be at most 999.", "lineItemQuantity")Create type-safe enumeration value objects that replace C# enums with full-featured classes:
public partial class OrderState : RequiredEnum<OrderState>
{
public static readonly OrderState Draft = new();
public static readonly OrderState Confirmed = new();
public static readonly OrderState Shipped = new();
public static readonly OrderState Delivered = new();
public static readonly OrderState Cancelled = new();
}
// Members are discovered via reflection; Value is the semantic contract
Console.WriteLine(OrderState.Draft.Value); // "Draft"
// Create from string
var result = OrderState.TryCreate("Confirmed");
// result.Value == OrderState.Confirmed
// Helper methods
OrderState.Confirmed.Is(OrderState.Confirmed); // true
OrderState.Confirmed.IsNot(OrderState.Cancelled); // true
// Invalid values are impossible
var invalid = OrderState.TryCreate("Unknown");
// Returns: Error.Validation("Invalid OrderState value: Unknown", "orderState")
// Ordinal exists as declaration-order metadata, not as the primary public identityPre-built email validation value object with RFC 5322 compliant validation:
var result = EmailAddress.TryCreate("user@example.com");
if (result.IsSuccess)
{
var email = result.Value;
Console.WriteLine(email); // Outputs: user@example.com
}
// With custom field name for validation errors
var result2 = EmailAddress.TryCreate(input, "contact.email");
// Validation errors
var invalid = EmailAddress.TryCreate("not-an-email");
// Returns: Error.Validation("Email address is not valid.", "email")
// Supports IParsable<T>
var parsed = EmailAddress.Parse("user@example.com", null);
if (EmailAddress.TryParse("user@example.com", null, out var email))
{
Console.WriteLine($"Valid: {email.Value}");
}var result = Url.TryCreate("https://example.com/path");
// Access URL components
if (result.IsSuccess)
{
var url = result.Value;
Console.WriteLine(url.Scheme); // "https"
Console.WriteLine(url.Host); // "example.com"
Console.WriteLine(url.Path); // "/path"
Console.WriteLine(url.IsSecure); // true
}
// Invalid URLs
var invalid = Url.TryCreate("not-a-url");
// Returns: Error.Validation("URL must be a valid absolute HTTP or HTTPS URL.", "url")var result = PhoneNumber.TryCreate("+14155551234");
if (result.IsSuccess)
{
var phone = result.Value;
Console.WriteLine(phone.GetCountryCode()); // "1"
}
// Normalizes input (removes spaces, dashes, parentheses)
var normalized = PhoneNumber.TryCreate("+1 (415) 555-1234");
// Stores as: "+14155551234"var discount = Percentage.TryCreate(15.5m);
var fromFraction = Percentage.FromFraction(0.155m); // Also 15.5%
if (discount.IsSuccess)
{
var pct = discount.Value;
Console.WriteLine(pct.AsFraction()); // 0.155
Console.WriteLine(pct.Of(100m)); // 15.5
Console.WriteLine(pct.ToString()); // "15.5%"
}
// Parse with % suffix
var parsed = Percentage.Parse("20%", null); // Validvar result = CurrencyCode.TryCreate("USD");
// Stores as uppercase: "USD"
var invalid = CurrencyCode.TryCreate("US");
// Error: Must be 3-letter codevar country = CountryCode.TryCreate("US"); // Uppercase
var language = LanguageCode.TryCreate("en"); // Lowercase
// ISO standard codes only
var invalid = CountryCode.TryCreate("USA"); // Error: Must be 2 lettersvar ipv4 = IpAddress.TryCreate("192.168.1.1");
var ipv6 = IpAddress.TryCreate("::1");
if (ipv4.IsSuccess)
{
var ip = ipv4.Value.ToIPAddress(); // Access System.Net.IPAddress
}var hostname = Hostname.TryCreate("example.com");
// RFC 1123 validation
var slug = Slug.TryCreate("my-blog-post");
// Lowercase letters, digits, and hyphens only
// No leading/trailing hyphens, no consecutive hyphens
var invalid = Slug.TryCreate("My Blog Post!"); // Errorvar age = Age.TryCreate(42);
// Range: 0-150
var tooOld = Age.TryCreate(200);
// Error: "Age is unrealistically high."MonetaryAmount is a scalar value object for single-currency systems where currency is a system-wide policy, not per-row data. It wraps a non-negative decimal rounded to 2 decimal places and maps to a single column in EF Core.
var price = MonetaryAmount.TryCreate(99.99m); // Result<MonetaryAmount>
var zero = MonetaryAmount.Zero; // 0.00
// Arithmetic — returns Result<MonetaryAmount> (handles overflow)
var total = price.Value.Add(MonetaryAmount.Create(10.00m));
var doubled = price.Value.Multiply(2);
// JSON: serializes as plain decimal number (99.99)
// EF Core: maps to 1 decimal column via ApplyTrellisConventionsMoney is a structured value object, not a scalar wrapper. Its identity is the pair of Amount and Currency, so its JSON and EF representations stay object-shaped rather than flowing through the IScalarValue<TSelf, TPrimitive> pipeline.
Monetary amounts with currency code and type-safe arithmetic:
var price = Money.TryCreate(99.99m, "USD");
if (price.IsSuccess)
{
Console.WriteLine(price.Value.Amount); // 99.99
Console.WriteLine(price.Value.Currency); // USD
}
// Arithmetic operations enforce currency matching
var total = price.Value.Add(Money.Create(10.00m, "USD"));
// Returns: Result<Money> with 109.99 USD
// Mixing currencies returns an error
var invalid = price.Value.Add(Money.Create(10.00m, "EUR"));
// Returns: Error — currency mismatch
// Negative amounts are rejected
var negative = Money.TryCreate(-5.00m, "USD");
// Returns: Error.Validation("Amount cannot be negative.", "amount")Value objects implementing IScalarValue work seamlessly with ASP.NET Core for automatic validation:
// 1. Register in Program.cs
builder.Services
.AddControllers()
.AddScalarValueValidation(); // Enable automatic scalar value validation!
// 2. Define your value objects (source generator adds IScalarValue automatically)
public partial class FirstName : RequiredString<FirstName> { }
public partial class CustomerId : RequiredGuid<CustomerId> { }
// 3. Use in DTOs with both custom and built-in value objects
public record CreateUserDto
{
public FirstName FirstName { get; init; } = null!;
public EmailAddress Email { get; init; } = null!;
public PhoneNumber Phone { get; init; } = null!;
public Age Age { get; init; } = null!;
public CountryCode Country { get; init; } = null!;
public Maybe<Url> Website { get; init; } // Optional — null in JSON → Maybe.None
}
// 4. Controllers get automatic validation - no manual Result.Combine needed!
[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
[HttpPost]
public IActionResult Create(CreateUserDto dto)
{
// If we reach here, dto is FULLY validated!
// All 6 value objects were automatically validated:
// - FirstName: non-empty string
// - Email: RFC 5322 format
// - Phone: E.164 format
// - Age: 0-150 range
// - Country: ISO 3166-1 alpha-2 code
// - Website: valid HTTP/HTTPS URL (optional, Maybe<Url>)
var user = new User(dto.FirstName, dto.Email, dto.Phone, dto.Age, dto.Country);
return Ok(user);
}
[HttpGet("{id}")]
public IActionResult Get(CustomerId id) // Route parameter validated automatically!
{
var user = _repository.GetById(id);
return Ok(user);
}
}
// Invalid requests automatically return 400 Bad Request with validation errorsBenefits:
- No manual
Result.Combine()calls in controllers - Works with route parameters, query strings, form data, and JSON bodies
- Validation errors automatically flow into
ModelState - Standard ASP.NET Core validation infrastructure
- Works with
[ApiController]attribute for automatic 400 responses
Money is the main built-in exception here: it is a structured value object with its own JSON converter, so it is not part of the automatic scalar model-binding/validation pipeline.
This library provides both base classes for creating custom value objects and ready-to-use value objects for common scenarios:
| Value Object | Base Class | Purpose | Key Features |
|---|---|---|---|
| RequiredString | Primitive wrapper | Non-empty strings | Source generation, IScalarValue, IParsable, ASP.NET validation, optional [StringLength] constraints |
| RequiredGuid | Primitive wrapper | Non-default GUIDs | Source generation, IScalarValue, NewUniqueV4()/V7(), ASP.NET validation |
| RequiredInt | Primitive wrapper | Non-default integers | Source generation, IScalarValue, IParsable, ASP.NET validation |
| RequiredDecimal | Primitive wrapper | Non-default decimals | Source generation, IScalarValue, IParsable, ASP.NET validation |
| RequiredEnum | Enum value object | Type-safe enumerations | Source generation, behavior, state machines, JSON serialization |
| Value Object | Purpose | Validation Rules | Example |
|---|---|---|---|
| EmailAddress | Email validation | RFC 5322 compliant, trimmed | user@example.com |
| Url | Web URLs | Absolute HTTP/HTTPS URIs | https://example.com/path |
| PhoneNumber | Phone numbers | E.164 format | +14155551234 |
| Percentage | Percentage values | 0-100 range, supports % suffix | 15.5 or 15.5% |
| CurrencyCode | Currency codes | ISO 4217 3-letter codes | USD, EUR, GBP |
| IpAddress | IP addresses | IPv4 and IPv6 | 192.168.1.1 or ::1 |
| Hostname | Hostnames | RFC 1123 compliant | example.com |
| Slug | URL slugs | Lowercase, digits, hyphens | my-blog-post |
| CountryCode | Country codes | ISO 3166-1 alpha-2 | US, GB, FR |
| LanguageCode | Language codes | ISO 639-1 alpha-2 | en, es, fr |
| Age | Age values | 0-150 range | 42 |
| MonetaryAmount | Monetary amounts (single-currency) | Non-negative, 2 decimal places | 99.99 |
| Value Object | Purpose | Validation Rules | Example |
|---|---|---|---|
| Money | Monetary amounts | Non-negative amount + ISO 4217 currency | 99.99 USD |
What are Primitive Value Objects?
Primitive value objects wrap single primitive types (string, Guid, etc.) to provide:
- Type safety: Prevents mixing semantically different values
- Domain semantics: Makes code self-documenting and expressive
- Validation: Encapsulates validation rules at creation time
- Immutability: Ensures values cannot change after creation
Money is intentionally separate from that scalar bucket. It is still a value object, but it is a structured one composed of multiple meaningful components.
Generated Code Features:
TryCreatemethods returningResult<T>with optionalfieldNameparameterIParsable<T>implementation (Parse/TryParse)- Explicit cast operators
- Validation with descriptive error messages
- Property name inference for error messages (class name converted to camelCase)
- JSON serialization via
ParsableJsonConverter<T> - OpenTelemetry activity tracing support, typically a better day-to-day diagnostic signal than full ROP tracing because it emits spans at value creation and validation boundaries
Culture-aware parsing: Numeric and date types (Age, MonetaryAmount, Percentage, RequiredInt<T>, RequiredDecimal<T>, RequiredLong<T>, RequiredDateTime<T>) also implement IFormattableScalarValue, adding TryCreate(string?, IFormatProvider?, string?) for culture-sensitive parsing. The standard TryCreate(string?) always uses InvariantCulture. String-based types do not implement this interface — culture doesn't affect their format.
-
Use partial classes
Required for source code generation to work correctly. -
Leverage generated methods
UseTryCreatefor safe parsing that returnsResult<T>. -
Use the fieldName parameter
Pass custom field names for better validation error messages in APIs. -
Compose with other value objects
Combine multiple value objects usingCombinefor validation. -
Use meaningful names
Class name becomes part of the error message (e.g., "Employee Id cannot be empty."). -
Prefer specific types over primitives
EmployeeIdis more expressive thanGuidorstring- eliminates primitive obsession.
- Trellis.Primitives.Generator — Source generator (required companion)
- Trellis.Results — Core
Result<T>type - Trellis.DomainDrivenDesign — Entity and aggregate patterns
- Trellis.Asp — ASP.NET Core integration
MIT — see LICENSE for details.