Entities — Tasks¶
Eight exercises that exercise the key entity skills: identity, equality, invariants, JPA mapping, versioning, lifecycle. Each lists the requirement, the validation criteria, and a worked solution.
Task 1 — Design a Customer entity¶
Requirement. Build a Customer entity with:
- Surrogate UUID id, assigned at construction.
- Mutable
emailandfullName(changed via behaviour methods). - An invariant: a customer cannot be
deactivatedand then havechangeEmailsucceed. - Identity-based equality and hash code.
- A no-arg protected constructor for JPA.
Validation.
- Constructor throws on null arguments.
customer1.equals(customer2)iff their ids are equal.customer.hashCode()does not change whenemailchanges.customer.deactivate(); customer.changeEmail("x")throwsIllegalStateException.
Worked solution.
@Entity
@Table(name = "customers")
public class Customer {
@Id
@Column(updatable = false)
private UUID id;
@Column(nullable = false) private String email;
@Column(nullable = false) private String fullName;
@Enumerated(EnumType.STRING) private Status status = Status.ACTIVE;
@Version private long version;
protected Customer() { }
public Customer(String email, String fullName) {
this.id = UUID.randomUUID();
this.email = Objects.requireNonNull(email);
this.fullName = Objects.requireNonNull(fullName);
}
public UUID id() { return id; }
public String email() { return email; }
public String fullName() { return fullName; }
public Status status() { return status; }
public void changeEmail(String newEmail) {
if (status != Status.ACTIVE) {
throw new IllegalStateException("Inactive customer cannot change email");
}
this.email = Objects.requireNonNull(newEmail);
}
public void deactivate() { this.status = Status.INACTIVE; }
public enum Status { ACTIVE, INACTIVE }
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Customer that)) return false;
return id.equals(that.id);
}
@Override public int hashCode() { return id.hashCode(); }
}
Task 2 — Fix a Set<Order> identity bug¶
Given.
public class Order {
@Id private Long id; // IDENTITY, null until persist
private String description;
@Override public int hashCode() { return Objects.hash(id, description); }
@Override public boolean equals(Object o) {
return o instanceof Order other && Objects.equals(id, other.id)
&& Objects.equals(description, other.description);
}
}
Set<Order> orders = new HashSet<>();
Order o = new Order();
o.setDescription("blue mug");
orders.add(o);
em.persist(o); // id assigned
o.setDescription("blue mug, large"); // attribute changed
orders.contains(o); // returns false
Fix the entity so orders.contains(o) returns true regardless of id assignment and attribute changes.
Validation.
hashCodedoes not depend on mutable attributes.- After
em.persist(o)and after later attribute changes,containsstill returns true.
Worked solution.
public class Order {
@Id private Long id;
private String description;
@Override public int hashCode() { return getClass().hashCode(); } // stable
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Order other)) return false;
return id != null && id.equals(other.id);
}
}
Note: with IDENTITY, two transient orders are unequal (id is null) — equality requires ==. Once persisted, equality is by id. Long-term fix: switch to UUID assigned at construction.
Task 3 — Add @Version and exercise optimistic locking¶
Requirement.
- Add
@Versionto theAccountentity. - Write a JUnit test that simulates two parallel transactions debiting the same account.
- Verify the second transaction throws
OptimisticLockException.
Validation.
- Without
@Version, both transactions succeed; final balance is wrong. - With
@Version, only one succeeds; the other throws.
Worked solution.
@Entity
public class Account {
@Id private UUID id;
@Version private long version;
private Money balance;
protected Account() { }
public Account(UUID id, Money initial) {
this.id = id; this.balance = initial;
}
public void debit(Money amount) {
if (amount.isGreaterThan(balance)) throw new InsufficientFundsException();
this.balance = balance.subtract(amount);
}
}
@Test
void concurrentDebitsRaiseOptimisticLock() {
UUID id = UUID.randomUUID();
inTx(em -> em.persist(new Account(id, Money.of(100))));
EntityManager em1 = emf.createEntityManager();
EntityManager em2 = emf.createEntityManager();
em1.getTransaction().begin();
em2.getTransaction().begin();
Account a1 = em1.find(Account.class, id);
Account a2 = em2.find(Account.class, id); // same version
a1.debit(Money.of(30));
a2.debit(Money.of(40));
em1.getTransaction().commit(); // succeeds: version 0 → 1
assertThrows(OptimisticLockException.class, em2.getTransaction()::commit);
}
Task 4 — Migrate from natural key to surrogate key¶
Given. Existing schema uses email as PK on users table; legacy data must be preserved; foreign keys from other tables reference users.email.
Requirement. Migrate to UUID surrogate PK with email kept as a unique column. Provide:
- The SQL migration steps.
- The new JPA entity.
- A backfill plan that maintains FK integrity.
Validation.
- Old
emailreferences continue to resolve to the same user post-migration. - Email can now change without breaking FKs.
- No duplicate UUIDs.
Worked solution.
-- Step 1: add nullable UUID column
ALTER TABLE users ADD COLUMN id UUID;
-- Step 2: backfill
UPDATE users SET id = gen_random_uuid() WHERE id IS NULL;
ALTER TABLE users ALTER COLUMN id SET NOT NULL;
-- Step 3: add new FK columns to dependent tables
ALTER TABLE orders ADD COLUMN user_id UUID;
UPDATE orders o SET user_id = u.id FROM users u WHERE o.user_email = u.email;
-- Step 4: switch constraints
ALTER TABLE orders DROP CONSTRAINT orders_user_email_fkey;
ALTER TABLE users DROP CONSTRAINT users_pkey;
ALTER TABLE users ADD PRIMARY KEY (id);
ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE (email);
ALTER TABLE orders ADD CONSTRAINT orders_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
-- Step 5: drop the old email columns from dependents (after release)
ALTER TABLE orders DROP COLUMN user_email;
@Entity
@Table(name = "users", uniqueConstraints = @UniqueConstraint(columnNames = "email"))
public class User {
@Id @Column(updatable = false) private UUID id;
@Column(nullable = false) private String email;
@Column(nullable = false) private String fullName;
@Version private long version;
// ...
}
Task 5 — Implement soft delete¶
Requirement. Add soft-delete to Customer: em.remove(customer) should set deleted_at = NOW() instead of physically deleting. Subsequent queries should not return deleted customers.
Validation.
- After
em.remove(c), the row still exists withdeleted_atset. em.find(Customer.class, c.id())returns null (because@Wherefilters).- Email uniqueness still works for a re-created customer with the same email.
Worked solution.
@Entity
@Table(name = "customers")
@SQLDelete(sql = "UPDATE customers SET deleted_at = NOW() WHERE id = ? AND version = ?")
@Where(clause = "deleted_at IS NULL")
public class Customer {
@Id private UUID id;
@Version private long version;
@Column(name = "deleted_at") private Instant deletedAt;
@Column(nullable = false) private String email;
// ...
}
Partial unique index on email to allow re-creation:
Task 6 — Write equals/hashCode that survives Hibernate proxies¶
Requirement. Make Customer's equals/hashCode work for both fully-loaded entities and em.getReference proxies.
Validation.
proxy.equals(fullEntity)returns true when they share the same id.fullEntity.equals(proxy)returns true symmetrically.- Works without
Hibernate.unproxycalls leaking into application code.
Worked solution.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Customer)) return false;
Customer that = (Customer) Hibernate.unproxy(o);
return id != null && id.equals(that.id);
}
@Override public int hashCode() { return getClass().hashCode(); }
The key: instanceof (accepts proxies, since the proxy is a subclass) + Hibernate.unproxy before accessing fields on that.
Task 7 — Detect N+1 in a test¶
Requirement. Add an integration test that fails when findAllOrders() exhibits N+1.
Validation. Test passes when the implementation uses JOIN FETCH; test fails when it relies on lazy loading.
Worked solution.
@Test
void findAllOrdersDoesNotNPlusOne() {
// arrange — 10 orders, each with 5 items
inTx(em -> {
for (int i = 0; i < 10; i++) {
Order o = new Order();
for (int j = 0; j < 5; j++) o.addItem(productId(), 1, Money.of(10));
em.persist(o);
}
});
SQLStatementCountValidator.reset();
List<Order> orders = orderRepo.findAllWithItems();
orders.forEach(o -> o.getItems().size());
SQLStatementCountValidator.assertSelectCount(1); // exactly 1 SELECT
}
Task 8 — Detect identity-vs-value-object misclassification¶
Requirement. Given a list of candidate concepts, classify each as Entity or Value Object and justify.
| Concept | Entity or VO? | Why? |
|---|---|---|
Money (an amount + currency) | ||
BankAccount | ||
Address (lines, city, country) | ||
Customer | ||
EmailAddress | ||
OrderLineItem (in an order) | ||
ShippingLabel (assigned to parcel) | ||
Country (ISO-3166) |
Worked answers.
| Concept | Verdict | Justification |
|---|---|---|
Money | VO | £100 is interchangeable with any other £100 — no identity. |
BankAccount | Entity | "Alice's account 12345" is a specific account, distinguishable. |
Address | VO | Two (10 Downing St, London) records are interchangeable. |
Customer | Entity | Persons have identity across attribute changes. |
EmailAddress | VO | Two alice@x.com strings are equal by value. |
OrderLineItem | VO inside Order, Entity if independently referenced | Depends on the aggregate design. |
ShippingLabel | Entity | Each label has a unique tracking number referenced by carriers. |
Country | VO (or reference data entity) | GB is GB everywhere. If queried by id with no behaviour, often VO. |
The pattern: ask "do siblings with identical attributes count as the same thing or different things?" Same → VO. Different → Entity.
Memorize this: the entity tasks recur in every backend project — design the entity, fix its equals/hashCode, add versioning, migrate keys, soft-delete, detect N+1, classify entity vs value. Master each once with a working solution in hand, and you carry a template for the next project. The validation criteria are not arbitrary — they are the evidence that the entity contract holds: identity is stable, equality is by id, invariants are enforced, lifecycle transitions don't break the equals/hashCode pair, and Hibernate doesn't burn you on lazy loads or batching.