TaskHub.Shared

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


πŸ†” 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