Intermediate

Java Best Practices

Clean code is not a nice-to-have: it is a necessity for the long-term success of any software.

Java version: Java 17 (LTS) — also applicable to Java 8, 11+


Table of Contents

  1. Course Overview
  2. Why care about best practices?
  1. The name is important
  1. Create Objects the Right Way
  1. Best practices for implementing methods
  1. Strings and numbers
  1. Iterations and branches
  1. Handle exceptions elegantly
  1. Write only helpful comments
  1. Create better tests
  1. Conclusion and further reading

1. Course Overview

This course, titled Java Best Practices, is presented by Andrejs Doronins on Pluralsight. It covers the following main topics:

  • The importance of naming and how to choose great names for classes, variables and methods.
  • Pitfalls related to object creation, method implementation and exception handling.
  • Writing value tests.
  • Practical tips for keeping code quality at the highest level.

Prerequisites:

  • Professional experience in Java in an IDE (IntelliJ, Eclipse, etc.)
  • Ability to write simple object-oriented code
  • Basic knowledge of Maven or Gradle

Structure of the demonstration project: The project is called Java17-Best-Practices and is a multi-module Maven project. It deals with a fictional airline application (Cloud Airlines) with entities like Passenger, Flight, FlightSearch, etc. Sources are available in two states:

  • Java17-Best-Practices-before: starting code (before refactoring)
  • Java17-Best-Practices-after: final code (after applying best practices)

2. Why care about best practices?

2.1 The benefits of clean code

Clean code is not a nice-to-have: it is a necessity for the long-term success of any software.

Consequences of poor code quality:

  • Decreased understanding → bugs
  • Slower and slower development
  • Decrease in job satisfaction
  • In a new project, we spend 50% of the time coding and 50% reading the code. Over time, we spend more than 90% reading code before we can make a single change.

“The ratio of time spent reading versus writing is well over 10 to 1. We are constantly reading old code as part of the effort to write new code. Therefore, making it easy to read makes it easier to write. » — Robert C. Martin

Analogy: Treat technical debt like credit card debt. The more you ignore it, the worse it gets.

Who is this course for?

  • Java Developers – Test Automation (QA) Engineers
  • Anyone who writes automated tests in Java

2.2 Prerequisites and project configuration

The demo project is available on GitHub (Java-Best-Practices-before) or via Pluralsight exercise files.

Maven structure of the project:

<?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>org.example</groupId>
    <artifactId>Java17-Best-Practices</artifactId>
    <packaging>pom</packaging>
    <version>1.0-SNAPSHOT</version>
    <modules>
        <module>m3naming</module>
        <module>m4objects</module>
        <module>m5methods</module>
        <module>m6stringsnumbers</module>
        <module>m7branchingiterating</module>
        <module>m8exceptions</module>
        <module>m9comments</module>
        <module>m10tests</module>
    </modules>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.8.1</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <!-- version dans le pom enfant -->
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.7.0</version>
                <configuration>
                    <source>17</source>
                    <target>17</target>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.22.1</version>
            </plugin>
        </plugins>
    </build>
</project>

3. The name is important

Bad naming leads to bad usage. The majority of time is spent reading the code, so it should be as readable as possible.

3.1 Class names must be specific

Basic rules:

  • A class name must be a noun (noun).
  • It must be as specific as possible.
  • A vague name like AirlineManager can accommodate any business logic, which violates the SRP (Single Responsibility Principle).

Example: before (bad)

// Nom trop vague : on ne sait pas ce qu'est un "Client" dans une compagnie aérienne
package cloudairlines;

public class Client {

    String name;
    String surname;
    int bags;
    boolean adult;

    public boolean bags() {
        return bags > 0;
    }
}
// Nom trop vague : "Search" — une recherche de quoi ?
package cloudairlines;

import java.util.List;

public class Search {
    public List<Flight> getTodayFlights() {
        return null;
    }
}

Example: after (correct)

// Renommé en Passenger : beaucoup plus spécifique
package cloudairlines;

public class Passenger {

    String name;
    String surname;
    int bagCount;
    boolean isAdult;

    public boolean hasBags() {
        return bagCount > 0;
    }
}
// Renommé en FlightSearchService : précis et révélateur d'intention
package cloudairlines;

import java.util.List;

public class FlightSearchService {

    public List<Flight> search(String fromDest, String toDest, String departDate) {
        return null;
    }

    public List<Flight> getTodayFlights() {
        return null;
    }
}

3.2 Class names must reflect the SRP

Vague names invite developers to drop in code that “just fits”, creating “big ball of mud” type classes.

Example: before (bad)

// AirlineManager viole le SRP : contient logique de recherche, réservations, flotte, personnel
package cloudairlines;

import java.util.List;

public class AirlineManager {

    public List<Flight> getTodayFlights() {
        return null;
    }

    public List<String> viewBookings() {
        return null;
    }

    public List<String> displayFleetInfo() {
        return null;
    }
}

Example: after (correct)

Split AirlineManager into smaller, focused classes:

  • FlightSearchService — flight search
  • BookingViewer — viewing reservations
  • FleetManager — fleet information
// Chaque classe a une seule responsabilité
package cloudairlines;
public class BookingViewer { /* ... */ }

package cloudairlines;
public class FleetManager { /* ... */ }

package cloudairlines;
public class FlightSearchService { /* ... */ }

Summary: Class names should be as specific nouns as possible. The two most common fixes are renaming and dividing into several smaller classes.


3.3 Naming variables

Rules:

  • Never a single letter or an obscure abbreviation (exception: loop variables i, j).
  • The name should always be specific, ideally 1-2 words.
  • boolean variables must be prefixed with is or has (e.g.: isActive, isValid, hasBags).
  • Use camelCase.
  • Use ALL_CAPS_WITH_UNDERSCORES for constants (static final fields).

Example of incremental evolution:

// Mauvais : 'd' ne veut rien dire
Object d = getFlightData();

// Mieux : data est toujours vague — quel type de données ?
Object data = getFlightData();

// Bien : spécifique et en camelCase
Object passengerDetails = getPassengerDetails();

Before / After on the Passenger class:

// Avant : bags et adult sont peu clairs
int bags;
boolean adult;

// Après : bagCount et isAdult sont explicites et suivent les conventions
int bagCount;      // ou numberOfBags
boolean isAdult;   // préfixe is pour boolean

Advantage of isAdult: if statements become readable in natural language:

if (passenger.isAdult) { /* ... */ }

3.4 Naming methods

Rules:

  • Methods are actions: use a verb, e.g. loadCustomerDetails(), setNewPrice().
  • They must reveal the intention: we must understand what they do without looking at their implementation.
  • Methods returning a boolean: prefix is or has, e.g. hasBags(), isValid().
  • Template: <verb><Subject>, e.g. getPassengerData(), hasBags().

Before / After:

// Avant : getData() — quelle donnée ? Les données de qui ?
Object getData() { ... }

// Avant : bags() — retourne le type de bagages ? le poids total ?
boolean bags() { return bags > 0; }

// Après : getPassengerData() — clair et spécifique
Object getPassengerData() { ... }

// Après : hasBags() — révèle l'intention, préfixe is/has pour boolean
boolean hasBags() { return bagCount > 0; }

3.5 Methods should only do one thing

Warning sign: If when describing what a method does we use the words “and”, “or”, or “if”, the method does several things and must be split.

// Mauvais : getSalesData fait 3 choses différentes
void getSalesData() {
    // 1. requête BDD
    // 2. formatage des données
    // 3. pré-calcul (ex. conversion de devise)
}

// Bon : méthodes petites et ciblées
List<SalesData> getSalesData() { ... }
List<SalesData> formatSalesData(List<SalesData> data) { ... }
List<SalesData> convertToLocalCurrency(List<SalesData> data) { ... }

Benefit: Good naming naturally leads to smaller methods, which makes code easier to reuse.

3.6 Exceptions to the rule

Some contexts allow single-word or no-verb method names:

  • Manipulation of native Java strings: charAt(), substring(), trim()
  • Java Streams: andThen(), orElseThrow()
  • Java Records (Java 16+): generated accessors have the parameter name without get prefix
public record Flight(String from, String to, String date) {}

// Usage — méthodes sans verbe générées par Java
flight.from();
flight.to();
flight.date();
  • Static factory methods: newSearch(), of(), create()
  • Builder pattern: the methods are used to construct the object (e.g. .withFrom("London").withTo("New York"))

3.7 Abbreviations and spelling

Rules:

  • Do not use abbreviations in 99% of cases (they are difficult to read and depend on cultural context).
  • Universal abbreviations in the industry can be tolerated (eg in an investment bank: qty for quantity, ccy for currency).
  • Correct spelling errors as soon as they are discovered: they negatively impact searching in the code.

Impact of a spelling error:

// Recherche dans l'IDE de "Passenger" (avec double s)
// → aucun résultat si la classe s'appelle "Passanger" (faute de frappe)
// → perte de temps et confusion

