Intermediate

Refactoring to SOLID Java

Technical debt is the silent killer of software projects. It accumulates over time, regardless of the quality of the team. If left unchecked, it will slowly kill the project.

Java version: JDK 17 (JDK 21+ compatible)


Table of Contents

  1. Course presentation
  2. Single Responsibility Principle (SRP)
  1. Open Closed Principle (OCP)
  1. Liskov Substitution Principle (LSP)
  1. Interface Segregation Principle (ISP)
  1. Dependency Inversion Principle (DIP)
  1. Synthesis of SOLID principles
  2. Prerequisites and configuration

1. Course presentation

This course teaches how to refactor Java applications by applying the five SOLID object-oriented principles, to make applications more robust and easier to maintain.

Educational objectives

  • Explore the Single Responsibility Principle and understand why it is one of the most important concepts in programming.
  • Discover how to add new features without touching existing code using the Open-Closed Principle.
  • Create correct relationships between types by applying the Liskov Substitution Principle.
  • Decompose interfaces that are too wide using the Interface Segregation Principle.
  • Understand how to create loosely coupled applications using the Dependency Inversion Principle.

At the end of this course, you will be able to concretely apply SOLID principles in Java applications to avoid technical debt and build testable, loosely coupled systems capable of evolving quickly over time. You will also learn many actionable techniques and refactoring tips.

Prerequisites

  • Good understanding of Java (intermediate level in object-oriented programming).
  • Knowledge of basic Java syntax.

Trainer


2. Single Responsibility Principle (SRP)

Module duration: 32m 15s

2.1 Code rigidity, code fragility and technical debt

Version check

This course was created with JDK 17, which is the minimum required version. However, it is compatible with more recent versions (JDK 21+). Java 17 records are used in demonstrations — it’s a modern Java feature that’s here to stay.

Inspirations

The course is inspired by the books of Robert C. Martin (Uncle Bob). His founding quote:

“It is not enough that the code works. »

This idea sums up a common mistake: focusing only on what is immediately in front of you, making applications work, without paying attention to how you write the software. The consequences are predictable: bugs, delays, unhappy customers, failed projects.

Code Fragility

Code brittleness is the tendency of software to break in many places every time it is modified. You make a change in a component (e.g. a class), and when you run the application, you start seeing unexpected errors and incorrect behavior in completely unrelated parts of the project.

This is a source of intense frustration for developers — the application seems to resist improvements.

Code Rigidity

Code rigidity is the tendency of software to be difficult to modify, even in simple ways. Each change leads to a cascade of other changes in dependent modules.

  • In a loosely-coupled system: changes are simple because they are isolated.
  • In a highly-coupled system: changes are complicated and require considerably more effort. Each small change leads to another change, which leads to another change, and so on.

Technical Debt

Fragility and rigidity are generally symptoms of high technical debt. Technical debt can be understood as prioritizing rapid delivery over code quality for long periods of time.

Every time you write code, you have to make a choice between speed and quality:

ChoiceShort termLong term
SpeedFast solution, immediate deliveryPoorly written code, accumulated debt
QualityLonger, more abstractionsMaintainable solution, easy to evolve

Important note: There are times when you need to fix a critical bug quickly, and it’s okay to sacrifice a little quality in this process, as long as you come back later to refactor it. The key is to make quality a long-term habit.

The cost of changes

The cost of making changes to a project should remain constant over time, ideally. However, with high technical debt:

  • The cost of changes increases over time.
  • The time required to deal with bugs reported by customers is increasing.
  • Customer satisfaction is gradually decreasing.

Technical debt accumulates over time, regardless of the quality of the team. If left unchecked, it will slowly kill your project.

Ideal workflow for controlling technical debt

Écrire du code → Implémenter des features / corriger des bugs
      ↓
Refactoriser le code pour le rendre maintenable
      ↓
Écrire plus de code
      ↓
Refactoriser à nouveau
      ↓
(Répéter indéfiniment)

Taking the time to polish and revisit your code helps control technical debt and minimize fragility and rigidity.


2.2 SOLID Overview

SOLID is an acronym for five software design principles that help build maintainable systems while keeping technical debt under control:

LetterPrincipleMeaning
SSingle Responsibility PrincipleSingle responsibility
OOpen-Closed PrincipleOpen/Closed
LLiskov Substitution PrincipleLiskov substitution
IInterface Segregation PrincipleInterface segregation
DDependency Inversion PrincipleInverting dependencies

By learning to use these design principles in your own projects, you will ensure that the code you manage is:

  • Easy to understand and reason about.
  • Editable quickly and with minimal risk.
  • Scalable over long periods of time.
  • Cost effective in terms of time and money.

Source code organization

The source code for the demos is available on GitHub. Each module is placed in a separate folder, and each folder is made up of two subfolders:

  • before/: Contains the non-refactored code that we start with at the start of each demo.
  • complete/: Contains the code refactored after applying one of the SOLID principles.

Technical prerequisites

  • Java SDK 17+
  • Maven
  • An IDE (IntelliJ recommended)

2.3 The Single Responsibility Principle

Definition

“Every function, class or module should have one and only one reason to change. » — Robert C. Martin

Each function, class, or module must have only one reason to change.

What is a “reason to change”?

A “reason to change” corresponds to responsibility. Here are examples of responsibilities commonly seen in programming:

  • business logic
  • persistence in database or file
  • The user interface (UI)
  • logging and telemetry
  • The orchestration or coordination of multiple components to achieve a specific goal
  • end users (end users)
  • I/O, network, error handling

Why is SRP important?

Code with many reasons to change is very problematic in the long term:

  • It is more difficult to understand, maintain and evolve.
  • It is difficult to test, leading to a high rate of bugs and regressions.
  • It generates side effects and high coupling.

Conversely, each component with a unique responsibility automatically becomes easier to understand, fix, and maintain. Classes become less coupled and more resistant to change.

How to implement SRP?

The implementation of the SRP can be summarized in two steps:

  1. Identify reasons for change in a given component. You need to train your eyes and mind to detect responsibilities by looking at the code.
  2. Extract these responsibilities to obtain a component that has only one purpose.

Note: The word “component” is used to refer to methods, classes, or packages as basic software units. Each method, class or package should have a single purpose.


2.4 Identify multiple reasons to change

Indicator 1: Complex if statements

if statements are a good indicator that the method is doing too many things. Each branch can be a reason to change and a responsibility.

Example: A method which checks whether the separation between an aircraft and its neighbor is safe, but whose calculation rules vary depending on the altitude:

si (altitude > 2000 pieds) {
    → Ensemble de règles A et calculs (logique métier)
} sinon (altitude haute) {
    → Vérifier les règles de séparation de l'espace aérien
    → Considérer si l'appareil a un équipement RVSM à bord
}

This method may change for several reasons:

  • If the altitude barrier changes (2000 feet changes).
  • If the Airspace object changes.
  • If RVSM rules are changed.

Solution: Move the safe separation calculation to a separate class, perhaps a specialized service that can handle all these rules.

Indicator 2: The switch instructions

switch statements are also a clue that the method is doing more than it should, especially if each case contains a particular algorithm.

Example: A method determining whether an aircraft can land on a given runway by checking wake turbulence:

