Built-in Value Objects
Value Objects (VOs) are objects defined by their attributes rather than a unique identity. In TaskHub.Shared.ValueObjects they are implemented as immutable C# record types with constructor-time validation.
Design Patterns in VOs
- Immutability: All VOs are
sealed record types.
- Validation on creation: Validation runs in the constructor; invalid input throws
ValueException.
- Implicit/explicit converters: Most VOs ship with
TypeConverter and JsonConverter so they round-trip through ASP.NET model binding and System.Text.Json without ceremony.
- EF Core friendly: Single-value VOs map via
ValueConverter; multi-property VOs map via OwnsOne.
π Strongly-Typed Identifiers
All ID types wrap a Guid and protect domain code from accidentally mixing IDs of different aggregate kinds (a UserId cannot be passed where a JobId is expected).
| VO |
Wraps |
Used for |
UserId |
Guid |
The authenticated user. |
ClientId |
Guid |
A client/customer. |
JobId |
Guid |
A job/work item. |
OfferId |
Guid |
An offer made against a job. |
ProfileId |
Guid |
A userβs profile. |
PortfolioId |
Guid |
A portfolio entry. |
ReviewId |
Guid |
A review record. |
RegionId |
Guid |
A geographic region. |
All ID types validate the GUID is non-empty (Guid.Empty is rejected).
π€ Personal & Identity
| VO |
Validation |
Email |
Matches ^[^@\s]+@[^@\s]+\.[^@\s]+$; non-empty; trimmed. |
PhoneNumber |
Length and digit check (E.164-style). |
Password |
Minimum length and complexity (configurable). Stored only as a hash. |
FirstName / LastName |
Non-empty, length-bounded. |
FullName |
Composition of FirstName + LastName. |
BirthDate |
Valid date in the past; minimum/maximum age policy. |
π Text Primitives
| VO |
Purpose |
Title |
Bounded title text β typical for jobs, posts, reviews. |
Comment |
Free-form comment text with length cap. |
Text |
Generic bounded text. |
Promo |
Promotion / discount code. |
Photo |
URL or relative path to a stored image (pairs with IStorageService). |
π Geography
| VO |
Validation / shape |
LatLng |
Latitude β [-90, 90], Longitude β [-180, 180]. Maps to PostGIS geography(Point, 4326) via converter. |
π° Domain-Specific
| VO |
Shape |
Payment |
Type (enum: Fixed/Range), Min, Max. Max β₯ Min, both non-negative. Persisted via OwnsOne. |
Score |
Bounded numeric rating (0β5 typical). |
π Bundled Converters
TaskHub.Shared.ValueObjects ships ready-made converters so VOs work cleanly through the entire request/response lifecycle.
Converters/JsonConverters/
| Converter |
Handles |
GuidValueJsonConverter |
All GUID-wrapping IDs (UserId, JobId, β¦). |
IntValueJsonConverter |
Integer-wrapping VOs. |
LongValueJsonConverter |
Long-wrapping VOs. |
StringJsonConverter |
String-wrapping VOs (Title, Email, β¦). |
DateTimeJsonConverter |
UTC-normalized DateTime/DateTimeOffset. |
CoordinatesJsonConverter |
LatLng. |
PaymentJsonConverter |
Payment complex type. |
Converters/TypeConverters/
These plug into ASP.NET Core model binding so route segments, query strings, and form fields parse straight to VOs.
| Converter |
Handles |
GuidValueTypeConverter |
ID types via route binding (e.g. /api/jobs/{id:guid} β JobId). |
IntValueTypeConverter / LongValueTypeConverter |
Numeric-wrapping VOs. |
StringValueTypeConverter |
String-wrapping VOs. |
CoordinatesTypeConverter |
LatLng from lat,lng query strings. |
PaymentTypeConverter |
Payment from compact form-encoded shape. |
The converters are registered automatically when the module is referenced β no manual JsonOptions.Converters.Add(...) is needed.
Example: Email
public sealed record Email
{
private static readonly Regex EmailRegex =
new(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public string Value { get; init; }
public Email(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ValueException("Email cannot be empty.");
var trimmed = value.Trim();
if (!EmailRegex.IsMatch(trimmed))
throw new ValueException("Email is not valid.");
Value = trimmed;
}
}
Example: LatLng
public sealed record LatLng(double Latitude, double Longitude)
{
public LatLng() : this(0, 0) { }
public static LatLng Create(double lat, double lng)
{
if (lat is < -90 or > 90) throw new ValueException("Latitude out of range.");
if (lng is < -180 or > 180) throw new ValueException("Longitude out of range.");
return new(lat, lng);
}
}
Example: Payment
public sealed record Payment(PaymentType Type, decimal Min, decimal Max)
{
public Payment(decimal fixedAmount) : this(PaymentType.Fixed, fixedAmount, fixedAmount)
{
if (fixedAmount < 0) throw new ValueException("Payment cannot be negative.");
}
public Payment(decimal min, decimal max) : this(PaymentType.Range, min, max)
{
if (min < 0) throw new ValueException("Min payment cannot be negative.");
if (max < min) throw new ValueException("Max must be β₯ Min.");
}
}
See Also