Back to Java: Notes from a Developer Returning to the Language
Why Return to Java?
Java never really went away. It just got quieter in my daily work as I moved toward JavaScript, TypeScript, and frontend-leaning stacks. But modern Java especially post-JDK 17 is a genuinely different language from what I remember. Records, sealed classes, pattern matching, virtual threads. It felt worth a proper revisit rather than pretending I knew it.
These are rough notes from the first few days getting back up to speed.
Day 1 Tooling and Hello World
The starting point was obvious: install JDK 21 and get something running.
# Verify the installation
java --version
javac --version
I used SDKMAN to manage the JDK install, which made switching versions trivial later. For the editor I went with IntelliJ IDEA Community for Java.
The classic first program:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
Two commands to understand from the start:
javac HelloWorld.javacompiles source to bytecode (HelloWorld.class)java HelloWorldruns the bytecode on the JVM
The distinction matters: Java ships bytecode, not native binaries. The JVM interprets (and JIT-compiles) that bytecode at runtime. This is still the foundation of why Java runs everywhere.
Day 2 Primitive Types, Casting, and the Memory Model
Java has 8 primitive types. The ones that come up constantly:
| Type | Size | Default | Notes |
|---|---|---|---|
int | 32-bit | 0 | Most common integer type |
long | 64-bit | 0L | Use L suffix for literals |
double | 64-bit | 0.0 | Default floating-point type |
boolean | 1-bit | false | true or false only |
byte | 8-bit | -127 to 127 | Holds char and value less than 127 |
char | 16-bit | ’�‘ | Unsigned Unicode character |
Type Casting
Widening (smaller → larger) is implicit. Narrowing requires an explicit cast and can lose data:
int x = 42;
long y = x; // widening implicit, safe
double d = 9.99;
int truncated = (int) d; // narrowing explicit, loses .99
Stack vs Heap
This tripped me up when I first learned Java years ago worth locking in early:
- Stack stores primitive values and references. Each method call gets its own stack frame. Memory is reclaimed automatically when the frame pops.
- Heap stores objects. Managed by the garbage collector. Lives longer than a single method call.
int a = 5; // 'a' lives on the stack
String s = "hello"; // 's' (the reference) is on the stack; the String object is on the heap
Primitives are always on the stack. Objects are always on the heap. The reference to an object sits on the stack.
Day 3 Variables, Constants, and String Basics
Variables and Constants
Java is statically typed declare the type upfront or use var (type inferred by the compiler, not runtime):
int count = 0;
var name = "Aderemi"; // compiler infers String
Constants use final. The convention is UPPER_SNAKE_CASE:
final int MAX_RETRIES = 3;
final String API_VERSION = "v2";
Once assigned, a final variable cannot be reassigned. For class-level constants, pair it with static:
public static final double TAX_RATE = 0.075;
String Basics
String in Java is immutable operations always return a new String, they do not modify in place. The most-used methods:
String s = "Hello, Java";
s.length(); // 11
s.charAt(7); // 'J'
s.substring(7); // "Java"
s.substring(7, 11); // "Java" (end index is exclusive)
s.contains("Java"); // true
s.equals("Hello, Java"); // true always use equals(), not ==
The == vs equals() distinction is critical. == compares references (memory addresses). equals() compares content. Two String objects with identical text will fail a == check if they were allocated separately:
String a = new String("test");
String b = new String("test");
a == b; // false different objects
a.equals(b); // true same content
String literals from the pool are an exception (the JVM interns them), but relying on that is fragile. Always use equals().
Day 4 Operators
Arithmetic
Standard arithmetic works exactly as expected, with one gotcha: integer division truncates toward zero rather than rounding.
int a = 10, b = 3;
a + b; // 13
a - b; // 7
a * b; // 30
a / b; // 3 not 3.33, truncation!
a % b; // 1 remainder
To get a decimal result from integer division, cast at least one operand to double first:
(double) a / b; // 3.3333...
Compound assignment and increment/decrement work the same as in C-derived languages:
int x = 5;
x += 3; // x is now 8
x++; // x is now 9 (post-increment: returns old value, then increments)
++x; // x is now 10 (pre-increment: increments first, then returns)
The prefix/postfix distinction only matters when the expression value is used in the same statement. Inside a standalone x++ or ++x it makes no difference.
Comparison and Logical
Comparison operators return boolean:
5 == 5 // true
5 != 3 // true
5 > 3 // true
5 <= 5 // true
Logical operators for combining booleans the important detail is short-circuit evaluation:
true && false // false evaluates right side only if left is true
true || false // true evaluates right side only if left is false
!true // false
Because of short-circuiting, && and || are safe to use when the right-hand operand has a side effect (like a method call) you only want to run conditionally. Bitwise & and | do not short-circuit they always evaluate both sides.
Bitwise
Less common in day-to-day application code but useful when working with flags, permissions, or low-level data:
| Operator | Name | Example | Result |
|---|---|---|---|
& | AND | 5 & 3 | 1 |
| | OR | 5 | 3 | 7 |
^ | XOR | 5 ^ 3 | 6 |
~ | NOT (unary) | ~5 | -6 |
<< | Left shift | 1 << 3 | 8 |
>> | Right shift | 8 >> 1 | 4 |
>>> | Unsigned right shift | 8 >>> 1 | 4 |
The difference between >> and >>> only shows up with negative numbers. >> preserves the sign bit (arithmetic shift); >>> fills with zeros regardless (logical shift).
Ternary
A compact inline conditional. Readable when short, painful when nested:
int age = 20;
String label = age >= 18 ? "adult" : "minor";
Don’t chain ternaries. Use an if/else block the moment it takes more than one line to read.
Operator Precedence
From highest to lowest, condensed to what actually matters in practice:
| Priority | Operators |
|---|---|
| 1 | ++, -- (postfix), method calls |
| 2 | ++, -- (prefix), !, ~, unary - |
| 3 | *, /, % |
| 4 | +, - |
| 5 | <<, >>, >>> |
| 6 | <, >, <=, >= |
| 7 | ==, != |
| 8 | & |
| 9 | ^ |
| 10 | | |
| 11 | && |
| 12 | || |
| 13 | ?: (ternary) |
| 14 | =, +=, -=, etc. |
The practical rule: use parentheses to make intent explicit. Don’t rely on memorising every precedence level. a + b * c is fine. a | b && c is a bug waiting to happen parenthesise it.
Day 5 Control Flow
if / else if / else
Nothing unusual here. The body can be a single statement without braces, but always use braces the dangling-else ambiguity is not worth the saved characters:
int score = 74;
if (score >= 90) {
System.out.println("A");
} else if (score >= 80) {
System.out.println("B");
} else if (score >= 70) {
System.out.println("C");
} else {
System.out.println("F");
}
Nested Conditions
Nesting works, but more than two levels deep is usually a sign to extract a method or restructure with early returns:
if (user != null) {
if (user.isActive()) {
if (user.hasPermission("admin")) {
// do the thing
}
}
}
Cleaner as guard clauses:
if (user == null) return;
if (!user.isActive()) return;
if (!user.hasPermission("admin")) return;
// do the thing
switch Statement (Traditional)
The traditional switch is still valid and widely found in existing codebases. One thing to know immediately: fall-through is the default. Without break, execution continues into the next case:
int day = 3;
switch (day) {
case 1:
System.out.println("Monday");
break;
case 2:
System.out.println("Tuesday");
break;
case 3:
System.out.println("Wednesday");
break;
default:
System.out.println("Other");
}
Forgetting break is one of the classic Java bugs. The compiler won’t warn you.
switch Expression (Java 14+)
The newer arrow-syntax switch expression eliminates fall-through entirely and can return a value directly. This is the form to prefer in modern code:
int day = 3;
String name = switch (day) {
case 1 -> "Monday";
case 2 -> "Tuesday";
case 3 -> "Wednesday";
case 4 -> "Thursday";
case 5 -> "Friday";
default -> "Weekend";
};
Each arm is isolated no fall-through, no break required. Multiple labels can share an arm:
String type = switch (day) {
case 1, 2, 3, 4, 5 -> "Weekday";
case 6, 7 -> "Weekend";
default -> "Unknown";
};
For arms that need more than one statement, use a block with yield to produce the value:
String label = switch (score) {
case 10 -> "Perfect";
default -> {
String result = score > 5 ? "Good" : "Low";
yield result + " (" + score + ")";
}
};
yield is the switch-expression equivalent of return it sets the value produced by that arm. Using return here would exit the enclosing method entirely, which is almost never what you want inside a switch expression.
Day 6 Loops
for Loop
The classic counted loop. Three-part header: initializer, condition, update:
for (int i = 0; i < 5; i++) {
System.out.println(i); // 0 1 2 3 4
}
The enhanced for-each loop is cleaner for iterating over arrays and collections when you don’t need the index:
int[] numbers = {10, 20, 30, 40};
for (int n : numbers) {
System.out.println(n);
}
while Loop
Checks the condition before each iteration. If the condition is false at the start, the body never runs:
int count = 0;
while (count < 3) {
System.out.println(count);
count++;
}
do-while Loop
Checks the condition after each iteration the body always runs at least once. Less common, but useful for input prompts or retry logic:
int n;
do {
n = getUserInput();
} while (n < 0);
break and continue
break exits the loop immediately. continue skips the rest of the current iteration and jumps to the next:
for (int i = 0; i < 10; i++) {
if (i == 5) break; // stop at 5
if (i % 2 == 0) continue; // skip even numbers
System.out.println(i); // prints 1, 3
}
Labelled Loops
Java supports labels on loops, which lets break and continue target an outer loop directly. I had never actually used this in practice before revisiting the language:
outer:
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
if (j == 1) continue outer; // skip to next iteration of outer loop
System.out.println(i + "," + j);
}
}
// prints: 0,0 | 1,0 | 2,0
break outer would exit the outer loop entirely rather than just the inner one. It’s not something you reach for often, but when you need to escape a nested loop cleanly it’s better than a sentinel variable.
Day 7 Methods
Declaration Syntax
A Java method declaration has four required parts: access modifier, return type, name, and parameter list. The body follows in braces:
accessModifier returnType methodName(ParameterType paramName) {
// body
return value;
}
A concrete example:
public int add(int a, int b) {
return a + b;
}
Parameters and Return Types
Parameters are declared with their type. Java passes primitives by value (a copy) and objects by reference (a copy of the reference not the object itself):
public String greet(String name) {
return "Hello, " + name + "!";
}
Multiple parameters, multiple types:
public double calculateArea(double width, double height) {
return width * height;
}
void Methods
When a method produces no return value, the return type is void. A bare return; is valid to exit early but is not required at the end:
public void printSeparator(int length) {
for (int i = 0; i < length; i++) {
System.out.print("-");
}
System.out.println();
}
Method Signature
The method signature is the name plus the parameter type list return type and parameter names are not part of it. This is what the compiler uses to distinguish overloaded methods:
// Different signatures same name, different parameters
public int multiply(int a, int b) { return a * b; }
public double multiply(double a, double b) { return a * b; }
public int multiply(int a, int b, int c) { return a * b * c; }
Overloading on return type alone is not allowed. int foo() and double foo() are indistinguishable from the call site the compiler rejects it.
Calculator Utility Class
Putting Day 7 together into a small utility class. No instance needed, so everything is static. Division and modulo include guards without them a zero denominator would throw an unchecked ArithmeticException at runtime with no helpful message:
public class Calculator {
public static int add(int a, int b) {
return a + b;
}
public static int subtract(int a, int b) {
return a - b;
}
public static int multiply(int a, int b) {
return a * b;
}
public static double divide(double a, double b) {
if (b == 0) {
throw new ArithmeticException("Division by zero");
}
return a / b;
}
public static double power(double base, int exponent) {
double result = 1;
for (int i = 0; i < Math.abs(exponent); i++) {
result *= base;
}
return exponent < 0 ? 1.0 / result : result;
}
public static int modulo(int a, int b) {
if (b == 0) {
throw new ArithmeticException("Modulo by zero");
}
return a % b;
}
}
Usage:
public class Main {
public static void main(String[] args) {
System.out.println(Calculator.add(10, 5)); // 15
System.out.println(Calculator.subtract(10, 5)); // 5
System.out.println(Calculator.multiply(4, 6)); // 24
System.out.println(Calculator.divide(10.0, 3.0)); // 3.3333...
System.out.println(Calculator.power(2, 8)); // 256.0
System.out.println(Calculator.modulo(17, 5)); // 2
}
}
The power method handles negative exponents power(2, -3) returns 0.125. Rolling it manually rather than reaching for Math.pow immediately was a useful exercise for combining a loop, a conditional, and a return value in one method.
Day 8 Classes and Objects
Defining a Class
A class is a blueprint. An object is an instance of that blueprint, allocated on the heap at runtime.
public class Person {
// instance fields each object gets its own copy
String name;
int age;
// instance method
void greet() {
System.out.println("Hi, I'm " + name);
}
}
Creating Instances with new
new allocates heap memory, initialises the fields, runs the constructor, and returns a reference:
Person alice = new Person();
alice.name = "Alice";
alice.age = 30;
alice.greet(); // Hi, I'm Alice
Person bob = new Person();
bob.name = "Bob";
bob.age = 25;
alice and bob are independent objects. Changing alice.name has no effect on bob.name.
Instance vs. Class Members
| Member type | Keyword | Lives on | One copy per |
|---|---|---|---|
| Instance field | (none) | heap, per object | object |
| Instance method | (none) | method area | class (shared, but this differs) |
| Class field | static | method area | class |
| Class method | static | method area | class |
public class Counter {
static int total = 0; // shared across all instances
int count = 0; // unique to each instance
void increment() {
count++;
total++;
}
}
Counter a = new Counter();
Counter b = new Counter();
a.increment();
a.increment();
b.increment();
System.out.println(a.count); // 2
System.out.println(b.count); // 1
System.out.println(Counter.total); // 3 access via class name, not instance
Access static members through the class name, not through an instance reference. The compiler allows both, but accessing via an instance is misleading it looks like the value belongs to that object.
Day 9 Constructors
Default Constructor
If you write no constructor, the compiler generates a no-argument one that sets all fields to their zero values (0, false, null). The moment you write any constructor yourself, the compiler stops generating the default:
public class Point {
int x;
int y;
// compiler generates: Point() {}
}
Point p = new Point(); // x=0, y=0
Parameterised Constructor
public class Point {
int x;
int y;
public Point(int x, int y) {
this.x = x; // 'this' distinguishes the field from the parameter
this.y = y;
}
}
Point p = new Point(3, 7);
this refers to the current object. When a parameter name shadows a field name, this.fieldName is the only unambiguous way to reach the field.
Constructor Chaining with this()
this() calls another constructor in the same class. It must be the first statement in the constructor body:
public class Rectangle {
int width;
int height;
String color;
public Rectangle(int width, int height, String color) {
this.width = width;
this.height = height;
this.color = color;
}
public Rectangle(int width, int height) {
this(width, height, "black"); // delegates to the three-arg constructor
}
public Rectangle() {
this(1, 1); // delegates to the two-arg constructor
}
}
Chaining keeps initialisation logic in one place. If the default color ever changes, only one constructor needs updating.
Object Lifecycle
newtriggers class loading if the class hasn’t been loaded yet.- Memory is allocated on the heap and zero-initialised.
- Instance initialisers and field assignments run top-to-bottom.
- The constructor body runs.
- The reference is returned to the caller.
- When no more references to the object exist, it becomes eligible for garbage collection. The GC reclaims the memory at an unspecified later time.
Java gives you no destructor. Resource cleanup (files, connections) goes in a try-with-resources block, not in object death.
Day 10 Encapsulation
The Problem with Public Fields
With public fields, any code anywhere can reach in and corrupt your object’s state:
public class BankAccount {
public double balance; // anyone can set this to -99999
}
account.balance = -99999; // no validation, no audit trail
Private Fields + Public Getters/Setters
Encapsulation means keeping fields private and exposing controlled access through methods:
public class BankAccount {
private double balance;
private String owner;
public BankAccount(String owner, double initialBalance) {
this.owner = owner;
if (initialBalance < 0) throw new IllegalArgumentException("Balance cannot be negative");
this.balance = initialBalance;
}
public double getBalance() {
return balance;
}
public void deposit(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Deposit must be positive");
balance += amount;
}
public void withdraw(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Withdrawal must be positive");
if (amount > balance) throw new IllegalStateException("Insufficient funds");
balance -= amount;
}
}
The class now owns every path into balance. Invalid states are rejected at the boundary rather than silently accepted.
Why It Matters
- Invariants you can guarantee
balance >= 0everywhere in the codebase. - Change isolation if the internal representation changes (say, storing balance in pence as a
long), callers using the public API don’t need to change. - Auditability validation and logging live in one place.
A getter with no corresponding setter is a read-only field from the caller’s perspective. That’s not a missing setter; it’s a deliberate design decision.
Day 11 Access Modifiers in Depth
Four levels, from most to least restrictive:
| Modifier | Same class | Same package | Subclass (other pkg) | Any class |
|---|---|---|---|---|
private | yes | |||
| package-private | yes | yes | ||
protected | yes | yes | yes | |
public | yes | yes | yes | yes |
Package-private is the default when no modifier is written. It is not a keyword omitting the modifier gives you package-private access.
public class Example {
private int a; // only this class
int b; // package-private any class in this package
protected int c; // package + subclasses in other packages
public int d; // everywhere
}
Design Boundaries
Start with private. Open up only as far as actually needed:
privateimplementation details that should never leak.- package-private collaborating classes within the same logical module.
protectedextension points for subclasses; use sparingly. Everyprotectedmember is part of your API for subclassers.publicyour stable external contract. Changing a public member is a breaking change.
A class itself can be public or package-private. A package-private class is invisible outside its package, which is useful for implementation classes you don’t want to expose.
Day 12 The static Keyword
Static Fields
A static field belongs to the class, not to any instance. All instances share the same value:
public class IdGenerator {
private static int nextId = 1;
private int id;
public IdGenerator() {
this.id = nextId++;
}
public int getId() { return id; }
}
IdGenerator a = new IdGenerator(); // id = 1
IdGenerator b = new IdGenerator(); // id = 2
Constants are always static final:
public static final double PI = 3.14159265358979;
Static Methods
Static methods have no this. They can only access static fields and other static methods directly:
public class MathUtils {
public static int clamp(int value, int min, int max) {
return Math.max(min, Math.min(max, value));
}
}
MathUtils.clamp(150, 0, 100); // 100
Calling a static method through an instance compiles but is a code smell the compiler resolves it to the class anyway.
Static Initialiser Blocks
Run once, when the class is first loaded. Useful when a static field needs complex setup that can’t fit in a field initialiser:
public class Config {
public static final Map<String, String> DEFAULTS;
static {
DEFAULTS = new HashMap<>();
DEFAULTS.put("timeout", "30");
DEFAULTS.put("retries", "3");
}
}
Math and Arrays as Utility Class Examples
Both are final classes with only static members the classic utility class pattern:
Math.abs(-7); // 7
Math.max(3, 9); // 9
Math.pow(2, 10); // 1024.0
Math.sqrt(144); // 12.0
Math.round(3.6); // 4
Math.random(); // [0.0, 1.0)
int[] nums = {5, 2, 8, 1, 9};
Arrays.sort(nums); // [1, 2, 5, 8, 9]
Arrays.binarySearch(nums, 8); // 3 (index after sort)
int[] copy = Arrays.copyOf(nums, 3); // [1, 2, 5]
Arrays.fill(copy, 0); // [0, 0, 0]
System.out.println(Arrays.toString(nums)); // [1, 2, 5, 8, 9]
Day 13 Inheritance
extends
A subclass inherits all non-private members of its superclass:
public class Animal {
String name;
public Animal(String name) {
this.name = name;
}
public void eat() {
System.out.println(name + " is eating.");
}
}
public class Dog extends Animal {
String breed;
public Dog(String name, String breed) {
super(name); // must be the first statement
this.breed = breed;
}
public void bark() {
System.out.println(name + " says: Woof!");
}
}
super for Fields and Constructors
super(...) calls the parent constructor. If the parent has no no-arg constructor, the subclass must explicitly call a parent constructor or the code won’t compile.
super.methodName() calls the parent’s version of a method from within an overriding method:
public class Animal {
public void describe() {
System.out.println("I am an animal named " + name);
}
}
public class Dog extends Animal {
@Override
public void describe() {
super.describe(); // runs Animal.describe() first
System.out.println("I am a " + breed);
}
}
What Inheritance Gives You
- Code reuse without duplication.
- Substitutability a
Dogcan be used anywhere anAnimalis expected. - Java enforces single inheritance for classes. A class can extend exactly one superclass. Use interfaces (Day 18) when you need multiple type contracts.
Every class that doesn’t explicitly extend anything implicitly extends java.lang.Object. That’s where toString(), equals(), and hashCode() come from.
Day 14 Method Overriding
@Override
A subclass can replace an inherited method by declaring a method with the same signature. @Override is technically optional but should always be present it lets the compiler catch typos in the method name or signature:
public class Animal {
public String sound() {
return "...";
}
}
public class Cat extends Animal {
@Override
public String sound() {
return "Meow";
}
}
Dynamic Dispatch
The JVM resolves the method at runtime based on the actual type of the object, not the declared type of the variable. This is the mechanism that makes polymorphism work:
Animal a = new Cat(); // declared as Animal, actually a Cat
a.sound(); // "Meow" Cat.sound() is called, not Animal.sound()
The decision of which sound() to call happens at runtime, every call, based on what a actually points to.
Covariant Return Types
An overriding method may narrow the return type to a subtype of the parent’s return type:
public class AnimalFactory {
public Animal create() {
return new Animal("generic");
}
}
public class DogFactory extends AnimalFactory {
@Override
public Dog create() { // Dog is a subtype of Animal legal
return new Dog("Rex", "Labrador");
}
}
Animal Hierarchy Example
public class Animal {
protected String name;
public Animal(String name) { this.name = name; }
public String sound() { return "..."; }
public void describe() {
System.out.println(name + " says: " + sound());
}
}
public class Dog extends Animal {
public Dog(String name) { super(name); }
@Override
public String sound() { return "Woof"; }
}
public class Cat extends Animal {
public Cat(String name) { super(name); }
@Override
public String sound() { return "Meow"; }
}
public class Duck extends Animal {
public Duck(String name) { super(name); }
@Override
public String sound() { return "Quack"; }
}
Animal[] animals = { new Dog("Rex"), new Cat("Whiskers"), new Duck("Donald") };
for (Animal a : animals) {
a.describe();
}
// Rex says: Woof
// Whiskers says: Meow
// Donald says: Quack
describe() is defined once in Animal. The correct sound() is selected at runtime for each object. Adding a new animal type requires no changes to describe() or the loop.
Day 15 Polymorphism
Compile-Time Polymorphism (Overloading)
The compiler picks the right method based on the static types of the arguments. Resolution happens before the program runs:
public class Printer {
public void print(int n) { System.out.println("int: " + n); }
public void print(double d) { System.out.println("double: " + d); }
public void print(String s) { System.out.println("String: " + s); }
}
Printer p = new Printer();
p.print(42); // int: 42
p.print(3.14); // double: 3.14
p.print("hello"); // String: hello
Runtime Polymorphism (Overriding)
The JVM picks the right method based on the actual type of the object. Resolution happens at runtime (see Day 14 for the mechanism):
Animal a = new Dog("Rex");
a.sound(); // "Woof" resolved at runtime to Dog.sound()
Upcasting
Assigning a subtype to a supertype variable. Always safe, always implicit:
Dog dog = new Dog("Rex");
Animal animal = dog; // upcast implicit, safe
The variable animal now has a narrower view of the object it can only see Animal methods. The object itself is still a Dog.
Downcasting
Recovering the subtype from a supertype variable. Requires an explicit cast. Fails at runtime with ClassCastException if the object is not actually that type:
Animal animal = new Dog("Rex");
Dog dog = (Dog) animal; // downcast explicit, safe here because the object IS a Dog
dog.bark();
Animal cat = new Cat("Whiskers");
Dog bad = (Dog) cat; // compiles, but throws ClassCastException at runtime
Always check with instanceof before downcasting to untrusted references (see Day 16).
Day 16 instanceof and Safe Casting
Classic instanceof
Returns true if the object is an instance of the given type (including subtypes):
Animal a = new Dog("Rex");
if (a instanceof Dog) {
Dog d = (Dog) a;
d.bark();
}
Two lines of boilerplate: the check and the cast. Easy to forget the check or use the wrong variable after it.
Pattern Matching instanceof (Java 16+)
The check and the cast collapse into one expression. The variable d is only in scope inside the if block where the check passed:
Animal a = new Dog("Rex");
if (a instanceof Dog d) {
d.bark(); // d is already typed as Dog, no explicit cast needed
}
It also works in expressions with &&:
if (a instanceof Dog d && d.breed.equals("Labrador")) {
System.out.println("Good boy, " + d.name);
}
The right side of && can safely use d because && short-circuits if the instanceof check failed, the right side never runs.
instanceof with null
null instanceof AnyType always returns false. No null check needed before an instanceof test:
Animal a = null;
System.out.println(a instanceof Dog); // false, no NullPointerException
When to Use Downcasting
Downcasting is a signal that the abstraction might be incomplete. If you find yourself checking instanceof Dog in multiple places, consider whether sound() or a similar method in the base class would eliminate the need. That said, legitimate uses exist: visitor patterns, serialisation, and framework interop all sometimes require type inspection.
Day 17 Abstract Classes
The abstract Keyword
An abstract class cannot be instantiated directly. It exists to be extended:
public abstract class Shape {
String color;
public Shape(String color) {
this.color = color;
}
public abstract double area(); // no body subclasses must implement
public abstract double perimeter(); // no body
public void describe() {
System.out.printf("%s: area=%.2f, perimeter=%.2f%n",
getClass().getSimpleName(), area(), perimeter());
}
}
Concrete subclasses must implement all abstract methods or be abstract themselves:
public class Circle extends Shape {
double radius;
public Circle(String color, double radius) {
super(color);
this.radius = radius;
}
@Override public double area() { return Math.PI * radius * radius; }
@Override public double perimeter() { return 2 * Math.PI * radius; }
}
public class Rectangle extends Shape {
double width, height;
public Rectangle(String color, double width, double height) {
super(color);
this.width = width;
this.height = height;
}
@Override public double area() { return width * height; }
@Override public double perimeter() { return 2 * (width + height); }
}
Shape[] shapes = { new Circle("red", 5), new Rectangle("blue", 4, 6) };
for (Shape s : shapes) {
s.describe();
}
// Circle: area=78.54, perimeter=31.42
// Rectangle: area=24.00, perimeter=20.00
Abstract Class vs. Concrete Class
Use an abstract class when:
- The concept makes no sense without specialisation (
Shapewith no defined geometry is meaningless). - You want to share implementation (fields, concrete methods) alongside abstract contracts.
- The subclasses share state that belongs on the base.
Don’t make a class abstract just because you want to force subclassing. If the class is complete and useful on its own, leave it concrete.
Day 18 Interfaces
Defining and Implementing
An interface declares a contract what a type can do, not how it does it. All methods are implicitly public abstract unless marked otherwise:
public interface Drawable {
void draw();
void resize(double factor);
}
public interface Printable {
void print();
}
A class implements an interface with implements. It must provide a body for every abstract method:
public class Circle implements Drawable, Printable {
double radius;
public Circle(double radius) { this.radius = radius; }
@Override public void draw() { System.out.println("Drawing circle r=" + radius); }
@Override public void resize(double factor) { radius *= factor; }
@Override public void print() { System.out.println("Circle(r=" + radius + ")"); }
}
Default Methods (Java 8+)
default methods have a body inside the interface. They let you add new behaviour to an interface without breaking all existing implementors:
public interface Drawable {
void draw();
void resize(double factor);
default void drawAndDescribe() {
draw();
System.out.println("Drawn.");
}
}
Implementors inherit drawAndDescribe() for free. They may still override it if they need different behaviour.
Static Interface Methods
Utility methods that belong conceptually to the interface but don’t need an instance:
public interface Validator {
boolean validate(String input);
static Validator nonEmpty() {
return input -> input != null && !input.isEmpty();
}
}
Validator v = Validator.nonEmpty();
v.validate("hello"); // true
Interface Fields
All fields in an interface are implicitly public static final. Interfaces cannot hold instance state.
Day 19 Multiple Interfaces, Interface Segregation, and Abstract Class vs. Interface
Multiple Interface Implementation
A class can implement as many interfaces as needed. This is the mechanism Java uses in place of multiple inheritance:
public interface Flyable { void fly(); }
public interface Swimmable { void swim(); }
public interface Runnable { void run(); }
public class Duck implements Flyable, Swimmable, Runnable {
@Override public void fly() { System.out.println("Duck flying"); }
@Override public void swim() { System.out.println("Duck swimming"); }
@Override public void run() { System.out.println("Duck running"); }
}
Default Method Conflict
If two interfaces define a default method with the same signature, the implementing class must override it:
interface A { default void hello() { System.out.println("A"); } }
interface B { default void hello() { System.out.println("B"); } }
class C implements A, B {
@Override
public void hello() {
A.super.hello(); // explicitly choose A's version, or write your own
}
}
Interface Segregation
Prefer narrow, focused interfaces over wide ones. A class shouldn’t be forced to implement methods it doesn’t use:
// Too wide a PDF printer doesn't need scan()
interface AllInOne { void print(); void scan(); void fax(); }
// Better compose only what you need
interface Printable { void print(); }
interface Scannable { void scan(); }
interface Faxable { void fax(); }
Marker Interfaces
An interface with no methods. Used to signal a capability to the runtime or frameworks:
public interface Serializable {} // java.io.Serializable works this way
The JVM and ObjectOutputStream check instanceof Serializable before serialising an object. Annotations (e.g. @JsonSerializable) have largely replaced marker interfaces in modern code, but you still encounter them in the standard library.
Abstract Class vs. Interface
| Dimension | Abstract class | Interface |
|---|---|---|
| Instantiation | No | No |
| Instance fields | Yes | No (static final only) |
| Constructor | Yes | No |
| Multiple inheritance | No (single extends) | Yes (multiple implements) |
| Access modifiers | Any | public only (methods) |
| Default implementation | Any method can have a body | default keyword required |
| Best for | Shared state + partial implementation | Behavioural contracts, mix-ins |
When in doubt: prefer interfaces. Use an abstract class when you genuinely need shared state or a constructor.
Day 20 Inner Classes
Java has four kinds of nested types, each with a distinct purpose.
Static Nested Class
Declared static inside another class. No implicit link to the outer instance. Behaves like a top-level class but is namespaced inside the outer:
public class Graph {
private int vertices;
public static class Edge { // does not need a Graph instance
int from, to, weight;
Edge(int from, int to, int weight) {
this.from = from;
this.to = to;
this.weight = weight;
}
}
}
Graph.Edge e = new Graph.Edge(0, 1, 10);
Use static nested classes for helper types that are logically part of the outer class but don’t need access to its instance members. Map.Entry is the canonical standard-library example.
Inner (Non-Static) Class
Has an implicit reference to the enclosing outer instance. Can access all members of the outer, including private ones:
public class OuterClass {
private int value = 10;
public class InnerClass {
public void display() {
System.out.println("Outer value: " + value); // direct access
}
}
}
OuterClass outer = new OuterClass();
OuterClass.InnerClass inner = outer.new InnerClass();
inner.display(); // Outer value: 10
The hidden outer reference means inner class instances can’t outlive their outer. Be cautious: inner classes held in long-lived collections can prevent the outer from being garbage-collected.
Local Class
Defined inside a method body. Visible only within that method. Can capture effectively-final local variables:
public void process(int multiplier) {
class Multiplier {
int apply(int x) { return x * multiplier; }
}
Multiplier m = new Multiplier();
System.out.println(m.apply(5));
}
Rarely needed; lambdas cover most use-cases more concisely.
Anonymous Class
A one-off class defined and instantiated in a single expression. Common before Java 8 lambdas for single-method interfaces:
Comparator<String> byLength = new Comparator<String>() {
@Override
public int compare(String a, String b) {
return Integer.compare(a.length(), b.length());
}
};
List<String> words = Arrays.asList("banana", "kiwi", "fig");
words.sort(byLength);
Post-Java-8, a lambda replaces this: words.sort((a, b) -> Integer.compare(a.length(), b.length())). You’ll still see anonymous classes for multi-method interfaces where a lambda can’t be used.
Day 21 Enums
Defining an Enum
An enum is a fixed set of named constants. Each constant is a singleton instance of the enum type:
public enum Direction {
NORTH, SOUTH, EAST, WEST
}
Direction d = Direction.NORTH;
System.out.println(d); // NORTH
System.out.println(d.name()); // NORTH
System.out.println(d.ordinal()); // 0 zero-based position
Adding Fields and Methods
Enum constants can carry data. Each constant calls the constructor:
public enum Planet {
MERCURY(3.303e+23, 2.4397e6),
VENUS (4.869e+24, 6.0518e6),
EARTH (5.976e+24, 6.37814e6);
private final double mass;
private final double radius;
Planet(double mass, double radius) {
this.mass = mass;
this.radius = radius;
}
static final double G = 6.67300E-11;
double surfaceGravity() {
return G * mass / (radius * radius);
}
double surfaceWeight(double otherMass) {
return otherMass * surfaceGravity();
}
}
System.out.println(Planet.EARTH.surfaceWeight(75)); // ~735.0 N
Enums can implement interfaces and declare abstract methods that each constant overrides a clean way to attach behaviour to each variant without a switch.
switch with Enums
Enums and switch expressions pair naturally:
String label = switch (direction) {
case NORTH -> "Up";
case SOUTH -> "Down";
case EAST -> "Right";
case WEST -> "Left";
};
The compiler warns if you miss a case (exhaustiveness checking).
EnumSet and EnumMap
Specialised collections backed by bit vectors and arrays respectively. Much faster than HashSet<EnumType> or HashMap<EnumType, V> for enum keys:
EnumSet<Direction> horizontal = EnumSet.of(Direction.EAST, Direction.WEST);
EnumSet<Direction> all = EnumSet.allOf(Direction.class);
EnumSet<Direction> vertical = EnumSet.complementOf(horizontal); // NORTH, SOUTH
EnumMap<Direction, String> labels = new EnumMap<>(Direction.class);
labels.put(Direction.NORTH, "Up");
labels.put(Direction.SOUTH, "Down");
Prefer EnumSet over Set<MyEnum> and EnumMap over Map<MyEnum, V> when you know the key type is an enum.
Day 22 String Deep-Dive
Immutability and the String Pool
String objects are immutable. Every operation that appears to modify a String actually creates a new one. String literals are interned in a pool maintained by the JVM; two literals with the same content share the same object:
String a = "hello";
String b = "hello";
System.out.println(a == b); // true same pooled object
System.out.println(a.equals(b)); // true
String c = new String("hello");
System.out.println(a == c); // false c is a new heap object outside the pool
System.out.println(a.equals(c)); // true
Never use == to compare String content. Always use equals() or equalsIgnoreCase().
StringBuilder vs. StringBuffer
String concatenation inside a loop creates a new object on every iteration. For heavy string building, use StringBuilder:
// Inefficient O(n²) allocations
String result = "";
for (int i = 0; i < 1000; i++) {
result += i; // new String each iteration
}
// Efficient single buffer, O(n)
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
sb.append(i);
}
String result = sb.toString();
StringBuffer is the thread-safe equivalent of StringBuilder. It synchronises every method, so it’s slower. Use StringBuilder unless you genuinely need thread safety on the builder itself (rare usually you synchronise at a higher level).
Common String Methods
String s = " Hello, Java! ";
s.trim() // "Hello, Java!" removes leading/trailing whitespace
s.strip() // same but Unicode-aware (Java 11+)
s.toLowerCase() // " hello, java! "
s.toUpperCase() // " HELLO, JAVA! "
s.replace("Java", "World") // " Hello, World! "
s.split(", ") // [" Hello", "Java! "]
s.startsWith(" Hello") // true
s.endsWith("! ") // true
s.indexOf("Java") // 8
s.isEmpty() // false
s.isBlank() // false (Java 11+, checks whitespace-only too)
String.valueOf(42) // "42" convert any primitive to String
"Hello".repeat(3) // "HelloHelloHello" (Java 11+)
Text Blocks (Java 15+)
Multi-line strings without escape sequences:
String json = """
{
"name": "Alice",
"age": 30
}
""";
The leading indentation common to all lines is stripped. The closing """ on its own line adds a trailing newline.
Day 23 Arrays
Declaration and Initialisation
int[] nums = new int[5]; // [0, 0, 0, 0, 0]
int[] primes = {2, 3, 5, 7, 11}; // array literal
String[] names = new String[3]; // [null, null, null]
Arrays are objects. The variable holds a reference. The length is fixed at creation and never changes use ArrayList (Day 27) when you need a resizable sequence.
Multi-Dimensional Arrays
Java implements these as arrays of arrays each row can have a different length (a jagged array):
int[][] matrix = {
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}
};
System.out.println(matrix[1][2]); // 6 row 1, column 2
// Jagged
int[][] triangle = new int[4][];
for (int i = 0; i < triangle.length; i++) {
triangle[i] = new int[i + 1];
}
// triangle[0].length == 1, triangle[3].length == 4
Arrays Utility
int[] nums = {5, 2, 8, 1, 9, 3};
Arrays.sort(nums); // [1, 2, 3, 5, 8, 9] in-place
int idx = Arrays.binarySearch(nums, 8); // 4 requires sorted array
int[] copy = Arrays.copyOf(nums, 4); // [1, 2, 3, 5]
int[] range = Arrays.copyOfRange(nums, 2, 5); // [3, 5, 8]
Arrays.fill(copy, 0); // [0, 0, 0, 0]
boolean eq = Arrays.equals(nums, nums); // true
System.out.println(Arrays.toString(nums)); // [1, 2, 3, 5, 8, 9]
Arrays.sort uses dual-pivot quicksort for primitives and timsort for objects. Both are O(n log n). binarySearch requires a sorted array calling it on an unsorted array gives undefined results with no error.
For 2D arrays, Arrays.deepToString and Arrays.deepEquals handle nested structure:
int[][] m = {{1, 2}, {3, 4}};
System.out.println(Arrays.deepToString(m)); // [[1, 2], [3, 4]]
Day 24 Wrapper Classes
Primitives vs. Wrappers
Every primitive has a corresponding wrapper class in java.lang:
| Primitive | Wrapper |
|---|---|
int | Integer |
long | Long |
double | Double |
boolean | Boolean |
char | Character |
byte | Byte |
short | Short |
float | Float |
Collections like ArrayList work with objects, not primitives. Wrappers bridge that gap.
Autoboxing and Unboxing
The compiler automatically converts between primitives and their wrappers where needed:
List<Integer> list = new ArrayList<>();
list.add(42); // autoboxing: int → Integer
int x = list.get(0); // unboxing: Integer → int
Autoboxing has overhead. In tight loops over large collections, this can matter. For performance-critical numeric code, consider primitive arrays instead.
Unboxing a null wrapper throws a NullPointerException:
Integer n = null;
int x = n; // NullPointerException at runtime
Common Wrapper Methods
// Parsing
int i = Integer.parseInt("42");
double d = Double.parseDouble("3.14");
boolean b = Boolean.parseBoolean("true");
// Converting to String
String s = Integer.toString(42); // "42"
String s = String.valueOf(42); // "42" works for any primitive
// Constants
Integer.MAX_VALUE // 2147483647
Integer.MIN_VALUE // -2147483648
Double.MAX_VALUE
Double.isNaN(0.0 / 0.0) // true
// Integer operations
Integer.toBinaryString(10) // "1010"
Integer.toHexString(255) // "ff"
Integer.bitCount(7) // 3 number of set bits
// Comparison (safe, no overflow risk unlike subtraction)
Integer.compare(3, 5) // negative (3 < 5)
Integer Cache
The JVM caches Integer objects for values in the range [-128, 127]. This means == on cached integers works but only in that range:
Integer a = 127;
Integer b = 127;
System.out.println(a == b); // true same cached object
Integer c = 128;
Integer d = 128;
System.out.println(c == d); // false different objects
This is why equals() is always the right choice for wrapper comparison.
Day 25 java.util.Objects
Objects (note the s) is a utility class of null-safe static helpers introduced in Java 7. It exists because many common operations on objects blow up with a NullPointerException when the object is null.
requireNonNull
Fails fast at the boundary rather than letting null propagate:
public class Service {
private final Repository repo;
public Service(Repository repo) {
this.repo = Objects.requireNonNull(repo, "repo must not be null");
}
}
Without this, a null repo would cause a NullPointerException deep inside some method call with no hint about where the null came from.
Null-Safe toString and equals
String s = null;
System.out.println(Objects.toString(s, "default")); // "default"
System.out.println(s.toString()); // NullPointerException
Objects.equals(null, null) // true
Objects.equals(null, "hello") // false
Objects.equals("hello", null) // false no NPE
hash and hashCode
Generates a hash from multiple fields use it when implementing hashCode() for a class:
public class Point {
int x, y;
@Override
public int hashCode() {
return Objects.hash(x, y);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point p)) return false;
return x == p.x && y == p.y;
}
}
The equals/hashCode Contract
The contract is what makes objects work correctly in collections:
equalsmust be reflexive, symmetric, transitive, and consistent.- If
a.equals(b)thena.hashCode() == b.hashCode(). The reverse is not required (hash collisions are allowed). - If you override
equals, always overridehashCode. Violating rule 2 breaksHashMap,HashSet, and any collection that uses hashing.
Map<Point, String> map = new HashMap<>();
Point p = new Point(1, 2);
map.put(p, "origin-ish");
// Without correct hashCode, lookup may fail even though equals returns true
map.get(new Point(1, 2)); // "origin-ish" only if hashCode is consistent with equals
Day 26 Packages and Imports
Organising Code with Packages
A package is a namespace. It maps to a directory structure on the filesystem:
// File: src/com/example/service/UserService.java
package com.example.service;
public class UserService {
// ...
}
The convention is reverse-domain-name notation (com.company.module). The package declaration must be the first non-comment line in the file.
import
import com.example.service.UserService; // single class
import com.example.service.*; // all public types in the package (not subdirectories)
java.lang is imported automatically (String, System, Math, Object, etc.). Everything else requires an explicit import or fully-qualified name.
import static
Imports static members directly, letting you use them without the class prefix:
import static java.lang.Math.PI;
import static java.lang.Math.sqrt;
import static java.util.Arrays.sort;
double circumference = 2 * PI * radius; // instead of Math.PI
double root = sqrt(16); // instead of Math.sqrt
sort(nums); // instead of Arrays.sort
Useful for constants and frequently-called utilities. Avoid wildcard static imports (import static X.*) in production code they make it impossible to tell where a name comes from.
Java Module System (JPMS, Java 9+)
The module system adds a layer above packages. A module is a named, self-describing group of packages with explicit dependencies:
// module-info.java at the root of the source tree
module com.example.app {
requires com.example.service; // declares dependency
exports com.example.api; // makes this package visible to other modules
}
Modules enforce strong encapsulation at the JVM level even reflection is blocked across module boundaries by default. Most projects don’t use the module system unless they’re building libraries or need runtime isolation. You’ll encounter it in the JDK itself (every java.* package is now in a module) but can ignore it for most application code.
Day 27 ArrayList and LinkedList
ArrayList
Backed by a resizable array. Random access by index is O(1). Insertion or deletion in the middle is O(n) because elements must shift:
List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
names.add("Charlie");
names.get(1); // "Bob" O(1)
names.set(0, "Alicia"); // replace at index
names.remove("Bob"); // remove by value O(n) scan + shift
names.remove(1); // remove by index O(n) shift
names.size(); // 2
names.contains("Charlie"); // true O(n) scan
names.isEmpty(); // false
// Iteration
for (String name : names) {
System.out.println(name);
}
// With index when you need it
for (int i = 0; i < names.size(); i++) {
System.out.println(i + ": " + names.get(i));
}
Always declare the variable as List<T>, not ArrayList<T>. Programming to the interface lets you swap implementations without changing callers.
LinkedList
Backed by a doubly-linked list. Insertion and deletion at the head or tail are O(1). Random access by index is O(n) because the list must walk from one end.
LinkedList<Integer> queue = new LinkedList<>();
queue.addFirst(1); // push to front
queue.addLast(2); // push to back
queue.peekFirst(); // view front without removing
queue.pollFirst(); // remove and return front
queue.peekLast();
queue.pollLast();
LinkedList implements both List and Deque. It’s a natural fit when you need a queue or stack backed by an O(1) head/tail operations.
When to Use Which
| Operation | ArrayList | LinkedList |
|---|---|---|
Random access get(i) | O(1) | O(n) |
| Add/remove at end | O(1) amort. | O(1) |
| Add/remove at middle | O(n) | O(n) |
| Add/remove at head | O(n) | O(1) |
| Memory overhead | Low | High (node pointers) |
ArrayList is the right default for almost every case. Use LinkedList only when you specifically need O(1) insertion at both ends and don’t need random access.
Day 28 HashMap and HashSet
HashMap
Stores key-value pairs. Keys must be unique. null keys and values are allowed. Lookup, insertion, and deletion are O(1) average (O(n) worst case with many hash collisions):
Map<String, Integer> scores = new HashMap<>();
scores.put("Alice", 95);
scores.put("Bob", 82);
scores.put("Charlie", 91);
scores.put("Alice", 97); // replaces the previous value for "Alice"
scores.get("Bob"); // 82
scores.getOrDefault("Dave", 0); // 0 safe default when key is absent
scores.containsKey("Charlie"); // true
scores.containsValue(82); // true
scores.remove("Bob");
// Iteration
for (Map.Entry<String, Integer> entry : scores.entrySet()) {
System.out.println(entry.getKey() + " → " + entry.getValue());
}
// Merge add to existing count, or start at 1
scores.merge("Alice", 1, Integer::sum);
HashSet
A set of unique elements backed by a HashMap. No duplicates, no guaranteed order. Add, remove, and contains are all O(1) average:
Set<String> seen = new HashSet<>();
seen.add("apple");
seen.add("banana");
seen.add("apple"); // duplicate ignored silently
seen.contains("apple"); // true
seen.size(); // 2
seen.remove("banana");
Set<String> other = new HashSet<>(Set.of("apple", "cherry"));
seen.retainAll(other); // intersection seen is now {"apple"}
equals and hashCode Matter Here
HashMap and HashSet use hashCode() to find the bucket and equals() to identify the key within the bucket. If your key class has incorrect or missing equals/hashCode, lookups silently fail:
// Point without equals/hashCode override
Map<Point, String> map = new HashMap<>();
map.put(new Point(1, 2), "A");
map.get(new Point(1, 2)); // null different objects, default hashCode differs
After adding equals and hashCode (see Day 25), the lookup works correctly.
Collision Basics
All keys with the same hashCode() % buckets land in the same bucket. Before Java 8, those buckets were linked lists O(n) per bucket in the worst case. From Java 8, buckets with more than 8 entries switch to a red-black tree, giving O(log n) per bucket. A good hash function keeps collisions rare enough that the average stays O(1).
Day 29 OOP Design Principles: SOLID
SOLID is five principles that guide class design toward code that’s easier to extend, test, and maintain.
S Single Responsibility Principle
A class should have one reason to change.
// Violation Order does three unrelated things
public class Order {
public void calculateTotal() { ... }
public void saveToDatabase() { ... } // persistence concern
public void sendConfirmationEmail() { ... } // notification concern
}
// Better each class owns one concern
public class Order { public void calculateTotal() { ... } }
public class OrderRepository { public void save(Order o) { ... } }
public class OrderNotifier { public void sendConfirmation(Order o) { ... } }
O Open/Closed Principle
Open for extension, closed for modification. Add new behaviour without changing existing code.
// Every new shape requires modifying AreaCalculator
public class AreaCalculator {
public double calculate(Object shape) {
if (shape instanceof Circle c) return Math.PI * c.radius * c.radius;
if (shape instanceof Rectangle r) return r.width * r.height;
// must edit this class for every new shape
}
}
// Better each shape knows its own area
public interface Shape { double area(); }
public class Circle implements Shape { ... }
public class Rectangle implements Shape { ... }
public class AreaCalculator {
public double calculate(Shape shape) { return shape.area(); }
// never needs to change for new shapes
}
L Liskov Substitution Principle
A subtype must be substitutable for its supertype without breaking correctness.
// Violation Square overrides setWidth and setHeight together, breaking Rectangle invariants
public class Square extends Rectangle {
@Override
public void setWidth(int w) { super.setWidth(w); super.setHeight(w); }
@Override
public void setHeight(int h) { super.setWidth(h); super.setHeight(h); }
}
// Code that works on Rectangle breaks when given a Square
Rectangle r = new Square();
r.setWidth(4);
r.setHeight(5);
assert r.area() == 20; // fails: area is 25 because Square forces equal sides
The fix is not to inherit. Square and Rectangle should share a Shape interface, not a superclass relationship.
I Interface Segregation Principle
No client should be forced to depend on methods it doesn’t use. Prefer narrow, focused interfaces (see Day 19).
// Too wide a basic user doesn't need admin methods
interface UserOperations {
void login();
void viewProfile();
void deleteUser(int id); // admin only
void viewAllUsers(); // admin only
}
// Better
interface UserOperations { void login(); void viewProfile(); }
interface AdminOperations { void deleteUser(int id); void viewAllUsers(); }
D Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on abstractions.
// Violation high-level OrderService hard-coded to a specific low-level implementation
public class OrderService {
private MySQLOrderRepository repo = new MySQLOrderRepository(); // concrete!
public void process(Order o) { repo.save(o); }
}
// Better depend on the interface, inject the implementation
public interface OrderRepository { void save(Order o); }
public class OrderService {
private final OrderRepository repo;
public OrderService(OrderRepository repo) { // injected
this.repo = repo;
}
public void process(Order o) { repo.save(o); }
}
Injecting the dependency makes OrderService testable with a mock repository and decoupled from any specific database.
Day 30 Phase 1 Project: Library Management System
Bringing all 29 days together into a coherent system. The design applies encapsulation, inheritance (where genuinely useful), interfaces, and the SOLID principles.
Domain Model
Book — represents a book in the library
Member — represents a registered library member
Loan — records a single borrowing of a Book by a Member
LibraryService — coordinates the core operations
Book
public class Book {
private final String isbn;
private final String title;
private final String author;
private boolean available;
public Book(String isbn, String title, String author) {
this.isbn = Objects.requireNonNull(isbn, "isbn");
this.title = Objects.requireNonNull(title, "title");
this.author = Objects.requireNonNull(author, "author");
this.available = true;
}
public String getIsbn() { return isbn; }
public String getTitle() { return title; }
public String getAuthor() { return author; }
public boolean isAvailable() { return available; }
void setAvailable(boolean available) { this.available = available; }
@Override
public String toString() {
return String.format("[%s] %s by %s (%s)",
isbn, title, author, available ? "available" : "on loan");
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Book b)) return false;
return isbn.equals(b.isbn);
}
@Override
public int hashCode() { return Objects.hash(isbn); }
}
Member
public class Member {
private static int nextId = 1;
private final int id;
private final String name;
private final List<Loan> activeLoans = new ArrayList<>();
public Member(String name) {
this.id = nextId++;
this.name = Objects.requireNonNull(name, "name");
}
public int getId() { return id; }
public String getName() { return name; }
public List<Loan> getActiveLoans() {
return Collections.unmodifiableList(activeLoans);
}
void addLoan(Loan loan) { activeLoans.add(loan); }
void removeLoan(Loan loan) { activeLoans.remove(loan); }
@Override
public String toString() {
return String.format("Member#%d (%s) — %d active loan(s)", id, name, activeLoans.size());
}
}
Loan
public class Loan {
private final Book book;
private final Member member;
private final LocalDate borrowedOn;
private LocalDate returnedOn;
public Loan(Book book, Member member) {
this.book = Objects.requireNonNull(book, "book");
this.member = Objects.requireNonNull(member, "member");
this.borrowedOn = LocalDate.now();
}
public Book getBook() { return book; }
public Member getMember() { return member; }
public LocalDate getBorrowedOn() { return borrowedOn; }
public boolean isActive() { return returnedOn == null; }
void close() { this.returnedOn = LocalDate.now(); }
@Override
public String toString() {
return String.format("Loan[%s → %s, from %s%s]",
book.getTitle(), member.getName(), borrowedOn,
isActive() ? "" : ", returned " + returnedOn);
}
}
LibraryService
public class LibraryService {
private final Map<String, Book> books = new HashMap<>();
private final Map<Integer, Member> members = new HashMap<>();
private final List<Loan> loans = new ArrayList<>();
public void addBook(Book book) {
books.put(book.getIsbn(), book);
}
public void registerMember(Member member) {
members.put(member.getId(), member);
}
public Loan borrow(String isbn, int memberId) {
Book book = books.get(isbn);
if (book == null) throw new IllegalArgumentException("Unknown ISBN: " + isbn);
if (!book.isAvailable()) throw new IllegalStateException("Book is already on loan");
Member member = members.get(memberId);
if (member == null) throw new IllegalArgumentException("Unknown member: " + memberId);
Loan loan = new Loan(book, member);
book.setAvailable(false);
member.addLoan(loan);
loans.add(loan);
return loan;
}
public void returnBook(String isbn, int memberId) {
Loan loan = loans.stream()
.filter(l -> l.isActive()
&& l.getBook().getIsbn().equals(isbn)
&& l.getMember().getId() == memberId)
.findFirst()
.orElseThrow(() -> new IllegalStateException("No active loan found"));
loan.close();
loan.getBook().setAvailable(true);
loan.getMember().removeLoan(loan);
}
public List<Book> availableBooks() {
return books.values().stream()
.filter(Book::isAvailable)
.toList();
}
public List<Loan> loanHistory() {
return Collections.unmodifiableList(loans);
}
}
Putting It Together
public class Main {
public static void main(String[] args) {
LibraryService library = new LibraryService();
Book b1 = new Book("978-0-13-468599-1", "Effective Java", "Joshua Bloch");
Book b2 = new Book("978-0-13-110362-7", "The C Programming Language", "Kernighan & Ritchie");
library.addBook(b1);
library.addBook(b2);
Member alice = new Member("Alice");
Member bob = new Member("Bob");
library.registerMember(alice);
library.registerMember(bob);
Loan loan1 = library.borrow("978-0-13-468599-1", alice.getId());
System.out.println(loan1);
System.out.println(alice);
library.returnBook("978-0-13-468599-1", alice.getId());
System.out.println("Available: " + library.availableBooks().size()); // 2
library.loanHistory().forEach(System.out::println);
}
}
Design Decisions
setAvailableis package-private (nopublic) onlyLibraryServiceshould toggle a book’s availability. Clients can read it viaisAvailable()but can’t mutate it directly.getActiveLoansreturns an unmodifiable view prevents callers from bypassingLibraryServiceand directly modifying a member’s loan list.- ISBN is the identity of
Bookequals/hashCodeare based onisbnalone, making it safe to useBookas a map key or set member. static int nextIdinMemberthe Day 8 class-vs-instance lesson applied: one counter shared across all member objects.LibraryServiceowns the relationships borrow and return logic lives in one place (SRP), the service depends on abstractions it constructs internally, and each class handles only its own state.