switch (previousAircraft.getWakeTurbulence()) {
    case HEAVY: → algorithme A
    case UPPER_HEAVY: → algorithme B
    case UPPER_MEDIUM: → algorithme C
    // Chaque case = une raison de changer
}

Indicator 3: “God Objects” (omniscient classes)

A class that knows too much or does too many things is often called a God Object. It usually has too many methods and too many dependencies.

Indicator 4: “Master Methods”

Methods that do everything — validate data, interact with the database, send emails, log, etc. — are clear indicators of a violation of the SRP.

Example: A scheduleFlight method in FlightPlanScheduler which:

  • Save flight plan to database.
  • Sends a confirmation email.
  • Updates the airport dashboard.
  • Coordinates all necessary services.

This method coordinates all the tasks necessary to plan a flight. It must notify other components when a flight plan is planned, and it does this by explicitly calling methods on the email and dashboard services.

Best approach: Use events to decouple the class from the need to handle these notifications. After planning a flight, we can publish a FlightPlanScheduledEvent with the relevant information, reduce coupling by eliminating email and dashboard services. Components that need to react can implement a handler interface and do their own logic.

Key point: Events are an excellent alternative for managing complex orchestration while maintaining low coupling and respecting the SRP.

Indicator 5: Inconsistent packages

SRP can also apply to packages. Packages are used to organize Java classes, and they should be cohesive: keep things that change together.

Example: Pilot, Radar, or Coordinate are classes that can be added in different packages in order to keep the aircraft package clean.

Indicator 6: “People” as a reason to change

People (stakeholders, users, teams) are also reasons to change. Pay attention to accidental duplication and the role clients (users) play in using various components of your application.


2.5 Refactoring to respect the SRP

SRP refactoring steps

  1. Train your mind to spot when a component has too many reasons to change. Use the clues mentioned previously.
  2. Always ask yourself: How many reasons to change are there here?
  3. If a component has too many responsibilities, refactor it on a case-by-case basis:
  • Move responsibilities to separate methods: Sometimes simply moving responsibilities to separate methods in the same class is enough.
  • Move logic to new classes: This is probably the approach you will use most often. Create new classes that encapsulate specific responsibilities.
  • Create consistent packages: Remember to create consistent packages that group related classes together.
  • Use events: For complex processes, use events to limit coupling between components.

2.6 Demo: Refactoring a method with too many responsibilities

Problem description

The program models a radar system. The radar must display aircraft within a given range relative to itself. Three classes were created to start:

  • AircraftTarget: Represents the aircraft detected by the radar (record with ID, latitude, longitude).
  • Radar: Class with an origin coordinate and a getAircraftInRange method.
  • Program: Main class.

Code before refactoring

AircraftTarget.java (Java 17 record):

package atm;

public record AircraftTarget(String id, int lat, int lon) {
}

Radar.java (before) — The getAircraftInRange method has two responsibilities:

  1. Filter aircraft in range (radar business logic).
  2. Format the output (presentation of coordinates).
package atm;

import java.util.List;

public class Radar {
    private int originLat;
    private int originLon;

    public Radar(int originLat, int originLon) {
        this.originLat = originLat;
        this.originLon = originLon;
    }

    public String getAircraftInRange(int range, List<AircraftTarget> allAircraft, boolean latFirst) {
        // Responsabilité 1 : filtrer les aéronefs dans la plage
        var aircraftInRange = allAircraft
                .stream()
                .filter(a -> {
                    var distance = (int) Math.sqrt(
                            (originLat - a.lat()) * (originLat - a.lat()) +
                                    (originLon - a.lon()) * (originLon - a.lon()));
                    return distance <= range;
                })
                .toList();

        // Responsabilité 2 : formater la sortie (if statement = raison de changer)
        var sb = new StringBuilder();
        if (latFirst) {
            aircraftInRange.forEach(a -> sb
                    .append("[")
                    .append(a.lat())
                    .append(" ")
                    .append(a.lon())
                    .append("] "));
        } else {
            aircraftInRange.forEach(a -> sb
                    .append("[")
                    .append(a.lon())
                    .append(" ")
                    .append(a.lat())
                    .append("] "));
        }

        return sb.toString();
    }
}

Program.java (before):

package atm;

import java.util.List;

public class Program {
    public static void main(String[] args) {
        var aircraft = List.of(
                new AircraftTarget("a1", 1, 2),
                new AircraftTarget("a2", 10, 3),
                new AircraftTarget("a3", -2, 7),
                new AircraftTarget("a4", 6, -3),
                new AircraftTarget("a5", 3, 4),
                new AircraftTarget("a6", 9, 10)
        );

        var radar = new Radar(0, 0);

        // lat first
        System.out.println(radar.getAircraftInRange(6, aircraft, true));

        // lon first
        System.out.println(radar.getAircraftInRange(6, aircraft, false));
    }
}

Analysis of responsibilities

By carefully reading the getAircraftInRange method, we identify two responsibilities:

  1. Responsibility 1: The first block filters the available aircraft and creates a new list of those within the provided range. This is the main logic of the method.
  2. Responsibility 2: The last part iterates over the filtered aircraft and constructs the radar output stream. The problem is the if statement that checks the latFirst parameter — that’s one more reason to change.

This method may change for at least two reasons:

  • If the filter logic changes (different distance calculation, for example).
  • If the output format changes (new coordinate format, HTML, JSON, etc.).

Solution: Extract formatting responsibility

The second responsibility (formatting) must be extracted into a new class: CoordinateOutputFormatter.

Code after refactoring

CoordinateOutputFormatter.java (new class, sole formatting responsibility):

package atm;

import java.util.List;

public class CoordinateOutputFormatter {
    private boolean latFirst;

    public CoordinateOutputFormatter(boolean latFirst) {
        this.latFirst = latFirst;
    }

    public String parse(List<AircraftTarget> targets) {
        var sb = new StringBuilder();
        if (latFirst) {
            targets.forEach(target -> buildLatLon(sb, target));
        } else {
            targets.forEach(target -> buildLonLat(sb, target));
        }

        return sb.toString();
    }

    private StringBuilder buildLonLat(StringBuilder sb, AircraftTarget target) {
        return sb
                .append("[")
                .append(target.lon())
                .append(" ")
                .append(target.lat())
                .append("] ");
    }

    private StringBuilder buildLatLon(StringBuilder sb, AircraftTarget target) {
        return sb
                .append("[")
                .append(target.lat())
                .append(" ")
                .append(target.lon())
                .append("] ");
    }
}

Radar.java (after refactoring) — Single responsibility: filter aircraft:

package atm;

import java.util.List;

public class Radar {
    private int originLat;
    private int originLon;

    public Radar(int originLat, int originLon) {
        this.originLat = originLat;
        this.originLon = originLon;
    }

    public String getAircraftInRange(int range, List<AircraftTarget> allAircraft,
                                      CoordinateOutputFormatter formatter) {
        var aircraftInRange = allAircraft
                .stream()
                .filter(a -> {
                    var distance = (int) Math.sqrt(
                            (originLat - a.lat()) * (originLat - a.lat()) +
                                    (originLon - a.lon()) * (originLon - a.lon()));
                    return distance <= range;
                })
                .toList();

        return formatter.parse(aircraftInRange);
    }
}

Program.java (after refactoring):

package atm;

import java.util.List;

import static java.lang.System.out;

