Todo el módulo hemos ido arrastrando una deuda, y ha llegado el momento de pagarla. Los campos de Libro, Empleado, Prestamo y Material siguen siendo public, y eso significa que cualquier línea de cualquier fichero puede escribir libro.disponible = true saltándose los avisos de prestar(), o empleado.prestamosAcumulados = -7, o asignar a un préstamo una multa inventada que no corresponde a ningún cálculo. Todo el trabajo de los constructores validando datos se desmorona en cuanto alguien puede escribir directamente en los campos. El encapsulamiento es el pilar que cierra esa puerta. Pero, atención: encapsular no es "poner un getter y un setter a cada campo" —esa receta mecánica, tan repetida, deja el objeto tan expuesto como antes con más líneas de código—. Encapsular es ocultar la representación interna y exponer solo operaciones que preserven las reglas del negocio. Esta lección te enseña la diferencia, que es la que separa un modelo de datos anémico de un modelo de dominio de verdad.

Contenido

  1. Qué es realmente encapsular
  2. Los cuatro modificadores de acceso
  3. Tabla completa de visibilidad
  4. Getters y setters: lo que la receta mecánica no cuenta
  5. Invariantes de clase
  6. Operaciones de negocio en lugar de setters
  7. Inmutabilidad
  8. El peligro de devolver referencias mutables: copia defensiva
  9. Encapsulamiento a nivel de paquete
  10. La Ley de Demeter
  11. BiblioTech: el dominio encapsulado
  12. Errores Comunes y Consejos
  13. Ejercicios

  1. Qué es realmente encapsular

Encapsular es separar lo que un objeto ofrece de cómo lo hace, de modo que el interior se pueda cambiar sin romper a nadie.

Piensa en un cajero automático. Su interfaz pública es: introducir tarjeta, teclear PIN, pedir importe, recoger billetes. Lo que hay dentro —la disposición de los casetes, el orden en que se dispensan los billetes, el protocolo con el banco— no solo está oculto, es que no debe importarte. Si mañana cambian los casetes, tú sigues sacando dinero igual.

Aplicado al código, encapsular bien produce tres beneficios concretos:

Beneficio Qué significa en la práctica
Invariantes garantizadas Si prestamosAcumulados solo puede cambiar por métodos, es imposible que sea negativo
Libertad para cambiar el interior Puedes sustituir un campo por otro cálculo sin tocar a los usuarios de la clase
Superficie pequeña de errores Si algo va mal en el estado de un objeto, el culpable está dentro de su clase, no en 40 ficheros

El segundo beneficio es el más subestimado, y merece un ejemplo. Supón que Prestamo guarda un campo multa:

public double multa;      // publico: veinte ficheros lo leen

Si un día decides que la multa no debe almacenarse sino calcularse siempre a partir de los días, tienes que modificar los veinte ficheros. En cambio, si desde el principio expones un método:

public double getMulta() {
    return Math.min(calcularDiasRetraso() * getTarifaDiaria(), MULTA_MAXIMA);
}

...puedes cambiar la implementación cuantas veces quieras: los veinte ficheros siguen llamando a getMulta() sin enterarse. El método es un contrato; el campo es una filtración.

  1. Los cuatro modificadores de acceso

Java ofrece cuatro niveles de visibilidad. Tres tienen palabra reservada y uno es el que se aplica cuando no escribes nada:

Modificador Palabra clave Idea en una frase
Privado private Solo dentro de la propia clase
De paquete (ninguna) Dentro del mismo paquete
Protegido protected Mismo paquete y subclases, estén donde estén
Público public Desde cualquier sitio
public class Libro {
    private   String  titulo;        // solo Libro
              String  notaInterna;   // clases del paquete dominio
    protected boolean disponible;    // paquete dominio + subclases de Libro
    public    String  getTitulo() { return titulo; }   // todos
}

Se aplican a clases, campos, métodos y constructores. Con un matiz: una clase de nivel superior solo puede ser public o de paquete; private y protected no tienen sentido ahí (sí lo tienen en clases internas, lección 04-03).

  1. Tabla completa de visibilidad

Esta es la tabla que conviene tener a mano hasta que se interiorice:

Modificador Misma clase Mismo paquete Subclase en otro paquete Cualquier clase
private No No No
(default) No No
protected No
public

Dos matices que se preguntan mucho:

Sobre protected. Incluye la visibilidad de paquete: un miembro protected es visible para todas las clases del mismo paquete, sean o no subclases. Y desde una subclase en otro paquete, solo se accede a través de una referencia del tipo de la subclase, no de la superclase. Es una regla sutil que raras veces molesta en la práctica.

Sobre private y las clases anidadas. Una clase puede acceder a los miembros private de otra clase anidada dentro de ella, y viceversa: la unidad de encapsulamiento en Java es el fichero de la clase de nivel superior, no cada clase individual. Lo verás en 04-03.

Criterio profesional de partida:

Declara todo private por defecto. Sube la visibilidad solo cuando tengas una razón concreta, y documenta esa razón.

En particular, desconfía de protected en campos: convierte cada subclase presente y futura en un cliente que depende de tu representación interna, y es exactamente el escenario de la fragilidad de la clase base que viste en 03-05. Si una subclase necesita algo, prefiere darle un método protected.

  1. Getters y setters: lo que la receta mecánica no cuenta

La receta que se enseña en todas partes es: campos private, y para cada uno un getX() y un setX(). El IDE incluso los genera solos. Y el resultado es este:

public class Empleado {
    private String nombre;
    private int    prestamosAcumulados;

    public String getNombre()             { return nombre; }
    public void   setNombre(String n)     { this.nombre = n; }
    public int    getPrestamosAcumulados() { return prestamosAcumulados; }
    public void   setPrestamosAcumulados(int p) { this.prestamosAcumulados = p; }
}

Pregúntate qué se ha ganado respecto a tener los campos públicos. La respuesta honesta: casi nada.

empleado.setPrestamosAcumulados(-7);    // sigue siendo posible
empleado.setPrestamosAcumulados(9999);  // se salta el limite de 3
empleado.setNombre(null);               // adios a la validacion del constructor

