Author Archives: admin

Adapter

In programming, a common problem is an interface mismatch. A class exists, but its interface is not what the system expects. The Adapter design pattern lets classes with incompatible interfaces work together.

There is no need to rewrite code or change existing classes. The Adapter “translates” one interface into another. It does this without modifying existing code.

Let’s consider an application that shows a weather forecast: WeatherClient. The application needs an object that implements the WeatherService interface. It calls the method getCurrentTemperature() on that object.

public class WeatherClient {
    WeatherService weatherService;

    public WeatherClient(WeatherService weatherService) {
        this.weatherService = weatherService;
    }

    public void showTemperature (String city) {
        System.out.println(city + ": " + weatherService.getCurrentTemperature(city));
    }
}

The WeatherService interface declares 1method, getCurrentTemperature(). It returns a String:

public interface WeatherService {
    String getCurrentTemperature(String city);
}

But the weather data comes from an external API. The ExternalWeatherApi interface declares one method, getTemperatureCelsius(). It returns a double:

public interface ExternalWeatherApi {
    public double getTemperatureCelsius (String city);
}

To connect with API, the code needs an object that implements ExternalWeatherApi. But this object can’t be passed to WeatherClient, because WeatherClient expects object implementing WeatherService:

public class Main {
    public static void main (String[] args) {
        ExternalWeatherApi weatherApi = new ExternalWeatherApi() {
            @Override
            public double getTemperatureCelsius(String city) {
                return 15.2;
            }
        };

        // WeatherClient weatherClient = new WeatherClient(weatherApi); // ERROR
    }
}

Passing weatherApi to WeatherClient causes an error. The Adapter solves this problem.

Implementacja of the Adapter pattern

The Adapter translates an object which implements ExternalWeatherApi interface to WeatherService interface.
The class ExternalApiToWeatherServiceAdapter implements WeatherService interface (required by WeatherClient). It also has an ExternalWeatherApi object (needed to call the external API) as a field.

public class ExternalApiToWeatherServiceAdapter implements WeatherService{

    ExternalWeatherApi weatherApi;

    public ExternalApiToWeatherServiceAdapter(ExternalWeatherApi weatherApi) {
        this.weatherApi = weatherApi;
    }

    @Override
    public String getCurrentTemperature(String city) {
        return weatherApi.getTemperatureCelsius(city) + " st. C";
    }
}

Thanks to this, an object of ExternalApiToWeatherServiceAdapter class can be passed to WeatherClient and WeatherClient can call getCurrentTemperature() defined by WeatherService:

public class Main {
    public static void main (String[] args) {
        ExternalWeatherApi weatherApi = new ExternalWeatherApi() {
            @Override
            public double getTemperatureCelsius(String city) {
                return 15.2;
            }
        };

        ExternalApiToWeatherServiceAdapter apiAdapter = 
             new ExternalApiToWeatherServiceAdapter(weatherApi);
        WeatherClient weatherClient = new WeatherClient(apiAdapter);
        weatherClient.showTemperature("Łódź");
    }
}

Program output in the console is: Łódź: 15.2 st. C

Summary

The Adapter pattern is a clean solution to incompatible interfaces. It enables reuse of existing code without changing it. It helps integrate different components while keeping the architecture consistent and flexible.

Adapter is common in integrations with external APIs, libraries, and legacy systems. It is especially useful when integrating old systems to new components. In production, an adapter rarely calls just one method. It often transforms whole objects to move them from one interface to another.

There is also a two-way adapter too. It adapts interfaces in both directions with one object. Such an adapter must implement both interfaces. A two-way adapter is less clear and less common.


The Adapter pattern is similar to the Facade pattern, but they have different goals:

  • the goal of Adapter is to modify an interface so it fits the client’s needs,
  • the goal of Facade is to provide a simple interface to a system or subsystem.

Facade