public class Program {
    public static void main(String[] args) {
        var aircraft = List.of(
                new AircraftTarget("a1", 1, 2),
                new AircraftTarget("a2", 10, 3),
                new AircraftTarget("a3", -2, 7),
                new AircraftTarget("a4", 6, -3),
                new AircraftTarget("a5", 3, 4),
                new AircraftTarget("a6", 9, 10)
        );

        var radar = new Radar(0, 0);

        // lat first
        var latLonFormatter = new CoordinateOutputFormatter(true);
        out.println(radar.getAircraftInRange(6, aircraft, latLonFormatter));

        // lon first
        var lonLatFormatter = new CoordinateOutputFormatter(false);
        out.println(radar.getAircraftInRange(6, aircraft, lonLatFormatter));
    }
}

Maven configuration (pom.xml)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>pluralsight</groupId>
    <artifactId>refactoring-solid-java17</artifactId>
    <version>1.0</version>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.13.2</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

</project>

What has changed and why

BeforeAfter
Radar.getAircraftInRange had 2 responsibilitiesRadar.getAircraftInRange has 1 responsibility (filter)
Formatting was integrated into RadarFormatting is in CoordinateOutputFormatter
Parameter boolean latFirst in RadarCoordinateOutputFormatter encapsulates this logic
Difficult to add new formatsEasy to add new trainers

2.7 SRP Summary

In this module, we covered:

  1. Code rigidity: Modifying one component breaks other unrelated parts of the application.
  2. Code fragility: Need to modify many components to implement a simple change.
  3. Both are symptoms of technical debt, which is the cost of constantly prioritizing speed over code quality.
  4. Applying SOLID principles is a great way to control technical debt.

The Single Responsibility Principle states that each component (method, class, or package) must have only one reason to change. Indicators to identify code violating the SRP:

  • Complex conditional statements (if/switch).
  • The master methods (methods that do everything).
  • God Objects (classes that know too much).
  • Inconsistent packages (too many unrelated dependencies).

Refactoring techniques to respect the SRP:

  • Create additional methods in the same class.
  • Move code to separate classes.
  • Clean up packages to make them cohesive.
  • Use events to keep responsibilities low.

Quote — Andrew Hunt and David Thomas (The Pragmatic Programmer): “We want to design components that are self-contained, independent, and with a single, well-defined purpose. »


3. Open Closed Principle (OCP)

Module duration: 20m 21s

3.1 The Open-Closed Principle

Definition

“Classes, functions, and modules should be closed for modification and open for extension. »

Classes, functions and modules must be closed to modification and open to extension.

What does “closed for editing” mean?

Adding new features should not modify existing code. Instead, components should be designed to be extensible — easy to adopt new behaviors.

The reason is simple: every time you modify existing code, there is a risk of breaking other functionality that depends on it.

Illustration of the problem without OCP

Composant A ←── dépend de ──── Composants B, C, D, E

Vous modifiez Composant A (vous êtes pressé, vous faites une erreur)
→ Tous les composants B, C, D, E qui dépendent de A sont cassés

The more dependencies a component has, the higher the risks associated with modifying it.

When can existing code be modified?

There are situations where extending a component is not pragmatic:

  • Bug fixes (fixed bugs).
  • Very trivial changes.

In these cases, add unit tests to mitigate risks.


3.2 Demo: Add functionality without OCP

Context

The FlightPlan class contains fields, a constructor and an isValid validation method. FlightPlans are essential in air traffic management, so validation is crucial before recording.

The isValid method creates an empty ValidationResult, then checks the validation rules one by one:

  1. Verify that the CallSign is not empty and has a valid length.
  2. Check that the start is not null or empty.
  3. Check that the destination is not null or empty.

New requirements

We are asked to create two new rules:

  • Check that the departure and destination are not the same.
  • Check that the destination time is after the departure time.

The naive approach (without OCP)

Without knowing the OCP, we continue to add things in the isValid method. We solve these new requirements by adding 6 lines of code in 30 seconds.

Problems with this approach

  1. The method will grow indefinitely: It already has almost 30 lines of code. The larger a method is, the more difficult it is to read and maintain.
  2. It becomes difficult to test: To test this method, you must test all the rules together, passing valid/invalid objects.
  3. Risk of introducing bugs: Every modification of existing code is risky. If the validation rules change, you need to modify this existing code (potentially breaking something).

3.3 OCP Implementation Strategies

The main idea behind all strategies is to design extensible components that can be extended easily.

Strategy 1: Inheritance and interfaces

Create new concrete classes with extended behavior, without modifying existing code.

Example: The AircraftWeightService class calculates the weight of an aircraft (average passenger weight + fuel quantity).

New requirement: take baggage into account in certain cases.

Wrong approach: Directly modify the existing method.

Simple approach: Add a new method in the same class with the luggageWeight parameter. You change the class but create new methods not yet used — the risk is minimal.

Best approach: Create a new derived class that inherits from AircraftWeightService:

// Classe de base existante (non modifiée)
public class AircraftWeightService {
    public double calculate(int passengerCount, double fuelQuantity) {
        return passengerCount * AVERAGE_PASSENGER_WEIGHT + fuelQuantity;
    }
}

// Nouvelle classe dérivée (extension sans modification)
public class AircraftWeightWithLuggageService extends AircraftWeightService {
    private double luggageWeight;

    public AircraftWeightWithLuggageService(double luggageWeight) {
        this.luggageWeight = luggageWeight;
    }

    @Override
    public double calculate(int passengerCount, double fuelQuantity) {
        return super.calculate(passengerCount, fuelQuantity) + luggageWeight;
    }
}

Strategy 2: Design Patterns

Design patterns like the Decorator Pattern or the Strategy Pattern are very effective for extending behavior:

  • Strategy Pattern: Defines a family of algorithms, encapsulates them and makes them interchangeable. Allows the algorithm to be modified independently of the clients using it.
  • Decorator Pattern: Dynamically attaches additional responsibilities to an object. Provides a flexible alternative to inheritance for extending functionality.

The “Rule Engine” as an extension pattern

To validate FlightPlan, you can create a rules engine:

Interface ValidationRule
    → CallSignNotEmpty      (implémente ValidationRule)
    → DepartureNotEmpty     (implémente ValidationRule)
    → DestinationNotEmpty   (implémente ValidationRule)
    → DepartureNotSameAsDestination (nouvelle règle)
    → DestinationTimeAfterDepartureTime (nouvelle règle)

Each new rule = a new concrete class. Existing code is never modified.


3.4 Demo: Apply OCP to add features

Refactoring of FlightPlan validation

Instead of putting all the logic in a method, we create a rule engine that can check flight plans.

ValidationRule interface:

public interface ValidationRule {
    ValidationResult validate(FlightPlan flightPlan);
}

This interface has a single method that receives a FlightPlan and returns a ValidationResult. The rules will be very granular, which allows them to be composed as desired.

Class CallSignNotEmpty (example of concrete rule):

public class CallSignNotEmpty implements ValidationRule {
    @Override
    public ValidationResult validate(FlightPlan flightPlan) {
        var result = new ValidationResult();
        if (flightPlan.getCallSign() == null || flightPlan.getCallSign().isEmpty()) {
            result.addError("CallSign cannot be empty");
        }
        return result;
    }
}

This rule has only 11 lines of code — very focused and easy to read.