Un setter sin validación es un campo público con dos líneas más. Peor aún: da la falsa sensación de estar encapsulando.

El criterio correcto es preguntarse, campo por campo, dos cosas:

  1. ¿Alguien de fuera necesita leer este dato? Si no, no hay getter.
  2. ¿Tiene sentido que alguien de fuera lo cambie a un valor arbitrario? Si no —y casi nunca lo tiene—, no hay setter: hay una operación de negocio.

Aplicado a BiblioTech:

Campo ¿Getter? ¿Setter? Por qué
Libro.titulo No El título de un libro no cambia
Libro.isbn No Es su identidad
Material.disponible Sí, como estaDisponible() No Cambia solo mediante prestar() / devolver()
Empleado.nombre No Dato identitario
Empleado.prestamosAcumulados No Cambia mediante registrarPrestamo() / registrarDevolucion()
Prestamo.diasTranscurridos Sí, validado Sí evoluciona con el tiempo
Prestamo.multa Sí, calculado Jamás Es una consecuencia, no un dato

Esa última fila merece detenerse. Si existiera setMulta(double):

prestamo.setMulta(0.0);      // "perdono" una multa de 20 EUR sin que nadie lo sepa
prestamo.setMulta(1000.0);   // cobro mil euros por un retraso de tres dias

Toda la política de multas de Nexus Software —tarifa diaria, tope de 20 €, saturación a cero— quedaría anulada por una línea. La regla se convierte en una sugerencia.

  1. Invariantes de clase

Una invariante es una condición que debe cumplirse siempre durante toda la vida del objeto, desde que el constructor termina hasta que se recolecta. Son las reglas que definen qué significa que un objeto sea válido.

Invariantes de BiblioTech:

Clase Invariante
Material titulo nunca es null ni está en blanco
Material referencia nunca es null
Empleado 0 <= prestamosAcumulados <= MAX_PRESTAMOS_SIMULTANEOS
Prestamo diasTranscurridos >= 0
Prestamo diaVencimiento == diaPrestamo + material.getDiasPrestamo()
Prestamo 0 <= multa calculada <= MULTA_MAXIMA

El encapsulamiento es el mecanismo que las hace cumplir, y funciona en dos frentes:

  • El constructor establece las invariantes al nacer (03-04).
  • Los métodos son los únicos que pueden modificar el estado, y por tanto los únicos responsables de mantenerlas.

Si algún campo es público, no hay invariantes: solo hay buenos deseos.

Cuando un mutador sí es necesario, debe validar:

/**
 * Actualiza los dias transcurridos desde que se realizo el prestamo.
 * @param dias numero de dias, nunca negativo
 */
public void setDiasTranscurridos(int dias) {
    if (dias < 0) {
        System.out.println("AVISO: dias negativos (" + dias + "). Se ignora la actualizacion.");
        return;                       // se preserva la invariante
    }
    this.diasTranscurridos = dias;
}

Igual que en 03-04: la forma correcta de rechazar sería lanzar una excepción, y llegará en el módulo 6.

  1. Operaciones de negocio en lugar de setters

Este es el cambio de mentalidad clave de la lección. En vez de preguntarte "¿qué campos tiene esta clase y cómo los expongo?", pregúntate "¿qué le puede pasar a este objeto en el negocio real?".

En BiblioTech, a un préstamo le pasan cosas muy concretas: se registra su devolución, se prorroga, se consulta su estado. No le pasa "que le asignen una multa".

Antes, con la receta mecánica:

// El llamador calcula, decide y asigna: la regla vive FUERA del objeto
int retraso = dias - 15;
if (retraso < 0) retraso = 0;
double multa = retraso * 0.25;
if (multa > 20.0) multa = 20.0;

prestamo.setDiasRetraso(retraso);
prestamo.setMulta(multa);
prestamo.setDevuelto(true);
libro.setDisponible(true);
empleado.setPrestamosAcumulados(empleado.getPrestamosAcumulados() - 1);

Cinco llamadas que deben hacerse todas y en orden. Si el llamador olvida una, el sistema queda incoherente: un préstamo devuelto cuyo libro sigue marcado como prestado.

Después, con una operación de negocio:

prestamo.registrarDevolucion(dias);

Una sola llamada, imposible de dejar a medias:

/**
 * Registra la devolucion del material tras el numero de dias indicado.
 * Calcula la multa segun las reglas vigentes, libera el material y
 * descuenta el prestamo al empleado.
 *
 * @param diasTranscurridos dias desde la entrega, nunca negativo
 * @return importe de la multa aplicada, entre 0 y MULTA_MAXIMA
 */
public double registrarDevolucion(int diasTranscurridos) {

    if (devuelto) {
        System.out.println("AVISO: el prestamo " + referencia + " ya estaba devuelto.");
        return multaAplicada;
    }

    setDiasTranscurridos(diasTranscurridos);      // valida la invariante

    this.multaAplicada = material.calcularMulta(this.diasTranscurridos);
    this.devuelto      = true;

    material.devolver();                          // el material vuelve a estanteria
    empleado.registrarDevolucion();               // el empleado libera un hueco

    return multaAplicada;
}

Compara las dos versiones en una tabla:

Criterio Cinco setters registrarDevolucion(int)
Dónde vive la regla de la multa En el llamador (y en cada llamador) En Prestamo, una sola vez
Se puede dejar a medias No
Se puede falsear la multa No
Qué se lee en main Aritmética Una frase del negocio
Cambiar la tarifa afecta a Todos los llamadores Una constante

Esa es la diferencia entre un modelo anémico (objetos que solo guardan datos, con la lógica fuera) y un modelo de dominio rico (objetos que protegen sus reglas). El primero es POO solo en la sintaxis.

  1. Inmutabilidad

Un objeto inmutable es aquel cuyo estado no puede cambiar después de construido. Se consigue con cuatro reglas:

  1. Todos los campos private final.
  2. Ningún setter ni método que modifique el estado.
  3. La clase declarada final, para que ninguna subclase añada mutabilidad.
  4. Si algún campo es una referencia mutable, no se expone directamente (apartado 8).

Ventajas, y son muchas:

Ventaja Explicación
Imposible corromper el estado No hay forma de dejarlo inválido tras el constructor
Seguro entre hilos Sin escrituras, no hay condiciones de carrera (módulo 8)
Se puede compartir sin miedo Copiar y compartir pasan a ser equivalentes
Buen candidato a clave Su hashCode no cambia nunca (lección 03-09 y módulo 5)
Más fácil de razonar Si lo viste válido una vez, lo será siempre

String es el ejemplo canónico, y ya sufriste sus consecuencias en el módulo 1: texto.toUpperCase() no cambia texto, devuelve otra cadena.

¿Por qué Libro es un buen candidato a inmutable? Porque sus datos bibliográficos —título, autor, ISBN, año— no cambian nunca. Un libro publicado en 2018 no pasará a serlo en 2019, y su ISBN es su identidad. Lo único que cambia es la disponibilidad, y esa es una propiedad del ejemplar prestado, discutiblemente incluso de otra clase.

Un Libro completamente inmutable se vería así:

public final class LibroInmutable {

    private final String titulo;
    private final String autor;
    private final String isbn;
    private final int    anioPublicacion;

    public LibroInmutable(String titulo, String autor, String isbn, int anioPublicacion) {
        this.titulo          = titulo;
        this.autor           = autor;
        this.isbn            = isbn;
        this.anioPublicacion = anioPublicacion;
    }

    public String getTitulo()          { return titulo; }
    public String getAutor()           { return autor; }
    public String getIsbn()            { return isbn; }
    public int    getAnioPublicacion() { return anioPublicacion; }

    /** "Modificar" un inmutable significa crear otro. */
    public LibroInmutable conTitulo(String nuevoTitulo) {
        return new LibroInmutable(nuevoTitulo, autor, isbn, anioPublicacion);
    }
}

Fíjate en conTitulo: en el mundo inmutable no se modifica, se deriva un objeto nuevo. Es el mismo patrón de String.toUpperCase().

Nota sobre record. Escribir una clase inmutable así es tan repetitivo que Java 16 incorporó los records: public record LibroInmutable(String titulo, String autor, String isbn, int anioPublicacion) { } genera automáticamente los campos private final, el constructor, los accesores, y además equals, hashCode y toString. Se estudian en la lección 04-07. Escribe primero la versión manual: entender qué genera el record es más útil que usarlo a ciegas.

En BiblioTech mantendremos disponible mutable —el ejemplar se presta y se devuelve— pero todo lo demás final. Es una inmutabilidad parcial, y es un compromiso razonable: minimiza lo que puede cambiar.

  1. El peligro de devolver referencias mutables: copia defensiva

Aquí está la fuga de encapsulamiento más sutil y la que más código "aparentemente correcto" arruina. Observa:

public class Prestamo {
    private final String[] incidencias;   // array: solucion provisional (modulo 5)

    public String[] getIncidencias() {
        return incidencias;               // ¡FUGA!
    }
}

El campo es private. El campo es final. Y aun así:

String[] fuera = prestamo.getIncidencias();
fuera[0] = "Incidencia inventada";        // se ha modificado el interior del prestamo

final protege la referencia, no el objeto apuntado (lo viste en 03-02). Al devolver el array, has entregado al exterior una llave del interior de tu objeto. La invariante ya no está garantizada.

La solución es la copia defensiva: devolver una copia, no el original.

/**
 * @return copia de las incidencias registradas; modificarla no afecta al prestamo
 */
public String[] getIncidencias() {
    return java.util.Arrays.copyOf(incidencias, incidencias.length);
}

El mismo peligro existe a la entrada. Si un constructor guarda un array que le pasan, el llamador conserva una referencia a él:

// Constructor ingenuo
public Prestamo(String[] incidencias) {
    this.incidencias = incidencias;       // el llamador puede seguir modificandolo
}

// Constructor con copia defensiva
public Prestamo(String[] incidencias) {
    this.incidencias = (incidencias == null)
                       ? new String[0]
                       : java.util.Arrays.copyOf(incidencias, incidencias.length);
}

Regla general:

Copia defensivamente al recibir y al devolver cualquier objeto mutable que forme parte del estado de tu clase.

¿Cuándo no hace falta? Cuando el objeto es inmutable. String, Integer o un LibroInmutable se pueden devolver directamente sin riesgo, porque nadie puede alterarlos. Es otro argumento a favor de la inmutabilidad: elimina toda una categoría de precauciones.

Y una matización importante: Prestamo.getMaterial() devuelve un Material mutable (su disponible cambia). ¿Habría que copiarlo? No, y aquí está el matiz: el material no es una parte interna del préstamo, es una entidad compartida del catálogo. Que dos préstamos y el catálogo vean el mismo objeto es precisamente lo que queremos. La copia defensiva se aplica a lo que es parte del objeto (composición), no a lo que es un colaborador (asociación). Vuelve a la tabla de relaciones de la lección 03-01: esa distinción, que allí parecía teórica, tiene aquí una consecuencia práctica directa.

  1. Encapsulamiento a nivel de paquete

El encapsulamiento no termina en la clase. Los paquetes son la segunda barrera, y la organización actual de BiblioTech ya la anticipa:

com.nexussoftware.bibliotech
├── BiblioTechApp.java          arranque y presentacion
├── dominio/
│   ├── Material.java           reglas de negocio
│   ├── Libro.java
│   ├── Revista.java
│   ├── Dvd.java
│   ├── Empleado.java
│   └── Prestamo.java
└── servicio/                   (a partir del modulo 5)
    └── GestorPrestamos.java    orquestacion de casos de uso

El criterio: público solo lo que otro paquete necesite de verdad. Una clase auxiliar que solo usa el dominio puede declararse sin public, y entonces es invisible desde fuera. Eso te deja libertad total para cambiarla, porque tienes la certeza de que nadie ajeno la usa.

Java 9 llevó esta idea un nivel más arriba con el sistema de módulos y module-info.java, que permite declarar qué paquetes exporta una librería entera. Se estudia en la lección 10-06.

  1. La Ley de Demeter

La Ley de Demeter, o principio de mínimo conocimiento, se resume en una frase:

