Skip to main content

Overview

The Shared.Domain module contains common domain building blocks used across all business modules. It provides base classes, value objects, interfaces, and the Result pattern implementation.

Components

Base Entity

All domain entities inherit from BaseEntity:
Entities/BaseEntity.cs
namespace Shared.Domain.Entities;

public abstract class BaseEntity
{
    public Guid Id { get; private set; }
}
The Id property uses a private setter to enforce immutability. Entity IDs are typically generated by the database or using Guid.NewGuid() in factory methods.

Result Pattern

The Result pattern provides functional error handling without exceptions:

Result<TValue>

Models/Result.cs
using System.Diagnostics;
using System.Net;

namespace Shared.Domain.Models;

[DebuggerDisplay("{DebuggerDisplay,nq}")]
public sealed class Result<TValue>
{
    private string DebuggerDisplay =>
        IsSuccess
        ? $"Success: {Value}"
        : $"Failure: {ErrorMessage} StatusCode: {StatusCode}";
    
    public TValue? Value { get; }
    public string? ErrorMessage { get; }
    public bool IsSuccess => ErrorMessage == null;
    public bool IsFailure => !IsSuccess;
    public HttpStatusCode StatusCode { get; }
    
    private Result(TValue value, HttpStatusCode statusCode)
    {
        Value = value;
        ErrorMessage = null;
        StatusCode = statusCode;
    }
    
    private Result(string errorMessage, HttpStatusCode statusCode)
    {
        Value = default;
        ErrorMessage = errorMessage;
        StatusCode = statusCode;
    }
    
    private Result(TValue? value, HttpStatusCode statusCode, string? errorMessage)
    {
        Value = value;
        ErrorMessage = errorMessage;
        StatusCode = statusCode;
    }
    
    // Factory methods
    public static Result<TValue> Success(
        TValue value, 
        HttpStatusCode statusCode = HttpStatusCode.OK)
        => new(value, statusCode);
    
    public static Result<TValue> Failure(
        string errorMessage, 
        HttpStatusCode statusCode = HttpStatusCode.BadRequest)
        => new(errorMessage, statusCode);
    
    // Convert from another Result type
    public static Result<TValue> Failure<TResult>(Result<TResult> failedResult)
    {
        if (failedResult.IsSuccess) 
            throw new ArgumentException("Result is success", nameof(failedResult));
        return new Result<TValue>(failedResult.ErrorMessage!, failedResult.StatusCode);
    }
    
    public static Result<TValue> Failure(VoidResult failedResult)
    {
        if (failedResult.IsSuccess) 
            throw new ArgumentException("Result is success", nameof(failedResult));
        return new Result<TValue>(failedResult.ErrorMessage!, failedResult.StatusCode);
    }
    
    public static Result<TValue> Copy<TResult>(Result<TResult> result) 
        where TResult : TValue
    {
        return new Result<TValue>(result.Value, result.StatusCode, result.ErrorMessage);
    }
    
    // Functional mapping
    public TResult Map<TResult>(
        Func<TValue, TResult> onSuccess, 
        Func<string, TResult> onFailure)
    {
        return IsSuccess ? onSuccess(Value!) : onFailure(ErrorMessage!);
    }
}
Usage Examples:
public Result<Product> CreateProduct(string title, decimal price)
{
    if (string.IsNullOrWhiteSpace(title))
        return Result<Product>.Failure("Title is required");
    
    if (price <= 0)
        return Result<Product>.Failure("Price must be positive");
    
    var product = new Product(title, price);
    return Result<Product>.Success(product, HttpStatusCode.Created);
}

VoidResult

For operations that don’t return a value:
Models/VoidResult.cs
using System.Net;

namespace Shared.Domain.Models;

public sealed class VoidResult
{
    public string? ErrorMessage { get; }
    public bool IsSuccess => ErrorMessage == null;
    public bool IsFailure => !IsSuccess;
    public HttpStatusCode StatusCode { get; }
    
    private VoidResult(string? errorMessage, HttpStatusCode statusCode)
    {
        ErrorMessage = errorMessage;
        StatusCode = statusCode;
    }
    
    public static VoidResult Success(HttpStatusCode statusCode = HttpStatusCode.OK)
        => new(null, statusCode);
    
    public static VoidResult Failure(
        string errorMessage, 
        HttpStatusCode statusCode = HttpStatusCode.BadRequest)
        => new(errorMessage, statusCode);
    
    public static VoidResult Failure<TResult>(Result<TResult> failedResult)
    {
        if (failedResult.IsSuccess) 
            throw new ArgumentException("Result is success", nameof(failedResult));
        return new VoidResult(failedResult.ErrorMessage!, failedResult.StatusCode);
    }
    