Module 3 Summary: Class names are specific nouns. Variable names are 1-2 words long and accurately describe the content. Method names are intention-revealing verbs, without using “and” or “or” in their description.


4. Create Objects the Right Way

Creating an object is fundamental in object-oriented programming. If done poorly, problems propagate throughout the code base.

4.1 Using Java Records

Java Records (available since Java 16) are a specialized class type for storing data in memory. They eliminate boilerplate code (constructor, equals, hashCode, toString, accessors).

Rule: If you need a class with no logic, no special methods, no complex state — just an object with data to pass — use Java Records.

Before: traditional class with boilerplate

// Beaucoup de code répétitif pour seulement 3 champs
public class FlightData {
    private final String from;
    private final String to;
    private final String date;

    public FlightData(String from, String to, String date) {
        this.from = from;
        this.to = to;
        this.date = date;
    }

    public String getFrom() { return from; }
    public String getTo() { return to; }
    public String getDate() { return date; }

    @Override
    public boolean equals(Object o) { /* ... */ }
    @Override
    public int hashCode() { /* ... */ }
    @Override
    public String toString() { /* ... */ }
}

After: Java Record

// Très court — Java génère constructeur, accesseurs, equals, hashCode, toString
import java.util.Objects;

public class RecordDemo {

    public static void main(String[] args) {
        var flight = new Flight("London", "New York", "2022-08-30");
        System.out.println(flight.from()); // Accesseur sans préfixe "get"
    }

    // Record inline (peut aussi être un fichier top-level)
    public record Flight(String from, String to, String date) {

        // Compact constructor pour validation (optionnel)
        public Flight {
            Objects.requireNonNull(from);
            Objects.requireNonNull(to);
        }
    }
}

Notes on Records:

  • Generated accessors are called from(), to(), date() — without get prefix.
  • They are immutable by default.
  • Compact constructor allows adding validation without repeating assignments.

4.2 Prefer dependency injection

Problem: Initializing a dependency directly in the constructor creates strong coupling — inflexible and untestable.

Before: strong coupling

// Problème : FlightSearch est lié à un seul FlightStoreImpl, impossible de tester en isolation
public class FlightSearchService {

    private FlightStore flightStore;

    public FlightSearchService() {
        this.flightStore = new FlightStoreImpl(); // dépendance codée en dur
    }
}

After: dependency injection (DI)

import java.util.List;
import java.util.Objects;

public class FlightSearchService {

    private FlightStore flightStore;

    // Constructor injection : la dépendance est fournie de l'extérieur
    public FlightSearchService(FlightStore store) {
        this.flightStore = Objects.requireNonNull(store);
    }

    // Constructeur de convenance avec implémentation par défaut
    public FlightSearchService() {
        this.flightStore = new FlightStoreImpl();
    }

    public static FlightSearchService newSearch() {
        return new FlightSearchService(new FlightStoreImpl());
    }

    public List<Flight> search(String fromDest, String toDest, String departDate) {
        List<Flight> availableFlights = flightStore.getFlights();
        return availableFlights;
    }
}

Benefits of dependency injection:

  • Flexible: we can inject different implementations (database, web API, CSV file).
  • Testable: in unit tests, we inject a hard-coded list of flights, without the need for a real database.

4.3 Know DI frameworks

For applications with hundreds of classes and thousands of dependencies, a DI framework greatly simplifies management.

4.4 Protect with guard clauses

Dependency injection allows others to pass invalid values ​​(null, 0, etc.). To avoid this, we use the concept of class invariant: properties that always remain true for all instances of a class.

Technique: fail fast — check validity immediately and throw an exception.

import java.util.Objects;

public class FlightSearchService {

    private FlightStore flightStore;

    public FlightSearchService(FlightStore store) {
        // Objects.requireNonNull lance NullPointerException avec un message si store == null
        this.flightStore = Objects.requireNonNull(store);
        // Variante avec message personnalisé :
        // this.flightStore = Objects.requireNonNull(store, "FlightStore cannot be null");
    }
}

Another example of fail fast in a setter:

public void setInterestRate(double rate) {
    if (rate < 0) {
        throw new IllegalArgumentException("Interest rate can't be less than 0");
    }
    this.interest = rate;
}

Note: Java itself uses fail fast everywhere. Example: LocalDate.of(2022, null, 1) immediately throws an exception.

4.5 Use static factory methods

static factory methods encapsulate the creation of complex objects and offer two major advantages:

  1. Hide complexity: The caller does not need to know the internal details.
  2. Flexibility: the author can modify the constructor without breaking the client code.

Examples in the JDK:

  • List.of(...), Map.of(...), Optional.of(...)
  • LocalDate.now(), LocalDate.of(...)
  • Collections.emptyList()

Example in the project:

public class FlightSearchService {

    private FlightStore flightStore;

    // Constructeur public toujours disponible pour injection personnalisée
    public FlightSearchService(FlightStore store) {
        this.flightStore = Objects.requireNonNull(store);
    }

    // Static factory method : cache les détails de construction
    public static FlightSearchService newSearch() {
        return new FlightSearchService(new FlightStoreImpl());
    }

    // Variantes possibles selon la source de données
    public static FlightSearchService newDbSearch() {
        return new FlightSearchService(new DbFlightStore());
    }

    public static FlightSearchService newWebApiSearch() {
        return new FlightSearchService(new WebApiFlightStore());
    }
}

Client side usage:

// Simple et lisible — le client n'a pas à savoir comment construire FlightSearchService
var searchService = FlightSearchService.newSearch();

4.6 Apply constructor chaining

When you have several constructors, avoid duplication of validation code by chaining the constructors with the keyword this.

Rule: All validation and assignments are in the constructor with the most parameters.

Before: validation code duplication

BankAccount() {
    this.balance = 0;
    // validation dupliquée ici aussi
}

BankAccount(double balance) {
    if (balance < 0) { /* validation dupliquée */ }
    this.balance = balance;
}

BankAccount(double balance, double interest) {
    if (interest < 0) { /* validation */ }
    if (balance < 0) { /* validation */ }
    this.balance = balance;
    this.interest = interest;
}

After: constructor chaining (DRY)

public class BankAccount {

    private double balance;
    private double interest;

    // Délègue au constructeur avec 1 paramètre
    BankAccount() {
        this(0);
    }

    // Délègue au constructeur avec 2 paramètres
    BankAccount(double balance) {
        this(balance, 0.1);
    }

    // Constructeur principal : toute la validation est ici
    BankAccount(double balance, double interest) {
        if (interest < 0) {
            throw new IllegalArgumentException("Interest rate can't be less than 0");
        }
        if (balance < 0) {
            throw new IllegalArgumentException("Starting balance can't be less than 0");
        }
        this.balance = balance;
        this.interest = interest;
    }
}

Chaining example for Flight:

public class Flight {

    private static final int DEFAULT_CAPACITY = 100;
    private String fromDest;
    private String toDest;
    private String date;
    int seatCapacity;

    // Constructeur simple : délègue au constructeur complet avec valeur par défaut
    public Flight(String fromDest, String toDest, String date) {
        this(fromDest, toDest, date, DEFAULT_CAPACITY);
    }

    // Constructeur principal avec validation
    public Flight(String fromDest, String toDest, String date, int seatCapacity) {
        // + validation des inputs
        this.fromDest = fromDest;
        this.toDest = toDest;
        this.date = date;
        this.seatCapacity = seatCapacity;
    }

    // Constructeur alternatif avec objet Airport
    public Flight(Airport from, Airport to, String date) {
        this.fromDest = from.getCity();
        // ...
    }

    @Override
    public String toString() {
        return "Flight{" +
                "fromDest='" + fromDest + '\'' +
                ", toDest='" + toDest + '\'' +
                ", date='" + date + '\'' +
                '}';
    }
}

Note: Use constants rather than magic numbers (DEFAULT_CAPACITY = 100 rather than 100).

4.7 Recognizing Primitive Obsession

Primitive Obsession is a code smell: we use too many primitive types (numbers, strings) instead of creating custom classes.

Sign: Constructor or method with 4+ parameters, especially if many are of the same type.

Before: 7 String parameters — guaranteed confusion

// Facile de mélanger l'ordre des paramètres
public Flight(String fromCountry, String fromCity, String fromTerminal,
              String toCountry, String toCity, String toTerminal,
              String date) { ... }

After: encapsulate in an Airport class

public class Airport {

    String country;
    String city;
    List<String> terminals;

    public String getCountry() { return country; }
    public String getCity() { return city; }
    public List<String> getTerminals() { return terminals; }
}

// Le constructeur de Flight passe de 7 à 3 paramètres
public Flight(Airport from, Airport to, String date) {
    this.fromDest = from.getCity();
    // ...
}

4.8 Know code smells

Primitive Obsession is just one of many code smells. They are generally grouped into:

  • Bloaters (methods/classes too big)
  • Object-Orientation Abusers (misuse of OO)
  • Change Preventers (cascading changes)
  • Couplers (over-coupling)
  • Dispensable (code unnecessary)

