Skip to main content

Domain Layer

The Domain layer is the heart of the application, containing all business logic, entities, and domain rules. It has zero dependencies on other layers, making it highly testable and portable.

Project Structure

SGRH.Domain/
├── Base/
│   └── EntityBase.cs              # Base class for all entities
├── Entities/
│   ├── Reservas/
│   │   ├── Reserva.cs             # Aggregate root for reservations
│   │   ├── DetalleReserva.cs      # Room details in reservation
│   │   └── ReservaServicioAdicional.cs
│   ├── Habitaciones/
│   │   ├── Habitacion.cs          # Room entity
│   │   ├── CategoriaHabitacion.cs
│   │   ├── HabitacionHistorial.cs # Temporal state tracking
│   │   └── TarifaTemporada.cs
│   ├── Clientes/
│   │   └── Cliente.cs
│   ├── Servicios/
│   │   ├── ServicioAdicional.cs
│   │   └── ServicioCategoriaPrecio.cs
│   ├── Temporadas/
│   │   └── Temporada.cs
│   └── Auditoria/
│       ├── AuditoriaEvento.cs
│       └── AuditoriaEventoDetalle.cs
├── Abstractions/
│   ├── Repositories/              # Repository interfaces
│   └── Policies/                  # Domain policy interfaces
├── Common/
│   └── Guard.cs                   # Validation guards
├── Enums/
│   ├── EstadoReserva.cs
│   └── EstadoHabitacion.cs
└── Exceptions/
    ├── ValidationException.cs
    ├── BusinessRuleViolationException.cs
    ├── NotFoundException.cs
    └── ConflictException.cs

Core Entities

Reserva (Aggregate Root)

The Reserva entity is a rich domain model with encapsulated behavior and state management.
public sealed class Reserva : EntityBase
{
    public int ReservaId { get; private set; }
    public int ClienteId { get; private set; }
    public EstadoReserva EstadoReserva { get; private set; }
    public DateTime FechaReserva { get; private set; }
    public DateTime FechaEntrada { get; private set; }
    public DateTime FechaSalida { get; private set; }

    // Calculated property - total cost
    public decimal CostoTotal =>
        _habitaciones.Sum(h => h.TarifaAplicada) +
        _servicios.Sum(s => s.SubTotal);

    private readonly List<DetalleReserva> _habitaciones = [];
    public IReadOnlyCollection<DetalleReserva> Habitaciones => _habitaciones;

    private readonly List<ReservaServicioAdicional> _servicios = [];
    public IReadOnlyCollection<ReservaServicioAdicional> Servicios => _servicios;

    private Reserva() { } // EF Core

    public Reserva(int clienteId, DateTime fechaEntrada, DateTime fechaSalida)
    {
        Guard.AgainstOutOfRange(clienteId, nameof(clienteId), 0);
        Guard.AgainstInvalidDateRange(fechaEntrada, fechaSalida,
                                      nameof(fechaEntrada), nameof(fechaSalida));

        ClienteId = clienteId;
        FechaEntrada = fechaEntrada;
        FechaSalida = fechaSalida;
        EstadoReserva = EstadoReserva.Pendiente;
        FechaReserva = DateTime.UtcNow;
    }
}
Notice the private setters and private collections with public read-only interfaces. This enforces invariants through methods.

Reservation State Machine

Reservations follow a strict state transition flow:

Business Logic Methods

public void AgregarHabitacion(int habitacionId, IReservaDomainPolicy policy)
{
    Guard.AgainstNull(policy, nameof(policy));
    EnsureEditable();
    Guard.AgainstOutOfRange(habitacionId, nameof(habitacionId), 0);

    if (_habitaciones.Any(h => h.HabitacionId == habitacionId))
        throw new ConflictException(
            "La habitación ya está incluida en esta reserva.");

    // Policy checks availability and maintenance status
    policy.EnsureHabitacionDisponible(
        habitacionId, FechaEntrada, FechaSalida,
        ReservaId == 0 ? null : ReservaId);

    policy.EnsureHabitacionNoEnMantenimiento(
        habitacionId, FechaEntrada, FechaSalida);

    var tarifa = policy.GetTarifaAplicada(habitacionId, FechaEntrada);

    _habitaciones.Add(new DetalleReserva(ReservaId, habitacionId, tarifa));

    if (_servicios.Count > 0)
        RecalcularSnapshots(policy);
}

