The OOP Mental Model
Before writing code, you need to think like an OOP engineer. This lesson builds the mental framework that everything else plugs into — and corrects the most common beginner misconceptions.
Most beginners treat OOP as just another syntax. That's the wrong frame. OOP is a way of modeling reality in code. The question shifts from "what does my program do?" to "what things exist in my program, and how do they communicate?"
The Shift from Procedural Thinking
In procedural code, you write functions that operate on external data. In OOP, data and behavior are bundled into objects. Objects own their state and expose behaviors — nothing outside can reach in and corrupt their internals.
| Procedural | Object-Oriented |
|---|---|
| calculateArea(shape) | shape.calculateArea() |
| Global/shared data passed around | Objects own their data exclusively |
| Logic lives in standalone functions | Logic lives in methods on responsible classes |
| Hard to scale past ~1000 lines | Scales to millions of lines (Android, Spring, etc.) |
Primitives (int, double, boolean) live on the stack — fast, auto-managed, copied on method calls. Objects live on the heap — garbage collected. When you pass an object to a method, Java copies the reference, not the object. This means the method can mutate your object's state. This distinction causes subtle, hard-to-find bugs.
// Primitives are COPIED into methods — original is unchanged int score = 90; doubleIt(score); System.out.println(score); // still 90 — the copy was doubled, not this // Objects: the REFERENCE is copied — same heap object is mutated int[] arr = {1, 2, 3}; zeroFirst(arr); System.out.println(arr[0]); // 0 — same array on the heap was modified // null = reference points to nothing. Always guard against it. String s = null; // s.length(); // NullPointerException at runtime — classic beginner crash if (s != null) { process(s); } // defensive null-check
Guard your data
Private fields, controlled access. No external code can corrupt your object's state.
Reuse without copy-paste
Child classes inherit parent behavior and can extend or override it. Write once, reuse everywhere.
One interface, many forms
Same method call, different behavior based on actual runtime type. Enables plug-and-play architecture.
Hide complexity
Expose only what callers need. They don't need to understand internals to use your class correctly.
int[] array to a method and modify arr[0] inside. Back in the caller, is arr[0] changed?Classes in Depth
You know what a class is. Now let's understand constructors, this, static vs instance members, method overloading, equals()/hashCode(), and how to design a class responsibly.
A class should represent exactly one concept. Fields store state. Methods define behavior. The constructor establishes a valid initial state — the object should be consistent from the moment new returns.
Constructor Chaining · static members · this
public class BankAccount { // static: shared across ALL instances — counts total created private static int totalAccounts = 0; private static final double INTEREST_RATE = 0.035; private final String accountId; // final = set once, never changed private String ownerName; private double balance; // Constructor 1 delegates to Constructor 2 via this(...) public BankAccount(String ownerName) { this(ownerName, 0.0); // must be FIRST statement } // Constructor 2: all initialization funnels here public BankAccount(String ownerName, double initialBalance) { if (initialBalance < 0) throw new IllegalArgumentException("Balance cannot be negative"); this.ownerName = ownerName; this.balance = initialBalance; totalAccounts++; this.accountId = "ACC-" + totalAccounts; } public void deposit(double amount) { if (amount <= 0) throw new IllegalArgumentException("Deposit must be positive"); balance += amount; } public void withdraw(double amount) { if (amount > balance) throw new IllegalStateException("Insufficient funds"); balance -= amount; } public void applyInterest() { balance += balance * INTEREST_RATE; } // Static method: no 'this', can't touch instance fields public static int getTotalAccounts() { return totalAccounts; } public String getAccountId() { return accountId; } public double getBalance() { return balance; } // equals + hashCode: mandatory pair — required for use in Sets/Maps @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof BankAccount other)) return false; // Java 16 pattern return accountId.equals(other.accountId); } @Override public int hashCode() { return Objects.hash(accountId); } @Override public String toString() { return String.format("[%s] %s — $%.2f", accountId, ownerName, balance); } }
If you override equals(), you MUST also override hashCode(). Objects that are equals() must produce the same hashCode. Violating this silently breaks HashSet and HashMap — two objects that should be "equal" get stored as separate entries. The bug is extremely hard to find at runtime.
Design a Product Class for an E-Commerce System
Your class must: auto-increment id (final), validate price > 0 and stock ≥ 0 in constructor, implement purchase(int qty) that throws IllegalStateException on insufficient stock, applyDiscount(double pct) that validates 0–100, and override toString(), equals(), hashCode(). Add a static getTotalProducts(). Write a main that creates products and stores them in a HashSet — verifying that two products with the same ID count as one entry.
Encapsulation Done Right
Encapsulation isn't just "make fields private." It's designing a class so that it is impossible to put it into an invalid state. Blind getters and setters defeat the purpose entirely.
If you add a setBalance(double b) method with no validation, anyone can write account.setBalance(-999999). You've made the field private but given away full control. That's worse than public.
The rule: an object should be the sole guardian of its own consistency. All state transitions happen through methods that enforce business rules. Outside code tells the object what to do, not how to update its fields.
public class PlayerCharacter { private String name; private int health, maxHealth, level, xp; private static final int XP_PER_LEVEL = 100; public PlayerCharacter(String name) { this.name = name; this.maxHealth = 100; this.health = maxHealth; this.level = 1; } public void takeDamage(int amount) { if (amount < 0) throw new IllegalArgumentException("Damage can't be negative"); health = Math.max(0, health - amount); // can't go below 0 if (isDead()) System.out.println(name + " has been defeated!"); } public void heal(int amount) { if (isDead()) return; // dead characters can't heal health = Math.min(maxHealth, health + amount); // can't exceed max } public void gainXP(int amount) { xp += amount; while (xp >= XP_PER_LEVEL) { xp -= XP_PER_LEVEL; levelUp(); } } private void levelUp() { // PRIVATE — callers don't control this directly level++; maxHealth += 20; health = maxHealth; System.out.printf("%s reached level %d! Max HP: %d%n", name, level, maxHealth); } public boolean isDead() { return health == 0; } public int getHealth() { return health; } public int getLevel() { return level; } }
Classes that only have data (getters/setters) with no real behavior are called Anemic Domain Models. All logic ends up in external "service" classes that manipulate them. This is procedural code disguised as OOP. If your class looks like a struct, redesign it so the class itself owns its business logic.
Defensive Copying
Even with private fields, you can leak mutable references. If a class stores a List<String> and its getter returns it directly, callers can call .clear() on that list — silently corrupting your object's state from the outside.
import java.util.*; public class Inventory { private final List<String> items; public Inventory(List<String> initial) { this.items = new ArrayList<>(initial); // defensive copy on the way IN } public void addItem(String item) { items.add(item); } // ❌ BAD: leaks internal reference — caller can items.clear() public List<String> getItemsBAD() { return items; } // ✅ GOOD: return an unmodifiable view public List<String> getItems() { return Collections.unmodifiableList(items); } }
Build an Immutable Money Class
All fields final. No setters. add(Money other) returns a new Money (same currency required; throw on mismatch). multiply(double factor) returns a new Money. subtract(Money other) throws if result would be negative. Override equals(), hashCode(), toString(). This mirrors how Java's BigDecimal and String work — complete immutability.
List<String> tags. Your getter returns tags directly. A caller does obj.getTags().clear(). What happens?Inheritance vs Composition
Inheritance is powerful and overused. You need to know not just how it works, but when to reach for it — and when composition is the better tool. This distinction separates junior from senior engineers.
The is-a vs has-a Rule
Use inheritance only when the relationship is genuinely is-a: a SavingsAccount IS a BankAccount. Use composition for has-a: a Car HAS an Engine — don't make Car extends Engine. The principle: "Favor composition over inheritance." Inheritance creates tight coupling — if the parent changes, children can silently break.
public class BankAccount { protected String id; protected double balance; public BankAccount(String id, double balance) { this.id = id; this.balance = balance; } public void withdraw(double amount) { if (amount > balance) throw new IllegalStateException("Insufficient funds"); balance -= amount; } public double getBalance() { return balance; } } // Child adds overdraft capability public class CheckingAccount extends BankAccount { private double overdraftLimit; public CheckingAccount(String id, double balance, double overdraftLimit) { super(id, balance); // MUST call parent constructor first this.overdraftLimit = overdraftLimit; } @Override public void withdraw(double amount) { if (amount > balance + overdraftLimit) throw new IllegalStateException("Exceeds overdraft limit"); balance -= amount; // balance goes negative = into overdraft } } // Child adds interest + monthly withdrawal cap public class SavingsAccount extends BankAccount { private double interestRate; private int withdrawalsThisMonth = 0; private static final int MAX_WITHDRAWALS = 6; public SavingsAccount(String id, double balance, double rate) { super(id, balance); this.interestRate = rate; } @Override public void withdraw(double amount) { if (withdrawalsThisMonth >= MAX_WITHDRAWALS) throw new IllegalStateException("Monthly withdrawal limit reached"); super.withdraw(amount); // reuse parent's balance check — don't duplicate! withdrawalsThisMonth++; } public void applyMonthlyInterest() { balance += balance * interestRate; withdrawalsThisMonth = 0; } }
Composition: When Not to Inherit
A Car isn't a specialized Engine. It HAS an engine, HAS a GPS. Compose — don't inherit. Composition is more flexible: you can swap the composed object at runtime, and there's no tight coupling to internal implementation details.
public class Engine { private int hp; public Engine(int hp) { this.hp = hp; } public void start() { System.out.println("Engine started (" + hp + "hp)"); } public void stop() { System.out.println("Engine stopped"); } } public class GPS { public void setDestination(String dest) { System.out.println("Routing to: " + dest); } } // Car HAS-A engine and HAS-A GPS — not IS-A Engine public class Car { private final String model; private final Engine engine; // composed private final GPS gps; // composed public Car(String model, int hp) { this.model = model; this.engine = new Engine(hp); this.gps = new GPS(); } public void start() { engine.start(); } public void navigateTo(String destination) { gps.setDestination(destination); } }
When you inherit from a class, your subclass is tightly coupled to its internal implementation. If the parent changes a method that your super.method() calls, your behavior can silently break without any compile error. This is the "fragile base class problem" and it's a primary reason experienced engineers prefer composition for everything except true is-a relationships.
Refactor: Inheritance → Composition + Strategy
You're given a poorly-designed hierarchy: Logger extends FileWriter extends BufferedStream. Refactor it: Logger should have-a OutputStrategy (interface). Implement ConsoleOutputStrategy and FileOutputStrategy. Logger accepts the strategy in its constructor and can have it swapped at runtime. Add a CompositeOutputStrategy that writes to multiple strategies simultaneously. This is the Strategy Pattern — preview of Lesson 8.
Polymorphism in Practice
Polymorphism is how you write code that works correctly with types you haven't invented yet. It powers plug-and-play architecture, frameworks, and testable systems.
Dynamic Dispatch — How Java Resolves Method Calls
When you call a method through a parent-type reference, Java resolves at runtime which overridden version to call based on the actual object type. This is dynamic dispatch, implemented via a virtual method table (vtable). The JVM, not the compiler, decides which method body runs.
public abstract class PaymentMethod { protected String ownerName; public abstract boolean processPayment(double amount); public abstract String getPaymentType(); protected void printReceipt(double amount, boolean success) { System.out.printf("[%s] %s $%.2f — %s%n", getPaymentType(), ownerName, amount, success ? "APPROVED" : "DECLINED"); } } public class CreditCard extends PaymentMethod { private double creditLimit, debt; public CreditCard(String owner, double limit) { ownerName = owner; creditLimit = limit; } @Override public boolean processPayment(double amount) { if (debt + amount > creditLimit) { printReceipt(amount, false); return false; } debt += amount; printReceipt(amount, true); return true; } public void payOffDebt(double amount) { debt = Math.max(0, debt - amount); } @Override public String getPaymentType() { return "CREDIT"; } } public class DebitCard extends PaymentMethod { private double balance; public DebitCard(String owner, double balance) { ownerName = owner; this.balance = balance; } @Override public boolean processPayment(double amount) { if (amount > balance) { printReceipt(amount, false); return false; } balance -= amount; printReceipt(amount, true); return true; } @Override public String getPaymentType() { return "DEBIT"; } } // The processor is completely agnostic about which subtype it receives public class Checkout { public static void main(String[] args) { PaymentMethod[] methods = { new CreditCard("Alex", 2000), new DebitCard("Sam", 150) }; for (PaymentMethod m : methods) { m.processPayment(200); // dynamic dispatch — right version runs // Pattern matching instanceof (Java 16+): access subtype-specific methods if (m instanceof CreditCard cc) { cc.payOffDebt(50); // cc is already cast — no explicit cast needed } } } }
Create class PayPal extends PaymentMethod. Pass it into the array. It works immediately. This is the Open/Closed Principle — code is open for extension, closed for modification. You'll formalize this in the SOLID lesson.
Build a Notification System with Runtime Extensibility
Abstract class Notification with abstract method send(String message). Implement EmailNotification, SMSNotification, PushNotification. NotificationService holds a List<Notification> and broadcast(String msg) sends to all. Then add a new SlackNotification — verifying that NotificationService requires zero changes. Add a sendFiltered(String msg, Predicate<Notification> filter) method using lambdas.
Animal a = new Dog(). Which version of makeSound() is called — and when is that decided?Abstraction: Abstract Classes vs Interfaces
Both define contracts. Knowing which to use — and when to use them together — is a mark of engineering maturity. The distinction is subtle but architecturally critical.
| Abstract Class | Interface | |
|---|---|---|
| Instance fields | Yes | Only static final constants |
| Constructor | Yes | No |
| Concrete methods | Yes | Only default/static (Java 8+) |
| A class can use how many | One (extends) | Many (implements A, B, C) |
| Best for | Shared state + behavior + partial implementation | Capability contracts (can-do) |
// Interfaces — define capabilities public interface Playable { void play(); void pause(); void stop(); } public interface Downloadable { void download(String path); long getSizeBytes(); } public interface Shareable { String getContentId(); // Default method: interfaces CAN have implementations since Java 8 default String generateShareLink() { return "https://share.app/" + getContentId(); } } // Abstract class: shared state + Template Method Pattern public abstract class MediaContent implements Playable, Shareable { protected final String id, title, author; protected boolean isPlaying = false; public MediaContent(String id, String title, String author) { this.id = id; this.title = title; this.author = author; } @Override public String getContentId() { return id; } // Template Method: defines the SKELETON — subclasses fill in onPlay() @Override public void play() { if (!isPlaying) { isPlaying = true; onPlay(); } // hook } @Override public void stop() { isPlaying = false; System.out.println("⏹ Stopped"); } @Override public void pause() { isPlaying = false; System.out.println("⏸ Paused"); } protected abstract void onPlay(); // subclasses define this public abstract int getDurationSeconds(); } public class Video extends MediaContent implements Downloadable { private int durationSec, resolutionP; public Video(String id, String title, String author, int dur, int res) { super(id, title, author); durationSec = dur; resolutionP = res; } @Override protected void onPlay() { System.out.printf("▶ Playing '%s' in %dp%n", title, resolutionP); } @Override public int getDurationSeconds() { return durationSec; } @Override public void download(String path) { System.out.println("Downloading to " + path); } @Override public long getSizeBytes() { return (long) durationSec * resolutionP * 1000; } }
play() defines the algorithm skeleton and delegates to the abstract hook onPlay(). Subclasses implement the hook without changing the overall flow or the bookkeeping logic. This is the Template Method design pattern — you'll find it in HttpServlet.doGet(), JUnit test lifecycle, and most major Java frameworks.
Design a Swappable Database Connector
Interface DatabaseConnection: connect(), disconnect(), executeQuery(String sql), isConnected(). Abstract class AbstractDatabaseConnection implements shared logic (connection state tracking, query logging, timing). Concrete classes MySQLConnection and PostgreSQLConnection. A DataService depends only on DatabaseConnection — never a concrete class. Switch implementations without touching DataService.
SOLID Principles
SOLID is five design principles that make OOP code maintainable, testable, and scalable. Each principle fixes a specific way class design goes wrong in real codebases.
Single Responsibility Principle
A class should have one — and only one — reason to change. If a class handles data persistence AND business logic, a database schema change forces edits to your business logic file. Split them.
Open/Closed Principle
Open for extension, closed for modification. Add new behaviors by writing new classes, not by editing existing ones. The payment processor in Lesson 5 is a perfect example: add PayPal by extending, not modifying.
Liskov Substitution Principle
A subclass must be fully substitutable for its parent without breaking the program. If code expects a BankAccount and receives a SavingsAccount, everything must work correctly — no surprises, no thrown exceptions that the parent wouldn't throw.
Interface Segregation Principle
Don't force classes to implement methods they don't need. A fat interface with 20 methods forces every implementer to stub out methods they don't use. Split it into small, focused interfaces.
Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on abstractions (interfaces). Your business logic should depend on an OrderRepository interface — not on MySQLDatabase directly.
// ❌ BAD: high-level class hard-codes a low-level dependency public class OrderService_BAD { private MySQLDatabase db = new MySQLDatabase(); // impossible to swap public void saveOrder(Order o) { db.insert(o); } // untestable without a real DB } // ✅ GOOD: depend on an abstraction public interface OrderRepository { void save(Order order); Order findById(int id); void delete(int id); List<Order> findAll(); } public class OrderService { private final OrderRepository repo; // interface, not concrete class public OrderService(OrderRepository repo) { // INJECTED, not created here this.repo = repo; } public void placeOrder(Order order) { order.validate(); repo.save(order); System.out.println("Order placed: " + order); } public List<Order> getAllOrders() { return repo.findAll(); } } // Production: real MySQL implementation public class MySQLOrderRepository implements OrderRepository { @Override public void save(Order o) { /* MySQL INSERT */ } @Override public Order findById(int id) { return null; /* MySQL SELECT */ } @Override public void delete(int id) { /* MySQL DELETE */ } @Override public List<Order> findAll() { return new ArrayList<>(); } } // Tests: fast in-memory implementation — no DB required public class InMemoryOrderRepository implements OrderRepository { private final Map<Integer, Order> store = new HashMap<>(); @Override public void save(Order o) { store.put(o.getId(), o); } @Override public Order findById(int id) { return store.get(id); } @Override public void delete(int id) { store.remove(id); } @Override public List<Order> findAll() { return new ArrayList<>(store.values()); } } // Wiring — swap implementations with zero changes to OrderService public class Main { public static void main(String[] args) { // Production: OrderService prod = new OrderService(new MySQLOrderRepository()); // Tests: OrderService test = new OrderService(new InMemoryOrderRepository()); } }
In a unit test, inject InMemoryOrderRepository instead of a real database. Tests run in milliseconds with no setup. This is the foundation of testable architecture. Frameworks like Spring do this injection automatically — now you understand what they're actually doing under the hood.
Audit and Refactor a God Class
Start with a UserManager that: validates user input, hashes passwords, saves to database, sends welcome email, and logs everything. Identify every SOLID violation. Refactor into:
UserValidator— validates inputsPasswordHasher— hashing logicUserRepository(interface) +InMemoryUserRepositoryEmailService(interface) +SmtpEmailServiceUserService— orchestrates, depends only on interfaces via constructor injection
After refactoring: add a MockEmailService for tests that records sent emails without sending real ones.
Design Patterns
Design patterns are proven, named solutions to recurring problems. Knowing them lets you communicate in one word — "use a Builder," "that's Observer" — and avoid reinventing solutions that have been battle-tested for decades. This lesson is a working reference; the dedicated Design Patterns lesson covers all six in full depth, including three different ways to implement a Singleton correctly.
Builder
Construct complex objects step-by-step with many optional parameters. Fluent API. Private constructor.
Factory Method
Delegate object creation to subclasses or a factory. Hides instantiation complexity behind an interface.
Singleton
Ensure only one instance exists globally. Used for config managers, thread pools, loggers. Handle with care.
Strategy
Encapsulate a family of algorithms. Make them interchangeable at runtime without changing the context class.
Observer
One-to-many dependency: when one object changes state, all registered dependents are notified automatically.
Decorator
Wrap an object to add behavior dynamically. More flexible than subclassing — stack decorators like layers.
// Problem: constructors with 8+ params are unreadable and error-prone. // Builder gives a fluent, validated, immutable construction API. public class HttpRequest { private final String url; private final String method; private final Map<String,String> headers; private final String body; private final int timeoutMs; private HttpRequest(Builder b) { // private — only Builder can create url = b.url; method = b.method; headers = Collections.unmodifiableMap(b.headers); body = b.body; timeoutMs = b.timeoutMs; } public static class Builder { private final String url; // required private String method = "GET"; private final Map<String,String> headers = new HashMap<>(); private String body = null; private int timeoutMs = 5000; public Builder(String url) { this.url = url; } public Builder method(String m) { method = m; return this; } public Builder header(String k, String v) { headers.put(k,v); return this; } public Builder body(String b) { body = b; return this; } public Builder timeout(int ms) { timeoutMs = ms; return this; } public HttpRequest build() { if (url == null || url.isBlank()) throw new IllegalStateException("URL required"); return new HttpRequest(this); } } } // Fluent, readable usage — clearly shows what each argument IS HttpRequest req = new HttpRequest.Builder("https://api.example.com/orders") .method("POST") .header("Authorization", "Bearer token123") .header("Content-Type", "application/json") .body("{\"item\":\"book\",\"qty\":2}") .timeout(10_000) .build();
This lesson keeps one worked example (Builder) so you can see a real pattern in context. For the complete treatment of all six patterns — including a thread-safe Singleton and the modern enum-based Singleton — head to the dedicated Design Patterns lesson.
Plugin Architecture using Factory + Strategy
Build an image processing pipeline. Each filter (Grayscale, Blur, Sharpen, Resize, Brightness) is a Strategy (interface ImageFilter with apply(Image img)). A FilterFactory takes a string name and returns the right implementation — throwing IllegalArgumentException for unknown names. An ImageProcessor accepts a List<ImageFilter> and applies them in sequence. Registering a new filter requires zero changes to existing code — demonstrating OCP. Bonus: implement a PipelineBuilder using the Builder pattern to construct filter chains fluently.
Generics & Collections
Generics let you write type-safe classes and methods that work with any type. They're the engine behind ArrayList, HashMap, and every Java collection. Understanding them makes everything in the standard library click.
Without generics, you'd store everything in List<Object> and cast on every retrieval — error-prone and ugly. Generics give the compiler full type information: "this list holds only Strings" — so bugs surface at compile time, not runtime.
Generic Classes with Bounded Type Parameters
// Everything stored in the repository must have an ID public interface Identifiable { int getId(); } // T extends Identifiable — we can call getId() inside the class public class GenericRepository<T extends Identifiable> { private final Map<Integer, T> store = new HashMap<>(); public void save(T entity) { store.put(entity.getId(), entity); } public T findById(int id) { T result = store.get(id); if (result == null) throw new NoSuchElementException("Not found: " + id); return result; } public List<T> findAll() { return new ArrayList<>(store.values()); } public void delete(int id) { store.remove(id); } public int count() { return store.size(); } // Generic method — finds first entity matching a predicate public Optional<T> findFirst(Predicate<T> predicate) { return store.values().stream().filter(predicate).findFirst(); } } // Fully type-safe for any Identifiable — no casts, no Object[] GenericRepository<User> users = new GenericRepository<>(); GenericRepository<Product> products = new GenericRepository<>(); users.save(new User(1, "Alex")); User u = users.findById(1); // no cast needed! // users.save(new Product(...)); // COMPILE ERROR — wrong type
import java.util.*; import java.util.stream.*; public class CollectionsDemo { record Employee(String name, String dept, double salary) {} public static void main(String[] args) { List<Employee> employees = List.of( new Employee("Alice", "Eng", 120_000), new Employee("Bob", "Eng", 95_000), new Employee("Carol", "HR", 80_000), new Employee("Dave", "Eng", 110_000) ); // Filter + map + collect List<String> highEarners = employees.stream() .filter(e -> e.salary() > 100_000) .map(Employee::name) .sorted() .collect(Collectors.toList()); // [Alice, Dave] // Group by department Map<String, List<Employee>> byDept = employees.stream() .collect(Collectors.groupingBy(Employee::dept)); // {Eng: [Alice, Bob, Dave], HR: [Carol]} // Average salary by department Map<String, Double> avgSalary = employees.stream() .collect(Collectors.groupingBy( Employee::dept, Collectors.averagingDouble(Employee::salary) )); // {Eng: 108333.33, HR: 80000.0} // Find highest-paid engineer Optional<Employee> topEng = employees.stream() .filter(e -> e.dept().equals("Eng")) .max(Comparator.comparingDouble(Employee::salary)); topEng.ifPresent(e -> System.out.println("Top: " + e.name())); // Alice } }
| Collection | When to use | Key characteristic |
|---|---|---|
| ArrayList | Indexed access, iteration | O(1) get, O(n) insert middle |
| LinkedList | Frequent front/back insertions | O(1) add/remove at ends |
| HashMap | Key-value lookup | O(1) average get/put |
| LinkedHashMap | Key-value + insertion order | Predictable iteration order |
| TreeMap | Sorted key-value pairs | O(log n), keys sorted |
| HashSet | Unique elements, fast lookup | O(1) contains |
| TreeSet | Sorted unique elements | O(log n), auto-sorted |
| PriorityQueue | Always need min/max next | O(log n) poll, O(1) peek |
Build a Type-Safe Generic Event Bus
Create EventBus<T> with subscribe(Consumer<T> handler) and publish(T event). Create UserCreatedEvent and OrderPlacedEvent. Create two separate buses — one per type. Prove at compile time that a handler registered on the user bus cannot receive order events. Then extend it: publish(T event, Predicate<T> filter) that only delivers to handlers whose filter returns true. Use the Stream API internally. Bonus: make it thread-safe using CopyOnWriteArrayList for the observer list.
Capstone: Build a Trading Platform
This multi-class engineering project integrates every concept from the course into a coherent real-world system. Design decisions are yours — there's no single right answer.
You're building the core engine of a stock trading platform. Users open accounts, place buy/sell orders (market, limit, stop-loss), and receive notifications when orders execute. Stock prices change and trigger alerts. An order book matches buyers to sellers.
User, Account, Order, Stock
Encapsulated, immutable IDs, validation in constructors, equals/hashCode/toString.
Order Types
Abstract Order → MarketOrder, LimitOrder, StopLossOrder. Each executes differently.
Capability Contracts
Executable, Cancellable, Notifiable. Not all order types implement all interfaces.
Services
OrderService, PriceService, NotificationService. Single responsibility, injected via interface.
Design Patterns Used
Builder (Order construction), Observer (price alerts), Factory (order types), Strategy (execution logic).
Type-Safe Collections
Generic Repository<T>. PriorityQueue for order book. Stream API throughout.
public abstract class Order implements Identifiable, Comparable<Order> { public enum Side { BUY, SELL } public enum Status { PENDING, PARTIALLY_FILLED, FILLED, CANCELLED } private static int nextId = 1000; private final int id; private final String ticker; private final Side side; private final int quantity; private int filledQty = 0; private Status status = Status.PENDING; private final long createdAt; protected Order(String ticker, Side side, int quantity) { if (quantity <= 0) throw new IllegalArgumentException("Quantity must be positive"); this.id = nextId++; this.ticker = ticker; this.side = side; this.quantity = quantity; this.createdAt = System.currentTimeMillis(); } // Subclasses define their own execution condition public abstract boolean canExecuteAt(double marketPrice); public abstract String getOrderType(); public void fill(int qty) { if (status == Status.CANCELLED) throw new IllegalStateException("Can't fill cancelled order"); filledQty += qty; status = (filledQty >= quantity) ? Status.FILLED : Status.PARTIALLY_FILLED; } public void cancel() { if (status == Status.FILLED) throw new IllegalStateException("Can't cancel filled order"); status = Status.CANCELLED; } @Override public int compareTo(Order other) { // FIFO by default — override in subclasses for price priority return Long.compare(this.createdAt, other.createdAt); } @Override public int getId() { return id; } public String getTicker() { return ticker; } public Side getSide() { return side; } public int getQuantity() { return quantity; } public int getFilledQty() { return filledQty; } public Status getStatus() { return status; } @Override public String toString() { return String.format("[#%d %s %s %dx %s | %s]", id, getOrderType(), side, quantity, ticker, status); } } // ─── Provided implementations ────────────────────────── public class MarketOrder extends Order { public MarketOrder(String ticker, Side side, int qty) { super(ticker, side, qty); } @Override public boolean canExecuteAt(double p) { return true; } // always fills @Override public String getOrderType() { return "MARKET"; } } public class LimitOrder extends Order { private final double limitPrice; public LimitOrder(String ticker, Side side, int qty, double limitPrice) { super(ticker, side, qty); this.limitPrice = limitPrice; } @Override public boolean canExecuteAt(double price) { return getSide() == Side.BUY ? price <= limitPrice : getSide() == Side.SELL ? price >= limitPrice : false; } @Override public String getOrderType() { return "LIMIT@$" + limitPrice; } } // ─── YOUR TASK ──────────────────────────────────────────── // 1. Implement StopLossOrder — executes when price DROPS BELOW trigger // 2. Implement OrderBook using PriorityQueue — matches buys to sells // 3. Implement a Builder for LimitOrder construction // 4. Apply Observer pattern: Stock notifies OrderBook on price changes // 5. Build OrderService with injected GenericRepository<Order>
| Evaluation Criterion | What to Look For |
|---|---|
| Encapsulation | No public mutable fields; all state changes through validated methods |
| Inheritance | is-a relationships only; super() used correctly; no duplicated logic |
| Polymorphism | Order engine iterates over Order references — works with any subtype added later |
| SOLID — SRP | Each service class has exactly one job; no god classes |
| SOLID — OCP | Adding StopLossOrder requires zero changes to OrderBook/OrderService |
| SOLID — DIP | All services injected via constructor; none create their own dependencies |
| Builder Pattern | LimitOrder.Builder with fluent API and validation in build() |
| Observer Pattern | Stock notifies subscribers; OrderBook subscribes to trigger executions |
| Generics + Collections | GenericRepository<T> used; PriorityQueue for order book; Streams for queries |
With a strong OOP and design-patterns foundation, your next milestones are: Exception Hierarchies (custom checked/unchecked exceptions), Concurrency (thread-safe objects, locks, synchronized, ExecutorService), Functional Java (lambdas, method references, Optional in depth), Unit Testing with JUnit 5 (your SOLID-compliant code is now fully testable), and Spring Framework — which is built entirely on DIP and the patterns you now understand.