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
- Qué es realmente encapsular
- Los cuatro modificadores de acceso
- Tabla completa de visibilidad
- Getters y setters: lo que la receta mecánica no cuenta
- Invariantes de clase
- Operaciones de negocio en lugar de setters
- Inmutabilidad
- El peligro de devolver referencias mutables: copia defensiva
- Encapsulamiento a nivel de paquete
- La Ley de Demeter
- BiblioTech: el dominio encapsulado
- Errores Comunes y Consejos
- Ejercicios
- 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:
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.
- 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).
- 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 |
Sí | No | No | No |
| (default) | Sí | Sí | No | No |
protected |
Sí | Sí | Sí | No |
public |
Sí | Sí | Sí | Sí |
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
privatepor 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.
- 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 constructorUn 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:
- ¿Alguien de fuera necesita leer este dato? Si no, no hay getter.
- ¿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 |
Sí | No | El título de un libro no cambia |
Libro.isbn |
Sí | No | Es su identidad |
Material.disponible |
Sí, como estaDisponible() |
No | Cambia solo mediante prestar() / devolver() |
Empleado.nombre |
Sí | No | Dato identitario |
Empleado.prestamosAcumulados |
Sí | No | Cambia mediante registrarPrestamo() / registrarDevolucion() |
Prestamo.diasTranscurridos |
Sí | 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 diasToda 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.
- 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.
- 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:
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 | Sí | No |
| Se puede falsear la multa | Sí | 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.
- Inmutabilidad
Un objeto inmutable es aquel cuyo estado no puede cambiar después de construido. Se consigue con cuatro reglas:
- Todos los campos
private final. - Ningún setter ni método que modifique el estado.
- La clase declarada
final, para que ninguna subclase añada mutabilidad. - 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 camposprivate final, el constructor, los accesores, y ademásequals,hashCodeytoString. Se estudian en la lección 04-07. Escribe primero la versión manual: entender qué genera elrecordes 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.
- 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 prestamofinal 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.
- 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 usoEl 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.
- 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(...)osb.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.
- 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 elEmpleadocompleto permite a cualquiera hacerprestamo.getEmpleado().registrarPrestamo()y descuadrar el sistema. Por eso el préstamo ofrecegetNombreEmpleado()yempleadoEnElLimite()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 ignoraSalida:
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.
finalno protege el objeto apuntado. - Usar
protecteden campos "por si acaso una subclase lo necesita". Convierte la representación interna en API pública para todas las subclases. Prefiere un métodoprotected. - Guardar datos derivados. Si
multase puede calcular a partir de los días, calcúlala. Un dato duplicado es un dato que puede desincronizarse. (EnPrestamoguardamosmultaAplicadasolo 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
publicuna 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
finalhoy. 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:
- Haz todos los campos
private, yfinallos que no deban cambiar. - Decide, campo por campo, si necesita getter, justificándolo en un comentario.
- Sustituye cualquier setter por operaciones de negocio:
cancelar(),ampliarUnaHora()ycambiarAsistentes(int)(que debe rechazar valores fuera de rango). - 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 hEl llamador conserva una referencia al mismo array que ahora es estado interno del objeto.
Fuga 2: el getter devuelve el array interno.
Ni private ni final protegen: final impide reasignar la variable, no modificar el array.
Fuga 3: setTotalMultas permite cualquier valor.
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()); // 0Un 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
- Introducción a Java
- Configuración del Entorno de Desarrollo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Operadores
- Entrada y Salida por Consola
- Tu Primer Programa Completo: BiblioTech
Módulo 2: Flujo de Control
- Sentencias Condicionales
- Bucles
- Sentencias Switch
- Break y Continue
- Depuración y Trazas de Ejecución
- Proyecto: Menú Interactivo de BiblioTech
Módulo 3: Programación Orientada a Objetos
- Introducción a la POO
- Clases y Objetos
- Métodos
- Constructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- La Clase Object: equals, hashCode y toString
Módulo 4: Programación Orientada a Objetos Avanzada
- Interfaces
- Clases Abstractas
- Clases Internas
- Clases Anónimas
- Expresiones Lambda
- Interfaces Funcionales y Referencias a Métodos
- Enumeraciones y Registros
Módulo 5: Estructuras de Datos y Colecciones
- Arreglos
- El Framework de Colecciones
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cola y Deque
- Pila
- Ordenación y Búsqueda en Colecciones
Módulo 6: Manejo de Excepciones
- Introducción a las Excepciones
- Bloque Try-Catch
- Throw y Throws
- Excepciones Personalizadas
- Bloque Finally
- Try-with-resources y AutoCloseable
- Estrategias de Manejo de Errores y Logging
Módulo 7: Entrada/Salida de Archivos
- Lectura de Archivos
- Escritura de Archivos
- Flujos de Archivos
- BufferedReader y BufferedWriter
- Serialización
- La API NIO.2: Path y Files
- Formatos de Intercambio: CSV y Properties
Módulo 8: Multihilo y Concurrencia
- Introducción al Multihilo
- Creación de Hilos
- Ciclo de Vida de un Hilo
- Sincronización
- Utilidades de Concurrencia
- Colecciones Concurrentes y Variables Atómicas
- Tareas Asíncronas con CompletableFuture
Módulo 9: Redes
- Introducción a las Redes
- Sockets
- ServerSocket
- DatagramSocket y DatagramPacket
- URL y HttpURLConnection
- El Cliente HTTP Moderno
Módulo 10: Temas Avanzados
- Genéricos
- Anotaciones
- Reflexión
- Características de Java 8: Streams y Optional
- Fechas y Horas con java.time
- Java 9 y Más Allá
- Memoria, Recolección de Basura y Rendimiento
Módulo 11: Frameworks y Librerías de Java
- Introducción a los Frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Pruebas Avanzadas con Mockito
- Librerías Esenciales del Ecosistema