public void QuitarHabitacion(int habitacionId, IReservaDomainPolicy policy)
{
    Guard.AgainstNull(policy, nameof(policy));
    EnsureEditable();

    var detalle = _habitaciones.FirstOrDefault(h => h.HabitacionId == habitacionId)
        ?? throw new NotFoundException("DetalleReserva", habitacionId.ToString());

    _habitaciones.Remove(detalle);

    if (_servicios.Count > 0)
        RecalcularSnapshots(policy);
}

Habitacion Entity

Rooms track their state changes over time through a temporal pattern:
public sealed class Habitacion : EntityBase
{
    public int HabitacionId { get; private set; }
    public int CategoriaHabitacionId { get; private set; }
    public int NumeroHabitacion { get; private set; }
    public int Piso { get; private set; }

    // Private collection - only modified through CambiarEstado()
    private readonly List<HabitacionHistorial> _historial = [];
    public IReadOnlyCollection<HabitacionHistorial> Historial => _historial;

    // Current state is the record with FechaFin == null
    public HabitacionHistorial? EstadoActual
        => _historial.FirstOrDefault(h => h.FechaFin is null);

    public Habitacion(int categoriaHabitacionId, int numeroHabitacion, int piso)
    {
        Guard.AgainstOutOfRange(categoriaHabitacionId, nameof(categoriaHabitacionId), 0);
        Guard.AgainstOutOfRange(numeroHabitacion, nameof(numeroHabitacion), 0);
        Guard.AgainstOutOfRange(piso, nameof(piso), 0);

        CategoriaHabitacionId = categoriaHabitacionId;
        NumeroHabitacion = numeroHabitacion;
        Piso = piso;

        _historial.Add(new HabitacionHistorial(HabitacionId, EstadoHabitacion.Disponible, null));
    }

    public void CambiarEstado(EstadoHabitacion nuevoEstado, string? motivo = null)
    {
        if (EstadoActual?.EstadoHabitacion == nuevoEstado)
            throw new BusinessRuleViolationException(
                $"La habitación ya se encuentra en estado {nuevoEstado}.");

        // Close current record
        EstadoActual?.Cerrar();

        // Create new active record
        _historial.Add(new HabitacionHistorial(HabitacionId, nuevoEstado, motivo));
    }
}
The temporal pattern allows tracking when and why room states changed, crucial for audit trails.

Cliente Entity

public sealed class Cliente : EntityBase
{
    public int ClienteId { get; private set; }
    public string NationalId { get; private set; } = default!;
    public string NombreCliente { get; private set; } = default!;
    public string ApellidoCliente { get; private set; } = default!;
    public string Email { get; private set; } = default!;
    public string Telefono { get; private set; } = default!;

    public Cliente(
        string nationalId,
        string nombreCliente,
        string apellidoCliente,
        string email,
        string telefono)
    {
        Guard.AgainstNullOrWhiteSpace(nationalId, nameof(nationalId), 20);
        Guard.AgainstNullOrWhiteSpace(nombreCliente, nameof(nombreCliente), 100);
        Guard.AgainstNullOrWhiteSpace(apellidoCliente, nameof(apellidoCliente), 100);
        Guard.AgainstNullOrWhiteSpace(email, nameof(email), 100);
        Guard.AgainstNullOrWhiteSpace(telefono, nameof(telefono), 20);

        NationalId = nationalId;
        NombreCliente = nombreCliente;
        ApellidoCliente = apellidoCliente;
        Email = email;
        Telefono = telefono;
    }