Habla solo con tus amigos inmediatos, no con los amigos de tus amigos.

En código: evita las cadenas de puntos que atraviesan varios objetos.

// MAL: main conoce la estructura interna de Prestamo Y de Material
String titulo = prestamo.getMaterial().getTitulo();
if (prestamo.getEmpleado().getPrestamosAcumulados() >= 3) { ... }

// BIEN: cada objeto responde por si mismo
String titulo = prestamo.getTituloMaterial();
if (prestamo.empleadoEnElLimite()) { ... }

¿Por qué importa? Porque prestamo.getMaterial().getTitulo() acopla a main con dos clases: si Material cambia getTitulo() por otra cosa, hay que tocar todos los sitios que encadenaban. Con prestamo.getTituloMaterial(), el cambio afecta solo a Prestamo.

Dos precisiones para no aplicarla como dogma:

  • No cuentes puntos: System.out.println(...) o sb.append(a).append(b) encadenan y están perfectamente bien. Lo que la ley desaconseja es navegar por la estructura ajena para tomar decisiones.
  • Llevarla al extremo genera montones de métodos delegados triviales. Aplícala donde el acoplamiento duela: en las decisiones de negocio.

  1. BiblioTech: el dominio encapsulado

Refactoricemos las clases del proyecto. Este es el estado en que quedan.

Material

package com.nexussoftware.bibliotech.dominio;

/** Material prestable del catalogo de Nexus Software. */
public class Material {

    public static final int    DIAS_PRESTAMO = 15;
    public static final double TARIFA_DIARIA = 0.25;
    public static final double MULTA_MAXIMA  = 20.0;
    public static final int    UMBRAL_LEVE   = 7;

    private static int materialesCreados = 0;

    private final String titulo;
    private final String referencia;
    private boolean      disponible;

    public Material(String titulo, String referencia, boolean disponible) {
        this.titulo     = (titulo == null || titulo.isBlank()) ? "Sin titulo" : titulo.trim();
        this.referencia = (referencia == null || referencia.isBlank())
                          ? "000-0000000000" : referencia.trim();
        this.disponible = disponible;
        materialesCreados++;
    }

    // --- Consultas (getters solo donde tienen sentido) ---

    public String  getTitulo()     { return titulo; }
    public String  getReferencia() { return referencia; }
    public boolean estaDisponible(){ return disponible; }

    public static int getMaterialesCreados() { return materialesCreados; }

    // --- Parametros que cada soporte redefine ---

    public int    getDiasPrestamo() { return DIAS_PRESTAMO; }
    public double getTarifaDiaria() { return TARIFA_DIARIA; }
    public String getTipo()         { return "Material"; }

    // --- Reglas de negocio ---

    public int calcularDiasRetraso(int diasTranscurridos) {
        return Math.max(0, diasTranscurridos - getDiasPrestamo());
    }

    public double calcularMulta(int diasTranscurridos) {
        return Math.min(calcularDiasRetraso(diasTranscurridos) * getTarifaDiaria(),
                        MULTA_MAXIMA);
    }

    public String clasificarGravedad(int diasTranscurridos) {
        int retraso = calcularDiasRetraso(diasTranscurridos);
        if (retraso == 0)           return "SIN RETRASO";
        if (retraso <= UMBRAL_LEVE) return "LEVE";
        return "GRAVE";
    }

    // --- Operaciones de negocio (NO hay setDisponible) ---

    /** @return true si el material se ha podido prestar. */
    public boolean prestar() {
        if (!disponible) {
            System.out.println("AVISO: '" + titulo + "' ya estaba prestado.");
            return false;
        }
        disponible = false;
        return true;
    }

    /** @return true si el material se ha podido devolver. */
    public boolean devolver() {
        if (disponible) {
            System.out.println("AVISO: '" + titulo + "' ya estaba disponible.");
            return false;
        }
        disponible = true;
        return true;
    }

    public String describir() {
        return titulo + " (ref. " + referencia + ")";
    }
}

Nota el cambio en prestar() y devolver(): ahora devuelven boolean en lugar de void. Así el llamador puede saber si la operación tuvo efecto, sin necesidad de leer el campo.

Y nota lo que no hay: setTitulo, setReferencia ni setDisponible. La disponibilidad solo cambia por las dos operaciones del negocio.

Libro

package com.nexussoftware.bibliotech.dominio;

/** Libro tecnico del catalogo. Sus datos bibliograficos son inmutables. */
public class Libro extends Material {

    private final String autor;
    private final int    anioPublicacion;

    public Libro(String titulo, String autor, String isbn,
                 int anioPublicacion, boolean disponible) {
        super(titulo, isbn, disponible);
        this.autor = (autor == null || autor.isBlank()) ? "Desconocido" : autor.trim();
        this.anioPublicacion = (anioPublicacion < 1450 || anioPublicacion > 2100)
                               ? 0 : anioPublicacion;
    }

    public Libro(String titulo, String autor, String isbn, int anioPublicacion) {
        this(titulo, autor, isbn, anioPublicacion, true);
    }

    public String getAutor()           { return autor; }
    public int    getAnioPublicacion() { return anioPublicacion; }

    /** El ISBN es la referencia de un libro. */
    public String getIsbn() { return getReferencia(); }

    @Override public String getTipo() { return "Libro"; }

    @Override
    public String describir() {
        return super.describir() + " - " + autor + ", " + anioPublicacion;
    }
}

Observa que Libro accede al título mediante getTitulo() heredado, no directamente: el campo de Material es private y ni siquiera su subclase puede tocarlo. Eso permitiría a Material cambiar su representación interna mañana sin romper a Libro.

Empleado

package com.nexussoftware.bibliotech.dominio;

/** Empleado de Nexus Software autorizado a tomar materiales en prestamo. */
public class Empleado {

    public static final int MAX_PRESTAMOS_SIMULTANEOS = 3;

    private static int empleadosRegistrados = 0;

    private final String nombre;
    private final String identificador;
    private int          prestamosAcumulados;      // invariante: 0..MAX

