Skip to content
Educora
Advanced22 min18 / 18

Clean code, SOLID and design patterns

Learn the rules of readable code, the SOLID principles and five key design patterns — Singleton, Factory, Builder, Strategy, Observer — with short Java examples.

Check yourself
In this lesson you will learn
  • Refactor an existing method using clean code rules
  • Explain the five SOLID principles with examples
  • Recognise and write the Singleton, Factory, Builder, Strategy and Observer patterns

A program is written once but read dozens of times — by teammates, or by you yourself six months later. Working code is not yet good code: good code is easy to read, change and test. In this lesson you will learn the everyday rules of professional Java developers: clean code principles, SOLID and the five most used design patterns.

Rules of clean code

  • Meaningful names: not c, r, calc but passedCount, average, averageOfPassed. A name should say what the thing does.
  • Small methods: one method, one job. If a method does not fit on the screen, split it.
  • No magic numbers: the constants PASS_MARK and BONUS_FACTOR instead of 50 and 1.1.
  • Immutability: final fields and records wherever possible; an object that never changes is also safe between threads.
  • **Optional instead of null**: if there may be no result, return Optional<Student> so that the caller cannot forget about it.
  • Do not swallow exceptions: an empty catch {} hides the bug; either handle it or pass it up.
It works, but nobody can read it
public double calc(List<Integer> l, int t) {
    double r = 0;
    int c = 0;
    for (int i = 0; i < l.size(); i++) {
        if (l.get(i) != null) {
            if (l.get(i) >= 50) { r = r + l.get(i); c++; }
        }
    }
    if (t == 1) return c == 0 ? 0 : r / c * 1.1;
    return c == 0 ? 0 : r / c;
}
Same result, clear intent
private static final int PASS_MARK = 50;
private static final double BONUS_FACTOR = 1.1;

public double averageOfPassed(List<Integer> scores, boolean withBonus) {
    double average = scores.stream()
            .filter(Objects::nonNull)
            .filter(score -> score >= PASS_MARK)
            .mapToInt(Integer::intValue)
            .average()
            .orElse(0);
    return withBonus ? average * BONUS_FACTOR : average;
}
Both methods return the same numbers (checked), but the second one is clear without any explanation

In the first version, what does t == 1 mean? Where does 1.1 come from? The reader has to execute every line in their head. In the second version the name, the constants and the stream tell everything, and boolean withBonus replaces the mysterious int t. Note that we did not change the logic — this is refactoring, and tests written beforehand (the JUnit lesson!) guarantee that the behaviour is preserved.

The SOLID principles

SOLID is an acronym for five object-oriented design principles popularised by Robert C. Martin. They make code resilient to change: when a new requirement arrives, you change one place, not ten.

LetterPrincipleIn short
SSingle ResponsibilityA class should have only one reason to change: the controller deals with HTTP, the service with rules, the repository with the database.
OOpen/ClosedCode is open for extension but closed for modification: a new rule is added as a new class without editing old code.
LLiskov SubstitutionA subclass must work wherever its parent class is expected, without breaking expectations.
IInterface SegregationSeveral small interfaces instead of one big one: a class should not be forced to implement methods it does not use.
DDependency InversionDepend on abstractions (interfaces), not on concrete classes — in the JUnit lesson ReportService depended on the ScoreRepository interface.

Design patterns

A design pattern is a proven solution scheme for a problem that comes up again and again. In 1994 the “Gang of Four” (Gamma, Helm, Johnson, Vlissides) collected 23 patterns in a book and split them into three groups: creational (how objects are created), structural (how objects are combined) and behavioural (how objects cooperate). Below are the five you will meet most often in Java.

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

public class Main {
    public static void main(String[] args) {
        AppConfig.INSTANCE.set("school", "School No. 6, Baku");
        System.out.println(AppConfig.INSTANCE.get("school"));
        System.out.println(AppConfig.INSTANCE == AppConfig.valueOf("INSTANCE"));

        for (String type : List.of("email", "sms")) {
            Notifier notifier = Notifier.of(type);
            notifier.send("Aysel", "Your exam score is 95");
        }
    }
}

enum AppConfig {                                   // Singleton: exactly one instance
    INSTANCE;
    private final Map<String, String> values = new HashMap<>();
    void set(String key, String value) { values.put(key, value); }
    String get(String key) { return values.get(key); }
}

interface Notifier {
    void send(String to, String text);

