Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Programación Orientada a Objetos

Excepciones Orientadas a Objetos

Diseño de Jerarquías y Contratos de Error

Universidad Nacional de Rio Negro - Sede Andina

Este capítulo es una continuación de Excepciones en Java, donde vimos los fundamentos del manejo de errores, la sintaxis try-catch-finally, y la diferencia entre excepciones checked y unchecked. Ahora profundizamos en cómo las excepciones son objetos y cómo diseñar sistemas de excepciones que aprovechen la orientación a objetos.

Este capítulo cubre:

  1. Excepciones como objetos: Herencia, polimorfismo y composición

  2. Diseño de jerarquías: Crear familias de excepciones de dominio

  3. Excepciones y contratos: Precondiciones, postcondiciones e invariantes

  4. Patrones avanzados: Encadenamiento, wrapping y traducción

  5. Excepciones en interfaces: El principio de sustitución

  6. Antipatrones y refactoring: Mejorando código existente


Las Excepciones son Objetos

En Excepciones en Java vimos que las excepciones son objetos que contienen información sobre errores. Ahora profundizamos en las implicaciones de esto desde la perspectiva de la orientación a objetos.

Throwable: La Raíz de la Jerarquía

Todas las excepciones heredan de Throwable, que define el contrato básico:

public class Throwable {
    private String message;           // Descripción del error
    private Throwable cause;          // Excepción que causó esta
    private StackTraceElement[] trace; // Pila de llamadas
    
    public Throwable() { }
    public Throwable(String message) { }
    public Throwable(String message, Throwable cause) { }
    public Throwable(Throwable cause) { }
    
    public String getMessage() { ... }
    public Throwable getCause() { ... }
    public void printStackTrace() { ... }
    public StackTraceElement[] getStackTrace() { ... }
}

Herencia en Excepciones

Como cualquier clase, las excepciones pueden:

// IOException es padre de FileNotFoundException
try {
    leerArchivo("datos.txt");
} catch (IOException e) {
    // Atrapa IOException Y FileNotFoundException (polimorfismo)
    System.out.println("Error de I/O: " + e.getMessage());
}

Excepciones con Estado

Las excepciones pueden tener atributos propios que proveen contexto adicional:

public class SaldoInsuficienteException extends RuntimeException {
    private final double saldoActual;
    private final double montoSolicitado;
    
    public SaldoInsuficienteException(double saldoActual, double montoSolicitado) {
        super(String.format(
            "Saldo insuficiente. Disponible: $%.2f, Solicitado: $%.2f",
            saldoActual, montoSolicitado
        ));
        this.saldoActual = saldoActual;
        this.montoSolicitado = montoSolicitado;
    }
    
    public double getSaldoActual() { return saldoActual; }
    public double getMontoSolicitado() { return montoSolicitado; }
    public double getDeficit() { return montoSolicitado - saldoActual; }
}

El código que atrapa puede usar estos métodos:

try {
    cuenta.retirar(1000);
} catch (SaldoInsuficienteException e) {
    System.out.println(e.getMessage());
    System.out.println("Te faltan: $" + e.getDeficit());
    // Podemos tomar decisiones basadas en el déficit
    if (e.getDeficit() < 100) {
        sugerirTransferencia(e.getDeficit());
    }
}

(diseño-jerarquias)=

Diseño de Jerarquías de Excepciones

El Patrón de Excepción Base de Dominio

Para sistemas complejos, es útil crear una excepción base para todo el dominio:

/**
 * Excepción base para todos los errores del sistema bancario.
 * Permite capturar cualquier error bancario con un solo catch.
 */
public abstract class BancoException extends Exception {
    private final String codigoError;
    private final LocalDateTime timestamp;
    
    protected BancoException(String mensaje, String codigoError) {
        super(mensaje);
        this.codigoError = codigoError;
        this.timestamp = LocalDateTime.now();
    }
    
    protected BancoException(String mensaje, String codigoError, Throwable causa) {
        super(mensaje, causa);
        this.codigoError = codigoError;
        this.timestamp = LocalDateTime.now();
    }
    
    public String getCodigoError() { return codigoError; }
    public LocalDateTime getTimestamp() { return timestamp; }
    
    /**
     * Indica si el error es recuperable (el usuario puede reintentar).
     */
    public abstract boolean esRecuperable();
}

Excepciones Específicas del Dominio

Las subclases representan errores específicos:

public class CuentaNoEncontradaException extends BancoException {
    private final String numeroCuenta;
    
    public CuentaNoEncontradaException(String numeroCuenta) {
        super("Cuenta no encontrada: " + numeroCuenta, "BANCO-001");
        this.numeroCuenta = numeroCuenta;
    }
    