The main goal of the Facade design pattern is to simplify communication between a client and a complex system. In large applications business logic is spread across many components. The Facade gives one interface which hides the complexity of the internal system.

Thanks to the Facade, the client does not need to know details of each class. It only uses the interface provided by the Facade.

Let’s consider a Report Generator application for managing documents. The application has several classes and methods. Called in the right order, they return a report.

The enum Role stores user roles used in the application:

public enum Role {
    ADMIN,
    USER;
}

The User class represents a user who wants to generate a document. The user must be authorized:

public class User {
    private String username;
    private Role role;

    public User(String username, Role role) {
        this.username = username;
        this.role = role;
    }

    public Role getRole() {
        return role;
    }
}

The Report class represents the report to generate:

public class Report {
    private String content;

    Report(String content) { this.content = content; }

    public void exportToPDF() {
        System.out.println("Exporting report to PDF");
        System.out.println(content);
    }
}

The AuthService class handles user authorization:

public class AuthService {
    public boolean hasAccess(User user) {
        return "ADMIN".equals(user.getRole());
    }
}

The DataService class generates data for the report:

public class DataService {
    public String fetchDataForReport() {
        return "Q2 financial data: [revenue, costs, profit...]";
    }
}

The ReportService class generates reports and returns a Report object:

public class ReportService {
    public Report generateReport(String data) {
        return new Report("Report generated based on:\n" + data);
    }
}

Another application (Client) connects to Report Generator. The Client is an external application that connects to the Report Generator and generates a report:

public class Client {
    public static void main (String[] args) {
        User admin = new User("John", Role.ADMIN);

        AuthService authService = new AuthService();
        DataService dataService = new DataService();
        ReportService reportService = new ReportService();

        if (authService.hasAccess(admin)) {
            String data = dataService.fetchDataForReport();
            Report report = reportService.generateReport(data);
            report.exportToPDF();
        } else {
            System.out.println("No permission to generate the report.");
        }
    }
}

The Client class must know how the Report Generator works. It must know all its classes and methods. It must know the order of method calls to generate a report. It also has direct access to Report Generator classes. The Facade gives the Client one interface and hides the details.

Implementation of the Facade pattern

The report-generation logic is moved from Client class to ReportFacade class. The ReportFacade class exposes 1 interface, generateReport(). The Client uses this interface:

public class ReportFacade {

    private AuthService authService = new AuthService();
    private DataService dataService = new DataService();
    private ReportService reportService = new ReportService();

    public void generateReport (User user) {
        if (authService.hasAccess(user)) {
            String data = dataService.fetchDataForReport();
            Report report = reportService.generateReport(data);
            report.exportToPDF();
        } else {
            System.out.println("No permission to generate the report.");
        }
    }
}

Access to other classes and methods is blocked. In Java, classes can be moved to a separate package and the public access modifier can be removed. Then only ReportFacade will have access. The Client will not.

The Client class now calls only generateReport() from the Facade. In this case, the User class cannot be hidden. The Client must use it.

public class Client {
    public static void main (String[] args) {
        User admin = new User("Jan", Role.ADMIN);

        ReportFacade reportFacade = new ReportFacade();
        reportFacade.generateReport(admin);
    }
}

The Client no longer needs to know the order of method calls. All report logic is hidden. The Client needs to know only one interface.

Summary

The Facade pattern is a clean way to organize complex systems. It offers simple and consistent access points. The Facade isolates the client from the system. It separates the business logic layer from internal methods. This makes code safer and easier to change.

The Facade is common in the service layer of web apps and in integrations with external services. The Facade acts like a gate. It ties many classes and components into one logical entry point.


The Facade pattern is similar to the Adapter pattern, but they have different goals:

  • the goal of Facade is to provide a simple interface to a system or subsystem,
  • the goal of Adapter is to modify an interface so it fits the client’s needs.

Strategy

Assume that, depending on conditions, the application must perform different actions. You can extract such a set of interchangeable actions (algorithms) into separate classes. These actions are strategies. The Strategy design pattern lets you change strategies dynamically at runtime.