    public Empleado(String nombre, String identificador, int prestamosAcumulados) {
        this.nombre = (nombre == null || nombre.isBlank())
                      ? "Empleado sin nombre" : nombre.trim();
        this.identificador = (identificador == null || !identificador.startsWith("EMP-"))
                             ? "EMP-000" : identificador.trim();
        this.prestamosAcumulados = Math.max(0,
                Math.min(prestamosAcumulados, MAX_PRESTAMOS_SIMULTANEOS));
        empleadosRegistrados++;
    }

    public Empleado(String nombre, String identificador) {
        this(nombre, identificador, 0);
    }

    public String getNombre()              { return nombre; }
    public String getIdentificador()       { return identificador; }
    public int    getPrestamosAcumulados() { return prestamosAcumulados; }
    public boolean puedeTomarPrestado()    { return prestamosAcumulados < MAX_PRESTAMOS_SIMULTANEOS; }

    public static int getEmpleadosRegistrados() { return empleadosRegistrados; }

    /** @return true si se ha podido registrar el prestamo. */
    public boolean registrarPrestamo() {
        if (!puedeTomarPrestado()) {
            System.out.printf("AVISO: %s ya tiene %d prestamos (maximo %d).%n",
                              nombre, prestamosAcumulados, MAX_PRESTAMOS_SIMULTANEOS);
            return false;
        }
        prestamosAcumulados++;
        return true;
    }

    public void registrarDevolucion() {
        prestamosAcumulados = Math.max(0, prestamosAcumulados - 1);
    }

    public String getIniciales() {
        StringBuilder sb = new StringBuilder();
        for (String parte : nombre.trim().split(" ")) {
            if (!parte.isEmpty()) {
                sb.append(parte.charAt(0)).append('.');
            }
        }
        return sb.toString().toUpperCase();
    }
}

La invariante 0 <= prestamosAcumulados <= MAX está garantizada en los tres únicos puntos donde el campo cambia: el constructor (con Math.max/Math.min), registrarPrestamo() (con la guarda) y registrarDevolucion() (con Math.max). No hay una cuarta puerta. Eso es encapsulamiento.

Prestamo

package com.nexussoftware.bibliotech.dominio;

import java.util.Arrays;

/** Registro de un prestamo de un material a un empleado. */
public class Prestamo {

    private static int prestamosCreados = 0;

    private final Material material;
    private final Empleado empleado;
    private final int      diaPrestamo;
    private final int      diaVencimiento;
    private final String   referencia;

    private int      diasTranscurridos;
    private boolean  devuelto;
    private double   multaAplicada;
    private String[] incidencias;         // array provisional (modulo 5)

    public Prestamo(Material material, Empleado empleado,
                    int diaPrestamo, int diasTranscurridos) {

        this.material = (material != null) ? material
                : new Libro("Material desconocido", "Desconocido", "000-0000000000", 0, false);
        this.empleado = (empleado != null) ? empleado
                : new Empleado("Empleado desconocido", "EMP-000");

        this.diaPrestamo       = Math.max(0, diaPrestamo);
        this.diaVencimiento    = this.diaPrestamo + this.material.getDiasPrestamo();
        this.diasTranscurridos = Math.max(0, diasTranscurridos);
        this.devuelto          = false;
        this.multaAplicada     = 0.0;
        this.incidencias       = new String[0];

        prestamosCreados++;
        this.referencia = String.format("PR-%04d", prestamosCreados);

        this.material.prestar();
        this.empleado.registrarPrestamo();
    }

    public Prestamo(Material material, Empleado empleado, int diaPrestamo) {
        this(material, empleado, diaPrestamo, 0);
    }

    // ---------- Consultas ----------

    public String   getReferencia()        { return referencia; }
    public Material getMaterial()          { return material; }     // colaborador compartido
    public Empleado getEmpleadoNoUsar()    { return empleado; }     // ver nota mas abajo
    public int      getDiaPrestamo()       { return diaPrestamo; }
    public int      getDiaVencimiento()    { return diaVencimiento; }
    public int      getDiasTranscurridos() { return diasTranscurridos; }
    public boolean  estaDevuelto()         { return devuelto; }

    /** Delegaciones que cumplen la Ley de Demeter. */
    public String getTituloMaterial()  { return material.getTitulo(); }
    public String getNombreEmpleado()  { return empleado.getNombre(); }

    /** @return copia defensiva: modificarla no afecta al prestamo. */
    public String[] getIncidencias() {
        return Arrays.copyOf(incidencias, incidencias.length);
    }

    public static int getPrestamosCreados() { return prestamosCreados; }

    // ---------- Reglas derivadas (calculadas, nunca almacenadas) ----------

    public int    calcularDiasRetraso() { return material.calcularDiasRetraso(diasTranscurridos); }
    public double calcularMulta()       { return material.calcularMulta(diasTranscurridos); }
    public String clasificarGravedad()  { return material.clasificarGravedad(diasTranscurridos); }
    public boolean estaVencido()        { return calcularDiasRetraso() > 0; }
    public int     diasRestantes()      { return Math.max(0, diaVencimiento - (diaPrestamo + diasTranscurridos)); }

    // ---------- Operaciones de negocio ----------

    /**
     * Actualiza los dias transcurridos. Unico mutador, y validado.
     * @param dias dias desde la entrega; los valores negativos se ignoran
     */
    public void setDiasTranscurridos(int dias) {
        if (dias < 0) {
            System.out.println("AVISO: dias negativos en " + referencia + ". Se ignora.");
            return;
        }
        this.diasTranscurridos = dias;
    }

    /**
     * Registra la devolucion: calcula y fija la multa segun las reglas
     * vigentes, libera el material y descuenta el prestamo al empleado.
     *
     * @param diasTranscurridos dias desde la entrega
     * @return multa aplicada, entre 0 y MULTA_MAXIMA
     */
    public double registrarDevolucion(int diasTranscurridos) {
        if (devuelto) {
            System.out.println("AVISO: " + referencia + " ya estaba devuelto.");
            return multaAplicada;
        }
        setDiasTranscurridos(diasTranscurridos);
        this.multaAplicada = calcularMulta();
        this.devuelto      = true;
        material.devolver();
        empleado.registrarDevolucion();
        return multaAplicada;
    }