4.9 The Builder Pattern to the rescue

When an object can be valid with any combination of parameters (classic example: pizza with ingredients of your choice), the telescoping constructor pattern becomes unmanageable.

Problem: Telescoping constructors

// Avec 6 ingrédients possibles → 2^6 = 64 combinaisons de constructeurs !
Pizza(boolean cheese) { ... }
Pizza(boolean cheese, boolean pepperoni) { ... }
Pizza(boolean cheese, boolean pepperoni, boolean mushrooms) { ... }
// ...

Solution: Builder Pattern

// Usage côté client — lisible et flexible
Flight flight = new Flight.Builder("London", "New York")
    .withDate("2022-10-15")
    .withSeatCapacity(200)
    .build();

Module 4 Summary: Use Records for simple data classes. Inject dependencies via constructor. Validate entries in fail fast. Provide static factory methods. Chain constructors to avoid duplication. Detecting Primitive Obsession. Use the Builder Pattern for complex constructions.


5. Best practices for implementing methods

The majority of code lives in methods — that’s where most of the pitfalls lie.

5.1 Adhere to the CQS principle

CQS = Command Query Separation

Rule: A method must either:

  • Execute command (change state) → return void
  • Perform a query (calculate/retrieve a value) → return a value

But never both at the same time. An unexpected side effect in a query method creates bugs that are difficult to reproduce.

// Mauvais : la méthode search() modifie l'état ET retourne des données
List<Flight> search(SearchRequest request) {
    this.lastSearchDate = LocalDate.now(); // effet de bord caché !
    return flightStore.getFlights();
}

// Bon : search() ne fait que retourner des données
List<Flight> search(SearchRequest request) {
    return flightStore.getFlights();
}

// Bon : updateLastSearchDate() ne fait que modifier l'état
void updateLastSearchDate() {
    this.lastSearchDate = LocalDate.now();
}

5.2 Keep parameter list short

Guideline:

  • 0, 1 or 2 parameters: ideal
  • 3 parameters: acceptable, but avoid
  • 4+ parameters: refactoring needed

Common causes of too many parameters:

  1. Method does too many things → split it
  2. Too many primitive types → Primitive Obsession → create object
  3. Parameter boolean (flag argument) → delete or divide

5.3 Collapse parameter list (demo)

Before: search method with sparse parameters (and future parameters)

// Problème : 3 strings du même type → facile de se tromper d'ordre
// De plus, la liste va s'agrandir : seatsAvailable, directFlight, etc.
public List<Flight> search(String from, String to, String date) { ... }

After: encapsulate in a SearchRequest object

// SearchRequest encapsule tous les paramètres de recherche
public class SearchRequest {

    private String from;
    private String to;
    private LocalDate date;

    public SearchRequest(String[] args) {
        this(args[0], args[1], args[2]);
    }

    public SearchRequest(String from, String to, String date) {
        this.from = Objects.requireNonNull(from);
        this.to = Objects.requireNonNull(to);
        this.date = parseDate(date);
    }

    private LocalDate parseDate(String date) {
        return LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
    }

    public String getFrom() { return from; }
    public String getTo() { return to; }
    public LocalDate getDate() { return date; }
}

// La méthode de recherche n'a plus qu'un seul paramètre
public List<Flight> search(SearchRequest request) { ... }

5.4 Remove Flag Arguments (demo)

flag arguments (boolean parameters) are an anti-pattern. They indicate that a method does more than one thing.

“Flag arguments are ugly. It immediately complicates the signature of the method, loudly proclaiming that this function does more than one thing. » — Robert C. Martin

Before: flag argument boolean economy

// Difficile à comprendre à l'appel : findEconomyPassengers(flight, true) ou (flight, false) ?
public List<Passenger> findEconomyPassengers(Flight flight, boolean economy) {
    List<Passenger> foundPassengers = new ArrayList<>();
    for (Passenger passenger : flight.getPassengerList()) {
        if (economy) {
            if (passenger.getSeatClass().equals("Economy")) {
                foundPassengers.add(passenger);
            }
        } else {
            if (!passenger.getSeatClass().equals("Economy")) {
                foundPassengers.add(passenger);
            }
        }
    }
    return foundPassengers;
}

After: two separate methods, or use of an enum

public enum SeatClass {
    ECONOMY, BUSINESS, FIRST
}

// Option 1 : deux méthodes distinctes
public List<Passenger> findEconomyPassengers(Flight flight) { ... }
public List<Passenger> findNonEconomyPassengers(Flight flight) { ... }

// Option 2 : paramètre enum SeatClass (plus expressif)
public List<Passenger> findPassengersBySeatClass(Flight flight, SeatClass seatClass) {
    List<Passenger> passengers = flight.getPassengerList();
    List<Passenger> foundPassengers = new ArrayList<>();
    for (Passenger passenger : passengers) {
        if (passenger.getSeatClass().equals(seatClass)) {
            foundPassengers.add(passenger);
        }
    }
    return foundPassengers;
}

5.5 Prefer enums when appropriate

Restriction in programming provides predictability and avoids bugs.

Problems with strings:

  • May be empty, contain spaces, have unwanted characters
  • May be misspelled

Advantages of enums:

  • Compiler rejects any undefined value
  • Auto-completion in IDE
  • Safer Refactoring
// Avant : chaîne de caractères — sujette aux erreurs
String seatClass = "Economy"; // risque de typo : "Econnomy", "economy"

// Après : enum — le compilateur garantit les valeurs valides
public enum SeatClass {
    ECONOMY, BUSINESS, FIRST
}

// Usage
SeatClass seatClass = SeatClass.ECONOMY;
// Ou avec import statique :
import static com.cloudairlines.passenger.SeatClass.*;
SeatClass seatClass = ECONOMY;

5.6 Replace String with LocalDate (demo)

Date type fields should not remain String. Convert them to LocalDate (or LocalDateTime, ZonedDateTime) as soon as possible.

// Avant : date comme String — peut être n'importe quoi
public SearchRequest(String from, String to, String date) {
    this.date = date; // pas de validation du format
}

// Après : conversion vers LocalDate dès la construction
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

public SearchRequest(String from, String to, String date) {
    this.from = Objects.requireNonNull(from);
    this.to = Objects.requireNonNull(to);
    this.date = parseDate(date);
}

private LocalDate parseDate(String date) {
    return LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
}

5.7 Fail fast in methods

The fail fast in methods: check the validity of the inputs first, before executing the business logic.

public List<Flight> search(SearchRequest request) {

    // Fail fast : vérification en premier
    if (request == null) {
        throw new IllegalArgumentException("Request cannot be null");
    }

    // La logique métier suit seulement si l'entrée est valide
    List<Flight> availableFlights = flightStore.getFlights();
    // ...
    return availableFlights;
}

5.8 The Null Object Pattern

To further reduce the number of null checks, we can use the Null Object Pattern:

  • Create an interface or abstract class
  • Create one or more real implementations
  • Create a null object for special cases (returns false, does nothing, etc.)

Recommended resource: Making Your Java Code More Object-oriented — “Leveraging Special Case Objects” module

5.9 Reduce verbosity with var

Local Variable Type Inference (available since Java 10): use the var keyword for long and verbose types.

Rule: Use var when the type is long and the type information is not lost.

// Avant : type long et verbeux
ObjectMapper objectMapper = new ObjectMapper();
BufferedReader bufferedReader = new BufferedReader(new FileReader(path));
Map<String, List<Flight>> flightsByCity = new HashMap<>();

// Après : var réduit la verbosité sans perdre d'information
var mapper = new ObjectMapper();
var reader = new BufferedReader(new FileReader(path));
var flightsByCity = new HashMap<String, List<Flight>>();

// Ne pas utiliser var pour les types simples — pas de gain
var name = "London"; // String name = "London" est plus clair
var count = 42;      // int count = 42 est plus clair

Exception: If we move a variable from local to class constant, we must redeclare the type:

// Champ de classe statique : on ne peut PAS utiliser var
private static final ObjectMapper MAPPER = new ObjectMapper();

5.10 Avoid creating unnecessary objects

Expensive objects created in methods are recreated on each invocation. Move them to final static fields for reuse.

Before: Pattern/Matcher recreated on each call

// Mauvais : Pattern compilé à chaque invocation de la méthode
public boolean isValidEmail(String email) {
    Pattern pattern = Pattern.compile("^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$");
    Matcher matcher = pattern.matcher(email);
    return matcher.matches();
}

After: Pattern in final static field

// Bon : Pattern compilé une seule fois
private static final Pattern EMAIL_PATTERN =
    Pattern.compile("^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$");

public boolean isValidEmail(String email) {
    return EMAIL_PATTERN.matcher(email).matches();
}

Project example: ObjectMapper

// Avant : ObjectMapper recréé à chaque appel de getFlights()
public List<Flight> getFlights(String json) {
    ObjectMapper mapper = new ObjectMapper(); // coûteux !
    // ...
}