    public static VoidResult Failure(VoidResult failedResult)
    {
        if (failedResult.IsSuccess) 
            throw new ArgumentException("Result is success", nameof(failedResult));
        return new VoidResult(failedResult.ErrorMessage!, failedResult.StatusCode);
    }
    
    public TResult Map<TResult>(
        Func<TResult> onSuccess, 
        Func<string, HttpStatusCode, TResult> onFailure)
    {
        return IsSuccess ? onSuccess() : onFailure(ErrorMessage!, StatusCode);
    }
}

Value Objects

Immutable value objects with validation:
ValueObjects/Email.cs
using System.Text.RegularExpressions;
using Shared.Domain.Models;

namespace Shared.Domain.ValueObjects;

public sealed class Email
{
    private static readonly Regex Regex = new(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.Compiled);
    
    public string Value { get; private set; }
    
    private Email(string value)
    {
        Value = value;
    }
    
    public static Result<Email> Create(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
            return Result<Email>.Failure("Email cannot be null or empty");
        
        if (!Regex.IsMatch(value))
            return Result<Email>.Failure("Email is not valid");
        
        return Result<Email>.Success(new Email(value));
    }
    
    // Explicit operators for convenience
    public static explicit operator Email(string emailString)
    {
        Result<Email> createEmailResult = Create(emailString);
        if (!createEmailResult.IsSuccess)
            throw new ArgumentException(createEmailResult.ErrorMessage, nameof(emailString));
        return createEmailResult.Value!;
    }
    
    public static explicit operator string(Email email) => email.Value;
}
ValueObjects/FullName.cs
using Shared.Domain.Models;

namespace Shared.Domain.ValueObjects;

public sealed class FullName
{
    public string FirstName { get; private set; }
    public string LastName { get; private set; }
    public string? MiddleName { get; private set; }
    
    private FullName(string firstName, string lastName, string? middleName = null)
    {
        FirstName = firstName;
        LastName = lastName;
        MiddleName = middleName;
    }
    
    public static Result<FullName> Create(
        string firstName, 
        string lastName, 
        string? middleName = null)
    {
        if (string.IsNullOrWhiteSpace(firstName))
            return Result<FullName>.Failure("First name is required");
        if (string.IsNullOrWhiteSpace(lastName))
            return Result<FullName>.Failure("Last name is required");
        
        return Result<FullName>.Success(new FullName(firstName, lastName, middleName));
    }
    
    public override string ToString()
    {
        return string.IsNullOrWhiteSpace(MiddleName)
            ? $"{FirstName} {LastName}"
            : $"{FirstName} {MiddleName} {LastName}";
    }
}
ValueObjects/PhoneNumber.cs
using System.Text.RegularExpressions;
using Shared.Domain.Models;

namespace Shared.Domain.ValueObjects;

public sealed class PhoneNumber
{
    private static readonly Regex Regex = new(@"^\+?[1-9]\d{1,14}$", RegexOptions.Compiled);
    
    public string Value { get; private set; }
    
    private PhoneNumber(string value)
    {
        Value = value;
    }
    
    public static Result<PhoneNumber> Create(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
            return Result<PhoneNumber>.Failure("Phone number cannot be null or empty");
        
        if (!Regex.IsMatch(value))
            return Result<PhoneNumber>.Failure("Phone number is not valid");
        
        return Result<PhoneNumber>.Success(new PhoneNumber(value));
    }
    
    public static explicit operator PhoneNumber(string phoneString)
    {
        Result<PhoneNumber> result = Create(phoneString);
        if (!result.IsSuccess)
            throw new ArgumentException(result.ErrorMessage, nameof(phoneString));
        return result.Value!;
    }
    
    public static explicit operator string(PhoneNumber phone) => phone.Value;
}
ValueObjects/Address.cs
using Shared.Domain.Models;

namespace Shared.Domain.ValueObjects;

public sealed class Address
{
    public string City { get; private set; }
    public string Street { get; private set; }
    public uint HouseNumber { get; private set; }
    public uint? ApartmentNumber { get; private set; }
    
    private Address(
        string city, 
        string street, 
        uint houseNumber, 
        uint? apartmentNumber)
    {
        City = city;
        Street = street;
        HouseNumber = houseNumber;
        ApartmentNumber = apartmentNumber;
    }
    
    public static Result<Address> Create(
        string city, 
        string street, 
        uint houseNumber, 
        uint? apartmentNumber = null)
    {
        if (string.IsNullOrWhiteSpace(city))
            return Result<Address>.Failure("City is required");
        if (string.IsNullOrWhiteSpace(street))
            return Result<Address>.Failure("Street is required");
        if (houseNumber == 0)
            return Result<Address>.Failure("House number must be greater than 0");
        
        return Result<Address>.Success(
            new Address(city, street, houseNumber, apartmentNumber));
    }
    
