Java version: Java 17 (LTS) — also applicable to Java 8, 11+
Table of Contents
- Class names must be specific
- Class names must reflect the SRP
- Variable naming
- Method naming
- Methods should only do one thing
- Exceptions to the rule
- Abbreviations and spelling
- Use Java Records
- Prefer dependency injection
- Know the DI frameworks
- Protect with guard clauses
- Use static factory methods
- Apply constructor chaining
- Recognize Primitive Obsession
- Know code smells
- The Builder Pattern to the Rescue
- Adhere to the CQS principle
- Keep parameter list short
- Reduce parameter list (demo)
- Remove Flag Arguments (demo)
- Prefer enums when appropriate
- Replace String with LocalDate (demo)
- Fail fast in methods
- The Null Object Pattern
- Reduce verbosity with var
- Avoid the creation of unnecessary objects
- Return appropriate values
- Prefer Optional
- Use StringBuilder in loops
- Prefer Text Blocks
- Separators for large numbers
- Use BigDecimal when precision matters
- Write cleaner conditionals
- Keep ternary expressions simple
- Switch to Switch Expressions
- How not to use if-else
- Prefer Streams to for loops
- Avoid Overly Complex Streams
- Catch specific exceptions
- Some RuntimeExceptions should not be caught
- Some RuntimeExceptions may be caught
- The rules of the catch block
- Translate exceptions
- Provide relevant information
- Use multi-catch when appropriate
- Prefer try-with-resources
- Redundant comments
- Compensatory comments
- Journal and wiki comments
- Commented code
- Lie comments
- Legitimate uses of comments
- Misnamed tests
- Clueless Tests
- DRY vs. DAMP
- Using test libraries
- The benefit of useful messages
- Make tests independent
- Promote composition in the test infrastructure
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
AirlineManagercan 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 searchBookingViewer— viewing reservationsFleetManager— 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.
booleanvariables must be prefixed withisorhas(e.g.:isActive,isValid,hasBags).- Use camelCase.
- Use ALL_CAPS_WITH_UNDERSCORES for constants (
static finalfields).
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: prefixisorhas, 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
getprefix
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:
qtyforquantity,ccyforcurrency). - 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()— withoutgetprefix. - 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:
- Hide complexity: The caller does not need to know the internal details.
- 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:
- Method does too many things → split it
- Too many primitive types → Primitive Obsession → create object
- 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
doubleto theBigDecimal(double)constructor reproduces the precision problem. Always usenew BigDecimal("7.6")with aString.
Rule: For financial calculations or any context requiring exact precision, always use BigDecimal.
Module 6 Summary:
StringBuilderin loops, Text Blocks for multi-line strings, underscores in large numbers,BigDecimalfor 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 catchingOutOfMemoryError,InternalError, etc. - Do not catch general
Exceptionclass — 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 intDateTimeParseException:LocalDate.parse("01/01/2024", formatter)→ invalid format
Three options when attracting:
- Set a default value and continue
- Exit gracefully (show message to user)
- 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:
- Cannot remove it — not sure if it is critical.
- 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
fororwhileloops directly in the test body - No
if/elseconnections 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:
- Boot from a clean state
- Execute its logic
- 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:
- IntelliJ IDEA Inspections (built-in): detects many common issues
Settings > Inspectionsto configure- Highlighting in code with
Alt+Entersuggestions
- 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.
11.3 Summary of recommended resources
| Subject | Resource |
|---|---|
| Design Patterns (Builder, etc.) | Design Patterns in Java (Pluralsight) |
| SOLID Principles | SOLID Software Design Principles in Java (Pluralsight) |
| Code Smells | Java Refactoring: Best Practices (Andrejs Doronins, Pluralsight) |
| Null / Optional | Applying Functional Programming Techniques in Java (Pluralsight) |
| Null Object Pattern | Making Your Java Code More Object-oriented (Pluralsight) |
| Dependency Injection (Spring) | Spring Core path (Pluralsight) |
| Floating Point | Math for Programmers (Pluralsight) |
| Testing Fundamentals | Fundamentals of Test Automation in Java (Pluralsight) |
| JUnit 5 | Java Unit Testing with JUnit (Pluralsight) |
| Advanced testing | Writing Highly Maintainable Unit Tests (Pluralsight) |
| Mockito | Getting Started with Mockito (Pluralsight) |
| Fluent Interfaces for Testing | Automated Tests in Java with Fluent Interface Using WebDriver Selenium (Andrejs Doronins, Pluralsight) |
| Essential reading | Effective Java — Joshua Bloch |
| Essential reading | Clean Code — Robert C. Martin |
12. Summary of rules by module
Naming (Module 3)
| Rule | Description |
|---|---|
| Classes = specific nouns | Passenger not Client, FlightSearchService not Search |
| Variables = 1-2 words, camelCase | bagCount, isAdult, passengerDetails |
Booleans → prefix is/has | isAdult, hasBags, isValid |
| Methods = revealing verbs | getPassengerDetails(), hasBags(), search() |
| Constants → ALL_CAPS | DEFAULT_CAPACITY, MAX_WEIGHT_KG |
| No abbreviations | numberOfBags not nBags |
| Correct spelling mistakes | Impact on search in code |
Creating Objects (Module 4)
| Rule | Description |
|---|---|
| Java Records for Simple Data | Eliminate the boilerplate |
| Dependency Injection | Via the manufacturer → testable and flexible |
| Guard clauses / Fail fast | Objects.requireNonNull() in constructor |
| Static factory methods | FlightSearch.newSearch() |
| Constructor chaining | this(balance, 0.1) — DRY principle |
| Avoiding Primitive Obsession | Wrap in objects (Airport instead of 6 strings) |
| BuilderPattern | For objects with parameter combinations |
Methods (Module 5)
| Rule | Description |
|---|---|
| CQS | Command (void) OR Query (return), never both |
| ≤3 parameters | Encapsulate in an object if more |
| No flag arguments | Split method or use enum |
| Restrictive types | Enum, LocalDate rather than String |
| Fail fast in methods | Checks first |
var for verbose types | var reader = new BufferedReader(...) |
| No expensive objects in methods | static final for Pattern, ObjectMapper |
No null returned | Empty Collections, Optional, Exceptions |
Strings & Numbers (Module 6)
| Rule | Description |
|---|---|
StringBuilder in loops | Avoid the creation of unnecessary objects |
| Text Blocks (Java 15+) | Readable multi-line strings |
| Underscores in the numbers | 1_000_000 rather than 1000000 |
BigDecimal for precision | Financial calculations, exact comparisons |
Branching & Iterations (Module 7)
| Rule | Description |
|---|---|
No == true / == false | if (isActive) not if (isActive == true) |
| Boolean methods for conditions | isDay(hour) instead of hour > 6 && hour < 22 |
| Simple ternaries only | No nesting |
| Switch expressions (Java 14+) | No fall-through, no break |
| Fail Fast / Return Early | No Arrow Anti-Pattern |
| Streams for simple iterations | Declarative, readable |
| No Streams that are too complex | Extract into named methods |
Exceptions (Module 8)
| Rule | Description |
|---|---|
No catch (Throwable) | Catch Errors |
No general catch (Exception) | Too wide |
| Specific wrestling | DateTimeParseException, NumberFormatException |
NullPointerException = programmer bug | Don’t catch — fix the code |
| Catch block not empty | Never empty, never just a comment |
No null in catch | Flip something sensible |
| Log OR rethrow, not both | Avoid double stack trace |
| Informative exception messages | Received value + expected value |
| Exception translation | Appropriate level of abstraction |
| Multi-catch | catch (IOException | SQLException e) |
| Try-with-resources | try (var reader = ...) — no finally |
Comments (Module 9)
| Rule | Description |
|---|---|
| No redundant comments | Don’t repeat what the code says |
| Don’t comment bad code | Rewrite it |
| No history in comments | Use Git |
| TODO with ticket only | //TODO:PROJ-789 |
| Never commented code in commit | Use Git to find history |
| Delete incorrect comments | Don’t update them — delete them |
| JavaDoc on Public APIs | Public library methods |
Tests (Module 10)
| Rule | Description |
|---|---|
| Descriptive Test Names | searchRejectsInvalidDateFormat |
@DisplayName for long descriptions | @DisplayName("...") |
| One test = one thing | No multiple scenarios in a test |
| DAMP > DRY | Readability > non-repetition in tests |
| No loops in tests | Use parameterized tests |
| Helpful Failure Messages | 3rd parameter of assertEquals() |
| Independent tests | @BeforeEach / @AfterEach |
| Composition > inheritance | Separate 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