Skip to main content

RA.Utilities.Core.ValueObjects

NuGet version NuGet Downloads Codecov Publish NuGet GitHub license

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 BadRequestException containing structured ValidationError details (codes from BaseErrorCode, property names, and attempted values) that RA.Utilities.Api middleware 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), and IBAN (ISO 13616 structure and ISO 7064 mod-97 check digits)
  • Structured validation errors — BadRequestException carrying ValidationError entries matching BaseErrorCode and BaseErrorMessage
  • Built-in JSON support — generic ValueObjectJsonConverter<T> serializes string-backed value objects as plain strings and validates them on deserialization
  • IParsable<T> support — Parse/TryParse implementations 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
Looking for recent updates?

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 ObjectNormalizationValidationTypical Use
Emailtrimmed, lower-casedrequired, max 254 chars, email format regexUser identities, communication, login
Currencytrimmed, upper-casedrequired, exactly 3 characters (ISO 4217)Pricing, payments, multi-currency accounting
SSNtrimmed, upper-casedrequired, max 10 chars, ^[A-Z][0-9]{8}[A-Z]$National identity, employee records
Money—PositiveMoney rejects negative amounts; Add/Subtract guard currency mismatchPricing, invoice line items, balances
IBANtrimmed, stripped of grouping separators, upper-casedrequired, 13–34 chars regex, ISO 7064 mod-97 check digitsBank 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, exposing Value and IParsable<TSelf>.
  • ValueObjectJsonConverter<T>: Shared System.Text.Json converter that writes value objects as plain strings and deserializes them through validation.
  • BadRequestException: Exception thrown upon validation failure, carrying ValidationError items with machine-readable error codes from BaseErrorCode.
  • ISO 7064 Mod-97: Mathematical checksum algorithm used by IBAN to 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​


🙌 Contributing & Support​

Found a bug or have a suggestion?​

Contributing​

Contributions are welcome! Follow these steps to contribute:

  1. Fork the repository: RedonAlla/RA.Utilities
  2. Create a branch for your feature or fix (e.g., feature/add-phone-value-object)
  3. Implement changes — new value objects must implement IValueObject<TSelf>, include unit tests, and provide complete XML doc comments
  4. Submit a Pull Request with a clear explanation of changes and test results
ResourceURL
NuGetRA.Utilities.Core.ValueObjects
SourceGitHub — RedonAlla/RA.Utilities
LicenseMIT
Build StatusGitHub Actions