    public void ActualizarDatos(
        string nombreCliente,
        string apellidoCliente,
        string email,
        string telefono)
    {
        Guard.AgainstNullOrWhiteSpace(nombreCliente, nameof(nombreCliente), 100);
        Guard.AgainstNullOrWhiteSpace(apellidoCliente, nameof(apellidoCliente), 100);
        Guard.AgainstNullOrWhiteSpace(email, nameof(email), 100);
        Guard.AgainstNullOrWhiteSpace(telefono, nameof(telefono), 20);

        NombreCliente = nombreCliente;
        ApellidoCliente = apellidoCliente;
        Email = email;
        Telefono = telefono;
    }
}

ServicioAdicional Entity

public sealed class ServicioAdicional : EntityBase
{
    public int ServicioAdicionalId { get; private set; }
    public string NombreServicio { get; private set; } = default!;
    public string TipoServicio { get; private set; } = default!;

    private readonly List<int> _temporadaIds = [];
    public IReadOnlyCollection<int> TemporadaIds => _temporadaIds;

    public void HabilitarEnTemporada(int temporadaId)
    {
        Guard.AgainstOutOfRange(temporadaId, nameof(temporadaId), 0);

        if (_temporadaIds.Contains(temporadaId))
            throw new ConflictException(
                $"El servicio ya está habilitado para la temporada {temporadaId}.");

        _temporadaIds.Add(temporadaId);
    }

    public bool EstaDisponibleEn(int? temporadaId)
    {
        if (temporadaId is null) return true;
        return _temporadaIds.Contains(temporadaId.Value);
    }
}

Domain Policies

Complex cross-aggregate business rules are delegated to policy interfaces:
public interface IReservaDomainPolicy
{
    // Get season for entry date (can be null if no season)
    int? GetTemporadaId(DateTime fechaEntrada);

    // Validate room availability for date range and current reservation
    void EnsureHabitacionDisponible(int habitacionId, DateTime fechaEntrada, 
                                     DateTime fechaSalida, int? reservaId);

    // Validate room is NOT in maintenance for date range
    void EnsureHabitacionNoEnMantenimiento(int habitacionId, DateTime fechaEntrada, 
                                           DateTime fechaSalida);

    // Get applicable rate per room for date (season + category)
    decimal GetTarifaAplicada(int habitacionId, DateTime fechaEntrada);

    // Validate service availability for season
    void EnsureServicioDisponibleEnTemporada(int servicioAdicionalId, int? temporadaId);

    // Get unit price for service in a reservation (MAX price by category)
    decimal GetPrecioServicioAplicado(int reservaId, int servicioAdicionalId);
}
Policies are interfaces defined in Domain but implemented in Application or Infrastructure layers. This preserves dependency inversion.

Repository Abstractions

Repository interfaces are defined in the Domain layer:
public interface IRepository<T, TKey> where T : class
{
    Task<T?> GetByIdAsync(TKey id, CancellationToken ct = default);
    Task<List<T>> GetAllAsync(CancellationToken ct = default);
    Task AddAsync(T entity, CancellationToken ct = default);
    void Update(T entity);
    void Delete(T entity);
}

public interface IReservaRepository : IRepository<Reserva, int>
{
    Task<Reserva?> GetByIdWithDetallesAsync(int reservaId, CancellationToken ct = default);
    Task<List<Reserva>> GetByClienteAsync(int clienteId, CancellationToken ct = default);
}

Domain Enums

public enum EstadoReserva
{
    Pendiente,
    Confirmada,
    Cancelada,
    Finalizada
}

Domain Exceptions

Custom exceptions for domain violations:
public class ValidationException : Exception
public class BusinessRuleViolationException : Exception
public class NotFoundException : Exception
public class ConflictException : Exception

Key Design Decisions

Encapsulation

Private setters and collections enforce invariants through methods only

Guard Clauses

Input validation happens at entity construction and method calls

Temporal Patterns

State changes are tracked historically for audit compliance

Policy Pattern

Complex rules delegated to policies to avoid circular dependencies

Application Layer

See how use cases orchestrate domain entities

Infrastructure Layer

Learn about persistence implementations

Repository Pattern

Deep dive into data access abstraction

Build docs developers (and LLMs) love