Advantages of this design

  1. Compliance with OCP: To add a new validation rule, we simply create a new class implementing ValidationRule. Existing code is never modified.
  2. Compliance with the SRP: By applying the OCP, we also strengthen the SRP, because each rule has only one responsibility.
  3. Testability: Each rule can be tested individually and in isolation.
  4. Readability: The code is much easier to read — each class has a descriptive name that explains what it does.

3.5 OCP for frameworks and APIs

Particular importance in frameworks and APIs

If you create frameworks for other people or work on APIs, you need to be very careful in making changes because they can break your clients in unexpected ways. The nature of these projects requires you to make extensions rather than modifications.

Best practices for modifying APIs or frameworks

  1. Do not modify existing public contracts: class data, method signatures.
  2. Expose abstractions to customers and let them add new behaviors on top of your framework.
  3. Use versioning if a breaking change is unavoidable, and give customers time to adapt.

Concrete example: The RadarStreamReader class reads radar data from a file. For compliance reasons, we are forced to use a byte array instead.

Don’t: Change or remove the existing method until your customers have had time to adapt.

To do:

public class RadarStreamReader {

    // Méthode originale marquée comme dépréciée
    @Deprecated
    public RadarData readFromFile(String filePath) {
        // Ancienne implémentation
    }

    // Nouvelle méthode overloadée
    public RadarData readFromBytes(byte[] data) {
        // Nouvelle implémentation
    }
}

Mark the old method as @Deprecated, alerting users that it will be removed in a future release. Add the new method in parallel. After a while, remove the old functionality.


3.6 OCP Summary

The main idea: modifying existing code is a risk. If your component is used by many other components or if it performs critical operations, the risks are very high.

To mitigate this risk, the Open-Closed Principle states that components should be closed to modification and open to extension.

Proven techniques:

  • Inheritance and interfaces to add new functionality without altering existing code.
  • The Decorator Pattern and the Strategy Pattern to extend the behavior.
  • Versioning and preservation of existing data contracts for frameworks and APIs.

Key principle: OCP is about change management. By following this principle, you will eliminate the risks associated with modifications and achieve designs that are easy and painless to extend.


4. Liskov Substitution Principle (LSP)

Module duration: 20m 58s

4.1 The Liskov Substitution Principle

Context

LSP is the least understood principle in the SOLID universe. This is not because it is very complex or difficult to grasp — on the contrary, it is very subtle and easy to transgress.

Formal definition

“If S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without modifying the behavior of the program. » — Barbara Liskov (1987)

Accessible definition

The LSP stipulates that any object of a type must be substitutable by objects of a derived type without altering the correctness of the program.

The keyword is substitutable.

The human thought trap

As humans, we tend to classify objects using “is-a” (is-a) relationships. We often say:

  • A square is a rectangle.
  • An ostrich is a bird.

But in programming, this way of classifying can lead to bad hierarchies. Substitutability does not depend on human logic — it depends on behavior.

Classic example: A square is a rectangle in mathematics, but in programming, if Square inherits from Rectangle, and Rectangle has independent setWidth and setHeight methods, a Square cannot respect them without forcing the constraint that width == height. This different behavior violates the LSP.


4.2 Violating the Liskov Substitution Principle

It is very easy to get confused in class hierarchies. Here are the clues that indicate that the object relationships are incorrect:

  1. Empty methods or empty functions (empty methods).
  2. hardened preconditions.
  3. Partially implemented abstract class interfaces or methods.
  4. Type checking.

Example 1: Empty method

// Classe de base
public class Plane {
    public void setAltitude(int altitude) {
        // Définit l'altitude de l'aéronef
        this.altitude = altitude;
    }
}

// Classe dérivée (PROBLÈME : un planeur n'a pas de contrôle d'altitude)
public class Glider extends Plane {
    @Override
    public void setAltitude(int altitude) {
        // Méthode vide — ne fait rien !
    }
}

Problem: The method can still be invoked on a Glider object. The program does not crash, but the result is misleading. If you read glider.setAltitude(1000) you think you have set the altitude to 1000 ft, but in reality nothing happens.

An object of type Glider cannot substitute an object of type Plane while preserving the correctness of the application.

Example 2: Reinforced preconditions

// Classe de base
public class RectangularAirspace {
    private int width;
    private int height;

    public void setWidth(int width) { this.width = width; }
    public void setHeight(int height) { this.height = height; }
}

// Classe dérivée (PROBLÈME : un carré a width == height)
public class SquareAirspace extends RectangularAirspace {
    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width); // Renforce la contrainte width == height
    }

    @Override
    public void setHeight(int height) {
        super.setHeight(height);
        super.setWidth(height); // Renforce la contrainte width == height
    }
}

Problem: Code that uses RectangularAirspace and calls setWidth(10) then setHeight(20) expects a 10x20 rectangle. If we substitute with SquareAirspace, we obtain a 20x20 square, which alters the correction of the program.

Example 3: Type checking

// Mauvais signe : forcer la vérification du type réel
public double calculateDistance(TwoDimensionalPoint p1, TwoDimensionalPoint p2) {
    if (p1 instanceof ThreeDimensionalPoint && p2 instanceof ThreeDimensionalPoint) {
        // Calcul en 3D
    } else {
        // Calcul en 2D
    }
}

The presence of an instanceof in the code is a strong indicator that the type hierarchy is incorrect.

Example 4: Partially implemented interface

public interface Aircraft {
    void fly();
    void brake(); // Freiner lors de l'atterrissage
}

// PROBLÈME : un hélicoptère atterrit verticalement et ne peut pas freiner normalement
public class Helicopter implements Aircraft {
    @Override
    public void fly() { /* OK */ }

    @Override
    public void brake() {
        throw new UnsupportedOperationException("Helicopters don't brake!"); // Violation !
    }
}

4.3 Demo: Incorrect inheritance between classes

Scenario description

We create TwoDimensionalPoint (latitude, longitude) and ThreeDimensionalPoint which inherits from TwoDimensionalPoint (by adding the altitude). In math, a 3D point is a special kind of 2D point, so inheritance seems logical.

// TwoDimensionalPoint
public class TwoDimensionalPoint {
    private double latitude;
    private double longitude;

    public TwoDimensionalPoint(double latitude, double longitude) {
        this.latitude = latitude;
        this.longitude = longitude;
    }

    // getters...
}

// ThreeDimensionalPoint extends TwoDimensionalPoint
public class ThreeDimensionalPoint extends TwoDimensionalPoint {
    private double altitude;

    public ThreeDimensionalPoint(double latitude, double longitude, double altitude) {
        super(latitude, longitude);
        this.altitude = altitude;
    }

    // getter...
}

The DistanceCalculator receives two TwoDimensionalPoint and calculates the distance.

The problem

  • Case 1: We pass two TwoDimensionalPoint → correct result (2D distance).
  • Case 2: We pass two ThreeDimensionalPoint → the program does not crash (because ThreeDimensionalPoint extends TwoDimensionalPoint), but the result is incorrect because the third coordinate (altitude) is not taken into account.

These lines of code mislead the reader. We think we are calculating the distance between 3D coordinates, but that is not what is happening.

The wrong attempt at correction

An attempted fix is ​​to modify DistanceCalculator to check the actual type of coordinates:

public double calculate(TwoDimensionalPoint p1, TwoDimensionalPoint p2) {
    if (p1 instanceof ThreeDimensionalPoint && p2 instanceof ThreeDimensionalPoint) {
        // Calcul 3D
    } else {
        // Calcul 2D
    }
}

Problem: This approach uses type checking (instanceof), which is a clear indicator of an LSP violation. If we add a FourDimensionalPoint later, we must modify this method again.


4.4 Refactoring to respect the LSP

Main technique: Eliminate the incorrect relationship

In most cases the only way to correct things is to eliminate the incorrect relationship between classes. This means breaking inheritance or interface implementation.

Glider/Plane example:

It was incorrect to assume that a glider is a type of aircraft. To fix, simply remove the relationship — Glider should not extend Plane. Now Glider is a standalone class.

Helicopter/Aircraft example:

If Helicopter implements Aircraft and is forced to implement the brake method incorrectly, the best approach is to divide the initial interface into two interfaces:

// Interface générique pour tous les objets volants
public interface FlyingObject {
    void fly();
    void navigate();
}

// Interface spécialisée pour les avions (avec piste)
public interface FixedWingAircraft extends FlyingObject {
    void brake();
    void landOnRunway();
}

// L'hélicoptère peut implémenter FlyingObject sans problème
public class Helicopter implements FlyingObject {
    @Override public void fly() { /* OK */ }
    @Override public void navigate() { /* OK */ }
}

// L'avion peut implémenter FixedWingAircraft
public class CommercialPlane implements FixedWingAircraft {
    @Override public void fly() { /* OK */ }
    @Override public void navigate() { /* OK */ }
    @Override public void brake() { /* OK */ }
    @Override public void landOnRunway() { /* OK */ }
}

By taking this route, we apply both LSP and ISP (Interface Segregation Principle).

Secondary technique: Tell, Don’t Ask

The Tell, Don’t Ask principle can be used to eliminate type checking and casting. Rather than asking an object what it is (with instanceof), tell it what it should do.


4.5 Demo: Refactoring classes for the LSP

Solution

The main problem was the inheritance relationship between TwoDimensionalPoint and ThreeDimensionalPoint. In this context, a ThreeDimensionalPoint cannot fully replace a TwoDimensionalPoint while preserving the correctness of the program (in particular for calculating distances).

Refactoring steps:

  1. Break the relationship: ThreeDimensionalPoint should no longer inherit from TwoDimensionalPoint.
  2. Add necessary fields in ThreeDimensionalPoint (latitude, longitude, altitude — standalone class).
  3. Modify DistanceCalculator: Use the overloading mechanism to have one method per point type.
public class DistanceCalculator {
    // Méthode pour points 2D
    public double calculate(TwoDimensionalPoint p1, TwoDimensionalPoint p2) {
        return Math.sqrt(
            Math.pow(p2.getLatitude() - p1.getLatitude(), 2) +
            Math.pow(p2.getLongitude() - p1.getLongitude(), 2)
        );
    }

    // Méthode overloadée pour points 3D (formule correcte)
    public double calculate(ThreeDimensionalPoint p1, ThreeDimensionalPoint p2) {
        return Math.sqrt(
            Math.pow(p2.getLatitude() - p1.getLatitude(), 2) +
            Math.pow(p2.getLongitude() - p1.getLongitude(), 2) +
            Math.pow(p2.getAltitude() - p1.getAltitude(), 2)
        );
    }
}
  1. Modify Main: Eliminate cases where the calculation method could receive an argument of one type and the second argument of another type. The two types of coordinates no longer form a hierarchy.

Result: The code respects the LSP and behaves as expected. When passing TwoDimensionalPoint, it calculates the correct distance in 2D. When passing ThreeDimensionalPoint, it calculates the correct distance in 3D.


4.6 LSP Summary

Key takeaways:

  • Stop thinking in terms of “is a” (is-a). Instead, ask yourself: can a subtype fully override its base type without altering the correctness of the application?
  • Empty methods, type checking, and hardened preconditions are all signs that you are violating the LSP.
  • This principle also applies to interfaces, not just classes. Unimplemented or empty methods are a clear indicator of violation.
  • Fix incorrect class hierarchies by eliminating relationships between these types.
  • Focus your attention on creating correct type hierarchies from the start.

Quote — SOLID motivational poster: “If it looks like a duck, quacks like a duck, but needs batteries, you probably have the wrong abstraction. »


5. Interface Segregation Principle (ISP)

Module duration: 17m 20s

5.1 The Interface Segregation Principle

Formal definition

“Clients should not be forced to depend on methods that they do not use. »

Clients should not be forced to rely on methods they do not use. In other words: keep interfaces short and focused.

Important: In the context of ISP, the word “interfaces” does not necessarily refer to Java interfaces, but rather to object-oriented abstractions which include interfaces and abstract classes.

Advantages of ISP

  1. Lightweight interfaces minimize dependencies on unused members and reduce code coupling.
  2. The code becomes more cohesive and focused.
  3. We obtain more composable abstractions which give more flexibility when using them.
  4. Strengthening SRP and LSP: SOLID principles work together and reinforce each other.

Interaction with other principles

  • ISP strengthens LSP: By keeping abstractions small, the classes that implement them are more likely to fully override the abstraction.
  • LSP strengthens SRP: Classes that implement smaller interfaces are more focused and have less reason to change.

5.2 Identify “fat” interfaces

Fat interfaces are those likely to violate the Interface Segregation Principle. Here are the main clues:

  1. Interfaces with many methods: Too many methods in a single interface.
  2. Interfaces with low cohesion: The methods seem unrelated to each other.
  3. Clients throwing exceptions instead of fully implementing methods (NotImplementedException, UnsupportedOperationException).
  4. Clients that provide an empty implementation for some methods.
  5. Client implements a large interface and becomes tightly coupled.

Example 1: LoginService interface with too many methods

// Interface "fat" — trop de méthodes
public interface LoginService {
    boolean authenticate(String username, String password);
    void logout(String sessionId);
    void updateRememberMeCookie(String cookie);
    void setSessionExpiration(int minutes);
    void logLoginAttempt(String username, boolean success);
    String generateToken(String userId);
    void validateToken(String token);
}

Problem: If you need to create a JWT authentication provider, you do not have cookies, so updateRememberMeCookie cannot be implemented correctly. You might not need setSessionExpiration either. The JWT authenticator is forced to implement more methods than it needs.

Example 2: Helicopter and Aircraft interface

public interface Aircraft {
    void fly();
    void navigate();
    void brake(); // Problème : les hélicoptères atterrissent verticalement
}

public class Helicopter implements Aircraft {
    @Override public void fly() { /* OK */ }
    @Override public void navigate() { /* OK */ }

    @Override
    public void brake() {
        throw new UnsupportedOperationException("Helicopters land vertically!");
        // Viole ISP ET LSP
    }
}

This code violates two object-oriented principles: LSP and ISP. The abstraction is simply too great.

Example 3: AircraftSeatsChooser with too many responsibilities

// Interface "fat" avec trop de responsabilités
public interface AircraftSeatsChooser {
    void selectSeat(String seatId);
    void processPayment(double amount); // Responsabilité paiement
    void renderSeatMap(Display display); // Responsabilité rendu visuel
}

This interface does too many things: booking seats, processing payments and rendering the seat map.


5.3 Demo: Problems caused by “fat” interfaces

Context