// Après : ObjectMapper en constante de classe
public class FlightSimpleStore {

    private static final ObjectMapper MAPPER = new ObjectMapper(); // créé une fois

    public List<Flight> getFlights(String json) {
        try {
            return MAPPER.readValue(json, new TypeReference<>() {});
        } catch (IOException e) {
            // log ou rethrow
        }
        return null;
    }
}

5.11 Return appropriate values

Rule #1: Avoid returning null

// Mauvais : retourner null oblige tous les appelants à vérifier null
public List<Flight> getFlights() {
    try {
        // requête BDD
        return flights;
    } catch (Exception e) {
        return null; // dangereux !
    }
}

// Bon : retourner une collection vide
import java.util.Collections;
import java.util.List;

public List<Flight> getFlights() {
    try {
        return flights;
    } catch (Exception e) {
        return Collections.emptyList(); // plus sûr
    }
}

Rule #2: Avoid magic numbers as error codes

// Mauvais : -1 comme code d'erreur — signification obscure
public int getPassengerCount() {
    if (error) return -1; // Que signifie -1 ?
    return count;
}

// Bon : lancer une exception ou retourner un Optional
public int getPassengerCount() {
    if (flightStore == null) {
        throw new IllegalStateException("FlightStore not initialized");
    }
    return count;
}

5.12 Prefer Optional

Optional (available since Java 8) is a container that can contain at most one non-null value. It forces the caller to handle the absence of value.

Workflow without Optional:

Méthode retourne une référence
→ On essaie de l'utiliser
→ NullPointerException
→ Frustration
→ Ajout d'une vérification null
→ Code cluttered

Workflow with Optional:

Méthode retourne Optional<Flight>
→ On est forcé d'unpacker la valeur avant de l'utiliser
→ On gère explicitement l'absence de valeur
→ Code plus sûr et plus expressif
import java.util.Optional;

// Méthode retournant un Optional
public Optional<Flight> findFirstAvailableFlight(String from, String to) {
    return flightStore.getFlights().stream()
        .filter(f -> f.getFromCity().equals(from) && f.getToCity().equals(to))
        .findFirst();
}

// Usage côté appelant
Optional<Flight> flight = searchService.findFirstAvailableFlight("London", "New York");

// Option 1 : valeur par défaut
Flight result = flight.orElse(defaultFlight);

// Option 2 : lancer une exception si absent
Flight result = flight.orElseThrow(() -> new FlightNotFoundException("No flight found"));

// Option 3 : exécuter du code si présent
flight.ifPresent(f -> System.out.println("Found: " + f));

// Option 4 : transformer la valeur si présente
Optional<String> destination = flight.map(f -> f.getToCity());

Recommended resource: Applying Functional Programming Techniques in Java — “Avoiding Nulls with the Optional Type” module

Module 5 Summary: CQS, short parameter list (≤3), remove flag arguments, replace strings with restrictive types, fail fast, var for verbose types, avoid creating expensive objects in methods, return empty collections rather than null, use Optional.


6. Character strings and numbers

The String, int and double types are so fundamental that they deserve a dedicated module.

6.1 Using StringBuilder in Loops

In Java, String are immutable. A concatenation creates a new object. In a loop, this creates thousands of useless objects.

Rule: Use StringBuilder whenever you concatenate in a loop.

Before: ineffective concatenation

// Mauvais : crée 1000 nouveaux String objects si 1000 itérations
String result = "";
while ((line = reader.readLine()) != null) {
    result += line; // IntelliJ signale : "String concatenation in a loop"
}

After: Effective StringBuilder

// Bon : StringBuilder est mutable, pas de création d'objets intermédiaires
StringBuilder fileContent = new StringBuilder();
String strCurrentLine;
BufferedReader objReader = new BufferedReader(new FileReader(path));
while ((strCurrentLine = objReader.readLine()) != null) {
    fileContent.append(strCurrentLine);
}
return fileContent.toString(); // conversion finale en String

IntelliJ Tip: Alt+Enter on the errored line → “Replace with StringBuilder” performs the transformation automatically.

6.2 Prefer Text Blocks

Text Blocks (available since Java 15) allow you to write multi-line strings in a readable manner, without escape characters.

Before: JSON as String literal

// Difficile à lire et à maintenir
String json = "[\n" +
        "  {\n" +
        "    \"from\":\"New York\",\n" +
        "    \"to\":\"London\",\n" +
        "    \"date\":\"2022-07-07\"\n" +
        "  },\n" +
        "  {\n" +
        "    \"from\":\"Tokyo\",\n" +
        "    \"to\":\"Singapore\",\n" +
        "    \"date\":\"2022-07-10\"\n" +
        "  }\n" +
        "]";

After: Text Block

// Lisible, propre, sans backslashes
String json = """
        [
          {
            "from":"New York",
            "to":"London",
            "date":"2022-07-07"
          },
          {
            "from":"Tokyo",
            "to":"Singapore",
            "date":"2022-07-10"
          }
        ]
        """;

Example in testing (main benefit):

@Test
public void testWithTextBlock() {
    String json = """
            [
              {
                "from":"New York",
                "to":"London",
                "date":"2022-07-07"
              },
              {
                "from":"Tokyo",
                "to":"Singapore",
                "date":"2022-07-10"
              }
            ]
            """;
    var store = new FlightSimpleStore();
    List<Flight> flights = store.getFlights(json);

    Assertions.assertEquals(2, flights.size());
}

6.3 Separators for large numbers

The simplest tip of the course: use the _ (underscore) character to separate groups of digits in large numbers.

// Avant : difficile à lire — est-ce un million ou 10 millions ?
static int MAX_CARGO_WEIGHT_LBS = 22046;
static int MAX_CARGO_WEIGHT_KG = 10000;
static int MAX_GOODS_VALUE_USD = 1000000;

// Après : immédiatement lisible
static int MAX_CARGO_WEIGHT_LBS = 22_046;
static int MAX_CARGO_WEIGHT_KG = 10_000;
static int MAX_GOODS_VALUE_USD = 1_000_000;

Rule: As soon as a number exceeds 1000, use underscores.

public class CargoFlight {

    static int MAX_CARGO_WEIGHT_LBS = 22_046;
    static int MAX_CARGO_WEIGHT_KG = 10_000;
    static int MAX_GOODS_VALUE_USD = 1_000_000;
}

6.4 Use BigDecimal when precision matters

Floating point numbers (double, float) are represented in binary approximately, which may give unexpected results.

Problem demonstrated:

// Le résultat attendu est 15.4 — mais ce n'est pas ce qu'on obtient
double total = 0;
total += 7.6;
total += 7.8;
System.out.println(total); // Affiche : 15.399999999999999 !

Why? There are an infinite number of numbers between 0 and 1 but limited memory — the representation is therefore approximate.

Solutions:

import java.math.BigDecimal;
import java.math.RoundingMode;

// Solution 1 : arrondir manuellement (rapide mais risqué pour l'argent)
if (Math.abs(15.4 - total) < 0.00001) {
    System.out.println("true"); // comparaison avec tolérance
}

// Solution 2 (recommandée) : BigDecimal pour la précision absolue
BigDecimal num1 = new BigDecimal("7.6"); // IMPORTANT : utiliser String, pas double
BigDecimal num2 = new BigDecimal("7.8");

System.out.println(num1.add(num2));                          // 15.4
System.out.println(num1.divide(num2, RoundingMode.HALF_DOWN)); // précision contrôlée

Warning: Passing the double to the BigDecimal(double) constructor reproduces the precision problem. Always use new BigDecimal("7.6") with a String.

Rule: For financial calculations or any context requiring exact precision, always use BigDecimal.

Module 6 Summary: StringBuilder in loops, Text Blocks for multi-line strings, underscores in large numbers, BigDecimal for precision calculations.


7. Iterations and branches

if/else, switch and for loop constructs are ubiquitous and deserve special attention.

7.1 Writing cleaner conditionals

Anti-pattern #1: Compare a boolean to true or false

// Mauvais : redondant
if (isActive == true) { ... }
if (isActive == false) { ... }

// Bon : direct
if (isActive) { ... }
if (!isActive) { ... }

Anti-pattern #2: Unnecessary negation

// Mauvais : "anti-négatif" — difficile à lire mentalement
if (!isClosed) { ... }

// Bon : positif et naturel
if (isOpen) { ... }

Best practice: Encapsulate complex conditions in Boolean methods

// Avant : condition complexe directement dans le if
if (hour > 6 && hour < 22) {
    // afficher les vols de jour
}

// Après : méthode avec un nom révélateur d'intention
private static boolean isDay(int hour) {
    return hour > 6 && hour < 22;
}

if (isDay(hour)) {
    // afficher les vols de jour
}

More complex example:

// Avant : 3 vérifications directement dans le if
if (input != null && !input.trim().isEmpty() && VALID_PATTERN.matcher(input).matches()) {
    // ...
}

// Après : méthode nommée
private boolean isValidSearchInput(String input) {
    return input != null && !input.trim().isEmpty() && VALID_PATTERN.matcher(input).matches();
}

if (isValidSearchInput(input)) {
    // ...
}

Benefits:

  • Reusable
  • Express logic in natural English
  • Makes code “fluent” to read

7.2 Keeping ternary expressions simple

Rule: Ternary expressions must never be nested or chained.

// Bon : ternaire simple et lisible
String message = passengerCount > maxSeats ? "overbooked" : "not overbooked";
System.out.println("Flight " + message);
// Exemple complet avec ternaire
int maxSeats = 150;
long passengerCount = IntStream.rangeClosed(1, 151)
        .mapToObj(i -> new Passenger(i))
        .count();

// Traditionnel if/else
if (passengerCount > maxSeats) {
    System.out.println("Flight overbooked");
} else {
    System.out.println("Flight not overbooked");
}

// Ternaire — plus court, lisible
String message = passengerCount > maxSeats ? "overbooked" : "not overbooked";
System.out.println("Flight " + message);
// Mauvais : ternaire imbriqué — illisible
String msg = a > b ? (c > d ? "cas1" : "cas2") : (e > f ? "cas3" : "cas4");
// → revenir au if/else dans ce cas

7.3 Switch to Switch Expressions

The classic switch (statement) is subject to a common bug: forgetting the break keyword.

Before: switch statement with fall-through bug

// Mauvais : sans les break, tous les cas s'exécutent !
switch (passenger.seatClass) {
    case ECONOMY:
        System.out.println("Basic meal");
        // OUBLI DE break → fall-through !
    case BUSINESS:
        System.out.println("Meal + dessert");
        // OUBLI DE break → fall-through !
    case FIRST:
        System.out.println("Meal + dessert + champagne");
}
// Résultat : affiche TOUT pour un passager Economy !

After: switch expression (Java 14+) — without fall-through

// Bon : nouvelle syntaxe avec ->
// Pas de break nécessaire, un seul case s'exécute
switch (passenger.seatClass) {
    case ECONOMY -> System.out.println("Basic meal");
    case BUSINESS -> System.out.println("Meal + dessert");
    case FIRST -> System.out.println("Meal + dessert + champagne");
}

7.4 How not to use if-else

There are three quality levels for input validation:

Level 1: Terrible — the Arrow Anti-Pattern (excessive nesting)

// MAUVAIS : if imbriqués → forme d'une flèche pointant à droite
public List<Flight> search(String from, String to, String date) {
    if (from != null) {
        if (to != null) {
            if (date != null) {
                // enfin, la vraie logique
                return doSearch(from, to, date);
            }
        }
    }
    return null;
}

Level 2: Less terrible — flatten conditions

// Mieux, mais encore imparfait
public List<Flight> search(String from, String to, String date) {
    if (from != null && to != null && date != null) {
        return doSearch(from, to, date);
    } else {
        throw new IllegalArgumentException("Arguments cannot be null");
    }
}

Level 3: Good — Fail Fast / Return Early

// Le meilleur : toutes les vérifications d'invalidité d'abord, puis la logique normale
public List<Flight> search(String from, String to, String date) {
    // Vérifications en haut → lire le code devient plus facile
    if (from == null || to == null || date == null) {
        throw new IllegalArgumentException(
            String.format("Input must be non-null. From: %s; To: %s; Date: %s", from, to, date));
    }

    // La logique métier suit, sans imbrication
    return doSearch(from, to, date);
}

Rule: Put all invalidity checks at the top of the method. If invalid → throw an exception or return immediately. Normal code follows without nesting.

7.5 Prefer Streams to for loops

Imperative vs. declarative approach:

  • Imperative: we tell how to do things, step by step
  • Declarative: we say what we want (Java Streams)

Before: traditional for loop

// Impératif : beaucoup de code pour une opération simple
List<Flight> foundFlights = new ArrayList<>();
for (Flight flight : availableFlights) {
    if (flight.getFromCity().equals(from) &&
            flight.getToCity().equals(to) &&
            flight.getDate().equals(date.toString())) {
        foundFlights.add(flight);
    }
}
return foundFlights;

After: Stream

// Déclaratif : plus court, plus lisible
return availableFlights.stream()
        .filter(f -> f.getFromCity().equals(from))
        .filter(f -> f.getToCity().equals(to))
        .filter(f -> f.getDate().equals(date.toString()))
        .toList(); // Java 16+ — plus besoin de .collect(Collectors.toList())

Other common Stream operations:

List<Flight> flights = flightStore.getFlights();

// Filtrer
flights.stream().filter(f -> f.getSeats() > 10)

// Transformer
flights.stream().map(f -> f.getToCity())

// Trier et collecter
flights.stream()
    .sorted(Comparator.comparing(Flight::getDate))
    .collect(Collectors.toList())

// Trouver le premier
Optional<Flight> first = flights.stream().findFirst()

// Compter
long count = flights.stream().filter(f -> ...).count()

7.6 Avoiding Overly Complex Streams

Rule: Streams are good for simple cases. Overly complex Streams are as difficult to read as imperative code — and much harder to debug.

Signs of an overly complex Stream:

  • Stream operations nested inside each other
  • The Arrow Anti-Pattern appears in the Stream
  • Difficult to mentally follow data flow
// Trop complexe — extrait de Effective Java (exemple à éviter)
// Ne pas faire ceci
Map<String, Long> result = flights.stream()
    .collect(groupingBy(Flight::getFromCity,
        counting()
    ));
// (exemple simplifié — le livre montre des niveaux d'imbrication encore plus profonds)

Solution: Extract well-named helper methods for complex operations to use inside the Stream.

// Bon : logique complexe extraite dans une méthode nommée
return flights.stream()
    .filter(this::isValidForCurrentSeason)
    .filter(this::hasAvailableSeats)
    .toList();

private boolean isValidForCurrentSeason(Flight flight) {
    // logique complexe ici
}

Summary Module 7: Clean conditionals without comparison to true/false. Encapsulate complex conditions in methods. Avoid the Arrow Anti-Pattern with fail fast. Use switch expressions (Java 14+). Prefer Streams for simple iterations.


8. Handle exceptions elegantly

When used well, exceptions improve robustness. Used incorrectly, they cause confusion and bugs.

8.1 Catching specific exceptions

Rules:

  • Never catch Throwable — risk catching OutOfMemoryError, InternalError, etc.
  • Do not catch general Exception class — too broad.
  • Catch specific exceptions that we anticipate.

Java exception hierarchy:

Throwable
├── Error (OutOfMemoryError, StackOverflowError, etc.) → NE PAS ATTRAPER
└── Exception
    ├── IOException, SQLException, etc. (checked exceptions)
    └── RuntimeException (unchecked)
        ├── NullPointerException → NE PAS ATTRAPER
        ├── IllegalArgumentException → OK à attraper
        ├── NumberFormatException → OK à attraper
        ├── DateTimeParseException → OK à attraper
        └── ...
// Mauvais : trop large
try {
    // ...
} catch (Throwable t) {
    // attrape y compris OutOfMemoryError !
}

// Mauvais : encore trop large
try {
    // ...
} catch (Exception e) {
    // attrape NullPointerException, etc.
}

// Bon : exception spécifique
try {
    LocalDate.parse(dateString, formatter);
} catch (DateTimeParseException e) {
    // gestion précise du problème
}

8.2 Certain RuntimeExceptions should not be caught

NullPointerException is an indication of a programmer bug. We must not catch it — we must fix the code to avoid it.

// Mauvais : cacher un NPE ne fait que retarder et compliquer le débogage
try {
    String city = flight.getAirport().getCity();
} catch (NullPointerException e) {
    // cache le problème !
    return "Unknown";
}

// Bon : prévenir le NPE avec une vérification ou Objects.requireNonNull()
Airport airport = Objects.requireNonNull(flight.getAirport(), "Airport cannot be null");
String city = airport.getCity();

8.3 Some RuntimeExceptions can be caught

Some RuntimeException represent user input errors — they can and should be caught.

Legitimate examples:

  • NumberFormatException: Integer.parseInt("abc") → string is not an int
  • DateTimeParseException: LocalDate.parse("01/01/2024", formatter) → invalid format

Three options when attracting:

  1. Set a default value and continue
  2. Exit gracefully (show message to user)
  3. Rethrow the exception (rethrow)