Thanks to Strategy, you can separate concrete actions from classes. The code becomes clearer and easier to extend. Users don’t need to know all class interfaces – they only set a strategy and call it.

Let’s consider an application that connects to several different APIs. The application must authenticate with each API, but each API requires a different authentication method.

The enum AuthType stores the authentication methods supported by the application:

public enum AuthType {
    BASIC,
    OAUTH,
    JWT;
}

The APIConnector class handles authentication:

public class APIConnector {
    
    public void authBasic (AuthType authType) {
        if (authType.equals(AuthType.BASIC)) {
            System.out.println("Authentication using Basic Auth");
        } else {
            System.err.println("Invalid authentication type");
        }
    }

    public void authOauth (AuthType authType) {
        if (authType.equals(AuthType.OAUTH)) {
            System.out.println("Authentication using OAuth2");
        } else {
            System.err.println("Invalid authentication type");
        }
    }

    public void authJwt (AuthType authType) {
        if (authType.equals(AuthType.JWT)) {
            System.out.println("Authentication using JWT");
        } else {
            System.err.println("Invalid authentication type");
        }
    }
}

Authentication requires creating an APIConnector object and calling the right method:

public class Main {
    public static void main (String[] args) {

        APIConnector connector = new APIConnector();
        connector.authBasic(AuthType.BASIC);
    }
}

This solution has two drawbacks:

  • the user (Main class) must know the interfaces of APIConnector to use it,
  • and each new authentication method requires changes in APIConnector.

You can modify APIConnector to have only one interface:

public class APIConnector {

    public void authenticate (AuthType authType) {
        switch (authType) {
            case BASIC:
                System.out.println("Authentication using Basic Auth");
                break;
            case OAUTH:
                System.out.println("Authentication using OAuth2");
                break;
            case JWT:
                System.out.println("Authentication using JWT");
                break;
            default:
                System.err.println("Unknown authentication type");
        }
    }
} 

Now using APIConnector is easier (Main needs to know only one interface, authenticate()), but each new method still requires a change in APIConnector. The Strategy pattern solves this problem.

Implementation of the Strategy pattern

The AuthStrategy interface declares one method, authenticate():

public interface AuthStrategy {
    void authenticate();
}

The BasicAuthStrategy, OAuthStrategy, and JwtAuthStrategy classes implement the interface. These are concrete strategies. Each one implements authenticate() in a different way and handle different way of authentication:

public class BasicAuthStrategy implements AuthStrategy{
    @Override
    public void authenticate() {
        System.out.println("Authentication using Basic Auth");
    }
}
public class OAuthStrategy implements AuthStrategy{
    @Override
    public void authenticate() {
        System.out.println("Authentication using OAuth2");
    }
}
public class JwtAuthStrategy implements AuthStrategy{
    @Override
    public void authenticate() {
        System.out.println("Authentication using JWT");
    }
}

The APIConnector class has a private field that holds an object that implements AuthStrategy, and a setter. In the authenticate() method you don’t need previous complicated logic. It only calls authenticate() on the object from the field:

public class APIConnector {

    private AuthStrategy authStrategy;

    public void authenticate () {
        this.authStrategy.authenticate();
    }

    public void setAuthStrategy(AuthStrategy authStrategy) {
        this.authStrategy = authStrategy;
    }
}

In the Main class, you still create an APIConnector. Then you set the right strategy and call authenticate() method.

public class Main {
    public static void main (String[] args) {

        APIConnector connector = new APIConnector();

        connector.setAuthStrategy(new BasicAuthStrategy());
        connector.authenticate();
    }
}

Because the field authStrategy is of type AuthStrategy, you can assign any class that implements this interface: BasicAuthStrategy, OAuthStrategy, or JwtAuthStrategy. AuthStrategy is an abstraction layer between APIConnector and the concrete strategies. When you call authenticate(), the app calls the proper implementation automatically.

