- Chapter
- Chapter 1 — Design at Multiple Scales
- Prerequisites
- Topic 1.1; Volume II (OOD & SOLID); Volume VI Topics 3.2, 6.3
- You will be able to
- Design classes around a single cohesive responsibility, encapsulate state so invariants cannot be broken, choose between entities, value objects, services and policies, design collaborations with clear message flows, and apply GRASP heuristics to assign responsibilities
- Covers
- Cohesion; Encapsulation; Collaboration
On this page
- 1. Concept / Definition
- 2. Why It Exists
- 3. Core Concepts
- 4. How It Works Internally — Assigning Responsibilities (GRASP)
- 4A. Deep Dive — Entities, Value Objects and Services in Modern Java
- 5. APIs / Features / Syntax
- 6. Practical Examples
- 7. Common Problems & Mistakes
- 8. Performance & Production Considerations
- 9. Architecture & Design Considerations
- 10. Interview Questions & Answers
1Concept / Definition
Class-level design decides which state and behaviour belong together in a class, how that state is protected, and how the class collaborates with others to realise use cases.
| Concern | Question | Good outcome |
|---|---|---|
| Cohesion | Do all members serve one purpose? | Methods use shared state towards one responsibility |
| Encapsulation | Can clients break the object's rules? | State private; invariants enforced by behaviour |
| Collaboration | How do objects work together? | Few, clear messages; each object does its own part |
2Why It Exists
Object orientation promised to put data and behaviour together; in practice many Java codebases separated them again — data classes with getters/setters, and "service" classes holding logic (the anemic domain model). The result is scattered rules, duplicated validation and objects that can be put into invalid states by any caller. Class-level design restores the original idea: objects that are responsible for their own consistency.
3Core Concepts
3.1 Cohesion

A class is cohesive when its methods work on its fields toward one responsibility (Volume VI, 6.3 for metrics).
| Class role | Cohesion looks like | Java form |
|---|---|---|
| Entity | Identity + lifecycle behaviour over its state | Order with addLine, pay, cancel |
| Value object | Immutable value + operations on it | record Money(BigDecimal, Currency) with plus |
| Domain service / policy | One business decision spanning objects | DiscountPolicy.discountFor(order, customer) |
| Application service | One use case orchestration | PlaceOrderService |
| Adapter | One technology concern | JpaOrderRepository, RazorpayGateway |
Cohesion red flags: name suffixes like Utils/Manager, members used by disjoint method groups, many unrelated injected dependencies, frequent changes for unrelated reasons.
3.2 Encapsulation

Encapsulation means clients cannot see or modify internal state directly and can only use operations that preserve the object's invariants.
// Poor encapsulation: any caller can break the rules
public class Order {
private List<OrderLine> lines = new ArrayList<>();
private OrderStatus status;
private BigDecimal total;
public List<OrderLine> getLines() { return lines; } // mutable list escapes
public void setStatus(OrderStatus s) { status = s; } // PAID → DRAFT possible
// total can disagree with lines
public void setTotal(BigDecimal t) {
total = t;
}
}// Encapsulated: invariants enforced, state changes only through behaviour
public class Order {
private final OrderId id;
private final List<OrderLine> lines = new ArrayList<>();
private OrderStatus status = OrderStatus.DRAFT;
public void addLine(ProductId product, int quantity, Money unitPrice) {
requireStatus(OrderStatus.DRAFT, "add lines");
if (quantity <= 0) {
throw new IllegalArgumentException("quantity must be positive");
}
lines.add(new OrderLine(product, quantity, unitPrice));
}
public void pay(PaymentReference ref) {
requireStatus(OrderStatus.DRAFT, "pay");
if (lines.isEmpty()) throw new EmptyOrder(id);
status = OrderStatus.PAID;
}
public Money total() { // derived, never stored inconsistently
return lines.stream()
.map(OrderLine::lineTotal)
.reduce(Money.ZERO_INR, Money::plus);
}
public List<OrderLine> lines() { return List.copyOf(lines); } // read-only snapshot
public OrderStatus status() { return status; }
private void requireStatus(OrderStatus expected, String action) {
if (status != expected) throw new IllegalOrderState(id, status, action);
}
}| Encapsulation technique | Java means |
|---|---|
| Hide fields | private fields; no setters by default |
| Immutable values | Records, final fields, List.copyOf |
| Validate at construction | Compact constructors, factory methods |
| Expose behaviour, not data | order.pay(…) instead of setStatus(PAID) |
| Prevent subclass tampering | final classes, sealed hierarchies |
| Package-private collaborators | Hide helper classes inside the package |
3.3 Collaboration

