Advanced System Design Tutorial 0/70 lessons ~6 min read Lesson 11

    Object-Oriented Design Fundamentals

    A low-level design interview is a responsibility-assignment exercise with a clock running. The interviewer listens for whether your types protect invariants, whether a change in fi

    Course progress0%
    Focus
    15 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    Object-Oriented Design Fundamentals

    Advanced System Design & Software Architecture · Lesson 11 · Low-Level Design and Design Patterns

    Lesson Overview

    A low-level design interview is a responsibility-assignment exercise with a clock running. The interviewer listens for whether your types protect invariants, whether a change in fines forces a change in catalog search, and whether you can name the boundary you refused to cross.

    TechLearningPro's campus library put catalog search, checkout, fines, and reminders in one LibraryService. A reminder-subject edit shared a pull request with the fine formula. A learner who owed nothing received a zero fine line because the email path and the formula shared a loop. Four stakeholders owned one type.

    This lesson is the object-design vocabulary you use before any pattern name. It covers encapsulation as a boundary with a contract, abstraction as the set of questions a caller is allowed to ask, inheritance as a subtype relation you can test, polymorphism as a call that does not name the concrete class, and composition as the default way to assemble behavior. Under those words sit cohesion, coupling, and Craig Larman's General Responsibility Assignment Software Patterns (GRASP): Information Expert, Creator, Controller, Low Coupling, High Cohesion, Polymorphism, Pure Fabrication, Indirection, and Protected Variations. You will apply them to a lending slice in Java 21: a Loan that knows whether it is overdue, a FinePolicy that knows the money rule, a BorrowCopy use case that orchestrates, and a catalog type that the loan is not allowed to import.

    /learn/low-level-design/encapsulation, /learn/low-level-design/inheritance, /learn/low-level-design/polymorphism, and /learn/low-level-design/composition-vs-inheritance introduce the same words with smaller examples. /learn/design-patterns/oop-for-patterns recaps them as a runway into Gang-of-Four names. /learn/low-level-design/coupling-cohesion defines the pair. Here the bar is an interview whiteboard and a review comment: assign each responsibility to a type, defend the coupling you kept, and show the Java that makes an illegal loan unrepresentable. Pattern names wait until lesson 14. SOLID, already practiced in lesson 4, is the constraint set; this lesson is the assignment method that comes first.

    Learning Objectives

    By the end of this lesson you will be able to:

    1. Assign a responsibility to a type using Information Expert and Creator, and name the stakeholder who would ask for a change to that type.
    2. Judge cohesion of a lending model (one reason to change per type) and name the coupling you accepted between Loan and MemberId.
    3. Draw an encapsulation boundary: which fields are hidden, which methods are the contract, and which invariants the constructor refuses to break.
    4. Choose composition for a fine rule and a notification channel, and state the one case where a sealed subtype hierarchy is the honest model.
    5. Explain polymorphism as a caller that depends on FinePolicy and a Spring @Bean that supplies one implementation, without the caller naming the class.
    6. Separate a use-case controller (BorrowCopy) from a domain object (Loan) and from a pure fabrication (JdbcLoanStore).
    7. Review an LLD sketch for god classes, anemic models, and inheritance used as code reuse, and say what you would move.
    8. Write the decision that keeps catalog search out of the lending aggregate, including the reopen condition.

    Prerequisites

    • Java 21: records, sealed types, packages, and java.time.Clock. You will read a Spring @Configuration and constructor injection. You will not configure a security filter chain.
    • Lesson 4's actor reading of single responsibility, and the habit of writing a contract a substitute must pass. This lesson assigns the types; it does not re-derive SOLID.
    • Lesson 8's distinction between an entity (identity over time) and a value object (equality by value). LoanId and Money are value objects. Loan is an entity.
    • Comfort reading a class with a private constructor and a static factory. UML notation is lesson 12; this lesson uses one class diagram as a responsibility map, not as a notation course.

    Detailed Core Concepts

    What an object is for in an LLD

    An object in a low-level design is a cluster of state and behavior that protects a rule. The rule is the reason the type exists. Loan exists so that a copy cannot be borrowed twice, a due date cannot precede the borrow instant, and a return cannot be recorded twice. If a type has state and no rule, it is a data bag, and the rule has leaked into a service where every caller must remember it. If a type has rules from several stakeholders, those stakeholders will collide in source control.

    The four textbook words are useful only when they name a decision:

    • Encapsulation is the decision that callers do not touch dueAt directly. They call isOverdue(clock) or markReturned(at). The field can change representation (a date versus an instant) without a caller edit, because the caller never saw the field.
    • Abstraction is the set of questions the type agrees to answer. Loan answers "are you overdue?" and "what fine do you owe under this policy?" It does not answer "what other titles did this member search last Tuesday?" That question belongs to a different type, with a different owner.
    • Inheritance is a claim that a subtype can stand in for its supertype. The claim is testable. It is the Liskov reading from lesson 4. Using extends to share three helper methods is not that claim.
    • Polymorphism is a call site that names a supertype or an interface. policy.fine(loan, clock) does not mention DailyRupeeFine. A new policy is a new class and a new @Bean. The call site stays.

    Composition is how you assemble those decisions. Loan holds a CopyId and a MemberId. BorrowCopy holds a LoanStore, a CopyAvailability, and a FinePolicy. None of those holdings is an extends clause. The object graph is the design.

    Cohesion: one reason, named

    A type is cohesive when its methods and fields change for the same reason. Myers, Constantine, and Stevens described a spectrum that is still useful in review, from weakest to strongest:

    KindMeaning in the lending serviceReview verdict
    CoincidentalUtils with formatIsbn and hashPasswordSplit. There is no shared reason.
    LogicalLibraryService with search, borrow, and sendReminder, selected by a flagSplit by actor. The flag is a missing type.
    TemporalStartup that loads fines, warms the cache, and opens SMTPAcceptable only at the composition root.
    ProceduralA method that must run steps in order, and a class that exists only to hold that orderKeep the order in one use case. Do not pretend the steps are one rule.
    CommunicationalMethods that operate on the same copy record: mark borrowed, mark returnedOften the right cohesion for an entity.
    SequentialOutput of one method is the input of the next, and they live together for that pipelineGood for a single workflow object. Dangerous when the steps gain different actors.
    FunctionalEvery part contributes to one rule: "a loan's fine is a pure function of days overdue and a policy"The target for FinePolicy.

    Interviewers ask why LibraryService is wrong. The answer is logical cohesion: unrelated operations grouped because they share the word "library." The repair is functional cohesion on FinePolicy and communicational cohesion on Loan. A Loan with borrow, renew, markReturned, and isOverdue stays cohesive when each method protects the same state machine. A five-line LoanManager that only forwards to a repository moved the rule off the data. That is how anemic models start.

    Coupling: the dependency you can defend

    Two types are coupled when a change to one forces a change to the other, or when understanding one requires the other. Structured design named forms you can still hear in a design review:

    • Content coupling. One type reaches into another's private fields, or uses reflection to set dueAt. This is a broken encapsulation boundary. Java's private is the language feature that makes the mistake visible. Package-private is a deliberate widening: types in com.techlearningpro.lending may share representation; types in com.techlearningpro.catalog may not.
    • Common coupling. Two types read and write the same mutable static, or the same unversioned global row, with no owner. A FineRates.RUPEES_PER_DAY static that the emailer and the policy both mutate is common coupling. Prefer a FinePolicy instance owned by the composition root.
    • Control coupling. A caller passes a flag that selects the callee's behavior: process(loan, Mode.EMAIL_OR_FINE). The flag is a missing type. Split the operations.
    • Stamp coupling. A caller passes a whole Member graph when the callee needs only MemberId. The callee recompiles when Member gains a phone number it never reads. Pass the identifier or a small value object.
    • Data coupling. The callee receives exactly the values it needs: a Loan and a Clock. This is the coupling you want between a policy and its inputs.
    • Message coupling, in the object sense: the caller knows the operation name and the contract, and nothing about the body. policy.fine(...) is message coupling. It is the goal of an interface at a real variation point.

    Zero coupling is not available. BorrowCopy must call CopyAvailability before it creates a loan. A boolean or a small enum keeps the catalog's search index out of lending. A return type of CatalogEntry pulls lending into the catalog's change stream. Agreeing on CopyId is mild connascence. Agreeing on an unenforced call order is connascence of execution: put that order in BorrowCopy only.

    Encapsulation is a boundary, not a keyword

    private on a field is the start. The boundary includes:

    • Construction. An illegal loan cannot be built. Loan.borrow rejects a due instant that is not after the borrow instant, a blank copy id, and a null member id. Callers do not get a Loan and then "set it up."
    • Mutation. State changes go through methods that name the business event: renew, markReturned. There is no setStatus(String). A string status is an invitation to "retruned".
    • Representation. The due date may be an Instant in the domain and a timestamptz in PostgreSQL. The JDBC adapter translates. The domain type does not import java.sql.Timestamp.
    • Package. The lending package is the boundary catalog code may not cross inward. Catalog may be called through CopyAvailability, which lending owns as an interface. That is dependency inversion from lesson 4, applied to a package seam. The full package rule is lesson 5; here you only need the seam to be visible in the constructor of BorrowCopy.

    A boundary without a contract is a wall with no door. The contract of FinePolicy is short enough to test: given a returned or overdue loan and a clock, return a Money in INR, zero or positive, with no I/O. A policy that emails the member has crossed the boundary. The email is a different type, invoked by the use case after the fine is computed.

    Inheritance, composition, and the sealed type you actually mean

    Composition places a collaborator in a field. You can replace the collaborator in a test by passing a different instance. You can add a second collaborator without growing a parent class. BorrowCopy is composed of a store, an availability port, and a clock. DailyRupeeFine is composed of a rupee amount and a cap. Those are fields, not parents.

    Inheritance is justified when you mean substitutability and the set of variants is known and closed, or when a framework forces a superclass (rare in this codebase; Spring favors interfaces and composition). Java 21 sealed interfaces express a closed set:

    java
    public sealed interface LoanStatus permits Open, Returned {
    }

    Open and Returned are records, and a switch on the sealed type is exhaustive. OverdueLoan extends Loan is the interview trap: overdue is a query against a clock, and a Java object does not change class at midnight. Ask whether an instance changes kind over time. If it does, keep the behavior on Loan and store a sealed status. Lesson 17 names the state pattern later; you can reject the hierarchy now.

    Sharing rounding code is a weak reason to inherit. Compose Money or call a package-private function. An abstract parent with one subclass is a speculative hierarchy. Keep the concrete class until the second variant is scheduled, and write that decision down.

    Polymorphism without a type switch

    A type switch in BorrowCopy (if (code.equals("DAILY"))) couples the use case to every policy. The polymorphic form is an interface the use case already holds. Adding FlatExamFine adds a class and a bean, and the call site stays. The cost is indirection: a bad implementation can violate the contract without a compiler error. The mitigation is the contract test from lesson 4, run against every implementation you register. Use polymorphism when the variation is real and the contract is stable.

    Overloading (fine(Loan) and fine(int days)) is compile-time selection. Override is the dynamic dispatch an LLD answer means.

    GRASP: assigning the method to a type

    Larman's GRASP patterns are questions, not libraries. Use them in the order an interview forces you to talk.

    Information Expert. The method goes to the type that already has the data. Overdue needs the due instant, so Loan.isOverdue(Clock) lives on Loan and the clock is an argument. An OverdueCalculator that reimplements the comparison from getters is a missed expert, unless the rule needs data the loan should not hold, such as a member's fine history.

    Creator. BorrowCopy creates the loan because it has the ids and the clock and is about to store it. Loan.borrow is the factory on the expert. Creator does not mean a LoanFactory class for every noun.

    Controller. BorrowCopy is the first domain object on "member borrowed a copy." The Spring controller only translates HTTP. Fine math in the web tier will drift from the batch job. HTTP status codes do not belong on BorrowCopy.

    Low Coupling and High Cohesion. After you assign a method, see which types had to change. sendReminder on Loan forces a mail import. The use case calls a ReminderSender instead.

    Polymorphism. When behavior varies by type, put it behind a common operation. FinePolicy is that move once a second formula is scheduled. One daily rate is a class.

    Pure Fabrication. JdbcLoanStore is not in the librarian's vocabulary. It exists so Loan does not speak SQL. If the SQL class computes the fine, it has stolen the expert's job.

    Indirection. CopyAvailability sits between lending and the catalog. An interface nobody can justify is a pass-through.

    Protected Variations. Wrap the fine formula, which the policy board changes. An ISBN is a value object with a strict constructor, not a strategy. A hypothetical variation is not a driver.

    Responsibility and the anemic temptation

    An anemic Loan with getters and a LoanService with the rules fails Information Expert. The service becomes the next god class. Put the state machine on Loan, the I/O order on BorrowCopy, the money rule on FinePolicy, and SQL on the store. The opposite failure is Loan.borrow opening a JDBC connection. Pass the data in and persist outside.

    This lesson does not introduce a pattern catalog, a generic result type, or a base Entity. The slice is borrow, return, and fine. Lesson 12 draws it as UML.

    Architecture and Design Diagrams

    The responsibility map is the design. Arrows mean "is allowed to call."

    mermaid
    classDiagram
    class BorrowCopy {
    +borrow(memberId, copyId) LoanId
    +returnCopy(loanId) Money
    }
    class Loan {
    -LoanId id
    -CopyId copyId
    -MemberId memberId
    -Instant borrowedAt
    -Instant dueAt
    -Instant returnedAt
    +isOverdue(Clock) boolean
    +markReturned(Instant) void
    +daysOverdue(Clock) int
    }
    class FinePolicy {
    <<interface>>
    +fine(Loan, Clock) Money
    }
    class DailyRupeeFine {
    -Money perDay
    -Money cap
    +fine(Loan, Clock) Money
    }
    class LoanStore {
    <<interface>>
    +save(Loan)
    +find(LoanId) Loan
    }
    class CopyAvailability {
    <<interface>>
    +reserve(CopyId) boolean
    }
    class JdbcLoanStore
    BorrowCopy --> Loan : creates
    BorrowCopy --> FinePolicy
    BorrowCopy --> LoanStore
    BorrowCopy --> CopyAvailability
    FinePolicy <|.. DailyRupeeFine
    LoanStore <|.. JdbcLoanStore
    Loan --> CopyId
    Loan --> MemberId

    Loan does not reference FinePolicy. The use case asks the loan for days overdue and the policy for money, so a rate change does not recompile the entity. The trade-off is a caller who holds a raw Loan and skips the policy. The lending package's public API is BorrowCopy.returnCopy, which always assesses the fine. A package-private Loan is the encapsulation that makes the skip hard.

    mermaid
    flowchart LR
    http[Checkout HTTP adapter]
    borrow[BorrowCopy]
    loan[Loan]
    avail[CopyAvailability]
    store[LoanStore]
    fine[FinePolicy]
    catalog[Catalog module]
    jdbc[JDBC]
    http --> borrow
    borrow --> loan
    borrow --> avail
    borrow --> store
    borrow --> fine
    avail --> catalog
    store --> jdbc

    Catalog and JDBC sit outside the domain types. The HTTP adapter sits outside the use case. A reminder sender, when it exists, is a fifth collaborator of the use case, called after a successful return, and a failure to send does not roll back the return. That failure split is the same shape as the receipt in lesson 4. The stakeholder is different: circulation staff, not checkout finance.

    A second diagram shows the state the object is allowed to occupy. It is a state machine, not a class hierarchy.

    mermaid
    stateDiagram-v2
    [*] --> Open: borrow
    Open --> Open: renew while not overdue
    Open --> Returned: markReturned
    Returned --> [*]

    Overdue is not a state you store. It is Open and clock.instant().isAfter(dueAt). Storing OVERDUE as a status forces a nightly job to mutate rows, and two nodes can disagree at midnight. Computing it keeps a single writer for real events: borrow, renew, return.

    Technical Explanation

    Identity and the loan invariant

    LoanId, CopyId, and MemberId are records. Two loans are the same loan when their ids match. Two Money values match on amount and currency. A reserve(String) method will accept a member id where a copy id was meant; distinct types close that hole. Wrap identifiers that cross a method boundary. Leave ordinary labels as String.

    Money stays non-negative, one currency, scale 2, as in lesson 4. An open loan has a null return instant and a due instant after the borrow instant. Renew moves the due instant forward and refuses when the loan is overdue, already returned, or already renewed. Those rules live on Loan because a desk batch and the HTTP path would otherwise each copy them.

    This walkthrough mutates Loan through methods and updates a row by id. Returning a new instance is equally valid. Callers still cannot assign returnedAt to a value the method would refuse. Pick one style per aggregate.

    The policy contract

    FinePolicy.fine returns a non-negative Money and performs no I/O. Days come from the loan so the policy cannot invent a count that disagrees with isOverdue. The cap is a constructor argument of DailyRupeeFine. A per-title replacement price is looked up by the use case and passed as Money; the policy does not take a catalog client. A desk return has to finish while the member is standing there, and a remote call inside fine spends that budget.

    The availability race

    Two desks can borrow the last copy. Loan can keep its own dates consistent and cannot see another JVM. CopyAvailability.reserve is a conditional update (available = false where available = true) that returns whether this caller won. BorrowCopy then saves the loan and releases the copy if the save fails. A partial unique index on copy_id where returned_at is null is the backstop. A synchronized block on a Spring singleton serializes only one process. Lesson 36 is distributed locking; this lesson only requires you to point at the atomic update.

    Spring's role

    Spring constructs BorrowCopy and DailyRupeeFine. It does not decide who is the Information Expert. A @Service annotation on Loan would be a category error: the entity is created per borrow, not once per process. Stereotype annotations belong on the use case, the policy, and the store. Loan stays a plain class so a unit test builds one without a context.

    Clock is a constructor argument. Production supplies Clock.systemUTC(). Tests supply Clock.fixed. A policy or a loan that calls Instant.now() cannot be tested at the boundary of midnight, which is where overdue bugs live.

    Security of the boundary

    BorrowCopy receives a MemberId the HTTP adapter copied from the authenticated principal. A member id taken from the request body borrows a copy in someone else's name. The domain object cannot see the session, so the adapter must not offer a second path that bypasses it. Whether the member is blocked for unpaid fines is a port, MemberStanding, consulted before Loan.borrow, not a boolean the client posts. Log the loan id and the amount. Keep government ids and donor notes out of logs and out of the reminder body.

    Practical Implementation or Design Walkthrough

    Problem

    Design the lending slice for one campus library inside TechLearningPro. A member borrows one physical copy. The copy becomes unavailable. On return, if the loan is overdue, the member is assessed a fine of a configured rupee amount per day, capped. Catalog search must not change when the fine rate changes. The desk UI and a future batch job must share the same rules.

    Requirements and constraints

    • Functional: borrow an available copy; refuse a second open loan on that copy; return a copy; compute a non-negative capped fine; renew once only if not overdue.
    • Non-functional: a return at the desk completes in the existing request, with the fine computed in process; no network call inside FinePolicy; the availability update is atomic in the database.
    • Constraint: one fine formula is live (daily rupees). A second formula is not scheduled. Do not build a rules engine.
    • Assumption: one currency, INR. One campus. No inter-branch transfer in this slice.

    Value types

    java
    package com.techlearningpro.lending;
    import java.math.BigDecimal;
    import java.math.RoundingMode;
    import java.util.Objects;
    public record MemberId(String value) {
    public MemberId {
    if (value == null || value.isBlank()) {
    throw new IllegalArgumentException("member id");
    }
    }
    }
    public record CopyId(String value) {
    public CopyId {
    if (value == null || value.isBlank()) {
    throw new IllegalArgumentException("copy id");
    }
    }
    }
    public record LoanId(String value) {
    public LoanId {
    if (value == null || value.isBlank()) {
    throw new IllegalArgumentException("loan id");
    }
    }
    }
    public record Money(BigDecimal amount, String currency) {
    public Money {
    Objects.requireNonNull(amount, "amount");
    Objects.requireNonNull(currency, "currency");
    if (currency.isBlank() || amount.signum() < 0) {
    throw new IllegalArgumentException("currency and non-negative amount");
    }
    amount = amount.setScale(2, RoundingMode.HALF_UP);
    }
    public Money plus(Money other) {
    if (!currency.equals(other.currency)) {
    throw new IllegalArgumentException("currency");
    }
    return new Money(amount.add(other.amount), currency);
    }
    public boolean greaterThan(Money other) {
    if (!currency.equals(other.currency)) {
    throw new IllegalArgumentException("currency");
    }
    return amount.compareTo(other.amount) > 0;
    }
    }

    The loan

    java
    package com.techlearningpro.lending;
    import java.time.Clock;
    import java.time.Duration;
    import java.time.Instant;
    import java.util.Objects;
    public final class Loan {
    private final LoanId id;
    private final CopyId copyId;
    private final MemberId memberId;
    private final Instant borrowedAt;
    private Instant dueAt;
    private Instant returnedAt;
    private int renewals;
    private Loan(LoanId id, CopyId copyId, MemberId memberId,
    Instant borrowedAt, Instant dueAt) {
    this.id = id;
    this.copyId = copyId;
    this.memberId = memberId;
    this.borrowedAt = borrowedAt;
    this.dueAt = dueAt;
    }
    public static Loan borrow(LoanId id, CopyId copyId, MemberId memberId,
    Instant borrowedAt, Duration term) {
    Objects.requireNonNull(id, "id");
    Objects.requireNonNull(copyId, "copyId");
    Objects.requireNonNull(memberId, "memberId");
    Objects.requireNonNull(borrowedAt, "borrowedAt");
    Objects.requireNonNull(term, "term");
    if (term.isNegative() || term.isZero()) {
    throw new IllegalArgumentException("term");
    }
    return new Loan(id, copyId, memberId, borrowedAt, borrowedAt.plus(term));
    }
    public boolean isOverdue(Clock clock) {
    return returnedAt == null && clock.instant().isAfter(dueAt);
    }
    public int daysOverdue(Clock clock) {
    if (!isOverdue(clock)) {
    return 0;
    }
    long days = Duration.between(dueAt, clock.instant()).toDays();
    return (int) Math.max(days, 1);
    }
    public void renew(Duration extension, Clock clock) {
    Objects.requireNonNull(extension, "extension");
    if (returnedAt != null) {
    throw new IllegalStateException("returned");
    }
    if (isOverdue(clock) || renewals >= 1) {
    throw new IllegalStateException("renewal refused");
    }
    if (extension.isNegative() || extension.isZero()) {
    throw new IllegalArgumentException("extension");
    }
    dueAt = dueAt.plus(extension);
    renewals++;
    }
    public void markReturned(Instant at) {
    Objects.requireNonNull(at, "at");
    if (returnedAt != null) {
    throw new IllegalStateException("already returned");
    }
    if (at.isBefore(borrowedAt)) {
    throw new IllegalArgumentException("return before borrow");
    }
    returnedAt = at;
    }
    public LoanId id() { return id; }
    public CopyId copyId() { return copyId; }
    public MemberId memberId() { return memberId; }
    public Instant dueAt() { return dueAt; }
    public boolean isOpen() { return returnedAt == null; }
    }

    daysOverdue charges at least one day once the due instant has passed, then whole days from the duration. Partial days are a product decision; the method is the one place to change it. A controller that computes (now - due) / 86400000 will disagree with the desk batch. That disagreement is the bug encapsulation exists to prevent.

    Policy, ports, and the use case

    java
    package com.techlearningpro.lending;
    import java.time.Clock;
    public interface FinePolicy {
    /** Non-negative, same currency as the policy, no I/O. Zero when the loan is not overdue. */
    Money fine(Loan loan, Clock clock);
    }
    public final class DailyRupeeFine implements FinePolicy {
    private final Money perDay;
    private final Money cap;
    public DailyRupeeFine(Money perDay, Money cap) {
    if (perDay.amount().signum() == 0) {
    throw new IllegalArgumentException("perDay");
    }
    if (!perDay.currency().equals(cap.currency()) || perDay.greaterThan(cap)) {
    throw new IllegalArgumentException("cap");
    }
    this.perDay = perDay;
    this.cap = cap;
    }
    @Override
    public Money fine(Loan loan, Clock clock) {
    int days = loan.daysOverdue(clock);
    Money raw = new Money(
    perDay.amount().multiply(java.math.BigDecimal.valueOf(days)),
    perDay.currency());
    return raw.greaterThan(cap) ? cap : raw;
    }
    }
    public interface LoanStore {
    void save(Loan loan);
    Loan find(LoanId id);
    }
    public interface CopyAvailability {
    /** Atomically reserve a copy. Returns false when another desk won. */
    boolean reserve(CopyId copyId);
    void release(CopyId copyId);
    }
    java
    package com.techlearningpro.lending;
    import java.time.Clock;
    import java.time.Duration;
    import java.util.Objects;
    import java.util.UUID;
    public final class BorrowCopy {
    private final LoanStore loans;
    private final CopyAvailability copies;
    private final FinePolicy fines;
    private final Clock clock;
    private final Duration term;
    public BorrowCopy(LoanStore loans, CopyAvailability copies, FinePolicy fines,
    Clock clock, Duration term) {
    this.loans = Objects.requireNonNull(loans);
    this.copies = Objects.requireNonNull(copies);
    this.fines = Objects.requireNonNull(fines);
    this.clock = Objects.requireNonNull(clock);
    this.term = Objects.requireNonNull(term);
    }
    public LoanId borrow(MemberId memberId, CopyId copyId) {
    if (!copies.reserve(copyId)) {
    throw new IllegalStateException("copy unavailable");
    }
    Loan loan = Loan.borrow(
    new LoanId(UUID.randomUUID().toString()),
    copyId, memberId, clock.instant(), term);
    try {
    loans.save(loan);
    return loan.id();
    } catch (RuntimeException ex) {
    copies.release(copyId);
    throw ex;
    }
    }
    public Money returnCopy(LoanId id) {
    Loan loan = loans.find(id);
    Money fine = fines.fine(loan, clock);
    loan.markReturned(clock.instant());
    loans.save(loan);
    copies.release(loan.copyId());
    return fine;
    }
    }

    The compensation on save failure is the design of the race, expressed in one method. A reviewer who moves reserve into Loan.borrow forces the entity to call a port, which couples the state machine to I/O. A reviewer who drops the release on failure leaks a copy that is reserved and has no loan. Both comments are about responsibility, not style.

    returnCopy computes the fine before markReturned, because daysOverdue is zero after return. That order is domain-significant. If you later need the fine after the fact, store the assessed Money on the loan at return time, as a fact, rather than recomputing from a clock that has moved. The walkthrough returns the amount to the caller and leaves persistence of the assessment as the next increment. Say so in review. Silent recomputation tomorrow will under-fine or over-fine.

    Renew stays on Loan and is invoked from a RenewLoan use case of the same shape. It is omitted here so this file stays the borrow-and-return story. The rule "one renewal, not when overdue" is already on the entity, which is where a second use case must find it.

    Wiring

    java
    package com.techlearningpro.lending.config;
    import com.techlearningpro.lending.BorrowCopy;
    import com.techlearningpro.lending.CopyAvailability;
    import com.techlearningpro.lending.DailyRupeeFine;
    import com.techlearningpro.lending.FinePolicy;
    import com.techlearningpro.lending.LoanStore;
    import com.techlearningpro.lending.Money;
    import java.math.BigDecimal;
    import java.time.Clock;
    import java.time.Duration;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    @Configuration
    class LendingBeans {
    @Bean
    Clock clock() {
    return Clock.systemUTC();
    }
    @Bean
    FinePolicy finePolicy() {
    return new DailyRupeeFine(
    new Money(new BigDecimal("10.00"), "INR"),
    new Money(new BigDecimal("500.00"), "INR"));
    }
    @Bean
    BorrowCopy borrowCopy(LoanStore loans, CopyAvailability copies,
    FinePolicy fines, Clock clock) {
    return new BorrowCopy(loans, copies, fines, clock, Duration.ofDays(14));
    }
    }

    LoanStore and CopyAvailability implementations live in a jdbc package. The reserve statement is update copy set available = false where id = :id and available = true, and the update count is the boolean. The domain interface does not mention SQL.

    Renew stays on Loan and is called from a sibling use case. The rule already has one home, which is the point of the entity. returnCopy assesses the fine before markReturned, because daysOverdue is zero after return. Store the assessed Money on the loan if you must show it later. Recomputing tomorrow from a moved clock will drift.

    java
    import static org.junit.jupiter.api.Assertions.assertEquals;
    import static org.junit.jupiter.api.Assertions.assertThrows;
    import java.math.BigDecimal;
    import java.time.Clock;
    import java.time.Duration;
    import java.time.Instant;
    import java.time.ZoneOffset;
    import org.junit.jupiter.api.Test;
    class LoanFineTest {
    @Test
    void oneMinutePastDueIsOneDayAndCannotBeSetFromOutside() {
    Instant start = Instant.parse("2026-11-01T09:00:00Z");
    Loan loan = Loan.borrow(
    new LoanId("L1"), new CopyId("C1"), new MemberId("M1"),
    start, Duration.ofDays(14));
    Clock later = Clock.fixed(start.plus(Duration.ofDays(14)).plusSeconds(60), ZoneOffset.UTC);
    FinePolicy policy = new DailyRupeeFine(
    new Money(new BigDecimal("10.00"), "INR"),
    new Money(new BigDecimal("500.00"), "INR"));
    assertEquals(new BigDecimal("10.00"), policy.fine(loan, later).amount());
    assertThrows(IllegalStateException.class, () -> loan.renew(Duration.ofDays(7), later));
    }
    }

    There is no setDueAt to call. The test proves the contract by behavior. A future subclass that widens renew to succeed when overdue fails a test written against Loan, which is the inheritance bar from lesson 4.

    Enterprise Real-World Scenarios

    The catalog search that billed a fine

    TechLearningPro's campus app shipped catalog search first. Lending was "just another method" on LibraryService, beside searchByTitle. The fine rate was a constant next to the page size. When the institute raised the daily fine before exams, the change was a one-line edit in a class that also built the search SQL. The release train for search relevance, which was mid-experiment, absorbed the fine change. Search shipped late. The fine shipped with a bug: the cap was applied per page of results because the same loop variable was reused. Members with long histories were capped wrong. Support credited accounts by hand.

    The redesign put dates and renewal on Loan, rate and cap on DailyRupeeFine, and reserve-then-save on BorrowCopy. Catalog search stopped sharing a type with fines. The next rate change was a constructor argument and a unit test, and a reindex stopped timing out the desk because return no longer loaded the search index. The squad refused OverdueLoan extends Loan and refused a catalog client inside the policy. When replacement price later became the cap, the use case looked up a Money through a port and passed it in. I/O stayed out of fine.

    Two desks, one copy, a getter for a lock

    A second campus opened with two service desks on one database. The first design checked availability in Java: load the copy, if the flag is true, save a loan, set the flag false. On the Saturday before exams the same copy was loaned twice. Both loans were valid objects. The invariant was not in Loan, because each loan was locally consistent. The invariant was across loans, and it lived in a check-then-act that was not atomic.

    The repair was CopyAvailability.reserve as a conditional update, plus a partial unique index, plus release if the save fails. The object model did not grow a lock field. In an interview, say which invariants one object can enforce and which need the store. A synchronized method on a Spring singleton covers one JVM. The copy is one row, shared by every pod. The same month, sendReminder() landed on Loan with a JavaMailSender, and a mail outage blocked returns inside the transaction. The send moved to a collaborator after commit. The responsibility was never the loan's.

    Best Practices

    • Name the rule before you name the class. If you cannot say what illegal state the type refuses, it is a data bag or a god class in progress.
    • Assign behavior with Information Expert. Pass Clock in. Do not call Instant.now() inside an entity.
    • Keep use-case orchestration in a controller object (BorrowCopy), HTTP mapping in the adapter, and SQL in a store fabrication.
    • Prefer composition. Use a sealed status for a closed set of states an instance can occupy. Do not subclass an entity because a query (overdue) became true.
    • Pass identifiers across boundaries. Pass graphs only when the callee is the expert for that graph.
    • Make package boundaries match stakeholder boundaries. Catalog does not import lending's concrete store.
    • Write the cross-object invariant ("one open loan per copy") next to the atomic store operation that enforces it.
    • Compensate in the use case when a later step fails after an earlier external effect (reserve, then save).
    • Keep money math on Money and rate math on the policy. Log identifiers and amounts, not government ids.
    • Record the variation you did not abstract, and the condition that reopens it.
    • When a reviewer asks for a factory, a manager, or a utility, ask which GRASP question it answers. Delete it if the answer is "every noun needs one."

    Common Mistakes and Anti-Patterns

    The god LibraryService. Search, borrow, fine, and email in one type, grown by "where else would it go?" Releases couple unrelated stakeholders, and a loop variable leaks from one feature into another. Split by rule and by actor. Leave a use case that is visibly a sequence.

    Anemic entity, obese service. Loan is a struct; LoanManager owns every if. The second client copies a rule badly. Move the state machine back onto Loan. Keep I/O on the use case and the store.

    Inheritance as reuse. OverdueLoan, SpecialMemberLoan, and ExamPeriodLoan extend Loan and override pieces. An instance cannot change class when the clock crosses dueAt, and the subtypes disagree on renew. Use a status or a policy collaborator.

    Setter encapsulation. Fields are private and every field has a public setter. Callers build illegal loans one setter at a time. The compiler is satisfied and the invariant is gone. Construct through one method that checks the rule.

    Stringly typed identities. reserve(String copyId) called with a member id. The bug is a review artifact, not a runtime surprise, if CopyId and MemberId are distinct types.

    Check-then-act as a domain method. if (copy.isAvailable()) loan.save() in the controller, with availability as a plain boolean loaded earlier. Two desks pass the check. Put the atomic transition behind CopyAvailability.reserve and back it with a database constraint.

    Control coupling with flags. process(request, boolean sendEmail, boolean chargeFine). Each flag is a responsibility that belongs to its own caller or type. The boolean combination is an untested matrix.

    Static mutable rates. FineRates.PER_DAY updated by an admin endpoint and read by every request. Tests flake, and two nodes diverge. The rate is constructor state of DailyRupeeFine, loaded from configuration, changed by redeploy or by a refreshed bean you designed on purpose.

    Troubleshooting and Design Review

    Symptom: A fine-rate change and a catalog-ranking change are in the same pull request.

    Likely cause: one type still contains both, or the policy calls the catalog.

    Investigation: search the fine constant and the search SQL. If they share a file, the boundary is missing. If they share a package and the policy imports the catalog entity, the dependency is backward.

    Corrective action: move the rate to DailyRupeeFine. Keep catalog types on the far side of CopyAvailability or a price port.

    Symptom: Two open loans exist for one copy after a busy hour.

    Likely cause: availability was checked in memory or in a non-conditional read.

    Investigation: read BorrowCopy.borrow and the SQL. Look for a select followed by an insert with no unique index.

    Corrective action: conditional update or insert that can fail, unique partial index, and release on a failed save. Add a test that two overlapping reserves return one success. A unit test with a fake that is not atomic does not catch this; state that limit.

    Symptom: Return receipts show a zero fine for a loan that is three days late, but only in the afternoon batch.

    Likely cause: markReturned ran before fine, or the batch calls Instant.now() while the test clock is not used on that path, or the batch recomputes after return.

    Investigation: order of calls in the batch use case versus BorrowCopy.returnCopy. Check for Instant.now() in the lending package.

    Corrective action: one path computes the fine from the open loan and stores the assessed amount. Inject Clock on the batch as well.

    Symptom: A new "exam week double fine" edit lands in BorrowCopy as an if (examWeek).

    Likely cause: the variation was real and was implemented as control coupling.

    Investigation: who schedules exam week, and whether the desk rate and the exam rate must ship separately.

    Corrective action: a second FinePolicy, selected at the composition root by configuration, still with no I/O. The use case stays on the interface. If exam week is a date range the policy can see via Clock, the existing policy can take the range in its constructor. Do not make BorrowCopy the expert on the calendar.

    Symptom: Domain tests boot Spring and a database.

    Likely cause: Loan or FinePolicy constructs a store, or the test is annotated @SpringBootTest out of habit.

    Investigation: constructors. A loan test needs Clock.fixed and ids.

    Corrective action: plain JUnit for the entity and the policy. Spring tests for wiring only.

    Review questions you can ask on any LLD sketch: What illegal state can I still construct? Which type is the expert for this rule? What coupling does this field add, and which stakeholder pays it? Which invariants need the database because two processes can run the method? What did we refuse to abstract, and when do we reopen?

    Interview Questions and Answers

    Q1. How do you decide which class gets a method during an LLD interview?

    I use Information Expert. Overdue needs the due instant, so Loan.isOverdue(Clock) lives on Loan, and the clock is an argument so a test can freeze midnight. A method that needs a mail client or a SQL session belongs on the use case. I say that split out loud so the interviewer hears both the rich entity and the refusal to put JDBC inside it.

    Q2. What is the difference between cohesion and a small class?

    Cohesion is one reason to change. Borrow, renew, return, and overdue on Loan are one lifecycle. Splitting them into LoanValidator, LoanHelper, and LoanManager smears that reason across files that only make sense together. A large LibraryService fails the other way: search and fines are different reasons. I judge the reason, then the size.

    Q3. A candidate models OverdueLoan extends Loan. What do you say?

    I ask whether the object changes Java type when time passes. It does not. Overdue is a predicate. A subclass copies fields, breaks renew under substitution, and forces the store to pick a class per row. I keep isOverdue on Loan. Stored facts that are a closed set become a sealed status. I do not persist overdue as an enum a cron must flip.

    Q4. When would you choose inheritance in this design at all?

    When I have a subtype contract, preferably a sealed hierarchy the compiler checks. Fine variants share a contract and not a representation, so they implement FinePolicy. I inherit when a framework forces a superclass. Spring domain code rarely needs that. Helpers are composition or a package-private function.

    Q5. What coupling is acceptable between lending and the catalog?

    A port lending owns: CopyAvailability.reserve(CopyId) or a lookup that returns a replacement Money. Lending does not import catalog entities or the search repository, or a relevance change breaks the desk. Passing CatalogEntry into Loan is stamp coupling.

    Q6. How do you enforce "one open loan per copy" if the Loan object is correct?

    The object enforces its dates and a single return. It cannot see another pod. The cross-row rule is a conditional update in CopyAvailability, a unique partial index, and a release if save fails. I walk that sequence. synchronized on a Spring service covers one JVM.

    Q7. What is a pure fabrication, and when is it a smell?

    JdbcLoanStore is a fabrication. Librarians do not say "JDBC." The type exists so the domain stays free of SQL. It becomes a smell when the SQL class computes fines or renewal eligibility. Those rules return to Loan or FinePolicy. The store inserts, updates, and finds.

    Q8. Why are public setters a failed encapsulation even when the fields are private?

    The boundary is the set of states a caller can reach. Setters allow a return before borrow and a skipped cap. I expose borrow, renew, and markReturned, and I keep the constructor private. A test that needs to poke a field is telling me a behavior is missing.

    Q9. How does this relate to SOLID without turning the answer into a checklist?

    Expert and cohesion find the single reason to change. FinePolicy is the variation point, extracted when a second formula is real. The contract (non-negative, no I/O) is the substitution rule. LoanStore and CopyAvailability are split because their failure modes differ. BorrowCopy depends on those interfaces; JDBC implements them from the outside. I name DIP if asked. I start from the responsibilities.

    Q10. Where does the Spring controller sit in GRASP terms?

    It is an adapter. The GRASP controller is BorrowCopy, the first domain object on "member borrowed a copy." The @RestController reads MemberId from the principal, calls the use case, and maps an unavailable copy to 409. If it computes days * rate, the batch job will drift.

    Q11. What do you deliberately leave concrete?

    The daily rupee formula, while it is the only one. The use case still sees FinePolicy so a test can stub a fixed Money. The reopen condition is the day the policy board schedules a second formula that must ship without editing BorrowCopy. The loan's date rules stay on the entity either way. Those rules are the type.

    Hands-On Exercises

    Exercise: Split a library god class

    Problem statement. You are given LibraryService with search, borrow, returnCopy, dailyFine, and sendOverdueEmail. Design the lending slice so a fine-rate change does not touch search, and two desks cannot create two open loans by racing a boolean flag. Keep one fine formula.

    Tasks.

    1. List the rules and the stakeholder for each method on the god class.
    2. Draw a responsibility diagram: use case, Loan, FinePolicy, ports. Mark which invariants are in memory and which need an atomic update.
    3. Implement Loan.borrow, renew, markReturned, and daysOverdue(Clock) so illegal transitions throw and there is no public setter.
    4. Implement DailyRupeeFine with a cap, and a test at one minute past due and at the cap.
    5. Write BorrowCopy.borrow with reserve, save, and release on failure.
    6. Show a Spring @Configuration that does not put @Service on Loan.
    7. State how the HTTP adapter obtains MemberId, and what it must not trust from the body.
    8. Write the decision that defers a second fine formula, including the reopen condition.

    Expected deliverables. Java types for the slice, one race narrative, one contract test, the configuration sketch, and the deferred-abstraction note.

    Evaluation rubric.

    Assessment areaWeightWhat "good" looks like
    Responsibility assignment25%Expert, creator, and controller are identifiable and match the code
    Encapsulation20%Illegal loans are not constructable; overdue is a query
    Coupling15%Catalog search is not on the lending change path
    Cross-object invariant20%Reserve is atomic and save failure releases
    Judgment10%No subclass-per-status and no rules engine
    Trust boundary10%Member identity comes from the session

    A solution that introduces AbstractEntity, a generic repository base, and an email method on Loan misses the exercise even if it compiles.

    Lesson Summary

    Object design for low-level work is responsibility assignment under explicit coupling. Encapsulation is the set of states you allow, cohesion is one stakeholder reason per type, and composition is how collaborators meet. Inheritance is a substitutable subtype, which overdue-as-a-subclass is not. GRASP's expert, creator, and controller questions place methods on Loan, creation on the use case together with Loan.borrow, and HTTP at the edge. Invariants that span processes move to an atomic store operation you can point to. The lending slice keeps catalog search and fine rates on different trains, and it refuses a pattern catalog until a variation is real. Lesson 12 takes this same model and draws it in UML so the diagram and the Java agree.

    Further Reading


    Ready to mark this lesson complete?Track your journey across the entire course.