    public String getNumeroCuenta() { return numeroCuenta; }
    
    @Override
    public boolean esRecuperable() {
        return true; // El usuario puede corregir el número
    }
}

public class LimiteExcedidoException extends BancoException {
    private final double limite;
    private final double montoIntentado;
    
    public LimiteExcedidoException(double limite, double montoIntentado) {
        super(String.format("Límite excedido. Máximo: $%.2f, Intentado: $%.2f",
                           limite, montoIntentado), "BANCO-002");
        this.limite = limite;
        this.montoIntentado = montoIntentado;
    }
    
    public double getLimite() { return limite; }
    public double getMontoIntentado() { return montoIntentado; }
    
    @Override
    public boolean esRecuperable() {
        return true; // El usuario puede intentar con monto menor
    }
}

public class FraudeDetectadoException extends BancoException {
    private final String motivo;
    
    public FraudeDetectadoException(String motivo) {
        super("Operación bloqueada por seguridad: " + motivo, "BANCO-999");
        this.motivo = motivo;
    }
    
    public String getMotivo() { return motivo; }
    
    @Override
    public boolean esRecuperable() {
        return false; // Requiere intervención del banco
    }
}

Manejo Polimórfico

La jerarquía permite diferentes niveles de especificidad:

public void realizarTransferencia(String origen, String destino, double monto) {
    try {
        banco.transferir(origen, destino, monto);
        System.out.println("Transferencia exitosa");
        
    } catch (CuentaNoEncontradaException e) {
        // Manejo específico: sugerir verificar el número
        System.out.println("Verificá el número de cuenta: " + e.getNumeroCuenta());
        
    } catch (LimiteExcedidoException e) {
        // Manejo específico: sugerir monto menor
        System.out.println("Podés transferir hasta $" + e.getLimite());
        
    } catch (FraudeDetectadoException e) {
        // Manejo específico: contactar al banco
        System.out.println("Contactá al banco: " + e.getCodigoError());
        bloquearCuenta(origen);
        
    } catch (BancoException e) {
        // Manejo genérico para cualquier otra excepción bancaria
        System.out.println("Error bancario: " + e.getMessage());
        if (e.esRecuperable()) {
            System.out.println("Podés intentar de nuevo");
        }
    }
}

Jerarquía de Ejemplo Completa


Excepciones y Contratos

Las excepciones son parte fundamental del contrato de una clase o método. Documentan qué puede salir mal y bajo qué condiciones.

Precondiciones y Excepciones

Las precondiciones son condiciones que deben cumplirse antes de ejecutar un método. Cuando se violan, lanzamos excepciones:

/**
 * Retira dinero de la cuenta.
 * 
 * @param monto El monto a retirar
 * @throws IllegalArgumentException si monto <= 0 (precondición)
 * @throws SaldoInsuficienteException si monto > saldo (precondición)
 * @throws CuentaBloqueadaException si la cuenta está bloqueada (precondición)
 */
public void retirar(double monto) throws SaldoInsuficienteException, CuentaBloqueadaException {
    // Validar precondiciones (fail fast)
    if (monto <= 0) {
        throw new IllegalArgumentException(
            "El monto debe ser positivo, se recibió: " + monto
        );
    }
    if (bloqueada) {
        throw new CuentaBloqueadaException(this.numero, this.motivoBloqueo);
    }
    if (monto > saldo) {
        throw new SaldoInsuficienteException(saldo, monto);
    }
    
    // Lógica principal (solo si las precondiciones se cumplen)
    saldo -= monto;
    registrarMovimiento(TipoMovimiento.RETIRO, monto);
}

Excepciones en Interfaces

Cuando definís una interfaz, las excepciones son parte del contrato:

public interface Repositorio<T> {
    /**
     * Busca una entidad por su ID.
     * 
     * @param id El identificador de la entidad
     * @return La entidad encontrada
     * @throws EntidadNoEncontradaException si no existe entidad con ese ID
     * @throws RepositorioException si hay error de acceso a datos
     */
    T buscar(String id) throws EntidadNoEncontradaException, RepositorioException;
    
    /**
     * Guarda una entidad.
     * 
     * @param entidad La entidad a guardar (no puede ser null)
     * @throws IllegalArgumentException si entidad es null
     * @throws DuplicadoException si ya existe una entidad con el mismo ID
     * @throws RepositorioException si hay error de acceso a datos
     */
    void guardar(T entidad) throws DuplicadoException, RepositorioException;
}

El Principio de Sustitución y Excepciones

