Inmutabilidad en Java

Intermedio
Java
Java
Actualizado: 27/04/2026

Cómo construir clases inmutables

flowchart TB
    Imm[Clase inmutable] --> Final[final clase]
    Imm --> Priv[Campos private final]
    Imm --> NoSet[Sin setters]
    Imm --> Defen[Copia defensiva en getters]
    Imm --> Cons[Constructor copia parámetros]
    Imm --> Ret[Devolver copias colecciones]
    Imm --> Rec[record canonical]
    Imm --> List[List copyOf]
    Imm --> Map[Map copyOf]
    Imm --> Eff[Eficiencia y thread safe]

Qué es una clase inmutable

Una clase inmutable es aquella cuyas instancias, una vez creadas, no pueden cambiar su estado. Todos los atributos quedan fijos tras el constructor. Si quieres un valor distinto, creas una nueva instancia.

String es el ejemplo más conocido en Java: s.toUpperCase() no modifica s; devuelve un nuevo String. Otros ejemplos: Integer, LocalDate, UUID, Path.

Por qué la inmutabilidad importa

  • Thread-safe por definición: sin estado mutable, no hay condiciones de carrera. No necesitan sincronización.
  • Predecibilidad: una vez que un método recibe la instancia, puede confiar en que no cambiará durante su ejecución.
  • Cachable: el hashCode se calcula una vez; el objeto puede ser clave de HashMap sin riesgo.
  • Testabilidad: los tests no tienen que preocuparse por mutaciones indirectas.
  • Seguridad: una API que recibe String no puede "dañar" el estado del llamador mutándolo.

La inmutabilidad es una de las bases de la programación funcional y del diseño concurrency-safe moderno.

Receta clásica de clase inmutable

Los pasos tradicionales para hacer una clase inmutable:

  1. Declarar la clase final (para que no la hereden y rompan invariantes).
  2. Todos los campos private final.
  3. No exponer setters.
  4. Inicializar todos los campos en el constructor.
  5. Copiar defensivamente los objetos mutables al entrar y al salir de la clase.
public final class Persona {
    private final String nombre;
    private final int edad;
    private final List<String> aficiones; // cuidado: List es mutable

    public Persona(String nombre, int edad, List<String> aficiones) {
        this.nombre = nombre;
        this.edad = edad;
        // Copia defensiva de entrada
        this.aficiones = List.copyOf(aficiones);
    }

    public String nombre() { return nombre; }
    public int edad() { return edad; }
    public List<String> aficiones() {
        // Copia defensiva de salida (List.copyOf devuelve inmutable)
        return aficiones; // ya es inmutable
    }
}

Copias defensivas de entrada

Si aceptas una colección mutable desde fuera, cópiala. Si no, el llamador podría modificarla después:

// SIN copia defensiva: ¡vulnerabilidad!
public Persona(String n, int e, List<String> a) {
    this.aficiones = a; // referencia compartida
}

List<String> mutable = new ArrayList<>(List.of("leer", "correr"));
Persona p = new Persona("Ana", 30, mutable);
mutable.add("hackear"); // ¡cambia el estado interno de p!

Con List.copyOf(x) (Java 10+) obtienes una copia inmutable: es la forma más limpia. Alternativas: new ArrayList<>(x), Collections.unmodifiableList(new ArrayList<>(x)).

Copias defensivas de salida

Si devuelves un objeto mutable, no devuelvas la referencia interna directa:

// SIN copia defensiva
public List<String> aficiones() { return aficiones; } // el llamador puede modificarla

Devuelve una vista inmutable (Collections.unmodifiableList) o una copia. Si ya almacenaste una lista inmutable (con List.copyOf), puedes devolverla directamente.

Inmutabilidad con records (Java 16+)

Los records son la forma moderna y concisa de declarar clases inmutables:

public record Persona(String nombre, int edad, List<String> aficiones) {
    // Constructor compacto para validación y copia defensiva
    public Persona {
        Objects.requireNonNull(nombre);
        if (edad < 0) throw new IllegalArgumentException("edad < 0");
        aficiones = List.copyOf(aficiones);
    }
}

