DOCODIVE
Intermediate Free Learning Path

Java Intermediate Course

Level up your Java with 30 intermediate lessons. Deep-dive into OOP, inheritance, polymorphism, interfaces, exceptions, generics, and collections — each with code, exact output, and official Oracle documentation links.

4–6 weeks 30 lessons 3 projects 1 capstone Beginner knowledge required
Start Learning
01

OOP Review & Method Overloading

Method overloading means defining multiple methods with the same name but different parameter lists. Java picks the right one based on the number and type of arguments. Return type alone cannot distinguish overloaded methods.

Java
public class Main {
    static int add(int a, int b) {
        return a + b;
    }

    static double add(double a, double b) {
        return a + b;
    }

    public static void main(String[] args) {
        System.out.println(add(5, 3));
        System.out.println(add(5.5, 3.2));
    }
}
Two add methods exist: one for ints, one for doubles. Java selects the int version when both arguments are ints, and the double version when they are doubles. Overloading works because the parameter lists differ.
terminal
8 8.7
The first call matched the int version (5 + 3 = 8). The second call matched the double version (5.5 + 3.2 = 8.7). Overloading let one method name handle both types.
02

Method Overriding & @Override

Overriding happens when a subclass redefines a method from its parent. The method signature stays identical, but the behavior changes. The @Override annotation tells the compiler to verify that you are truly overriding.

Java
class Animal {
    void sound() {
        System.out.println("Animal makes a sound");
    }
}

class Dog extends Animal {
    @Override
    void sound() {
        System.out.println("Dog barks");
    }
}

public class Main {
    public static void main(String[] args) {
        Animal a = new Animal();
        a.sound();
        Animal d = new Dog();
        d.sound();
    }
}
Animal defines sound(). Dog extends Animal and overrides sound() with its own version. The @Override annotation confirms this. When d (typed Animal but actually a Dog) calls sound(), the Dog version runs because of dynamic dispatch.
terminal
Animal makes a sound Dog barks
The first call used the Animal version. The second call used the Dog version even though the variable type was Animal — this is runtime polymorphism. Overriding lets subclasses customize inherited behavior.
03

Inheritance & extends

Inheritance lets one class acquire the fields and methods of another. The extends keyword establishes the parent-child relationship. Java supports single inheritance — a class can extend only one parent.

Java
class Vehicle {
    String brand = "Generic";

    void start() {
        System.out.println("Vehicle started");
    }
}

class Car extends Vehicle {
    void honk() {
        System.out.println("Beep beep");
    }
}

public class Main {
    public static void main(String[] args) {
        Car c = new Car();
        System.out.println(c.brand);
        c.start();
        c.honk();
    }
}
Car extends Vehicle, inheriting the brand field and start() method. Car adds its own honk() method. The Car object can use all three members — inherited and own.
terminal
Generic Vehicle started Beep beep
The Car object accessed the inherited brand and start(), plus its own honk(). This shows inheritance reusing parent code while extending it.
04

super Keyword

super refers to the parent class. Use super() to call the parent constructor, and super.method() to call an overridden parent method. This lets a subclass extend rather than replace parent behavior.

Java
class Person {
    String name;

    Person(String name) {
        this.name = name;
    }

    void print() {
        System.out.println("Name: " + name);
    }
}

class Student extends Person {
    int grade;

    Student(String name, int grade) {
        super(name);
        this.grade = grade;
    }

    @Override
    void print() {
        super.print();
        System.out.println("Grade: " + grade);
    }
}

public class Main {
    public static void main(String[] args) {
        Student s = new Student("Ali", 10);
        s.print();
    }
}
Student's constructor calls super(name) to initialize the parent's name field. Student's print() first calls super.print() to print the name, then adds the grade. This chains parent behavior with child extension.
terminal
Name: Ali Grade: 10
super(name) set the inherited name field. super.print() ran the parent version first, then the child added the grade line. This is how you build on parent behavior.
05

Abstract Classes

An abstract class cannot be instantiated — it exists to be extended. It can have abstract methods (no body) that subclasses must implement, plus concrete methods with code. Abstract classes provide a common template.

