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 via super(id).
  • The JPA no-arg constructor is @NoArgsConstructor(access = PROTECTED) — not
    hand-written, not public.
  • equals/hashCode live once on DomainEntity using Hibernate.getClass
    (survives lazy proxies) and id equality (null-safe so transient instances aren’t
    “equal”).
  • Audit stamps are Hibernate-managed (@CreationTimestamp/@UpdateTimestamp)
    on AuditableEntity; a new entity picks AuditableEntity if 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:

  • @Getter on entities.
  • @NoArgsConstructor(access = PROTECTED) for the JPA constructor.
  • @RequiredArgsConstructor for 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 provided dependency + explicit annotationProcessorPaths on the
    compiler plugin (don’t rely on classpath discovery).

C. The rest of the scaffold (owned elsewhere — don’t restate, apply)

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).

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 only flyway-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 on spring-boot-starter-flyway.