    static Notifier of(String type) {              // Factory: hides which class is created
        return switch (type) {
            case "email" -> (to, text) -> System.out.println("Email to " + to + ": " + text);
            case "sms" -> (to, text) -> System.out.println("SMS to " + to + ": " + text);
            default -> throw new IllegalArgumentException("Unknown type: " + type);
        };
    }
}
Expected output
School No. 6, Baku
true
Email to Aysel: Your exam score is 95
SMS to Aysel: Your exam score is 95
Singleton (with an enum) and Factory (a static factory method)

Singleton ensures that a class has only one object. In Java the simplest thread-safe way is a single-element enum; Spring beans are singletons by default too. Factory creates objects with a method instead of new and hides which class is created: the caller knows only the Notifier interface. In the JDK, List.of(...), Integer.valueOf(...) and Path.of(...) are factory methods.

Java
public class Main {
    public static void main(String[] args) {
        Report full = new Report.Builder("Aysel")
                .subject("Math")
                .score(95)
                .comment("Excellent work")
                .build();
        Report minimal = new Report.Builder("Murad").build();
        System.out.println(full);
        System.out.println(minimal);
    }
}

record Report(String student, String subject, int score, String comment) {
    static class Builder {                          // Builder: readable step-by-step creation
        private final String student;
        private String subject = "General";
        private int score;
        private String comment = "";

        Builder(String student) { this.student = student; }
        Builder subject(String value) { subject = value; return this; }
        Builder score(int value) { score = value; return this; }
        Builder comment(String value) { comment = value; return this; }
        Report build() { return new Report(student, subject, score, comment); }
    }
}
Expected output
Report[student=Aysel, subject=Math, score=95, comment=Excellent work]
Report[student=Murad, subject=General, score=0, comment=]
Builder: for many parameters, some of them optional

When a constructor has four or five parameters, it is hard to tell which value is which in new Report("Aysel", "Math", 95, "Excellent"), and optional parameters need many constructors. Builder assembles the object step by step with named methods, and at the end build() returns a finished, immutable object. Familiar examples: StringBuilder, HttpClient.newBuilder(), Stream.builder().

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

public class Main {
    public static void main(String[] args) {
        GradingRule strict = raw -> raw;                          // Strategy 1
        GradingRule withBonus = raw -> Math.min(100, raw + 5);    // Strategy 2

        GradeBoard board = new GradeBoard(withBonus);
        board.subscribe((name, score) -> System.out.println("[parent app] " + name + " got " + score));
        board.subscribe((name, score) -> System.out.println("[teacher log] " + name + ": " + score));

        board.addScore("Leyla", 88);
        board.setRule(strict);
        board.addScore("Elvin", 88);
    }
}

interface GradingRule { int apply(int raw); }
interface ScoreListener { void onScore(String name, int score); }

class GradeBoard {
    private GradingRule rule;
    private final List<ScoreListener> listeners = new ArrayList<>();

    GradeBoard(GradingRule rule) { this.rule = rule; }
    void setRule(GradingRule rule) { this.rule = rule; }
    void subscribe(ScoreListener listener) { listeners.add(listener); }

    void addScore(String name, int raw) {
        int score = rule.apply(raw);
        listeners.forEach(l -> l.onScore(name, score));          // Observer: notify everyone
    }
}
Expected output
[parent app] Leyla got 93
[teacher log] Leyla: 93
[parent app] Elvin got 88
[teacher log] Elvin: 88
Strategy (a swappable grading rule) and Observer (notifying subscribers)

Strategy turns an algorithm into an object that can be passed in from outside: GradeBoard does not know what is inside the bonus or the strict rule, and adding a new rule does not require editing it — this is the “O” of SOLID. Comparator is a strategy too. Observer delivers one object's change to every object subscribed to it: the parent app and the teacher log receive the same event, while GradeBoard knows nothing about them. Button listeners in graphical interfaces and Spring's events work the same way.

PatternProblem it solvesIn the JDK and Spring
Singletonone instance is enough: configuration, a cacheRuntime.getRuntime()
Factoryhiding which class gets createdList.of(), Path.of()
Builderbuilding an object with many parameters clearlyStringBuilder, HttpClient.newBuilder()
Strategyswapping an algorithm at run timeComparator
Observertelling many subscribers about an eventPropertyChangeListener, ApplicationEventPublisher

Key points

  • Clean code: meaningful names, small methods, constants instead of magic numbers, immutability, Optional, no empty catch.
  • Refactoring improves structure without changing behaviour; tests make it safe.
  • SOLID: one responsibility, extension without editing, substitutable subclasses, small interfaces, depending on abstractions.
  • Creational patterns: Singleton (enum), Factory (of(...)), Builder (build()).
  • Behavioural patterns: Strategy — a swappable algorithm (Comparator), Observer — notifying subscribers.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
What is a simple, thread-safe way to write a Singleton in Java?