Stop Breaking HashSet: equals() and hashCode() for JPA in Quarkus
Production-safe entity equality patterns for Panache, Hibernate proxies, and stable hashing, with EqualsVerifier tests
Most developers think equals() and hashCode() are boilerplate. You generate them in your IDE, move on, and never think about them again.
That works for value objects. It does not work for JPA entities.
In real systems, entities cross transaction boundaries. They move from managed to detached state. Hibernate wraps them in proxies. IDs get assigned after persist(). And suddenly, your HashSet cannot find objects that are clearly there. Your cache starts duplicating entries. Your deduplication logic silently stops working.
I’ve debugged this in production. Everything looked fine in unit tests. Then a Set-based guard started allowing duplicates because equality was based on object identity. Two instances representing the same database row were “different” in Java. That is not a loud failure. That is a silent data corruption.
The problem is simple: Java’s equality contract is strict, and JPA entity lifecycles are tricky. If you combine both without understanding them deeply, things break under load.
Today we build this properly. End to end. With Quarkus, Panache, PostgreSQL Dev Services, and strict contract verification using EqualsVerifier.
Before touching code, you need to understand why equals() and hashCode() are critical, especially in JPA applications.
The everyday scenarios where it silently breaks
The vanishing entity in a Set:
Set<Product> cart = new HashSet<>();
Product p = productRepository.findById(1L);
cart.add(p);
// Later in another transaction...
Product sameProduct = productRepository.findById(1L);
cart.contains(sameProduct); // returns FALSE — even though it's the same DB row!This happens because after a transaction boundary, the entity objects are different Java instances. If equals() uses object identity (the default), two objects pointing to the same database row are “not equal.”
Duplicate rows appearing in a List that’s deduplicated:
List<Order> orders = orderRepository.listAll();
Set<Order> uniqueOrders = new HashSet<>(orders); // Supposed to deduplicate
// But if equals/hashCode are wrong, you end up with duplicatesEntity moved to detached state breaks the hash:
Set<Employee> team = new HashSet<>();
Employee emp = new Employee(); // id is null here
team.add(emp);
employeeRepository.persist(emp); // NOW id is set (e.g., 42L)
team.contains(emp); // returns FALSE — hash has changed!This is the notorious mutable hashCode trap. Using id in hashCode() before it’s assigned.
The Java Contract Explained
Java defines a formal contract in java.lang.Object. You must not break it or you’ll get unpredictable behavior from HashMap, HashSet, ArrayList.contains(), and virtually every collection.
The equals() contract
Reflexive:
x.equals(x)must betrueSymmetric: if
x.equals(y)theny.equals(x)Transitive: if
x.equals(y)andy.equals(z)thenx.equals(z)Consistent: multiple calls with unchanged objects always return the same result
Null-safe:
x.equals(null)must returnfalse(never throw NPE)
The hashCode() contract
Consistent: same object must return same hash across calls (within one JVM run)
equals-implies-same-hash: if
x.equals(y)thenx.hashCode() == y.hashCode()Not the reverse:
x.hashCode() == y.hashCode()does NOT implyx.equals(y)(collisions are allowed)
The golden rule you must never break
If two objects are equal, they MUST have the same hashCode. The reverse is not required.
The default Object.equals() uses reference identity (==), and Object.hashCode() uses memory address. These defaults are almost always wrong for JPA entities.
Why JPA makes this extra hard
JPA entities have a unique lifecycle challenge:
They can be new (id is
null)They can be managed (in the persistence context)
They can be detached (after transaction ends)
They can be proxied (Hibernate wraps them in a subclass proxy for lazy loading)
All four states need equals()/hashCode() to work consistently. That’s the tricky part.
Project Setup
Prerequisites
You need the following:
Java 17+
Maven 3.8+
Podman (for Quarkus Dev Services / PostgreSQL)
Project Setup
Create the project or start from my Github repository:
quarkus create app com.example:equals-demo \
--extension=quarkus-rest,quarkus-hibernate-orm-panache,quarkus-jdbc-postgresql
cd equals-demoExtensions explained:
quarkus-rest- REST endpoints (for integration testing scenarios)quarkus-hibernate-orm-panache- JPA with Panache entitiesquarkus-jdbc-postgresql- PostgreSQL driver
Add EqualsVerifier to pom.xml:
<dependency>
<groupId>nl.jqno.equalsverifier</groupId>
<artifactId>equalsverifier</artifactId>
<version>4.4</version>
<scope>test</scope>
</dependency>Configure src/main/resources/application.properties:
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.schema-management.strategy=drop-and-create
quarkus.hibernate-orm.log.sql=trueNo manual PostgreSQL setup. Dev Services will start a container via Podman automatically when you run tests or dev mode.
The Entities We’ll Build
We’ll model a simple e-commerce domain: Category → Product → OrderLine.
This gives us:
A simple entity (
Category) — good baselineAn entity with a natural business key (
Productwith SKU)An entity with a composite relationship (
OrderLine)
Domain overview
Category (id, name)
|
└── Product (id, sku, name, price, category)
|
└── OrderLine (id, product, quantity)Naive Implementation (and Why It Breaks)
Let’s start with what most developers do intuitively and precisely why it fails.
Naive Tag entity
package com.example.entity;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "categories")
public class Tag extends PanacheEntity {
public String name;
// NAIVE — using all fields including mutable ones
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
Tag category = (Tag) o;
return java.util.Objects.equals(id, category.id) &&
java.util.Objects.equals(name, category.name);
}
@Override
public int hashCode() {
return java.util.Objects.hash(id, name); // mutable fields in hash
}
}Problems with this:
idisnullbeforepersist(). Two unsavedTagobjects with the same name are “equal” — even if they’re different objects.When
namechanges, thehashCode()changes. AnyHashSetcontaining this entity is now corrupt.When
idgets assigned by Hibernate, thehashCode()changes. The entity “disappears” from anyHashSetit was in.
Demonstration of the breakage
// This is NOT a test you want to pass — it demonstrates the bugs above
Set<Tag> tags = new HashSet<>();
Tag t = new Tag();
t.name = "jpa";
tags.add(t); // hashCode computed: Objects.hash(null, "jpa")
// Persist it — Hibernate sets id = 1L
tagRepository.persist(t);
// Now try to find it in the same Set
boolean found = tags.contains(t); // FALSE! hashCode changed when id was assignedThe Right Way for JPA/Panache Entities
There are three legitimate strategies. We’ll implement and explain each.
Strategy 1: Business Key (Natural Key)
Use a field that is business-meaningful, non-null, and immutable (like a SKU, email, UUID assigned in the constructor). This is the cleanest approach.
package com.example.entity;
import java.util.Objects;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "products")
public class Product extends PanacheEntity {
@Column(nullable = false, unique = true)
public String sku; // Business key — assigned at construction, never changes
public String name;
@Column(name = "price")
public java.math.BigDecimal price;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
public Tag category;
// Required for JPA
public Product() {
}
public Product(String sku, String name, java.math.BigDecimal price) {
if (sku == null || sku.isBlank()) {
throw new IllegalArgumentException("SKU must not be blank");
}
this.sku = sku;
this.name = name;
this.price = price;
}
/**
* Equality based on the business key (SKU).
* Works before AND after persistence.
* Works with Hibernate proxies (we check instanceof, not getClass()).
*/
@Override
public boolean equals(Object o) {
if (this == o)
return true;
// Use instanceof + null check — works with Hibernate proxies
if (!(o instanceof Product))
return false;
Product other = (Product) o;
// SKU must never be null (enforced in constructor)
return Objects.equals(sku, other.sku);
}
/**
* hashCode based ONLY on the business key.
* This never changes over the entity's lifetime.
*/
@Override
public int hashCode() {
return Objects.hashCode(sku);
}
@Override
public String toString() {
return "Product{id=" + id + ", sku='" + sku + "', name='" + name + "'}";
}
}This is the cleanest solution.
Why it works:
skuis immutable.It exists before persistence.
It never changes.
Behavior under stress:
Even across transaction boundaries, a reloaded Product with same SKU equals the original. That makes Sets and caches stable.
Important: we use instanceof, not getClass(). Hibernate proxies subclass your entity. Using getClass() breaks equality with proxies.
Strategy 2: UUID assigned at construction.
When there’s no natural business key, assign a UUID yourself before JPA gets involved.
package com.example.entity;
import java.util.Objects;
import java.util.UUID;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "order_lines")
public class OrderLine extends PanacheEntityBase {
@Id
@GeneratedValue
public Long id;
// UUID assigned at construction — stable across the entire lifecycle
@Column(name = "uuid", nullable = false, unique = true, updatable = false)
public final UUID uuid = UUID.randomUUID();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
public Product product;
@Column(nullable = false)
public int quantity;
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof OrderLine))
return false;
OrderLine other = (OrderLine) o;
return Objects.equals(uuid, other.uuid);
}
@Override
public int hashCode() {
return Objects.hashCode(uuid);
}
@Override
public String toString() {
return "OrderLine{id=" + id + ", uuid=" + uuid + ", quantity=" + quantity + "}";
}
}Key insight: uuid is assigned in the field initializer, which runs before any JPA operation. It’s never null and never changes. Perfect hashCode material.
Strategy 3: The database id approach
If you truly have no natural or surrogate key at construction time, you can use the database id, but you must handle the null case carefully. The trick: use a constant hashCode for entities without an id yet.
package com.example.entity;
import java.util.Objects;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "categories")
public class Category extends PanacheEntity {
@Column(nullable = false)
public String name;
public Category() {
}
public Category(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof Category))
return false;
Category other = (Category) o;
// If id is null, only same instance is equal (already caught by == above)
return id != null && Objects.equals(id, other.id);
}
@Override
public int hashCode() {
// Fixed constant: all Category objects get bucket 31.
// This is "good enough" — it means more hash collisions but is ALWAYS CORRECT.
// Never use id here if it can be null.
return getClass().hashCode();
}
@Override
public String toString() {
return "Category{id=" + id + ", name='" + name + "'}";
}
}The tradeoff: Using a constant hashCode() means all Category instances land in the same bucket of a HashMap. Performance degrades to O(n) for hash operations. For small collections (typically true for entity Sets), this is perfectly acceptable. It is always correct though.
Rule of thumb: Prefer Strategy 1 (business key) > Strategy 2 (UUID) > Strategy 3 (constant hash). Only use Strategy 3 as a last resort.
Testing with EqualsVerifier
EqualsVerifier by Jan Ouwens is the gold standard for testing equals() and hashCode() contracts. It checks all the contract properties automatically and has built-in awareness of JPA/Hibernate patterns.
Basic EqualsVerifier test
package com.example;
import org.junit.jupiter.api.Test;
import com.example.entity.Product;
import com.example.entity.Tag;
import io.quarkus.test.junit.QuarkusTest;
import nl.jqno.equalsverifier.EqualsVerifier;
import nl.jqno.equalsverifier.Warning;
class ProductEqualsTest {
@Test
void product_equalsAndHashCode_satisfyContract() {
Tag tag1 = new Tag();
tag1.id = 1L;
tag1.name = "Cat1";
Tag tag2 = new Tag();
tag2.id = 2L;
tag2.name = "Cat2";
EqualsVerifier.forClass(Product.class)
.suppress(Warning.NONFINAL_FIELDS)
.withPrefabValues(Tag.class, tag1, tag2)
.withOnlyTheseFields("sku")
.verify();
}
}Note: This is a plain unit test (not @QuarkusTest) so EqualsVerifier can access Product fields without Hibernate enhancement interference. We provide prefab Tag values because Product has a @ManyToOne(fetch = FetchType.LAZY) relation to Tag.
Comprehensive EqualsVerifier setup for JPA entities
EqualsVerifier needs to know:
JPA entities can’t be
final(it normally expects immutable value objects)Hibernate uses subclass proxies, so
instanceofis the right checkCertain fields (like
id) are intentionally excluded from equality
package com.example;
import java.math.BigDecimal;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import com.example.entity.Category;
import com.example.entity.OrderLine;
import com.example.entity.Product;
import nl.jqno.equalsverifier.EqualsVerifier;
import nl.jqno.equalsverifier.Warning;
class EntityEqualsContractTest {
// Product (Business Key: SKU)
@Nested
@DisplayName("Product — business key equality (SKU)")
class ProductEqualsTest {
@Test
@DisplayName("equals/hashCode contract is satisfied")
void contract_satisfied() {
Product p1 = new Product("SKU-X", null, null);
Product p2 = new Product("SKU-Y", null, null);
EqualsVerifier.forClass(Product.class)
.suppress(Warning.NONFINAL_FIELDS) // JPA entities are non-final
.withPrefabValues(Product.class, p1, p2)
.withOnlyTheseFields("sku") // Only sku drives equality
.verify();
}
@Test
@DisplayName("Two Products with same SKU are equal")
void sameSkuIsEqual() {
Product p1 = new Product("SKU-001", "Widget A", BigDecimal.TEN);
Product p2 = new Product("SKU-001", "Widget B", BigDecimal.ONE); // Different name!
assert p1.equals(p2) : "Same SKU → must be equal";
assert p1.hashCode() == p2.hashCode() : "Same SKU → same hashCode";
}
@Test
@DisplayName("hashCode is stable before and after setting id (simulating persist)")
void hashCodeStableAcrossPersist() {
Product p = new Product("SKU-STABLE", "Test", BigDecimal.ZERO);
int hashBefore = p.hashCode();
// Simulate what JPA does when persisting (sets the id)
p.id = 99L;
int hashAfter = p.hashCode();
assert hashBefore == hashAfter : "hashCode must not change when id is assigned";
}
@Test
@DisplayName("Product works correctly in a HashSet across simulated transaction boundary")
void worksInHashSetAcrossTransactionBoundary() {
Product original = new Product("SKU-TX-001", "Widget", BigDecimal.TEN);
original.id = 1L; // Simulates post-persist state
java.util.Set<Product> set = new java.util.HashSet<>();
set.add(original);
// Simulate loading same entity in a new session (new object, same data)
Product reloaded = new Product("SKU-TX-001", "Widget", BigDecimal.TEN);
reloaded.id = 1L;
assert set.contains(reloaded) : "Same SKU → must be found in Set";
}
@Test
@DisplayName("null is never equal to a Product")
void nullSafety() {
Product p = new Product("SKU-NULL", "Test", BigDecimal.ONE);
assert !p.equals(null) : "equals(null) must return false";
}
@Test
@DisplayName("Products with different SKUs are not equal")
void differentSkuNotEqual() {
Product p1 = new Product("SKU-A", "Same Name", BigDecimal.TEN);
Product p2 = new Product("SKU-B", "Same Name", BigDecimal.TEN);
assert !p1.equals(p2);
}
}
// OrderLine (UUID Strategy)
@Nested
@DisplayName("OrderLine — UUID-based equality")
class OrderLineEqualsTest {
@Test
@DisplayName("equals/hashCode contract is satisfied")
void contract_satisfied() {
EqualsVerifier.forClass(OrderLine.class)
.suppress(Warning.NONFINAL_FIELDS)
.withOnlyTheseFields("uuid")
.verify();
}
@Test
@DisplayName("Two distinct OrderLine instances are NOT equal (each has unique UUID)")
void distinctInstancesAreNotEqual() {
OrderLine line1 = new OrderLine();
OrderLine line2 = new OrderLine();
assert !line1.equals(line2) : "Two new OrderLines should not be equal";
// (UUID collision probability: 1 in 2^122 — negligible)
}
@Test
@DisplayName("Same instance is always equal to itself")
void sameInstanceIsEqual() {
OrderLine line = new OrderLine();
assert line.equals(line);
assert line.hashCode() == line.hashCode();
}
@Test
@DisplayName("hashCode is stable even before id is set")
void hashCodeStableBeforePersist() {
OrderLine line = new OrderLine();
int hash1 = line.hashCode();
int hash2 = line.hashCode();
assert hash1 == hash2;
line.id = 100L; // simulate persist
assert line.hashCode() == hash1 : "hashCode must not change after id assignment";
}
}
// Category (Constant Hash / DB Id Strategy)
@Nested
@DisplayName("Category — database id equality with constant hashCode")
class CategoryEqualsTest {
@Test
@DisplayName("equals/hashCode contract is satisfied")
void contract_satisfied() {
EqualsVerifier.forClass(Category.class)
.suppress(Warning.NONFINAL_FIELDS)
.suppress(Warning.IDENTICAL_COPY_FOR_VERSIONED_ENTITY) // id can be null
.suppress(Warning.SURROGATE_KEY) // equality by id only
.suppress(Warning.STRICT_HASHCODE) // hashCode is intentionally constant
.suppress(Warning.JPA_GETTER) // PanacheEntity uses public field id, no getter
.verify();
}
@Test
@DisplayName("Two unsaved Categories with same name are NOT equal (both have null id)")
void unsavedCategoriesAreNotEqual() {
Category c1 = new Category("Electronics");
Category c2 = new Category("Electronics");
// Same name, but different objects, both null id
// equals() returns false when id is null (only self-equality holds)
assert !c1.equals(c2) : "Unsaved entities with null id must not be equal to each other";
}
@Test
@DisplayName("Category with id is equal to another Category with same id")
void savedCategoriesWithSameIdAreEqual() {
Category c1 = new Category("Electronics");
c1.id = 5L;
Category c2 = new Category("Different Name");
c2.id = 5L;
assert c1.equals(c2) : "Same DB id → same entity";
assert c1.hashCode() == c2.hashCode();
}
@Test
@DisplayName("hashCode is constant regardless of state")
void hashCodeIsConstant() {
Category c = new Category("Books");
int hashBefore = c.hashCode();
c.id = 7L;
c.name = "Changed";
assert c.hashCode() == hashBefore : "hashCode must be constant";
}
}
}Advanced EqualsVerifier: handling lazy-loaded relations
When your entity has @ManyToOne(fetch = FetchType.LAZY), EqualsVerifier may try to use that field in its tests and fail because it can’t instantiate a Hibernate proxy. Tell it to ignore those fields:
@Test
void product_withLazyRelation_contractSatisfied() {
Tag tag1 = new Tag();
tag1.id = 1L;
tag1.name = "Cat1";
Tag tag2 = new Tag();
tag2.id = 2L;
tag2.name = "Cat2";
EqualsVerifier.forClass(Product.class)
.suppress(Warning.NONFINAL_FIELDS)
.withPrefabValues(Tag.class, tag1, tag2)
.withOnlyTheseFields("sku")
.verify();
}EqualsVerifier for all entities at once
For large projects, verify all entity classes in a single parameterized test:
package com.example;
import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import com.example.entity.Category;
import com.example.entity.OrderLine;
import com.example.entity.Product;
import nl.jqno.equalsverifier.EqualsVerifier;
import nl.jqno.equalsverifier.Warning;
class AllEntitiesEqualsContractTest {
static Stream<Class<?>> entityClasses() {
return Stream.of(
Category.class,
Product.class,
OrderLine.class);
}
@ParameterizedTest(name = "{0} — equals/hashCode contract")
@MethodSource("entityClasses")
void allEntities_satisfyEqualsContract(Class<?> entityClass) {
var verifier = EqualsVerifier.forClass(entityClass)
.suppress(Warning.NONFINAL_FIELDS)
.suppress(Warning.IDENTICAL_COPY_FOR_VERSIONED_ENTITY);
if (entityClass == Category.class) {
verifier.suppress(Warning.SURROGATE_KEY)
.suppress(Warning.STRICT_HASHCODE) // Category uses constant hashCode
.suppress(Warning.JPA_GETTER) // PanacheEntity uses public field id, no getter
.verify();
} else if (entityClass == Product.class) {
Product p1 = new Product("SKU-A", null, null);
Product p2 = new Product("SKU-B", null, null);
verifier.withPrefabValues(Product.class, p1, p2)
.withOnlyTheseFields("sku")
.verify();
} else if (entityClass == OrderLine.class) {
verifier.withOnlyTheseFields("uuid")
.verify();
} else {
verifier.verify();
}
}
}Integration Tests with Quarkus
Now let’s test the behavior end-to-end with real database operations using Quarkus’s @QuarkusTest and @TestTransaction.
Repository classes
First, our Panache repositories:
package com.example.repository;
import com.example.entity.Category;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class CategoryRepository implements PanacheRepository<Category> {
public Category findByName(String name) {
return find("name", name).firstResult();
}
}And the ProductRepository:
package com.example.repository;
import com.example.entity.Product;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class ProductRepository implements PanacheRepository<Product> {
public Product findBySku(String sku) {
return find("sku", sku).firstResult();
}
}Integration test
package com.example;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
import java.util.HashSet;
import java.util.Set;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import com.example.entity.Product;
import com.example.repository.CategoryRepository;
import com.example.repository.ProductRepository;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@QuarkusTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ProductPanacheEqualsIntegrationTest {
@Inject
ProductRepository productRepository;
@Inject
CategoryRepository categoryRepository;
@Test
@Order(1)
@Transactional
@DisplayName("Product equality works before and after persist in same transaction")
void equalityBeforeAndAfterPersistSameTransaction() {
Product product = new Product("INT-SKU-001", "Integration Widget", new BigDecimal("9.99"));
int hashBefore = product.hashCode();
assertNull(product.id, "id should be null before persist");
productRepository.persist(product);
assertNotNull(product.id, "id should be set after persist");
assertEquals(hashBefore, product.hashCode(),
"hashCode must not change after persist (business key doesn't change)");
}
@Test
@Order(2)
@Transactional
@DisplayName("Entity found by query equals entity found by id")
void entityFoundByQueryEqualsEntityFoundById() {
Product byId = productRepository.findById(
productRepository.findBySku("INT-SKU-001").id);
Product bySku = productRepository.findBySku("INT-SKU-001");
// Different query paths → should still be equal
assertEquals(byId, bySku);
assertEquals(byId.hashCode(), bySku.hashCode());
}
@Test
@Order(3)
@Transactional
@DisplayName("Entity persisted and added to Set can be found by a fresh query")
void entityInSetFoundByFreshQuery() {
Product original = new Product("INT-SKU-002", "Set Test Widget", new BigDecimal("19.99"));
productRepository.persist(original);
Set<Product> productSet = new HashSet<>();
productSet.add(original);
// Simulate fresh query (same transaction, but new entity reference from JPA)
Product freshQuery = productRepository.findBySku("INT-SKU-002");
assertTrue(productSet.contains(freshQuery),
"Fresh query result must be found in the Set — business key equality must work");
}
@Test
@Order(4)
@Transactional
@DisplayName("Updating mutable fields doesn't break Set membership")
void mutableFieldUpdateDoesNotBreakSetMembership() {
Product product = productRepository.findBySku("INT-SKU-001");
Set<Product> managedSet = new HashSet<>();
managedSet.add(product);
// Mutate a mutable field
product.name = "Renamed Widget";
product.price = new BigDecimal("14.99");
// Must still be findable in the Set (because hashCode is based on SKU, not
// name/price)
assertTrue(managedSet.contains(product),
"Mutating non-key fields must not break Set membership");
}
@Test
@Order(5)
@Transactional
@DisplayName("Two different SKUs produce different, unequal products")
void differentSkusProduceDifferentProducts() {
Product p1 = new Product("INT-SKU-A", "Widget A", BigDecimal.TEN);
Product p2 = new Product("INT-SKU-B", "Widget A", BigDecimal.TEN); // same name, different sku
productRepository.persist(p1);
productRepository.persist(p2);
assertNotEquals(p1, p2, "Different SKUs → not equal");
// hashCodes CAN be equal (collision) but let's check they're not both in a set
// as one
Set<Product> set = Set.of(p1, p2);
assertEquals(2, set.size(), "Two distinct products must occupy two slots in a Set");
}
@Test
@Order(6)
@DisplayName("Entity loaded in separate transactions is equal (simulating detached → reloaded)")
@Transactional
void entityEqualAfterReload() {
// This test loads from DB — equals must work across transaction boundaries
Product first = productRepository.findBySku("INT-SKU-001");
Product second = productRepository.findBySku("INT-SKU-001");
// In the same persistence context (same transaction), these are literally the
// same object.
// Across transactions, they would be different objects but should be equal.
assertEquals(first, second);
assertEquals(first.hashCode(), second.hashCode());
}
@AfterAll
static void cleanup() {
// Quarkus @TestTransaction usually rolls back; if not using it, clean up here.
}
}Testing the Category (constant hash strategy)
package com.example;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import com.example.entity.Category;
import com.example.repository.CategoryRepository;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@QuarkusTest
class CategoryEqualsIntegrationTest {
@Inject
CategoryRepository categoryRepository;
@Test
@Transactional
@DisplayName("Two unsaved Categories are not equal to each other")
void twoUnsavedCategoriesNotEqual() {
Category c1 = new Category("Books");
Category c2 = new Category("Books"); // same name, both id=null
// Per our implementation: id=null means only self-equality holds
assertNotEquals(c1, c2,
"Unsaved categories with null id should not be equal to each other");
}
@Test
@Transactional
@DisplayName("After persist, same-id categories are equal even with different names")
void persistedCategoriesEqualByIdOnly() {
Category c1 = new Category("Electronics");
categoryRepository.persist(c1);
// Reload (same transaction → likely same instance from L1 cache, but let's be
// explicit)
Category c2 = categoryRepository.findById(c1.id);
assertEquals(c1, c2);
}
@Test
@Transactional
@DisplayName("hashCode is constant regardless of mutation")
void hashCodeConstantDespiteMutation() {
Category c = new Category("Music");
categoryRepository.persist(c);
int originalHash = c.hashCode();
c.name = "Music & Instruments"; // mutate
assertEquals(originalHash, c.hashCode(),
"Constant hashCode must not change after field mutation");
}
@Test
@Transactional
@DisplayName("Performance warning: all categories in same hash bucket (constant hash)")
void allCategoriesInSameBucket() {
Category c1 = new Category("A");
Category c2 = new Category("B");
Category c3 = new Category("C");
// All three have the same hashCode (by design — constant)
assertEquals(c1.hashCode(), c2.hashCode());
assertEquals(c2.hashCode(), c3.hashCode());
// This is OK for correctness, but means HashSet lookups are O(n)
// For small domain collections, this is acceptable
}
}Running the Tests
Run the full suite with Maven:
./mvnw test
Podman (or Docker) must be running. Quarkus Dev Services automatically starts a PostgreSQL container for the @QuarkusTest integration tests. The first run takes a few extra seconds while the container image is pulled.
The test suite is split into three layers:
Unit tests (no container needed):
ProductEqualsTest — Verifies the Product business-key contract with EqualsVerifier. Runs as a plain JUnit test, no Quarkus bootstrap.
EntityEqualsContractTest — Comprehensive nested test class covering all three strategies: Product (business key), OrderLine (UUID), and Category (database id with constant hash). Includes contract verification, null safety, HashSet stability, and cross-boundary equality checks.
AllEntitiesEqualsContractTest — Parameterized sweep that runs EqualsVerifier across every entity class in one go, with per-entity configuration.
Integration tests (PostgreSQL via Dev Services):
ProductPanacheEqualsIntegrationTest — Ordered sequence of six tests that persist real rows and assert equality before/after persist, across query paths, inside HashSets, and after mutable-field mutations.
CategoryEqualsIntegrationTest — Verifies the constant-hash strategy against a live database: unsaved inequality, id-based equality after persist, hash stability across mutations, and the deliberate single-bucket behavior.
What to inspect in the output:
All tests green means the equals/hashCode contracts hold across every entity lifecycle state.
Watch for Hibernate SQL in the log (quarkus.hibernate-orm.log.sql=true is on). You should see INSERT and SELECT statements during the integration tests, confirming real database round-trips.
If an EqualsVerifier test fails, the error message pinpoints exactly which contract property was violated (e.g., “Symmetry”, “hashCode changed”) and which fields caused it. Read the message carefully before changing any suppression flags.
To run a single test class in isolation:
./mvnw test -Dtest=EntityEqualsContractTest
./mvnw test -Dtest=ProductPanacheEqualsIntegrationTestProduction Hardening
What happens under load
If equality is wrong, deduplication logic fails silently. For example:
Caching layers store duplicates.
Security filters tracking processed tokens break.
Domain logic using
Setloses integrity.
This does not crash. It corrupts.
Under high concurrency, equality inconsistencies amplify. Two threads loading same entity instance in different sessions must still compare equal.
Concurrency guarantees
Equality does not guarantee thread safety.
Even with perfect equals():
Two threads updating same entity need optimistic locking.
@Versionis required for correctness.hashCode()must not depend on mutable state.
Your equality logic only ensures collection correctness. It does not solve race conditions.
Security considerations
Never include lazy relations in equality.
If you include @ManyToOne fields:
You risk triggering lazy loading inside
equals().You risk circular equality calls.
You risk
StackOverflowError.
Equality must rely on stable, local identifiers only.
Conclusion
We built three equality strategies for JPA entities in Quarkus: business key, UUID, and constant hash with database ID. We verified the Java contract using EqualsVerifier, and we tested behavior across real persistence boundaries with PostgreSQL Dev Services. Most importantly, we avoided mutable hash codes and proxy-breaking type checks.
Equality is not boilerplate. It is a correctness boundary.


