Guide contents

Lesson 4 of 31

Class Diagrams and Relationships in Java

A class diagram shows the types in a design and how they relate. In an interview it is a quick way to agree on the shape before writing code. It should be small: five to eight boxes, with only the fields and methods that matter. This article covers the notation, then shows how each relationship becomes Java.

Notation

A box has three parts: the type name, its fields, and its methods. A + marks public, a - marks private. A line such as <<interface>> above the name marks the kind of type.

classDiagram
    class ParkingLot {
        -levels: List~Level~
        +park(vehicle) Ticket
        +leave(ticket) Money
    }
    class Level {
        -spots: List~Spot~
    }
    class Spot {
        -id: SpotId
        -size: SpotSize
    }
    class PricingStrategy {
        <<interface>>
        +fee(minutes) Money
    }
    class HourlyPricing
    ParkingLot *-- Level : owns
    Level *-- Spot : owns
    ParkingLot --> PricingStrategy : uses
    PricingStrategy <|.. HourlyPricing : implements

The arrows carry the meaning. There are six that matter.

Relationship Mermaid Meaning Lifetimes
Composition A *-- B A owns B; B does not exist without A tied together
Aggregation A o-- B A holds B, but B exists independently independent
Association A --> B A refers to B independent
Dependency A ..> B A uses B only inside a method for one call
Realization `T < .. A` A implements interface T
Inheritance `P < -- C` C extends class P

Composition: a field that owns

The part is created by the whole, stored in a private field, and never handed out for others to keep. When the whole is no longer referenced, its parts become unreachable too, and the garbage collector reclaims them. Java has no ownership rule in the language, so composition is a discipline: the whole creates the parts, and it exposes them only as immutable values or unmodifiable views.

Java
import java.util.List;

public class Main {
    record Spot(int id) {}

    record Level(List<Spot> spots) {
        Level {
            spots = List.copyOf(spots);
        }
    }

    record ParkingLot(List<Level> levels) {
        ParkingLot {
            levels = List.copyOf(levels);
        }
    }

    public static void main(String[] args) {
        var lot = new ParkingLot(List.of(new Level(List.of(new Spot(1), new Spot(2)))));
        System.out.println(lot);
        // once `lot` is unreachable, its levels and spots are unreachable too
    }
}
Output
ParkingLot[levels=[Level[spots=[Spot[id=1], Spot[id=2]]]]]

List.copyOf in each compact constructor stores an unmodifiable copy, so the caller cannot keep a handle that changes the whole's contents later. This is the default and preferred relationship.

Aggregation: shared or independent parts

The whole uses a part that also lives elsewhere. In Java a field holds a reference, and several objects can hold the same reference. The choice is a design decision.

Situation Form
The part is created elsewhere and handed in a constructor parameter stored in a final field
The part is shared by several objects and is mutable the same reference, plus a note on who may change it, and a lock if threads share it
The part is shared and immutable the same reference, with no further care
The part is looked up in a shared store an ID field plus a map

The example below shows a shared, mutable part: two printers write to one Counter. Because both hold the same object, a change through one is visible through the other.

Java
public class Main {
    static final class Counter {
        private int count;

        void increment() {
            count++;
        }

        int count() {
            return count;
        }
    }

    static final class Printer {
        private final Counter pages;

        Printer(Counter pages) {
            this.pages = pages;
        }

        void print() {
            pages.increment();
        }
    }

    public static void main(String[] args) {
        var pages = new Counter();
        var printerA = new Printer(pages);
        var printerB = new Printer(pages);
        printerA.print();
        printerB.print();
        printerB.print();
        System.out.println(pages.count());
    }
}
Output
3

The diagram shows this as Printer o-- Counter. The last row of the table, the ID, is the most useful in designs, so it gets its own section.

Association by ID

Two independent objects that know about each other can store an ID instead of a reference, and resolve it through a collection that owns the objects.

Java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

public class Main {
    record AuthorId(int value) {}

    record Author(String name) {}

    record Book(String title, AuthorId author) {}  // association: refers to an author by ID

    static final class Catalog {
        private final Map<AuthorId, Author> authors = new HashMap<>();
        private final List<Book> books = new ArrayList<>();

        void addAuthor(AuthorId id, Author author) {
            authors.put(id, author);
        }

        void addBook(Book book) {
            books.add(book);
        }

        List<Book> books() {
            return List.copyOf(books);
        }

        Optional<Author> authorOf(Book book) {
            return Optional.ofNullable(authors.get(book.author()));
        }
    }

    public static void main(String[] args) {
        var catalog = new Catalog();
        catalog.addAuthor(new AuthorId(1), new Author("Ursula Le Guin"));
        catalog.addBook(new Book("The Dispossessed", new AuthorId(1)));
        catalog.addBook(new Book("Unknown", new AuthorId(9)));

        for (Book book : catalog.books()) {
            String line = catalog.authorOf(book)
                    .map(author -> book.title() + " by " + author.name())
                    .orElse(book.title() + " has no known author");
            System.out.println(line);
        }
    }
}
Output
The Dispossessed by Ursula Le Guin
Unknown has no known author

