Part 0
A note before you start.
By the end of this note you will be able to design a class hierarchy for a real problem, recognise common design patterns when you see them, and read code in two object-oriented languages (Java and C++) at the same level of comfort. The path runs from data definitions, through inheritance and composition, into design patterns, and finishes with a small MVC application.
How to use this note
This note is built around a single idea: good design starts with the shape of the data. Every other decision (which class to write, what should inherit from what, when to use a pattern) follows from understanding the data your program is operating on. The first two parts spend a lot of time on this because everything afterward depends on it.
Java is the primary language because it forces decisions to be explicit. Every variable has a declared type, every method belongs to a class, and the compiler shouts at you when you get something wrong. That makes it a good language to learn in. C++ comes later in the note, after you have the design vocabulary, because C++ adds memory management on top of object-oriented design and that is too much to learn at once.
Each module follows the same shape. It names a problem, draws or shows a small example, walks through the solution, and ends with a prompt you should be able to answer. Some modules show the same idea in both Java and C++ side by side. The differences are usually small (different keywords) and the similarities are the point.
A note on the textbook this draws from
The data-definitions approach used in the early parts comes from the "How to Design Classes" curriculum (Felleisen, Krishnamurthi, et al). The patterns chapter draws from the canonical "Design Patterns" book (Gamma, Helm, Johnson, Vlissides). Both are cited at the end of the page. You do not need either to follow this note.
Why does this note teach you to think about data first, before talking about classes or inheritance?
Part I. Data, classes, and Java
What a class is, and how to write one.
A class is two things rolled together: a description of some data, and a set of operations on that data. Most of object-oriented design is figuring out which of those two things you have in front of you, and writing the class that fits.
1. What "object-oriented" actually means
An object is a bundle of data plus the code that knows how to operate on that data. A class is a template for making such bundles. When you say new Circle(5), you are asking the runtime to make a new Circle object, give it a radius of 5, and hand you back a reference to it. From now on, you can ask that object questions (call its methods) and the object will use its own internal data to answer.
That sounds simple, and it is. The complexity in OOD comes from a different question: given a real-world problem, what classes should you write, what should each one contain, and how should they relate to each other? Object-oriented design is mostly the discipline of answering that question well.
Two ideas show up over and over.
- Encapsulation. An object hides its internal data and exposes only methods. Outside code can ask the object questions but cannot reach inside and change a field directly. This way, the object enforces its own consistency. If a Bank Account's balance can only change through a
depositorwithdrawmethod, the balance is never accidentally negative. - Polymorphism. Different objects can respond to the same method call in different ways. A Circle and a Rectangle both understand
area()but compute it differently. Code that uses thearea()method does not need to know which kind of shape it is talking to.
That is the whole sales pitch. Everything else (classes, interfaces, inheritance, patterns) is mechanism in service of those two ideas.
Why is it useful for a Bank Account class to keep its balance private and only allow access through methods like deposit and withdraw?
2. Data definitions: classifying the shape of data
Before you write any class, write a data definition: a short description of what kind of data you are dealing with. Data definitions classify into a few shapes, and once you know the shape, the class structure follows.
Atomic data
A single value with no internal parts. A temperature is an integer. A name is a string. The class for atomic data is usually unnecessary because the language already has it (use int, use String). You only wrap it in a class when there is some constraint or behaviour you want to attach. For example, "a temperature, but only above absolute zero."
Composite data: a "product"
A bundle of fixed parts. A point has an x and a y. A book has a title, an author, and a year. This is the most common shape, and it maps to a single class with one field per part.
class Book {
String title;
String author;
int year;
}
Either-or data: a "sum"
A value is one of several alternatives. A traffic light is either Red, Yellow, or Green. A shape is either a Circle, a Rectangle, or a Triangle. A tree node is either a Leaf or an Internal node. This is where object-oriented design starts to get interesting, because Java represents this with an interface plus a class per alternative. We will write that out in Part II.
The trick is recognising the shape
If you read a problem statement and find the word "and" between requirements ("a book has a title and an author and a year"), that is product data. If you find the word "or" ("a shape is a circle or a rectangle"), that is sum data. Most real problems mix both: products of sums of products. The skill you are building is taking apart a description and seeing the structure underneath.
"A movie has a title, a director, and a runtime in minutes." Is this product data, sum data, or a mix? What about "a payment is either cash, credit, or check"?
3. A first Java class
Java's syntax for a class is more verbose than Python's, on purpose. The verbosity exists because the compiler reads every type, every visibility modifier, and every method signature, and uses that information to catch mistakes before the program runs.
// Book.java
public class Book {
private final String title;
private final String author;
private final int year;
public Book(String title, String author, int year) {
this.title = title;
this.author = author;
this.year = year;
}
public String getTitle() { return title; }
public String getAuthor() { return author; }
public int getYear() { return year; }
}
The pieces, from top to bottom:
public class Book. The class is named Book and is visible to other classes. The file must be namedBook.java. One public class per file is the rule.private final String title. A field.privatemeans only methods inside this class can read or write it.finalmeans it is set once (in the constructor) and never changes after.- The constructor. A method with the same name as the class. It runs once, when you say
new Book(...), and is responsible for filling in the fields. - Getters. Methods that return field values. Outside code reads through these instead of touching the fields directly. This is encapsulation in practice.
Constructing and using
Book b = new Book("The Pragmatic Programmer", "Hunt & Thomas", 1999);
String t = b.getTitle();
int y = b.getYear();
The variable b holds a reference to the Book object. The object itself lives somewhere on the heap. b.getTitle() sends the message "tell me your title" to that object, which runs the corresponding method and returns the value.
Why is each field declared private final instead of just String title? What goes wrong if you remove private? What goes wrong if you remove final?
4. Methods on simple classes
A method is a function that lives inside a class and has access to that class's fields. Methods are how you give a class its behaviour.
public class Circle {
private final double radius;
public Circle(double radius) { this.radius = radius; }
public double area() {
return Math.PI * radius * radius;
}
public double circumference() {
return 2 * Math.PI * radius;
}
public boolean isLargerThan(Circle other) {
return this.area() > other.area();
}
}
Three things to notice. First, methods can use fields directly: inside area, radius refers to this Circle's radius. Second, methods can call other methods on the same object via this (or implicitly, by just calling them). Third, methods can take other objects of the same class as arguments. isLargerThan compares this Circle to another Circle.
Static methods
Sometimes a method does not need any object's data. It is a "utility" function that just happens to live on a class. Mark it static and it lives on the class itself, not on any instance.
public class CircleUtils {
public static double areaOf(double radius) {
return Math.PI * radius * radius;
}
}
// usage:
double a = CircleUtils.areaOf(5.0); // no `new`, no instance
The general rule: if a method genuinely uses the object's fields, make it an instance method. If it does not, consider whether the class is even the right place for it.
toString, equals, hashCode
Three methods every class implicitly inherits from Object. The defaults are usually wrong, and you should override them.
toString(). Returns a string representation. Used forSystem.out.printlnand debugging.equals(Object o). Returns true if this object is "equal to" o. The default checks if they are the literally the same object (same reference). For Book, you probably want "same title, author, and year."hashCode(). Returns an int. Two equal objects must return the same hashCode. Used by HashSet and HashMap.
If you override equals on a class but forget to override hashCode, what specifically goes wrong when you put instances of that class in a HashSet?
5. Tests, expectations, and exceptions
A class is not finished when it compiles. It is finished when there are tests that pin down what it should do.
JUnit, the standard
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class CircleTest {
@Test
void areaOfUnitCircle() {
Circle c = new Circle(1.0);
assertEquals(Math.PI, c.area(), 1e-9);
}
@Test
void isLargerThanWorks() {
assertTrue(new Circle(5).isLargerThan(new Circle(3)));
}
}
Each @Test method is one independent check. JUnit runs all of them and reports which ones passed and which ones failed. assertEquals(expected, actual, delta) is the workhorse: it fails the test if the actual value differs from the expected by more than delta (the third argument is for floating-point comparisons, where exact equality is unreliable).
The discipline is: every public method on every class should have at least one test, and tests should cover both the normal case and the edge cases. A test is the only documentation that cannot drift out of date, because the build runs it.
Exceptions
Some method calls cannot succeed. new Circle(-5.0) is nonsense. Pulling an item from an empty list is nonsense. The standard way to signal "this cannot be done" in Java is to throw an exception.
public Circle(double radius) {
if (radius < 0) {
throw new IllegalArgumentException("radius must be non-negative");
}
this.radius = radius;
}
An exception interrupts the normal flow of execution. It bubbles up through method calls until something catches it.
try {
Circle c = new Circle(-5);
} catch (IllegalArgumentException e) {
System.err.println("bad radius: " + e.getMessage());
}
Java distinguishes checked exceptions (the compiler forces you to either catch them or declare that your method throws them) from unchecked exceptions (you do not have to catch them). The convention is: checked for things the caller can reasonably recover from (file not found, network unreachable), unchecked for programmer errors (null pointer, array out of bounds). The Java standard library is inconsistent about this rule, but the principle is right.
Testing that exceptions are thrown
@Test
void rejectsNegativeRadius() {
assertThrows(IllegalArgumentException.class, () -> new Circle(-1.0));
}
The lambda is the code under test. assertThrows runs it and fails the test if the expected exception is not thrown.
Why is throwing an exception in the constructor better than building a half-valid object that you have to remember to validate later?
Part II. More complex data
Sums, recursion, and lists.
Real data is rarely a single flat record. It is a record made of records, with alternative shapes that branch into more alternatives, sometimes referring back to its own type. The structure of the classes you write is a one-to-one mirror of the structure of the data they describe.
6. Sums and products: classes for either-or data
When a value is "one of several alternatives," Java represents it with an interface (the umbrella type) and one class per alternative.
interface Shape {
double area();
}
class Circle implements Shape {
private final double radius;
public Circle(double r) { radius = r; }
public double area() { return Math.PI * radius * radius; }
}
class Rectangle implements Shape {
private final double w, h;
public Rectangle(double w, double h) { this.w = w; this.h = h; }
public double area() { return w * h; }
}
class Triangle implements Shape {
private final double base, height;
public Triangle(double b, double h) { base = b; height = h; }
public double area() { return 0.5 * base * height; }
}
Now any code that has a Shape can call area() on it without knowing which kind it is. The runtime picks the right method based on the actual class of the object. This is polymorphism in action.
Shape s = new Circle(3);
double a = s.area(); // calls Circle.area, returns 9π
Shape t = new Rectangle(4, 5);
double b = t.area(); // calls Rectangle.area, returns 20
The product-of-sum-of-products pattern
Most real data nests this way. A drawing might be a list of shapes (so the list is a product, but each element is a sum). A bank statement is a list of transactions, where each transaction is either a deposit, a withdrawal, or a transfer (sum), and each of those holds an amount and a date (product). The data definition gives you the class structure: every "or" becomes an interface, every "and" becomes a class with fields.
"A vehicle is either a Car (with make, model, mpg) or a Bike (with frame size)." Sketch the interface and two classes for this data definition.
7. Self-referential data and recursion
Some data shapes refer to themselves. A linked list is "either empty, or a value followed by another linked list." A tree node is "either a leaf, or an internal node with a value and two child trees that are themselves trees." The data definition refers to itself, and the class structure mirrors that.
interface IntList {
int length();
int sum();
}
class Empty implements IntList {
public int length() { return 0; }
public int sum() { return 0; }
}
class Cons implements IntList {
private final int first;
private final IntList rest; // a list, just like us
public Cons(int first, IntList rest) { this.first = first; this.rest = rest; }
public int length() { return 1 + rest.length(); }
public int sum() { return first + rest.sum(); }
}
The list {1, 2, 3} is built as new Cons(1, new Cons(2, new Cons(3, new Empty()))). To compute its length, the Cons asks its rest for its length and adds 1. Eventually the recursion bottoms out at the Empty, which returns 0. This is the same recursion you saw in Python or any other language; the only difference is that here the recursion is built into the type.
The structural recursion rule
Whenever the data definition refers to itself, the methods that process it will use recursion in the same shape. The base case is the non-recursive alternative (Empty). The recursive case combines the current value with the result of recursing on the rest. This pattern is so reliable that you can write the skeleton of any list-processing method before knowing what it does, just from the data definition.
Add a method contains(int n) to IntList that returns true if any element equals n. Sketch both Empty and Cons implementations.
8. Mutual recursion
Sometimes data is "two-way recursive": an A contains zero or more Bs, and each B contains zero or more As. A file system is the classic example. A directory contains files and other directories. A directory's children are themselves directory-or-file. This is mutual recursion at the data level, and it forces mutual recursion at the method level.
interface FsItem {
int sizeBytes();
}
class File implements FsItem {
private final int bytes;
public File(int bytes) { this.bytes = bytes; }
public int sizeBytes() { return bytes; }
}
class Directory implements FsItem {
private final List<FsItem> children;
public Directory(List<FsItem> children) { this.children = children; }
public int sizeBytes() {
int total = 0;
for (FsItem c : children) total += c.sizeBytes();
return total;
}
}
The Directory.sizeBytes method walks its children. Each child is an FsItem (could be a file, could be another directory). When it is a directory, that directory's sizeBytes walks its children. The recursion alternates between the two classes naturally, because the data does.
The same shape shows up everywhere. An HTML document contains elements; an element contains other elements (mutually recursive with itself, technically, but the same idea). A parse tree of a programming language has expressions that contain statements that contain expressions. Once you see this pattern, you see it everywhere.
Add a method fileCount() to FsItem that returns the total number of files (not directories) in the tree. Sketch both classes.
9. Lists, the canonical recursive structure
Lists deserve their own module because they are the most common recursive structure and they unlock most of the patterns later in this note. The Java standard library has a built-in List<T> interface and an ArrayList<T> implementation that is what you reach for in real code, but knowing the recursive structure underneath matters when you start writing your own data types.
import java.util.List;
import java.util.ArrayList;
List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
names.add("Carol");
for (String n : names) System.out.println(n);
int count = names.size();
String first = names.get(0);
boolean hasBob = names.contains("Bob");
Internally ArrayList stores its elements in a contiguous array (not a linked structure). It is fast for indexing and slow for inserting at the front. We will revisit this trade-off in Module 15.
The for-each loop
The for (String n : names) form is Java's enhanced for loop. It works on anything that implements Iterable<T>, which is every standard collection. Underneath, the compiler turns it into an explicit iterator loop. We will look at Iterator as a design pattern in Part VI; for now, just know it works.
Generics in 30 seconds
The <String> after List is a generic type parameter. It says "this is a list of Strings specifically, not a list of anything." Without it, you would lose type safety: any object could go in, and you would have to cast on the way out. Generics let one class definition serve any element type while preserving compile-time checking. Module 14 covers them properly.
Write a small Java program that takes a List<Integer> and returns the sum of its elements. Use the for-each loop.
Part III. Inheritance and composition
Two ways to share code, and when to use which.
Inheritance is the most-taught feature of OOP and the most-misused. Composition is the alternative, and is usually the right answer. The trade-off between the two is one of the most consequential design decisions you make.
10. Abstract classes and interfaces
Java has two ways to declare "this is a type, but I am not telling you exactly how it is implemented." They are similar, with one important difference.
Interface
interface Drawable {
void draw();
double area();
}
An interface is a pure contract. It lists method signatures with no bodies (except for default methods, a later addition). Any class that implements Drawable must provide all the methods. A class can implement many interfaces.
Abstract class
abstract class Shape {
private final String name;
public Shape(String name) { this.name = name; }
public String getName() { return name; }
public abstract double area(); // no body, subclasses must provide
}
class Circle extends Shape {
private final double radius;
public Circle(double r) { super("circle"); radius = r; }
public double area() { return Math.PI * radius * radius; }
}
An abstract class can have fields, constructors, and concrete methods, in addition to abstract ones. It cannot be instantiated directly (you cannot say new Shape(...)). Subclasses extend it with extends and provide the missing pieces. A class can extend only one parent.
Which one to use
The rule of thumb: start with an interface. If you find yourself writing the same field or the same concrete method in every implementer, switch to an abstract class so you can put them in one place. If you ever need to inherit from two parents, switch back to an interface (Java does not allow multiple inheritance of classes).
In modern Java (Java 8 and later), interfaces can have default method implementations. This blurs the line: an interface with default methods looks a lot like an abstract class. The remaining difference is fields. Interfaces still cannot have non-static fields, so abstract classes still win when you have shared state.
You have ten classes that all need a name field and a getName() method, plus an area() method that each one computes differently. Should you use an interface, an abstract class, or both? Why?
11. When to inherit and when not to
The temptation is to use inheritance whenever two classes share code. Resist this. Inheritance creates a strong, permanent relationship between the parent and the child: the child's behaviour can be silently changed by changes to the parent, the child has access to the parent's protected internals, and you can never inherit from two parents at once. These are big costs, and they are worth paying only when the relationship is genuinely an "IS-A" relationship.
The IS-A test
Subclass relationships should pass the Liskov Substitution Principle: anywhere the parent type is expected, the subclass should work without surprising the caller. If Circle extends Shape, every place that takes a Shape should accept a Circle. If Square extends Rectangle... hold on. A Square IS a Rectangle in geometry, but in code, this might bite. If Rectangle has setters for width and height, can a Square allow them to be set independently? No. So Square cannot fully substitute for Rectangle, and the inheritance is a mistake.
The classic test: read the relationship aloud. "A Circle is a Shape." True. "A Manager is an Employee." True (probably). "A Stack is a List." Iffy. A stack has only push, pop, and peek; a list has random access. If you inherit Stack from List, you accidentally allow callers to insert in the middle of your stack, which is supposed to be impossible.
Inheritance as a code-sharing mechanism (don't)
The most common abuse is inheriting purely to share code. "Both classes need a logger, so let's put the logger in a base class and make them both inherit from it." This works in the short term and creates a tangled dependency in the long term. Composition (next module) does the same job without the IS-A claim.
You have a BankAccount class. You need to add a SavingsAccount with an interest rate. Should SavingsAccount extend BankAccount? Why or why not?
12. Composition: HAS-A vs IS-A
Composition is the act of one class holding another class as a field, and delegating to it. If A inherits from B, A IS-A B. If A holds a B as a field, A HAS-A B. The HAS-A relationship is much weaker, much more flexible, and almost always preferable.
// Composition: a Logger is held by Account, not inherited from
class Logger {
public void log(String msg) { System.out.println("[log] " + msg); }
}
class Account {
private final Logger logger;
private double balance;
public Account(Logger logger) { this.logger = logger; }
public void deposit(double amount) {
balance += amount;
logger.log("deposited " + amount);
}
}
The Account uses the Logger but is not a Logger. You can swap in a different Logger without changing the Account class. You can use the Logger in many other classes that have nothing in common with Account. You can write a TestLogger that records messages instead of printing, and substitute it for tests.
Delegation
The pattern of "this object holds another object and forwards some method calls to it" is called delegation. It is the foundational technique of composition. Almost every design pattern in Part VI is built on delegation in some form.
The slogan
Favour composition over inheritance. This is one of the most quoted lines from the Gang of Four book, and it has held up. Inheritance is a tool you should reach for only when you have a real IS-A relationship and you have ruled out composition. The default should be: hold the other thing as a field.
A Car has an engine, four wheels, a radio. Should each of these be a parent class of Car (Car extends Engine?), a field of Car (Car has-an Engine?), or something else?
13. The fragile-base-class problem
The deepest reason to prefer composition is the fragile base class problem. A class that someone else inherits from cannot be changed safely.
Suppose you have a class BaseList with methods add(x) and addAll(xs). Internally, addAll calls add in a loop. Someone subclasses your BaseList as CountingList and overrides add to also bump a counter. They use CountingList's own addAll (which they did not override), which calls add, which increments the counter. Each item adds 1 to the counter. Good.
Now you change BaseList's addAll to use a more efficient bulk-add operation that does not call add. CountingList's counter no longer increments. Their tests fail. They did nothing wrong; you did nothing wrong. The inheritance relationship coupled their code to your private implementation choices.
Why composition avoids this
If CountingList had instead held a BaseList as a field and forwarded methods, it would have wrapped both add and addAll explicitly. The internal implementation of BaseList.addAll would have been irrelevant; CountingList only relied on the public methods.
This is why library authors are conservative about which methods they expose for overriding. Every overridable method is a promise about implementation: you cannot change it without potentially breaking subclassers. If a class is final in Java (or has only final methods), it is communicating "you may use me, but you may not subclass me; I want the freedom to change."
If you are designing a class that other people will use but you are not sure how, should you mark it final or leave it open for subclassing? Argue both sides.
Part IV. Generics, ADTs, higher-order
Code that works for any type.
Once you can write a class for one specific kind of data, the next step is writing classes that work for any kind. Generics, abstract data types, and higher-order functions are the three tools that get you there.
14. Generics: writing once, reusing forever
You wrote IntList in Module 7. What if you want a list of strings? Or a list of Books? Without generics, you would write StringList, BookList, and so on, each one a copy. With generics, you write a single MyList<T> and use it for any T.
interface MyList<T> {
int length();
boolean contains(T item);
}
class Empty<T> implements MyList<T> {
public int length() { return 0; }
public boolean contains(T item) { return false; }
}
class Cons<T> implements MyList<T> {
private final T first;
private final MyList<T> rest;
public Cons(T first, MyList<T> rest) { this.first = first; this.rest = rest; }
public int length() { return 1 + rest.length(); }
public boolean contains(T item) { return first.equals(item) || rest.contains(item); }
}
The <T> after the class name declares a type parameter. Inside the class, T stands for whatever type the user picks. MyList<String> is a list of strings. MyList<Book> is a list of books. The same code, parameterised by type.
Type erasure (Java's quirk)
Java implements generics by type erasure: at runtime, all those Ts have been replaced with Object. The compile-time type check is real, but the bytecode does not know about generics. This has two consequences. First, you cannot write new T() inside a generic class (the runtime does not know what T is). Second, you cannot do x instanceof MyList<String>; you can only check x instanceof MyList<?> (the unbounded wildcard). Most of the time these limitations do not matter, but knowing they exist saves confusion.
Bounded type parameters
Sometimes you want T to be more specific than "anything." A SortedList<T> only makes sense if T can be compared. You write <T extends Comparable<T>> and now T is restricted to types that implement Comparable. The compiler will reject any T that cannot.
You want a method max(List<T> xs) that returns the largest element. What constraint do you need on T, and how do you write it?
15. Java collections and the Collections framework
The Java standard library has a small, well-thought-out hierarchy of collection types. Knowing the shape of this framework saves enormous time. The interfaces are roughly:
| Interface | Means | Typical class |
|---|---|---|
List<T> | Ordered, indexable, duplicates allowed | ArrayList<T> |
Set<T> | No duplicates, no defined order | HashSet<T> |
Map<K,V> | Key-value lookup, no duplicate keys | HashMap<K,V> |
Queue<T> | First-in-first-out (with peek and poll) | ArrayDeque<T> |
Deque<T> | Double-ended queue. Stack lives here too | ArrayDeque<T> |
The discipline: declare variables by interface, not by class. Write List<String> names = new ArrayList<>(), not ArrayList<String> names = new ArrayList<>(). This way, if you later switch to LinkedList<String>, the rest of your code does not change.
Picking the right one
For Lists, almost always ArrayList. LinkedList is only better if you are inserting and removing from the front constantly. For Sets, HashSet by default; TreeSet if you need elements in sorted order. For Maps, HashMap by default; TreeMap if you need keys sorted; LinkedHashMap if you need insertion-order iteration.
The Collections utility class
Beyond the data structures, there is a static class called Collections with methods that operate on them: Collections.sort(list), Collections.reverse(list), Collections.unmodifiableList(list). These are all worth knowing exist, even if you do not memorise them.
You need to keep track of which words appear in a document and how many times each one occurs. Which collection type do you reach for, and what are the key and value?
16. Abstract data types: contracts vs implementations
An abstract data type (ADT) is a description of what a data type does, separated from how it does it. A Stack is an ADT. The contract is "push, pop, peek; LIFO order." There are many possible implementations: array-backed, linked-list-backed, growing on push or pre-allocated.
The Java collection framework above is built on this distinction. List<T> is the ADT (the interface, the contract). ArrayList<T> and LinkedList<T> are two implementations. Both satisfy the contract; they make different performance trade-offs.
Why the distinction matters
- Substitutability. If your code uses the ADT (the interface), you can change the implementation without changing the code that uses it. This is liberating. Start with whatever is easiest, and swap in a more efficient implementation later if profiling tells you to.
- Reasoning. When you read code that takes a
List<T>, you do not have to think about array indexing or pointer chasing. You think at the level of the contract: "this code reads elements in order." The implementation is irrelevant. - Testing. You can write a "fake" implementation of an ADT for tests. Replace the real database-backed UserStore with an in-memory HashMap-backed one for unit tests. The class under test does not know.
Most of the patterns you will see in Part VI are about formalising this distinction: the Strategy pattern is "use an ADT to swap out an algorithm," the Adapter pattern is "make this concrete class look like a different ADT," and so on.
Why is it valuable to write code against the List<T> interface instead of against ArrayList<T> directly, even if you know you will only ever use ArrayList?
17. Higher-order functions and lambdas
A higher-order function is a function that takes another function as an argument or returns one. In Java this is expressed through functional interfaces (interfaces with a single abstract method) and lambdas (a compact syntax for instances of those).
List<Integer> nums = List.of(1, 2, 3, 4, 5);
// filter, keep only even numbers
List<Integer> evens = nums.stream()
.filter(n -> n % 2 == 0)
.toList();
// map, double each number
List<Integer> doubled = nums.stream()
.map(n -> n * 2)
.toList();
// reduce, sum them
int total = nums.stream().mapToInt(Integer::intValue).sum();
The lambda n -> n % 2 == 0 is shorthand for an instance of Predicate<Integer> (a functional interface that takes a T and returns a boolean). Java's compiler picks the right type based on context.
The standard functional interfaces
Function<T, R>. Takes T, returns R.x -> x.length()Predicate<T>. Takes T, returns boolean.x -> x > 5Consumer<T>. Takes T, returns nothing.x -> System.out.println(x)Supplier<T>. Takes nothing, returns T.() -> new Random().nextInt()
The Stream API
Streams are Java's pipe-style API for processing collections. You start with a stream, chain transformations (filter, map, sorted, distinct), and end with a terminal operation (collect, sum, forEach). The intermediate stages are lazy: nothing happens until the terminal operation runs. The result is code that reads top-to-bottom as a series of transformations rather than a tangle of for-loops.
Why this matters
Higher-order functions take patterns that you used to write as loops with mutable variables (the "running sum," the "filtered copy") and turn them into single declarative expressions. The result is shorter, harder to get wrong, and often clearer. They also make the visitor and observer patterns later in this note feel almost trivial.
Given a List<Book>, write a stream pipeline that returns the titles of all books published before 2000, in alphabetical order.
Part V. Hierarchical data and sightings
When data is a tree.
A surprising amount of real-world data is a tree. File systems, HTML documents, parse trees, organisational charts, decision trees, scene graphs in games. Once you can recognise a tree, you have a standard set of tools to operate on it.
18. Hierarchical data: trees of any shape
A tree is a recursive data structure where each node has zero or more children and exactly one parent (except the root, which has none). Module 8 already showed one example: the file system. Let's generalise.
interface Tree<T> {
T value();
List<Tree<T>> children();
}
class Node<T> implements Tree<T> {
private final T value;
private final List<Tree<T>> children;
public Node(T value, List<Tree<T>> children) {
this.value = value;
this.children = children;
}
public T value() { return value; }
public List<Tree<T>> children() { return children; }
}
A leaf is just a Node with an empty list of children. A binary tree is a special case where every node has at most two children. A general tree (any number of children per node) is what we have above.
Tree traversals
You walk a tree in one of three orders, each useful for different purposes:
- Pre-order. Visit the node, then each subtree (left to right). Good for printing a tree as a structured outline.
- Post-order. Visit each subtree, then the node. Good for computing things like total size, where each subtree's answer must be ready before the parent's.
- Level-order (breadth-first). Visit all nodes at depth 0, then depth 1, then depth 2. Uses a queue, not recursion.
Each is a few lines of code given the recursive structure. The traversal you pick depends on what you are computing.
Write a method countNodes(Tree<T> t) that returns the total number of nodes in a tree. Which traversal order does it implicitly use?
19. The visitor pattern
When you have a sum-typed data structure (interface plus several implementations) and you want to add a new operation that works on all of them, you have two choices. Option A: add a method to the interface. Option B: use the Visitor pattern.
Option A is fine when you control all the classes and operations are added rarely. The cost is that every implementation of the interface has to be touched. Option B is preferable when operations are added often or by different teams.
interface ShapeVisitor<R> {
R visitCircle(Circle c);
R visitRectangle(Rectangle r);
R visitTriangle(Triangle t);
}
interface Shape {
<R> R accept(ShapeVisitor<R> v);
}
class Circle implements Shape {
private final double radius;
public Circle(double r) { radius = r; }
public double getRadius() { return radius; }
public <R> R accept(ShapeVisitor<R> v) { return v.visitCircle(this); }
}
// And the visitor that computes area:
class AreaVisitor implements ShapeVisitor<Double> {
public Double visitCircle(Circle c) { return Math.PI * c.getRadius() * c.getRadius(); }
public Double visitRectangle(Rectangle r) { return r.getW() * r.getH(); }
public Double visitTriangle(Triangle t) { return 0.5 * t.getBase() * t.getHeight(); }
}
// usage
Shape s = new Circle(3);
double a = s.accept(new AreaVisitor());
The pattern flips control. Each shape has one method, accept, which calls the right method on the visitor. The visitor contains all the logic for one operation. To add a new operation (e.g., circumference), write a new visitor; the shape classes do not change.
The trade-off: it is now hard to add a new shape, because every existing visitor must add a method for it. So Visitor is preferable when the set of types is stable and the set of operations grows. Direct interface methods are preferable when the set of operations is stable and the set of types grows. This is sometimes called the expression problem: pick which axis you want to be easy.
You have an interface Expr with classes Num, Add, Mult. Should you use the visitor pattern, or should each class have its own evaluate method? Defend your choice.
20. Recognising patterns in real data
The point of Parts II through V is not the specific code. It is the recognition skill. When you see a real-world problem, you should be able to look at it and immediately identify the data shapes underneath.
Some sightings, with the structure they imply:
- "A user has many orders, each with line items." Product (User), with a List of products (Order), each containing a List of products (LineItem). No sum types here, just nested products.
- "A vehicle is either a car (with model, mpg) or a bike (with frame size)." Sum type at the top. Two product implementations.
- "A folder contains files and other folders." Mutual recursion. Sum type with one alternative being a product that contains a list of the sum type.
- "An expression is either a number, or two sub-expressions joined by an operator." Sum type with self-recursive cases. The classic AST shape.
- "A status is one of: pending, approved, rejected (with a reason)." Sum type. Note that one of the alternatives carries data and the others do not. Both Java and C++ handle this with classes; in some other languages this is one line.
The skill compounds. Once you can recognise these patterns, the code falls out almost automatically. The hard part is reading the requirements, not writing the classes.
Sketch the data definition (interfaces and classes) for a chess board: 64 squares, each either empty or holding a piece, where a piece is one of six types and one of two colours.
Part VI. Design patterns
Solutions that show up over and over.
A design pattern is not a clever trick. It is a name for a structural solution that has shown up in enough programs that the name carries useful meaning. Once you know the names, design conversations are an order of magnitude faster.
21. Why patterns exist
In 1994, four authors (the "Gang of Four") catalogued 23 recurring design patterns and gave each one a name, an intent, and a structure. The book changed the field. Not because the patterns were new (they were not, mostly), but because giving them names let designers talk about them without having to re-derive them every time.
Patterns sit at a level above individual classes and below entire architectures. They are the vocabulary of "how does this set of classes relate to each other to solve a recurring problem." When you read someone else's code and they say "this is a Strategy", you know what to expect: an interface that represents the algorithm, several implementations, and a host class that holds one of them and delegates.
Three categories
The Gang of Four organise patterns into three groups, and we will follow that:
- Creational. Patterns about how objects get created. Factory Method, Abstract Factory, Builder, Singleton, Prototype.
- Structural. Patterns about how objects compose into bigger structures. Adapter, Bridge, Composite, Decorator, Facade, Flyweight, Proxy.
- Behavioural. Patterns about how objects interact and distribute responsibility. Observer, Strategy, Command, State, Iterator, Template Method, Visitor (you saw it last module), and several more.
The next three modules cover the most useful ones in each group. You will not memorise all 23, and you should not try. The five or six common ones come up constantly; the rest are worth recognising when you see them but rarely worth reaching for first.
The danger of patterns
The most common pattern abuse: applying a pattern when a simpler solution would do. Beginning OOD students sometimes wrap every class in a Factory, every method in a Strategy, every list in an Iterator. The pattern is a tool, not a goal. Reach for one when the simpler solution has stopped working, not before.
Why does giving a recurring solution a name (like "Strategy") make a code review faster, even if the implementation is the same as it would be without the name?
22. Creational patterns
Factory Method
The simplest creational pattern. Instead of calling a constructor directly, you call a factory method that decides which concrete class to instantiate.
abstract class Shape {
public abstract double area();
public static Shape from(String kind, double... args) {
return switch (kind) {
case "circle" -> new Circle(args[0]);
case "rectangle" -> new Rectangle(args[0], args[1]);
default -> throw new IllegalArgumentException("unknown shape " + kind);
};
}
}
// usage
Shape s = Shape.from("circle", 3);
The factory method centralises the decision of which class to make. If you later add a Triangle, you change the factory; callers do not change. This is the spirit of every creational pattern: decouple the call site from the concrete class being created.
Builder
Constructors with many parameters become unreadable: new House(true, false, 3, 2, "blue", null, null, "modern"). The Builder pattern replaces them with a chain of named methods.
House h = new House.Builder()
.bedrooms(3)
.bathrooms(2)
.colour("blue")
.style("modern")
.build();
Implementation: a static inner Builder class with one method per parameter (returning this for chaining) and a final build() that constructs the real object. The Builder also lets you enforce required-vs-optional fields and apply validation centrally.
Singleton (and why to be careful)
A Singleton is a class that has exactly one instance, accessed through a getInstance() static method. Used for things like loggers and configuration registries.
Singletons are the most over-used pattern. They are global mutable state with extra steps. They make testing harder (you cannot substitute a fake), they hide dependencies (a class that uses a Singleton looks like it depends on nothing, but really depends on the Singleton's setup), and they introduce thread-safety questions. Most of the time, what you actually want is a single instance held by your application's top-level wiring code and passed in via constructors. That is composition, and it is better.
What is the difference between using a Singleton for a Logger and just having one Logger instance that your top-level code passes to every class that needs it? Which is better, and why?
23. Structural patterns
Adapter
You have a class with one interface; you need it to look like a different interface. The Adapter wraps the first and exposes the second.
class ThirdPartyXml {
public String readXmlData() { return "<data>...</data>"; }
}
interface JsonReader {
String readJson();
}
class XmlToJsonAdapter implements JsonReader {
private final ThirdPartyXml underlying;
public XmlToJsonAdapter(ThirdPartyXml u) { underlying = u; }
public String readJson() {
return convertXmlToJson(underlying.readXmlData());
}
private String convertXmlToJson(String xml) { /* ... */ return "{...}"; }
}
Adapter shows up everywhere when you integrate libraries. Each library has its own conventions, and adapters let you wrap them into your application's preferred interfaces, isolating the rest of the code from the library's specifics.
Composite
You saw this in Module 8 with the file system. The Composite pattern is: a single object and a group of objects share an interface so that callers can treat one and many uniformly.
interface FsItem { int sizeBytes(); }
class File implements FsItem { /* one thing */ }
class Directory implements FsItem { /* many things, also a thing */ }
Anywhere you have a tree where leaves and internal nodes should look the same to callers, you have a Composite. UI element trees, document elements, scene graphs in games, expression trees in compilers. Composite is everywhere.
Decorator
Decorator is wrapping that adds behaviour. Like Adapter, it implements the same interface as the thing it wraps; unlike Adapter, the underlying interface and the decorator's interface are the same. Java's I/O streams are the canonical example: a BufferedInputStream wraps a FileInputStream and adds buffering, a GZIPInputStream wraps the buffered stream and adds decompression, and the outermost stream still implements InputStream.
InputStream in = new GZIPInputStream(
new BufferedInputStream(
new FileInputStream("data.gz")));
Each wrapper adds one capability. Combinations are formed at runtime by composing wrappers. This is composition over inheritance taken to its conclusion.
What is the difference between an Adapter and a Decorator, given that both wrap an underlying object? Give an example where you'd use each.
24. Behavioural patterns
Strategy
You have an algorithm with several variants. Each variant is a class implementing a common interface. The host object holds one of these and delegates to it.
interface SortStrategy<T extends Comparable<T>> {
void sort(List<T> list);
}
class QuickSort<T extends Comparable<T>> implements SortStrategy<T> { /* ... */ }
class MergeSort<T extends Comparable<T>> implements SortStrategy<T> { /* ... */ }
class Sorter<T extends Comparable<T>> {
private SortStrategy<T> strategy;
public Sorter(SortStrategy<T> s) { strategy = s; }
public void sort(List<T> list) { strategy.sort(list); }
}
Strategy is the most-used pattern in everyday OOP code. Anywhere you would write a switch on "what kind of thing is this" to pick an algorithm, Strategy is the cleaner alternative.
Observer
One object holds a list of observers and calls a method on each one when something changes. Observer is the foundation of GUI event handling and the publish-subscribe systems used in nearly every messaging framework.
interface Observer {
void onUpdate(String event);
}
class Subject {
private final List<Observer> observers = new ArrayList<>();
public void subscribe(Observer o) { observers.add(o); }
public void unsubscribe(Observer o) { observers.remove(o); }
public void publish(String event) {
for (Observer o : observers) o.onUpdate(event);
}
}
Java's old AWT and Swing libraries used this pattern under the name EventListener. Modern UI libraries (React's hooks, Android's LiveData, RxJava) are all elaborations of Observer.
Iterator
You have already used this. The Iterator pattern is "an object that knows how to walk a collection one element at a time." Java's Iterable<T> and Iterator<T> interfaces are exactly the pattern, and the for-each loop is sugar over them.
State
State is Strategy with a twist: the host object's behaviour changes over time as it transitions between states. Each state is a class. Methods on the host delegate to the current state, which can change the host's state to something else.
Vending machines, network protocols, game characters, anything that has modes you cycle through. State is the pattern.
You're writing a chat application. When a new message arrives, ten different parts of the UI need to react (notification badge, sound, popup, etc.). Which pattern do you reach for and why?
25. SOLID principles and design heuristics
Patterns are concrete solutions to recurring problems. Principles are the higher-level rules that tell you whether your design is good. The most-quoted set is Robert Martin's SOLID.
| Letter | Principle | What it means |
|---|---|---|
| S | Single Responsibility | Each class should do one thing. If you can describe a class with "and," it does too much. |
| O | Open/Closed | Open for extension, closed for modification. Adding new behaviour shouldn't require editing old code. |
| L | Liskov Substitution | Subclasses must be usable wherever the parent is. (Module 11.) |
| I | Interface Segregation | Many small interfaces beat one big one. Clients shouldn't depend on methods they don't use. |
| D | Dependency Inversion | Depend on abstractions, not concrete classes. High-level code should not import low-level details directly. |
SOLID is a checklist, not a recipe. A class can violate any single principle and still be fine, and following all five blindly produces over-engineered code. The principles tell you why a design feels wrong; the patterns tell you how to fix it.
Other heuristics worth knowing
- DRY (Don't Repeat Yourself). If two places have the same logic, extract a method or class. Be careful not to over-DRY: two pieces of code that look similar today may diverge tomorrow, and forced sharing creates worse coupling than copying.
- YAGNI (You Aren't Gonna Need It). Don't build flexibility you have not yet needed. Real flexibility comes from refactoring when needs change, not from upfront design.
- Tell, Don't Ask. Methods should ask the object to do something, not pull data out and then act on it externally.
account.deposit(50), notaccount.setBalance(account.getBalance() + 50). - Law of Demeter. A method should only call methods on its own class, its parameters, objects it created, and its direct fields.
customer.getOrder().getLineItems().get(0).getProduct().getPrice()is a chain of trains. Breaking the chain is not always wrong, but it is a smell.
A class has methods load(), save(), and render(). Which SOLID principle does it most likely violate, and how would you split it?
Part VII. A second language: C++
Same ideas, more knobs.
C++ shares almost all of Java's design vocabulary (classes, inheritance, polymorphism, generics by another name) and adds direct control over memory and runtime layout. The conceptual jump from Java to C++ is small. The detail you need to track is large.
26. Why C++ exists alongside Java
C++ pre-dates Java by about a decade, and the two languages were aimed at different problems. Java prioritised portability and safety: same bytecode running on any JVM, garbage-collected memory, no pointer arithmetic. C++ prioritised performance and direct hardware access: compile to native code, programmer manages memory, every cost is visible.
The trade-off shows up everywhere. C++ programs are typically faster than equivalent Java programs by some constant factor. C++ programs are also more error-prone (memory leaks, dangling pointers, undefined behaviour) because the language gives you the rope. Both languages are still in widespread use because both trade-offs are still useful: Java for application code where productivity wins, C++ for systems code where every microsecond counts.
Where you'll see C++
- Operating systems and drivers (parts of Linux, all of Windows kernel-mode code).
- Game engines (Unreal, Unity's runtime). Real-time graphics and physics need predictable performance.
- High-frequency trading. Microseconds matter.
- Embedded systems. Memory-constrained devices.
- Browsers. Chromium and Firefox cores.
- Database engines (parts of MySQL, MongoDB, all of HBase's storage layer).
The shared theme: C++ is what you reach for when performance, memory layout, or hardware access is non-negotiable.
You are writing a backend service that handles HTTP requests. There is no special performance requirement. Java or C++? Why?
27. Classes in C++
A C++ class looks remarkably like a Java class. The keywords are slightly different and the layout is split across header (.h) and source (.cpp) files, but the ideas transfer almost directly.
// circle.h
#ifndef CIRCLE_H
#define CIRCLE_H
class Circle {
public:
Circle(double radius);
double area() const;
double circumference() const;
private:
double radius_;
};
#endif
// circle.cpp
#include "circle.h"
#include <cmath>
Circle::Circle(double radius) : radius_(radius) {}
double Circle::area() const {
return M_PI * radius_ * radius_;
}
double Circle::circumference() const {
return 2 * M_PI * radius_;
}
Things to notice. The header declares the interface (just signatures); the source file defines them. public: and private: are sections, not per-member modifiers like Java. const after a method signature means "this method does not modify the object", the C++ way of marking a method as read-only. The constructor uses an initializer list (: radius_(radius)) to set fields, which is more efficient than assigning in the body.
Inheritance and virtual
class Shape {
public:
virtual double area() const = 0; // = 0 makes it pure virtual (Java's abstract)
virtual ~Shape() = default; // virtual destructor, important!
};
class Circle : public Shape {
public:
Circle(double r) : radius_(r) {}
double area() const override {
return M_PI * radius_ * radius_;
}
private:
double radius_;
};
Methods are not virtual by default in C++ (unlike Java, where they all are). To get polymorphism, you mark the method virtual in the base class. = 0 makes it pure virtual (no implementation, must be overridden). The override keyword on the derived method is optional but recommended; the compiler will warn if you accidentally do not actually override anything.
Multiple inheritance
C++ allows a class to inherit from multiple parents. Java does not. This causes problems (the diamond problem: if both parents inherit from a common grandparent, which copy of the grandparent's data does the child get?), and most modern C++ codebases avoid it for everything except inheriting from "interface-like" classes (pure virtual classes with no data).
Why does C++ require you to mark methods virtual for polymorphism, when Java makes everything virtual by default? What's the trade-off?
28. Memory: stack, heap, RAII
The biggest difference between Java and C++ is memory. In Java, every object lives on the heap and the garbage collector cleans up. In C++, you choose: stack or heap, automatic or manual.
Stack allocation
void example() {
Circle c(5); // c lives on the stack
double a = c.area();
} // c is destroyed automatically here
The variable c is a Circle, not a pointer to a Circle. It lives on the stack frame of example. When example returns, c's destructor (~Circle) is called automatically. No leaks, no manual cleanup.
Heap allocation
void example() {
Circle* c = new Circle(5); // c is a pointer to heap memory
double a = c->area();
delete c; // MUST manually free, or you leak
}
Heap-allocated objects need to be explicitly freed with delete. Forget, and you leak memory. Free twice, or use after free, and you get undefined behaviour and likely a crash. This is the source of an enormous fraction of C++ bugs.
RAII: the C++ idiom
The fundamental C++ technique for managing resources is RAII (Resource Acquisition Is Initialization). The idea: tie the lifetime of a resource (memory, file handle, lock, network connection) to the lifetime of an object on the stack. The object's constructor acquires the resource; the destructor releases it. Because the destructor runs automatically when the stack frame ends, the resource is always cleaned up, even if an exception is thrown.
class FileHandle {
public:
FileHandle(const std::string& path) {
f_ = std::fopen(path.c_str(), "r");
if (!f_) throw std::runtime_error("open failed");
}
~FileHandle() {
if (f_) std::fclose(f_);
}
private:
FILE* f_;
};
void readSomething() {
FileHandle f("data.txt"); // open
// ... use f ...
} // f goes out of scope, destructor closes the file
RAII is the C++ alternative to Java's garbage collection plus try-finally. It is also more powerful: try-finally only handles one resource at a time; RAII handles arbitrary nesting trivially. Modern C++ uses RAII for almost everything, including heap memory (next module).
What goes wrong if a C++ destructor throws an exception during stack unwinding caused by another exception? (This is real and you should never do it.)
29. References, pointers, smart pointers
C++ has three ways to refer to another object. They look similar but mean different things.
Pointer
Circle* p = new Circle(5);
double a = p->area(); // arrow for pointer-to-member
delete p;
A raw pointer is a memory address. It can be null, can be reseated, can dangle (point at freed memory). It is what C uses, and modern C++ avoids using them directly in favour of smart pointers (below).
Reference
void printArea(const Circle& c) { // const reference, no copy, can't modify
std::cout << c.area() << std::endl;
}
Circle c(5);
printArea(c);
A reference is an alias for an existing object. It cannot be null, cannot be reseated, and is dereferenced implicitly (no * or ->). References are the way to pass objects to functions without copying them.
Smart pointers
Modern C++ wraps heap allocations in objects that auto-free on destruction (RAII applied to memory).
#include <memory>
std::unique_ptr<Circle> p = std::make_unique<Circle>(5);
double a = p->area();
// no manual delete; ~unique_ptr frees the Circle when p goes out of scope
unique_ptr is a single owner: when it goes out of scope, the object is freed. You cannot copy a unique_ptr (that would make two owners), but you can move it to transfer ownership.
shared_ptr is reference-counted shared ownership. Every copy bumps a counter; every destruction decrements it; when the counter hits zero, the object is freed. Use it when ownership genuinely is shared, but prefer unique_ptr when you can.
weak_ptr is a non-owning observer of a shared_ptr. It does not affect the count. You upgrade it to a shared_ptr only when you actually need to use the object, and the upgrade can fail (returning null) if the object has already been freed. Used to break reference cycles.
The default rule
Modern C++ style: never use raw new and delete directly. Always use a smart pointer (or stack allocation, or a standard container). The result is C++ code that is nearly as memory-safe as Java, while still letting you reach for raw control when you genuinely need it.
Two objects each hold a shared_ptr to the other. Why is this a memory leak, and how would you fix it with weak_ptr?
30. Templates: C++ generics
C++ templates and Java generics solve the same problem (write code that works for any type), but they work very differently under the hood.
template<typename T>
class MyList {
public:
void add(const T& item);
T get(size_t index) const;
size_t size() const;
private:
std::vector<T> items_;
};
The syntax is similar to Java's. The semantics are very different. Java's generics are erased at runtime: there is one bytecode-level MyList class regardless of T. C++ templates are monomorphised at compile time: the compiler generates a separate MyList<int>, MyList<std::string>, and so on, each one fully specialised for that type.
The implications:
- Performance. No type erasure means no boxing of primitives, no virtual dispatch, no runtime type checks. Templated code is as fast as hand-written code for each type.
- Code size. Each instantiation is separate compiled code. A program that uses
MyListwith 20 different types ships 20 separateMyListbinaries (the linker often merges duplicates). - Error messages. Template errors are notoriously verbose. A small mistake in a template can produce a multi-page error referring to types deep in the standard library. C++20 added concepts to constrain template parameters with readable errors, but legacy code does not use them.
- You can do more. Templates can specialise on values, not just types. Templates can recurse on themselves to compute things at compile time. Templates are effectively a Turing-complete sub-language. This is power; it is also a footgun.
The standard template library
C++'s standard library, the STL, is a deeply considered set of templated containers and algorithms. std::vector<T> is the analogue of Java's ArrayList. std::map<K, V> is the analogue of TreeMap. std::unordered_map is HashMap. The algorithms (std::sort, std::find, std::transform) work on any container that exposes the right iterators. The STL is C++'s best-loved feature for a reason.
If you use MyList<int> in 5 different files in your program, how many copies of MyList's compiled code are produced in C++? In Java? Why?
Part VIII. Building larger programs
From classes to applications.
A small program might be three classes. A real application is hundreds. The reason most large applications survive is a small set of architectural patterns that cut the program into pieces that can be reasoned about separately. The most enduring of these is Model-View-Controller.
31. Model-View-Controller
MVC is a way of splitting an interactive application into three responsibilities, each one a class or set of classes:
- Model. The data and the rules that act on it. The Model knows nothing about how it is shown or how the user interacts with it. It can be tested in isolation.
- View. The presentation. Reads the Model and renders it to the screen (or to whatever output medium). Knows how to display, but does not decide what to do when the user clicks.
- Controller. The interpreter of user input. Receives clicks, keystrokes, network requests; decides what should change in the Model; tells the View to refresh.
Why this works. The Model has zero dependencies on the rest of the application, you can run the entire business logic without a UI, which makes it testable. The View has one dependency (the Model) and is replaceable: swap a desktop View for a web View without touching the Model. The Controller is the only place that needs to know about both.
Variants
There are several variations on the same idea. MVP (Model-View-Presenter) makes the Presenter the only thing the View talks to (the View doesn't read the Model directly). MVVM (Model-View-ViewModel) replaces the Controller/Presenter with a ViewModel that exposes data and commands as data-bound properties. The names are confusing; the underlying split (data vs presentation vs interaction) is the same.
Why is it valuable that the Model in MVC has no dependency on the View? Imagine writing tests for a complicated piece of business logic and explain how this dependency-direction helps.
32. Putting it together: a small MVC app
A complete tiny MVC application in Java. The model is a counter; the view shows it; the controller increments and decrements it. Trivial, but it shows the wiring.
// Model.java
class CounterModel {
private int count = 0;
private final List<Runnable> observers = new ArrayList<>();
public int getCount() { return count; }
public void increment() { count++; notifyObservers(); }
public void decrement() { count--; notifyObservers(); }
public void subscribe(Runnable r) { observers.add(r); }
private void notifyObservers() { for (Runnable r : observers) r.run(); }
}
// View.java
class CounterView {
private final CounterModel model;
public CounterView(CounterModel model) {
this.model = model;
model.subscribe(this::render);
}
public void render() {
System.out.println("Count is: " + model.getCount());
}
}
// Controller.java
class CounterController {
private final CounterModel model;
public CounterController(CounterModel m) { model = m; }
public void handleInput(String input) {
switch (input) {
case "+" -> model.increment();
case "-" -> model.decrement();
default -> System.err.println("unknown: " + input);
}
}
}
// Main.java
public class Main {
public static void main(String[] args) {
CounterModel model = new CounterModel();
new CounterView(model); // view subscribes itself
CounterController ctrl = new CounterController(model);
Scanner s = new Scanner(System.in);
while (s.hasNext()) {
ctrl.handleInput(s.next());
}
}
}
The Observer pattern (Module 24) shows up here: the View subscribes to the Model so it re-renders whenever the Model changes. The Controller does not call the View directly; it just changes the Model and lets the wiring handle the rest.
Scale this up and you have an interactive desktop app, a web application, or a mobile app. The pieces grow (the Model becomes a domain layer with many classes, the View becomes a layout tree of UI components, the Controller becomes a router with dozens of endpoints), but the contract among the three roles stays the same.
If you wanted to add a second view (say, also write the count to a file every time it changes), what changes? Which of the three classes do you modify?
33. Reading a real OOD codebase
The final test of this note is the same as the previous two: pick up code that someone else wrote and read it. A few exercises to try.
Read the source of java.util.ArrayList in the OpenJDK sources. It is a few hundred lines, mostly clear, and uses generics and the standard collection interfaces correctly. Notice how it implements List, RandomAccess, Cloneable, and Serializable; how it grows the underlying array; how it implements iterator().
Pick a small Java game on GitHub. Find a 2D game, board game, or puzzle clone. Identify the Model classes (the rules of the game), the View classes (the rendering), the Controller (the input handler). See how they communicate. Look for design patterns: Observer is everywhere, Strategy is common for AI, State is used for game phases, Composite is used for the board.
Read std::vector in libstdc++ or libc++. The C++ analogue of ArrayList. Notice how it is a class template, how it manages memory with placement-new and explicit destruction, how it grows. The differences from Java's ArrayList are illuminating.
Find a small open-source project that uses MVC explicitly. Spring MVC for Java; Cocoa for Swift; older versions of ASP.NET. Read how the framework wires up routes to controllers to models to views. Notice what is plumbing and what is domain logic.
What you should be able to do now
- Take a problem statement, identify product and sum data, and write the data definition as Java classes.
- Decide whether two classes should be related by inheritance or composition, and defend the choice.
- Write generic classes and methods, with bounded type parameters when needed.
- Use Java's collection framework effectively, picking the right interface and implementation for each use.
- Apply higher-order functions and the Stream API to express collection processing declaratively.
- Recognise the major design patterns (Strategy, Observer, Composite, Decorator, Adapter, Factory) when you see them in code, and reach for the right one when designing.
- Read and write C++ that uses RAII, smart pointers, and templates idiomatically.
- Apply MVC to split an interactive application into model, view, and controller, and explain why the split is valuable.
That is the full list of objectives. If most of them feel comfortable, you are done. If a few feel shaky, the relevant module is at the top of this page.