12 buenas prácticas en Java, revisadas para Java 25

6 min de lectura

Esta lista circula desde hace más de diez años. La mayoría de los consejos siguen siendo buenos, pero algunos han envejecido y un par de ejemplos originales ni siquiera compilaban. Los repasamos uno a uno con Java 25, ejecutando todo lo que se puede ejecutar.

1. Inicialización perezosa, solo cuando cuesta

El consejo original era no crear objetos hasta que hagan falta:

class Paises {
    private List<String> paises;
    List<String> getPaises() {
        if (paises == null) paises = new ArrayList<>();
        return paises;
    }
}

Crear una lista vacía cuesta muy poco en una máquina virtual moderna, y este patrón tiene un problema que no se ve: no es seguro con varios hilos. Si dos hilos llaman a la vez a getPaises(), los dos pueden ver null y crear cada uno su lista. Lanzamos ocho hilos contra el mismo objeto, varias veces:

java Perezosa.javados hilos recibieron listas distintas en el intento 1

En tres ejecuciones pasó en los intentos 1, 178 y 1: depende del momento, que es lo peor que puede tener un error. Lo sensato hoy es inicializar en la declaración (private final List<String> paises = new ArrayList<>();) y reservar la inicialización perezosa para objetos realmente caros, como una conexión o un archivo grande, y con el cuidado que requiera.

2. Nada de campos públicos, y cuidado con lo que devuelves

Un arreglo público lo puede cambiar cualquiera. Pero hacerlo privado y devolverlo con un get no basta: quien lo recibe tiene el mismo arreglo y puede modificarlo igual.

private static final String[] DIAS = {"Domingo", "Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado"};
private static final List<String> LISTA = List.of(DIAS);

static String[] diasArreglo() { return DIAS; }
static String[] diasCopia()   { return DIAS.clone(); }
static List<String> dias()    { return LISTA; }
Calendario.diasArreglo()[0] = "¿?";
IO.println(Calendario.diasCopia()[0]);
salida¿?
List.of no deja cambiarla: UnsupportedOperationException

Alguien cambió el primer día desde fuera, y el cambio se ve en la copia que pide después: el original quedó estropeado para todos. Devolver clone() protege, pero copia el arreglo en cada llamada. Hoy lo más limpio es devolver una List.of, que no se puede modificar y no hay que copiar.

El ejemplo original tenía otro error, de los que no detecta el compilador: la lista de días de la versión «buena» decía "Jueves", "Sabado", "Domingo". Le faltaba el viernes y repetía el domingo.

3. Minimiza la mutabilidad

El original proponía un constructor privado con un método valueOf para hacer la clase inmutable. Eso controla cómo se crean los objetos, pero no impide cambiarlos:

public class Empleado {
    private String nombre;
    private Empleado(String nombre) { this.nombre = nombre; }
    public static Empleado valueOf(String nombre) { return new Empleado(nombre); }
    public String getNombre() { return nombre; }
    public void setNombre(String nombre) { this.nombre = nombre; }

    public static void main(String[] args) {
        Empleado e = Empleado.valueOf("Ana");
        e.setNombre("Luis");
        System.out.println(e.getNombre());
    }
}
java EmpleadoLuis

Para que sea inmutable, los campos tienen que ser final y no puede haber ningún método que los cambie. Desde Java 16 el record hace todo eso de una vez. Si tiene una lista, hay que copiarla, porque el record solo impide cambiar la referencia, no el contenido:

record Empleado(String nombre, List<String> proyectos) {
    Empleado {
        Objects.requireNonNull(nombre);
        proyectos = List.copyOf(proyectos);
    }
}
java Registro.javaEmpleado[nombre=Ana, proyectos=[web]]
no se puede: UnsupportedOperationException
true