The ID design has three advantages over a stored reference. It cannot form a cycle. A Book can be created, copied and compared without loading an Author. And lookup returns an Optional, so a dangling association is a value the caller handles, not a NullPointerException. The cost is one hash lookup per navigation. See object graphs and references for the alternatives when a cycle is unavoidable.

Dependency: a parameter

A type that uses another only inside a method takes it as a parameter and does not store it. This is the loosest coupling, and the diagram shows it with a dashed arrow.

Java
public class Main {
    record Money(long cents) {}

    record Receipt(String text) {}

    // Ticket does not know which printer it gets; the printer is passed in.
    interface Printer {
        Receipt print(String text);
    }

    record PlainPrinter() implements Printer {
        public Receipt print(String text) {
            return new Receipt(text);
        }
    }

    record CheckoutResult(Money fee, Receipt receipt) {}

    record Ticket(int minutes) {
        CheckoutResult checkout(Printer printer) {
            var fee = new Money(minutes * 5L);
            var receipt = printer.print(minutes + " minutes: " + fee.cents() + " cents");
            return new CheckoutResult(fee, receipt);
        }
    }

    public static void main(String[] args) {
        var ticket = new Ticket(90);
        var result = ticket.checkout(new PlainPrinter());
        System.out.println(result.fee().cents() + " | " + result.receipt().text());
    }
}
Output
450 | 90 minutes: 450 cents

Realization: implements

The dotted arrow with a hollow head is class X implements Interface. In a diagram, mark the interface with <<interface>> and draw one arrow per implementer. This is where Strategy, Observer and similar patterns appear on a class diagram. Because Printer has one method, it can also be implemented with a lambda, and the diagram still shows one realization arrow for the idea.

Java
public class Main {
    @FunctionalInterface
    interface PricingStrategy {
        long fee(int minutes);
    }

    record HourlyPricing(long centsPerHour) implements PricingStrategy {
        public long fee(int minutes) {
            long hours = (minutes + 59) / 60;
            return hours * centsPerHour;
        }
    }

    public static void main(String[] args) {
        PricingStrategy hourly = new HourlyPricing(300);
        PricingStrategy flat = minutes -> 500;
        System.out.println(hourly.fee(61) + " " + flat.fee(61));
    }
}
Output
600 500

Inheritance: a class that extends a class

Java has class inheritance, and the diagram shows it as Parent <|-- Child. It shares fields and methods through the parent. It also ties the child to the parent's implementation: a change in the parent reaches every child (the fragile base class problem). Most design problems do not need it. When a textbook diagram shows Vehicle <|-- Car and Vehicle <|-- Truck, redraw it as one of:

  • an enum or sealed interface with records, when the set of kinds is closed
  • an interface with two implementing classes, when the set is open
  • a class with a kind field, when the kinds differ only in a value

Inheritance from a class remains reasonable when the parent is designed for it (an abstract class with a template method and clear rules for subclasses).

classDiagram
    class VehicleKind {
        <<enumeration>>
        MOTORCYCLE
        CAR
        TRUCK
    }
    class Vehicle {
        -plate: String
        -kind: VehicleKind
    }
    Vehicle --> VehicleKind

Drawing a good diagram in an interview

  1. Draw after the requirements, before the code. It should take about three minutes.
  2. Show only what the discussion needs. Skip getters, constructors and trivial types.
  3. Mark ownership. Filled diamonds (*--) for owned parts and plain arrows (-->) for references and ID associations show which objects live and die together.
  4. Show the varying parts as interfaces or enums. These are the extension points the interviewer will probe.
  5. Keep the diagram and the code consistent. If the code adds a type, update the diagram.

A sequence diagram is the better tool for showing one scenario, such as parking a car, through several objects in order.

sequenceDiagram
    participant C as Caller
    participant L as ParkingLot
    participant S as SpotFinder
    participant T as TicketBook
    C->>L: park(vehicle)
    L->>S: findSpot(vehicle.kind)
    S-->>L: SpotId
    L->>T: issue(vehicle, spot)
    T-->>L: Ticket
    L-->>C: Ticket

Interview points

  • Composition ties lifetimes together and is the default. Aggregation and association keep lifetimes independent.
  • Java does not enforce ownership, so composition is kept by creating parts inside the whole and exposing them as immutable values.
  • An ID association has no cycles and no loading cost. Navigating it costs a lookup that can return empty.
  • Dependency is a parameter or local variable, not a field.
  • Realization is implements. Redraw most inheritance hierarchies as an enum, a sealed interface or an interface with implementers.

Summary

  • A class diagram lists types, key fields, key methods and six kinds of relationship.
  • Composition is a private field that the whole creates and owns by discipline.
  • Association is an ID looked up in a map. It avoids cycles and returns an Optional for a missing target.
  • Dependency is a parameter. Realization is implements. Inheritance is extends and is used sparingly.
  • Keep the diagram small and consistent with the code, and use a sequence diagram for one scenario.