Hand-Crafted Persistence for Clean Architecture

George
Technical blog from UNIL engineering teams
7 min readJun 23, 2023
Photo by Nicolas Hoizey on Unsplash

Robert C. Martin once famously said that “database is a detail”. That being true, however, the persistence of our models (independently of this or that particular database) is a very important concern which must be taken into consideration during aggregate design and implementation. In this article we take a look at how we can construct a robust and comprehensible schema for aggregate persistence in a relational database.

We assume the reader is familiar with concepts of Clean Architecture and Domain-Driven Design. All the source code referred to in this article are for Java ecosystem and can be consulted in its integrality from several GitHub repositories listed in the reference section. Let’s explain the tools and techniques that we shall be using.

No (full-blown) ORM

There is an intractable problem, known under the name of object-relational impedance mismatch, which explains why it is so difficult to map objects to a relational store, in general. The very popular Java Persistence Architecture and its canonical implementation — Hibernate — do, nevertheless, go a long way to (almost) solve it. These are wonderful and mature technologies but they are not suitable for persistence if one chooses to follow closely Clean Architecture precepts. Here is why.

  • We know from Martin’s article that when we cross the Boundary between Use Cases layer and a persistence gateway (secondary adapter), we can only use plain Java objects. Not Hibernate dynamic proxies. The model at the core of our domain must remain pure and simple Java objects.
  • To free ourselves from any dependencies on a particular persistence technology, we must perform a mapping in the Interface Adapters layer which will convert any object or data structure directly usable by a persistence adapter into a fully constructed graph of model objects. This mapping will, by itself, negate all the advantages of lazy-loading or caching provided by Hibernate. Our interaction with a database is always effectively eager.
  • Careful aggregate design which favors small aggregates with a handful of entities and value objects only and which does not use many-to-one or many-to-many mappings will not benefit from the sophisticated relational mapping provided by Hibernate.
  • Techniques like read-models require a flexible access to the underlying persistence schema and rely on the perfect knowledge of how tables are arranged so that a performant SQL could be issued against it.

All this leads us to conclude that we simply do not need Hibernate. But, of course, there are other frameworks which can greatly help us with the persistence. There is a great project — Spring Data JDBC — which allows exactly the type of implementation of Repository pattern we are looking for. It persists all aggregates eagerly and only supports a small subset of relations: embeddable, one-to-one, and one-to-many mappings. Of course, being based on familiar Spring JDBC core, this framework is perfect for hand-made SQL queries against the database.

Mapping with MapStruct

Mapping from and to raw structures and objects obtain from the persistence store to our models is a repetitive error-prone task which is best left to a object-to-object mapper framework. There are many implementations of mappers in the world of Java. The one that stands out particularly is MapStruct. Here are some advantages of this library:

  • MapStruct generates human-readable source code for each mapping which can be examined or debugged by a developer.
  • MapStruct takes care of most common or predictable mappings automatically but allows to extend any mapping with custom code.
  • MapStruct is very well documented.

Using MapStruct in a combination with Spring Data JDBC allows for a straightforward and comprehensible persistence schema which accommodates any well-designed model graph with little effort on the part of a developer.

Keeping track of schema changes with Flyway

Since we are designing the database’s schema ourselves rather than relying on Hibernate’s ddl-auto mechanism, we need a predictable and repeatable way to construct, reconstruct, and migrate our DDL definitions. This will especially be true if we favor iterative design and evolving architecture in our design. Flyway is a great tool for all the tasks related to the database schema migrations. It is very well integrated into the Spring and Spring Boot ecosystem.

With Flyway, we shall keep in one place all the SQL needed to create our schema as we go along designing our aggregate persistence. These scripts are readily comprehensible by a developer and can be used to rebuild the database at any point in time. Let’s now look at some concrete implementation examples of our persistence approach.

Persisting Library domain

This example refers to our port of Library domain. Here is the initial Flyway script creating catalog table:

CREATE TABLE public."catalog"
(
isbn varchar NOT NULL,
title varchar NOT NULL,
author varchar NOT NULL,
"version" int NULL,
CONSTRAINT catalog_pk PRIMARY KEY (isbn)
);

And here is CatalogEntryDbEntity used by Spring Data JDBC to map to the rows of the catalog table:

@Data
@Table(name = "catalog")
public class CatalogEntryDbEntity {

@Id
@Column("isbn")
private String isbn;

@Column("title")
private String title;

@Column("author")
private String author;

@Version
private Integer version;

}

There is absolutely nothing complicated here since the DB entity is simple collection of scalars. One thing is worth mentioning. It’s Version annotation on version field. This is one way to signal to Spring Data JDBC library if an entity is a newly created one — with version being null — or if an entity exists already in the database (in this case a version will be assigned a specific numerical value automatically). The version field must be mapped all the way through to the domain model.

Here is our CatalogEntry domain model — an aggregate with few simple attributes: primitives and one value object, Isbn :

@Getter
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class CatalogEntry {

@EqualsAndHashCode.Include
Isbn isbn;

String title;

String author;

// needed for Spring Data JDBC
Integer version;

public static CatalogEntry of(Isbn isbn, String title, String author) {
// code omitted
}

@Builder
public CatalogEntry(Isbn isbn, String title, String author, Integer version) {
// code omitted
}

}

Here is how we can use MapStruct mapper to map CatalogEntryDbEntity to a corresponding instance of CatlogEntry aggregate:

@Mapper(componentModel = "spring", uses = {MapStructConverters.class})
public abstract class MapStructDbMapper implements DbMapper {

// abstract methods will be implemented by MapStruct for us
protected abstract CatalogEntry map(CatalogEntryDbEntity dbEntity);

protected abstract CatalogEntryDbEntity map(CatalogEntry catalogEntry);

protected abstract Hold map(HoldDbEntity dbEntity);

// code omitted

@IgnoreForMapping
@Override
public CatalogEntry convert(CatalogEntryDbEntity dbEntity) {
return map(dbEntity);
}

@IgnoreForMapping
@Override
public CatalogEntryDbEntity convert(CatalogEntry catalogEntry) {
return map(catalogEntry);
}

// code omitted
}

Predictably, there is not much code in here — MapStruct generated all default mappers for the fields with identical names for us. The only one thing we need to specify is how to convert Isbn value object from the domain model to its String representation used by the DB entity. This is done in MapStructConverters class used by our mapper and it is a straight forward conversion.

Persisting one-to-many relations

One can see a one-to-many relationship mapping example when examining the conversion of PatronDbEntity with a set of HoldDbEntity s to an instance of Patron aggregate root with a set of Hold s. Here is PatronDbEntity :

@Data
@Table("patron")
public class PatronDbEntity {

@Id
@Column("patron_id")
private String patronId;

@Column("full_name")
private String fullName;

@Column("level")
private String level;

@MappedCollection(idColumn = "patron_id")
private Set<HoldDbEntity> holds;

@Version
private Integer version;
}

And the child HoldDbEntity :

@Data
@Table("hold")
public class HoldDbEntity {

@Column("isbn")
private String isbn;

@Column("patron_id")
private String patronId;

@Column("start_date")
private LocalDate startDate;

@Column("duration")
private Integer duration;

@Column("date_completed")
private LocalDate dateCompleted;

@Column("date_canceled")
private LocalDate dateCanceled;
}

One can find the corresponding domain model for Patron here. Of course, to actually persist PatronDbEntity in the database we need to adjust our schema with a Flyway script:

CREATE TABLE public.patron
(
patron_id varchar NOT NULL,
full_name varchar NOT NULL,
"level" varchar NULL,
"version" int NULL,
CONSTRAINT patron_pk PRIMARY KEY (patron_id)
);

CREATE TABLE public."hold"
(
isbn varchar NOT NULL,
patron_id varchar NOT NULL,
start_date date NOT NULL,
duration int NULL,
date_completed date NULL,
date_canceled date NULL,
CONSTRAINT hold_pk PRIMARY KEY (isbn, patron_id),
CONSTRAINT hold_catalog_fk FOREIGN KEY (isbn) REFERENCES public."catalog" (isbn),
CONSTRAINT hold_patron_fk FOREIGN KEY (patron_id) REFERENCES public.patron (patron_id)
);

We specify appropriate tables, columns, and constraints: especially a foreign key hold_patron_fk which guarantees consistent one-to-many relation between PatronDbEntity and HoldDbEntity .

Persistence gateway

What is left to examine is the implementation of the persistence gateway (secondary adapter) itself. According to CA’s principles it implements PersistenceGatewayOutputPort and it will be injected with the Spring Data JDBC repositories for catalog entry and patron DB entities (among others) and the MapStruct mapper from entities to domain models. Here is how a loading of a CatalogEntry aggregate looks like:

@Service
@RequiredArgsConstructor
public class PersistenceGateway implements PersistenceGatewayOutputPort {

// mapper from persistent entities to models

private final DbMapper dbMapper;

// Spring Data repositories for each "aggregate" (persistent root entity)

private final CatalogEntryDbEntityRepository catalogRepo;

private final PatronDbEntityRepository patronRepo;

// code omitted

@Override
public CatalogEntry loadCatalogEntry(Isbn isbn) {
String errorMessage = "Cannot load catalog entry with ISBN: %s"
.formatted(isbn.getNumber());
Optional<CatalogEntry> catalogEntryOpt;
try {
catalogEntryOpt = catalogRepo.findById(isbn.getNumber())
.map(dbMapper::convert);
} catch (Exception e) {
throw new PersistenceError(errorMessage, e);
}
return catalogEntryOpt.orElseThrow(() -> new PersistenceError(errorMessage));
}

// code omitted
}

To summarize what these various tools help us with:

  • Flyway sets up the database schema according to the SQL scripts which we provide.
  • Spring Data JDBC eagerly loads and persists all DB entities.
  • MapStruct implements mapping or conversion from DB entities to model entities.
  • Persistence gateway, then, has a straightforward implementations of read/write methods with a bit of a help from Java’s Stream API.

Discussion

Not to overload this article any further we refer the reader to the reference section. Especially interesting, when it comes to persistence, is the port of Cargo domain with Clean Architecture. In that example, one has an possibility to directly compare the hand-crafted persistence approach presented here with the classical JPA and Hibernate approach for the same domain. We tackle an issue of using caching with our persistence gateway there as well.

As a conclusion, we may add that there is definitively a sufficient rationale for hand-crafted persistence when it comes to applications using DDD and Clean Architecture. The basic premise is that it is one of the ways we can keep the model truly independent from the persistence, all the while, maintaining a robust, flexible, and comprehensible persistence schema.

References

--

--