Añadir "móvil" a la lista original después de crear el empleado no le afecta, y su lista no se puede tocar. El original también decía que los objetos inmutables «golpean el rendimiento». Crear objetos pequeños es barato, y en la práctica se ganan otras cosas: se pueden compartir entre hilos sin sincronizar y usar como claves de un mapa. Lo explicamos en qué es un objeto inmutable.

4. Interfaces antes que clases abstractas

Sigue siendo buen consejo: una clase puede implementar varias interfaces, pero solo heredar de una clase. Lo que ha cambiado es la objeción del original, que decía que añadir un método a una interfaz rompe todas las clases que la implementan. Desde Java 8 existen los métodos default, que traen su implementación, así que se pueden añadir sin romper nada. Lo vemos en el curso: herencia e interfaces.

5. Variables locales con el alcance más pequeño

Declara cada variable justo donde se usa por primera vez, y a ser posible inicializada en la misma línea. El original añadía «si no es posible, asígnale null», y eso es mal consejo: el compilador de Java ya avisa si usas una variable local sin haberle dado valor, y un null de relleno desactiva ese aviso y lo cambia por un NullPointerException en tiempo de ejecución.

6. Usa la biblioteca estándar

No reinventes lo que ya está probado. El original recomendaba «Google Collections», que hace años pasó a llamarse Guava. Además, buena parte de lo que se usaba de ella ya está en el JDK: List.of, Map.of, Optional, los streams, Objects.requireNonNull. Antes de añadir una dependencia, merece la pena mirar si no lo hace ya el propio Java.

7. Tipos primitivos antes que wrappers, y nunca == entre ellos

Integer a = 127, b = 127;
Integer c = 128, d = 128;
IO.println("127 == 127 -> " + (a == b));
IO.println("128 == 128 -> " + (c == d));
IO.println("128 equals 128 -> " + c.equals(d));

Boolean bandera = null;
try {
    if (bandera == true) IO.println("verdadero");
} catch (NullPointerException e) {
    IO.println(e.getMessage());
}
IO.println("Boolean.TRUE.equals(bandera) -> " + Boolean.TRUE.equals(bandera));
javac -g Wrappers.java && java Wrappers127 == 127 -> true
128 == 128 -> false
128 equals 128 -> true
Cannot invoke "java.lang.Boolean.booleanValue()" because "bandera" is null
Boolean.TRUE.equals(bandera) -> false

Esto es más traicionero de lo que contaba el original. Integer guarda una caché de los valores entre -128 y 127, así que == funciona con números pequeños y falla con los grandes: el error no sale en las pruebas con datos de ejemplo y aparece en producción. Con Integer, siempre equals.

El Boolean a null lanza la excepción porque, para compararlo con true, Java tiene que convertirlo a boolean, y null no tiene conversión. Boolean.TRUE.equals(bandera) es la forma segura.

El ejemplo original usaba new Integer(10). Ese constructor está obsoleto desde Java 9 y el compilador lo avisa. Se usa Integer.valueOf(10), o directamente Integer x = 10;.

8. Las cadenas, con cuidado

Dos cosas. new String("texto") crea un objeto innecesario: escribe directamente "texto". Y no acumules cadenas con += dentro de un bucle, porque el coste crece con el cuadrado del número de vueltas. Lo medimos en concatenar cadenas en un bucle.

9. Devuelve colecciones vacías, no null

List<String> buscarNull(String texto) { return texto.isEmpty() ? null : List.of(texto); }
List<String> buscar(String texto)     { return texto.isEmpty() ? List.of() : List.of(texto); }
java Vacias.javavacía: 0
null: NullPointerException

Con una lista vacía, el for simplemente no da ninguna vuelta. Con null, el for lanza la excepción, y cada uno de los que llamen al método tiene que acordarse de comprobarlo. Devolver List.of() no cuesta prácticamente nada, así que no hay excusa de rendimiento.

10. Las copias defensivas te salvan

El ejemplo clásico es una clase que guarda una fecha que le pasan:

class Estudiante {
    private final Date nacimiento;
    Estudiante(Date nacimiento) { this.nacimiento = nacimiento; }
    Date getNacimiento() { return nacimiento; }
}

class EstudianteCopia {
    private final Date nacimiento;
    EstudianteCopia(Date nacimiento) { this.nacimiento = new Date(nacimiento.getTime()); }
    Date getNacimiento() { return new Date(nacimiento.getTime()); }
}
Date fecha = new Date(100, 4, 17);
var e = new Estudiante(fecha);
var c = new EstudianteCopia(fecha);
fecha.setYear(2019);
salidasin copia: 3919
con copia: 2000
LocalDate: 2000-05-17 / 2019-05-17

La versión sin copia ve el cambio, porque guarda el mismo objeto que tiene quien la llamó. Y el año sale 3919, no 2019: setYear cuenta los años desde 1900. Es uno de los motivos por los que Date está obsoleta casi entera.

La copia del original, new Date(fechaNacimiento), no compila, porque Date no tiene ese constructor:

javac DateMal.javaDateMal.java:5: error: no suitable constructor found for Date(Date)
        Date copia = new Date(original);
                     ^
    constructor Date.Date(long) is not applicable
      (argument mismatch; Date cannot be converted to long)
    constructor Date.Date(String) is not applicable
      (argument mismatch; Date cannot be converted to String)

Lo correcto es new Date(fecha.getTime()), en el constructor y en el get. Aunque la solución de verdad es no usar Date: LocalDate, de java.time, es inmutable, así que no hace falta copiarla. withYear devuelve una fecha nueva y deja la original como estaba (última línea de la salida).

11. Que no se escape una excepción de un finally

El original solo daba el título. Esto es lo que pasa:

int dividir(int a, int b) {
    try {
        return a / b;
    } finally {
        throw new IllegalStateException("fallo al cerrar");
    }
}

int conReturn(int a, int b) {
    try {
        return a / b;
    } finally {
        return -1;
    }
}
java Finally.javaexcepción que llega: java.lang.IllegalStateException: fallo al cerrar
con return en finally: -1
try-with-resources: java.lang.ArithmeticException: / by zero
  suprimida: java.lang.IllegalStateException: fallo al cerrar

Las dos divisiones son entre cero, pero esa excepción desaparece. En la primera, la del finally la sustituye y nadie llega a saber que hubo una división entre cero. En la segunda, el return del finally se la traga y el método devuelve -1 como si todo hubiera ido bien.

try con recursos lo resuelve: si al cerrar el recurso también falla algo, la excepción original es la que llega, y la del cierre queda adjunta como «suprimida», sin perderse. Es la forma de cerrar archivos y conexiones desde Java 7.

12. No lances Exception a secas

Tampoco tenía explicación. throw new Exception("algo falló") obliga a quien te llama a capturar Exception, que es tanto como capturarlo todo: también los errores de programación que deberían verse. Lanza la excepción más concreta que exista (IllegalArgumentException si te pasan un dato inválido, IllegalStateException si el objeto no está listo, IOException si falla la entrada o salida) o crea la tuya. Lo vemos en el curso: excepciones y Optional.

Basado en 10 Most Useful Java Best Practice Quotes, de Viral Patel.

Entrada publicada en 2013 y revisada en octubre de 2026. Todo el código se compiló y se ejecutó con Java 25 LTS en un contenedor limpio; las salidas están copiadas de esa ejecución.

18 comentarios en «12 buenas prácticas en Java, revisadas para Java 25»

  1. Muy bueno 😛 Solo una nota:
    en el tip numero 10 si no me falla en el método Estudiante falta indicar que tipo de datos recibira el método que para ese ejemplo es un objeto de tipo Date y creo que es asi

    public Estudiante(Date fechaNacimiento) {
    this. fechaNacimiento = fechaNacimiento;
    }

    si estoy mal un disculpa 🙂

    Responder

Deja un comentario