In the air traffic control industry, controllers work with aircraft labels to get a clear view of aircraft in flight. They can interact with these tags to see more details about a particular flight and, in modern systems, issue commands to pilots.

Initial AircraftLabel interface:

public interface AircraftLabel {
    String getDisplayValue();          // Afficher l'identification
    void setColor(Color color);        // Changer la couleur (sélection)
    void onLabelClicked();             // Interaction souris : clic
    void onLabelDoubleClicked();       // Interaction souris : double-clic
    void onLabelTouched();             // Interaction tactile
    void issueCommand(String command); // Émettre des commandes
}

The problem

We are asked to develop a StandardDisplayAircraftLabel that works with standard displays without touch support. The initial version of the client does not need the commands functionality.

The StandardDisplayAircraftLabel class is then forced to implement:

  • onLabelTouched() → empty body (no touch support)
  • issueCommand() → empty body (not necessary)
public class StandardDisplayAircraftLabel implements AircraftLabel {
    private String callSign;
    private Coordinate currentPosition;
    private int speed;
    private Color currentColor;

    @Override
    public String getDisplayValue() {
        return callSign + " " + currentPosition + " " + speed;
    }

    @Override
    public void setColor(Color color) {
        this.currentColor = color;
    }

    @Override
    public void onLabelClicked() {
        // Logique du clic
    }

    @Override
    public void onLabelDoubleClicked() {
        // Logique du double-clic
    }

    @Override
    public void onLabelTouched() {
        // Corps vide — pas de support tactile
    }

    @Override
    public void issueCommand(String command) {
        // Corps vide — pas nécessaire pour ce client
    }
}

These empty methods are clear indicators that the AircraftLabel interface is too broad.


5.4 Refactoring code that depends on large interfaces

Available options

Option 1: You own the code → Split the fat interface into multiple interfaces.

It is safe to divide a large interface into several smaller ones because in Java one can implement as many interfaces as desired. Instead of having one large interface, you get many lightweight interfaces that you can compose as needed.

Option 2: Legacy or third-party code (you do not own the interfaces) → Use the Adapter Pattern.

We create our own abstraction and depend on it rather than abstractions defined by others.

Refactoring example: AircraftSeatsChooser

// Avant : une seule interface "fat"
public interface AircraftSeatsChooser {
    void selectSeat(String seatId);
    void processPayment(double amount);
    void renderSeatMap(Display display);
}

// Après : interfaces séparées et focalisées
public interface AircraftSeatChooser {
    void selectSeat(String seatId);
    boolean isSeatAvailable(String seatId);
}

public interface PaymentProcessor {
    void processPayment(double amount);
    void refund(String transactionId);
}

public interface SeatRenderer {
    void renderSeatMap(Display display);
    void highlightSeat(String seatId);
}

With this approach, if a client wants a rendered seat selector, they can implement AircraftSeatChooser and SeatRenderer. There will be no unused methods.


5.5 Demo: Refactoring code to ISP

Refactoring the AircraftLabel interface

We have the AircraftLabel interface, so we can simply divide it into several interfaces based on its responsibilities.

New interfaces created:

// Interface de base : affichage et couleur (commune à tous)
public interface AircraftLabel {
    String getDisplayValue();
    void setColor(Color color);
}

// Interface pour les interactions souris
public interface LabelWithMouseInteraction {
    void onLabelClicked();
    void onLabelDoubleClicked();
}

// Interface pour les interactions tactiles
public interface LabelWithTouchInteraction {
    void onLabelTouched();
}

// Interface pour la capacité à émettre des commandes
public interface LabelWithCommandCapability {
    void issueCommand(String command);
}

StandardDisplayAircraftLabel after refactoring:

// Implémente uniquement les interfaces pertinentes
public class StandardDisplayAircraftLabel
        implements AircraftLabel, LabelWithMouseInteraction {

    private String callSign;
    private Coordinate currentPosition;
    private int speed;
    private Color currentColor;

    @Override
    public String getDisplayValue() {
        return callSign + " " + currentPosition + " " + speed;
    }

    @Override
    public void setColor(Color color) {
        this.currentColor = color;
    }

    @Override
    public void onLabelClicked() {
        // Logique du clic
    }

    @Override
    public void onLabelDoubleClicked() {
        // Logique du double-clic
    }
    // Plus de méthodes vides !
}

For a system with touch screen and controls:

public class TouchScreenAircraftLabel
        implements AircraftLabel, LabelWithMouseInteraction,
                   LabelWithTouchInteraction, LabelWithCommandCapability {
    // Implémente toutes les interfaces pertinentes
}

What the refactoring accomplished

  • The StandardDisplayAircraftLabel class is now clean and focused.
  • It implements two interfaces and provides adequate implementations for all methods defined in these abstractions.
  • No more empty methods hanging around with empty bodies.
  • Refactoring is easy to do if you own the interfaces — and generally doesn’t break existing code.

5.6 ISP Summary

ISP helps create small composable interfaces that can be used and combined.

Large interfaces can cause unexpected bugs or side effects — it is best to avoid using oversized abstractions.

Identifying large interfaces is relatively simple if you pay attention to the symptoms:

  • Methods that throw NotImplementedException.
  • Methods with an empty body.

Refactoring for ISP simply involves dividing large interfaces into several focused interfaces that can then be combined. If you don’t own the interfaces, use the Adapter Pattern to isolate yourself from large abstractions that you can’t modify.

Quote — Robert C. Martin: “Fat interfaces lead to inadvertent couplings between components that ought otherwise to be isolated. »


6. Dependency Inversion Principle (DIP)

Module duration: 21m 43s

6.1 The Dependency Inversion Principle

Frequent confusion

This principle causes confusion, especially among new developers. Developers tend to confuse this principle with Dependency Injection (DI) or Inversion of Control (IoC). Although they are related, they are not the same thing.

Formal definition

“High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions. »

Definition of terms

High-level module:

  • A module or component created to solve real problems and use cases.
  • Generally more abstract and directly linked to the business domain.
  • Example: AirportFlightInformation, FlightPlanScheduler.

Low-level module:

  • Provides low-level utility services, such as data access, logging, file management.
  • Examples: FileFlightRepository, SqlFlightPlanRepository, EmailService.

Abstraction:

  • A Java interface or abstract class that defines a contract without implementation details.
  • Example: FlightRepository interface with a findAll() method.

The direct coupling problem

If AirportFlightInformation (high level) directly instantiates FileFlightRepository (low level), any modification to FileFlightRepository may break AirportFlightInformation. Additionally, it is difficult to test AirportFlightInformation in isolation because it needs a real repository instance.


6.2 Demo: High-level components depend on low-level components

Context

We model a system that displays flight information in airport terminals.

  • Flight: Main entity (record with callSign, destination, departureTime, callToAction).
  • FileFlightRepository: Low-level class that loads all available flights from a file.
  • AirportFlightInformation: High level service with two methods:
  • displayAllFlights() — Displays all flights.
  • displayFlightsDepartingNow() — Displays flights departing now.

The concrete problem

// MAUVAIS : Module de haut niveau qui dépend directement d'un détail de bas niveau
public class AirportFlightInformation {
    // Dépend directement de la classe concrète FileFlightRepository
    private FileFlightRepository repository = new FileFlightRepository("/data/flights.txt");