The AuthType enum is no longer needed.

Adding a new authentication method no longer requires changes in APIConnector. You only add a new strategy.

Summary

The Strategy pattern is a practical application of the open–closed principle and the dependency inversion principle.

Strategy is a popular and simple pattern. It is used when you need flexible changes to behavior. The pattern makes testing, extension, and maintenance easier.


The Strategy pattern and the State pattern both let you change behavior at runtime, but they have different goals:

  • Strategy encapsulates algorithms that are injected from outside and the client chooses which algorithm to use,
  • State defines behavior for each state of an object ahead of time and the change in behavior happens automatically with state changes.

Dependency inversion principle

1. High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces).
2. Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

Robert C. Martin

In traditional architecture, high-level modules consist of lower-level modules. It is tight coupling. In such architecture, high-level modules depend on lower-level modules. Any change made in a lower-level module forces a change in the higher-level modules. This dependency complicates the reuse of high-level components.

The goal of the dependency inversion principle is to reduce these couplings (loose coupling). Both layers communicate through interfaces that provide the methods required by high-level modules – an additional layer of abstraction. This is often done using dependency injection.

Let’s consider an example situation. We have a class called Displayer (a low-level module) responsible for displaying on the screen:

public class Displayer {
    public void out() {
        System.out.println("Display on the screen");
    }
}

The ComputerSystem class (a high-level module) consists of, among other things, an object of the Displayer class – it depends on it:

public class ComputerSystem {
    private Displayer displayer;

    public ComputerSystem() {
        this.displayer = new Displayer();
    }

    public void out () {
        this.displayer.out();
    }
}

Let’s create a Printer class responsible for printing and add it to the ComputerSystem:

public class Printer{
    public void out() {
        System.out.println("Print on the printer");
    }
}
public class ComputerSystem {
    private Displayer displayer;
    private Printer printer;

    public ComputerSystem() {
        this.displayer = new Displayer();
        this.printer = new Printer();
    }

    public void out (String device) {
        switch (device) {
            case "SCREEN":
                this.displayer.out();
                break;
            case "PRINTER":
                this.printer.out();
                break;
            default:
                break;
        }
    }
}

Now let’s create an object of the ComputerSystem class and call the out method:

public class Runner {
    public static void main (String[] args) {
        ComputerSystem system = new ComputerSystem();

        system.out("PRINTER");
    }
}

The above example works, but it has 3 flaws:

  • Adding each new device requires changing the existing code of the ComputerSystem class – breaking the open-closed principle,
  • The ComputerSystem class depends on the Displayer and Printer classes – breaking the dependency inversion principle,
  • The classes are tightly coupled; the ComputerSystem class cannot exist without Displayer and Printer.

Improved implementation

Let’s create an interface with the out method and implement it in the Displayer and Printer classes:

public interface Device {
    public void out();
}
public class Displayer implements Device{
    @Override
    public void out() {
        System.out.println("Display on the screen");
    }
}
public class Printer implements Device{
    @Override
    public void out() {
        System.out.println("Print on the printer");
    }
}

Nothing really changed in the classes themselves. They previously contained the out method, and now they implement the out method from the interface.

In the constructor of the ComputerSystem class, add an object implementing the Device interface as a parameter – this can be either Displayer or Printer because both classes implement the interface. This is dependency injection:

public class ComputerSystem {
    private Device device;

    public ComputerSystem(Device device) {
        this.device = device;
    }

    public void out() {
        this.device.out();
    }
}

When passing an object of the Displayer or Printer class to the constructor, that specific implementation of the interface is stored in the device field. The program will automatically recognize the class of the object stored in device and run the method from the appropriate class – polymorphism:

public class Runner {
    public static void main (String[] args) {
        Printer printer = new Printer();
        ComputerSystem system = new ComputerSystem(printer);

        system.out();
    }
}

Why to use the dependency inversion principle

The dependency inversion principle makes it easier to expand the application. New lower-level modules just need to implement the intermediary interface to be passed as arguments.

The dependency inversion principle gives more control to the user. They do not have to rely on tight dependencies; instead, they can connect high-level and low-level modules using the intermediary interface.

Higher-level modules are more versatile because they are not dependent on lower-level modules.

Interface segregation principle

Clients shouldn’t be forced to depend upon interfaces that they don’t use.

Robert C. Martin

Classes and methods we write should meet the client’s business needs.

If a class implements an interface, it should implement all of its methods. There shouldn’t be unused methods in the class. Additionally, there shouldn’t be methods that do nothing (are empty) or cause errors when used.

If a class implements an interface, it must implement all its methods. Therefore, it’s better to create more small interfaces and implement them as needed than to create overly large interfaces with too many methods that won’t be used.

Let’s consider an example interface and 3 classes implementing it:

public interface Vehicle {
    void drive();
    void floatOn();
    void fly();
}
public class Car implements Vehicle{
    @Override
    public void drive() {
        System.out.println("Car is driving.");
    }

    @Override
    public void floatOn() {
        System.err.println("Cars don't float!");
    }

    @Override
    public void fly() {
        System.err.println("Cars don't fly!");
    }
}
public class Ship implements Vehicle {
    @Override
    public void drive() {
        System.err.println("Ships don't drive!");
    }

    @Override
    public void floatOn() {
        System.out.println("Ship is floating.");
    }

    @Override
    public void fly() {
        System.err.println("Ships don't fly!");
    }
}
public class Plane implements Vehicle{
    @Override
    public void drive() {
        System.out.println("Plane is driving.");
    }

    @Override
    public void floatOn() {
        System.out.println("Plane is floating.");
    }

    @Override
    public void fly() {

        System.out.println("Plane is flying.");
    }
}

Let’s create a list of our vehicles and call each method on them:

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class Runner {
    public static void main (String[] args) {
        List<Vehicle> vehicles = new ArrayList<>(Arrays.asList(new Car(), new Ship(), new Plane()));

        for (Vehicle vehicle : vehicles) {
            vehicle.drive();
            vehicle.floatOn();
            vehicle.fly();
        }
    }
}

After running the example, we get:

Cars don't float!
Cars don't fly!
Ships don't drive!
Ships don't fly!
Car is driving.
Ship is floating.
Plane is driving.
Plane is floating.
Plane is flying.

All 3classes implement the interface, so they must implement all methods. But not all classes use all methods.

Improved implementation

Let’s break this interface into 3 separate interfaces.

public interface Drivable {
    void drive();
}
public interface Floating {
    void floatOn();
}
public interface Flying {
    void fly();
}

and implement them only in the appropriate classes:

public class Car implements Drivable {
    @Override
    public void drive() {
        System.out.println("Car is driving.");
    }
}
public class Ship implements Floating {
    @Override
    public void floatOn() {
        System.out.println("Ship is floating.");
    }
}
public class Plane implements Drivable, Floating, Flying {
    @Override
    public void drive() {
        System.out.println("Plane is driving.");
    }

    @Override
    public void floatOn() {
        System.out.println("Plane is floating.");
    }

    @Override
    public void fly() {

        System.out.println("Plane is flying.");
    }
}

Let’s call the available methods on them again:

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class Runner {
    public static void main (String[] args) {
        Car car = new Car();
        Ship ship = new Ship();
        Plane plane = new Plane();

        car.drive();
        ship.floatOn();
        plane.drive();
        plane.floatOn();
        plane.fly();
    }
}

After running the program this time, we get:

Car is driving.
Ship is floating.
Plane is driving.
Plane is floating.
Plane is flying.

There are no more unused or unnecessary methods in any of the classes.

Why to use the Integration segregation principle

Code written according to the interface segregation principle is easier to develop. When creating a new class, only the necessary methods need to be implemented.

Interfaces written according to the interface segregation principle are easier to reuse. With fewer specific methods, they are more universal.

Liskov substitution principle

Subclasses should satisfy the expectations of clients accessing subclass objects through references of supercalss type, not just as regards syntactic safety (such as absence of “method-not-found” errors) but also as regards behavioral correctness.

Barbara Liskov

When inheritance is used, it should be possible to replace an object of the base class with an object of a subclass without breaking the program (either technically or from a business perspective). In other words, if a class Child inherits from a class Parent, then an object of the Child class should be usable in any method that works with a Parent reference or pointer.

This means the subclass must implement all the methods of the base class. The subclass should add new features or extend the base class, but it should not change or remove any important behavior.

Let’s look at an example. We have a base class IRobotRoomba, and two classes (specific models) that inherit from it:

public class IRobotRoomba {
    public void vacuum () {
        System.out.println("IRobot Roomba is vacuuming");
    }

    public void mop () throws Exception {
        System.out.println("IRobot Roomba is mopping");
    }
}
public class Plus405Combo extends IRobotRoomba{
    final int height = 106;

    @Override
    public void vacuum() {
        System.out.println("IRobot Roomba Plus 405 Combo is vacuuming");
    }

    @Override
    public void mop() {
        System.out.println("IRobot Roomba Plus 405 Combo is mopping");
    }
}
public class I5 extends IRobotRoomba {
    final int height = 92;

    @Override
    public void vacuum() {
        System.out.println("IRobot Roomba I5 is vacuuming");
    }

    @Override
    public void mop() throws Exception {
        // THIS MODEL CAN'T MOP
        throw new Exception();
    }
}

Now, we have a class SmartHomeSystem that uses the robot to vacuum and mop:

public class SmartHomeSystem {
    public void vacuumHome (IRobotRoomba iRobot) {
        iRobot.vacuum();
    }
    public void mopHome (IRobotRoomba iRobot) {
        try {
            iRobot.mop();
        } catch (Exception e) {
            System.err.println("An error occured!");
        }
    }
}

Let’s create a list of robots:

  • iRobotRoomba,
  • iRobotRoomba Plus 405 Combo,
  • iRobotRoomba i5

and use them to vacuum and mop the house:

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class Runner {
    public static void main (String[] args) {
        SmartHomeSystem system = new SmartHomeSystem();

        List<IRobotRoomba> robots = new ArrayList<>(Arrays.asList(new IRobotRoomba(), new Plus405Combo(), new I5()));

        for (IRobotRoomba robot : robots) {
            system.vacuumHome(robot);
            system.mopHome(robot);
        };
    }
}

Program output:

IRobot Roomba is vacuuming  
IRobot Roomba is mopping  
IRobot Roomba Plus 405 Combo is vacuuming  
IRobot Roomba Plus 405 Combo is mopping  
IRobot Roomba I5 is vacuuming
An error occured!

The I5 model cannot mop. Replacing the base class with I5 breaks the program – it breaks the Liskov substitution principle.

Improved implementation

Let’s move the mopping function into a separate interface:

public class IRobotRoomba {
    public void vacuum () {
        System.out.println("IRobot Roomba is vacuuming");
    }
}
public interface moppingDevice {
    void mop ();
}

and implement it in iRobot Roomba Plus 405 Combo:

public class Plus405Combo extends IRobotRoomba implements MoppingDevice {
    final int height = 106;

    @Override
    public void vacuum() {
        System.out.println("IRobot Roomba Plus 405 Combo is vacuuming");
    }

    @Override
    public void mop() {
        System.out.println("IRobot Roomba Plus 405 Combo is mopping");
    }
}

Class I5 only needs to implement 1 method now:

public class I5 extends IRobotRoomba {
    final int height = 92;

    @Override
    public void vacuum() {
        System.out.println("IRobot Roomba I5 is vacuuming");
    }
}

We also update the SmartHomeSystem class to use objects implementing MoppingDevice interface for mopping:

public class SmartHomeSystem {
    public void vacuumHome(IRobotRoomba iRobot) {
        iRobot.vacuum();
    }

    public void mopHome(MoppingDevice iRobot) {
        iRobot.mop();

    }
}

This time we need to create 2 lists:

  • robots contain all IRobotRoomba devices (vacuum cleaners),
  • but moppingDevices contains only devices implementing moppingDevice interface (only iRobot Roomba Plus 405 Combo at the moment)

and again use them to vacuum and mop the house:

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class Runner {
    public static void main (String[] args) {
        SmartHomeSystem system = new SmartHomeSystem();

        List<IRobotRoomba> robots = new ArrayList<>(Arrays.asList(new IRobotRoomba(), new Plus405Combo(), new I5()));
        List<MoppingDevice> moppingDevices = new ArrayList<>(Arrays.asList(new Plus405Combo()));

        for (IRobotRoomba robot : robots) {
            system.vacuumHome(robot);
        };

        for (MoppingDevice moppingDevice : moppingDevices) {
            system.mopHome(moppingDevice);
        };
    }
}

Program output:

IRobot Roomba is vacuuming  
IRobot Roomba Plus 405 Combo is vacuuming  
IRobot Roomba I5 is vacuuming  
IRobot Roomba Plus 405 Combo is mopping

We moved the problematic method into a separate interface. Only robots that can mop implement it. This way, we can safely replace base class objects with subclass objects.

Additional rules from the Liskov substitution principle

There are 2more rules that come from the Liskov substitution principle:

  • Subclasses shouldn’t strengthen preconditions.
    Preconditions can be weakaned – it won’t break a program. If the preconditions in the base class are harder to satisfy, the program will still have to satisfy them..
  • Subclasses shouldnt weaken postconditions.
    Postconditions can be strengthen – it won’t break a program. Można wzmacniać warunki końcowe ponieważ to nie zepsuje programu.If the postconditions in the base class are easier to satisfy, the program will be prepared to handle them.

Open-closed principle

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

Mayer Bertrand

Program behavior (functions, classes, modules, etc.) should be changed without modifying existing code. Instead, new code should be added to extend the program.

The code should be written in a way that makes it easy to add new features without changing already working parts. The goal is to grow the program by adding new elements, not by editing existing ones.

To follow this rule, it’s important to use abstractions.

Let’s say we have two classes, each representing a different type of customer:

public class RegularCustomer {
    final double discountLevel = 0.95;

    // method returns unit price with discount
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}
public class PremiumCustomer {
    final double discountLevel = 0.8;

    // method returns unit price with discount
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}

We also have the PriceCalculator class. It calculates the final price depending on the customer type and quantity:

public class PriceCalculator {
    // method returns final price depending on the type of base price, customer type and quantity
    public double calculateFinalPrice (double basePrice, int quantity, String customerType) {
        switch (customerType) {
            case "REGULAR":
                RegularCustomer regularCustomer = new RegularCustomer();
                return quantity * regularCustomer.calculateDiscountedPrice(basePrice);
            case "PREMIUM":
                PremiumCustomer premiumCustomer = new PremiumCustomer();
                return quantity * premiumCustomer.calculateDiscountedPrice(basePrice);
            default:
                return quantity * basePrice;
        }
    }
}
public class Runner {
    public static void main (String [] args) {
        System.out.println(PriceCalculator.calculateFinalPrice(100, 100, "PREMIUM"));
}

This code works, but there is a problem:

  • to add a new type of customer, one must change the code in the PriceCalculator class:
public class VipCustomer {
    final double discountLevel = 0.5;