El Principio de Sustitución de Liskov (ver Principio de Sustitución de Liskov (L)) tiene implicaciones para las excepciones:

public interface Servicio {
    void procesar() throws IOException;
}

// ✓ CORRECTO: declara la misma excepción
public class ServicioA implements Servicio {
    @Override
    public void procesar() throws IOException { }
}

// ✓ CORRECTO: declara una subclase de IOException
public class ServicioB implements Servicio {
    @Override
    public void procesar() throws FileNotFoundException { }
}

// ✓ CORRECTO: no declara ninguna excepción
public class ServicioC implements Servicio {
    @Override
    public void procesar() { }
}

// ❌ ERROR: SQLException no es subclase de IOException
public class ServicioD implements Servicio {
    @Override
    public void procesar() throws SQLException { } // No compila
}

¿Por qué esta restricción? El código cliente que usa la interfaz espera solo IOException. Si una implementación pudiera lanzar SQLException, el cliente no estaría preparado para manejarla.


Traducción de Excepciones

El Problema de las Capas

En arquitecturas por capas, las excepciones de capas inferiores no deberían escapar a capas superiores:

Principio: Cada capa traduce excepciones técnicas a excepciones de su nivel de abstracción.

Encadenamiento de Excepciones

La solución es traducir excepciones, preservando la causa original:

public class RepositorioUsuarios implements Repositorio<Usuario> {
    private final Connection conexion;
    
    @Override
    public Usuario buscar(String id) throws RepositorioException {
        try {
            PreparedStatement stmt = conexion.prepareStatement(
                "SELECT * FROM usuarios WHERE id = ?"
            );
            stmt.setString(1, id);
            ResultSet rs = stmt.executeQuery();
            
            if (rs.next()) {
                return mapearUsuario(rs);
            }
            throw new EntidadNoEncontradaException("Usuario", id);
            
        } catch (SQLException e) {
            // Traducir excepción técnica a excepción de dominio
            // Preservamos la causa original para debugging
            throw new RepositorioException(
                "Error al buscar usuario: " + id, 
                e  // ← La SQLException original queda como causa
            );
        }
    }
}

Accediendo a la Causa

El código que maneja puede acceder a toda la cadena:

try {
    Usuario usuario = repositorio.buscar("123");
} catch (RepositorioException e) {
    System.out.println("Error de repositorio: " + e.getMessage());
    
    // Acceder a la causa original (para logging/debugging)
    Throwable causa = e.getCause();
    if (causa instanceof SQLException) {
        SQLException sql = (SQLException) causa;
        System.out.println("Error SQL: " + sql.getSQLState());
    }
    
    // El stack trace muestra toda la cadena
    e.printStackTrace();
}

Patrón de Traducción Completo

public class ServicioPedidos {
    private final RepositorioPedidos repoPedidos;
    private final ServicioPagos servicioPagos;
    
    public Pedido crearPedido(Cliente cliente, List<Item> items) 
            throws PedidoException {
        
        try {
            // Crear pedido
            Pedido pedido = new Pedido(cliente, items);
            
            // Persistir
            repoPedidos.guardar(pedido);
            
            // Procesar pago
            servicioPagos.cobrar(cliente, pedido.getTotal());
            
            return pedido;
            
        } catch (RepositorioException e) {
            // Traducir error de repositorio
            throw new PedidoException(
                "Error al guardar pedido", "PED-001", e
            );
        } catch (PagoException e) {
            // Traducir error de pago
            throw new PedidoException(
                "Error al procesar pago: " + e.getMessage(), "PED-002", e
            );
        }
    }
}

Excepciones Personalizadas: Guía Completa

Anatomía de una Excepción Bien Diseñada

Una excepción personalizada debe seguir ciertos patrones:

public class ValidacionException extends RuntimeException {
    
    // 1. Atributos inmutables (final) para información del error
    private final String campo;
    private final Object valorRechazado;
    private final String restriccion;
    
    // 2. Constructores que cubren los casos de uso comunes
    public ValidacionException(String campo, Object valor, String restriccion) {
        super(construirMensaje(campo, valor, restriccion));
        this.campo = campo;
        this.valorRechazado = valor;
        this.restriccion = restriccion;
    }
    
    public ValidacionException(String campo, Object valor, String restriccion, 
                               Throwable causa) {
        super(construirMensaje(campo, valor, restriccion), causa);
        this.campo = campo;
        this.valorRechazado = valor;
        this.restriccion = restriccion;
    }
    
    // 3. Método auxiliar para construir mensaje descriptivo
    private static String construirMensaje(String campo, Object valor, 
                                           String restriccion) {
        return String.format(
            "Validación fallida en '%s': valor '%s' no cumple '%s'",
            campo, valor, restriccion
        );
    }
    