    public void displayAllFlights() {
        var flights = repository.findAll();
        flights.forEach(f -> System.out.println(f.getCallSign() + " → " + f.getDestination()));
    }

    public void displayFlightsDepartingNow() {
        var now = LocalTime.now();
        var flights = repository.findAll()
            .stream()
            .filter(f -> f.getDepartureTime().equals(now))
            .toList();
        flights.forEach(f -> System.out.println(f));
    }
}

Issue 1: The file path is hardcoded. If we pass it as an argument to the constructor:

public class FileFlightRepository {
    private String filePath;

    public FileFlightRepository(String filePath) {
        this.filePath = filePath;
    }

    public List<Flight> findAll() {
        // Lecture depuis this.filePath
    }
}

This breaks AirportFlightInformation because it instantiates the repository directly.

Problem 2: If we decide to use an SQL database instead of files, we must modify AirportFlightInformation, which is a high-level module.

Problem 3: Difficult to test — how to test AirportFlightInformation in isolation without needing a real file?


6.3 Write code that respects the Dependency Inversion Principle

Solution: Introduce an abstraction

// SqlFlightPlanRepository (bas niveau) — problème initial
public class SqlFlightPlanRepository {
    public FlightPlan getById(String id) {
        // Requête SQL...
    }
}

// FlightPlanScheduler (haut niveau) — problème initial
public class FlightPlanScheduler {
    public void scheduleFlight(String id, LocalDateTime departureTime) {
        // Instancie directement le repository de bas niveau — PROBLÈME
        var repository = new SqlFlightPlanRepository();
        var flightPlan = repository.getById(id);
        flightPlan.setDepartureTime(departureTime);
    }
}

Refactoring steps:

  1. Create an interface (abstraction) for the repository:
// L'abstraction — ni haut ni bas niveau
public interface FlightPlanRepository {
    FlightPlan getById(String id);
}
  1. The low-level module implements the abstraction:
// Le bas niveau dépend de l'abstraction (pas le contraire)
public class SqlFlightPlanRepository implements FlightPlanRepository {
    @Override
    public FlightPlan getById(String id) {
        // Requête SQL...
    }
}
  1. Create a factory method that constructs the concrete type but returns it as an abstraction:
public class FlightPlanRepositoryFactory {
    public static FlightPlanRepository create() {
        return new SqlFlightPlanRepository(); // Type concret, retourné comme abstraction
    }
}
  1. High level module depends on abstraction:
public class FlightPlanScheduler {
    private FlightPlanRepository repository; // Dépend de l'abstraction !

    public FlightPlanScheduler() {
        this.repository = FlightPlanRepositoryFactory.create();
    }

    public void scheduleFlight(String id, LocalDateTime departureTime) {
        var flightPlan = repository.getById(id);
        flightPlan.setDepartureTime(departureTime);
    }
}

Note: There is still a coupling with FlightPlanRepositoryFactory — this is where Dependency Injection comes in.


6.4 Dependency Injection (DI)

Definition

Dependency Injection is a technique that allows the creation of dependent objects outside of a class and provides these dependencies to the class.

Remaining issue after basic DIP

public class FlightPlanScheduler {
    private FlightPlanRepository repository;

    public FlightPlanScheduler() {
        // Toujours couplé à FlightPlanRepositoryFactory !
        this.repository = FlightPlanRepositoryFactory.create();
    }
}

This high-level module still uses FlightPlanRepositoryFactory directly, which is a low-level component.

Solution: Dependency Injection by constructor

// BIEN : Toutes les dépendances sont déclarées comme abstractions dans le constructeur
public class FlightPlanScheduler {
    private final FlightPlanRepository repository; // Abstraction !

    // Injection par constructeur
    public FlightPlanScheduler(FlightPlanRepository repository) {
        this.repository = repository;
    }

    public void scheduleFlight(String id, LocalDateTime departureTime) {
        // Focalisé uniquement sur ce qu'il doit faire
        // Plus de création de repository ici !
        var flightPlan = repository.getById(id);
        flightPlan.setDepartureTime(departureTime);
    }
}

Usage

// Création du repository en dehors de la classe
FlightPlanRepository repository = new SqlFlightPlanRepository();
FlightPlanScheduler scheduler = new FlightPlanScheduler(repository);
scheduler.scheduleFlight("FLT-001", LocalDateTime.now().plusHours(2));

Advantages

  1. Eliminates any coupling with low-level components.
  2. Facilitates testing: You can pass a mock repository to the manufacturer and test FlightPlanScheduler in isolation.
  3. Flexibility: You can use any implementation that respects the FlightPlanRepository interface (SQL, file, in memory, etc.).

The injection problem in large applications

In real applications, components have many dependencies and the dependencies themselves have dependencies:

ClassA → ClassB, ClassC
ClassB → ClassD
ClassC → ClassE

To instantiate ClassA:

var d = new ClassD();
var e = new ClassE();
var b = new ClassB(d);
var c = new ClassC(e);
var a = new ClassA(b, c);

This gets very complicated in real applications. This is where Inversion of Control (IoC) comes in.


6.5 Inversion of Control (IoC)

Definition

Inversion of Control (IoC) is a design principle in which the management of the creation, configuration and lifecycle of objects is entrusted to another framework or container.

  • We no longer need to create objects manually with new.
  • The container creates them for you.
  • This is called “Inversion” of Control because control of object creation is removed from the programmer.

When to use IoC?

IoC works well for service-like classes. It should not be used for:

  • The entities.
  • value objects.

In these cases, you must control the creation of the objects.

Benefits of IoC Container

  1. Allows switching between different implementations at runtime without touching or recompiling the code.
  2. Increases the modularity of the program.
  3. Manages the lifecycle of objects and their configuration (for example, singleton).
LibraryDescription
SpringThe most used, very complete
Google GuiceLight and powerful
DaggerCompile-time dependency injection

Relationship between DIP, DI and IoC

DIP (Principe)
    → Haut niveau et bas niveau doivent dépendre d'abstractions