    /** @return multa efectivamente aplicada; 0 mientras no se haya devuelto. */
    public double getMultaAplicada() { return multaAplicada; }

    /** Anota una incidencia del prestamo (roturas, paginas sueltas...). */
    public void anotarIncidencia(String texto) {
        if (texto == null || texto.isBlank()) {
            return;
        }
        String[] ampliado = Arrays.copyOf(incidencias, incidencias.length + 1);
        ampliado[incidencias.length] = texto.trim();
        this.incidencias = ampliado;      // el modulo 5 hara esto mucho mejor
    }

    public boolean empleadoEnElLimite() { return !empleado.puedeTomarPrestado(); }
}

Sobre getEmpleadoNoUsar. Ese nombre deliberadamente feo señala una decisión de diseño: exponer el Empleado completo permite a cualquiera hacer prestamo.getEmpleado().registrarPrestamo() y descuadrar el sistema. Por eso el préstamo ofrece getNombreEmpleado() y empleadoEnElLimite() en su lugar. En la lección 03-08 reducirás formalmente la superficie pública de esta clase, y ese getter será uno de los primeros en desaparecer.

Uso desde BiblioTechApp

Libro    javaEfectivo = new Libro("Java Efectivo", "Joshua Bloch", "978-0000000001", 2018);
Empleado marta        = new Empleado("Marta Ruiz", "EMP-001");

Prestamo p = new Prestamo(javaEfectivo, marta, 100, 0);

System.out.printf("%s: '%s' a %s, vence el dia %d%n",
                  p.getReferencia(), p.getTituloMaterial(),
                  p.getNombreEmpleado(), p.getDiaVencimiento());
System.out.println("Disponible tras prestar: " + javaEfectivo.estaDisponible());

p.anotarIncidencia("Pagina 42 subrayada");

double multa = p.registrarDevolucion(20);
System.out.printf("Devuelto con %d dias de retraso. Multa: %.2f EUR (%s)%n",
                  p.calcularDiasRetraso(), multa, p.clasificarGravedad());
System.out.println("Disponible tras devolver: " + javaEfectivo.estaDisponible());
System.out.println("Prestamos de Marta: " + marta.getPrestamosAcumulados());

// Intentos de romper las invariantes desde fuera:
// javaEfectivo.disponible = true;          // NO COMPILA: campo private
// p.multaAplicada = 0.0;                   // NO COMPILA: campo private
// marta.prestamosAcumulados = 99;          // NO COMPILA: campo private
p.setDiasTranscurridos(-5);                 // avisa y se ignora

Salida:

PR-0001: 'Java Efectivo' a Marta Ruiz, vence el dia 115
Disponible tras prestar: false
Devuelto con 5 dias de retraso. Multa: 1,25 EUR (LEVE)
Disponible tras devolver: true
Prestamos de Marta: 0
AVISO: dias negativos en PR-0001. Se ignora.

Las tres líneas comentadas son lo importante de esta lección: ya no compilan. Las reglas de Nexus Software han dejado de depender de la buena voluntad de quien usa las clases.

Errores Comunes y Consejos

  • Confundir encapsulamiento con "poner getters y setters". Un setter público sin validación deja el objeto tan abierto como un campo público. Encapsular es decidir qué operaciones tienen sentido, no automatizar accesos.
  • Generar todos los accesores con el IDE sin pensar. El generador no sabe qué campos deben ser inmutables. Genera y luego borra lo que no deba existir.
  • Devolver una referencia mutable interna. El error del apartado 8. final no protege el objeto apuntado.
  • Usar protected en campos "por si acaso una subclase lo necesita". Convierte la representación interna en API pública para todas las subclases. Prefiere un método protected.
  • Guardar datos derivados. Si multa se puede calcular a partir de los días, calcúlala. Un dato duplicado es un dato que puede desincronizarse. (En Prestamo guardamos multaAplicada solo tras la devolución, porque entonces es un hecho registrado, no un cálculo vivo.)
  • Cadenas largas de getters. a.getB().getC().getD() acopla tu código a tres clases. Delega.
  • Hacer public una clase auxiliar del dominio. Si solo la usa su paquete, déjala sin modificador.
  • Consejo: escribe la clase desde fuera hacia dentro. Primero decide qué operaciones ofrecerá, luego qué campos necesita para cumplirlas. Al revés se acaba con getters de todo.
  • Consejo: si un campo no cambia, hazlo final hoy. Es la forma más barata de documentar y garantizar una invariante.
  • Consejo: prueba a comentar un getter. Si el proyecto sigue compilando, bórralo. La API pública más segura es la más pequeña.

Ejercicios

Ejercicio 1: encapsular Reserva

Retoma la clase Reserva de la lección 03-04 (sala, solicitante, hora de inicio, hora de fin, asistentes, activa, código) y encapsúlala correctamente:

  1. Haz todos los campos private, y final los que no deban cambiar.
  2. Decide, campo por campo, si necesita getter, justificándolo en un comentario.
  3. Sustituye cualquier setter por operaciones de negocio: cancelar(), ampliarUnaHora() y cambiarAsistentes(int) (que debe rechazar valores fuera de rango).
  4. Enumera en Javadoc las invariantes de la clase y comprueba que ninguna operación pueda romperlas.

Ejercicio 2: detectar y cerrar una fuga de encapsulamiento

Este código tiene tres fugas de encapsulamiento distintas. Identifícalas, explica cómo se explotaría cada una desde fuera y corrígelas:

public class HistorialEmpleado {

    private final String   nombre;
    private final String[] referenciasPrestamos;
    private int            totalMultas;

    public HistorialEmpleado(String nombre, String[] referenciasPrestamos) {
        this.nombre = nombre;
        this.referenciasPrestamos = referenciasPrestamos;
    }

    public String[] getReferenciasPrestamos() {
        return referenciasPrestamos;
    }

    public void setTotalMultas(int totalMultas) {
        this.totalMultas = totalMultas;
    }
}

Ejercicio 3: de modelo anémico a modelo rico