    // 4. Getters para acceso a la información
    public String getCampo() { return campo; }
    public Object getValorRechazado() { return valorRechazado; }
    public String getRestriccion() { return restriccion; }
    
    // 5. Métodos de conveniencia opcionales
    public boolean esCampo(String nombreCampo) {
        return campo.equals(nombreCampo);
    }
}

¿Checked o Unchecked?

La decisión entre checked y unchecked depende de la semántica:

Usar Unchecked (extends RuntimeException) cuando:

// Unchecked: error de programación
public class EmailInvalidoException extends RuntimeException {
    public EmailInvalidoException(String email) {
        super("Email inválido: " + email);
    }
}

Usar Checked (extends Exception) cuando:

// Checked: situación esperada que requiere manejo
public class UsuarioNoEncontradoException extends Exception {
    private final String username;
    
    public UsuarioNoEncontradoException(String username) {
        super("Usuario no encontrado: " + username);
        this.username = username;
    }
    
    public String getUsername() { return username; }
}

Factory Methods para Excepciones

Para excepciones con múltiples causas posibles, los métodos factory clarifican la intención:

public class ConexionException extends Exception {
    
    public enum Tipo { TIMEOUT, RECHAZADA, AUTENTICACION, DESCONOCIDO }
    
    private final Tipo tipo;
    private final String servidor;
    
    private ConexionException(String mensaje, Tipo tipo, String servidor, 
                              Throwable causa) {
        super(mensaje, causa);
        this.tipo = tipo;
        this.servidor = servidor;
    }
    
    // Factory methods con nombres descriptivos
    public static ConexionException porTimeout(String servidor, int segundos) {
        return new ConexionException(
            String.format("Timeout conectando a %s después de %d segundos", 
                         servidor, segundos),
            Tipo.TIMEOUT, servidor, null
        );
    }
    
    public static ConexionException porRechazo(String servidor, String motivo) {
        return new ConexionException(
            String.format("Conexión rechazada por %s: %s", servidor, motivo),
            Tipo.RECHAZADA, servidor, null
        );
    }
    
    public static ConexionException porAutenticacion(String servidor, 
                                                      String usuario) {
        return new ConexionException(
            String.format("Autenticación fallida en %s para usuario %s", 
                         servidor, usuario),
            Tipo.AUTENTICACION, servidor, null
        );
    }
    
    public static ConexionException envolver(String servidor, Exception causa) {
        return new ConexionException(
            "Error de conexión a " + servidor + ": " + causa.getMessage(),
            Tipo.DESCONOCIDO, servidor, causa
        );
    }
    
    public Tipo getTipo() { return tipo; }
    public String getServidor() { return servidor; }
    
    public boolean esRecuperable() {
        return tipo == Tipo.TIMEOUT || tipo == Tipo.RECHAZADA;
    }
}

Uso:

// Código claro y expresivo
throw ConexionException.porTimeout("db.ejemplo.com", 30);
throw ConexionException.porAutenticacion("api.servicio.com", "admin");

// Manejo basado en tipo
try {
    conectar(servidor);
} catch (ConexionException e) {
    if (e.esRecuperable()) {
        System.out.println("Reintentando...");
        reintentar(e.getServidor());
    } else {
        notificarAdministrador(e);
    }
}

Excepciones y Gestión de Recursos

AutoCloseable como Contrato

La interfaz AutoCloseable define un contrato para recursos que deben cerrarse:

public interface AutoCloseable {
    /**
     * Cierra este recurso, liberando cualquier recurso subyacente.
     * 
     * @throws Exception si no se puede cerrar correctamente
     */
    void close() throws Exception;
}

Implementando AutoCloseable

public class ConexionBaseDatos implements AutoCloseable {
    private Connection conexion;
    private boolean cerrada = false;
    
    public ConexionBaseDatos(String url, String usuario, String password) 
            throws SQLException {
        this.conexion = DriverManager.getConnection(url, usuario, password);
    }
    
    public ResultSet ejecutarConsulta(String sql) throws SQLException {
        verificarAbierta();
        Statement stmt = conexion.createStatement();
        return stmt.executeQuery(sql);
    }
    
    private void verificarAbierta() {
        if (cerrada) {
            throw new IllegalStateException("Conexión ya cerrada");
        }
    }
    
    @Override
    public void close() throws SQLException {
        if (!cerrada) {
            cerrada = true;
            conexion.close();
        }
    }
}

Uso con try-with-resources:

try (ConexionBaseDatos db = new ConexionBaseDatos(url, user, pass)) {
    ResultSet rs = db.ejecutarConsulta("SELECT * FROM usuarios");
    while (rs.next()) {
        System.out.println(rs.getString("nombre"));
    }
}  // db.close() se llama automáticamente, incluso si hay excepción

Suppressed Exceptions

Cuando el bloque try lanza una excepción Y el close también falla, Java “suprime” la segunda excepción pero la preserva:

public class RecursoProblematico implements AutoCloseable {
    @Override
    public void close() {
        throw new RuntimeException("Error al cerrar");
    }
}

try (RecursoProblematico r = new RecursoProblematico()) {
    throw new RuntimeException("Error en operación");
} catch (RuntimeException e) {
    System.out.println("Principal: " + e.getMessage());
    // Error en operación
    
    Throwable[] suprimidas = e.getSuppressed();
    for (Throwable t : suprimidas) {
        System.out.println("Suprimida: " + t.getMessage());
        // Error al cerrar
    }
}

Antipatrones y Cómo Evitarlos

1. Catch Genérico

// ❌ MAL: atrapa todo, incluso bugs
try {
    procesarPedido(pedido);
} catch (Exception e) {
    logger.error("Error", e);
}

// ✓ BIEN: específico
try {
    procesarPedido(pedido);
} catch (ProductoNoDisponibleException e) {
    notificarCliente(e.getProducto());
} catch (PagoRechazadoException e) {
    solicitarOtroPago(e.getMotivo());
}

2. Excepción Silenciada

// ❌ MAL: el error desaparece
try {
    guardar(datos);
} catch (IOException e) {
    // Vacío - nadie sabe que falló
}

// ✓ BIEN: al menos loggear
try {
    guardar(datos);
} catch (IOException e) {
    logger.warn("No se pudo guardar: {}", e.getMessage());
    // Y decidir qué hacer: reintentar, usar cache, etc.
}

3. Throw en Finally

// ❌ MAL: oculta la excepción original
try {
    return calcular();
} finally {
    throw new RuntimeException("Limpieza fallida");
    // La excepción de calcular() se pierde
}

// ✓ BIEN: loggear errores de limpieza
try {
    return calcular();
} finally {
    try {
        limpiar();
    } catch (Exception e) {
        logger.error("Error en limpieza", e);
    }
}

4. Excepciones para Control de Flujo

// ❌ MAL: excepción como goto
public int buscarIndice(int[] arr, int valor) {
    try {
        for (int i = 0; ; i++) {  // Sin condición de fin
            if (arr[i] == valor) return i;
        }
    } catch (ArrayIndexOutOfBoundsException e) {
        return -1;  // "Fin" del arreglo
    }
}

// ✓ BIEN: condición explícita
public int buscarIndice(int[] arr, int valor) {
    for (int i = 0; i < arr.length; i++) {
        if (arr[i] == valor) return i;
    }
    return -1;
}

5. Retornar null en Lugar de Excepción

// ❌ MAL: null se propaga y causa NPE después
public Usuario buscarUsuario(String id) {
    try {
        return repositorio.buscar(id);
    } catch (SQLException e) {
        return null;  // El llamador no sabe que hubo error
    }
}

// ✓ BIEN: excepción clara
public Usuario buscarUsuario(String id) throws RepositorioException {
    try {
        return repositorio.buscar(id);
    } catch (SQLException e) {
        throw new RepositorioException("Error buscando usuario", e);
    }
}

// ✓ También bien: Optional para ausencia esperada
public Optional<Usuario> buscarUsuario(String id) {
    return Optional.ofNullable(repositorio.buscar(id));
}

6. Log and Throw

// ❌ MAL: el error se loggea múltiples veces
try {
    procesar();
} catch (Exception e) {
    logger.error("Error", e);  // Log aquí
    throw e;  // Y el que atrapa también loggea
}

// ✓ BIEN: una cosa u otra
try {
    procesar();
} catch (Exception e) {
    throw new ProcesamientoException("Falló procesamiento", e);
    // El que atrapa final decide si loggear
}

Ejemplo: Sistema de Reservas

Un ejemplo completo que muestra todos los conceptos de excepciones OOP en acción.

Jerarquía de Excepciones

// Excepción base del dominio
public abstract class ReservaException extends Exception {
    private final String codigoError;
    private final LocalDateTime timestamp;
    
    protected ReservaException(String mensaje, String codigo) {
        super(mensaje);
        this.codigoError = codigo;
        this.timestamp = LocalDateTime.now();
    }
    
    protected ReservaException(String mensaje, String codigo, Throwable causa) {
        super(mensaje, causa);
        this.codigoError = codigo;
        this.timestamp = LocalDateTime.now();
    }
    
    public String getCodigoError() { return codigoError; }
    public LocalDateTime getTimestamp() { return timestamp; }
    public abstract boolean esRecuperable();
}

// Excepciones específicas
public class HabitacionNoDisponibleException extends ReservaException {
    private final int numeroHabitacion;
    private final LocalDate fechaDeseada;
    private final List<LocalDate> alternativas;
    
    public HabitacionNoDisponibleException(int numero, LocalDate fecha,
                                           List<LocalDate> alternativas) {
        super(String.format("Habitación %d no disponible para %s", numero, fecha),
              "RES-001");
        this.numeroHabitacion = numero;
        this.fechaDeseada = fecha;
        this.alternativas = List.copyOf(alternativas);
    }
    
    public int getNumeroHabitacion() { return numeroHabitacion; }
    public LocalDate getFechaDeseada() { return fechaDeseada; }
    public List<LocalDate> getAlternativas() { return alternativas; }
    
    @Override
    public boolean esRecuperable() {
        return !alternativas.isEmpty();
    }
}

public class ClienteBloqueadoException extends ReservaException {
    private final String clienteId;
    private final String motivo;
    
    public ClienteBloqueadoException(String clienteId, String motivo) {
        super("Cliente bloqueado: " + motivo, "RES-002");
        this.clienteId = clienteId;
        this.motivo = motivo;
    }
    
    public String getClienteId() { return clienteId; }
    public String getMotivo() { return motivo; }
    
    @Override
    public boolean esRecuperable() {
        return false; // Requiere intervención manual
    }
}

public class PagoFallidoException extends ReservaException {
    private final double monto;
    private final String metodoPago;
    private final boolean reintentable;
    
    private PagoFallidoException(String mensaje, String codigo, double monto,
                                  String metodoPago, boolean reintentable,
                                  Throwable causa) {
        super(mensaje, codigo, causa);
        this.monto = monto;
        this.metodoPago = metodoPago;
        this.reintentable = reintentable;
    }
    
    // Factory methods
    public static PagoFallidoException porFondosInsuficientes(double monto) {
        return new PagoFallidoException(
            "Fondos insuficientes para pago de $" + monto,
            "PAG-001", monto, "tarjeta", false, null
        );
    }
    
    public static PagoFallidoException porTimeout(double monto, String metodo) {
        return new PagoFallidoException(
            "Timeout procesando pago",
            "PAG-002", monto, metodo, true, null
        );
    }
    
    public static PagoFallidoException porError(double monto, Exception causa) {
        return new PagoFallidoException(
            "Error de procesamiento: " + causa.getMessage(),
            "PAG-999", monto, "desconocido", true, causa
        );
    }
    
    public double getMonto() { return monto; }
    public String getMetodoPago() { return metodoPago; }
    
    @Override
    public boolean esRecuperable() {
        return reintentable;
    }
}

Clases de Dominio

public class Habitacion {
    private final int numero;
    private final TipoHabitacion tipo;
    private final double precioPorNoche;
    private final Set<LocalDate> fechasOcupadas;
    
    public Habitacion(int numero, TipoHabitacion tipo, double precio) {
        this.numero = numero;
        this.tipo = tipo;
        this.precioPorNoche = precio;
        this.fechasOcupadas = new HashSet<>();
    }
    
    public boolean estaDisponible(LocalDate fecha) {
        return !fechasOcupadas.contains(fecha);
    }
    
    public boolean estaDisponible(LocalDate desde, LocalDate hasta) {
        LocalDate fecha = desde;
        while (!fecha.isAfter(hasta)) {
            if (!estaDisponible(fecha)) return false;
            fecha = fecha.plusDays(1);
        }
        return true;
    }
    
    public void reservar(LocalDate desde, LocalDate hasta) {
        LocalDate fecha = desde;
        while (!fecha.isAfter(hasta)) {
            fechasOcupadas.add(fecha);
            fecha = fecha.plusDays(1);
        }
    }
    
    public void cancelarReserva(LocalDate desde, LocalDate hasta) {
        LocalDate fecha = desde;
        while (!fecha.isAfter(hasta)) {
            fechasOcupadas.remove(fecha);
            fecha = fecha.plusDays(1);
        }
    }
    
    public List<LocalDate> buscarAlternativas(LocalDate cercaA, int dias) {
        List<LocalDate> alternativas = new ArrayList<>();
        for (int offset = 1; offset <= 14 && alternativas.size() < 3; offset++) {
            LocalDate antes = cercaA.minusDays(offset);
            LocalDate despues = cercaA.plusDays(offset);
            
            if (estaDisponible(antes, antes.plusDays(dias - 1))) {
                alternativas.add(antes);
            }
            if (estaDisponible(despues, despues.plusDays(dias - 1))) {
                alternativas.add(despues);
            }
        }
        return alternativas;
    }
    
    // Getters...
    public int getNumero() { return numero; }
    public TipoHabitacion getTipo() { return tipo; }
    public double getPrecioPorNoche() { return precioPorNoche; }
}

public enum TipoHabitacion {
    SIMPLE(1), DOBLE(2), SUITE(4);
    
    private final int capacidad;
    
    TipoHabitacion(int capacidad) {
        this.capacidad = capacidad;
    }
    
    public int getCapacidad() { return capacidad; }
}

public class Cliente {
    private final String id;
    private final String nombre;
    private final String email;
    private boolean bloqueado;
    private String motivoBloqueo;
    
    public Cliente(String id, String nombre, String email) {
        this.id = id;
        this.nombre = nombre;
        this.email = email;
        this.bloqueado = false;
    }
    
    public void bloquear(String motivo) {
        this.bloqueado = true;
        this.motivoBloqueo = motivo;
    }
    
    // Getters...
    public String getId() { return id; }
    public String getNombre() { return nombre; }
    public String getEmail() { return email; }
    public boolean estaBloqueado() { return bloqueado; }
    public String getMotivoBloqueo() { return motivoBloqueo; }
}

public class Reserva implements AutoCloseable {
    private final String codigo;
    private final Cliente cliente;
    private final Habitacion habitacion;
    private final LocalDate fechaEntrada;
    private final LocalDate fechaSalida;
    private EstadoReserva estado;
    private boolean cerrada = false;
    
    public Reserva(String codigo, Cliente cliente, Habitacion habitacion,
                   LocalDate entrada, LocalDate salida) {
        this.codigo = codigo;
        this.cliente = cliente;
        this.habitacion = habitacion;
        this.fechaEntrada = entrada;
        this.fechaSalida = salida;
        this.estado = EstadoReserva.PENDIENTE;
    }
    
    public void confirmar() {
        verificarNoCerrada();
        this.estado = EstadoReserva.CONFIRMADA;
    }
    
    public void cancelar() {
        verificarNoCerrada();
        this.estado = EstadoReserva.CANCELADA;
    }
    
    public double calcularTotal() {
        long noches = ChronoUnit.DAYS.between(fechaEntrada, fechaSalida);
        return noches * habitacion.getPrecioPorNoche();
    }
    
    private void verificarNoCerrada() {
        if (cerrada) {
            throw new IllegalStateException("Reserva ya cerrada: " + codigo);
        }
    }
    
    @Override
    public void close() {
        if (!cerrada) {
            cerrada = true;
            if (estado == EstadoReserva.PENDIENTE) {
                // Liberar habitación si no se confirmó
                habitacion.cancelarReserva(fechaEntrada, fechaSalida);
            }
        }
    }
    
    // Getters...
    public String getCodigo() { return codigo; }
    public Cliente getCliente() { return cliente; }
    public Habitacion getHabitacion() { return habitacion; }
    public EstadoReserva getEstado() { return estado; }
}

public enum EstadoReserva {
    PENDIENTE, CONFIRMADA, CANCELADA
}

Servicio de Reservas

public class ServicioReservas {
    private final RepositorioHabitaciones habitaciones;
    private final ServicioPagos pagos;
    private final ServicioNotificaciones notificaciones;
    private int contadorReservas = 0;
    
    public ServicioReservas(RepositorioHabitaciones habitaciones,
                            ServicioPagos pagos,
                            ServicioNotificaciones notificaciones) {
        this.habitaciones = habitaciones;
        this.pagos = pagos;
        this.notificaciones = notificaciones;
    }
    