DI (Technique d'implémentation du DIP)
    → Créer les dépendances en dehors de la classe et les passer par constructeur

IoC (Framework/Conteneur qui automatise la DI)
    → Gère automatiquement la création et l'injection des dépendances

6.6 Demo: Refactoring the code to respect the DIP

Return to Airport Flight Information System

We start again from the FileFlightRepository and AirportFlightInformation case where we broke the high-level class by modifying the repository constructor.

Complete solution:

Step 1: Create the interface (abstraction)

public interface FlightRepository {
    List<Flight> findAll();
}

Step 2: The low-level repository implements the interface

public class FileFlightRepository implements FlightRepository {
    private String filePath;

    public FileFlightRepository(String filePath) {
        this.filePath = filePath;
    }

    @Override
    public List<Flight> findAll() {
        // Lecture depuis le fichier
        try (var reader = new BufferedReader(new FileReader(filePath))) {
            return reader.lines()
                .map(line -> parseFlightFromLine(line))
                .toList();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

Step 3: Top-level class depends on abstraction via DI

public class AirportFlightInformation {
    private final FlightRepository repository; // Abstraction !

    // Dependency Injection par constructeur
    public AirportFlightInformation(FlightRepository repository) {
        this.repository = repository;
    }

    public void displayAllFlights() {
        var flights = repository.findAll();
        flights.forEach(f -> System.out.println(f.getCallSign() + " → " + f.getDestination()));
    }

    public void displayFlightsDepartingNow() {
        var now = LocalTime.now();
        var flights = repository.findAll()
            .stream()
            .filter(f -> f.getDepartureTime().toLocalTime().equals(now))
            .toList();
        flights.forEach(f -> System.out.println(f));
    }
}

Step 4: Usage

public class Main {
    public static void main(String[] args) {
        // Créer le repository de bas niveau
        var repository = new FileFlightRepository("/data/flights.txt");

        // Injecter dans le service de haut niveau
        var flightInfo = new AirportFlightInformation(repository);
        flightInfo.displayAllFlights();
        flightInfo.displayFlightsDepartingNow();
    }
}

Advantages of this design

  • AirportFlightInformation knows nothing about the internal details of the repository.
  • It does not know if the data comes from a file, an SQL database, an API, or a memory cache.
  • It only cares that the repository respects its contract (the FlightRepository interface).
  • Testability: We can easily pass a mock:
// Test avec un mock repository
var mockRepository = new InMemoryFlightRepository(List.of(
    new Flight("AF123", "Paris", LocalDateTime.now(), "Boarding now"),
    new Flight("LH456", "Frankfurt", LocalDateTime.now().plusHours(1), "On time")
));
var flightInfo = new AirportFlightInformation(mockRepository);
// Tests sans avoir besoin d'un vrai fichier !

6.7 DIP Summary and Course Summary

DIP Summary

Key points:

  • High-level classes should depend on abstractions and not implementation details.
  • The Dependency Inversion Principle, Dependency Injection and Inversion of Control work together to eliminate coupling between components.
  • Stability is greatly improved with DIP and DI, as components can be isolated effectively.
  • For testing, you can create mock objects and pass them to the constructor.
  • In enterprise applications, use an IoC container (Spring, Guice, Dagger).

“Always remember that new is glue. If you new up service-like components, you generate coupling, which is not good. »


7. Summary of SOLID principles

Overview

The five SOLID principles provide the foundation for building clean, maintainable architectures. Here is a full recap:

PrincipleMain ruleProblem solved
S — Single ResponsibilityOne component = one reason to changeCode too coupled, difficult to test
O — Open-ClosedClosed to modification, open to extensionRisk of breaking existing functionality
L — Liskov SubstitutionA subtype must be substitutable for its base typeIncorrect type hierarchies
I — Interface SegregationCustomers only depend on the methods they use“Fat” interfaces causing inadvertent coupling
D — Dependency InversionHigh level and low level depend on abstractionsStrong coupling between components

Principles work together

The SOLID principles are interconnected and mutually reinforcing:

  • Applying SRP makes the code easier to extend (strengthens OCP).
  • Applying the ISP helps to respect the LSP (smaller interfaces = easier to fully implement them).
  • Applying DIP makes the code testable and makes mock injection easier.

Technical debt is the real enemy

Technical debt is the silent killer of software projects. It accumulates over time, regardless of the quality of the team. If left unchecked, it will slowly kill the project.

Recommended workflow:

  1. Write code, implement features.
  2. Refactor regularly.
  3. Apply SOLID principles to maintain low coupling.
  4. Write unit tests for key components.
  5. Repeat.

Refactoring techniques by principle

For SRP

  • Extract methods.
  • Move code to separate classes.
  • Organize packages by cohesion.
  • Use events for orchestration.

For OCP

  • Use inheritance to extend behavior.
  • Use Strategy Pattern for interchangeable algorithms.
  • Use the Decorator Pattern to add behaviors dynamically.
  • Create rule engines for extensible validations.

For the LSP

  • Ask: “Can this subtype fully override the base type?” »
  • Remove incorrect hierarchies.
  • Split interfaces into smaller, focused interfaces.

For ISP

  • Split “fat” interfaces into small, coherent interfaces.
  • Use Adapter Pattern for third-party interfaces.
  • Compose interfaces according to customer needs.

For DIP

  • Introduce abstractions (interfaces or abstract classes).
  • Use constructor injection.
  • Delegate dependency management to an IoC container.

Anti-patterns to avoid

Anti-patternDescriptionPrinciple violated
God ObjectClass that knows/does everythingPRS
Master MethodMethod that does everythingPRS
Feature EnvyMethod that greatly modifies the state of other classesSRP, OCP
Type Checkinginstanceof in polymorphic codeLSP
Empty Method OverrideOverloaded methods that are empty or throw exceptionsLSP, ISP
Fat InterfaceInterface with too many inconsistent methodsISP
New is GlueInstantiate services directly with newDIP
Hardcoded dependenciesHard-coded paths, URLs, credentials in classesDIP

Key concepts to master

Records Java 17

Records are a Java 17 feature used extensively in this course. They allow you to create immutable data classes in a concise way:

// Déclaration
public record AircraftTarget(String id, int lat, int lon) {}

// Utilisation
var target = new AircraftTarget("a1", 10, 20);
System.out.println(target.id());  // a1
System.out.println(target.lat()); // 10

Streams and Lambda

The demo code makes extensive use of Java streams and lambdas:

var aircraftInRange = allAircraft
    .stream()
    .filter(a -> distance(a) <= range) // Lambda de filtrage
    .toList();                          // Collecte en liste

var (local inference type)

Java 10+ allows you to infer the type of local variables with var:

var aircraft = List.of(new AircraftTarget("a1", 1, 2)); // List<AircraftTarget>
var radar = new Radar(0, 0); // Radar

8. Prerequisites and configuration

Development environment

ToolMinimal versionNote
JDK17JDK 21+ compatible
Maven3.xDependency Manager
IDEAllIntelliJ recommended
JUnit4.13.2For unit testing

Maven configuration (pom.xml)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>pluralsight</groupId>
    <artifactId>refactoring-solid-java17</artifactId>
    <version>1.0</version>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.13.2</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

Source code structure

The source code is organized as follows for each module:

02/                         ← Module SRP
  demos/
    demo/
      before/               ← Code avant refactoring
        pom.xml
        src/main/java/atm/
          AircraftTarget.java
          Radar.java
          Program.java
      complete/             ← Code après refactoring
        pom.xml
        src/main/java/atm/
          AircraftTarget.java
          Radar.java
          CoordinateOutputFormatter.java
          Program.java

03/                         ← Module OCP (fichiers ZIP)
  demos/
    ocp-demo-before.zip
    ocp-demo-complete.zip

04/                         ← Module LSP (fichiers ZIP)
  demos/
    demo-lsp-before.zip
    demo-lsp-complete.zip

05/                         ← Module ISP (fichiers ZIP)
  demos/
    isp-before.zip
    isp-complete.zip

06/                         ← Module DIP (fichiers ZIP)
  demos/
    dip-before.zip
    dip-complete.zip

Useful Maven Commands

# Compiler le projet
mvn compile

# Exécuter les tests
mvn test

# Construire le projet (compile + test)
mvn package

# Exécuter le programme principal
mvn exec:java -Dexec.mainClass="atm.Program"

# Nettoyer le projet
mvn clean

Additional Resources



Search Terms

refactoring · solid · java · backend · architecture · full-stack · web · principle · definition · ocp · dip · srp · indicator · interface · dependency · ioc · isp · lsp · technical · advantages · context · debt · interfaces · inversion

Interested in this course?

Contact us to book it or get a custom training plan for your team.