Collaboration design decides who calls whom with what message. Good collaborations:
| Guideline | Meaning |
|---|---|
| Tell, don't ask | Send commands to the object that owns the data rather than querying and deciding elsewhere |
| Law of Demeter | Talk to direct collaborators, not to their internals (a.getB().getC()) |
| Few collaborators per class | High fan-out indicates too many responsibilities (Volume VI, 6.2) |
| Depend on abstractions at boundaries | Ports/interfaces for infrastructure collaborators |
| Explicit dependencies | Constructor injection; no hidden new or static access |
// Collaboration through constructor-injected roles; each collaborator has one job
@Service
public class PlaceOrderService {
private final Carts carts;
private final PricingPolicy pricing;
private final Orders orders;
private final DomainEvents events;
public PlaceOrderService(Carts carts, PricingPolicy pricing,
Orders orders, DomainEvents events) {
this.carts = carts; this.pricing = pricing;
this.orders = orders; this.events = events;
}
@Transactional
public OrderId place(CartId cartId, CustomerId customer) {
Cart cart = carts.require(cartId);
PricedCart priced = pricing.price(cart, customer); // ask the expert to decide
Order order = Order.fromPricedCart(customer, priced); // creator creates
orders.save(order);
events.publish(new OrderPlaced(order.id(), order.total()));
return order.id();
}
}4How It Works Internally — Assigning Responsibilities (GRASP)

