Domain-Driven Design (DDD) is a software development approach that places the business domain at the center of the software design. SGRH implements core DDD tactical patterns to model the hotel reservation system.
The domain layer (SGRH.Domain) contains the heart of the business logic - all the rules that make the hotel reservation system work correctly.
Entities are objects with a unique identity that persists over time. In SGRH, all entities inherit from EntityBase:
SGRH.Domain/Base/EntityBase.cs
namespace SGRH.Domain.Base;public abstract class EntityBase{ protected abstract object GetKey(); public override bool Equals(object? obj) { if (obj is not EntityBase other) return false; if (ReferenceEquals(this, other)) return true; if (GetType() != other.GetType()) return false; return GetKey().Equals(other.GetKey()); } public override int GetHashCode() => GetKey().GetHashCode(); public static bool operator ==(EntityBase? a, EntityBase? b) => a is null ? b is null : a.Equals(b); public static bool operator !=(EntityBase? a, EntityBase? b) => !(a == b);}
EntityBase provides identity-based equality - two entities are equal if they have the same ID, regardless of their property values.
Child entities within an aggregate have internal constructors:
SGRH.Domain/Entities/Reservas/DetalleReserva.cs
using SGRH.Domain.Base;using SGRH.Domain.Common;namespace SGRH.Domain.Entities.Reservas;public sealed class DetalleReserva : EntityBase{ public int DetalleReservaId { get; private set; } public int ReservaId { get; private set; } public int HabitacionId { get; private set; } public decimal TarifaAplicada { get; private set; } private DetalleReserva() { } // Internal constructor - only Reserva can create instances internal DetalleReserva(int reservaId, int habitacionId, decimal tarifaAplicada) { Guard.AgainstOutOfRange(habitacionId, nameof(habitacionId), 0); Guard.AgainstOutOfRange(tarifaAplicada, nameof(tarifaAplicada), 0m); ReservaId = reservaId; HabitacionId = habitacionId; TarifaAplicada = tarifaAplicada; } internal void ActualizarTarifa(decimal nuevaTarifa) { Guard.AgainstOutOfRange(nuevaTarifa, nameof(nuevaTarifa), 0m); TarifaAplicada = nuevaTarifa; } protected override object GetKey() => DetalleReservaId;}
Notice the internal constructor - only classes in SGRH.Domain can create DetalleReserva instances. This enforces that room details can only be added through the Reserva aggregate root.
using SGRH.Domain.Exceptions;namespace SGRH.Domain.Common;public static class Guard{ public static void AgainstNullOrWhiteSpace(string? value, string name, int maxLength) { if (string.IsNullOrWhiteSpace(value)) throw new ValidationException($"{name} no puede estar vacío."); if (value.Length > maxLength) throw new ValidationException($"{name} supera el máximo de {maxLength} caracteres."); } public static void AgainstNull(object? value, string name) { if (value is null) throw new ValidationException($"{name} no puede ser null."); } public static void AgainstOutOfRange(int value, string name, int minExclusive) { if (value <= minExclusive) throw new ValidationException($"{name} debe ser mayor a {minExclusive}."); } public static void AgainstInvalidDateRange(DateTime start, DateTime end, string startName, string endName) { if (start >= end) throw new ValidationException($"{startName} debe ser anterior a {endName}."); }}
namespace SGRH.Domain.Exceptions;public sealed class BusinessRuleViolationException : DomainException{ public BusinessRuleViolationException(string message) : base("BUSINESS_RULE_VIOLATION", message) { }}
Usage Example:
Reserva.cs:52
private void EnsureEditable(){ if (EstadoReserva == EstadoReserva.Confirmada) throw new BusinessRuleViolationException( "Una reserva confirmada no puede ser modificada."); if (EstadoReserva == EstadoReserva.Cancelada) throw new BusinessRuleViolationException( "Una reserva cancelada no puede ser modificada."); if (EstadoReserva == EstadoReserva.Finalizada) throw new BusinessRuleViolationException( "Una reserva finalizada no puede ser modificada.");}
namespace SGRH.Domain.Abstractions.Policies;public interface IReservaDomainPolicy{ // Get season ID for entry date (nullable if no season) int? GetTemporadaId(DateTime fechaEntrada); // Validate room availability for date range void EnsureHabitacionDisponible( int habitacionId, DateTime fechaEntrada, DateTime fechaSalida, int? reservaId); // Validate room is NOT in maintenance void EnsureHabitacionNoEnMantenimiento( int habitacionId, DateTime fechaEntrada, DateTime fechaSalida); // Get applicable rate for room and date decimal GetTarifaAplicada(int habitacionId, DateTime fechaEntrada); // Validate service availability for season void EnsureServicioDisponibleEnTemporada( int servicioAdicionalId, int? temporadaId); // Get service unit price for reservation decimal GetPrecioServicioAplicado( int reservaId, int servicioAdicionalId);}
Why a Domain Service?Checking room availability requires querying other reservations - data that a single Reserva entity doesn’t have. The domain defines the interface, but the implementation lives in the persistence layer where it can access the database.
public void Confirmar(){ if (EstadoReserva != EstadoReserva.Pendiente) throw new BusinessRuleViolationException( $"Solo una reserva Pendiente puede confirmarse. Estado actual: {EstadoReserva}."); if (_habitaciones.Count == 0) throw new BusinessRuleViolationException( "No se puede confirmar una reserva sin habitaciones."); EstadoReserva = EstadoReserva.Confirmada;}public void Cancelar(){ if (EstadoReserva == EstadoReserva.Finalizada) throw new BusinessRuleViolationException( "Una reserva finalizada no puede cancelarse."); if (EstadoReserva == EstadoReserva.Cancelada) throw new BusinessRuleViolationException( "La reserva ya está cancelada."); EstadoReserva = EstadoReserva.Cancelada;}
The Habitacion entity tracks state changes over time:
SGRH.Domain/Entities/Habitaciones/Habitacion.cs
using SGRH.Domain.Base;using SGRH.Domain.Enums;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 - history cannot be modified directly private readonly List<HabitacionHistorial> _historial = []; public IReadOnlyCollection<HabitacionHistorial> Historial => _historial; // Calculated property: returns current record (FechaFin == null) public HabitacionHistorial? EstadoActual => _historial.FirstOrDefault(h => h.FechaFin is null); private Habitacion() { } 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; // Create initial history record _historial.Add(new HabitacionHistorial( HabitacionId, EstadoHabitacion.Disponible, null)); } public void CambiarEstado(EstadoHabitacion nuevoEstado, string? motivo = null) { // Verify it's not the same current state if (EstadoActual?.EstadoHabitacion == nuevoEstado) throw new BusinessRuleViolationException( $"La habitación ya se encuentra en estado {nuevoEstado}."); // Close the current record EstadoActual?.Cerrar(); // Create new current record _historial.Add(new HabitacionHistorial( HabitacionId, nuevoEstado, motivo)); } protected override object GetKey() => HabitacionId;}
This pattern maintains a complete audit trail of all room state changes while keeping the current state easily accessible.