Inappropriate Intimacy — Optimize¶
Ten optimisation angles that arise specifically because two classes are too close. Each is a concrete pattern with the rule it makes safe.
1. JPA bidirectional fetch joins instead of cascading lazy loads¶
Bidirectional @OneToMany/@ManyToOne is convenient but tempts code to walk back and forth, triggering N+1.
// Read path that genuinely needs both sides — fetch them in one shot
@Query("""
select distinct o from Order o
left join fetch o.lines l
left join fetch l.product
where o.id = :id
""")
Optional<Order> findWithLines(Long id);
Rule: every read path either uses a join fetch or accepts that the collection stays lazy and untouched.
2. Break equals/hashCode infinite recursion by anchoring identity on the id¶
@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);
}
@Override public int hashCode() { return getClass().hashCode(); }
Rule: equals on a JPA entity uses id only; hashCode is constant per class (Vlad Mihalcea's pattern). Never include associations.
3. Serialization cycles — use @JsonManagedReference / @JsonBackReference¶
class Department {
@JsonManagedReference List<Employee> employees;
}
class Employee {
@JsonBackReference Department department;
}
The BackReference side is silently dropped from the JSON output, breaking the cycle.
Rule: pick one side as the owner of serialisation. Better still — return DTOs, never entities, from controllers.
4. @JsonIdentityInfo when both sides must serialise¶
When the same graph contains the same object twice and you cannot drop a side:
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
class Node { Long id; List<Node> neighbours; }
Jackson writes the full object the first time and a reference ({"@id":1}) afterwards. No recursion.
Rule: prefer references over duplicated subtrees in serialised graphs.
5. Lazy proxies and Hibernate.initialize to defuse cascade walks¶
Inside a transaction:
Hibernate.initialize(order.getLines());
order.getLines().forEach(l -> Hibernate.initialize(l.getProduct()));
You explicitly choose what to materialise; nothing else triggers a load.
Rule: control materialisation explicitly at the query boundary, never let view rendering pull lazy collections.
6. Eviction without coupling — domain events instead of cross-service cache.remove¶
@Component
class UserChangedListener {
private final CacheManager cacheManager;
@EventListener
void on(UserChangedEvent e) { cacheManager.getCache("users").evict(e.userId()); }
}
Rule: caches are private state; let owners invalidate their own caches in response to events.
7. Replace bidirectional service calls with a mediator¶
Two services calling each other become three classes: A, B, and Mediator that orchestrates them. CBO of A and B drops, MPC drops, the bidirectional edge disappears.
class TransferMediator {
private final AccountService accounts;
private final LedgerService ledger;
void transfer(TransferCmd cmd) {
accounts.debit(cmd.from(), cmd.amount());
ledger.record(cmd);
accounts.credit(cmd.to(), cmd.amount());
}
}
Rule: when two collaborators must coordinate, extract a third class that owns the coordination.
8. Replace boolean-trail returns with intent-revealing immutable results¶
A common intimacy pattern: caller reads several fields after each call to figure out what happened.
// Before — caller must read state on the service
service.process(x);
if (service.lastFailed()) ...
if (service.lastWarning() != null) ...
// After — return a value object
ProcessResult r = service.process(x);
if (r.failed()) ...
Rule: a method's outcome belongs in its return value, not in mutable fields of the callee.
9. Pre-compute on the owning side to remove "peek" calls¶
If OrderReport repeatedly asks Order for line totals, give Order a method that returns the total once.
class Order {
public Money totalForReport() { return lines.stream().map(OrderLine::total).reduce(Money.ZERO, Money::plus); }
}
Rule: the loop belongs on the side that owns the data; reports consume one number, not n.
10. Snapshot DTOs at the boundary to stop deep walks¶
Returning a deep entity graph from a @RestController is an open invitation for Inappropriate Intimacy — every consumer learns the whole shape.
record OrderView(Long id, BigDecimal total, List<OrderLineView> lines) {}
record OrderLineView(String sku, int qty, BigDecimal lineTotal) {}
@GetMapping("/orders/{id}")
public OrderView get(@PathVariable Long id) {
return mapper.toView(repo.findWithLines(id).orElseThrow());
}
The DTO contains exactly what the API promises and nothing else; entity internals stay inside the persistence boundary.
Rule: never expose an @Entity over HTTP, gRPC, or any external boundary.
Quick rules¶
- Pick one direction as the navigable one; mark the other read-only or drop it.
equals/hashCodeon entities depend onidonly;toStringexcludes collections.- Use
@JsonManagedReference+@JsonBackReferenceor@JsonIdentityInfo— never both at once. - Add
join fetchto the small number of read paths that need it; leave the rest lazy. - Default
@ManyToOnetoFetchType.LAZYeverywhere on the project. - Replace cross-service cache pokes with events or owner-side
invalidatemethods. - Promote coordination between two intimate services to a mediator.
- Return DTOs from controllers, not entities.
- Audit
cascade = CascadeType.ALL— assign one aggregate root per relationship. - Compute on the owning side; expose the result, not the fields.
Memorize this¶
- The fix for bidirectional intimacy is almost never "tune the framework" — it is "remove a direction".
- Bidirectional + Jackson + Lombok is a recursion-bug triangle; break it before the first feature ships.
- Two services with bidirectional MPC > 10 each are one mediator away from being clean.
- DTOs are not boilerplate; they are the moat around your domain.
- Every "evict the other guy's cache" call is a future bug; replace with events.
- Pre-compute on the owner; reports consume results, not internals.