Los records automáticamente:

  • Declaran todos los componentes como final.
  • Generan accessors (nombre(), edad(), aficiones()).
  • Implementan equals, hashCode, toString coherentes.
  • Son final (no se pueden heredar).

Son la opción por defecto en Java moderno para datos inmutables.

Patrón "wither" (modificación inmutable)

Cuando necesitas una "copia modificada" de una instancia inmutable, añade métodos withX(...) que devuelven nuevas instancias:

public record Configuracion(String host, int puerto, int timeout) {
    public Configuracion withHost(String nuevoHost) {
        return new Configuracion(nuevoHost, puerto, timeout);
    }

    public Configuracion withPuerto(int nuevoPuerto) {
        return new Configuracion(host, nuevoPuerto, timeout);
    }

    public Configuracion withTimeout(int nuevoTimeout) {
        return new Configuracion(host, puerto, nuevoTimeout);
    }
}

// Uso
Configuracion base = new Configuracion("localhost", 8080, 30);
Configuracion prod = base.withHost("prod.example.com").withPuerto(443);
// base sigue inalterada

Se puede encadenar, es thread-safe y permite definir defaults sin builders complejos.

Inmutabilidad profunda vs superficial

  • Superficial (shallow): los campos directos no cambian, pero si alguno apunta a un objeto mutable, ese objeto sí puede mutar.
  • Profunda (deep): ni el objeto ni ninguno de sus campos, ni los campos de sus campos, son mutables.
public final class Equipo {
    private final String nombre;
    private final List<Persona> miembros; // Persona es inmutable, List.copyOf es inmutable

    public Equipo(String nombre, List<Persona> miembros) {
        this.nombre = nombre;
        this.miembros = List.copyOf(miembros);
    }
}

Aquí Equipo es profundamente inmutable porque Persona también lo es y la List está copiada como inmutable.

Colecciones inmutables

Para colecciones pequeñas y fijas:

List<String> colores = List.of("rojo", "verde", "azul");
Set<Integer> primos = Set.of(2, 3, 5, 7, 11);
Map<String, Integer> edades = Map.of("Ana", 30, "Bob", 25);

Para copias de colecciones existentes:

List<String> copia = List.copyOf(otraLista);

Intentar modificar una colección inmutable lanza UnsupportedOperationException.

Cuándo NO ser inmutable

La inmutabilidad tiene costes. Para objetos muy grandes que cambian frecuentemente, crear copias en cada modificación es caro.

  • Entidades con ciclo de vida mutable: p.ej. entidades JPA, modelos de UI complejos.
  • Estructuras de rendimiento crítico: buffers, arrays grandes que se modifican in-place.
  • Estado acumulativo interno (builders, cachés). Un builder típico es mutable hasta que se construye el resultado inmutable.

La regla práctica: inmutabilidad por defecto, mutabilidad justificada. Empieza inmutable; convierte en mutable solo cuando midas el coste real.

Resumen

| Opción | Cuándo usar | |--------|-------------| | record | Datos puros con constructor canónico (Java 16+) | | Clase final con campos private final | Clases inmutables con lógica no trivial | | List.of, Set.of, Map.of | Colecciones literales pequeñas (Java 9+) | | List.copyOf(x) | Copia inmutable de una colección existente (Java 10+) | | Patrón wither | Derivar nuevas instancias modificadas | | Collections.unmodifiableList | Vista no modificable sobre lista existente (legacy) |

La inmutabilidad es una elección de diseño: pocos conceptos dan tanto retorno en robustez como limitar el estado mutable al mínimo imprescindible.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Java es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Java

Explora más contenido relacionado con Java y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Definir qué es una clase inmutable y sus garantías. Aplicar final a la clase, campos y referencias. Implementar copias defensivas para tipos mutables compuestos. Usar records como la forma moderna de inmutabilidad. Comprender ventajas en concurrencia y testabilidad. Aplicar el patrón 'wither' para derivar nuevas instancias.