On this page
The problem with entity setters is not that setName is aesthetically unpleasant. The problem is that a public mutation can bypass the rules that make several fields and child rows valid together.
I built a small purchase-order aggregate around three invariants:
- A line quantity must be between 1 and 100.
- An order cannot be confirmed without at least one line.
- A confirmed order cannot be edited.
The design uses no public setters. It exposes commands such as addLine, changeQuantity, and confirm, returns immutable read views, and verifies stale concurrent edits with real Hibernate optimistic locking.
Start from an invalid state that setters allow
This shape is convenient but cannot protect the aggregate:
@Entity
@Getter
@Setter
class PurchaseOrder {
private OrderStatus status;
@OneToMany(mappedBy = "order")
private List<OrderLine> lines;
}
Any caller can now:
order.setStatus(CONFIRMED); // even when lines is empty
order.setLines(List.of()); // even after confirmation
A service may remember to validate first, but another service, mapper, test fixture, or deserializer may not. The entity has no single boundary where its rules are guaranteed.
This is not an argument against Lombok or every getter. A generated accessor does not create a Swagger endpoint; API documentation comes from controller mappings and schemas. A getter also does not cause LazyInitializationException by itself. That exception occurs when code accesses an uninitialized lazy association after its persistence context is unavailable. The real questions are which state is public, who may mutate it, and where loading occurs.
Put commands on the aggregate root
The root owns its lines and exposes business operations:
@Entity
@Table(name = "purchase_orders")
public class PurchaseOrder {
@Id @GeneratedValue
private Long id;
@Version
private long version;
@Enumerated(EnumType.STRING)
private OrderStatus status = OrderStatus.DRAFT;
private long changeSequence;
@OneToMany(
mappedBy = "order",
cascade = CascadeType.ALL,
orphanRemoval = true
)
private final List<OrderLine> lines = new ArrayList<>();
protected PurchaseOrder() {}
public static PurchaseOrder draft() {
return new PurchaseOrder();
}
}
The protected no-argument constructor exists for JPA. Application code uses the named factory, which starts the aggregate in a valid state.
Each method says what the caller intends and enforces the relevant rules:
public void addLine(String sku, int quantity) {
requireDraft();
Objects.requireNonNull(sku, "sku");
lines.add(new OrderLine(this, sku, quantity));
changeSequence++;
}
public void changeQuantity(String sku, int quantity) {
requireDraft();
OrderLine line = lines.stream()
.filter(candidate -> candidate.sku().equals(sku))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(
"unknown sku: " + sku));
line.changeQuantity(quantity);
changeSequence++;
}
public void confirm() {
requireDraft();
if (lines.isEmpty()) {
throw new IllegalStateException(
"an order needs at least one line");
}
status = OrderStatus.CONFIRMED;
changeSequence++;
}
private void requireDraft() {
if (status != OrderStatus.DRAFT) {
throw new IllegalStateException(
"confirmed orders cannot be edited");
}
}
changeQuantity is more useful than setQuantity. It identifies the line, verifies aggregate status, delegates the line-level range rule, and participates in aggregate versioning.
The child still protects its local invariant:
void changeQuantity(int quantity) {
if (quantity < 1 || quantity > 100) {
throw new IllegalArgumentException(
"quantity must be between 1 and 100");
}
this.quantity = quantity;
}
The root protects cross-object rules; the child protects a rule about its own value.
Do not serialize the entity as the API
Returning JPA entities from controllers couples the HTTP schema to persistence and makes lazy loading, bidirectional relationships, and accidental field exposure part of request handling.
The read boundary is an immutable DTO:
public record OrderView(
Long id,
long version,
String status,
List<PurchaseOrder.OrderLineView> lines
) {
public static OrderView from(PurchaseOrder order) {
return new OrderView(
order.id(),
order.version(),
order.status().name(),
order.lineViews()
);
}
}
Mapping should occur inside a transaction or after a query that deliberately fetches the required graph. The controller returns OrderView, not a live Hibernate proxy.
This also makes the API contract explicit. Adding an internal persistence field does not automatically publish it, and removing a getter from the entity does not unexpectedly break JSON.
Optimistic locking must cover the whole aggregate
@Version prevents a stale root-row update from silently overwriting a newer one. The usual SQL shape is conceptually:
UPDATE purchase_orders
SET status = ?, version = version + 1
WHERE id = ? AND version = ?;
If no row matches, Hibernate reports an optimistic-lock failure.
There is a subtle boundary: changing only an OrderLine does not necessarily dirty the purchase_orders row. A version field on the root therefore may not protect concurrent child-only edits.
The experiment makes every aggregate mutation increment changeSequence on the root. That deliberately dirties the root row, so its version advances when a line changes. Other valid strategies include putting versions on children, forcing an optimistic lock on the root, or remodeling the transaction boundary. The important part is testing the actual SQL behavior rather than assuming @Version automatically covers a graph.
The integration test loads two detached copies, commits one editor, then attempts to merge the stale editor:
PurchaseOrder firstEditor = loadDetached(id);
PurchaseOrder staleEditor = loadDetached(id);
firstEditor.changeQuantity("SKU-1", 2);
mergeAndCommit(firstEditor);
staleEditor.changeQuantity("SKU-1", 3);
assertThatThrownBy(() -> mergeAndCommit(staleEditor))
.isInstanceOf(OptimisticLockException.class);
That test originally exposed the child-only version gap. Adding a root mutation made the test pass for the right reason.
Test the domain without Hibernate, then test Hibernate
Most invariants are plain unit tests:
@Test
void confirmedOrderCannotBeMutated() {
PurchaseOrder order = PurchaseOrder.draft();
order.addLine("SKU-1", 2);
order.confirm();
assertThatThrownBy(() ->
order.changeQuantity("SKU-1", 3))
.isInstanceOf(IllegalStateException.class);
}
These are fast and show that the object protects itself. The separate @DataJpaTest proves mapping, cascade behavior, and optimistic locking against Hibernate and H2.
Both levels are necessary. A unit test cannot prove SQL version checks. A persistence test should not be the only place a business rule is readable.
Where accessors remain reasonable
Accessors are not forbidden. I use them deliberately:
| Type | Typical choice |
|---|---|
| Request/response DTO | Immutable record or explicit constructor |
| Configuration binding | Framework-compatible immutable properties where possible |
| JPA identifier/version | Narrow read access when application code needs it |
| Aggregate collection | Immutable view or mapped DTO, never the mutable list |
| Business mutation | Named method that enforces the rule |
| Persistence-only constructor | protected no-argument constructor |
The test is whether the public method preserves meaning. order.confirm() describes a transition. order.setStatus(CONFIRMED) exposes storage.
Transaction and loading boundaries still matter
A well-encapsulated entity does not solve every JPA problem. Services still need to:
- load the aggregate with the relationships required for the command;
- keep one transaction around the command and commit;
- avoid N+1 query patterns when mapping lists of aggregates;
- decide how optimistic-lock conflicts become API responses;
- avoid leaking entities beyond the persistence boundary;
- publish external events only after durable state is committed.
The concrete lesson is not “never use @Getter.” It is: make the aggregate the only place where its invariants can be changed, make the API a separate model, and test the concurrency boundary with the real ORM.
The Jakarta Persistence specification defines entity and version semantics, while the Hibernate user guide documents Hibernate’s mapping and optimistic-lock behavior.