You have a domain model with its rules, for instance "an article is published only once", and then JPA and Hibernate arrive with their annotations, their empty constructors and their setters. The shortest route is to put @Entity on the domain class and carry on. It works, until the business rule and the shape of the table change for different reasons and find themselves in the same file. Clean Architecture proposes keeping two models, a pure one in the domain and a persistence one in the infrastructure, connected by a mapper and by an adapter that implements an interface (the port) declared in the domain. In this article we build that mechanism in a program that compiles and runs without Spring, and then we look at how this site's backend does it and how Flyway versions the schema, with a test run on an in-memory database. Prerequisite: knowing what a layer is; the domain layer is the previous article in the series.
Why two models and not one
A JPA entity must have a no-argument constructor and mutable fields, because Hibernate instantiates and populates it on its own. A domain entity that wants to guarantee its own invariants has the opposite constraint: it is built only in valid states and does not change under the feet of whoever uses it. The two demands contradict each other, and the only way to satisfy both in one class is to weaken the domain one. The concrete cost shows up in the tests: a rule that lives in a JPA-annotated class is proved more easily with an integration test, whereas a pure class is proved with a method call.
The price of the separation is just as concrete: for every entity there are two classes and a mapper to write and maintain, and a new field has to be added in three places. For a configuration table with a single row it is not worth it, and indeed in this site's backend the advertising settings adapter has no JPA entity at all (we see it below). The separation pays off when the domain has real rules; where there is only a data container, a record and a query are enough.
The pure domain
The listing defines the value objects (ArticleId, Title), the state (PublicationStatus), the Article entity and the ArticleRepository port. Not a single line imports Spring or JPA. Two choices deserve an explanation. The first: Title validates in the compact constructor, so an empty title does not exist as an object and no higher level has to remember to check it. The second concerns publish: a record is immutable, so the method cannot "change the state" of this; it returns a new instance with state PUBLISHED. This is the most common defect when you write a method like this on a record: a void publish() that computes the new state and then throws it away compiles without warnings and does nothing.
import java.time.Instant;
import java.util.*;
// Domain layer: no annotations, no framework imports.
record ArticleId(UUID value) {
ArticleId { Objects.requireNonNull(value); }
static ArticleId generate() { return new ArticleId(UUID.randomUUID()); }
}
record Title(String value) {
Title {
if (value == null || value.isBlank()) throw new IllegalArgumentException("Title cannot be empty");
if (value.length() > 255) throw new IllegalArgumentException("Title too long");
}
}
enum PublicationStatus { DRAFT, PUBLISHED, ARCHIVED }
class ArticleAlreadyPublishedException extends RuntimeException {
ArticleAlreadyPublishedException(String m) { super(m); }
}
record Article(ArticleId id, Title title, PublicationStatus status, Instant publishedAt) {
static Article draft(Title title) {
return new Article(ArticleId.generate(), title, PublicationStatus.DRAFT, null);
}
// A record is immutable: publish() cannot change 'this', it returns another instance.
Article publish(Instant now) {
if (status != PublicationStatus.DRAFT) {
throw new ArticleAlreadyPublishedException("Article already published: " + id.value());
}
return new Article(id, title, PublicationStatus.PUBLISHED, now);
}
}
// The port: a domain interface, implemented outside.
interface ArticleRepository {
Optional<Article> findById(ArticleId id);
Article save(Article article);
}
The ArticleRepository port has only the operations the domain wants, in its own types: it takes and returns Article and ArticleId, never rows or JPA entities. This is the point that inverts the dependency: the domain declares what it needs, and the infrastructure adapts. Note also that publish receives the instant as a parameter instead of calling Instant.now(): this way the test does not depend on the clock and its output is always the same.
Infrastructure side: row, mapper, adapter
ArticleRow is, in this program, the stand-in for the JPA entity: a mutable class with "table" types (String for the identifier, Instant, the state name as text) and no rules. With JPA the same class would carry @Entity, @Id and @Column; I do not show it running because it would require Hibernate and a database, and the rest of the mechanism would not change. The mapper converts in both directions and, when reading, rebuilds the value objects: if the row contains data that the domain rejects, the error surfaces at that point instead of propagating.
import java.time.Instant;
import java.util.*;
// Infrastructure layer. ArticleRow stands in for the JPA @Entity: a mutable class,
// only "table" types (String, Instant), no business rules.
class ArticleRow {
String id;
String title;
String status;
Instant publishedAt;
}
class ArticleMapper {
Article toDomain(ArticleRow r) {
return new Article(new ArticleId(UUID.fromString(r.id)), new Title(r.title),
PublicationStatus.valueOf(r.status), r.publishedAt);
}
ArticleRow toRow(Article a) {
ArticleRow r = new ArticleRow();
r.id = a.id().value().toString();
r.title = a.title().value();
r.status = a.status().name();
r.publishedAt = a.publishedAt();
return r;
}
}
// The adapter implements the port. Here the "table" is a Map; with JPA it would be
// replaced by a JpaRepository<ArticleEntity, ...>, and the rest would stay the same.
class InMemoryArticleAdapter implements ArticleRepository {
final Map<String, ArticleRow> table = new HashMap<>();
private final ArticleMapper mapper = new ArticleMapper();
public Optional<Article> findById(ArticleId id) {
return Optional.ofNullable(table.get(id.value().toString())).map(mapper::toDomain);
}
public Article save(Article article) {
ArticleRow row = mapper.toRow(article);
table.put(row.id, row);
return mapper.toDomain(row);
}
}
The adapter implements the port and keeps the mapper private. Here the table is a Map; replacing it with a JpaRepository changes only the body of the two methods. A detail not to be overlooked: save returns the object read back from the row through the mapper, not the one it received, so the caller sees what was actually persisted (with a database, including the values generated by the database).
The use case, and what you observe
The PublishArticleUseCase use case is in the application layer: it depends on the port, not on the adapter, and orchestrates three steps (load, apply the rule, save). In main I use it the way a controller would: I save a draft, I publish it, I try to publish it again, and finally I insert by hand into the "table" a row with an empty title, to see what happens when the persisted data violates the domain.
import java.time.Instant;
// Application layer: knows the port, not the adapter.
class PublishArticleUseCase {
private final ArticleRepository repository;
PublishArticleUseCase(ArticleRepository repository) { this.repository = repository; }
Article execute(ArticleId id, Instant now) {
Article article = repository.findById(id)
.orElseThrow(() -> new IllegalStateException("Article not found: " + id.value()));
return repository.save(article.publish(now));
}
}
public class Demo {
public static void main(String[] args) {
InMemoryArticleAdapter adapter = new InMemoryArticleAdapter();
PublishArticleUseCase useCase = new PublishArticleUseCase(adapter);
Instant now = Instant.parse("2026-11-12T10:00:00Z");
Article draft = adapter.save(Article.draft(new Title("Pure domain")));
System.out.println("saved: " + draft.status());
Article published = useCase.execute(draft.id(), now);
System.out.println("published: " + published.status() + " on " + published.publishedAt());
System.out.println("has the original draft changed? " + draft.status());
try {
useCase.execute(draft.id(), now);
} catch (ArticleAlreadyPublishedException e) {
System.out.println("second publish: " + e.getMessage().replaceAll("[0-9a-f-]{36}", "<uuid>"));
}
// A corrupted row in the table does not enter the domain: the value object rejects it.
ArticleRow corrupted = new ArticleRow();
corrupted.id = java.util.UUID.randomUUID().toString();
corrupted.title = " ";
corrupted.status = "DRAFT";
adapter.table.put(corrupted.id, corrupted);
try {
adapter.findById(new ArticleId(java.util.UUID.fromString(corrupted.id)));
} catch (IllegalArgumentException e) {
System.out.println("reading corrupted row: " + e.getMessage());
}
}
}
// saved: DRAFT
// published: PUBLISHED on 2026-11-12T10:00:00Z
// has the original draft changed? DRAFT
// second publish: Article already published: <uuid>
// reading corrupted row: Title cannot be empty
The output has four readings. The original draft stays DRAFT after publication: a second instance was born, and whoever held a reference to the first sees no hidden changes. The second publish fails with the domain exception, and the rule is proved without starting anything: it is a method call. Finally the corrupted row is rejected by the value object at read time. The program is deterministic (identical across two consecutive runs; the UUID is replaced with a placeholder in the message for exactly this reason).
How this site's backend does it
The backend has an ArticleRepository port in the domain.port package and an ArticleRepositoryAdapter in infrastructure.persistence.adapter, which uses a Spring Data repository and a mapper. The mapper is not written by hand: it is a MapStruct interface, a tool that generates the conversion code at compile time (documentation). Where the conversion is not obvious, for example the state that in the table is lowercase text and in the domain an enum, you declare a named converter (qualifiedByName). The listing is an excerpt that I read from the source, not a runnable program.
// domain/port/ArticleRepository.java (excerpt): no JPA imports
public interface ArticleRepository {
Optional<Article> findById(Long id);
Optional<Article> findBySlug(String slug);
// ...
}
// infrastructure/persistence/adapter/ArticleRepositoryAdapter.java (excerpt)
@Repository
@Transactional(readOnly = true)
public class ArticleRepositoryAdapter implements ArticleRepository {
private final JpaArticleRepository jpaArticle; // Spring Data
private final ArticleMapper mapper; // MapStruct
@Override
public Optional<Article> findById(Long id) {
return jpaArticle.findById(id.intValue()).map(mapper::toArticle);
}
// ...
}
// infrastructure/persistence/mapper/ArticleMapper.java (excerpt)
@Mapper
public interface ArticleMapper {
@Mapping(target = "id", source = "id", qualifiedByName = "articleIdToLong")
@Mapping(target = "status", source = "status", qualifiedByName = "stringToArticleStatus")
Article toArticle(ArticleEntity entity);
// ...
}
Two observations on what the source really shows. The adapter is annotated @Transactional(readOnly = true): the transaction is declared on the persistence adapter, not in the domain, and the writes redefine it. And not all the backend's adapters use JPA: AdsSettingsRepositoryAdapter, for a single-row table, uses JdbcTemplate with a query and a RowMapper, without an entity. It is the same architecture with less ceremony, and the port in the domain is identical. The rule is not "always a separate JPA entity", it is "the domain does not know the technology".
On the manual mapper versus MapStruct: the hand-written one, as in the program above, is obvious to read and fails at runtime if you forget a field; MapStruct fails (or warns) at compile time on unmapped fields, and you pay for it with an annotation processor in the build. With records that have compact constructors and nested value objects, generation needs more converters than it seems, so it is not an automatic saving.
Flyway: the schema has a history
Flyway applies the SQL files of a folder in version order and remembers in a table (flyway_schema_history) which ones it has already run. The file name is the contract: V, the version number, two underscores, the description. Each versioned migration runs only once per database, and the Flyway documentation specifies that repeatable ones are instead rerun when they change (Migrations). The listing shows two minimal migrations.
-- V1__create_articles.sql
CREATE TABLE articles (
id BIGINT PRIMARY KEY,
slug VARCHAR(300) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'draft'
);
-- V2__add_published_at.sql
ALTER TABLE articles ADD COLUMN published_at TIMESTAMP WITH TIME ZONE;
The backend's configuration is in application.yml. Every property below has a default that is different or less obvious, so it is worth knowing what was chosen. ddl-auto: validate makes Hibernate check that the entities match the tables, but not create them: Spring Boot's default is none (or create-drop with an embedded database and no migration tool), as the properties appendix reports. baseline-on-migrate is false by default, and leaving it so means that a non-empty database with no history makes startup fail instead of being declared "version 1". out-of-order: true is a project choice: it admits a migration with a lower version than those already applied, because in this repo versions are assigned in ranges reserved for parallel work (the comment in the file explains it).
spring:
jpa:
hibernate:
ddl-auto: validate # Hibernate checks the schema, it does not create it
open-in-view: false
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: false # a non-empty database with no history is an error, not a case to "fix"
validate-on-migrate: true
out-of-order: true # also applies late-arriving migrations with a lower version
out-of-order has a cost that the comment does not mention: two environments can apply the same migrations in a different order, and if two of them touch the same object the result depends on the order. It is fine when the migrations are independent of one another. In the repository the migrations go from V001 to V240 and are 228 files (measured on 2026-11-12 with ls backend/src/main/resources/db/migration | wc -l): the numbering is not contiguous, and that is the direct consequence of those ranges.
An applied migration is not edited
Flyway records a checksum of the content of every applied migration and compares it at every startup. The program applies the two migrations to an in-memory H2 database (PostgreSQL mode) with Flyway 10.20.1, runs it again with nothing new, then adds a comment to the version 2 file and tries again. The result shows the three things you need to know.
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.output.MigrateResult;
import java.nio.file.*;
public class FlywayDemo {
static Flyway flyway() {
return Flyway.configure()
.dataSource("jdbc:h2:mem:demo;MODE=PostgreSQL;DB_CLOSE_DELAY=-1", "sa", "")
.locations("filesystem:mig")
.load();
}
public static void main(String[] args) throws Exception {
MigrateResult r1 = flyway().migrate();
System.out.println("1st run: applied " + r1.migrationsExecuted);
MigrateResult r2 = flyway().migrate();
System.out.println("2nd run: applied " + r2.migrationsExecuted);
// I modify a migration that has already been applied
Path v2 = Path.of("mig/V2__add_published_at.sql");
String original = Files.readString(v2);
Files.writeString(v2, original + "-- tweak\n");
try {
flyway().migrate();
} catch (Exception e) {
System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage().lines().findFirst().orElse(""));
e.getMessage().lines().skip(1).filter(l -> l.contains("Resolved") || l.contains("Applied") || l.contains("Migration")).forEach(System.out::println);
} finally {
Files.writeString(v2, original);
}
}
}
// 1st run: applied 2
// 2nd run: applied 0
// FlywayValidateException: Validate failed: Migrations have failed validation
// Migration checksum mismatch for migration version 2
// -> Applied to database : 1955181868
// -> Resolved locally : 1439618130
The second run applies nothing: this is the idempotence that matters, and it is provided by the history table, not by the IF NOT EXISTS clauses put in the files. Those clauses do not make a migration rerunnable, and may even hide an error, for example a table that is already there with a different shape from the expected one. The third run shows the refusal: even a single comment added to an already applied migration changes the checksum, and Flyway stops. The two numbers are the checksums computed on the file (stable: identical across two runs). The practical rule is that a published migration is not touched; you write the next one. As for rolling back, the same documentation page describes undo migrations as a separate category, which you do not improvise by rewriting a file.
Adding a NOT NULL constraint to a table with data
Adding a NOT NULL column in one go to a table that contains rows fails, because the existing rows have no value. The safe route takes three migrations: add the column allowing null, fill the existing rows, and only then impose the constraint. Keeping them separate means that if the backfill fails the constraint has not been attempted yet, and that each step is readable on its own.
-- V3__add_view_count_nullable.sql
ALTER TABLE articles ADD COLUMN view_count INTEGER;
-- V4__backfill_view_count.sql
UPDATE articles SET view_count = 0 WHERE view_count IS NULL;
-- V5__view_count_not_null.sql
ALTER TABLE articles ALTER COLUMN view_count SET NOT NULL;
ALTER TABLE articles ALTER COLUMN view_count SET DEFAULT 0;
The test: the program brings the schema to version 2 (target("2")), inserts a row as if it were production data, then lets Flyway apply versions 3, 4 and 5. It reads the row and tries to insert one with a null view_count.
import org.flywaydb.core.Flyway;
import java.sql.*;
public class Expand {
static final String URL = "jdbc:h2:mem:expand;MODE=PostgreSQL;DB_CLOSE_DELAY=-1";
public static void main(String[] args) throws Exception {
Flyway.configure().dataSource(URL, "sa", "").locations("filesystem:mig3")
.target("2").load().migrate(); // schema at v2
try (Connection c = DriverManager.getConnection(URL, "sa", "");
Statement s = c.createStatement()) {
s.execute("INSERT INTO articles (id, slug) VALUES (1, 'old-article')"); // existing data
}
Flyway.configure().dataSource(URL, "sa", "").locations("filesystem:mig3")
.load().migrate(); // v3 (column), v4 (backfill), v5 (constraint)
try (Connection c = DriverManager.getConnection(URL, "sa", "");
Statement s = c.createStatement();
ResultSet rs = s.executeQuery("SELECT slug, view_count FROM articles")) {
while (rs.next()) System.out.println(rs.getString(1) + " -> " + rs.getInt(2));
try { s.execute("INSERT INTO articles (id, slug, view_count) VALUES (2, 'x', NULL)"); }
catch (SQLException e) { System.out.println("NULL rejected: " + e.getErrorCode()); }
}
}
}
// old-article -> 0
// NULL rejected: 23502
The old row received 0 from the backfill step and the constraint rejects the null (code 23502 is the NOT NULL violation according to H2). It must be said clearly what was tested: this test runs on H2 in PostgreSQL mode, not on PostgreSQL. The syntax used is standard and accepted by both, but the messages and some behaviors (the locks taken by ALTER TABLE on a large table, for example) belong to the database and are not verified here. A classic mistake to avoid in SQL for PostgreSQL: in the HAVING clause you cannot use the name of an output column, and the expression has to be rewritten; the SELECT page says so.
Frequently asked questions
Must I always have a JPA entity separate from the domain?
No. It is needed when the domain has rules and invariants to protect and you do not want the persistence framework to shape its form. For configuration tables or data without behavior, a record and a query are enough: this site's backend uses JdbcTemplate with no entity for the advertising settings table.
Manual mapper or MapStruct?
The manual mapper is the simplest to understand and suits few fields; MapStruct removes the repetitive code and flags unmapped fields at compile time, but adds an annotation processor and explicit converters for the non-trivial types. This site's backend uses MapStruct, with named converters for the enums.
Why does Flyway refuse a migration that I only touched up?
Because it compares the file's checksum with the one recorded when it was applied. You saw it above: one extra comment changes the checksum. A migration that has already run is considered immutable; the fix is written as a later migration.
What you can do now
You can write an immutable domain entity with its rule, a port in the domain and an adapter that implements it without letting persistence leak upward. You can tell when the separation is worth its cost and when an adapter with JdbcTemplate is enough. You can version the schema with migrations that are never rewritten, and introduce a constraint on a table with data in three steps. The next step is the use case that orchestrates these pieces, and the overall picture is in Clean Architecture in Spring Boot 3.4; to test the adapters with a real database see Testcontainers.
Sources
- Redgate Flyway - Migrations - versioned, repeatable and undo migrations; consulted on 2026-11-12
- Spring Boot - Common Application Properties - default values of
spring.jpa.hibernate.ddl-auto,spring.flyway.baseline-on-migrate,validate-on-migrate; consulted on 2026-11-12 - MapStruct Reference Guide - MapStruct as an annotation processor that generates the mapping code; consulted on 2026-11-12
- PostgreSQL 16 - SELECT - the name of an output column cannot be used in WHERE and HAVING; consulted on 2026-11-12
- Backend sources:
ArticleRepositoryAdapter.java,ArticleMapper.java,AdsSettingsRepositoryAdapter.java,application.ymlunderbackend/src/main/- read on 2026-11-12 - Test run: Flyway 10.20.1 and H2 2.2.224 from Maven Central, OpenJDK 21.0.13, in a temporary folder outside the repository

Commenti
Caricamento commenti...
Sign in to comment