Java
abstract class Shape {
    abstract double area();

    void print() {
        System.out.println("This is a shape");
    }
}

class Circle extends Shape {
    double radius;

    Circle(double r) {
        radius = r;
    }

    @Override
    double area() {
        return 3.14 * radius * radius;
    }
}

public class Main {
    public static void main(String[] args) {
        Circle c = new Circle(2);
        System.out.println(c.area());
        c.print();
    }
}
Shape is abstract with an abstract area() method and a concrete print() method. Circle extends Shape and must implement area(). The Circle object can call both its implemented area() and the inherited concrete print().
terminal
12.56 This is a shape
area() was the abstract method that Circle implemented (3.14 × 2 × 2 = 12.56). print() was inherited concrete behavior. Abstract classes mix enforced contracts with shared implementation.
06

Interfaces

An interface is a contract that defines method signatures with no bodies. A class implements an interface and must provide all the methods. Interfaces enable polymorphism across unrelated class hierarchies.

Java
interface Drawable {
    void draw();
}

class Circle implements Drawable {
    public void draw() {
        System.out.println("Drawing a circle");
    }
}

class Square implements Drawable {
    public void draw() {
        System.out.println("Drawing a square");
    }
}

public class Main {
    public static void main(String[] args) {
        Drawable d1 = new Circle();
        Drawable d2 = new Square();
        d1.draw();
        d2.draw();
    }
}
Drawable declares one abstract method draw(). Circle and Square both implement Drawable and provide their own draw(). Both objects are stored as Drawable, and each calls its own draw() at runtime.
terminal
Drawing a circle Drawing a square
Both classes fulfilled the Drawable contract with different implementations. Polymorphism let the same method call produce different results depending on the actual object type.
07

Multiple Interfaces

A class can implement multiple interfaces, giving it multiple types. This is Java's answer to multiple inheritance — a class can inherit behavior from one parent but implement many contracts.

Java
interface Flyable {
    void fly();
}

interface Swimmable {
    void swim();
}

class Duck implements Flyable, Swimmable {
    public void fly() {
        System.out.println("Duck flies");
    }

    public void swim() {
        System.out.println("Duck swims");
    }
}

public class Main {
    public static void main(String[] args) {
        Duck d = new Duck();
        d.fly();
        d.swim();
    }
}
Duck implements two interfaces, Flyable and Swimmable, and must provide both fly() and swim(). The Duck object can be treated as either type and can call both methods.
terminal
Duck flies Duck swims
One class satisfied two contracts. This is how Java achieves multiple-type behavior without the dangers of multiple inheritance.
08

Polymorphism

Polymorphism means 'many forms'. A parent-type reference can point to any child object, and method calls are resolved at runtime based on the actual object type. This lets one line of code work with many object types.

Java
class Animal {
    void speak() {
        System.out.println("Some sound");
    }
}

class Cat extends Animal {
    @Override
    void speak() {
        System.out.println("Meow");
    }
}

class Cow extends Animal {
    @Override
    void speak() {
        System.out.println("Moo");
    }
}

public class Main {
    public static void main(String[] args) {
        Animal[] animals = { new Cat(), new Cow(), new Animal() };
        for (Animal a : animals) {
            a.speak();
        }
    }
}
An Animal array holds three different actual types: Cat, Cow, and Animal. The for-each loop calls speak() on each. At runtime, Java dispatches to the correct override based on the actual object.
terminal
Meow Moo Some sound
The same speak() call produced three different outputs because the actual object types differed. Runtime polymorphism is what makes this possible.
09

Encapsulation Deep Dive

Encapsulation bundles data (fields) with the methods that operate on them, and hides internal state using private access. Data is accessed only through controlled public methods, protecting integrity and reducing coupling.

Java
class Account {
    private double balance = 0;

    void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
        }
    }

    void withdraw(double amount) {
        if (amount > 0 && amount <= balance) {
            balance -= amount;
        }
    }

    double getBalance() {
        return balance;
    }
}