Un compañero ha escrito esta lógica en BiblioTechApp usando setters. Refactorízala moviendo cada regla a la clase de dominio que corresponda, de modo que en main quede una sola llamada. Indica qué métodos nuevos aparecen y en qué clase.

// En main
if (marta.getPrestamosAcumulados() < 3 && javaEfectivo.estaDisponible()) {
    javaEfectivo.setDisponible(false);
    marta.setPrestamosAcumulados(marta.getPrestamosAcumulados() + 1);
    Prestamo p = new Prestamo();
    p.setMaterial(javaEfectivo);
    p.setEmpleado(marta);
    p.setDiaPrestamo(100);
    p.setDiaVencimiento(100 + 15);
    System.out.println("Prestamo registrado");
} else {
    System.out.println("No se puede prestar");
}

Soluciones

Solución 1

package com.nexussoftware.bibliotech.dominio;

/**
 * Reserva de una sala de reuniones de Nexus Software.
 *
 * Invariantes garantizadas durante toda la vida del objeto:
 *   - 0 <= horaInicio <= 23
 *   - horaInicio < horaFin <= 23
 *   - asistentes >= 1
 *   - codigo no nulo y unico
 */
public class Reserva {

    private static int reservasCreadas = 0;

    private final String   sala;           // sin setter: cambiar de sala es otra reserva
    private final Empleado solicitante;    // sin setter: identidad de la reserva
    private final int      horaInicio;     // sin setter: se fija al reservar
    private final String   codigo;         // sin setter: lo genera el sistema

    private int     horaFin;               // muta solo por ampliarUnaHora()
    private int     asistentes;            // muta solo por cambiarAsistentes()
    private boolean activa;                // muta solo por cancelar()

    public Reserva(String sala, Empleado solicitante,
                   int horaInicio, int horaFin, int asistentes) {

        this.sala = (sala == null || sala.isBlank()) ? "Sala sin nombre" : sala.trim();
        this.solicitante = (solicitante != null) ? solicitante
                           : new Empleado("Empleado desconocido", "EMP-000");

        this.horaInicio = (horaInicio < 0 || horaInicio > 23) ? 9 : horaInicio;

        if (horaFin <= this.horaInicio || horaFin > 23) {
            this.horaFin = Math.min(23, this.horaInicio + 1);
        } else {
            this.horaFin = horaFin;
        }

        this.asistentes = Math.max(1, asistentes);
        this.activa     = true;

        reservasCreadas++;
        this.codigo = String.format("RES-%04d", reservasCreadas);
    }

    // --- Consultas ---
    public String  getCodigo()      { return codigo; }       // lo pide el usuario
    public String  getSala()        { return sala; }         // se muestra en listados
    public int     getHoraInicio()  { return horaInicio; }   // se muestra
    public int     getHoraFin()     { return horaFin; }      // se muestra
    public int     getAsistentes()  { return asistentes; }   // se muestra
    public boolean estaActiva()     { return activa; }       // se consulta al listar
    public int     duracionHoras()  { return horaFin - horaInicio; }

    /** Delegacion: evita que el exterior navegue hasta el Empleado. */
    public String getNombreSolicitante() { return solicitante.getNombre(); }
    // NO hay getSolicitante(): expondria un objeto mutable del dominio.

    // --- Operaciones de negocio (ningun setter) ---

    /** @return true si la reserva se ha cancelado ahora. */
    public boolean cancelar() {
        if (!activa) {
            System.out.println("AVISO: la reserva " + codigo + " ya estaba cancelada.");
            return false;
        }
        activa = false;
        return true;
    }

    /** @return true si se ha podido ampliar sin salirse del dia. */
    public boolean ampliarUnaHora() {
        if (!activa) {
            System.out.println("AVISO: no se puede ampliar una reserva cancelada.");
            return false;
        }
        if (horaFin >= 23) {
            System.out.println("AVISO: la reserva " + codigo + " ya llega al final del dia.");
            return false;
        }
        horaFin++;                       // la invariante horaInicio < horaFin se mantiene
        return true;
    }

    /** @return true si el cambio de asistentes se ha aplicado. */
    public boolean cambiarAsistentes(int nuevos) {
        if (nuevos < 1) {
            System.out.println("AVISO: numero de asistentes invalido (" + nuevos + ").");
            return false;
        }
        asistentes = nuevos;
        return true;
    }
}

Justificación de las decisiones clave: sala, solicitante, horaInicio y codigo son final porque cambiarlos convertiría la reserva en otra distinta; horaFin, asistentes y activa mutan, pero solo a través de operaciones que validan, así que las tres invariantes del Javadoc se mantienen en todo momento. Y no existe getSolicitante(): exponer el Empleado permitiría a cualquiera alterarlo desde una reserva, que es exactamente el acoplamiento que la Ley de Demeter desaconseja.

Solución 2

Fuga 1: el constructor guarda el array recibido.

String[] refs = { "PR-0001", "PR-0002" };
HistorialEmpleado h = new HistorialEmpleado("Marta Ruiz", refs);
refs[0] = "REFERENCIA FALSA";      // se ha modificado el interior de h

El llamador conserva una referencia al mismo array que ahora es estado interno del objeto.

Fuga 2: el getter devuelve el array interno.

h.getReferenciasPrestamos()[1] = "OTRA FALSA";   // se escribe dentro de h

Ni private ni final protegen: final impide reasignar la variable, no modificar el array.

Fuga 3: setTotalMultas permite cualquier valor.

h.setTotalMultas(-500);   // total de multas negativo: estado imposible

Es un setter sin validación sobre un dato acumulativo que solo debería crecer mediante operaciones del negocio.

Versión corregida:

package com.nexussoftware.bibliotech.dominio;

import java.util.Arrays;

/**
 * Historial de prestamos de un empleado.
 * Invariantes: referenciasPrestamos nunca es null; totalMultas >= 0.
 */
public class HistorialEmpleado {

    private final String   nombre;
    private final String[] referenciasPrestamos;
    private int            totalMultas;

