Inappropriate Intimacy — Tasks¶
Eight exercises that take you from "spot the smell" to "prove the boundary in CI". Work through them in order — earlier ones build the vocabulary later ones rely on.
Exercise 1 — Catalogue bidirectional access¶
Given the following classes, list every place where Customer and LoyaltyAccount read or write each other's internals. Produce a table of access pairs.
class Customer {
String name;
int tier;
LoyaltyAccount account;
void upgrade() {
if (account.points > 1000) tier = 2;
account.lastUpgradeAt = LocalDate.now();
}
}
class LoyaltyAccount {
int points;
LocalDate lastUpgradeAt;
Customer owner;
void debit(int p) {
points -= p;
if (points < 0 && owner.tier == 2) owner.tier = 1;
}
}
Validation: at least 4 bidirectional accesses identified.
Exercise 2 — Refactor to one-way ownership¶
Refactor Exercise 1 so Customer is the only side that mutates tier, and LoyaltyAccount is the only side that mutates points and lastUpgradeAt. Use messages (account.debit(p) returns a result) rather than field access.
Validation: each field has exactly one writer class; no cross-class field assignment remains.
Exercise 3 — Break a Jackson serialisation cycle¶
Given:
@Entity class Author { @Id Long id; String name;
@OneToMany(mappedBy = "author") List<Book> books; }
@Entity class Book { @Id Long id; String title;
@ManyToOne Author author; }
@RestController class AuthorController {
@GetMapping("/authors/{id}") Author get(@PathVariable Long id) { ... }
}
Produce two solutions:
- Annotate the entities with
@JsonManagedReference/@JsonBackReference. - Return a DTO (
AuthorViewwithList<BookView>) instead of the entity.
Validation: GET /authors/1 returns a finite JSON document; integration test covers both solutions.
Exercise 4 — Fix an equals/hashCode cycle¶
Two entities have equals/hashCode that recurse into each other (see find-bug Scenario 3). Rewrite them so identity is based on id only, then write a test that adds 1,000 such objects to a HashSet in 50 ms and proves no StackOverflow.
Validation: test asserts no exception and timing under 50 ms.
Exercise 5 — Enforce a domain-purity rule with ArchUnit¶
Add a test class ArchitectureRulesTest with rules that:
- Forbid
javax.persistenceandjakarta.persistenceimports fromcom.shop..domain... - Forbid Spring annotations in the same packages.
- Forbid
com.fasterxml.jacksonimports in domain. - Forbid cycles between sub-packages of
com.shop.
Then deliberately introduce a violation and watch the test fail.
Validation: build fails on the deliberate violation, passes after revert.
Exercise 6 — Split a JPMS module¶
You are given a single Maven module with packages:
Create a module-info.java that:
- Exports
com.shop.apionly. - Keeps
com.shop.api.implandcom.shop.persistence.*hidden. - Uses a qualified export to share
com.shop.utilwithcom.shop.testsonly.
Validation: javac rejects any external module trying to import a hidden package.
Exercise 7 — Compute CBO and MPC manually¶
For the following class, compute CBO, MPC, and MPC/CBO. Then state whether refactoring is warranted.
class OrderProcessor {
private final OrderRepository repo;
private final TaxCalculator tax;
private final ShippingService shipping;
void process(Order o) {
repo.lock(o.id());
repo.attach(o);
var t = tax.compute(o);
var s = tax.region(o);
shipping.book(o);
shipping.notify(o.customer());
shipping.track(o.id());
repo.flush();
}
}
Validation: CBO = 3, MPC = 8, MPC/CBO ≈ 2.7 — within healthy band; no refactor needed.
Exercise 8 — Replace cross-service cache invalidation with events¶
Starting point:
class UserService {
final Map<Long, User> cache = new ConcurrentHashMap<>();
public User load(long id) { ... }
}
class AuditService {
final UserService users;
void onEvent(Event e) { users.cache.remove(e.userId()); }
}
Refactor so:
UserService.cacheisprivate.AuditServiceno longer referencesUserService.- A
UserChangedEventflows between them through Spring'sApplicationEventPublisher.
Validation: AuditService.onEvent publishes; UserService consumes and evicts itself; ArchUnit rule forbids any class outside users package from naming UserService.cache.
Validation table¶
| # | Skill | Pass condition |
|---|---|---|
| 1 | Recognise bidirectional access | 4+ pairs listed |
| 2 | Apply one-way ownership | Each field has one writer |
| 3 | Break Jackson cycle | Two working solutions implemented |
| 4 | Anchor equals/hashCode on id | 1,000-element set test passes under 50 ms |
| 5 | Enforce purity with ArchUnit | Build fails on violation |
| 6 | Use JPMS to hide packages | External import is rejected by javac |
| 7 | Read CBO/MPC numbers | Correct computation, correct verdict |
| 8 | Decouple via events | No direct field access between services remains |
Worked solution sketch — Exercise 2¶
public final class Customer {
private final CustomerId id;
private String name;
private Tier tier;
private final LoyaltyAccount account;
public Customer(CustomerId id, String name, LoyaltyAccount account) {
this.id = id;
this.name = name;
this.tier = Tier.BRONZE;
this.account = account;
}
public void upgradeIfEligible() {
if (account.qualifiesForUpgrade()) {
this.tier = this.tier.next();
account.recordUpgrade(LocalDate.now());
}
}
void downgrade() { this.tier = this.tier.previous(); }
}
public final class LoyaltyAccount {
private int points;
private LocalDate lastUpgradeAt;
private final Consumer<DebitResult> onOverdraft; // callback, not a field reach
public LoyaltyAccount(int points, Consumer<DebitResult> onOverdraft) {
this.points = points;
this.onOverdraft = onOverdraft;
}
public boolean qualifiesForUpgrade() { return points > 1000; }
public DebitResult debit(int p) {
points -= p;
var result = new DebitResult(points >= 0, points);
if (!result.ok()) onOverdraft.accept(result);
return result;
}
void recordUpgrade(LocalDate at) { this.lastUpgradeAt = at; }
}
What changed:
- No class reads or writes another's fields.
- Callback (
onOverdraft) replaces the previous reach intoowner.tier—LoyaltyAccountdoes not knowCustomerexists. Customerexposes a package-privatedowngrade()that the same package can wire as the overdraft handler — explicit, narrow, and reviewable.- Field writers are now:
tier->Customer,pointsandlastUpgradeAt->LoyaltyAccount. Each field has exactly one owner.
Memorize this¶
- Decoupling is incremental — eliminate one bidirectional access at a time.
- A successful refactor is measured by each field has one writer, not by line count.
- Tests, not comments, prove that a boundary is real (ArchUnit, JPMS, integration tests).
- Callbacks and events are the way to keep behaviour without keeping intimacy.
- A finite JSON document and a fast
HashSetlookup are the two most common tangible wins. - Run the metrics before and after — numbers convince teams that the refactor was worth it.