RA.Utilities.Core.ValueObjects
RA.Utilities.Core.ValueObjects provides strongly-typed, validated domain primitives: Email, Currency, SSN, Money, and IBAN.
It eliminates primitive obsession by ensuring that input values are validated and normalized once at system boundaries, so invalid states are unrepresentable throughout your application.
🎯 Purpose
In typical applications, domain primitives like email addresses, currency codes, bank account numbers, and monetary figures are represented as primitive string or decimal values. This pattern invites subtle bugs: casing mismatches (eur vs EUR), whitespace corruption, invalid formatting, and cross-currency addition.
A value object is validated and normalized once, at the boundary of your system. Once created, an instance is guaranteed to be valid and canonical.
- Type Safety: The compiler prevents accidental parameter transposition — you cannot pass a raw string or an email where a currency code or IBAN is required.
- Fail Fast at the Boundary: Invalid inputs immediately throw a
BadRequestExceptioncontaining structuredValidationErrordetails (codes fromBaseErrorCode, property names, and attempted values) thatRA.Utilities.Apimiddleware translates into standardized HTTP 400 responses. - Consistent Normalization: Automatic formatting (lowercased emails, upper-cased currency/SSN/IBAN codes, stripped whitespace/separators) guarantees that logically equivalent values produce equal instances.
- Value Equality: Implemented as C# records, instances compare by value rather than reference.
- Decoupled Domain Logic: Core business logic receives guaranteed-valid models and never needs defensive re-validation or scattered regex checks.
✨ Key Features
- 5 value object types —
Email(RFC 5321 length,LocalPart/Domain),Currency(ISO 4217 code),SSN(letter + 8 digits + letter),Money(decimal amount + currency with guarded arithmetic), andIBAN(ISO 13616 structure and ISO 7064 mod-97 check digits) - Structured validation errors —
BadRequestExceptioncarryingValidationErrorentries matchingBaseErrorCodeandBaseErrorMessage - Built-in JSON support — generic
ValueObjectJsonConverter<T>serializes string-backed value objects as plain strings and validates them on deserialization IParsable<T>support —Parse/TryParseimplementations enable automatic route, query, and header binding in ASP.NET Core minimal APIs- Extensible architecture — implement
IValueObject<TSelf>to reuse the generic JSON converter for your own string-based value objects - Zero external dependencies — depends only on
RA.Utilities.Core.Exceptions - No Configuration — no DI registration or options setup required
v10.1.0 introduced Money.Subtract(other) for currency-safe subtraction, and v10.2.0 introduced the IBAN value object. See the Migration Guides for details.
🛠️ Getting Started
Prerequisites
- .NET 10.0 or later
RA.Utilities.Core.Exceptions(included as a package dependency)
Installation
.NET CLI
dotnet add package RA.Utilities.Core.ValueObjects
Package Manager Console
Install-Package RA.Utilities.Core.ValueObjects
PackageReference (csproj)
<PackageReference Include="RA.Utilities.Core.ValueObjects" Version="10.2.0" />
Hello World
Validate and normalize user inputs at the boundary of your system:
using RA.Utilities.Core.ValueObjects;
var email = new Email(" User@Gmail.com ");
Console.WriteLine(email.Value); // "user@gmail.com" (trimmed and lower-cased)
Console.WriteLine(email.LocalPart); // "user"
Console.WriteLine(email.Domain); // "gmail.com"
var iban = new IBAN("gb82 west 1234 5698 7654 32");
Console.WriteLine(iban.Value); // "GB82WEST12345698765432" (normalized compact)
Console.WriteLine(iban.CountryCode); // "GB"
Invalid input throws a BadRequestException immediately:
new Email("not-an-email"); // throws BadRequestException with BaseErrorCode.EmailNotValid
new IBAN("GB00WEST12345698765432"); // throws BadRequestException with BaseErrorCode.IbanCheckDigits
No DI registration is required. Built-in JSON converters are applied automatically through [JsonConverter] attributes on each value object.
🧠 Core Concepts
Available Value Objects
| Value Object | Normalization | Validation | Typical Use |
|---|---|---|---|
Email | trimmed, lower-cased | required, max 254 chars, email format regex | User identities, communication, login |
Currency | trimmed, upper-cased | required, exactly 3 characters (ISO 4217) | Pricing, payments, multi-currency accounting |
SSN | trimmed, upper-cased | required, max 10 chars, ^[A-Z][0-9]{8}[A-Z]$ | National identity, employee records |
Money | — | PositiveMoney rejects negative amounts; Add/Subtract guard currency mismatch | Pricing, invoice line items, balances |
IBAN | trimmed, stripped of grouping separators, upper-cased | required, 13–34 chars regex, ISO 7064 mod-97 check digits | Bank account numbers, SEPA wire transfers |
Shared Behavior: IValueObject<TSelf>
Email, Currency, SSN, and IBAN implement IValueObject<TSelf>, which combines a normalized string Value with IParsable<TSelf>:
public interface IValueObject<TSelf> : IParsable<TSelf>
where TSelf : IValueObject<TSelf>
{
string Value { get; }
}
This interface enables generic operations over string-backed value objects and powers ValueObjectJsonConverter<T>.
How Validation Flows Through the System
Boundary (API/DTO) Domain API Layer
────────────────── ────── ─────────
new Email(input) → guaranteed-valid → BadRequestException
new IBAN(input) value object instance caught by middleware
throws BadRequestException → HTTP 400 + JSON body
if invalid
JsonSerializer.Deserialize<T> → same validation → JsonException on
via T.Parse invalid JSON
MapGet("/{iban}") → TryParse binds route → automatic 400 on
minimal API binding values with validation invalid route values
Terminology
- Value Object: An immutable type identified purely by its values rather than an identity key. Two instances with identical property values are equal.
- Normalization: Canonical formatting (trimming, case normalization, separator stripping) applied during construction so logically equal values share the same internal string.
IValueObject<TSelf>: Shared interface for string-backed value objects, exposingValueandIParsable<TSelf>.ValueObjectJsonConverter<T>: SharedSystem.Text.Jsonconverter that writes value objects as plain strings and deserializes them through validation.BadRequestException: Exception thrown upon validation failure, carryingValidationErroritems with machine-readable error codes fromBaseErrorCode.- ISO 7064 Mod-97: Mathematical checksum algorithm used by
IBANto detect transcription errors in bank account numbers.
📖 How-To Guides
Validate an email address
using RA.Utilities.Core.Exceptions;
using RA.Utilities.Core.ValueObjects;
public void RegisterUser(string emailInput)
{
var email = new Email(emailInput); // throws BadRequestException if invalid
var existing = _userRepository.FindByEmail(email);
if (existing is not null)
{
throw new ConflictException("User", email);
}
_userRepository.Add(new User(email));
}
Enforce a valid currency on a price
using RA.Utilities.Core.ValueObjects;
public Money GetPrice(string currencyCode)
{
var currency = new Currency(currencyCode); // exactly 3 characters, e.g. "EUR"
return new Money(19.99m, currency);
}
Validate and normalize an International Bank Account Number (IBAN)
using RA.Utilities.Core.ValueObjects;
public void ProcessTransfer(string rawIban, decimal amount, string currencyCode)
{
var destination = new IBAN(rawIban); // strips spaces/dashes, validates mod-97 check digits
var transferAmount = Money.PositiveMoney(amount, new Currency(currencyCode));
Console.WriteLine($"Sending {transferAmount} to {destination.CountryCode} account: {destination.Value}");
}
Reject negative amounts and mismatched currencies
using RA.Utilities.Core.ValueObjects;
var price = Money.PositiveMoney(19.99m, new Currency("EUR"));
var shipping = Money.PositiveMoney(4.99m, new Currency("EUR"));
var discount = Money.PositiveMoney(4.00m, new Currency("EUR"));
var total = price.Add(shipping); // 24.98 EUR
var net = price.Subtract(discount); // 15.99 EUR
// Attempting to combine different currencies throws BadRequestException (CurrencyMismatch):
total.Add(new Money(5m, new Currency("USD")));
total.Subtract(new Money(1m, new Currency("USD")));
Bind value objects in Minimal APIs
IParsable<T> implementation enables direct binding from route, query, and header values in ASP.NET Core:
using RA.Utilities.Core.ValueObjects;
app.MapGet("/users/{email}", (Email email) => $"Looking up {email.LocalPart} at {email.Domain}");
app.MapGet("/accounts/{iban}", (IBAN iban) => $"Routing to country: {iban.CountryCode}");
If an incoming value fails validation, ASP.NET Core minimal APIs return an automatic HTTP 400 Bad Request.
Serialize to and deserialize from JSON
Value objects serialize as plain JSON strings and deserialize back through validation:
using System.Text.Json;
using RA.Utilities.Core.ValueObjects;
var json = JsonSerializer.Serialize(new Email("User@Gmail.com"));
// json == "\"user@gmail.com\""
var email = JsonSerializer.Deserialize<Email>("\" User@Gmail.com \"");
// email.Value == "user@gmail.com" (trimmed and normalized)
var iban = JsonSerializer.Deserialize<IBAN>("\"GB82 WEST 1234 5698 7654 32\"");
// iban.Value == "GB82WEST12345698765432"
JsonSerializer.Deserialize<Currency>("\"EURO\""); // throws JsonException
Create a custom value object
Implement IValueObject<TSelf> and decorate the record with [JsonConverter]:
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using RA.Utilities.Core.ValueObjects;
using RA.Utilities.Core.ValueObjects.Converters;
[JsonConverter(typeof(ValueObjectJsonConverter<Sku>))]
public sealed partial record Sku : IValueObject<Sku>
{
public string Value { get; }
public Sku(string? value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
Value = value.Trim().ToUpperInvariant();
}
public static Sku Parse(string s, IFormatProvider? provider) => new(s);
public static bool TryParse(
[NotNullWhen(true)] string? s,
IFormatProvider? provider,
[MaybeNullWhen(false)] out Sku result)
{
if (string.IsNullOrWhiteSpace(s))
{
result = null;
return false;
}
try
{
result = new Sku(s);
return true;
}
catch
{
result = null;
return false;
}
}
public static implicit operator string(Sku sku)
{
ArgumentNullException.ThrowIfNull(sku);
return sku.Value;
}
public override string ToString() => Value;
}
⚙️ Configuration
This package has no configuration options. There are no dependency injection setup calls, no appsettings.json sections, and no options classes. Value objects are instantiated directly in your application code.
When using RA.Utilities.Api, the GlobalExceptionHandler automatically translates any BadRequestException thrown by value object validation into standardized HTTP 400 responses.
✨ Available Value Objects
The Email value object represents a validated, normalized email address. Values are trimmed and lower-cased on creation, must not exceed 254 characters, and must match a standard email format.
Currency
The Currency value object represents a validated currency code. Values are trimmed and upper-cased on creation and must be exactly 3 characters, as defined by ISO 4217 (e.g., EUR, USD).
SSN
The SSN value object represents a validated social security number. Values are trimmed and upper-cased on creation, must not exceed 10 characters, and must match the format ^A-Z[A-Z]$ — a letter, eight digits, and a letter (e.g., J01234567R).
Money
The Money value object pairs a monetary Amount (decimal) with a Currency. It guards arithmetic so amounts in different currencies can never be combined accidentally.
IBAN
The IBAN value object represents a validated, normalized International Bank Account Number. Values are trimmed, stripped of grouping separators (spaces, apostrophes, backslashes, and hyphens), upper-cased on creation, checked against the ISO 13616 structural pattern (13–34 alphanumeric characters), and verified against the ISO 7064 mod-97 check digit algorithm.
Migration Guides
Use this guide when adopting new features or upgrading RA.Utilities.Core.ValueObjects between versions.
Troubleshooting & FAQ
Common issues encountered when working with RA.Utilities.Core.ValueObjects and guidance on how to resolve them.
🙌 Contributing & Support
Found a bug or have a suggestion?
- GitHub Issues: Report a bug or request a feature
- Discussions: Ask questions or discuss ideas
Contributing
Contributions are welcome! Follow these steps to contribute:
- Fork the repository: RedonAlla/RA.Utilities
- Create a branch for your feature or fix (e.g.,
feature/add-phone-value-object) - Implement changes — new value objects must implement
IValueObject<TSelf>, include unit tests, and provide complete XML doc comments - Submit a Pull Request with a clear explanation of changes and test results
Links
| Resource | URL |
|---|---|
| NuGet | RA.Utilities.Core.ValueObjects |
| Source | GitHub — RedonAlla/RA.Utilities |
| License | MIT |
| Build Status | GitHub Actions |