    public HistorialEmpleado(String nombre, String[] referenciasPrestamos) {
        this.nombre = (nombre == null || nombre.isBlank()) ? "Desconocido" : nombre.trim();

        // COPIA DEFENSIVA a la entrada: el llamador ya no alcanza nuestro estado.
        this.referenciasPrestamos = (referenciasPrestamos == null)
                ? new String[0]
                : Arrays.copyOf(referenciasPrestamos, referenciasPrestamos.length);

        this.totalMultas = 0;
    }

    public String getNombre()      { return nombre; }
    public int    getTotalMultas() { return totalMultas; }
    public int    getNumeroPrestamos() { return referenciasPrestamos.length; }

    /** @return copia defensiva; modificarla no afecta al historial. */
    public String[] getReferenciasPrestamos() {
        return Arrays.copyOf(referenciasPrestamos, referenciasPrestamos.length);
    }

    /**
     * Suma una multa al historial. Sustituye al setter: el total solo
     * puede crecer, y solo con importes validos.
     *
     * @param importe importe positivo en euros
     * @return true si se ha acumulado
     */
    public boolean acumularMulta(int importe) {
        if (importe <= 0) {
            System.out.println("AVISO: importe de multa invalido (" + importe + ").");
            return false;
        }
        totalMultas += importe;
        return true;
    }
}

Comprobación de que las fugas están cerradas:

String[] refs = { "PR-0001", "PR-0002" };
HistorialEmpleado h = new HistorialEmpleado("Marta Ruiz", refs);

refs[0] = "REFERENCIA FALSA";                       // ya no afecta
h.getReferenciasPrestamos()[1] = "OTRA FALSA";      // modifica una copia
h.acumularMulta(-500);                              // rechazado con aviso

System.out.println(Arrays.toString(h.getReferenciasPrestamos()));  // [PR-0001, PR-0002]
System.out.println(h.getTotalMultas());                            // 0

Un apunte de rendimiento: copiar en cada llamada tiene un coste. Cuando importe, las alternativas son devolver una vista de solo lectura (List.copyOf, módulo 5) o hacer inmutable el contenido. La regla de partida sigue siendo copiar: la corrección primero, la optimización después y solo con medidas.

Solución 3

El fragmento comprueba precondiciones, muta tres objetos y calcula el vencimiento desde fuera. Todo eso pertenece al dominio.

La regla completa "¿se puede prestar este material a este empleado?" tiene dos partes que ya viven en sus clases (estaDisponible() y puedeTomarPrestado()), y falta un sitio donde combinarlas. Como el Prestamo es quien las relaciona, se añade allí un método static de comprobación previa:

En Prestamo:

/**
 * Indica si es posible registrar un prestamo de este material a este empleado.
 * @return true si el material esta disponible y el empleado no ha llegado al limite
 */
public static boolean sePuedePrestar(Material material, Empleado empleado) {
    return material != null && empleado != null
           && material.estaDisponible()
           && empleado.puedeTomarPrestado();
}

Y el constructor canónico ya hace el resto —marca el material, incrementa el contador del empleado y calcula el vencimiento con diaPrestamo + material.getDiasPrestamo()—, así que main queda así:

if (Prestamo.sePuedePrestar(javaEfectivo, marta)) {
    Prestamo p = new Prestamo(javaEfectivo, marta, 100);
    System.out.println("Prestamo " + p.getReferencia() + " registrado, vence el dia "
                       + p.getDiaVencimiento());
} else {
    System.out.println("No se puede prestar");
}

Comparación:

Antes Después
11 líneas en main 1 llamada de comprobación + 1 constructor
El plazo de 15 días escrito en main Lo aporta el material (y funciona con revistas y DVD)
Tres mutaciones manuales que se pueden olvidar Las hace el constructor, siempre
setDisponible público No existe
Si se añade una regla nueva, hay que buscarla en todos los main Se añade en sePuedePrestar

Métodos nuevos: uno solo, Prestamo.sePuedePrestar(Material, Empleado). Los demás ya existían. Y desaparecen todos los setters del fragmento original: setDisponible, setPrestamosAcumulados, setMaterial, setEmpleado, setDiaPrestamo y setDiaVencimiento. Seis puertas cerradas.

Un detalle a discutir: sePuedePrestar es static porque responde a una pregunta antes de que exista el préstamo. Es una de las pocas ocasiones en que un método static en una clase de dominio está justificado.

Conclusión

Has cerrado la puerta que llevaba abierta todo el módulo. Sabes que encapsular no es poner accesores, sino ocultar la representación y exponer solo operaciones que preserven las reglas; conoces los cuatro modificadores de acceso y la tabla completa de visibilidad, y tienes el criterio profesional de partir de private y subir la visibilidad solo con una razón. Sabes decidir campo por campo si merece getter y, sobre todo, por qué un setter para cada campo destruye lo que se pretendía proteger: setMulta convertiría la política de multas de Nexus Software en una sugerencia. Has aprendido a formular las invariantes de una clase y a garantizarlas cerrando todas las puertas por las que el estado puede cambiar. Manejas la inmutabilidad —campos final, sin setters, clase final— y entiendes por qué los datos bibliográficos de un Libro son su caso ideal. Y conoces la fuga más sutil de todas: devolver una referencia mutable interna, con la copia defensiva como remedio a la entrada y a la salida, y con el matiz de que un colaborador compartido como Material no se copia. Cierras con encapsulamiento a nivel de paquete y la Ley de Demeter.

El dominio de BiblioTech ya no se puede corromper desde fuera: javaEfectivo.disponible = true no compila, y las tres invariantes de Empleado, Prestamo y Material se mantienen porque solo hay unos pocos métodos que pueden tocarlas.

Pero ha aparecido un problema nuevo, y lo has visto en getEmpleadoNoUsar: la superficie pública de tus clases ha crecido sin control. Prestamo expone más de veinte métodos, algunos de los cuales nadie debería llamar. Encapsular responde a cómo ocultar; falta responder a qué exponer. En la lección siguiente, Abstracción, aprenderás a decidir qué entra en la API pública de una clase y qué no, a no mezclar niveles de abstracción dentro de un mismo método, a documentar contratos con precondiciones y postcondiciones, y a reconocer tanto las abstracciones con fugas como el vicio contrario, el de abstraer lo que nadie ha pedido.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados