Im vorherigen, zweiten Teil dieser Serie über die Hexagonale Architektur (deren offizieller Name „Ports and Adapters“ lautet), habe ich demonstriert, wie man eine Java-Anwendung mit eben dieser Architektur implementiert – zunächst ohne Application Framework und Datenbank. Stattdessen haben wir einen simplen Dependency-Injection-Mechanismus implementiert und ein In-Memory-Repository als Persistenz-Adapter eingesetzt.
In diesem dritten Teil werden wir einen Adapter implementieren, der die Daten – Warenkörbe und Produkte eines Online-Shops – in einer Datenbank speichert. Und um die Flexibilität unserer Architektur zu unterstreichen, werden wir den Adapter nicht einfach ersetzen, sondern ihn hinzufügen und den gewünschten Adapter (In-Memory oder Datenbank) über eine System Property auswählbar machen.
Die Architektur unserer Anwendung sieht aktuell wie folgt aus (Port und Adapter, um die es heute geht, sind hervorgehoben):
Die anfängliche Implementierung ohne Datenbank erlaubt es uns nun – ganz im Sinne der Ports-and-Adapters-Architektur – die Entscheidung über die einzusetzende Datenbank und das Datenbankschema auf die Erfahrungen zu stützen, die wir bei der Entwicklung des Anwendungskerns gesammelt haben.
Warenkörbe und Produkte lassen sich sehr gut in einer relationalen Datenbank ablegen. Daher werden wir für die Persistierung MySQL verwenden und für das O/R-Mapping JPA. Die folgende Grafik zeigt unsere Ziel-Architektur:
Und all das werden wir – wiederum im Sinne der Ports-and-Adapters-Architektur – umsetzen, ohne eine einzige Zeile Code im Anwendungskern ändern zu müssen.
Eingesetzte Technologien
Wir werden die folgenden zusätzlichen Technologien für die Erweiterung unserer Anwendung einsetzen:
- Hibernate als OR-Mapper (Implementierung der Jakarta Persistence API – JPA),
- Testcontainers, ein Framework, das es uns erlaubt, aus Tests heraus eine MySQL-Datenbank als Docker-Container zu starten.
Mit Hilfe von Testcontainers werden wir Integration-Tests für die Hibernate-Adapter implementieren.
Den Quellcode zum Artikel findest du in diesem GitHub-Repository.
Sekundäre Ports und Adapter
Zur Auffrischung zeige ich dir in der folgenden Grafik noch einmal den aktuellen Stand der sekundären Ports und Adapter (an den primären Ports und Adaptern werden wir in diesem Teil der Serie nichts ändern – diese habe ich daher im oberen Teil des Klassendiagramms ausgeblendet).
Wir werden heute das Paket eu.happycoders.shop.adapter.out.persistence.jpa hinzufügen. Das folgende Klassendiagramm zeigt dieses Paket mit zwei neuen Adaptern – JpaCartRepository
und JpaProductRepository
– im rechten unteren Bereich:
Bevor wir mit der Implementierung beginnen, möchte ich auf das Thema Mapping zu sprechen kommen, das ich hier im ersten Teil der Serie behandelt habe.
Wir werden hier das von mir favorisierte Zwei-Wege-Mapping einsetzen: Wir werden für jede Modell-Entity eine entsprechende JPA-Entity anlegen und dann sowohl beim Persistieren einer Entity als auch beim Laden aus der Datenbank auf die jeweils andere Entity-Klasse mappen.
Implementierung der Adapter-Klassen
Der folgenden Screenshot zeigt das um alle benötigten Klassen erweiterte adapter-Modul in der IDE (das Paket mit den primären Adaptern, in.rest habe ich zugeklappt):
Beginnen wir mit der Implementierung der JPA-Entities…
Implementierung der JPA-Entity-Klassen
Um die JPA-Klassen implementieren zu können, benötigen wir zunächst eine Abhängigkeit zu JPA. Die fügen wir mit folgendem Eintrag in der pom.xml des adapter-Moduls hinzu:
<dependency>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
<version>3.1.0</version>
</dependency>
Code-Sprache: HTML, XML (xml)
Beachte, dass wir an dieser Stelle noch keine Dependency zu Hibernate benötigen. Hibernate ist eine Implementierung von JPA, daher benötigen wir es erst zur Laufzeit. Wir werden Hibernate (und einige zusätzlich benötigte Abhängigkeiten, wie den MySQL-JDBC-Treiber) im weiteren Verlauf im Test-Scope hinzufügen – und später noch einmal im bootstrap-Modul.
ProductJpaEntity
Beginnen wir mit der JPA-Entity für das Produkt. Hier zur Erinnerung die entsprechende Entity-Klasse, Product (der Link führt ins GitHub-Repository), aus dem model-Modul:
@Data
@Accessors(fluent = true)
@AllArgsConstructor
public class Product {
private final ProductId id;
private String name;
private String description;
private Money price;
private int itemsInStock;
}
Code-Sprache: Java (java)
ProductId ist ein Record, der lediglich einen String enthält. Die Verwendung solch eines Value Objects hat zwei Vorteile: Zum einen bringt es Typsicherheit mit sich, zum anderen können wir die Gültigkeit einer Produkt-ID im Konstruktor des Value Objects sicherstellen.
Wir erstellen nun im Paket eu.happycoders.shop.adapter.out.persistence.jpa eine entsprechende JPA-Entity-Klasse, ProductJpaEntity:
@Entity
@Table(name = "Product")
@Getter
@Setter
public class ProductJpaEntity {
@Id private String id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String description;
@Column(nullable = false)
private String priceCurrency;
@Column(nullable = false)
private BigDecimal priceAmount;
private int itemsInStock;
}
Code-Sprache: Java (java)
Die JPA-Entity unterscheidet sich in einigen Punkten von der Modell-Entity:
- Die technischen JPA-Annotationen
@Entity
und@Table
machen die Klasse zu einer JPA-Entity, die auf die Datenbank-Tabelle „Product“ abgebildet wird. - Die mit
@Id
markierte ID ist ein String, keinProductId
-Record. - Alle nicht-primitiven Pflichtfelder sind mit
@Column(nullable = false)
annotiert, damit sich nicht irrtümlichnull
-Werte in die Datenbank einschleichen. - Der Preis, der in der Modell-Klasse vom Typ
Money
war, ist – in Ermangelung eines entsprechenden SQL-Typs – aufgeteilt auf zwei Felder:priceCurrency
undpriceAmount
.
CartLineItemJpaEntity
Hier zur Erinnerung das CartLineItem (ein Eintrag im Warenkorb) aus dem model-Modul:
@Getter
@Accessors(fluent = true)
@RequiredArgsConstructor
@AllArgsConstructor
public class CartLineItem {
private final Product product;
private int quantity;
// ...
}
Code-Sprache: Java (java)
Die Modell-Klasse enthält zudem Methoden, um die Anzahl eines Produkts zu erhöhen und um den Gesamtpreis einer Warenkorbposition zu berechnen.
Hier ist die zugehörige JPA-Klasse, CartLineItemJpaEntity, ebenfalls im Paket eu.happycoders.shop.adapter.out.persistence.jpa. Die JPA-Entity benötigt keine entsprechenden Methoden, da ihr einziger Zweck die Persistierung ist.
@Entity
@Table(name = "CartLineItem")
@Getter
@Setter
public class CartLineItemJpaEntity {
@Id @GeneratedValue private Integer id;
@ManyToOne private CartJpaEntity cart;
@ManyToOne private ProductJpaEntity product;
private int quantity;
}
Code-Sprache: Java (java)
Wir sehen hier einige weitere JPA-Annotationen:
@GeneratedValue
am Primärschlüsselid
sorgt dafür, dass dieses Feld automatisch von MySQL durch aufsteigende Zahlen gefüllt wird. Da dieser Primärschlüssel lediglich für die Datenbank von Relevanz ist, gibt es in der Modellklasse kein entsprechendes Feld.@ManyToOne
ancart
definiert eine N:1-Beziehung zum Warenkorb und führt letztendlich dazu, dass in der DatenbanktabelleCartLineItem
eine Spalte mit dem Namencart_id
erzeugt wird sowie eine Fremdschlüsselbeziehung auf dieCart
-Tabelle.@ManyToOne
anproduct
definiert entsprechend eine N:1-Beziehung zum Produkt.
CartJpaEntity
Und zuletzt, wieder zur Erinnerung, die Cart-Klasse aus dem model-Modul:
@Accessors(fluent = true)
@RequiredArgsConstructor
public class Cart {
@Getter private final CustomerId id; // cart ID = customer ID
private final Map<ProductId, CartLineItem> lineItems = new LinkedHashMap<>();
// ...
}
Code-Sprache: Java (java)
Die Modellklasse enthält außerdem Methoden, um Produkte hinzuzufügen, die Warenkorbeinträge abzurufen und um den Gesamtwert des Warenkorbs zu berechnen.
Hier ist die entsprechende JPA-Klasse, CartJpaEntity:
@Entity
@Table(name = "Cart")
@Getter
@Setter
public class CartJpaEntity {
@Id private int customerId;
@OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true)
private List<CartLineItemJpaEntity> lineItems;
}
Code-Sprache: Java (java)
Hier habe ich eine weitere JPA-Annotation verwendet:
@OneToMany
anlineItems
führt dazu, dass ein Zugriff auf dieses Feld alle auf diesen Warenkorb referenzierenden Einträge derCartLineItem
-Tabelle ausliest und in dieser Liste speichert. Und andersherum werden beim Speichern eines Warenkorbs alle in dieser Liste befindlichen Einträge in dieCartLineItem
-Tabelle geschrieben.
Damit haben wir alle benötigten JPA-Klassen implementiert. Kommen wir nun zu den Mappern.
Implementierung der Modell-JPA-Mapper-Klassen
Beginnen wir wieder mit dem Produkt…
ProductMapper
Der ProductMapper wandelt ein Product
in eine ProductJpaEntity
um und umgekehrt. Der Mapper liegt im gleichen Paket wie die JPA-Entities, eu.happycoders.shop.adapter.out.persistence.jpa. Da hier auch die Adapter liegen werden, brauchen der Mapper und seine Methoden nicht public
zu sein.
final class ProductMapper {
private ProductMapper() {}
static ProductJpaEntity toJpaEntity(Product product) {
ProductJpaEntity jpaEntity = new ProductJpaEntity();
jpaEntity.setId(product.id().value());
jpaEntity.setName(product.name());
jpaEntity.setDescription(product.description());
jpaEntity.setPriceCurrency(product.price().currency().getCurrencyCode());
jpaEntity.setPriceAmount(product.price().amount());
jpaEntity.setItemsInStock(product.itemsInStock());
return jpaEntity;
}
static Optional<Product> toModelEntityOptional(ProductJpaEntity jpaEntity) {
return Optional.ofNullable(jpaEntity).map(ProductMapper::toModelEntity);
}
static Product toModelEntity(ProductJpaEntity jpaEntity) {
return new Product(
new ProductId(jpaEntity.getId()),
jpaEntity.getName(),
jpaEntity.getDescription(),
new Money(
Currency.getInstance(jpaEntity.getPriceCurrency()),
jpaEntity.getPriceAmount()),
jpaEntity.getItemsInStock());
}
static List<Product> toModelEntities(List<ProductJpaEntity> jpaEntities) {
return jpaEntities.stream().map(ProductMapper::toModelEntity).toList();
}
}
Code-Sprache: Java (java)
Das final
-Keyword an der Klasse und der private Konstruktor haben zudem den Sinn, dass diese Klasse nicht instantiiert wird. Hier folge ich einer Empfehlung von statischen Codeanalysetools. Ansonsten dürfte die Mapper-Klasse keiner weiteren Erklärung bedürfen.
CartMapper
Zur Erinnerung: Als sekundären Port für Warenkörbe haben wir nur ein CartRepository, welches Warenkörbe samt ihrer Einträge persistiert. Wir haben kein separates CartLineItemRepository
.
Dementsprechend werden wir sowohl Cart
als auch CartLineItem
in einer gemeinsamen Mapper-Klasse, dem CartMapper, von und nach CartJpaEntity
und CartLineItemJpaEntity
umwandeln. Hier zunächst der Code für die Umwandlung von Modell-Klassen in JPA-Klassen:
final class CartMapper {
private CartMapper() {}
static CartJpaEntity toJpaEntity(Cart cart) {
CartJpaEntity cartJpaEntity = new CartJpaEntity();
cartJpaEntity.setCustomerId(cart.id().value());
cartJpaEntity.setLineItems(
cart.lineItems().stream()
.map(lineItem -> toJpaEntity(cartJpaEntity, lineItem))
.toList());
return cartJpaEntity;
}
static CartLineItemJpaEntity toJpaEntity(
CartJpaEntity cartJpaEntity, CartLineItem lineItem) {
ProductJpaEntity productJpaEntity = new ProductJpaEntity();
productJpaEntity.setId(lineItem.product().id().value());
CartLineItemJpaEntity entity = new CartLineItemJpaEntity();
entity.setCart(cartJpaEntity);
entity.setProduct(productJpaEntity);
entity.setQuantity(lineItem.quantity());
return entity;
}
// method toModelEntityOptional(…), see below
}
Code-Sprache: Java (java)
Den Primärschlüssel CartLineItemJpaEntity.id
brauchen wir nicht zu setzen; dies geschieht, wie oben erwähnt, automatisch durch MySQL.
Bei der Umwandlung in die andere Richtung, also von JPA-Klassen in Modell-Klassen, müssen wir eine Besonderheit beachten: Die Methode Cart.addProduct(…) bzw. die von ihr aufgerufene Methode CartLineItem.increaseQuantityBy(…) wirft eine NotEnoughItemsInStockException
, sollte ein Produkt nicht in ausreichender Stückzahl vorhanden sein. Das darf natürlich nicht dann passieren, wenn wir einen Warenkorb aus der Datenbank laden.
Dementsprechend benötigen wir noch eine weitere Methode in Cart
, die diese Prüfung umgeht:
public void putProductIgnoringNotEnoughItemsInStock(Product product, int quantity) {
lineItems.put(product.id(), new CartLineItem(product, quantity));
}
Code-Sprache: Java (java)
Diese Methode können wir dann in CartMapper.toModelEntityOptional(…)
einsetzen, um eine CartJpaEntity
in ein Cart
umzuwandeln:
static Optional<Cart> toModelEntityOptional(CartJpaEntity cartJpaEntity) {
if (cartJpaEntity == null) {
return Optional.empty();
}
CustomerId customerId = new CustomerId(cartJpaEntity.getCustomerId());
Cart cart = new Cart(customerId);
for (CartLineItemJpaEntity lineItemJpaEntity : cartJpaEntity.getLineItems()) {
cart.putProductIgnoringNotEnoughItemsInStock(
ProductMapper.toModelEntity(lineItemJpaEntity.getProduct()),
lineItemJpaEntity.getQuantity());
}
return Optional.of(cart);
}
Code-Sprache: Java (java)
Damit haben wir alle benötigten Mapper implementiert. Kommen wir nun zu den eigentlichen Adaptern.
Implementierung der JPA-Adapter
Beginnen wir wieder mit dem Produkt…
JpaProductRepository
Zur Erinnerung: Der für die Produkte zuständige Port, ProductRepository, definiert drei Methoden:
public interface ProductRepository {
void save(Product product);
Optional<Product> findById(ProductId productId);
List<Product> findByNameOrDescription(String query);
}
Code-Sprache: Java (java)
Im letzten Teil der Serie haben wir dieses Interface im In-Memory-Adapter InMemoryProductRepository implementiert. In diesem Teil fügen wir den JPA-Adapter, JpaProductRepository im Paket eu.happycoders.shop.adapter.out.persistence.jpa (also im selben Paket wie die JPA-Entities und die Mapper) hinzu.
Wir starten mit dem Grundgerüst der Klasse. Zunächst einmal benötigen wir für JPA eine EntityManagerFactory
, die wir über den Konstruktor injizieren. Im Konstruktor rufen wir zudem die Methode createDemoProducts()
auf, die – genau wie im InMemoryProductRepository
– einige Test-Produkte anlegt:
public class JpaProductRepository implements ProductRepository {
private final EntityManagerFactory entityManagerFactory;
public JpaProductRepository(EntityManagerFactory entityManagerFactory) {
this.entityManagerFactory = entityManagerFactory;
createDemoProducts();
}
private void createDemoProducts() {
DemoProducts.DEMO_PRODUCTS.forEach(this::save);
}
// ... interface methods ...
}
Code-Sprache: Java (java)
Nun können wir die im Port-Interface definierten Methoden anlegen. Die save(…)
-Methode erzeugt zunächst innerhalb eines try-with-resources-Blocks einen EntityManager
, startet eine Transaktion, wandelt das Product
in ein ProductJpaEntity
um, speichert dieses über EntityManager.merge(…)
in der Datenbank und committed schließlich die Transaktion. Durch das Ende des try-with-resources-Blocks wird zuletzt der EntityManager
geschlossen.
@Override
public void save(Product product) {
try (EntityManager entityManager = entityManagerFactory.createEntityManager()) {
entityManager.getTransaction().begin();
entityManager.merge(ProductMapper.toJpaEntity(product));
entityManager.getTransaction().commit();
}
}
Code-Sprache: Java (java)
Auch die zwei weiteren Methoden erzeugen zunächst einen EntityManager
. Die Methode findById(…)
lädt via EntityManager.find(…)
ein ProductJpaEntity
und wandelt dieses via ProductMapper
in ein Optional<Product>
um:
@Override
public Optional<Product> findById(ProductId productId) {
try (EntityManager entityManager = entityManagerFactory.createEntityManager()) {
ProductJpaEntity jpaEntity =
entityManager.find(ProductJpaEntity.class, productId.value());
return ProductMapper.toModelEntityOptional(jpaEntity);
}
}
Code-Sprache: Java (java)
findByNameOrDescription(…)
erzeugt via EntityManager.createQuery(…)
eine TypedQuery
, lädt dann via TypedQuery.getResultList()
aus der Datenbank die der Suche entsprechende ProductJpaEntity
-Liste und wandelt diese via ProductMapper
in eine Product
-Liste um:
@Override
public List<Product> findByNameOrDescription(String queryString) {
try (EntityManager entityManager = entityManagerFactory.createEntityManager()) {
TypedQuery<ProductJpaEntity> query =
entityManager
.createQuery(
"from ProductJpaEntity "
+ "where name like :query or description like :query",
ProductJpaEntity.class)
.setParameter("query", "%" + queryString + "%");
List<ProductJpaEntity> entities = query.getResultList();
return ProductMapper.toModelEntities(entities);
}
}
Code-Sprache: Java (java)
Wenn du bereits mit JPA gearbeitet hast, kommt dir das alles vermutlich verhältnismäßig umständlich vor. Das liegt daran, dass die meisten Frameworks uns zum einen EntityManager
und Transaktionsverwaltung zur Verfügung stellen und zum anderen das Erstellen von Custom Queries, z. B. über Annotationen, vereinfachen.
In den zwei Folgeteilen dieses Tutorials wirst du sehen, wie die Repositories durch den Einsatz von Spring bzw. Quarkus deutlich weniger Boilerplate-Code enthalten werden.
JpaCartRepository
Der für Warenkörbe zuständige Port, CartRepository, definiert ebenfalls drei Methoden:
public interface CartRepository {
void save(Cart cart);
Optional<Cart> findByCustomerId(CustomerId customerId);
void deleteByCustomerId(CustomerId customerId);
}
Code-Sprache: Java (java)
Den im vorherigen Teil der Serie implementierten Adapter InMemoryCartRepository erweitern wir nun um einen JPA-Adapter, JpaCartRepository, wiederum im Paket eu.happycoders.shop.adapter.out.persistence.jpa.
Wir injizieren wieder über den Konstruktor eine EntityManagerFactory
und implementieren save(…)
und findByCustomerId(…)
analog zu den entsprechenden Methoden im ProductJpaRepository
:
public class JpaCartRepository implements CartRepository {
private final EntityManagerFactory entityManagerFactory;
public JpaCartRepository(EntityManagerFactory entityManagerFactory) {
this.entityManagerFactory = entityManagerFactory;
}
@Override
public void save(Cart cart) {
try (EntityManager entityManager = entityManagerFactory.createEntityManager()) {
entityManager.getTransaction().begin();
entityManager.merge(CartMapper.toJpaEntity(cart));
entityManager.getTransaction().commit();
}
}
@Override
public Optional<Cart> findByCustomerId(CustomerId customerId) {
try (EntityManager entityManager = entityManagerFactory.createEntityManager()) {
CartJpaEntity cartJpaEntity =
entityManager.find(CartJpaEntity.class, customerId.value());
return CartMapper.toModelEntityOptional(cartJpaEntity);
}
}
// method deleteByCustomerId(…), see below
}
Code-Sprache: Java (java)
Die deleteByCustomerId(…)
-Methode sucht zunächst über EntityManager.find(…)
den entsprechenden Warenkorb und löscht diesen dann via EntityManager.remove(…)
aus der Datenbank. Der Parameter cascade = CascadeType.ALL
an der @OneToMany-Annotation der CartJpaEntity sorgt dafür, dass alle zugehörigen CartLineItemJpaEntity
-Datensätze ebenfalls gelöscht werden.
@Override
public void deleteByCustomerId(CustomerId customerId) {
try (EntityManager entityManager = entityManagerFactory.createEntityManager()) {
entityManager.getTransaction().begin();
CartJpaEntity cartJpaEntity =
entityManager.find(CartJpaEntity.class, customerId.value());
if (cartJpaEntity != null) {
entityManager.remove(cartJpaEntity);
}
entityManager.getTransaction().commit();
}
}
Code-Sprache: Java (java)
Damit ist auch die Implementierung der Adapter abgeschlossen.
EntityManagerFactoryFactory und persistence.xml
Wir benötigen noch eine weitere Klasse, um die in die Konstruktoren der Adapter injizierte EntityManagerFactory
zu erzeugen. Das machen wir über die EntityManagerFactoryFactory
, die im selben Paket wie die Adapter liegt:
public final class EntityManagerFactoryFactory {
private EntityManagerFactoryFactory() {}
public static EntityManagerFactory createMySqlEntityManagerFactory(
String jdbcUrl, String user, String password) {
return Persistence.createEntityManagerFactory(
"eu.happycoders.shop.adapter.out.persistence.jpa",
Map.of(
"hibernate.dialect", "org.hibernate.dialect.MySQLDialect",
"hibernate.hbm2ddl.auto", "update",
"jakarta.persistence.jdbc.driver", "com.mysql.jdbc.Driver",
"jakarta.persistence.jdbc.url", jdbcUrl,
"jakarta.persistence.jdbc.user", user,
"jakarta.persistence.jdbc.password", password));
}
}
Code-Sprache: Java (java)
Durch den Map-Eintrag „hibernate.hbm2ddl.auto = update“ werden alle benötigten Datenbanktabellen automatisch angelegt. Diese Einstellung ist nur für kleine Demo-Anwendungen wie diese und für Integration Tests geeignet. Für eine produktive Anwendung empfehle ich den Einsatz von Flyway oder Liquibase.
Für JPA benötigen wir außerdem noch eine persistence.xml-Klasse im src/main/resources/META-INF-Verzeichnis des adapter-Moduls mit folgendem Inhalt:
<persistence xmlns="https://jakarta.ee/xml/ns/persistence" version="3.0">
<persistence-unit name="eu.happycoders.shop.adapter.out.persistence.jpa">
<class>eu.happycoders.shop.adapter.out.persistence.jpa.CartJpaEntity</class>
<class>eu.happycoders.shop.adapter.out.persistence.jpa.CartLineItemJpaEntity</class>
<class>eu.happycoders.shop.adapter.out.persistence.jpa.ProductJpaEntity</class>
<exclude-unlisted-classes>true</exclude-unlisted-classes>
</persistence-unit>
</persistence>
Code-Sprache: HTML, XML (xml)
Beachte, dass der Name der Persistence Unit in der XML-Datei, „eu.happycoders.shop.adapter.out.persistence.jpa“, exakt derjenige ist, der in der EntityManagerFactoryFactory
als erster Parameter an die Methode Persistence.createEntityManagerFactory(…)
übergeben wird.
Integrationstests für JPA-Adapter
Im Abschnitt Unit-Tests für Persistenz-Adapter des vorherigen Teils der Serie habe ich die Aufteilung der Testklassen in die abstrakten Klassen AbstractProductRepositoryTest und AbstractCartRepositoryTest sowie die konkreten Klassen InMemoryProductRepositoryTest und InMemoryCartRepositoryTest erläutert: Die abstrakten Klassen implementieren alle Testfälle, und die konkreten Klassen erzeugen die jeweiligen Instanzen der JPA-Adapter-Klassen.
Hier noch einmal das zugehörige Klassendiagramm:
Um die zwei neuen JPA-Adapter zu testen, müssen wir nur noch die Klassen JpaProductRepositoryTest
und JpaCartRepositoryTest
hinzufügen und die in den abstrakten Basisklassen definierten abstrakten Methoden createProductRepository()
bzw. createCartRepository()
implementieren.
Hier ist ein Screenshot aller Adapter-Test-Klassen in meiner IDE:
Im nächsten Abschnitt zeige ich dir die Implementierung eines der zwei JPA-Repository-Adapter. Der andere Adapter wird analog implementiert.
Integrationstests für JpaProductRepository-Adapter
Als neue Dependency hatten wir bisher lediglich die Jakarta Persistence API in die pom.xml-Datei des adapter-Moduls aufgenommen. Um die Integrationstests auszuführen, benötigen wir nun auch Hibernate als konkrete Implementierung dieser API.
Dazu fügen wir folgende Abhängigkeiten im Test-Scope hinzu:
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>6.3.1.Final</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>8.0.1.Final</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.glassfish</groupId>
<artifactId>jakarta.el</artifactId>
<version>5.0.0-M1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
Code-Sprache: HTML, XML (xml)
hibernate-core ist die eigentliche Hibernate-Library. Diese benötigt zur Laufzeit noch hibernate-validator, jakarta.el und einen JDBC-Treiber – als solchen verwenden wir mysql-connector-java.
Die letzte Dependency, org.testcontainers:mysql, erlaubt es uns, wie du gleich sehen wirst, sehr komfortabel aus den Integrationstests heraus per Docker eine MySQL-Datenbank zu starten.
Den JpaProductRepositoryTest implementieren wir dann wie folgt, im Paket eu.happycoders.shop.adapter.out.persistence.jpa:
class JpaProductRepositoryTest
extends AbstractProductRepositoryTest<JpaProductRepository> {
private static MySQLContainer<?> mysql;
private static EntityManagerFactory entityManagerFactory;
@BeforeAll
static void startDatabase() {
mysql = new MySQLContainer<>(DockerImageName.parse("mysql:8.1"));
mysql.start();
entityManagerFactory =
EntityManagerFactoryFactory.createMySqlEntityManagerFactory(
mysql.getJdbcUrl(), "root", "test");
}
@Override
protected JpaProductRepository createProductRepository() {
return new JpaProductRepository(entityManagerFactory);
}
@AfterAll
static void stopDatabase() {
entityManagerFactory.close();
mysql.stop();
}
}
Code-Sprache: Java (java)
In der mit @BeforeAll
annotierten Methode startDatabase()
starten wir in den ersten zwei Zeilen die MySQL-Datenbank über die Testcontainers-JDBC-API.
Dann erstellen wir über unsere im vorherigen Abschnitt erstellte EntityManagerFactoryFactory
eine EntityManagerFactory
und speichern diese im Feld entityManagerFactory
. Benutzername und Passwort sind die Standardwerte des Testcontainers-MySQL-Moduls.
Die aus der abstrakten Elternklasse überschriebene createProductRepository()
-Methode muss dann bloß noch eine Instanz von JpaProductRepository
erzeugen und dabei dem Konstruktor die zuvor erstellte EntityManagerFactory
-Instanz übergeben.
In der mit @AfterAll
annotierten stopDatabase()
-Methode schließen wir die EntityManagerFactory
und beenden den MySQL-Docker-Container.
Die Testklasse JpaCartRepositoryTest wird analog dazu implementiert.
Du solltest jetzt die Tests direkt aus deiner IDE oder mit folgendem Aufruf aus einem Terminal heraus ausführen können:
mvn clean test
Code-Sprache: Klartext (plaintext)
Anpassung des Bootstrap-Moduls
Unsere JPA-Adapter sind bereit. Nun müssen wir noch das bootstrap-Modul dahingehend anpassen, dass wir – wie in der Einführung angekündigt – per System Property festlegen können, ob die alten In-Memory-Adapter oder die neuen JPA-Adapter eingesetzt werden.
Dazu müssen wir zunächst die folgenden Abhängigkeiten in die pom.xml des bootstrap-Moduls einfügen. Es handelt sich um die gleichen Abhängigkeiten, wie wir sie im adapter-Modul im Test-Scope hinzugefügt haben:
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>6.3.1.Final</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>8.0.1.Final</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.glassfish</groupId>
<artifactId>jakarta.el</artifactId>
<version>5.0.0-M1</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
<scope>runtime</scope>
</dependency>
Code-Sprache: HTML, XML (xml)
Im GitHub-Repository habe ich die Versionen übrigens zentral in einem Dependency-Management-Block in der pom.xml im Root-Verzeichnis definiert, nicht in den pom.xml-Dateien der Module. Dieser Artikel wäre allerdings unübersichtlicher geworden, wenn ich für jede Dependency zwei Einträge gemacht hätte.
Leider gibt es momentan noch eine kleine Inkompatiblität: Sowohl resteasy-undertow als auch hibernate-core haben eine Dependency auf die jandex-Library – allerdings mit zwei unterschiedlichen Group-IDs, sodass Maven beide Libraries in den Classpath aufnimmt. Das können wir verhindern, indem wir der bestehenden Dependency zu resteasy-undertow einen exclusion
-Block hinzufügen:
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-undertow</artifactId>
<exclusions>
<!-- Conflicts with io.smallrye:jandex, a dependency from Hibernate -->
<exclusion>
<groupId>org.jboss</groupId>
<artifactId>jandex</artifactId>
</exclusion>
</exclusions>
</dependency>
Code-Sprache: HTML, XML (xml)
Zuletzt müssen wir nur noch die initPersistenceAdapters()
-Methode der RestEasyUndertowShopApplication anpassen.
Aktuell sieht diese noch wie folgt aus:
private void initPersistenceAdapters() {
cartRepository = new InMemoryCartRepository();
productRepository = new InMemoryProductRepository();
}
Code-Sprache: Java (java)
Wir ändern sie wie folgt ab:
private void initPersistenceAdapters() {
String persistence = System.getProperty("persistence", "inmemory");
switch (persistence) {
case "inmemory" -> initInMemoryAdapters();
case "mysql" -> initMySqlAdapters();
default -> throw new IllegalArgumentException(
"Invalid 'persistence' property: '%s' (allowed: 'inmemory', 'mysql')"
.formatted(persistence));
}
}
private void initInMemoryAdapters() {
cartRepository = new InMemoryCartRepository();
productRepository = new InMemoryProductRepository();
}
private void initMySqlAdapters() {
EntityManagerFactory entityManagerFactory =
EntityManagerFactoryFactory.createMySqlEntityManagerFactory(
"jdbc:mysql://localhost:3306/shop", "root", "test");
cartRepository = new JpaCartRepository(entityManagerFactory);
productRepository = new JpaProductRepository(entityManagerFactory);
}
Code-Sprache: Java (java)
Wir lesen zunächst die System Property „persistence“ aus, wobei wir den Wert „inmemory“ als Default-Wert festlegen. Je nach gesetztem Wert rufen wir dann entweder initInMemoryAdapters()
oder initMySqlAdapters()
auf.
Die Method initInMemoryAdapters()
entspricht der vorherigen initPersistenceAdapters()
-Methode und erzeugt die In-Memory-Adapter.
Die Methode initMySqlAdapters()
erzeugt – wie wir es in den Integrationstests gemacht haben – über die EntityManagerFactoryFactory
eine EntityManagerFactory
und übergibt diese an die Konstruktoren von JpaCartRepository
und JpaProductRepository
.
Connection-URL, Benutzername und Passwort habe ich hier hart kodiert – das sollte man in einer produktiven Anwendung natürlich nicht so machen.
Start der Anwendung
Du kannst die Anwendung wieder direkt aus deiner IDE starten, indem du die Launcher
-Klasse des bootstrap-Moduls startest. Wenn du keine weiteren Einstellungen änderst, wird die Anwendung weiterhin im In-Memory-Modus gestartet.
Start einer lokalen MySQL-Datenbank
Um die Anwendung im MySQL-Modus zu starten, musst du zunächst eine lokale MySQL-Datenbank starten. Das geschieht am einfachsten über Docker, z. B. über das folgende Kommando:
docker run --name hexagon-mysql -d -p3306:3306 \
-e MYSQL_DATABASE=shop -e MYSQL_ROOT_PASSWORD=test mysql:8.1
Code-Sprache: Klartext (plaintext)
Falls dein lokaler Port 3306 bereits durch eine andere MySQL-Installation belegt ist, verwende einfach einen anderen freien Port, z. B. Port 3307, indem du -p3306:3306
durch -p3307:3306
ersetzt. Du musst dann den Port in der initMySqlAdapters()
-Methode der RestEasyUndertowShowApplication
-Klasse entsprechend anpassen.
Start der Anwendung im MySQL-Modus
Um schließlich auch die Anwendung im MySQL-Modus zu starten, muss du die entsprechende System Property setzen. In IntelliJ geht das beispielsweise wie folgt:
1. Klicke auf das grüne Start-Icon der Launcher-Klasse und dann auf „Modify Run Configuration...“:
2. Klicke in dem sich öffnenen Dialog entweder auf „Modify options“ und dann auf „Add VM options“ – oder drücke Alt+V. Dadurch wird das Eingabefeld „VM options“ sichtbar. Trage dort
3. Klick auf „OK“ und starte danach die Anwendung. Was ich noch gerne mache, ist im „Modify options“-Dropdown die letzte Option „Show the run/debug configuration settings before start“ zu aktivieren. Dann öffnet sich das „Edit Run Configuration“-Fenster bei jedem Start der Anwendung, sodass man die Systemeigenschaft bei jedem Start sehen und ggf. ändern kann.
Wenn die Anwendung läuft, kannst du ihre Endpunkte – so wie im Abschnitt „Start der Anwendung“ des vorherigen Teils beschrieben – testen. Im Gegensatz zur In-Memory-Lösung sollten jetzt nach einem Neustart der Anwendung alle Daten erhalten bleiben.
Fazit und Ausblick
Wir haben in diesem Teil des Tutorials eine nach der Ports-und-Adapters-Architektur entwickelte Anwendung um einen Datenbank-Adapter erweitert. Neben der Implementierung der Entity-, Mapper- und Adapter-Klassen im adapter-Modul mussten wir lediglich ein paar Zeilen Code im bootstrap-Modul hinzufügen, um die neuen Adapter-Klassen zu instanziieren.
Im Kern der Anwendung, also in den model- und application-Klassen mussten wir nicht eine einzige Zeile Code ändern – eine beeindruckende Eigenschaft der Ports-und-Adapters-Architektur!
Nun haben wir zwar eine lauffähige Anwendung – wir haben allerdings bereits festgestellt, dass das Erzeugen der EntityManager
und das Transaktionsmanagement ziemlich viel Boilerplate-Code erforderten. Und wenn wir unsere Anwendung „production-ready“ machen wollen, müssten wir die Anwendung besser konfigurierbar und robuster machen und bräuchten auch noch Health- und Metrics-Endpoints. Da würde es ohne Application Framework langsam umständlich werden.
Daher zeige ich dir in den nächsten zwei Teilen der Artikelserie, wie wir unsere Applikation in eine Quarkus-Anwendung umwandeln und danach in eine Spring-Anwendung. Und damit werde ich – hoffentlich eindrucksvoll – demonstrieren, dass bei der hexgonalen Architektur selbst das Application Framework nur ein austauschbares technisches Detail ist.
Wenn dir der Artikel gefallen hat, würde ich mich über eine kurze Bewertung auf ProvenExpert freuen.
Möchtest du auf dem Laufenden bleiben und informiert werden, wenn neue Artikel auf HappyCoders.eu veröffentlicht werden? Dann klicke hier, um dich für den kostenlosen HappyCoders-Newsletter anzumelden.