    // method returns unit price with discount
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}
public class PriceCalculator {
    // method returns final price depending on the type of base price, customer type and quantity
    public double calculateFinalPrice (double basePrice, int quantity, String customerType) {
        switch (customerType) {
            case "REGULAR":
                RegularCustomer regularCustomer = new RegularCustomer();
                return quantity * regularCustomer.calculateDiscountedPrice(basePrice);
            case "PREMIUM":
                PremiumCustomer premiumCustomer = new PremiumCustomer();
                return quantity * premiumCustomer.calculateDiscountedPrice(basePrice);
            case "VIP":
                VipCustomer vipCustomer = new VipCustomer();
                return quantity * vipCustomer.calculateDiscountedPrice(basePrice);
            default:
                return quantity * basePrice;
        }
    }
}

We are breaking the Open closed principle — we are modifying working code just to add something new.

Improved implementation

Let’s add an interface.

public interface Customer {
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}

and implement it in all cusotmer classes:

public class RegularCustomer implements Customer{
    final double discountLevel = 0.95;

    @Override
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}
public class PremiumCustomer implements Customer{
    final double discountLevel = 0.8;

    @Override
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}
public class VipCustomer implements Customer{
    final double discountLevel = 0.5;

    @Override
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}

Now the PriceCalculator class is much simpler:

public class PriceCalculator {
    public double calculateFinalPrice (double basePrice, int quantity, Customer customer) {
        return quantity * customer.calculateDiscountedPrice(basePrice);
    }
}

We don’t need to check the customer type anymore. We just pass in the correct object, and Java calls the correct method.

public class Runner {
    public static void main (String [] args) {
        System.out.println(PriceCalculator.calculateFinalPrice(100, 100, new RegularCustomer()));
}

Why to use the Open-closed principle

Code written with the Open-closed principle is easier to grow and change. By using an interface that contains common behaviors , we can:

  • Add a new customer type by just creating a new class that implements the interface and without need to touch the old code, which is already tested and working.
  • The code becomes simpler and easier to read.

Single responsibility principle

Gather together the things that change for the same reasons. Separate those things that change for different reasons.

Robert C. Martin

Every function, class or module should have one responsibility and that responsibility should be encapsulated in it.

The code of a program should be organized into classes. Each class should have one clear goal. A class should contain methods that do similar things. This means that similar functions (doing similar tasks) should be grouped into one class.

Let’s look at an example. Below is a class that generates a report:

public class ReportGenerator {
    // method prints the report
    public void printReport (String reportId) {
        System.out.println(generateReport(reportId));
    }
    
    // method generates the report based on the given ID
    private String generateReport (String reportId) {

        // some logic to generate the report

        return wygenerowanyRaport;
    }
}
public class Runner {
    public static void main (String [] args) {
        ReportGenerator generator = new ReportGenerator();
        generator.printReport("123");
}

The class has two responsibilities. That means there are two possible reasons to change it:

  • The generateReport method is responsible for creating a report,
  • The printReport method is responsible for displaying a report

A class should have only one reason to change.

Robert C. Martin

Improved implementation

Let’s split this class into two separate classes.

The ReportGenerator class will now only be responsible for generating reports:

public class ReportGenerator {   
    // method generates the report based on the given ID
    public String generateReport (String reportId) {

        // some logic to generate the report

        return generatedRaport;
    }
}

And the ReportPrinter class will be responsible for printing reports:

public class ReportPrinter {   
    // method prints the report
    public void printReport (String reportToPrint) {
        System.out.println(reportToPrint);
    }
}
public class Runner {
    public static void main (String [] args) {
        ReportGenerator generator = new ReportGenerator();
        ReportPrinter printer = new ReportPrinter();
        
        String generatedReport = generator.printReport("123");
        printer.printReport(generatedReport);        
}

Why to use the Single responsibility principle

Code written with the Single responsibility principle is easier to maintain. Functions, classes, and modules become smaller and more consistent. Is is less likely to introduce bugs when changing anything.

It’s also easier to reuse code. Functions, classes and modules are simpler, more readable, and less specific. The code is better organized and more modular.