    public override string ToString()
    {
        return ApartmentNumber.HasValue
            ? $"{City}, {Street} {HouseNumber}/{ApartmentNumber.Value}"
            : $"{City}, {Street} {HouseNumber}";
    }
}
ValueObjects/BirthDate.cs
using Shared.Domain.Models;

namespace Shared.Domain.ValueObjects;

public sealed class BirthDate
{
    public DateOnly Value { get; private set; }
    
    private BirthDate(DateOnly value)
    {
        Value = value;
    }
    
    public static Result<BirthDate> Create(DateOnly value)
    {
        // Must be at least 13 years old
        int age = DateTime.UtcNow.Year - value.Year;
        if (age < 13)
            return Result<BirthDate>.Failure("Must be at least 13 years old");
        
        // Can't be in the future
        if (value > DateOnly.FromDateTime(DateTime.UtcNow))
            return Result<BirthDate>.Failure("Birth date cannot be in the future");
        
        return Result<BirthDate>.Success(new BirthDate(value));
    }
    
    public int GetAge()
    {
        var today = DateTime.UtcNow;
        int age = today.Year - Value.Year;
        if (Value.ToDateTime(TimeOnly.MinValue) > today.AddYears(-age))
            age--;
        return age;
    }
}

Repository Interfaces

Base repository contract used by all modules:
Interfaces/IBaseRepository.cs
using System.Linq.Expressions;
using Shared.Domain.Entities;

namespace Shared.Domain.Interfaces;

public interface IBaseRepository<TEntity> where TEntity : BaseEntity
{
    Task<bool> IsExistAsync(Guid id, CancellationToken cancellationToken);
    
    Task AddAsync(TEntity entity, CancellationToken cancellationToken);
    
    void Update(TEntity entity, Action actionUpdate, CancellationToken cancellationToken);
    
    void Delete(TEntity entity, CancellationToken cancellationToken);
    
    Task<TEntity?> GetByIdAsync(
        Guid id, 
        CancellationToken cancellationToken, 
        params string[]? includeProperties);
    
    Task<TEntity?> GetByIdAsNoTrackingAsync(
        Guid id, 
        CancellationToken cancellationToken, 
        params string[]? includeProperties);
    
    Task ExecuteDeleteAsync(CancellationToken cancellationToken);
    
    Task ExecuteDeleteAsync(
        Expression<Func<TEntity, bool>> condition, 
        CancellationToken cancellationToken);
    
    Task SaveChangesAsync(CancellationToken cancellationToken);
}
Interfaces/IPaginationRepository.cs
namespace Shared.Domain.Interfaces;

public interface IPaginationRepository<TProjection>
{
    Task<(IReadOnlyCollection<TProjection> Items, int TotalCount)> GetPagedAsync(
        int pageNumber, 
        int pageSize, 
        CancellationToken cancellationToken);
}

Projections

Base class for read models:
Projections/BaseProjection.cs
namespace Shared.Domain.Projections;

public abstract class BaseProjection
{
    public Guid Id { get; set; }
}

Enums

Enums/BlobResourceType.cs
namespace Shared.Domain.Enums;

public enum BlobResourceType
{
    Photo,
    Video
}

Design Principles

Immutability

Value objects are immutable - once created, their values cannot change

Self-Validation

Value objects validate themselves during creation, ensuring invalid states are impossible

Factory Methods

Static Create methods return Result<T> to handle validation errors functionally

Encapsulation

Private setters prevent external modification of entity state

Usage Patterns

Creating Value Objects

// Always use the Create method
Result<Email> emailResult = Email.Create("[email protected]");

if (emailResult.IsSuccess)
{
    Email email = emailResult.Value!;
    // Use email
}
else
{
    // Handle validation error
    string error = emailResult.ErrorMessage!;
}

Using Result Pattern

public VoidResult UpdateCustomerEmail(Customer customer, string newEmail)
{
    Result<Email> emailResult = Email.Create(newEmail);
    if (emailResult.IsFailure)
        return VoidResult.Failure(emailResult);
    
    customer.ChangeEmail(emailResult.Value!);
    return VoidResult.Success();
}

Benefits

Value objects prevent primitive obsession. Instead of passing strings everywhere, you pass Email, PhoneNumber, etc., making the code more expressive and preventing errors.
Validation logic lives in one place (the value object’s Create method), ensuring consistency across the application.
The Result pattern eliminates exceptions for expected failures, making error handling explicit and predictable.
Pure domain logic with no dependencies on infrastructure makes unit testing straightforward.

Build docs developers (and LLMs) love