Übung »Häufige DDD-Anti-Patterns bei Spring JPA«

Im ST2-Praktikum begegnen uns die nachfolgenden Anti-Patterns häufig. Will man eine DDD-Architektur mit Spring JPA umsetzen, dann sollte man diese Patterns vermeiden.

Dauer
Ca. 90 min

Inhalt

Worum geht es?

Wir gehen zunächst die nachfolgenden Anti-Patterns gemeinsam durch und diskutieren sie in der großen Runde. Dann gehen Sie in Kleingruppen in Ihren eigenen (Praktikums-)Code und schauen, ob Sie die Anti-Patterns auch bei sich finden. Refactoren Sie Ihren Code.

Anti-Patterns

Die nachfolgenden Patterns führen nicht zu Fehlern im Code, aber zu schlechtem Code.

UUIDs statt Entity-Referenzen

Einige Male haben wir folgenden Code gesehen:

@Entity
public class Planet {
    @Id
    @GeneratedValue
    private UUID id;
    private UUID northNeighbourId;
    private UUID eastNeighbourId;
    private UUID southNeighbourId;
    private UUID westNeighbourId;
    //...
}

Nur weil das Certification-Interface UUIDs verwendet, ist das noch lange kein Grund, das im Entity auch zu machen! So eine Programmierung führt dazu, dass man im ApplicationService dann so in der Art programmieren muss (Pseudocode - habe es nicht auf Lauffähigkeit getestet):

    public Planet findPlanet( UUID id ) {
        Planet planet = planetRepository.findById( id ).orElseThrow( () -> new PlanetException( "..." ) );
        UUID northNeighbourId = planet.northNeighbourId();
        Planet northNeighbour = planetRepository.findById( northNeighbourId ).
                                    orElseThrow( () -> new PlanetException( "..." ) );
        // etc.                            
    }

Statt dessen machen Sie IMMER Beziehungen zu anderen Entities:

@Entity
public class Planet {
    @Id
    @GeneratedValue
    private UUID id;
    @OneToOne
    private Planet northNeighbour;
    @OneToOne
    private Planet eastNeighbour;
    @OneToOne
    private Planet southNeighbour;
    @OneToOne
    private Planet westNeighbour;
    //...
}

Wenn Sie das machen, dann werden beim Fetch des Entities aus der DB die Nachbarschaftsbeziehungen mit geholt. Müssen Sie sich also gar nicht drum kümmern! Sie können in den Methoden von Planet direkt auf die Nachbarn zugreifen.

Code Copy-Pasting

Häufig auch das Copy-Pasting von Code, der eigentlich identisch ist. Vielleicht so in der Art:

    public void setNorthNeighbourRelation( Planet planet ) {
        //
        // 
        // ... irgendwelche 20 Zeilen von Prüfungen und setzen der Beziehung ... 
        //
    }
    
    public void setEastNeighbourRelation( Planet planet ) {
        //
        // 
        // ... derselbe Code, nur "north" durch "east" ersetzt ... 
        //
    }
    
    // ... und dann nochmal genauso für "south" und "west"

Es leuchtet vermutlich ein, warum das ein ganz schlechter Stil ist: Wenn Sie einen Bug einbauen und den dann fixen müssen, dann müssen Sie ihn 4x fixen. Wie hoch ist die Chance, dass man es 3x macht und 1x vergisst?

Prozedurales statt objekt-orientiertes Programmieren

Häufiger konnte man auch folgendes Anti-Pattern, das im Kern ein prozedurales statt objekt-orientiertes Programmieren ist. Im ApplicationService von Robot gab es dann so in etwa die folgenden Methoden:

    public void move( Direction direction, Robot robot ) {
        // ... und dann wurde der Robot mittels Repo aus der DB geholt, auf einen neuen Planeten bewegt,  
        // und dann mittels Repo wieder abgespeichert.
    }

Damit ist die Business-Logik für den Robot da, wo sie nicht hingehört: nämlich außerhalb des Robots. Viel besser (und objektorientiert …) ist es, diese Logik im Robot selbst zu implementieren. Ganz grob etwa so:

@Service
public class RobotApplicationService {
    //...
    public void executeCommand( Command command ) {
        //...
        Robot robot = robotRepository.findById( command.getRobotId() ).
                            orElseThrow( () -> new RobotException( "..." );
        if ( command.isMove() ) {
            robot.move( command.getDirection() );
        }
    }
    //...
}

@Entity
public class Robot {
    //...
    public void move( Direction direction ) {
        // Move-Logik ist jetzt da, wo sie hingehört: INNERHALB des Robots.
        //...
    }
    //...
}

Business-Logik im ApplicationService, statt in der Domain (also in den Entities)

Wenn man das vorige Anti-Pattern “konsequent umsetzt” (Achtung, Ironie!), dann ist am Ende die gesamte Business-Logik in einer Klasse, nämlich dem ApplicationService. Die Entities sind dann nur noch reine “CRUD-Container”. Damit hat man jetzt Art von Reuse und guter Wartbarkeit wirkungsvoll verhindert :-(.

Value Objects mit public Settern

Thematischer Schwenk: Value Objects (… @Embeddable-Klassen …) sind per Definition immutable. Sie haben also keine public Setter! Sonst kann man sie von außen ändern.

Value Objects ohne passende equals( Object obj ) Implementation

Zwei Value Objects sind per Definition gleich, wenn ihre Attributwerte gleich sind. Daraus folgt zwingend, dass sie eine Implementation für die Methode equals( Object obj ) haben, die alle Attributwerte einbezieht und vergleicht. Wenn man das nicht implementiert, wird nur die Java-Object-ID verglichen - und dann sind zwei verschiedene Instanzen immer ungleich.

Repositories in den Entities selbst

Man könnte versucht sein, im Entity selbst ein Repository als Membervariable (oder Methodenparameter) zu haben - dann könnte man ja im Entity direkt speichern, oder andere Entities holen.

Das sollte man aber vermeiden, denn der Domain Layer ist technologie-agnostisch. Man bräuchte ja keine relationale Datenbank zu nutzen, sondern könnte Objekte anders persistieren. Man sollte also das Lifecycle-Management einer DB außerhalb des Domain Layers machen - z.B. in einem ApplicationService.

IDs vom Typ Long statt UUID

In ganz vielen Web-Tutorials zu JPA findet man folgende Implementation einer Entity-ID:

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

Long-IDs haben aber große Nachteile, insbesondere dass sie Sequenzen bilden und damit “erratbar” werden. Daher: Nehmen Sie im Regelfall immer UUIDs als Entity-ID. Also besser:

    @Id
    @GeneratedValue
    private UUID id;

System.out.println statt Verwendung des Loggers

System.out.println ist praktisch, um sich ohne Debugger den “Trace” einer Anwendung ausgeben zu lassen. Für das Praktikum ist das egal, aber in der Praxis ist es immer besser, stattdessen einen Logger zu benutzen. Der funktioniert auch im Produktiv-Environment (im Gegensatz zu System.out.println). In Spring ist das ganz einfach:

@Entity
public class Planet {
    //...
    @Transient    
    private Logger logger = LoggerFactory.getLogger( Planet.class );
    //...
    
    public void setNeighbour( Direction direction, Planet neighbour ) {
        logger.info( "Attempt to set neighbour " + neighbour + " at " + direction );
    }
}

Im Produktiv-Environment können Sie den Logging-Level dann je nach Bedarf hoch- oder runtersetzen.

Field-based Autowiring

Sie können eine Spring-gemanagedte Klasse (z.B. ein Repository oder einen Service) über @Autowired automatisch instanziieren lassen. Dafür gibt es mehrere grundsätzliche Wege. Beim Field-based Autowiring wird @Autowired direkt über der Variablendeklaration angegeben:

public class PlanetApplicationService {
    @Autowired
    private PlanetRepository planetRepository;
    //...
}

Damit kann man die Klasse PlanetApplicationService aber nicht ohne das Spring-Framework nutzen, z.B. wenn man mit einem Mock testen will. Besser ist Constructor-based Autowiring:

public class PlanetApplicationService {
    private PlanetRepository planetRepository;

    @Autowired
    public PlanetApplicationService( PlanetRepository planetRepository ) {
        this.planetRepository = planetRepository;
    }
    //...
}

Hier werden die Parameter des Konstruktors “autowired”. Man kann die Klasse dann auch ohne Spring instanziieren.

Häufig gemachte Fehler

Die nachfolgenden Patterns führen tatsächlich unter Umständen dazu, dass eine Spring-Applikation nicht mehr läuft, also z.B. die allseits beliebte Failed to load Application Context Exception zu werfen.

Entity ohne Repo

Zu jedem Entity muss es ein Repository (Interface, z.B. von CrudRepository abgeleitet) geben.

Entity ohne ID

Wenn man bei einem Entity vergisst, eine ID mit @Id zu spezifizieren, dann bekommt man in etwa folgende Exception:

Invocation of init method failed; nested exception is org.hibernate.AnnotationException: 
        No identifier specified for entity: thkoeln.st.st2praktikum.planet.domain.Planet

Versuch, ein Entity zu speichern, bei dem die ID schon bei einem anderen Entity vorhanden ist, oder null

Beides führt zu einer Constraint Violation in der unterliegenden relationalen Datenbank, und damit zu einer Hibernate-Exception.

Abgeleitetes Entity, und die Oberklasse ist nicht auch als Entity getaggt

Vererbung sollte man sich grundsätzlich überlegen, weil man sich ein paar Nachteile einhandelt, was enge Kopplung und z.B. Serialisierbarkeit in REST-APIs angeht. Die Alternative ist Komposition (googeln Sie mal “Composition over Inheritance” für mehr Hintergrund dazu).

Wenn man Entities ableitet, dann muss man aufpassen, dass auch der Parent als @Entity getaggt ist.

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
public abstract class MyAbstractParentEntity {
    @Id
    @GeneratedValue
    private UUID id;
    //...
}

@Entity
public class ConcreteEntity extends MyAbstractParentEntity {
    // id wird geerbt ...
}

@Entity
public class AnotherConcreteEntity extends MyAbstractParentEntity {
    //...
}

Repo mit falschem Entity- oder ID-Typ

Ein Repository sieht wie folgt aus:

public interface PlanetRepository extends CrudRepository<Planet, UUID> {
}

Hier wird gern mal jeder möglich Unfug :-) in die spitzen Klammern geschrieben: Mal die Repo-Klasse selbst, mal ein falscher ID-Typ (z.B. wenn das Entity UUID als ID-Typ verwendet, im Repo aber Long steht). Nicht immer sind die Fehlermeldungen sofort verständlich.

Falscher Transaktion-Kontext, zu merken z.B. durch LazyInitializationException

Manchmal bekommt man aus seinem Domain-Layer-Code folgende Exception. Grund ist, dass der Aufruf außerhalb des Transaktionskontexts ist, und das dynamische Nachladen (“Lazy Loading”) von Entity-Referenzen schlägt fehl.

org.hibernate.LazyInitializationException: could not initialize proxy 
[thkoeln.st.st2praktikum.planet.domain.Planet#aa225c5c-d72f-4d15-bd90-547d818ef338] - no Session

Der gern genutzte Fix hier ist, den Fetchtype auf “eager” umzustellen. Damit wird das Lazy Loading ausgeschaltet, und alle Referenzen werden direkt der Query des Objekts mit aus der Datenbank geladen. Das kann ok sein, kann bei verschachtelten Strukturen aber auch sehr “teuer” werden.

@Entity
public class Planet {
    //...
    @OneToOne ( fetch = FetchType.EAGER )
    private Planet northNeighbour;
    //...

Alternativ, und meistens die bessere Variante, ist es, die Transaktionskontexte zu analysieren. Hier kann man mit einer @Transactional Annotation an Methoden und Klassen arbeiten. Muss man im Einzelfall sorgfältig analysieren.

Autowiring außerhalb von Spring gemanagedten Klassen

Versucht man ein Autowiring in außerhalb von Spring gemanagedten Klassen, dann funktioniert das nicht - beim Zugriff erhält man eine NullPointerException. Dazu zählen alle Klassen, die nicht mit @Service, @Component oder @Bean getaggt sind - also auch Entities!

“Viel hilft viel” - @Entity, @Embeddable und @Service gemeinsam an einer Klasse

Das war auch in einem Codebeispiel dabei - aber “viel hilft viel” gilt hier leider nicht.

@Embeddable
@Entity
@Component
public class Planet {
    //...
    @OneToOne ( fetch = FetchType.EAGER )
    private Planet northNeighbour;
    //...

Mehrdimensionale Collections für Entity- oder Embeddable-Beziehungen

Mehrdimensionale Collections, z.B. HashMaps, können in JPA nicht direkt persistiert werden. Wie sollte eine Tabellenstruktur dafür aussehen?

EntityNotFoundException für eine Entity-Referenz beim Speichern

Bekommt man eine EntityNotFoundException für eine Entity-Referenz beim Speichern, dann wird möglicherweise versucht, eine Referenz zu einem noch transienten Entity abzuspeichern. Hibernate bekommt eine ID, geht davon aus, dass es die in der DB gibt, und bekommt dann eine Constraint Violation.

@OneToOne ( cascade = CascadeType.MERGE )

Wenn es aber gewollt ist, dass die Referenz ggfs. noch nicht persistiert ist, dann kann man die Referenz entsprechen mit einer Cascade-Anweisung annotieren. Mit der “Merge” Direktive legt Hibernate das noch transiente referenzierte Objekt dann vorab in der DB an.