    /**
     * Crea una nueva reserva.
     * 
     * @throws IllegalArgumentException si los parámetros son inválidos
     * @throws ClienteBloqueadoException si el cliente está bloqueado
     * @throws HabitacionNoDisponibleException si no hay disponibilidad
     * @throws PagoFallidoException si el pago no se procesa
     */
    public Reserva crearReserva(Cliente cliente, int numeroHabitacion,
                                 LocalDate entrada, LocalDate salida) 
            throws ReservaException {
        
        // Validar precondiciones
        validarParametros(cliente, entrada, salida);
        
        // Verificar cliente
        if (cliente.estaBloqueado()) {
            throw new ClienteBloqueadoException(
                cliente.getId(), cliente.getMotivoBloqueo()
            );
        }
        
        // Buscar habitación
        Habitacion habitacion = habitaciones.buscar(numeroHabitacion);
        
        // Verificar disponibilidad
        if (!habitacion.estaDisponible(entrada, salida)) {
            int dias = (int) ChronoUnit.DAYS.between(entrada, salida);
            List<LocalDate> alternativas = habitacion.buscarAlternativas(entrada, dias);
            throw new HabitacionNoDisponibleException(
                numeroHabitacion, entrada, alternativas
            );
        }
        
        // Crear reserva provisional
        String codigo = generarCodigo();
        Reserva reserva = new Reserva(codigo, cliente, habitacion, entrada, salida);
        habitacion.reservar(entrada, salida);
        
        // Procesar pago (con rollback si falla)
        try {
            pagos.cobrar(cliente, reserva.calcularTotal());
        } catch (PagoFallidoException e) {
            // Rollback: liberar habitación
            habitacion.cancelarReserva(entrada, salida);
            throw e;
        }
        
        // Confirmar y notificar
        reserva.confirmar();
        notificaciones.enviarConfirmacion(cliente.getEmail(), reserva);
        
        return reserva;
    }
    
    private void validarParametros(Cliente cliente, LocalDate entrada, 
                                    LocalDate salida) {
        Objects.requireNonNull(cliente, "Cliente no puede ser null");
        Objects.requireNonNull(entrada, "Fecha de entrada no puede ser null");
        Objects.requireNonNull(salida, "Fecha de salida no puede ser null");
        
        if (entrada.isBefore(LocalDate.now())) {
            throw new IllegalArgumentException(
                "Fecha de entrada no puede ser pasada: " + entrada
            );
        }
        if (!salida.isAfter(entrada)) {
            throw new IllegalArgumentException(
                "Fecha de salida debe ser posterior a entrada"
            );
        }
    }
    
    private String generarCodigo() {
        return "RES-" + String.format("%06d", ++contadorReservas);
    }
}

Uso del Sistema

public class AplicacionReservas {
    
    public static void main(String[] args) {
        // Configurar sistema
        RepositorioHabitaciones habitaciones = new RepositorioHabitacionesMemoria();
        ServicioPagos pagos = new ServicioPagosSimulado();
        ServicioNotificaciones notifs = new ServicioNotificacionesConsola();
        
        ServicioReservas servicio = new ServicioReservas(habitaciones, pagos, notifs);
        
        // Crear cliente
        Cliente cliente = new Cliente("CLI-001", "Ana García", "ana@email.com");
        
        // Intentar reserva
        try {
            Reserva reserva = servicio.crearReserva(
                cliente, 
                101,  // habitación
                LocalDate.of(2024, 7, 15),
                LocalDate.of(2024, 7, 20)
            );
            
            System.out.println("¡Reserva exitosa!");
            System.out.println("Código: " + reserva.getCodigo());
            System.out.println("Total: $" + reserva.calcularTotal());
            
        } catch (HabitacionNoDisponibleException e) {
            System.out.println("No hay disponibilidad: " + e.getMessage());
            
            if (e.esRecuperable()) {
                System.out.println("Fechas alternativas disponibles:");
                for (LocalDate alternativa : e.getAlternativas()) {
                    System.out.println("  - " + alternativa);
                }
            }
            
        } catch (ClienteBloqueadoException e) {
            System.out.println("No se puede procesar: " + e.getMessage());
            System.out.println("Contacte a soporte con código: " + e.getCodigoError());
            
        } catch (PagoFallidoException e) {
            System.out.println("Error de pago: " + e.getMessage());
            
            if (e.esRecuperable()) {
                System.out.println("Puede intentar nuevamente");
            } else {
                System.out.println("Verifique su método de pago");
            }
            
        } catch (ReservaException e) {
            // Catch genérico para cualquier otra excepción de reserva
            System.out.println("Error inesperado: " + e.getMessage());
            System.out.println("Código: " + e.getCodigoError());
            System.out.println("Timestamp: " + e.getTimestamp());
        }
    }
}

Resumen

Excepciones como Objetos

Diseño de Jerarquías

Excepciones y Contratos

Traducción de Excepciones

Buenas Prácticas

PrácticaDescripción
Captura específicaEvitar catch (Exception e)
No silenciarSiempre manejar o loggear
Mensajes descriptivosIncluir contexto y valores
Preservar causaUsar constructor con Throwable
Factory methodsPara excepciones con múltiples causas
AutoCloseablePara recursos que deben cerrarse

Ejercicios


Próximo paso

Para seguir, conviene pasar a el material siguiente, donde el recorrido continúa sobre esta base.