Algebraic Data Types in Java

  • von Nicolai Mainiero

Nicolai Mainiero, Softwarearchitekt bei sidion

Domain Driven Design beim Aktienhandel

Bei der Modellierung von Businessobjekten im Domain Driven Design kommt es vor, dass eine einfache Aufzählung (Enum) wie sie in Java standardmäßig vorhanden ist nicht ausreichend ist, da sich die einzelnen Ausprägungen unterscheiden bzw. verschiedene Eigenschaften haben.

Hier am Beispiel eines OrderDetails Objekt einer Applikation zum Aktienhandel.

  public class OrderDetails {
      private final String symbol;
      private final int shares;
      private final TradeType tradeType;
      private final OrderType orderType;
  }

Um anzuzeigen ob gekauft oder verkauft werden soll, geben wir den TradeType an. Diese Aufzählung besteht aus den beiden Möglichkeiten, kaufen oder verkaufen.

  public enum TradeType {
    BUY,
    SELL
  }

Der OrderType ist etwas komplexer, da wir neben dem Auftragstyp auch noch weitere Daten erfassen möchten. Eine Market Order zum Beispiel wird sofort zu aktuellen Marktpreisen ausgeführt, eine Limit Order hingegen wird zu einem festgelegten Preis ausgeführt, benötigt also mehr Informationen als eine simple Market Order. Wenn später noch Stop Order hinzugefügt werden müssen sogar zwei Informationen, der Limitpreis und der Stopppreis, erfasst werden.

Algebraischer Datentyp

Ein algebraischer Datentyp besteht aus mehreren Varianten oder Ausprägungen, ähnlich zu einem Java enum, allerdings können die verschiedenen Ausprägungen verschiedene Eigenschaften oder Methoden haben. Leider werden algebraische Datentypen nicht direkt in Java unterstützt, so dass man bei der Implementierung etwas kreativ werden muss.

Nochmals zusammengefasst, was wir von einem ADT erwarten:

  • Die Ausprägungen haben unterschiedliche Anzahl und Typen an Eigenschaften und Methoden
  • Zur Compilezeit wird überprüft, dass alle Ausprägungen auch berücksichtigt werden

Werfen wir zunächst ein Blick auf Scala das (in der aktuellen Version) ADTs auch nicht direkt unterstützt, aber mit deutlich weniger Code auskommt. Dennoch gibt es uns eine Richtung vor, die wir auch in Java nutzen können.

sealed abstract class OrderType
case object Market extends OrderType
case class Limit(priceLimit: BigDecimal) extends OrderType

In Scala prüft der der Compiler ob auf alle möglichen Ausprägungen getestet worden ist. Ob eine Kauforder ausgeführt werden soll, könnte in Scala dann folgendermaßen aussehen:

order match {
  case Market => true
  case Limit(priceLimit) => price <= priceLimit
}

In Java kann das mit Hilfe zweier Designpattern nachgebaut werden:

Damit sieht dann der OrderType in Java wie folgt aus:

package de.sidion.articles.adt;

import java.math.BigDecimal;

public abstract class OrderType {
    private OrderType() {
    }

    public static final class Market extends OrderType {
        private Market() {
        }

        public static final Market INSTANCE = new Market();

        public <T> T visit(Visitor<T> visitor) {
            return visitor.visit(this);
        }
    }

    public static final class Limit extends OrderType {

        private final BigDecimal limitPrice;

        public Limit(BigDecimal limitPrice) {
            this.limitPrice = limitPrice;
        }

        public <T> T visit(Visitor<T> visitor) {
            return visitor.visit(this);
        }

        public BigDecimal getLimitPrice() {
            return this.limitPrice;
        }

    }

    public interface Visitor<T> {
        T visit(Market m);

        T visit(Limit l);
    }

    public abstract <T> T visit(Visitor<T> visitor);
}

Das Visitor Interface sorgt dafür, dass alle möglichen Ausprägungen berücksichtigt werden, vorausgesetzt das Interface wird auch bei der Implementierung der Geschäftslogik verwendet.

Das Sealed Classs Pattern verwendet eine abstrakte Klasse mit privatem Konstruktor, damit konkrete Klassen nur als innere Klassen innerhalb des OrderType erstellt werden können.

Die Logik kann jetzt wie folgt implementiert werden:

orderType.visit(new OrderType.Visitor<Boolean>() {
   
    public Boolean visit(OrderType.Market m) {
        return true;
    }

    public Boolean visit(OrderType.Limit limitOrder) {
        return price.compareTo(limitOrder.getLimitPrice()) <= 0;
    }
});

Dadurch, dass hier der Visitor und keine if-instanceOf-else verwendet wird, können wir zur Compilezeit sicher sein, dass alle möglichen Fälle berücksichtigt worden sind. Außerdem führt das Hinzufügen eines weiteren OrderType zu einem Compilierfehler, die dann dafür sorgen, dass dieser Typ korrekt berücksichtigt wird.

Ausblick auf Sealed Classes in Java 15

Mit Java 15 wurden sealed classes (JEP 360 [1]) als preview feature eingeführt. Betrachten wir, wie sich das Entwurfsmuster damit umsetzen lässt.

sealed interface OrderType permits OrderType.Market, OrderType.Limit {

    final class Market implements OrderType {
        private Market() {
        }

        public static final Market INSTANCE = new Market();

        public <T> T visit(Visitor<T> visitor) {
            return visitor.visit(this);
        }
    }

    final class Limit implements OrderType {...}

    interface Visitor<T> {
        T visit(Market m);

        T visit(Limit l);
    }

    <T> T visit(Visitor<T> visitor);
}

Tatsächlich ändert sich noch nicht so viel. Zwar kann nun mit Java Sprachmitteln eine nicht erweiterbare Klassenhierachie definiert werden, aber es kann noch nicht auf Records, ein weiteres preview feature, umgestiegen werden. Dafür fehlt noch das Patternmatching für switch Ausdrücke [2]. Dann lässt sich der Code mit Hilfe von Records und pattern matching stark vereinfachen.

boolean accepted = switch (order) {
    case Market m -> true;
    case Limit l -> price.compareTo(l.getLimitPrice()) <= 0;

Auch hier würde der Compiler wieder dafür sorgen, dass alle Möglichkeiten berücksichtigt werden.

Fazit

Mit ein bisschen Kreativität ist es bereits jetzt schon möglich ADTs in Java zu realisieren und zur Modellierung der Business Domain zu verwenden. Dabei unterstützt der Compiler und sorgt dafür, dass alle Ausprägungen auch berücksichtigt werden. Durch den raschen Entwicklungszyklus von Java können Features parallel entwickelt und frühzeitig veröffentlicht werden. Auch wenn sich dadurch wie hier, ein unvollständiges Bild ergibt, können bereits erste Erfahrungen gemacht werden.

[1] JEP 360: Sealed Classes (Preview)

[2] JEP draft: Pattern matching for switch (Preview)

[3] GitHub - nicolaimainiero/adt-examples-java: Sample of ADT in Java with sealed classes and the visitor pattern

Zurück