private LocalDate parseInputDate(String departDate) {
    try {
        return LocalDate.parse(departDate, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
    } catch (DateTimeParseException exception) {
        // Option 1 : retourner une valeur par défaut
        // return LocalDate.now();

        // Option 3 : traduire et relancer
        throw new IllegalArgumentException(
            String.format("Failed to parse input date '%s', expected format is yyyy-MM-dd", departDate));
    }
}

Do not use exceptions as a substitute for if/else:

// Mauvais : utiliser une exception pour du branchement normal
try {
    int value = Integer.parseInt(input);
} catch (NumberFormatException e) {
    value = 0; // logique de branchement déguisée en exception
}

// Bon : utiliser une vérification normale
if (input != null && input.matches("\\d+")) {
    int value = Integer.parseInt(input);
} else {
    int value = 0;
}

8.4 The catch block rules

Rule #1: Never leave the catch block empty

// TERRIBLE : cache complètement le problème
try {
    parseDate(dateString);
} catch (DateTimeParseException e) {
    // vide — comme si rien ne s'était passé
}

// Également mauvais : un commentaire ne fait pas mieux
try {
    parseDate(dateString);
} catch (DateTimeParseException e) {
    // TODO fix this later
}

Rule #2: Do not return null in the catch

// Mauvais : retourner null augmente le risque de NullPointerException plus loin
try {
    return parseDate(dateString);
} catch (DateTimeParseException e) {
    return null; // dangereux !
}

Rule #3: Do not log AND restart

// Mauvais : double stack trace — confus et trompeur
try {
    return parseDate(dateString);
} catch (DateTimeParseException e) {
    e.printStackTrace(); // log
    throw e;             // ET rethrow → stack trace apparaît deux fois
}

// Bon : l'un ou l'autre — ici on choisit rethrow
try {
    return parseDate(dateString);
} catch (DateTimeParseException e) {
    throw new IllegalArgumentException("Invalid date format: " + dateString, e);
}

8.5 Translate exceptions

Exception Translation: re-throw a higher abstraction level exception to better match the context.

// Avant : exception de bas niveau ne dit rien sur le domaine
try {
    return airportStore.findByName(name);
} catch (NoSuchElementException e) {
    throw e; // NoSuchElementException est une exception de collections Java
}

// Après : exception traduite → révèle le problème métier
try {
    return airportStore.findByName(name);
} catch (NoSuchElementException e) {
    throw new InvalidAirportNameException("Airport not found: " + name, e);
}

// Ou utiliser IllegalArgumentException avec message explicite
try {
    return LocalDate.parse(departDate, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
} catch (DateTimeParseException e) {
    throw new IllegalArgumentException(
        String.format("Failed to parse input date '%s', expected format is yyyy-MM-dd", departDate));
}

8.6 Providing relevant information

Rule: Include all relevant information in the exception message:

  • What was the input value?
  • What was the expected value?
  • In what context did this happen?
// Mauvais : message générique et inutile
throw new IllegalArgumentException("Invalid date");

// Bon : message informatif avec valeur d'entrée et format attendu
private static final String DATE_FORMAT = "yyyy-MM-dd";

private LocalDate parseInputDate(String departDate) {
    try {
        return LocalDate.parse(departDate, DateTimeFormatter.ofPattern(DATE_FORMAT));
    } catch (DateTimeParseException e) {
        throw new IllegalArgumentException(
            String.format("Failed to parse input date '%s'. Expected format is '%s'",
                          departDate, DATE_FORMAT));
    }
}

Another example: validation of the number of arguments

public SearchRequestAfter(String[] args) {
    if (args.length != 3) {
        throw new IllegalArgumentException(
            "Exactly 3 arguments must be provided in the following order: 'From', 'To', 'Date'." +
            " You provided " + args.length + " arguments");
    }
    // ...

    if (from.equalsIgnoreCase(to)) {
        throw new IllegalArgumentException(
            "'From' and 'To' cannot be the same. You input '" + from + "'");
    }
}

Tip: Raise and read your own exceptions to ensure the message is useful without the context of the surrounding code.

8.7 Use multi-catch when appropriate

Since Java 7, you can catch several exceptions in a single catch block if the processing is identical.

// Avant : code dupliqué
try {
    // ...
} catch (IOException e) {
    log.error("IO error", e);
    throw new ServiceException(e);
} catch (SQLException e) {
    log.error("SQL error", e);
    throw new ServiceException(e);
}

// Après : multi-catch — plus concis
try {
    // ...
} catch (IOException | SQLException e) {
    log.error("Database or IO error", e);
    throw new ServiceException(e);
}

Note: Rules exist on what can and cannot be combined (exceptions in inheritance relationships cannot be combined).

8.8 Prefer try-with-resources

The try-with-resources (Java 7+) automatically closes resources that implement AutoCloseable, eliminating the need for the finally block.

Before: try-catch-finally verbose

// Beaucoup de boilerplate — code de nettoyage dans finally
public static String readFileBufferedReader(String path) {
    BufferedReader objReader = null;
    StringBuilder fileContent = new StringBuilder();
    try {
        String strCurrentLine;
        objReader = new BufferedReader(new FileReader(path));
        while ((strCurrentLine = objReader.readLine()) != null) {
            fileContent.append(strCurrentLine);
        }
    } catch (IOException e) {
        throw new UncheckedIOException(e);
    } finally {
        if (objReader != null) {
            try {
                objReader.close();
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        }
    }
    return fileContent.toString();
}

After: try-with-resources + var

// Concis : Java ferme automatiquement le reader à la fin du bloc try
public static String readFileBufferedReaderSimpler(String pathToFile) {
    try (var reader = new BufferedReader(new FileReader(pathToFile))) {
        var sb = new StringBuilder();
        String line;
        while ((line = reader.readLine()) != null) {
            sb.append(line);
        }
        return sb.toString();
    } catch (IOException e) {
        throw new UncheckedIOException(e);
    }
    // Pas de finally ! Java gère le reader.close() automatiquement
}

// Pour les petits fichiers (Java 11+) : encore plus simple
public static String readSmallFile(String pathToFile) throws IOException {
    return Files.readString(Path.of(pathToFile));
}

Example with InputStream and var:

public static String readFileWithInputStream(String pathToFile) {
    try (var in = new FileInputStream(pathToFile)) {
        return new String(in.readAllBytes(), StandardCharsets.UTF_8);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

Module 8 Summary: Catching specific exceptions (not Throwable or General Exception). The catch block must never be empty. Do not log AND restart. Include all relevant information in the message. Translate to the appropriate level of abstraction. Multi-catch to reduce verbosity. Prefer try-with-resources.


9. Write only helpful comments

Feedback is not always beneficial. Many are useless at best, harmful at worst.

9.1 Redundant comments

A redundant comment repeats what the code already clearly says.

// Mauvais : les commentaires ne font que répéter le code
// The name of the passenger
String name;

// Gets the age of the passenger
public int getAge() { ... }

// Loop over the list of books
for (Book book : books) {
    if (book.getTitle().equals(searchTitle)) {
        return true;
    }
}

Rule: Remove comments that only repeat code. The basic constructs of the language (for, if, try/catch) require no comments if they are named well.

9.2 Compensatory comments

These are comments that add value only because the code is poorly written.

“The proper use of comments is to compensate for our failure to express ourselves in the code. We must have them because we cannot always figure out how to express ourselves without them. But their use is not a cause for celebration. » — Robert C. Martin

Golden rule: Don’t comment out bad code. Rewrite it.

// Mauvais : le commentaire compense un mauvais code
// Loop over the passenger list and add them to the result if their seat class matches
for (Passenger p : passengers) {
    if (p.sc.equals("E")) { // sc = seat class, E = Economy
        result.add(p);
    }
}

// Bon : le code se documente lui-même
for (Passenger passenger : passengers) {
    if (passenger.getSeatClass() == SeatClass.ECONOMY) {
        result.add(passenger);
    }
}

9.3 Journal and Wiki Comments

Log comments (change lists) are useless: this is the role of Git (or other VCS).

// MAUVAIS : historique des changements en commentaire
/**
 * 2020-01-15 JohnDoe: Added validation
 * 2020-03-22 JohnDoe: Fixed bug with null date
 * 2021-05-10 JaneDoe: Optimized for performance
 */
public List<Flight> search(SearchRequest request) { ... }

TODOs: They have their place, but only if they are actionable and linked to a ticket.

// Mauvais : TODO vague et non actionable
// TODO fix this
// TODO

// Bon : TODO avec un lien vers l'issue tracker
// TODO: Handle timezone properly — see https://jira.example.com/PROJ-456

9.4 Commented code

Commented code without explanation is one of the worst practices.

Two problems:

  1. Cannot remove it — not sure if it is critical.
  2. We cannot uncomment it — we don’t know what impacts it would have.
// TERRIBLE : code commenté sans explication
//    public List<Flight> searchWithCache(SearchRequest request) {
//        if (cache.contains(request)) {
//            return cache.get(request);
//        }
//        return search(request);
//    }

Rule: Never commit commented code. Remove dead code and use Git to find the history.

9.5 Comments that lie

Comments that no longer correspond to the code (because the code has evolved but not the comment) are misleading.

// Mauvais : le commentaire dit "retourne un Map" mais le code retourne un PersonalDetails
// Returns a Map<String, String> with passenger's personal info
public PersonalDetails getPassengerDetails(String id) { ... }

Rule: When you find an incorrect comment, do not update it — delete it. If the code becomes difficult to understand without commenting, improve the code itself.

9.6 Legitimate uses of comments

1. JavaDoc on public methods of an API or library

/**
 * Searches for available flights matching the given criteria.
 *
 * @param request the search request containing origin, destination, and date
 * @return a list of matching flights, empty if none found
 * @throws IllegalArgumentException if request is null
 */
public List<Flight> search(SearchRequest request) {
    // ...
}

Note: No JavaDoc needed on trivial getters/setters.

2. Copyright in file header

/*
 * Copyright (c) 2022 Cloud Airlines, Inc.
 * All rights reserved.
 */

3. Compensate for limitations of a third-party API

// Ce comportement bizarre est dû à un bug connu dans la librairie XYZ v2.3
// voir https://github.com/xyz/issues/1234
// À supprimer quand XYZ sera mis à jour en v3.0
legacyLib.methodWithStrangeEffect();

4. TODOs with ticket number

// TODO: Add caching for repeated searches — PROJ-789
public List<Flight> search(SearchRequest request) { ... }

Module 9 Summary: The majority of comments compensate for bad code. The solution is to improve the code and remove the comment. JavaDoc on public APIs and TODOs with tickets are legitimate.


10. Create better tests

Automated tests have a cost (writing, maintenance). Their value = benefits - costs. This module focuses on reducing the cost of testing.

10.1 Misnamed tests

A poorly named test is like a book called “About Things.”

Change in naming:

// Mauvais : que signifie "searchFails" ?
@Test
public void searchFails() { ... }

// Mieux : plus descriptif mais vague
@Test
public void searchFailsInvalidInput() { ... }

// Bien : "Rejects" exprime l'intention
@Test
public void searchRejectsInvalidInput() { ... }

// Meilleur : décrit précisément ce qui est testé
@Test
public void searchRejectsInvalidDateFormat() { ... }

// Encore mieux : séparateurs par underscores (autorisé dans les tests)
@Test
public void search_rejectsInvalidDateFormat() { ... }

Use @DisplayName for long description:

@Test
@DisplayName("Search should be rejected when 'from' and 'to' are the same city")
public void sameCityNotAllowed() {
    Assertions.assertThrows(IllegalArgumentException.class,
            () -> new SearchRequest("London", "London", "2022-07-15"));
}

With TestNG:

@Test(description = "Search should be rejected when from and to are the same city")
public void sameCityNotAllowed() { ... }

Real project example:

import org.junit.jupiter.api.*;
import org.junit.jupiter.params.*;
import org.junit.jupiter.params.provider.*;

public class SearchRequestTests {

    @Test
    @DisplayName("Your description here")
    public void sameCityNotAllowed() {
        Assertions.assertThrows(IllegalArgumentException.class,
                () -> new SearchRequest("London", "London", "2022-07-15"));
    }

    @ParameterizedTest
    @CsvSource({
            "    ,New York,2022-10-16",
            "London,    ,2022-10-16"
    })
    public void invalidInputIsRejected(String from, String to, String date) {
        Assertions.assertThrows(NullPointerException.class,
                () -> new SearchRequest(from, to, date));
    }

    @ParameterizedTest
    @MethodSource("com.cloudairlines.utils.TestDataUtils#getInvalidDates")
    public void searchRejectsInvalidDateFormat(String invalidDate) {
        Assertions.assertThrows(IllegalArgumentException.class,
                () -> new SearchRequest("London", "New York", invalidDate));
    }
}

10.2 Clueless Tests

Rule: A test must check exactly one thing.

Pattern: “If we send input A, the system should do B.”

If when describing a test we say “and also”, “or if”, “then also check” — the test is doing too many things.

Unfocused testing issues:

  • Multiple points of failure
  • Difficult to name
  • Difficult to diagnose when failing
  • Overlap with other tests

Before: test checking multiple scenarios

// Mauvais : 3 scénarios différents dans un seul test
@ParameterizedTest
@CsvSource({
    ",London,2022-07-15",     // from est null
    "London,,2022-07-15",     // to est null
    "London,London,2022-07-15" // from == to
})
public void searchFails(String from, String to, String date) {
    // branchement sur les cas — signe que le test n'est pas focalisé
    if (from == null || to == null) {
        Assertions.assertThrows(NullPointerException.class, ...);
    } else {
        Assertions.assertThrows(IllegalArgumentException.class, ...);
    }
}

After: separate and focused tests

// Test 1 : null inputs → NullPointerException
@ParameterizedTest
@CsvSource({
    "    ,New York,2022-10-16",
    "London,    ,2022-10-16"
})
public void invalidInputIsRejected(String from, String to, String date) {
    Assertions.assertThrows(NullPointerException.class,
            () -> new SearchRequest(from, to, date));
}

// Test 2 : même ville → IllegalArgumentException
@Test
public void sameCityNotAllowed() {
    Assertions.assertThrows(IllegalArgumentException.class,
            () -> new SearchRequest("London", "London", "2022-07-15"));
}

10.3 DRY vs. DAMP

DRY (Don’t Repeat Yourself) applies to production code. DAMP (Descriptive And Meaningful Phrases) applies to test code.

DAMP means: maximize the readability of tests, even if this involves some duplication.

DAMP tests:

  • No for or while loops directly in the test body
  • No if/else connections directly in the test
  • No low-level code — wrapper in helper methods
  • Flat tests (little nesting)

Before: loop in test

// Mauvais : boucle dans le test — peut masquer des échecs individuels
@Test
public void searchRejectsInvalidDates() {
    List<String> invalidDates = List.of("2022-07/16", "2022-13-16", "01-01-2024");
    for (String date : invalidDates) {
        Assertions.assertThrows(IllegalArgumentException.class,
                () -> new SearchRequest("London", "New York", date));
    }
}
// S'affiche comme 1 seul test — impossible de savoir quelle date a échoué

After: parameterized test (DAMP)

// Bon : chaque valeur est un test distinct → diagnostic précis en cas d'échec
@ParameterizedTest
@MethodSource("com.cloudairlines.utils.TestDataUtils#getInvalidDates")
public void searchRejectsInvalidDateFormat(String invalidDate) {
    Assertions.assertThrows(IllegalArgumentException.class,
            () -> new SearchRequest("London", "New York", invalidDate));
}

Test data externalized in a helper method:

public class TestDataUtils {
    /**
     * Provides invalid date strings for parameterized tests.
     * Includes format errors (wrong separator, invalid month, wrong order).
     */
    public static Stream<Arguments> getInvalidDates() {
        return Stream.of(
                Arguments.of("2022-07/16"),  // séparateur invalide
                Arguments.of("2022-13-16"),  // mois invalide
                Arguments.of("01-01-2024")   // ordre jour-mois-année au lieu de année-mois-jour
        );
    }
}

10.4 Using test libraries

External libraries improve the readability of assertions thanks to a fluent interface.

AssertJ — fluent assertions:

import static org.assertj.core.api.Assertions.*;

// Bien plus expressif que JUnit natif
assertThat(foundFlights).hasSize(1);
assertThat(foundFlights).extracting(Flight::getFromCity).containsOnly("London");
assertThat(passenger.getSeatClass()).isEqualTo(SeatClass.ECONOMY);
assertThat(price).isGreaterThan(BigDecimal.ZERO);
assertThatThrownBy(() -> new SearchRequest(null, "London", "2022-01-01"))
    .isInstanceOf(NullPointerException.class);

RestAssured — REST API tests:

given()
    .contentType(ContentType.JSON)
    .body(searchRequest)
.when()
    .post("/api/flights/search")
.then()
    .statusCode(200)
    .body("flights.size()", equalTo(3));

10.5 The benefit of useful messages

Adding a useful failure message can turn 10 minutes of debugging into 10 seconds.

Before: assertion without message

// En cas d'échec : "expected: 1 but was: 0" — pas très informatif
Assertions.assertEquals(1, foundFlights.size());

After: assertion with message

@Test
public void searchReturnsCorrectNumberOfFlights() {
    var request = new SearchRequest("London", "New York", "2022-10-15");
    var flightSearch = new FlightSearch(new TestFlightStore());

    var foundFlights = flightSearch.search(request);

    Assertions.assertEquals(1, foundFlights.size(),
            "Test data contains multiple London to New York flights. " +
            "The search should've produced exactly one search result given the unique date.");
}

In case of failure, the message appears in the results:

expected: 1 but was: 0
Test data contains multiple London to New York flights.
The search should've produced exactly one search result given the unique date.

10.6 Make tests independent

Rule: Each test (or small group of tests) must:

  1. Boot from a clean state
  2. Execute its logic
  3. End with a clean state so as not to affect subsequent tests

Consequence of a dependency between tests: A failed test causes a cascade of unrelated failures — very difficult to disentangle.

Independence test: Run tests:

  • Alone, multiple times
  • With other tests from the same package
  • With tests of other packages

The result should always be the same.

// Exemple de TestFlightStore utilisé dans le test — données contrôlées et isolées
private class TestFlightStore implements FlightStore {
    @Override
    public List<Flight> getFlights() {
        return List.of(
                new Flight(LONDON_GATWICK, NEW_YORK_JFK, "2022-10-15"),
                new Flight(LONDON_GATWICK, NEW_YORK_JFK, "2022-11-15"),
                new Flight(LONDON_GATWICK, PARIS_CDG, "2022-11-20")
        );
    }
}

JUnit 5 — cleaning before/after each test:

@BeforeEach
void setUp() {
    // initialisation d'un état propre avant chaque test
    database.clear();
    testData.reset();
}

@AfterEach
void tearDown() {
    // nettoyage après chaque test
    database.clear();
}

10.7 Promoting composition in the test infrastructure

Anti-pattern: inheritance to share test code

Inheritance in tests creates complex trees and monstrous parent classes.

// Anti-pattern
BaseTest
├── FlightSearchTest   (hérite tout de BaseTest même ce dont il n'a pas besoin)
├── PassengerTest      (idem)
└── BookingTest        (idem)

Best practice: Composition — separate utility classes

// Bon : classes utilitaires séparées et ciblées
DateTimeUtils.generateRandomDate()
MathUtils.roundToTwoDecimals()
TestDataUtils.getInvalidDates()
TestDataFactory.createValidPassenger()
TestDataFactory.createValidFlight()

Demo: move a helper method into a utility class

// Avant : méthode helper dans la classe de test
public class SearchRequestTests {
    private static Stream<Arguments> getInvalidDates() {
        return Stream.of(
                Arguments.of("2022-07/16"),
                Arguments.of("2022-13-16"),
                Arguments.of("01-01-2024")
        );
    }
}

// Après : méthode dans une classe utilitaire réutilisable
package com.cloudairlines.utils;

public class TestDataUtils {
    /**
     * Helpful JavaDoc here
     */
    public static Stream<Arguments> getInvalidDates() {
        return Stream.of(
                Arguments.of("2022-07/16"),
                Arguments.of("2022-13-16"),
                Arguments.of("01-01-2024")
        );
    }
}

// Référence dans la classe de test
@MethodSource("com.cloudairlines.utils.TestDataUtils#getInvalidDates")

Module 10 Summary: Name tests descriptively. Small and focused tests. DAMP > DRY for testing. Add useful failure messages. Independent testing. Promote composition over inheritance.


11. Conclusion and further reading

11.1 Learn Modern Java

Java has improved enormously over the versions. Many examples found on the web (Stack Overflow, tutorials) are outdated.

Examples of Java code evolution:

// Avant (Java 8 et moins) : lecture d'un fichier
BufferedReader reader = new BufferedReader(new FileReader(path));
// ... (beaucoup de boilerplate)

// Maintenant (Java 11+) : lecture d'un petit fichier en une ligne
String content = Files.readString(Path.of(path));
// Avant : créer une ArrayList avec éléments
List<String> list = new ArrayList<>();
list.add("London");
list.add("New York");

// Maintenant (Java 9+) : immutable list factory method
List<String> list = List.of("London", "New York");
// Avant : collecter un stream en liste
list.stream()
    .filter(...)
    .collect(Collectors.toList());

// Maintenant (Java 16+) : .toList() directement
list.stream()
    .filter(...)
    .toList();

Tip: On Stack Overflow, scan all answers — modern solutions are often hidden lower down because they are posted later.

11.2 Maintain clean code

Code quality is not something you achieve once — it must be continually maintained.

Static Analysis Tools:

  1. IntelliJ IDEA Inspections (built-in): detects many common issues
  • Settings > Inspections to configure
  • Highlighting in code with Alt+Enter suggestions
  1. SonarLint (IntelliJ/Eclipse plugin): more advanced analysis
  • Shows problematic code
  • Gives examples of bad and good code
  • Detailed explanation of each problem
  • Acts as a “pair programming buddy”

The Boy Scout Rule: Leave the code in a better state than you found it.

Si vous travaillez sur une feature et que vous voyez un petit
problème à côté (ex. concaténation String dans une boucle),
corrigez-le dans un commit séparé.
Les petites améliorations s'accumulent au fil du temps.

Code Review: A regular code review process helps maintain standards. Define team rules in accordance with course best practices.

SubjectResource
Design Patterns (Builder, etc.)Design Patterns in Java (Pluralsight)
SOLID PrinciplesSOLID Software Design Principles in Java (Pluralsight)
Code SmellsJava Refactoring: Best Practices (Andrejs Doronins, Pluralsight)
Null / OptionalApplying Functional Programming Techniques in Java (Pluralsight)
Null Object PatternMaking Your Java Code More Object-oriented (Pluralsight)
Dependency Injection (Spring)Spring Core path (Pluralsight)
Floating PointMath for Programmers (Pluralsight)
Testing FundamentalsFundamentals of Test Automation in Java (Pluralsight)
JUnit 5Java Unit Testing with JUnit (Pluralsight)
Advanced testingWriting Highly Maintainable Unit Tests (Pluralsight)
MockitoGetting Started with Mockito (Pluralsight)
Fluent Interfaces for TestingAutomated Tests in Java with Fluent Interface Using WebDriver Selenium (Andrejs Doronins, Pluralsight)
Essential readingEffective Java — Joshua Bloch
Essential readingClean Code — Robert C. Martin

12. Summary of rules by module

Naming (Module 3)

RuleDescription
Classes = specific nounsPassenger not Client, FlightSearchService not Search
Variables = 1-2 words, camelCasebagCount, isAdult, passengerDetails
Booleans → prefix is/hasisAdult, hasBags, isValid
Methods = revealing verbsgetPassengerDetails(), hasBags(), search()
Constants → ALL_CAPSDEFAULT_CAPACITY, MAX_WEIGHT_KG
No abbreviationsnumberOfBags not nBags
Correct spelling mistakesImpact on search in code

Creating Objects (Module 4)

RuleDescription
Java Records for Simple DataEliminate the boilerplate
Dependency InjectionVia the manufacturer → testable and flexible
Guard clauses / Fail fastObjects.requireNonNull() in constructor
Static factory methodsFlightSearch.newSearch()
Constructor chainingthis(balance, 0.1) — DRY principle
Avoiding Primitive ObsessionWrap in objects (Airport instead of 6 strings)
BuilderPatternFor objects with parameter combinations

Methods (Module 5)

RuleDescription
CQSCommand (void) OR Query (return), never both
≤3 parametersEncapsulate in an object if more
No flag argumentsSplit method or use enum
Restrictive typesEnum, LocalDate rather than String
Fail fast in methodsChecks first
var for verbose typesvar reader = new BufferedReader(...)
No expensive objects in methodsstatic final for Pattern, ObjectMapper
No null returnedEmpty Collections, Optional, Exceptions

Strings & Numbers (Module 6)

RuleDescription
StringBuilder in loopsAvoid the creation of unnecessary objects
Text Blocks (Java 15+)Readable multi-line strings
Underscores in the numbers1_000_000 rather than 1000000
BigDecimal for precisionFinancial calculations, exact comparisons

Branching & Iterations (Module 7)

RuleDescription
No == true / == falseif (isActive) not if (isActive == true)
Boolean methods for conditionsisDay(hour) instead of hour > 6 && hour < 22
Simple ternaries onlyNo nesting
Switch expressions (Java 14+)No fall-through, no break
Fail Fast / Return EarlyNo Arrow Anti-Pattern
Streams for simple iterationsDeclarative, readable
No Streams that are too complexExtract into named methods

Exceptions (Module 8)

RuleDescription
No catch (Throwable)Catch Errors
No general catch (Exception)Too wide
Specific wrestlingDateTimeParseException, NumberFormatException
NullPointerException = programmer bugDon’t catch — fix the code
Catch block not emptyNever empty, never just a comment
No null in catchFlip something sensible
Log OR rethrow, not bothAvoid double stack trace
Informative exception messagesReceived value + expected value
Exception translationAppropriate level of abstraction
Multi-catchcatch (IOException | SQLException e)
Try-with-resourcestry (var reader = ...) — no finally

Comments (Module 9)

RuleDescription
No redundant commentsDon’t repeat what the code says
Don’t comment bad codeRewrite it
No history in commentsUse Git
TODO with ticket only//TODO:PROJ-789
Never commented code in commitUse Git to find history
Delete incorrect commentsDon’t update them — delete them
JavaDoc on Public APIsPublic library methods

Tests (Module 10)

RuleDescription
Descriptive Test NamessearchRejectsInvalidDateFormat
@DisplayName for long descriptions@DisplayName("...")
One test = one thingNo multiple scenarios in a test
DAMP > DRYReadability > non-repetition in tests
No loops in testsUse parameterized tests
Helpful Failure Messages3rd parameter of assertEquals()
Independent tests@BeforeEach / @AfterEach
Composition > inheritanceSeparate utility classes (TestDataUtils)


Search Terms

java · backend · architecture · full-stack · web · comments · methods · prefer · exceptions · tests · appropriate · naming · numbers · objects · caught · class · clean · expressions · iterations · know · list · loops · must · names

Interested in this course?

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