Erstgespräch vereinbaren
Kommende Termine
4. Mai 2026
IT-Talk: Digitale Verantwortung
Details →
12. Juni 2026
Vernissage
28. September 2026
IT-Talk: Event-Driven Architecture

Immutable-Implementierungen in Java - klassische und besondere Fallstricke

Unveränderliche Objekte in Java: Defensive Copies, Builder Pattern, Guava Immutables und typische Fallstricke vermeiden.

Laptop mit geöffnetem Code-Editor

Die meisten Programmiersprachen bieten Mechanismen, um Werte als unveränderlich zu markieren, was die Fehlererkennung zur Kompilier- oder Laufzeit ermöglicht. Java bietet vergleichsweise wenig eingebaute Unterstützung für unveränderliche Datenstrukturen, weshalb viele Entwickler sie aufgrund des vermeintlich hohen Implementierungsaufwands meiden.

Zentrale Vorteile unveränderlicher Typen:

  • Vereinfachte Fehlererkennung
  • Sicherer Betrieb in nebenläufigen Umgebungen
  • Fehlermeldungen näher an der Ursache

Listen als Parameter

Das klassische Problem

Ein typisches Datenobjekt mit einem Listen-Attribut:

public class DataWithList {
  private final List<String> list;

  public DataWithList(List<String> list) {
    this.list = list;
  }

  public List<String> getList() {
    return list;
  }
}

Problem 1: Datenänderung nach Übergabe

Externe Änderungen an der Originalliste wirken sich auf die gespeicherte Referenz aus. Lösung: eine defensive Kopie im Konstruktor erstellen.

Problem 2: Datenänderung während der Nutzung

Konsumenten können die zurückgegebene Liste modifizieren und nachfolgende Aufrufer beeinflussen. Zwei Lösungen:

  1. Bei jedem getList()-Aufruf eine neue Kopie erstellen
  2. Collections.unmodifiableList() verwenden

Besserer Ansatz mit Guava

Wesentlich eleganter geht es mit den Immutable-Implementierungen von Guava:

public class DataWithListCopyAndImmutable {
  private final ImmutableList<String> list;

  public DataWithListCopyAndImmutable(List<String> list) {
    this.list = ImmutableList.copyOf(list);
  }

  public ImmutableList<String> getList() {
    return list;
  }
}

Unveränderliche Objekte

Eine Schwachstelle bei Vererbung:

public class BaseNumber {
  int value;

  public BaseNumber(int value) {
    this.value = value;
  }

  public int getValue() {
    return value;
  }
}

public class OtherNumber extends BaseNumber {
  public void setValue(int value) {
    this.value = value;
  }
}

Eine Unterklasse kann Mutator-Methoden einführen und die Unveränderlichkeitserwartungen brechen.

Beispiel-Implementierung

Eine korrekt implementierte unveränderliche Klasse:

public final class Person {
  private final String firstName;
  private final String lastName;

  public Person(String firstName, String lastName) {
    this.firstName = Preconditions.checkNotNull(firstName,"firstName is null");
    this.lastName = Preconditions.checkNotNull(lastName,"lastName is null");
    Preconditions.checkArgument(!firstName.isEmpty(),"firstName is empty");
    Preconditions.checkArgument(!lastName.isEmpty(),"lastName is empty");
  }

  public String getFirstName() {
    return firstName;
  }

  public String getLastName() {
    return lastName;
  }
}

Zentrale Anforderungen:

  • Die Klasse muss final sein, um Vererbung zu verhindern
  • Alle Felder müssen final sein
  • Idealerweise ein Konstruktor mit Validierung
  • Guavas Preconditions für Parametervalidierung verwenden

Reflection-Schwachstellen

Trotz Best Practices kann Reflection die Unveränderlichkeit umgehen:

ImmutableNumber instance = new ImmutableNumber(12);
Field valueField = instance.getClass().getDeclaredField("value");
valueField.setAccessible(true);
valueField.set(instance, 13);

Vorsicht vor Magie, man wird sie schwer wieder los.

Veränderliche Parameter

Wenn unveränderliche Alternativen nicht existieren (z.B. java.util.Date), sind defensive Kopien notwendig:

public final class MutableParameterContainer {
  private final Date date;
  private final byte[] data;

  public MutableParameterContainer(Date date, byte[] data) {
    this.date = copyOf(date);
    this.data = copyOf(data);
  }

  public byte[] getData() {
    return copyOf(data);
  }
}

Schnelle Lösungen

Interfaces verwenden, um Unveränderlichkeit auf Typebene zu erzwingen:

public interface PoorMansImmutable {
  String getName();
  int getAge();
}

Rückgabetypen als Interface statt als konkrete Klasse deklarieren verhindert den Zugriff auf Mutatoren zur Kompilierzeit.

Enums

Enums können unerwartet veränderlich sein, wenn Felder nicht als final markiert sind:

public enum MutableEnum {
  One(1), Two(2);

  private int value;

  private MutableEnum(int value) {
    this.value = value;
  }
}

Alle Enum-Felder sollten als final deklariert werden.

Builder Pattern

Für Objekte mit zahlreichen Attributen bieten Builder lesbare Konstruktion bei gleichzeitiger Beibehaltung der Unveränderlichkeit:

public final class SimpleBook {
  private final String isbn;
  private final String title;
  private final int year;

  private SimpleBook(String isbn, String title, int year) {
    this.isbn = Preconditions.checkNotNull(isbn, "isbn is null");
    this.title = Preconditions.checkNotNull(title, "title is null");
    this.year = Preconditions.checkNotNull(year, "year is null");
  }
}

public class SimpleBookBuilder implements Builder<SimpleBook> {
  private String isbn;
  private String title;
  private Integer year;

  @Override
  public SimpleBook build() {
    return new SimpleBook(this.isbn, this.title,
            Preconditions.checkNotNull(this.year, "year not set"));
  }
}

Vererbung mit Buildern

Abstrakte Basisklassen und Builder ermöglichen Code-Wiederverwendung:

public abstract class AbstractCatBuilder<B extends AbstractCatBuilder<B>> {
  String color;
  public B color(String color) {
    this.color = color;
    return (B) this;
  }
}

public final class MyCat extends AbstractCat {
  public static class MyCatBuilder extends AbstractCatBuilder<MyCatBuilder> {
    private String nickname;

    public MyCatBuilder nickname(String nickname) {
      this.nickname = nickname;
      return this;
    }
  }
}

Copy-with-Modifications

Für Szenarien, die selektive Attributänderungen erfordern:

public final class FilterSettings {
  private final Optional<Integer> minAge;
  private final Optional<Integer> maxAge;
  private final Optional<String> firstName;

  public FilterSettings withFirstName(String firstName) {
    return new FilterSettings(this.minAge, this.maxAge, Optional.of(firstName));
  }
}

Jede Methode erstellt eine neue Instanz mit geänderten Attributen.

Fazit

Leider mangelt es Java an einer sprachlichen Unterstützung für unveränderliche Objekte, dennoch lohnt sich der Aufwand. Die vereinfachte Fehlererkennung stellt den primären Vorteil unveränderlicher Datentypen dar. In Kombination mit Precondition-Checks verbessern diese Patterns die Code-Qualität erheblich.

Zurück zum Blog