| Step | Question | Heuristic |
|---|---|---|
| 1 | Which data does the behaviour need? | Information Expert — put it with that data |
| 2 | Who should create this object? | Creator — the aggregate that contains or closely uses it |
| 3 | Does the behaviour span several objects? | Domain service or policy (Pure Fabrication) |
| 4 | Will this behaviour vary? | Protected Variations — interface + strategies |
| 5 | Who handles the use-case request? | Controller — application service |
| 6 | Does the assignment raise coupling? | Low Coupling — reconsider |
4ADeep Dive — Entities, Value Objects and Services in Modern Java
| Type | Identity | Mutability | Equality | Java 25 form |
|---|---|---|---|---|
| Value object | None | Immutable | By value | record Money(...), record Email(...) |
| Entity | Stable id | Controlled mutation via behaviour | By id | Class with private state; JPA @Entity optional |
| Aggregate root | Id; consistency boundary | Mutation only via root | By id | Order controlling OrderLines |
| Domain service | None | Stateless | — | final class or @Component without state |
| Application service | None | Stateless | — | @Service orchestrating use cases |
// Value object: validation + behaviour, immutable, equality by value
public record Money(BigDecimal amount, Currency currency) implements Comparable<Money> {
public static final Money ZERO_INR =
new Money(BigDecimal.ZERO, Currency.getInstance("INR"));
public Money {
Objects.requireNonNull(currency);
amount = amount.setScale(currency.getDefaultFractionDigits(),
RoundingMode.HALF_EVEN);
}
public Money plus(Money other) {
requireSame(other);
return new Money(amount.add(other.amount), currency);
}
public Money times(long n) {
return new Money(amount.multiply(BigDecimal.valueOf(n)), currency);
}
public int compareTo(Money o) { requireSame(o); return amount.compareTo(o.amount); }
private void requireSame(Money o) {
if (!currency.equals(o.currency)) {
throw new CurrencyMismatch(currency, o.currency);
}
}
}JPA entities and encapsulation: JPA needs a no-arg constructor and field access — use protected constructors, field access (@Access(FIELD)), no public setters, and @Embeddable records for value objects. Keep collections private; expose copies.
5APIs / Features / Syntax
| Feature | Class design use |
|---|---|
| Records, compact constructors | Value objects with validation |
| Sealed classes/interfaces | Controlled variants of a concept |
final classes (default stance) | Prevent unintended inheritance |
List.copyOf, Collections.unmodifiableList | Encapsulate collections |
| Constructor injection (Spring) | Explicit collaborators |
| Package-private visibility | Hide helper classes |
| ArchUnit | Rules like "no public setters on domain entities" |
@ArchTest
static final ArchRule noSettersOnDomainEntities =
noMethods().that().areDeclaredInClassesThat().resideInAPackage("..domain..")
.and().haveNameMatching("set[A-Z].*")
.should().bePublic();6Practical Examples
6.1 Moving validation into value objects
Email, phone and PAN validation spread over controllers and services moved into records with compact constructors; invalid values can no longer exist past the boundary.
6.2 Replacing a Utils class
OrderUtils.calculateTotal(order), OrderUtils.isCancellable(order) and OrderUtils.canShip(order) moved into Order (Information Expert); OrderUtils was deleted.
6.3 Worked example — from anemic to rich class design
| Aspect | Anemic Subscription + SubscriptionService | Rich Subscription |
|---|---|---|
| State changes | setStatus, setPlan, setRenewalDate from service | upgradeTo(plan, clock), cancel(reason, clock), renew(clock) |
| Rules | In service methods, duplicated in batch job | In the entity, once |
| Invalid states possible | Yes (cancelled + auto-renew) | No — transitions checked |
| Tests | Service tests with mocks | Plain entity tests |
public void upgradeTo(Plan newPlan, Clock clock) {
if (status != SubscriptionStatus.ACTIVE) {
throw new IllegalSubscriptionState(id, status, "upgrade");
}
if (!newPlan.isHigherThan(plan)) throw new NotAnUpgrade(plan, newPlan);
plan = newPlan;
changes.add(new PlanChanged(id, plan, Instant.now(clock)));
}6.4 Worked example — designing collaborations for a use case
Use case "refund an order line":
| Responsibility | Class | Heuristic |
|---|---|---|
| Receive the request, transaction boundary | RefundService (application) | Controller |
| Decide refundable amount | RefundPolicy (domain service) | Pure Fabrication; varies by product type |
| Record the refund against the line | Order.refundLine(lineId, amount) | Information Expert |
| Execute money movement | PaymentGateway port | Protected Variations |
| Notify customer | DomainEvents → notifier | Low Coupling (event) |
@Transactional
public RefundId refund(OrderId orderId, LineId lineId, RefundReason reason) {
Order order = orders.require(orderId);
Money amount = refundPolicy.refundableAmount(order, lineId, reason, clock);
Refund refund = order.refundLine(lineId, amount, reason);
paymentGateway.refund(order.paymentReference(), amount);
events.publish(new LineRefunded(orderId, lineId, amount));
return refund.id();
}6.5 Worked example — class design review
| Check | Finding in CustomerService (900 lines) | Action |
|---|---|---|
| Cohesion (dependency clusters) | 3 clusters: profile, addresses, notifications | Extract AddressBook, CustomerNotifier |
| Encapsulation | Customer has 22 public setters | Replace with behaviour methods |
| Collaboration | Service navigates customer.getAccount().getTier().getDiscount() | Hide delegate: customer.discountRate() |
| Roles | Validation logic in service | Move into Email, PhoneNumber records |
7Common Problems & Mistakes
- Anemic domain models with public setters and logic in services.
- Leaking mutable internals (collections, arrays, dates).
- God classes with many unrelated collaborators.
- Deep navigation chains instead of asking the expert.
- Inheritance for code reuse instead of composition.
- Framework annotations everywhere obscuring the domain model.
8Performance & Production Considerations
| Aspect | Note |
|---|---|
| Immutable value objects | Thread-safe; cheap allocation; safe to cache |
| Defensive copies | Small cost; use unmodifiable views for large collections |
| Rich JPA entities | Watch lazy loading inside domain methods; fetch needed associations |
| Stateless services | Safe as Spring singletons; scale horizontally |
Small, well-encapsulated classes rarely cost measurable performance on a modern JVM. Short-lived value objects such as Money are allocated in thread-local buffers and collected cheaply in the young generation, and escape analysis can remove many allocations entirely when an object never leaves the method that created it. Immutable records are also safe to share across threads and to cache without locks. The real production risks lie elsewhere: rich JPA entities whose behaviour methods touch lazy associations cause N+1 queries, so fetch what each use case needs with fetch joins or entity graphs. Defensive copies of large collections on hot paths add garbage; return unmodifiable views there. Profile with JFR before trading encapsulation for speed.
9Architecture & Design Considerations
- Treat classes as self-protecting units; the same principle defines module and service APIs.
- Use value objects as the shared vocabulary across modules.
- Keep application services thin and domain objects rich.
- Enforce class-level rules (no setters, no framework in domain) with architecture tests.
10Interview Questions & Answers
What is class cohesion?
Class cohesion is the degree to which a class's members work together towards one responsibility. In a cohesive class most methods use most of the fields, and every member serves the same purpose, such as Order managing its lines and lifecycle, or a Money record holding a value and its arithmetic. Low cohesion shows up as Utils or Manager names, groups of methods that use disjoint subsets of fields, many unrelated injected dependencies, and a class that changes for unrelated reasons. Metrics such as LCOM quantify this. High cohesion matters because a requirement then maps to one place, and the class is easier to name, understand, test and change safely.
What is encapsulation?
Encapsulation means an object hides its internal state and lets clients change it only through operations that preserve its invariants. It is more than private fields with getters and setters: a public setStatus, or a getter returning the internal mutable list, is still a hole. A well-encapsulated Order keeps its lines private, exposes addLine and pay methods that check the current status, derives its total instead of storing it, and returns List.copyOf(lines) so callers cannot modify internals. As a result, invalid states such as a paid order with no lines cannot be created by any caller, and each rule lives in one place where it cannot drift.
Why are public setters on entities a problem?
Public setters let any caller put the entity into an invalid state, and they push business rules out of the entity into every caller. With setStatus available, code can move a PAID order back to DRAFT or mark an empty order as paid; with setTotal, the stored total can disagree with the lines. Every service, batch job and controller then has to remember the rules, so they get duplicated, drift apart and are tested separately, which is the anemic domain model. Setters also hide intent: setStatus(CANCELLED) says less than cancel(reason, clock). Replace setters with behaviour methods that validate transitions, and create valid objects through constructors or factories.
What is Tell, Don't Ask?
Tell, Don't Ask means sending a command to the object that owns the data instead of querying its state and making the decision elsewhere. The ask style reads account.getBalance(), compares it with the amount, checks getStatus() and then calls setBalance() from a service. The tell style calls account.withdraw(amount) and lets Account enforce the balance and frozen-account rules itself, throwing a domain exception when needed. Telling keeps behaviour next to its data, which is Information Expert, keeps invariants in one place and reduces coupling to internal structure. It works hand in hand with the Law of Demeter. Queries are still fine for display and reporting; the rule targets decisions and state changes.
How do value objects differ from entities?
A value object has no identity: it is defined entirely by its attributes, is immutable and is compared by value, so two Money instances of 100 INR are interchangeable. An entity has a stable identity that persists while its attributes change over a lifecycle, so two customers with the same name are still different customers, and equality is by ID. In Java 25, value objects are naturally records with compact constructors for validation, such as Money or Email, while entities are classes with private state changed only through behaviour methods. Prefer value objects wherever identity is not needed: they are thread-safe, easy to test, safe to share and cure primitive obsession.
How do you assign a responsibility to a class?
I use the GRASP heuristics as a sequence of questions. Which data does the behaviour need? Information Expert puts it in the class holding that data. Who creates this object? Creator suggests the aggregate that contains or closely uses it, so Order creates its OrderLines. Does the behaviour span several objects and fit none of them? Make a domain service or policy, a Pure Fabrication such as RefundPolicy. Will it vary? Protected Variations puts it behind an interface with strategies. Who receives the use-case request? An application service acting as Controller. Finally I check Low Coupling and High Cohesion: if an assignment adds many dependencies or unrelated members, I reconsider it.
How do you keep JPA entities well encapsulated?
JPA needs a no-argument constructor and a way to set fields, but not public setters. Use field access, by annotating fields or with @Access(AccessType.FIELD), so Hibernate writes fields directly and the class can expose only behaviour methods. Make the no-arg constructor protected for the provider, and offer a public constructor or static factory that creates a valid entity. Keep collections private, mutate them only through methods such as addLine, and return List.copyOf or an unmodifiable view. Model values as @Embeddable types, which Hibernate 6.2 and later also accept as records, for example Money. Ban public setters with an ArchUnit rule, and watch lazy loading inside domain methods.
How does class-level design relate to service design?
The same principles apply at every scale; only the boundary gets larger. A cohesive class owns one responsibility; a well-designed service owns one business capability and changes for one reason. Encapsulation becomes data ownership: a service hides its database behind its API just as a class hides its fields behind methods, and no other service reads its tables. Intention-revealing methods become small APIs with commands like PlaceOrder rather than generic CRUD endpoints. Collaboration rules such as Tell, Don't Ask and few collaborators become avoiding chatty calls and long synchronous chains, often by using events. Teams that design self-protecting classes tend to build self-protecting modules and services.