ADR-0017: Persistent-entity & new-backend baseline
- Status: Accepted
- Date: 2026-07-13
- Scope: core (any backend service)
Context
Every new backend re-derives the same foundational conventions: how entities are
based, how identity is generated, how Lombok is (and isn’t) used, how the package
tree is laid out. These conventions exist — but only implicitly, as code in the
Spring backends (DomainEntity/AuditableEntity in expenses); they were never
written as a decision. So when the Quarkus tickets backend was scaffolded, an
assistant re-invented the base entity and got identity wrong: it generated the
UUID by hand in the constructor (super(UUID.randomUUID())) and hand-wrote the
JPA no-arg constructor, instead of letting the persistence layer own the id.
The fix each time is the same, and the user should not have to teach it. This ADR
records the baseline a new service adopts on day one — the pieces that weren’t
written down — and points at the ADRs that own the rest.
Decision
When scaffolding a new backend, adopt this baseline before writing feature code.
A. Persistent-entity base classes (two levels)
Two @MappedSuperclass bases; concrete entities extend the one they need.
@Getter
@MappedSuperclass
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public abstract class DomainEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID) // the PERSISTENCE LAYER owns the id
private UUID id;
@Override public boolean equals(Object o) { // proxy-safe, id-based, null-safe pre-persist
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
return getId() != null && Objects.equals(getId(), ((DomainEntity) o).getId());
}
@Override public int hashCode() { return Hibernate.getClass(this).hashCode(); }
}
@Getter
@MappedSuperclass
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public abstract class AuditableEntity extends DomainEntity {
@CreationTimestamp @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt;
@UpdateTimestamp @Column(name = "updated_at", nullable = false) private Instant updatedAt;
}Hard rules that follow:
- The id is generated by the persistence layer (
@GeneratedValue). Never
UUID.randomUUID()in a constructor, and never pass the id in viasuper(id). - The JPA no-arg constructor is
@NoArgsConstructor(access = PROTECTED)— not
hand-written, not public. equals/hashCodelive once onDomainEntityusingHibernate.getClass
(survives lazy proxies) and id equality (null-safe so transient instances aren’t
“equal”).- Audit stamps are Hibernate-managed (
@CreationTimestamp/@UpdateTimestamp)
onAuditableEntity; a new entity picksAuditableEntityif it wants them, else
DomainEntity. Business constructors set business fields only — no id/audit
plumbing.
B. Lombok — the DDD-allowed subset
Our domain style (ADR-0003) bans most of Lombok. The allowed set is small and fixed:
@Getteron entities.@NoArgsConstructor(access = PROTECTED)for the JPA constructor.@RequiredArgsConstructorfor constructor injection on services/components
(mind the 0006-disambiguate-beans-by-concrete-type footgun on Spring+Lombok).- Never
@Setter, never@Builder— state changes go through intent methods,
construction through guarded constructors (0003-ddd-intent-methods). - Business constructors stay hand-written (they carry guards/logic). A
hand-written getter (e.g. a defensive-copy collection getter) coexists with
@Getter— Lombok skips a field that already has one. - Wire it as a
provideddependency + explicitannotationProcessorPathson the
compiler plugin (don’t rely on classpath discovery).
C. The rest of the scaffold (owned elsewhere — don’t restate, apply)
- Clean-Architecture packages
domain / application / infrastructure / presentation
undercom.senseei.<project>— 0002-clean-architecture-and-use-cases. - Schema owned by migrations, ORM set to validate — 0010-schema-changes-via-migrations.
- Request DTOs are records with validation annotations; response DTOs are records
built from entities — 0011-dto-design-rules. - Value objects for primitives with rules — 0013-value-objects-for-domain-primitives.
- Outbound integrations are profile-switched ports; dev needs zero credentials —
0012-profile-switched-outbound-gateways. - Every paginated query gets a unique id tiebreaker — 0016-stable-pagination-tiebreaker.
D. Framework-neutral note (Spring ↔ Quarkus)
The entity + Lombok baseline is identical across Spring Boot and Quarkus — JPA is
JPA. One nuance: column naming. Spring Boot auto-applies snake_case; other stacks
(Quarkus) do not by default — configure a snake_case physical naming strategy, or
use explicit @Column(name = "...") on every multi-word column. Don’t rely on the
default mapping.
Consequences
- A new service starts from a known-good baseline; the identity mistakes
(hand-generated ids, public no-arg constructors, a missing audit base) can’t recur —
they’re a written rule, not tribal code. - One more ADR to keep current as the baseline evolves (e.g. when a stack is added).
- This ADR is deliberately an index + the two missing pieces (base classes, Lombok
policy), not a re-statement of 0002/0010/0011/0012/0013 — one decision, one home
(0003-collaboration-working-agreements).
Related
- 0003-ddd-intent-methods — entities own invariants via intent methods; this ADR is
the persistence/identity/Lombok counterpart to that domain-behavior stance. - 0002-clean-architecture-and-use-cases, 0010-schema-changes-via-migrations,
0011-dto-design-rules, 0012-profile-switched-outbound-gateways,
0013-value-objects-for-domain-primitives, 0016-stable-pagination-tiebreaker —
the rest of the baseline this indexes. - 0006-disambiguate-beans-by-concrete-type — the Lombok-constructor footgun to avoid
when using@RequiredArgsConstructor. - Origin: caught scaffolding the Quarkus tickets backend (
docs/tickets/backend/0001).
Amendment — 2026-09-10: on Spring Boot 4, depend on the starter, not the library
Spring Boot 4 moved auto-configuration into per-technology modules that the starters
bring in. A library on the classpath without its starter is silently inert.
Dracomania was scaffolded with org.flywaydb:flyway-core alone: no migration ran, no
error was raised, and ddl-auto: validate passed because there were no entities yet.
- Every Boot-integrated library enters through its
spring-boot-starter-*
(spring-boot-starter-flyway, not onlyflyway-core). The library may stay as an
explicit dependency; the starter is what makes it run. - When a new integration does nothing and reports nothing, check for its starter before
debugging its configuration. - This applies to every Spring Boot 4 backend (Expenses, barbershop, dracomania).
Expenses already depends onspring-boot-starter-flyway.