public class Main {
    public static void main(String[] args) {
        Account a = new Account();
        a.deposit(100);
        a.withdraw(40);
        a.withdraw(100);
        System.out.println(a.getBalance());
    }
}
balance is private, so external code cannot touch it directly. deposit() validates positive amounts, withdraw() validates positive and sufficient funds. The invalid withdraw(100) is rejected silently. getBalance() returns the safe value.
terminal
60.0
Deposit added 100, valid withdraw removed 40 (100 - 40 = 60), and the invalid withdraw of 100 was ignored because it exceeded the balance. Encapsulation protected the data from invalid states.
10

Packages & Imports

Packages group related classes and prevent name conflicts. The package statement declares the package, and import brings classes from other packages into scope. The fully qualified name includes the package path.

Java
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        names.add("Ali");
        names.add("Sara");
        System.out.println(names);
    }
}
import java.util.ArrayList and import java.util.List make those types available by their simple names. Without imports, you would write java.util.ArrayList every time. The List interface is used with ArrayList as its implementation.
terminal
[Ali, Sara]
The imported classes worked directly by name. The List printed its elements in insertion order. Packages and imports keep code organized across large projects.
11

Exception Handling (try/catch)

Exceptions are runtime errors that disrupt normal flow. try/catch handles them gracefully: code in try is monitored, and catch handles specific exception types. Without handling, the program would crash.

Java
public class Main {
    public static void main(String[] args) {
        try {
            int result = 10 / 0;
            System.out.println(result);
        } catch (ArithmeticException e) {
            System.out.println("Error: " + e.getMessage());
        }
        System.out.println("Program continues");
    }
}
The try block attempts division by zero, which throws ArithmeticException. The catch block catches that exact type and prints the error message. After catch, the program continues normally instead of crashing.
terminal
Error: / by zero Program continues
The exception was caught, so the crash was prevented. The message '/ by zero' came from the exception object, and the line after try/catch still ran. This is graceful error handling.
12

finally & try-with-resources

finally always executes whether an exception occurred or not — ideal for cleanup. try-with-resources automatically closes resources like files and scanners, removing the need for manual close() calls.

Java
public class Main {
    public static void main(String[] args) {
        try {
            System.out.println("Inside try");
            int x = 5 / 1;
            System.out.println(x);
        } finally {
            System.out.println("Inside finally");
        }
        System.out.println("Done");
    }
}
The try block runs normally. finally runs after try regardless of success or failure. Here no exception occurred, but finally still executed. Then the final print ran.
terminal
Inside try 5 Inside finally Done
finally ran after the try completed successfully. If the try had thrown, finally would still have run. This guarantee makes finally the right place for cleanup.
13

throw & throws

throw creates an exception inside a method. throws declares that a method may throw a checked exception, pushing the responsibility to the caller. Together they let you propagate errors up the call stack.

Java
public class Main {
    static void checkAge(int age) throws IllegalArgumentException {
        if (age < 18) {
            throw new IllegalArgumentException("Too young");
        }
        System.out.println("Age valid");
    }

    public static void main(String[] args) {
        try {
            checkAge(15);
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
        }
    }
}
checkAge declares throws IllegalArgumentException. When age is under 18, it throws a new IllegalArgumentException. The caller wraps the call in try/catch to handle the thrown exception.
terminal
Too young
checkAge(15) threw the exception, which propagated to main's catch block. The message 'Too young' was printed. throw creates the error, throws declares it, and catch handles it.
14

Custom Exceptions

You can create your own exception classes by extending Exception (checked) or RuntimeException (unchecked). Custom exceptions make errors meaningful and domain-specific.

Java
class InsufficientFundsException extends Exception {
    InsufficientFundsException(String message) {
        super(message);
    }
}

public class Main {
    static void withdraw(double balance, double amount) throws InsufficientFundsException {
        if (amount > balance) {
            throw new InsufficientFundsException("Not enough money");
        }
        System.out.println("Withdrawn: " + amount);
    }

    public static void main(String[] args) {
        try {
            withdraw(50, 100);
        } catch (InsufficientFundsException e) {
            System.out.println("Caught: " + e.getMessage());
        }
    }
}
InsufficientFundsException extends Exception, making it a checked exception. withdraw throws it when the amount exceeds balance. main catches the custom type and prints its message.
terminal
Caught: Not enough money
The custom exception carried a domain-specific message. Using a named exception type makes the error clear and lets callers catch specifically that case.
15

Generics Basics

Generics let classes and methods work with any type while keeping type safety. A type parameter like <T> is replaced with a concrete type at compile time, preventing ClassCastException at runtime.

Java
class Box<T> {
    private T value;

    void set(T v) {
        value = v;
    }

    T get() {
        return value;
    }
}

public class Main {
    public static void main(String[] args) {
        Box<String> stringBox = new Box<>();
        stringBox.set("Hello");
        System.out.println(stringBox.get());

        Box<Integer> intBox = new Box<>();
        intBox.set(42);
        System.out.println(intBox.get());
    }
}
Box<T> is a generic class with a type parameter T. Box<String> stores strings; Box<Integer> stores integers. The same class works for both types with full compile-time safety.
terminal
Hello 42
One generic class handled two different types. The compiler enforced that only Strings go into stringBox and only Integers into intBox. This is type safety without duplication.
16

Generic Classes

A generic class declares one or more type parameters. You can use multiple parameters, like <K, V> for key-value pairs. This is the basis of Java's collection framework.

Java
class Pair<K, V> {
    private K key;
    private V value;

    Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    K getKey() {
        return key;
    }

    V getValue() {
        return value;
    }
}

public class Main {
    public static void main(String[] args) {
        Pair<String, Integer> p = new Pair<>("Age", 25);
        System.out.println(p.getKey() + ": " + p.getValue());
    }
}
Pair has two type parameters, K and V. The constructor stores them, and getters return them. Pair<String, Integer> creates a pair of a string key and an integer value.
terminal
Age: 25
The generic Pair class stored a String and an Integer safely. Two type parameters let one class represent many different key-value structures.
17

Generic Methods

Methods can have their own type parameters, independent of the class. The type parameter is declared before the return type, like <T> T method(T input). This makes a single method work with any type.

Java
public class Main {
    static <T> void printArray(T[] array) {
        for (T item : array) {
            System.out.println(item);
        }
    }

    public static void main(String[] args) {
        String[] words = { "A", "B" };
        Integer[] nums = { 1, 2 };
        printArray(words);
        printArray(nums);
    }
}
printArray is a generic method with type parameter <T>. It works on any array type. It is called once with a String array and once with an Integer array. The compiler infers T in each call.
terminal
A B 1 2
One generic method printed both a String array and an Integer array. Type inference removed the need for overloaded methods.
18

Collections Framework Overview

The Collections Framework is a set of interfaces and classes for storing and processing groups of objects. The main interfaces are List (ordered), Set (no duplicates), and Map (key-value). ArrayList, HashSet, and HashMap are common implementations.

Java
import java.util.ArrayList;
import java.util.HashSet;
import java.util.HashMap;
import java.util.List;
import java.util.Set;
import java.util.Map;

public class Main {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("A");
        list.add("A");

        Set<String> set = new HashSet<>();
        set.add("A");
        set.add("A");

        Map<String, Integer> map = new HashMap<>();
        map.put("A", 1);

        System.out.println("List: " + list);
        System.out.println("Set: " + set);
        System.out.println("Map: " + map);
    }
}
List keeps order and allows duplicates, so 'A' appears twice. Set removes duplicates, so 'A' appears once. Map stores key-value pairs. All three print their contents.
terminal
List: [A, A] Set: [A] Map: {A=1}
The three collection types behaved differently: List kept both A's, Set kept one, Map paired A with 1. Choosing the right collection depends on your data needs.
19

List & ArrayList Deep Dive

ArrayList is a resizable array implementation of List. It provides indexed access, so get(index) is fast. Common methods include add, get, set, remove, size, and contains. It is the most-used List implementation.

Java
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> items = new ArrayList<>();
        items.add("Apple");
        items.add("Banana");
        items.add("Mango");

        items.remove(1);
        items.set(1, "Orange");

        System.out.println(items.size());
        System.out.println(items.get(0));
        System.out.println(items.contains("Apple"));
        System.out.println(items);
    }
}
add() appends three items. remove(1) deletes Banana. set(1, "Orange") replaces Mango with Orange. size() returns 2, get(0) returns Apple, contains checks existence, and the list prints its final state.
terminal
2 Apple true [Apple, Orange]
After remove and set, the list held Apple and Orange. size was 2, Apple existed, and the final list showed the two remaining items. ArrayList makes index-based operations easy.
20

LinkedList

LinkedList stores elements as nodes linked in both directions. Insertions and deletions in the middle are faster than ArrayList, but random access get(index) is slower. It also implements Deque, so it can act as a queue.

Java
import java.util.LinkedList;

public class Main {
    public static void main(String[] args) {
        LinkedList<String> queue = new LinkedList<>();
        queue.add("First");
        queue.add("Second");
        queue.add("Third");

        System.out.println(queue.poll());
        System.out.println(queue.peek());
        System.out.println(queue);
    }
}
add() appends three elements. poll() removes and returns the head (First). peek() returns but does not remove the new head (Second). The list prints its remaining elements.
terminal
First Second [Second, Third]
poll removed First, peek looked at Second without removing it, and the final list held Second and Third. LinkedList's queue methods make FIFO behavior simple.
21

Set & HashSet

Set is a collection with no duplicate elements. HashSet is the most common implementation, backed by a hash table. It offers O(1) average time for add, remove, and contains, and does not guarantee order.

Java
import java.util.HashSet;
import java.util.Set;

public class Main {
    public static void main(String[] args) {
        Set<String> names = new HashSet<>();
        names.add("Ali");
        names.add("Sara");
        names.add("Ali");

        System.out.println(names.size());
        System.out.println(names.contains("Sara"));
        System.out.println(names);
    }
}
Three add calls are made, but 'Ali' is added twice. HashSet ignores the duplicate, so size is 2. contains checks for Sara (true). The set prints its unique elements in arbitrary order.
terminal
2 true [Sara, Ali]
The duplicate Ali was automatically removed, leaving two unique values. HashSet's fast lookups and deduplication make it ideal when uniqueness matters.
22

Map & HashMap

Map stores key-value pairs. HashMap is the standard implementation, using hashing for fast lookups. Keys must be unique; putting a new value for an existing key overwrites the old one.

Java
import java.util.HashMap;
import java.util.Map;

public class Main {
    public static void main(String[] args) {
        Map<String, Integer> grades = new HashMap<>();
        grades.put("Ali", 85);
        grades.put("Sara", 92);
        grades.put("Ali", 90);

        System.out.println(grades.get("Ali"));
        System.out.println(grades.containsKey("Sara"));
        System.out.println(grades);
    }
}
put() inserts pairs. Putting 'Ali' twice overwrites the first value with 90. get retrieves a value by key. containsKey checks existence. The map prints all pairs.
terminal
90 true {Sara=92, Ali=90}
The second put for Ali replaced 85 with 90. Lookup by key returned 90. HashMap provides fast key-based retrieval.
23

Iterators

An Iterator steps through a collection element by element. hasNext() checks for more elements, next() returns the next one, and remove() safely deletes the current element during iteration.

Java
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> items = new ArrayList<>();
        items.add("A");
        items.add("B");
        items.add("C");

        Iterator<String> it = items.iterator();
        while (it.hasNext()) {
            String s = it.next();
            if (s.equals("B")) {
                it.remove();
            }
        }
        System.out.println(items);
    }
}
iterator() returns an Iterator. The while loop calls hasNext() to check for elements and next() to get each one. When 'B' is found, it.remove() deletes it safely. The list prints the remaining elements.
terminal
[A, C]
B was removed during iteration without causing a ConcurrentModificationException. Using it.remove() instead of list.remove() is the safe way to delete during iteration.
24

Comparable & Comparator

Comparable defines a class's natural order with compareTo(). Comparator defines an external order with compare(). Use Comparable for one natural sort, and Comparator for multiple custom sorts without changing the class.

Java
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

class Student {
    String name;
    int age;

    Student(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String toString() {
        return name + "(" + age + ")";
    }
}

public class Main {
    public static void main(String[] args) {
        List<Student> students = new ArrayList<>();
        students.add(new Student("Ali", 25));
        students.add(new Student("Sara", 22));
        students.add(new Student("Zain", 28));

        Collections.sort(students, Comparator.comparingInt(s -> s.age));

        for (Student s : students) {
            System.out.println(s);
        }
    }
}
Student has name and age plus a toString method. Comparator.comparingInt sorts by age ascending. Collections.sort applies that comparator. The sorted list is printed.
terminal
Sara(22) Ali(25) Zain(28)
Students were sorted by age from youngest to oldest. The Comparator defined the sort order externally, so Student did not need to implement Comparable.
25

String vs StringBuilder vs StringBuffer

String is immutable — every change creates a new object. StringBuilder is mutable and faster for many modifications, but not thread-safe. StringBuffer is mutable and thread-safe but slower. For loops building text, use StringBuilder.

Java
public class Main {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        sb.append("Hello");
        sb.append(" ");
        sb.append("World");

        System.out.println(sb.toString());
        System.out.println(sb.reverse());
    }
}
StringBuilder starts empty. Three append() calls build the text efficiently in one object. toString() returns the final string. reverse() returns the reversed text.
terminal
Hello World dlroW olleH
Appending mutated the same StringBuilder instead of creating new String objects. Reverse produced the reversed text. For repeated string building, StringBuilder avoids unnecessary allocation.
26

Autoboxing & Unboxing

Autoboxing automatically converts a primitive to its wrapper (int to Integer). Unboxing converts a wrapper back to a primitive (Integer to int). Java does this silently in assignments and method calls.

Java
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Integer> nums = new ArrayList<>();
        nums.add(10);
        nums.add(20);

        int sum = 0;
        for (Integer n : nums) {
            sum += n;
        }
        System.out.println(sum);
    }
}
nums.add(10) autoboxes the primitive int into an Integer. The for-each loop unboxes each Integer back to int when added to sum. This conversion is automatic and invisible.
terminal
30
Primitives flowed into and out of the Integer list seamlessly. Autoboxing and unboxing bridge the gap between primitives and collections that require objects.
27

Varargs (Variable Arguments)

Varargs let a method accept zero or more arguments of the same type using ... syntax. Internally, the arguments are passed as an array. A method can have only one varargs parameter, and it must be last.

Java
public class Main {
    static int sum(int... nums) {
        int total = 0;
        for (int n : nums) {
            total += n;
        }
        return total;
    }

    public static void main(String[] args) {
        System.out.println(sum(1, 2, 3));
        System.out.println(sum(10, 20));
        System.out.println(sum());
    }
}
sum accepts any number of ints via int... nums. Inside, nums is treated as an array. The for-each loop adds every value. Three calls pass different argument counts, including zero.
terminal
6 30 0
sum handled three, two, and zero arguments. Varargs made the method flexible without requiring overloads for every count.
28

Static Blocks & Initializers

A static block runs once when the class is first loaded, before any object is created. It is used to initialize static fields. Instance initializer blocks run before constructors for each new object.

Java
public class Main {
    static int staticValue;

    static {
        staticValue = 42;
        System.out.println("Static block ran");
    }

    public static void main(String[] args) {
        System.out.println("Static value: " + staticValue);
    }
}
The static block sets staticValue to 42 and prints a message. It runs exactly once when the class loads. main then reads and prints the initialized static value.
terminal
Static block ran Static value: 42
The static block ran before main, initializing the static field. This is useful for one-time setup like loading config or initializing complex static data.
29

Inner Classes

An inner class is a class defined inside another class. Non-static inner classes can access the outer class's members, including private ones. Static nested classes behave like top-level classes but are grouped logically.

Java
class Outer {
    private String message = "Hello";

    class Inner {
        void print() {
            System.out.println(message);
        }
    }
}

public class Main {
    public static void main(String[] args) {
        Outer outer = new Outer();
        Outer.Inner inner = outer.new Inner();
        inner.print();
    }
}
Outer has a private message field and an Inner class. Inner can access the private message directly. main creates an Outer, then creates an Inner bound to it using outer.new Inner(), and calls print().
terminal
Hello
The inner class read the outer class's private field. Inner classes are tightly coupled to their outer instance and can reach its private state.
30

Intermediate Recap + Projects

You now know overloading, overriding, inheritance, super, abstract classes, interfaces, polymorphism, encapsulation, packages, exceptions, generics, collections (List, Set, Map), iterators, Comparable/Comparator, StringBuilder, autoboxing, varargs, static blocks, and inner classes. Apply them in projects combining multiple concepts.

Java
import java.util.ArrayList;
import java.util.List;

class Task {
    String name;
    boolean done;

    Task(String name) {
        this.name = name;
    }

    public String toString() {
        return (done ? "[x] " : "[ ] ") + name;
    }
}

public class Main {
    public static void main(String[] args) {
        List<Task> tasks = new ArrayList<>();
        tasks.add(new Task("Learn Java"));
        tasks.add(new Task("Build project"));
        tasks.get(0).done = true;

        for (Task t : tasks) {
            System.out.println(t);
        }
    }
}
Task holds a name and a done flag. toString prints a checkbox style. ArrayList stores Task objects. The first task is marked done. The loop prints each task using toString.
terminal
[x] Learn Java [ ] Build project
The first task shows [x] (done) and the second shows [ ] (pending). This combines classes, ArrayList, loops, and custom toString — a foundation for the To-Do List project.

Java Intermediate Projects

Apply OOP, collections, exceptions, and generics. Click each project to open its full guide.

About this project

Build a menu-driven to-do list that can add, view, mark complete, and remove tasks. Tasks are stored in an ArrayList and managed with methods.

What you'll practice
ArrayList classes switch loops Scanner
Step-by-step: How it works
  1. A Task class holds the name and done flag.
  2. An ArrayList<Task> stores all tasks.
  3. A switch menu lets the user choose an action.
  4. Option 1 adds a task, option 2 lists tasks, option 3 marks a task done.
  5. Option 4 removes a task, option 5 exits the loop.
  6. A while loop keeps the menu running until exit.
Java
import java.util.ArrayList;
import java.util.Scanner;

class Task {
    String name;
    boolean done;

    Task(String name) {
        this.name = name;
    }

    public String toString() {
        return (done ? "[x] " : "[ ] ") + name;
    }
}

public class ToDoList {
    public static void main(String[] args) {
        ArrayList tasks = new ArrayList<>();
        Scanner sc = new Scanner(System.in);
        int choice;

        do {
            System.out.println("\n1. Add task");
            System.out.println("2. View tasks");
            System.out.println("3. Mark done");
            System.out.println("4. Remove task");
            System.out.println("5. Exit");
            System.out.print("Choose: ");
            choice = sc.nextInt();
            sc.nextLine();

            switch (choice) {
                case 1:
                    System.out.print("Task name: ");
                    tasks.add(new Task(sc.nextLine()));
                    break;
                case 2:
                    for (int i = 0; i < tasks.size(); i++) {
                        System.out.println(i + ". " + tasks.get(i));
                    }
                    break;
                case 3:
                    System.out.print("Task number to mark done: ");
                    int doneIndex = sc.nextInt();
                    if (doneIndex >= 0 && doneIndex < tasks.size()) {
                        tasks.get(doneIndex).done = true;
                    }
                    break;
                case 4:
                    System.out.print("Task number to remove: ");
                    int removeIndex = sc.nextInt();
                    if (removeIndex >= 0 && removeIndex < tasks.size()) {
                        tasks.remove(removeIndex);
                    }
                    break;
            }
        } while (choice != 5);

        sc.close();
    }
}
Sample Run
terminal
1. Add task 2. View tasks 3. Mark done 4. Remove task 5. Exit Choose: 1 Task name: Learn Java 1. Add task 2. View tasks 3. Mark done 4. Remove task 5. Exit Choose: 2 0. [ ] Learn Java

About this project

Build a bank account with deposit, withdraw, and balance check — using encapsulation, private fields, and custom exceptions for insufficient funds.

What you'll practice
encapsulation custom exception private fields methods Scanner
Step-by-step: How it works
  1. Account has a private balance field.
  2. deposit() adds money (rejects negatives).
  3. withdraw() checks funds and throws InsufficientFundsException if short.
  4. getBalance() returns the current balance.
  5. The main method uses try/catch to handle the custom exception.
Java
class InsufficientFundsException extends Exception {
    InsufficientFundsException(String message) {
        super(message);
    }
}

class Account {
    private double balance = 0;

    void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
            System.out.println("Deposited: " + amount);
        }
    }

    void withdraw(double amount) throws InsufficientFundsException {
        if (amount > balance) {
            throw new InsufficientFundsException("Insufficient funds!");
        }
        balance -= amount;
        System.out.println("Withdrawn: " + amount);
    }

    double getBalance() {
        return balance;
    }
}

public class BankManager {
    public static void main(String[] args) {
        Account acc = new Account();
        acc.deposit(100);

        try {
            acc.withdraw(40);
            acc.withdraw(100);
        } catch (InsufficientFundsException e) {
            System.out.println("Error: " + e.getMessage());
        }

        System.out.println("Final balance: " + acc.getBalance());
    }
}
Sample Run
terminal
Deposited: 100.0 Withdrawn: 40.0 Error: Insufficient funds! Final balance: 60.0

About this project

Count how many times each word appears in a sentence using a HashMap. This demonstrates the power of Map for counting and fast lookups.

What you'll practice
HashMap split() for-each loop getOrDefault() Map.Entry
Step-by-step: How it works
  1. The sentence is split into words using split(" ").
  2. A HashMap<String, Integer> stores word counts.
  3. For each word, getOrDefault() fetches the current count (or 0).
  4. The count is incremented and put back.
  5. A for-each over entrySet() prints each word and count.
Java
import java.util.HashMap;
import java.util.Map;

public class WordCounter {
    public static void main(String[] args) {
        String sentence = "the cat and the dog";
        String[] words = sentence.split(" ");

        Map counts = new HashMap<>();

        for (String word : words) {
            int current = counts.getOrDefault(word, 0);
            counts.put(word, current + 1);
        }

        for (Map.Entry entry : counts.entrySet()) {
            System.out.println(entry.getKey() + ": " + entry.getValue());
        }
    }
}
Sample Run
terminal
the: 2 cat: 1 and: 1 dog: 1

Intermediate Capstone Project

Combine OOP, inheritance, interfaces, collections, and exceptions into one complete project.

About this project

Build a simple library system with an abstract Item class, a Book subclass, and an ArrayList catalog. It supports adding books and listing all items using polymorphism.

What you'll practice
abstract classes inheritance polymorphism ArrayList overriding
Step-by-step: How it works
  1. An abstract Item class defines title and an abstract info().
  2. Book extends Item, adds an author, and overrides info().
  3. An ArrayList<Item> stores books.
  4. Adding uses add().
  5. Listing loops through and calls info(), which dispatches to Book's version at runtime.
Java
import java.util.ArrayList;
import java.util.List;

abstract class Item {
    String title;

    Item(String title) {
        this.title = title;
    }

    abstract void info();
}

class Book extends Item {
    String author;

    Book(String title, String author) {
        super(title);
        this.author = author;
    }

    @Override
    void info() {
        System.out.println("Book: " + title + " by " + author);
    }
}

public class Library {
    public static void main(String[] args) {
        List catalog = new ArrayList<>();

        catalog.add(new Book("Effective Java", "Joshua Bloch"));
        catalog.add(new Book("Clean Code", "Robert Martin"));

        for (Item item : catalog) {
            item.info();
        }
    }
}
Sample Run
terminal
Book: Effective Java by Joshua Bloch Book: Clean Code by Robert Martin
You've completed all 30 lessons. Ready to continue?

Continue to Java Advanced and Practice resources.

📱 Scan this QR code with your phone camera to instantly open this page.

Works on iOS, Android, and any modern device. No app installation required.

Account Verified!

Your email has been verified successfully.