Skip to content

6. Layered Architectures

6.1. Introduction

Let’s revisit the latest version of the tax calculation application (version 3, Chapter 5). Stripped down to its essentials, its main program had the following structure (simplified diagram):

// creation of an ITax object from a text or JSON file
ITax tax;
try {
    tax = fileName.toLowerCase().endsWith(".json") ? new JsonTax(fileName) : new FileTax(fileName);
} catch (TaxException e) {
    IO.println("The following error occurred: [Code=" + e.getCode() + ", Message=" + e.getMessage() + "]");
    return;
}
// input loop
while (true) {
    String parameters = IO.readln("Tax calculation parameters in the format: ...").strip();
    ...
    // the parameters are correct: we calculate the tax
    IO.println("Tax=" + tax.calculate(married.equals("y"), nbChildren, salary) + " euros");
}

This program combines three types of processing found in most applications:

  • retrieving data stored in files, databases, etc.: lines 1–8;
  • user interaction: lines 11–12 (input) and 14 (output);
  • the use of a business algorithm, the tax calculation: line 14. This calculation was previously performed by the abstract class [AbstractTax], the parent class of [FileTax] and [JsonTax]: in version 3, data reading and calculation are still combined within a single object.

Experience has shown that isolating these different processes into separate classes improves application maintainability. The architecture of an application structured in this way is as follows:

Image

This architecture is called “three-tier architecture.” The term “tier” normally refers to a part of the application that runs on a different machine. When the parts are on the same machine, the term “layered architecture” is used instead:

  • The [business] layer contains the application’s business rules. For our application, these are the rules used to calculate a taxpayer’s tax. This layer requires data to function:
    • tax brackets, which change every year;
    • the number of children, the taxpayer’s marital status, and annual income;
  • the data access layer, or [dao] (DAO = Data Access Object), provides data already stored in files or databases. This includes the tax brackets, as in version 3 of the application;
  • the user interface layer, or [ui] (UI = User Interface), provides the data entered by the user and displays the results to the user. This applies here to the number of children, marital status, and the taxpayer’s annual income.

In general:

  • the [dao] layer handles access to persistent data (files, databases) or non-persistent data (network, sensors, etc.);
  • the [ui] layer handles interactions with the user, if there is one;
  • the three layers are made independent of one another through the use of interfaces.

We will revisit the [Tax] application, which we have already examined several times, to give it a layered architecture. First, we need to define the interfaces for the application’s various layers.

6.2. The interfaces of the [Tax] application

Remember that an interface defines a set of method signatures. Classes that implement the interface provide the implementation for these methods.

Let’s return to the layered architecture of our application:

Image

In this type of architecture, the user often takes the initiative. The user makes a request in [1] and receives a response in [8]. This is called the request-response cycle. Let’s take the example of calculating a taxpayer’s tax. This will require several steps:

  • The [ui] layer asks the user for their marital status, number of children, and annual salary. This is the [1] operation described above;
  • once this is done, layer [ui] asks layer [business] to calculate the tax. To do this, it sends the data it received from the user to that layer. This is operation [2];
  • The [business] layer needs certain information to complete its task: the tax brackets. It requests this information from layer [dao] via path [3, 4, 5, 6]: [3] is the initial request, and [6] is the response to that request;
  • With all the data it needed, layer [business] calculates the tax;
  • The [business] layer can now respond to the request made by the [ui] layer in [2]. This is the [7] path;
  • The [ui] layer formats this result and then presents it to the user. This is the path [8];
  • One could imagine that the user might run tax simulations and want to save them. They would use the same path, [1-8], to do so.

As seen in this description, a layer uses the resources of the layer to its right, never those of the layer to its left. Consider two adjacent layers:

Image

The layer [A] makes requests to the layer [B]. In the simplest cases, a layer is implemented by a single class. But an application evolves over time, and the layer [B] may successively have different implementation classes: [B1], [B2], and so on. If the [B] layer is the [dao] layer, it may have an initial implementation, [B1], that retrieves data from a file. A few years later, you might want to store this data in a database: in that case, you create a second implementation class, [B2]. If, in the initial application, the [A] layer worked directly with the [B1] class, we would be forced to partially rewrite the code for the [A] layer. For example, suppose we wrote something like the following in the [A] layer:

1
2
3
B1 b1 = new B1(...);
...
b1.getData(...);
  • Line 1: An instance of the [B1] class is created;
  • line 3: data is requested from this instance.

If the new implementation class [B2] has methods with the same signature as the class [B1], you’ll need to change all instances of [B1] to [B2]. This is the best-case scenario, and it is quite unlikely if you have not paid attention to these signatures. In practice, it is common for the classes [B1] and [B2] to have different method signatures, meaning that a significant portion of the [A] layer must be rewritten.

This issue can be mitigated by inserting an interface between the [A] and [B] layers. This freezes the method signatures that the [B] layer exposes to the [A] layer within an interface. The previous diagram becomes the following:

Image

The [A] layer no longer communicates directly with the [B] layer, but rather with its [IB] interface. In the code for the [A] layer, the implementation class [Bi] of the [B] layer now appears only once, when the object that implements the [IB] interface is created. After that, the [IB] interface—and not its implementation class—is used. The previous code becomes:

1
2
3
IB ib = new B1(...);
...
ib.getData(...);
  • Line 1: An instance of *[ib] that implements the *[IB] interface is created by instantiating the *[B1]* class;
  • Line 3: Data is requested from the [ib] instance.

Now, if we replace the [B1] implementation of the [B] layer with a [B2] implementation, and both of these implementations adhere to the same [IB] interface, only line 1 of the [A] layer needs to be modified. This is a major advantage that, on its own, justifies the systematic use of interfaces between two layers.

We can go even further and make the [A] layer completely independent of the [B] layer. In the code above, line 1 poses a problem because it hard-codes the class name [B1]. Ideally, the [A] layer should receive an implementation of the [IB] interface without having to specify a class name. This would be consistent with the diagram above: the [A] layer interfaces with the [IB] interface, and there is no reason why it would need to know the name of the class that implements that interface.

This is what dependency injection (DI) enables. The previous architecture evolves as follows:

Image

A cross-cutting component—the dependency injection container—creates the objects that implement the layers and connects them to one another: it passes the [A] layer a reference to the object that implements the [B] layer. The code for the [A] layer then takes the following form:

// the layer [A] receives the layer [B] through its constructor
public class A {
    private final IB ib;

    public A(IB ib) {
        this.ib = ib;
    }

    public void work() {
        ...
        ib.getData(...);
    }
}
  • Lines 5–7: The [A] layer receives, via its constructor, a [ib] instance that implements the [IB] interface. It is not the [A] layer that creates it, but the container;
  • Line 11: Data is requested from the [ib] instance.

The implementation class [B1] no longer appears anywhere in the code of the [A] layer. When the implementation [B1] is replaced by a new implementation [B2], nothing will change in the code of the [A] layer: the only thing that will change is the single location in the application where the container is told which class implements [IB]. This location can even read the name of the implementation to use from a configuration file.

In the Java world, dependency injection was popularized in the early 2000s by the Spring framework, whose configuration was then written in XML files. The Spring Framework, now in version 7, remains the industry standard; it forms the basis of Spring Boot, which we will use in Chapter 13, “Web Services .” We’ll use its container in version 5 of the application (there are others, such as CDI, the Jakarta standard EE, or Google Guice). The combination of “Java interfaces + dependency injection” significantly improves application maintainability by making the layers independent of one another.

Let’s return to the layered architecture of our application. In simple cases, we can start with the [business] layer to discover the application’s interfaces. To function, the application needs data:

  • already available in files, databases, or via the network. This data is provided by the [dao] layer;
  • not yet available. In that case, they are provided by the [ui] layer, which obtains them from the user.

What interface must the [dao] layer provide to the [business] layer? The [dao] layer must provide the tax brackets to the [business] layer. In our application, the [dao] layer uses existing data but does not create new data. Its interface can be as follows:

package dao;

import entities.TaxBracket;
import java.util.List;

// the interface of the [dao] layer
public interface ITaxDao {

    // the tax brackets
    List<TaxBracket> getTaxBrackets();
}
  • Line 1: The interface is located in package [dao]. The file [ITaxDao.java] must therefore be located in a folder named [dao], as we will see;
  • Line 10: The [ITaxDao] interface defines the [getTaxBrackets] method, which provides the tax brackets to the [business] layer. Java does not have properties: read-only data is exposed via the [getXxx] method, in accordance with the JavaBeans convention. The tax brackets are returned as a [List<TaxBracket>] list rather than an array: we will see that the implementations return an immutable list, whereas the elements of an array can always be modified by the recipient;
  • line 3: imports the type [TaxBracket], defined in the package [entities]:
1
2
3
4
5
6
7
package entities;

import java.math.BigDecimal;

// a tax bracket: a record, hence a non-modifiable class
public record TaxBracket(BigDecimal limit, BigDecimal coeffR, BigDecimal coeffN) {
}

A tax bracket is represented by a record (Java 16): a non-modifiable class for which the compiler generates the constructor (based on the components declared in parentheses after the type name), the fields [private final], the accessors [limit()], [coeffR()] and [coeffN()], as well as the methods [equals], [hashCode], and [toString]. We write [new TaxBracket(limit, coeffR, coeffN)] to create a tax bracket. The three components are of type [BigDecimal], the Java class for exact decimal numbers: a [double] cannot exactly represent 0.068 or 291.09. Since a *[BigDecimal] is itself immutable, a tax bracket cannot be changed once it has been created. A traditional JavaBean class, with [setXxx] methods, would have allowed this; we will see that this limitation eliminates a side effect of an older way of implementing the [business]* layer.

What interface should the [business] layer present to the [ui] layer? Let’s review the interactions between these two layers:

  • The [ui] layer asks the user for their marital status, number of children, and annual salary. This is operation [1] in the schema;
  • Once this is done, the [ui] layer asks the [business] layer to calculate the tax. To do so, it sends the data it received from the user to the [business] layer. This is operation [2].

The interface for layer [business] might look like this:

1
2
3
4
5
6
7
package business;

// the interface of the [business] layer
public interface ITaxBusiness {

    int calculateTax(boolean married, int nbChildren, int salary);
}
  • Line 1: Everything related to the [business] layer is placed in the [business] package;
  • line 6: the [ITaxBusiness] interface defines only one method, which calculates a taxpayer’s tax based on their marital status, number of children, and annual salary. The methods of an interface are implicitly public and abstract: there is no need to write [public abstract].

We will now examine an initial implementation of this layered architecture.

6.3. Sample Application—Version 4

6.3.1. The Project

Version 4 is a program consisting of several source files that does not use any external libraries. We will run it using the Java source code launcher, without creating a Maven project. Each layer occupies a separate folder:

TaxV4/
  Main.java                  integration of the layers
  TaxData.txt                the tax brackets
  TaxDataInvalid.txt         an incorrect brackets file
  entities/
    TaxBracket.java
    ErrorCode.java
    TaxException.java
  dao/
    ITaxDao.java
    HardwiredTax.java
    FileTax.java
  business/
    ITaxBusiness.java
    TaxBusiness.java
  ui/
    Dialog.java
  • The [entities] folder contains the cross-layer types for the layers [ui], [business], and [dao]: the record [TaxBracket], the exception [TaxException], and the enumeration [ErrorCode] of its error codes;
  • the [dao] folder contains the interface and classes of the [dao] layer. We will use two implementations of the [ITaxDao] interface: the [HardwiredTax] class, whose data is hard-coded (version 2), and the [FileTax] class, which reads data from a text file (version 3). Reading a JSON file—the other option in version 3—will be covered in version 5;
  • the folder [business] contains the interface and the class for the layer [business];
  • the folder [ui] contains the class for the layer [ui];
  • The file [Main.java] creates the layers and links them together;
  • the file [TaxData.txt] contains the tax brackets read by the [FileTax] implementation of the [dao] layer.

Unlike in other languages, folder names matter here: in Java, a class from the [dao] package is located in a folder named [dao], a class in the [tax.dao] package is in a [tax/dao] folder, and these folders are relative to the source root. This is how the compiler and the virtual machine locate the classes based on their fully qualified names.

The file [Main.java] is a compact source file: it has no package declaration and therefore belongs to the unnamed package at the source root. Starting with Java 22 (multi-file program launcher, JEP 458), the command [java Main.java], entered in the [TaxV4] folder, is sufficient to run the program: the source code launcher compiles [Main.java] in memory, and whenever it encounters a class from another file—for example, [dao.FileTax]— it searches for the file [dao/FileTax.java] starting from the directory of [Main.java] and compiles it in turn. No file named [.class] is written to disk. A class from the unnamed package can use classes from other packages, but the reverse is not possible: no class can import [Main]. This is why only the main program is placed there.

A IDE opens this folder as-is and runs [Main.java] without any special configuration. When the program requires external libraries (Jackson, Spring), in version 5, we will switch to a Maven project.

6.3.2. Application Components

Image

We refer to types that span multiple layers as entities. These are generally the classes that encapsulate the data from the [dao] layer. These entities typically extend all the way up to the [ui] layer. The application entities are the [TaxBracket] record, which has already been presented, and the [TaxException] exception, along with its enumeration of error codes:

package entities;

import java.util.StringJoiner;

// the error codes of the [dao] layer: each one corresponds to a bit, so they can be combined
public enum ErrorCode {
    ACCESS, LINE, FIELD1, FIELD2, FIELD3;

    // the bit of the code: 1 for ACCESS, 2 for LINE, 4 for FIELD1, 8 for FIELD2, 16 for FIELD3
    public int bit() {
        return 1 << ordinal();
    }

    // the names of the codes present in a combination of bits, for example "FIELD2, FIELD3" for 24
    public static String names(int code) {
        StringJoiner names = new StringJoiner(", ");
        for (ErrorCode c : values()) {
            if ((code & c.bit()) != 0) {
                names.add(c.name());
            }
        }
        return names.toString();
    }
}
  • line 6: an enumeration ([enum]). In Java, the values of an enumeration are objects—unique instances of the [ErrorCode] class—and an enumeration can have methods;
  • line 7: the five error codes. The method [ordinal()], inherited from the class [Enum], returns the position of the value in the list: 0 for [ACCESS], 4 for [FIELD3];
  • Lines 10–12: Each code is associated with a bit, obtained by a left shift: [1 << ordinal()] equals 1, 2, 4, 8, and 16. These powers of 2 can be combined using the operator [|]: the code [FIELD2.bit() | FIELD3.bit()] (8 + 16 = 24) means that fields 2 and 3 of a line are incorrect. Version 3 explicitly specified the value of each code ([ACCESS(1)], [LINE(2)]…) and decoded a combination into a set [EnumSet]; calculating the bit based on the position eliminates the need for these values here. The exception’s error code remains a simple integer, which all implementations of the [dao] layer can use;
  • lines 15–23: The static method [names] returns the names of the codes present in a bit combination: “FIELD2, FIELD3” for 24. The method [values()], generated by the compiler, returns the array of enumeration values; [code & c.bit()] is not null if the bit corresponding to code [c] is present in [code]; [name()] returns the name of the value. A [StringJoiner] (Java 8) concatenates strings, separating them with the separator provided to its constructor.
package entities;

import java.io.Serial;

// the exception thrown by the [dao] layer
// it derives from RuntimeException: it is an unchecked exception
public class TaxException extends RuntimeException {

    // version of the class for serialization (an exception is serializable)
    @Serial
    private static final long serialVersionUID = 1L;

    // error code (a combination of ErrorCode bits)
    private final int code;

    // constructors
    public TaxException(String message, int code) {
        this(message, null, code);
    }

    public TaxException(String message, Throwable cause, int code) {
        super(message, cause);
        this.code = code;
    }

    // the error code
    public int getCode() {
        return code;
    }
}
  • Line 7: An application-specific exception is derived from the [RuntimeException] class; it is an unchecked exception. The methods that throw it do not need to declare it in a [throws] clause, and their callers are not required to catch it. This is the standard approach for errors that one layer reports to another layer;
  • lines 10–11: an exception is serializable (the class [Throwable] implements the interface [Serializable]). The [serialVersionUID] field specifies the class version for serialization; without it, the compiler, when using the [-Xlint:all] option, issues a warning. The annotation [@Serial] (Java 14) allows the compiler to verify the declaration of this field;
  • line 14: the error code, an integer that combines bits [ErrorCode]. The field is [final]: it is initialized in the constructor and cannot be changed afterward;
  • lines 17–24: two constructors, one with a message and a code, the other with the original exception (the cause) in addition, which will be accessible via the [getCause()] method. The first calls the second ([this(...)]), which calls the constructor of the parent class [RuntimeException] ([super(...)]);
  • lines 27–29: the error code accessor.

In this version, only the [FileTax] class in the [dao] layer throws [TaxException].

6.3.3. The [dao] layer

Let’s review the interface of the [dao] layer:

package dao;

import entities.TaxBracket;
import java.util.List;

// the interface of the [dao] layer
public interface ITaxDao {

    // the tax brackets
    List<TaxBracket> getTaxBrackets();
}

We implement this interface in two different ways. First, with the [HardwiredTax] class:

package dao;

import entities.TaxBracket;
import java.math.BigDecimal;
import java.util.List;

// a [dao] layer whose data is "hardwired" in the code
public class HardwiredTax implements ITaxDao {

    // tax brackets (2004 tax scale on 2003 income): a non-modifiable list
    private final List<TaxBracket> taxBrackets = List.of(
            bracket("4962", "0", "0"),
            bracket("8382", "0.068", "291.09"),
            bracket("14753", "0.191", "1322.92"),
            bracket("23888", "0.283", "2668.39"),
            bracket("38868", "0.374", "4846.98"),
            bracket("47932", "0.426", "6883.66"),
            bracket("0", "0.481", "9505.54"));

    @Override
    public List<TaxBracket> getTaxBrackets() {
        return taxBrackets;
    }

    // a bracket built from strings: the BigDecimal values are exact
    private static TaxBracket bracket(String limit, String coeffR, String coeffN) {
        return new TaxBracket(new BigDecimal(limit), new BigDecimal(coeffR), new BigDecimal(coeffN));
    }
}
  • line 8: the class [HardwiredTax] implements ([implements]) the interface [ITaxDao];
  • Lines 11–18: the tax brackets. [List.of] (Java 9) creates a read-only list: its methods [add], [set] or [remove] throw [UnsupportedOperationException]. The field is initialized in its declaration;
  • lines 20–23: the implementation of the interface method. The annotation [@Override] instructs the compiler to verify that the method correctly implements a method of the interface: a typo in its name would cause a compilation error;
  • Lines 26–28: A private method that creates a tax bracket from three strings. A [BigDecimal] constructed from a string is exact: [new BigDecimal("0.068")] is exactly 0.068, whereas [new BigDecimal(0.068)] would be 0.0680000000000000049…, making [double] the closest to 0.068.

The [ITaxDao] interface is also implemented by the [FileTax] class, which reads tax brackets from a text file. The [TaxData.txt] file is as follows:

1
2
3
4
5
6
7
4962:0:0
8382:0.068:291.09
14753:0.191:1322.92
23888:0.283:2668.39
38868:0.374:4846.98
47932:0.426:6883.66
0:0.481:9505.54

Each line describes a tax bracket in the format [limit:coeffR:coeffN]. Numbers are written with a decimal point, regardless of the system language; this ensures the file is readable on all machines. The values are those from the 2004 tax schedule for 2003 income, retained as-is: this is an exercise. The class [FileTax] is as follows:

package dao;

import static entities.ErrorCode.ACCESS;
import static entities.ErrorCode.FIELD1;
import static entities.ErrorCode.FIELD2;
import static entities.ErrorCode.FIELD3;
import static entities.ErrorCode.LINE;

import entities.TaxException;
import entities.TaxBracket;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

// a [dao] layer that reads the tax brackets from a text file
// one line per bracket: limit:coeffR:coeffN (decimal point)
public class FileTax implements ITaxDao {

    // data file, relative to the current folder
    private final String fileName;

    // tax brackets
    private final List<TaxBracket> taxBrackets;

    // constructor
    public FileTax(String fileName) {
        // we store the name of the file
        this.fileName = fileName;
        // the brackets read
        List<TaxBracket> brackets = new ArrayList<>();
        // reading the file line by line
        int lineNumber = 0;
        try {
            Path path = Path.of(fileName);
            if (!Files.exists(path)) {
                throw new NoSuchFileException(fileName, null, "The file [" + fileName + "] does not exist");
            }
            for (String lineRead : Files.readAllLines(path)) {
                lineNumber++;
                // empty lines are ignored
                String line = lineRead.strip();
                if (line.isEmpty()) {
                    continue;
                }
                // line split into three fields separated by :
                String[] fields = line.split("\\s*:\\s*");
                int code = 0;
                BigDecimal limit = null, coeffR = null, coeffN = null;
                if (fields.length != 3) {
                    code = LINE.bit();
                } else {
                    // conversions of the 3 fields: each error adds its bit to the code
                    limit = parse(fields[0]);
                    coeffR = parse(fields[1]);
                    coeffN = parse(fields[2]);
                    if (limit == null) code |= FIELD1.bit();
                    if (coeffR == null) code |= FIELD2.bit();
                    if (coeffN == null) code |= FIELD3.bit();
                }
                // error?
                if (code != 0) {
                    throw new TaxException("Line no. " + lineNumber + " is incorrect: [" + line + "]", code);
                }
                // we store the new tax bracket
                brackets.add(new TaxBracket(limit, coeffR, coeffN));
            }
        } catch (IOException e) {
            // file access error: it is wrapped in a TaxException
            throw new TaxException("Error while reading the file " + fileName, e, ACCESS.bit());
        }
        // we return a non-modifiable copy of the list
        taxBrackets = List.copyOf(brackets);
    }

    public String getFileName() {
        return fileName;
    }

    @Override
    public List<TaxBracket> getTaxBrackets() {
        return taxBrackets;
    }

    // a positive or zero number written with digits and a decimal point; null if incorrect
    private static BigDecimal parse(String field) {
        return field.matches("\\d+(\\.\\d+)?") ? new BigDecimal(field) : null;
    }
}
  • Lines 3–7: Static import ([import static], Java 5) allows you to use the static members of a class without prefixing them with the class name. The values of an enum are static members: we write [FIELD1] instead of [ErrorCode.FIELD1];
  • line 21: the class [FileTax] implements the interface [ITaxDao];
  • Lines 24, 27: the file name and the read segments. These [final] fields are initialized only once, in the constructor (lines 32 and 76). They are returned by the methods on lines 79–86; [getTaxBrackets] is the interface method;
  • line 30: the constructor receives the name of the tax bracket file;
  • line 34: the read tax brackets are accumulated in a list named [ArrayList];
  • line 38: [Path.of] (Java 11) creates the file path. A relative path is relative to the current directory, the one in which the [java] command was entered;
  • Lines 39–41: If the file does not exist, a [NoSuchFileException] ([java.nio.file] package) is thrown with an explicit message. Its three-parameter constructor takes the file name, the name of a possible second file (here, [null]), and the reason for the error. Without this test, [Files.readAllLines] would itself throw a [NoSuchFileException] whose message would consist solely of the file name;
  • Line 42: [Files.readAllLines] reads the entire file using UTF-8 and returns a list of its lines. The method opens and closes the file itself. For a large file, it would be preferable to read it in real time using [Files.lines] or [BufferedReader];
  • line 45: [strip()] (Java 11) removes leading and trailing spaces;
  • line 50: the line is split by [split], whose parameter is a regular expression: the separator is a colon, possibly surrounded by spaces ([\s*] matches zero or more spaces). In a Java string, the backslash is doubled;
  • Lines 53–54: A line that does not have three fields is invalid;
  • lines 57–62: conversion of the three fields. Each incorrect field adds a bit to the error code using the operator [|=];
  • lines 65–67: an invalid line triggers a [TaxException], whose message provides the line number and content. This exception is not a [IOException]: it is not caught on line 71 and is propagated as-is to the caller;
  • line 69: the read block is added to the list;
  • lines 71–74: All I/O errors stem from [IOException]: file not found ([NoSuchFileException]), insufficient permissions ([AccessDeniedException]), file not in UTF-8 ([MalformedInputException]), etc. The error is wrapped in a [TaxException] with code [ACCESS]; the original exception is retained as the cause;
  • line 76: [List.copyOf] (Java 10) returns a read-only copy of the list;
  • Lines 89–91: Conversion of a field to [BigDecimal]. The [matches] method verifies that the entire string matches the regular expression [\d+(\.\d+)?]: one or more digits, optionally followed by a period and more digits. Thus, “0,191” is rejected, as is any negative number. The constructor [BigDecimal(String)] is not dependent on the user’s language: it always expects a decimal point. In case of an error, the method returns [null].

6.3.4. The [business] layer

Let’s review the interface for this layer:

1
2
3
4
5
6
7
package business;

// the interface of the [business] layer
public interface ITaxBusiness {

    int calculateTax(boolean married, int nbChildren, int salary);
}

Its implementation, [TaxBusiness], is as follows:

package business;

import dao.ITaxDao;
import entities.TaxBracket;
import java.math.BigDecimal;
import java.math.MathContext;
import java.util.List;

// the [business] layer receives the [dao] layer through its constructor
public class TaxBusiness implements ITaxBusiness {

    // the tax brackets, requested once and for all from the [dao] layer
    private final List<TaxBracket> brackets;

    // constructor
    public TaxBusiness(ITaxDao dao) {
        brackets = dao.getTaxBrackets();
    }

    // tax calculation
    @Override
    public int calculateTax(boolean married, int nbChildren, int salary) {
        // checking the parameters
        if (nbChildren < 0) {
            throw new IllegalArgumentException("nbChildren (" + nbChildren + ") must be positive or zero");
        }
        if (salary < 0) {
            throw new IllegalArgumentException("salary (" + salary + ") must be positive or zero");
        }
        // number of shares
        BigDecimal shares = BigDecimal.valueOf(nbChildren).divide(BigDecimal.TWO)
                .add(BigDecimal.valueOf(married ? 2 : 1));
        if (nbChildren >= 3) {
            shares = shares.add(new BigDecimal("0.5"));
        }
        // taxable income and family quotient
        BigDecimal income = new BigDecimal("0.72").multiply(BigDecimal.valueOf(salary));
        BigDecimal qf = income.divide(shares, MathContext.DECIMAL128);
        // the bracket: the first one whose limit is greater than or equal to QF,
        // the last one if none fits (its limit is not used)
        TaxBracket bracket = brackets.getLast();
        for (int i = 0; i < brackets.size() - 1; i++) {
            if (qf.compareTo(brackets.get(i).limit()) <= 0) {
                bracket = brackets.get(i);
                break;
            }
        }
        // tax (integer part)
        return income.multiply(bracket.coeffR()).subtract(shares.multiply(bracket.coeffN())).intValue();
    }
}
  • Line 10: The class [TaxBusiness] implements the interface [ITaxBusiness]. The [business] layer must collaborate with the [dao] layer: it therefore receives, via its constructor (line 16), a reference to the object that implements the [ITaxDao] interface;
  • Lines 13, 16–18: When the object is constructed, the [business] layer requests the tax brackets from the [dao] layer once and for all and stores them in a [final] field. Note that the [business] layer knows the [dao] layer only through its [ITaxDao] interface: it does not know whether the tax brackets come from the code, a file, or, later, a database;
  • lines 24–29: The [business] layer does not trust its callers. An incorrect parameter triggers a [IllegalArgumentException], the unchecked exception that Java provides for this case, with a message that identifies the parameter;
  • lines 31–35: the number of shares. A [BigDecimal] has no arithmetic operators: instead, its methods [add], [subtract], [multiply], and [divide] are used. Since it is immutable, each operation returns a new object, hence the notation [shares = shares.add(...)]. Division by [BigDecimal.TWO] (a constant added in Java 19) is exact: 3 children result in 1.5 shares;
  • lines 37–38: taxable income and the family quotient. Division by the number of shares can result in an infinite number of decimal places (division by 3, for example): without specified precision, [divide] would then trigger a [ArithmeticException]. [MathContext.DECIMAL128] limits the result to 34 significant digits;
  • lines 41–47: We search for the first bracket whose threshold is greater than or equal to the family quotient. The [BigDecimal] values are compared with [compareTo], which returns a negative, zero, or positive number: [qf.compareTo(limit) <= 0] means [qf <= limit]. The last bracket, [brackets.getLast()] (Java 21), is used if none of the others apply: its threshold (0) is not used;
  • line 49: the tax. [intValue()] returns the integer part of the result.

A method that was once common involved modifying the limit of the last bracket (something like [brackets[brackets.length - 1].setLimit(qf + 1)]) before the search so that the search loop would always stop. This instruction modified the array provided by the [dao] layer: a side effect on data shared by all layers. We could no longer write it here, since a [TaxBracket] is unmodifiable, as is the list that contains them. Modern code simply takes the last segment when no boundary is suitable, without modifying the data it receives.

6.3.5. The [ui] layer

In versions 2 and 3, the user interface was written in the main program, following the creation of the calculation object. The only difference was in how this object was created. We now isolate this interaction in a class within the [ui] layer, which knows the [business] layer only through its interface:

package ui;

import business.ITaxBusiness;

// the console dialog with the user: it only knows the interface of the [business] layer
public final class Dialog {

    // help displayed in case of incorrect input
    private static final String SYNTAX = """
            syntax: Married NbChildren Salary
            Married: y for married, n for not married
            NbChildren: number of children
            Salary: annual salary in euros""";

    // utility class: no instance
    private Dialog() {
    }

    // dialog loop: input of the parameters, calculation, display
    public static void run(ITaxBusiness business) {
        while (true) {
            // we ask for the tax calculation parameters
            String input = IO.readln("Tax calculation parameters in the format: "
                    + "Married (y/n) NbChildren Salary, or nothing to stop: ");
            String parameters = input == null ? "" : input.strip();
            // anything to do?
            if (parameters.isEmpty()) {
                break;
            }
            // checking the number of parameters
            String[] fields = parameters.split("\\s+");
            if (fields.length != 3) {
                IO.println(SYNTAX);
                continue;
            }
            // married
            String married = fields[0].toLowerCase();
            if (!married.equals("y") && !married.equals("n")) {
                IO.println(SYNTAX + "\nIncorrect Married argument: type y or n");
                continue;
            }
            // number of children
            int nbChildren = integer(fields[1]);
            if (nbChildren < 0) {
                IO.println(SYNTAX + "\nIncorrect NbChildren argument: type a positive or zero integer");
                continue;
            }
            // salary
            int salary = integer(fields[2]);
            if (salary < 0) {
                IO.println(SYNTAX + "\nIncorrect Salary argument: type a positive or zero integer");
                continue;
            }
            // the parameters are correct: we ask the [business] layer for the calculation
            IO.println("Tax=" + business.calculateTax(married.equals("y"), nbChildren, salary) + " euros");
        }
    }

    // a positive or zero integer, -1 if the string is not one
    private static int integer(String field) {
        try {
            return Integer.parseInt(field);
        } catch (NumberFormatException e) {
            return -1;
        }
    }
}
  • Line 6: The class [Dialog] has no state of its own and contains only static members. Java does not have top-level static classes: we declare the class [final] (which cannot be subclassed) and give it a private constructor (lines 16–17), which prevents instances from being created;
  • lines 9–13: the help message is a text block (Java 15), delimited by three quotation marks. It can span multiple lines; the standard indentation of the lines is removed. Since the closing quotation marks are placed at the end of the last line, the text does not end with a line break;
  • line 20: the method [run] receives the layer [business] in the form of its interface [ITaxBusiness]. It knows nothing about the class that implements it or the layer [dao];
  • Lines 23–25: [IO.readln] (Java 25) prints its parameter and then reads a line from the keyboard. It returns [null] at the end of the input stream; line 25 then replaces [null] with the empty string: an end-of-stream signal terminates the dialog just like an empty line;
  • line 31: the entered line is split by spaces; the regular expression [\s+] (one or more spaces) ignores multiple spaces;
  • lines 37–38: the strings are compared with [equals], never with [==], which compares references;
  • lines 43–53: the number of children and the salary must be positive integers or zero. The [integer] method (lines 60–66) returns -1 if the field is not an integer: [Integer.parseInt] then throws a [NumberFormatException], which is intercepted. A negative number and an incorrect entry are thus rejected by the same check;
  • line 55: the parameters are correct: the calculation is requested from the [business] layer.

6.3.6. Integrating the layers

All that remains is to create the layers and link them together. This is the role of the main program [Main.java]:

import dao.FileTax;
import dao.HardwiredTax;
import dao.ITaxDao;
import entities.ErrorCode;
import entities.TaxException;
import business.ITaxBusiness;
import business.TaxBusiness;
import ui.Dialog;

// integration of the layers: this is the only place where the implementation classes are named
// argument: "hardwired" for HardwiredTax,
// otherwise the name of the file read by FileTax (TaxData.txt by default)
void main(String[] args) {
    String source = args.length > 0 ? args[0] : "TaxData.txt";
    ITaxBusiness business;
    try {
        // creation of the [dao] layer
        ITaxDao dao = source.equals("hardwired") ? new HardwiredTax() : new FileTax(source);
        // creation of the [business] layer, which receives the [dao] layer
        business = new TaxBusiness(dao);
    } catch (TaxException e) {
        // display of the error and stopping the program with exit code 1
        String origin = e.getCause() == null ? "" : ", original exception: " + e.getCause().getMessage();
        IO.println("The following error occurred: [Code=" + ErrorCode.names(e.getCode())
                + ", Message=" + e.getMessage() + origin + "]");
        System.exit(1);
        return;
    }

    // the [ui] layer: the dialog with the user
    Dialog.run(business);
}
  • Lines 1–8: A compact source file (Java 25) does not have a class declaration, but it may contain imports. The compiler places its contents in an implicit class;
  • line 13: the [main] method receives command-line arguments in the [args] array. The argument selects the implementation of the [dao] layer: [hardwired] for the [HardwiredTax] class; otherwise, the name of the file read by the [FileTax] class—[TaxData.txt] by default;
  • line 18: creation of layer [dao]. This is the only place in the program where the classes [HardwiredTax] and [FileTax] are named;
  • line 20: creation of layer [business], which receives layer [dao];
  • Lines 21–28: The creation of layer [dao] may trigger the creation of [TaxException]. The error is then displayed, along with the message from the original exception (if any), and the program terminates with exit code 1 ([System.exit(1)]). The error code is displayed in plain text using the [ErrorCode.names] method. The [return] statement on line 27 is never executed, but the compiler is unaware that [System.exit] does not yield control: without it, the compiler would report that the variable [business] may not be initialized on line 31;
  • Line 31: The layers are ready: the [ui] layer interacts with the user.

6.3.7. Executions

In the [TaxV4] folder, with the [TaxData.txt] file ([FileTax] implementation), and when testing input validation:

> java Main.java
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 2 60000
Tax=4282 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: n 3 60000
Tax=4282 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: x 2 60000
syntax: Married NbChildren Salary
Married: y for married, n for not married
NbChildren: number of children
Salary: annual salary in euros
Incorrect Married argument: type y or n
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 2
syntax: Married NbChildren Salary
Married: y for married, n for not married
NbChildren: number of children
Salary: annual salary in euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y -2 60000
syntax: Married NbChildren Salary
Married: y for married, n for not married
NbChildren: number of children
Salary: annual salary in euros
Incorrect NbChildren argument: type a positive or zero integer
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 2 60000€
syntax: Married NbChildren Salary
Married: y for married, n for not married
NbChildren: number of children
Salary: annual salary in euros
Incorrect Salary argument: type a positive or zero integer
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: 

With the implementation [HardwiredTax]:

1
2
3
4
5
6
> java Main.java hardwired
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 2 60000
Tax=4282 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 3 60000
Tax=2959 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: 

With a file that does not exist:

> java Main.java TaxDataMissing.txt
The following error occurred: [Code=ACCESS, Message=Error while reading the file TaxDataMissing.txt, original exception: TaxDataMissing.txt: The file [TaxDataMissing.txt] does not exist]

The error code is [ACCESS], and the original exception message, [NoSuchFileException], is displayed: it consists of the file name, followed by the reason provided to its creator.

With the following file, [TaxDataInvalid.txt], where line 3 contains decimal commas:

1
2
3
4
4962:0:0
8382:0.068:291.09
14753:0,191:1322,92
23888:0.283:2668.39

the result is:

> java Main.java TaxDataInvalid.txt
The following error occurred: [Code=FIELD2, FIELD3, Message=Line no. 3 is incorrect: [14753:0,191:1322,92]]

Fields 2 and 3 of line 3 are incorrect: the error code combines the bits from [FIELD2] and [FIELD3], and the [ErrorCode.names] method displays it as “FIELD2, FIELD3.”

6.3.8. Conclusion

The layered architecture and the use of interfaces have brought a certain flexibility to our application. This is evident in the way the main program creates the layers [dao] and [business]:

1
2
3
4
        // creation of the [dao] layer
        ITaxDao dao = source.equals("hardwired") ? new HardwiredTax() : new FileTax(source);
        // creation of the [business] layer, which receives the [dao] layer
        business = new TaxBusiness(dao);

Changing the implementation of the [dao] layer affects only line 2. Once the layers are created, the code for the [ui] layer is the same in both cases: the [business] layer is manipulated there through its [ITaxBusiness] interface, not through its implementation class.

The same flexibility is evident in the [business] layer: it is built based on a reference to the interface of the [dao] layer. Changing the implementation of the latter therefore has no impact on the [business] layer. This is why our single [TaxBusiness] class was able to work without modification with two different implementations of the [dao] layer.

6.4. Sample Application - Version 5

Image

This new version builds on the previous one with the following changes:

  • The layers [dao] and [business] are each placed in a Maven module that generates a library (a JAR file) and tested using the unit testing framework JUnit;
  • The [dao] layer now includes a third implementation, [JsonTax], which reads tax brackets from a JSON file using the Jackson library;
  • The integration of the layers is handled by Spring’s dependency injection container, and the implementation of the [dao] layer is selected from a configuration file.

In large projects, multiple developers work on the same application. Layered architectures facilitate this way of working: because the layers communicate with each other through well-defined interfaces, a developer working on one layer does not have to worry about the work of other developers on other layers. All that is required is that everyone adheres to the interfaces.

Thus, to test the [business] layer, the developer needs an implementation of the [dao] layer. Until that implementation is complete, they can use a dummy implementation of the [dao] layer, provided it adheres to the [ITaxDao] interface. This is another advantage of the layered architecture: a delay in the [dao] layer does not prevent the [business] layer from being tested. The mock implementation also has the advantage of being much simpler to set up than the actual [dao] layer, which may require a DBMS, network connections, etc.

Once the [dao] layer is complete and tested, it is provided to the developers of the [business] layer in the form of a library (a JAR file published in a Maven repository) rather than source code to be copied. Ultimately, the application is delivered as a main program (the one from the [ui] layer) and class libraries (the other layers).

6.4.1. The TaxV5 Maven Project

Version 5 is a multi-module Maven project: a parent project, which does not produce anything on its own, contains three modules, one per layer. The tests do not form a separate project: the Maven convention places a module’s tests in its [src/test/java] folder. They are compiled and run by Maven but are not part of the module’s JAR file. This results in the following directory structure:

TaxV5/
  pom.xml                          parent project
  tax-dao/                         module: entities and [dao] layer (jar)
    pom.xml
    TaxData.txt
    TaxData.json
    TaxDataInvalid.txt             an incorrect brackets file, for the tests
    src/main/java/tax/entities/
      TaxBracket.java
      TaxException.java
    src/main/java/tax/dao/
      ITaxDao.java
      HardwiredTax.java
      FileTax.java
      JsonTax.java
    src/test/java/tax/dao/
      DaoTests.java                JUnit tests
      TrialDao.java                trial program
  tax-business/                    module: [business] layer (jar)
    pom.xml
    src/main/java/tax/business/
      ITaxBusiness.java
      TaxBusiness.java
    src/test/java/tax/business/
      BusinessTests.java           JUnit tests
      TrialBusiness.java           trial program
  tax-ui/                          module: [ui] layer and integration of the layers
    pom.xml
    tax.properties                 configuration files
    tax-json.properties
    tax-hardwired.properties
    tax-error.properties
    TaxData.txt
    TaxData.json
    src/main/java/tax/ui/
      Dialog.java
      Main.java
      TaxConfig.java
      MainConfig.java

The packages are now prefixed with [tax]: [tax.entities], [tax.dao], [tax.business], [tax.ui]. The modules [tax-dao] and [tax-business] will in fact be reused by other projects (Chapters 10 through 13), and two libraries used by the same program must not define two classes with the same fully qualified name. In professional projects, packages are prefixed with the organization’s reverse domain name ([org.springframework], [tools.jackson]…), which ensures unique names.

The file [pom.xml] in the parent project is as follows:

<?xml version="1.0" encoding="UTF-8"?>
<!-- parent project of the [Tax] application, version 5: one Maven module per layer -->
<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>examples.tax</groupId>
    <artifactId>tax-v5</artifactId>
    <version>5.0</version>
    <packaging>pom</packaging>

    <!-- the modules, in the order of their dependencies -->
    <modules>
        <module>tax-dao</module>
        <module>tax-business</module>
        <module>tax-ui</module>
    </modules>

    <properties>
        <maven.compiler.release>25</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <!-- the versions of the libraries, common to all modules -->
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>examples.tax</groupId>
                <artifactId>tax-dao</artifactId>
                <version>5.0</version>
            </dependency>
            <dependency>
                <groupId>examples.tax</groupId>
                <artifactId>tax-business</artifactId>
                <version>5.0</version>
            </dependency>
            <dependency>
                <groupId>tools.jackson.core</groupId>
                <artifactId>jackson-databind</artifactId>
                <version>3.1.5</version>
            </dependency>
            <dependency>
                <groupId>org.springframework</groupId>
                <artifactId>spring-context</artifactId>
                <version>7.0.9</version>
            </dependency>
            <dependency>
                <groupId>org.junit.jupiter</groupId>
                <artifactId>junit-jupiter</artifactId>
                <version>6.0.3</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <!-- JUnit for the tests of all modules -->
    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.15.0</version>
                    <configuration>
                        <!-- keeps the names of the method parameters in the compiled code -->
                        <parameters>true</parameters>
                        <compilerArgs>
                            <arg>-Xlint:all</arg>
                        </compilerArgs>
                    </configuration>
                </plugin>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-surefire-plugin</artifactId>
                    <version>3.5.6</version>
                </plugin>
                <plugin>
                    <groupId>org.codehaus.mojo</groupId>
                    <artifactId>exec-maven-plugin</artifactId>
                    <version>3.5.1</version>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>
</project>
  • Lines 8–11: the parent project’s details: group [examples.tax], name [tax-v5], version [5.0]. The packaging type [pom] indicates that the project does not produce a JAR file: it simply groups modules together and applies a common configuration to them;
  • lines 14–18: the modules, i.e., the subfolders, each of which contains a Maven project. A Maven command entered in the [TaxV5] folder applies to all modules, in the order dictated by their dependencies: [tax-dao], then [tax-business], then [tax-ui];
  • lines 20–23: the properties inherited by the modules: Java 25 and source files in UTF-8;
  • lines 26–54: the section [dependencyManagement] specifies the versions of the dependencies without adding them to the modules. A module that requires a dependency declares it without a version; this way, all versions are consolidated in a single location. It includes the application modules themselves (lines 28–37), Jackson 3 (lines 38–42), the Spring container (lines 43–47), and JUnit (lines 48–52);
  • lines 57–63: the parent’s [dependencies] section adds its dependencies to all modules: in this case, JUnit, with the scope [test]. A dependency with the scope [test] is used only for compiling and running tests: it is not included in the dependencies of the produced library;
  • lines 66–91: the [pluginManagement] section similarly sets the version and configuration of the Maven plugins for all modules: the compiler (with the [-Xlint:all] option, lines 75–77), Surefire, which runs the tests ([mvn test]), and exec, which runs a program ([mvn exec:java]). Line 74 instructs the compiler to preserve the names of the method parameters in the compiled code: JUnit uses this to display parameterized tests, as we will see.

The application is built using the following command, entered in the [TaxV5] folder:

> mvn install

For each module, Maven executes the phases of its lifecycle in the following order: compilation ([compile]), compilation and test execution ([test]), creating the JAR file in the module’s [target] folder ([package]), and finally installation ([install]). If a test fails, the build stops. The [install] phase copies the JAR file and the [pom.xml] file from each module to the user’s local Maven repository, the [~/.m2/repository] folder: for example, the [examples/tax/tax-dao/5.0/tax-dao-5.0.jar] file is located there. Maven searches this local repository for dependencies before downloading them from Maven Central. This local repository serves two purposes:

  • a Maven command entered in a module’s folder—for example, [mvn exec:java] in the [tax-ui] folder—finds the modules on which that module depends in the local repository;
  • the applications in Chapters 10 through 13 declare dependencies in their [pom.xml] files on [examples.tax:tax-dao:5.0], [examples.tax:tax-business:5.0], and, for the [Dialog] class, [examples.tax:tax-ui:5.0]: they thus reuse the layers from version 5 without copying the source code, just as they would use Jackson or Spring.

After making any changes to a module, you must therefore rerun [mvn install] so that the other projects can see the new version. The [-DskipTests] option skips running the tests, and the [-q] (“quiet”) option limits Maven’s output to errors only.

If you open a project in IDE that depends on these modules before installing version 5, Maven will search for them in vain on Maven Central, and IDE will report unresolved dependencies, for example, “Unresolved dependency: 'examples.tax:tax-dao:jar:5.0' " in IntelliJ IDEA. Maven keeps track of this failure: it leaves files named [*.lastUpdated] in each module’s folder within the local repository ([%USERPROFILE%\.m2\repository\examples\tax\tax-dao\5.0], etc.), and the error may persist after the [mvn install] build. The solution:

  • Install version 5 ([mvn install] in the [TaxV5] folder, or, in IntelliJ, the “Maven” panel / [tax-v5] / “Lifecycle " / [install]);
  • delete the [*.lastUpdated] files from the [tax-dao/5.0], [tax-business/5.0], and [tax-ui/5.0] from the local repository, without touching the files [.jar] and [.pom];
  • resynchronize the projects (using the “Sync All Maven Projects” button in the “Maven” panel) and, if the error persists, clear the caches for IntelliJ (“File / Invalidate Caches… / Invalidate and Restart”).

To determine whether the problem stems from Maven or IDE, type [mvn compile] in the project folder that depends on version 5: if Maven responds “BUILD SUCCESS,” the dependencies are correct and only IDE needs to be resynchronized.

6.4.2. The [dao] layer

The [tax-dao] module contains the [dao] layer and the application entities. Its [pom.xml] file is as follows:

<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!-- the parent project provides the common versions and configuration -->
    <parent>
        <groupId>examples.tax</groupId>
        <artifactId>tax-v5</artifactId>
        <version>5.0</version>
    </parent>

    <artifactId>tax-dao</artifactId>

    <dependencies>
        <!-- Jackson for reading the JSON file -->
        <dependency>
            <groupId>tools.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
    </dependencies>

</project>
  • lines 8–12: the parent project. The module inherits the group and version from it; only its name is specified (line 14). Its coordinates are therefore [examples.tax:tax-dao:5.0];
  • lines 18–21: the dependency on Jackson, without a version: this is determined by the parent project. JUnit is inherited from the parent.

The Entities

The record [TaxBracket] corresponds to version 4 (package [tax.entities]). The enumeration [ErrorCode] is removed: [TaxException] thus becomes common to all implementations of the [dao] layer, without being linked to the codes of any of them:

package tax.entities;

import java.io.Serial;

// the exception of the [Tax] application, whatever the layer that throws it
// it derives from RuntimeException: it is an unchecked exception
public class TaxException extends RuntimeException {

    // version of the class for serialization (an exception is serializable)
    @Serial
    private static final long serialVersionUID = 1L;

    // error code
    private final int code;

    // constructors
    public TaxException(String message) {
        this(message, null, 0);
    }

    public TaxException(String message, Throwable cause) {
        this(message, cause, 0);
    }

    public TaxException(String message, int code) {
        this(message, null, code);
    }

    public TaxException(String message, Throwable cause, int code) {
        super(message, cause);
        this.code = code;
    }

    // the error code
    public int getCode() {
        return code;
    }
}
  • line 14: the error code remains a simple integer: each implementation of the [dao] layer defines its own codes;
  • lines 17–32: four constructors, with or without a cause, with or without a code. They all call the last one. Without a code, this one is 0.

The [FileTax] implementation

The [ITaxDao] interface and the [HardwiredTax] class are those of version 4. The [FileTax] class differs only in the way it writes its error codes:

// a [dao] layer that reads the tax brackets from a text file
// one line per bracket: limit:coeffR:coeffN (decimal point)
public class FileTax implements ITaxDao {

    // error codes specific to this implementation: powers of 2, which can be combined
    public static final int ACCESS = 1, LINE = 2, FIELD1 = 4, FIELD2 = 8, FIELD3 = 16;

    // data file, relative to the current folder
    private final String fileName;

    // tax brackets
    private final List<TaxBracket> taxBrackets;
  • Line 6: The error codes, which were previously in the [ErrorCode] enumeration in the entity package, have become constants in the [FileTax] class: These are, in fact, codes specific to this implementation of the [ITaxDao] interface ([JsonTax] has its own). In Java, a constant is a field [static final], whose name is conventionally written in uppercase. Multiple fields of the same type can be declared in the same statement. Constants are written as [FIELD1] within the class and as [FileTax.FIELD1] outside the class. Static imports from version 4 are no longer necessary.

Further on in the constructor:

                String[] fields = line.split("\\s*:\\s*");
                int code = 0;
                BigDecimal limit = null, coeffR = null, coeffN = null;
                if (fields.length != 3) {
                    code = LINE;
                } else {
                    // conversions of the 3 fields: each error adds its code
                    limit = parse(fields[0]);
                    coeffR = parse(fields[1]);
                    coeffN = parse(fields[2]);
                    if (limit == null) code |= FIELD1;
                    if (coeffR == null) code |= FIELD2;
                    if (coeffN == null) code |= FIELD3;
                }
                // error?
                if (code != 0) {
                    throw new TaxException("Line no. " + lineNumber + " is incorrect: [" + line + "]", code);
                }
                // we store the new tax bracket
                brackets.add(new TaxBracket(limit, coeffR, coeffN));
            }
        } catch (IOException e) {
            // file access error: it is wrapped in a TaxException
            throw new TaxException("Error while reading the file " + fileName, e, ACCESS);
        }
  • lines 4–14: the codes are integers that are combined directly with the operator [|=];
  • Lines 17, 24: The errors are encapsulated in a [TaxException], along with their codes.

The [JsonTax] implementation

Like version 3 of Chapter 5, the third implementation of the [dao] layer reads the tax brackets from a JSON file using the Jackson library. The [TaxData.json] file is as follows:

1
2
3
4
5
6
7
8
9
[
  { "limit": 4962, "coeffR": 0, "coeffN": 0 },
  { "limit": 8382, "coeffR": 0.068, "coeffN": 291.09 },
  { "limit": 14753, "coeffR": 0.191, "coeffN": 1322.92 },
  { "limit": 23888, "coeffR": 0.283, "coeffN": 2668.39 },
  { "limit": 38868, "coeffR": 0.374, "coeffN": 4846.98 },
  { "limit": 47932, "coeffR": 0.426, "coeffN": 6883.66 },
  { "limit": 0, "coeffR": 0.481, "coeffN": 9505.54 }
]

The [JsonTax] class is as follows:

package tax.dao;

import tax.entities.TaxException;
import tax.entities.TaxBracket;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.List;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.json.JsonMapper;

// a [dao] layer that reads the tax brackets from a JSON file
public class JsonTax implements ITaxDao {

    // error codes specific to this implementation
    public static final int ACCESS = 1, FORMAT = 2;

    // data file, relative to the current folder
    private final String fileName;

    // tax brackets
    private final List<TaxBracket> taxBrackets;

    // constructor
    public JsonTax(String fileName) {
        this.fileName = fileName;
        String json;
        try {
            Path path = Path.of(fileName);
            if (!Files.exists(path)) {
                throw new NoSuchFileException(fileName, null, "The file [" + fileName + "] does not exist");
            }
            json = Files.readString(path);
        } catch (IOException e) {
            throw new TaxException("Error while reading the file " + fileName, e, ACCESS);
        }
        try {
            // deserialization of the JSON array into an array of TaxBracket records
            TaxBracket[] brackets = JsonMapper.builder().build().readValue(json, TaxBracket[].class);
            if (brackets == null) {
                throw new TaxException("the file does not contain an array");
            }
            taxBrackets = List.of(brackets);
        } catch (JacksonException | TaxException e) {
            throw new TaxException("The file " + fileName
                    + " is not a valid JSON file of tax brackets", e, FORMAT);
        }
    }

    public String getFileName() {
        return fileName;
    }

    @Override
    public List<TaxBracket> getTaxBrackets() {
        return taxBrackets;
    }
}
  • Line 17: Error codes for this implementation: file access error or incorrect content;
  • Lines 29–37: The file is read in its entirety into a string by [Files.readString] (Java 11), with the same I/O error handling as in [FileTax];
  • line 40: the string JSON is converted to an array [TaxBracket[]]. In Jackson 3, whose packages begin with [tools.jackson], the [JsonMapper] object that performs the conversion is immutable: it is constructed using an intermediate object, the “builder” ([JsonMapper.builder()...build()]). Its method [readValue] takes the text JSON and the class of the object to be created, in this case [TaxBracket[].class]. Jackson knows how to construct a record: it maps the names JSON ([limit], [coeffR], [coeffN]) to the record’s components and calls its constructor. The numbers are read directly into [BigDecimal], the component type, without going through a [double]: they are therefore exact;
  • lines 41–43: [readValue] returns [null] if the file contains the literal JSON [null]; in that case, an exception is thrown;
  • line 44: the array becomes an immutable list;
  • lines 45–48: A file that is not a JSON array of tax brackets causes a [JacksonException]. In Jackson 3, this exception is derived from [RuntimeException]: it is an unchecked exception. The [catch] block catches two types of exceptions (“multi-catch,” Java 7) and wraps them in a [TaxException] of type [FORMAT].

A Test Program

Before writing automated tests, let’s perform an initial visual test of the [dao] layer. The [TrialDao] program displays the tax brackets provided by the three implementations. We have placed it in the module’s test sources ([src/test/java]): it will not be included in the [dao] layer’s JAR file:

package tax.dao;

import tax.entities.TaxException;
import tax.entities.TaxBracket;
import java.util.List;

// trial program: the tax brackets provided by the three implementations of the [dao] layer
public class TrialDao {

    public static void main(String[] args) {
        try {
            List<ITaxDao> daos = List.of(
                    new HardwiredTax(), new FileTax("TaxData.txt"), new JsonTax("TaxData.json"));
            for (ITaxDao dao : daos) {
                IO.println("--- " + dao.getClass().getSimpleName());
                for (TaxBracket bracket : dao.getTaxBrackets()) {
                    IO.println(bracket);
                }
            }
        } catch (TaxException e) {
            // display of the error
            String origin = e.getCause() == null ? ""
                    : ", original exception: " + e.getCause().getMessage();
            IO.println("The following error occurred: [Code=" + e.getCode()
                    + ", Message=" + e.getMessage() + origin + "]");
        }
    }
}
  • line 10: the [main] method. Starting with Java 25, it would no longer need to be public or accept program arguments; in Maven projects, we retain its classic form [public static void main(String[] args)], which is recognized by all tools ([exec-maven-plugin] plugin, IDE);
  • lines 12–13: a list of the three implementations of the [ITaxDao] interface;
  • line 15: [getClass()] returns the actual class of the object, whose name (without the package) is [getSimpleName()];
  • line 17: [IO.println] displays the tax bracket using its method [toString], which is generated by the compiler for a record;
  • lines 20–26: Handles a potential [TaxException].

The program runs in the [tax-dao] folder. The [test-compile] phase compiles the main source code and the test source code; the parameter [exec.mainClass] specifies the class to be executed, and [exec.classpathScope=test] instructs the exec plugin to use the test classes and scope dependencies [test]:

> mvn -q test-compile exec:java -Dexec.mainClass=tax.dao.TrialDao -Dexec.classpathScope=test
--- HardwiredTax
TaxBracket[limit=4962, coeffR=0, coeffN=0]
TaxBracket[limit=8382, coeffR=0.068, coeffN=291.09]
TaxBracket[limit=14753, coeffR=0.191, coeffN=1322.92]
TaxBracket[limit=23888, coeffR=0.283, coeffN=2668.39]
TaxBracket[limit=38868, coeffR=0.374, coeffN=4846.98]
TaxBracket[limit=47932, coeffR=0.426, coeffN=6883.66]
TaxBracket[limit=0, coeffR=0.481, coeffN=9505.54]
--- FileTax
TaxBracket[limit=4962, coeffR=0, coeffN=0]
TaxBracket[limit=8382, coeffR=0.068, coeffN=291.09]
TaxBracket[limit=14753, coeffR=0.191, coeffN=1322.92]
TaxBracket[limit=23888, coeffR=0.283, coeffN=2668.39]
TaxBracket[limit=38868, coeffR=0.374, coeffN=4846.98]
TaxBracket[limit=47932, coeffR=0.426, coeffN=6883.66]
TaxBracket[limit=0, coeffR=0.481, coeffN=9505.54]
--- JsonTax
TaxBracket[limit=4962, coeffR=0, coeffN=0]
TaxBracket[limit=8382, coeffR=0.068, coeffN=291.09]
TaxBracket[limit=14753, coeffR=0.191, coeffN=1322.92]
TaxBracket[limit=23888, coeffR=0.283, coeffN=2668.39]
TaxBracket[limit=38868, coeffR=0.374, coeffN=4846.98]
TaxBracket[limit=47932, coeffR=0.426, coeffN=6883.66]
TaxBracket[limit=0, coeffR=0.481, coeffN=9505.54]

All three implementations return the same ranges. The [toString] method of a [BigDecimal] always writes a decimal point, regardless of the user's language.

6.4.3. The [business] layer

The [pom.xml] file in the [tax-business] module is as follows:

<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!-- the parent project provides the common versions and configuration -->
    <parent>
        <groupId>examples.tax</groupId>
        <artifactId>tax-v5</artifactId>
        <version>5.0</version>
    </parent>

    <artifactId>tax-business</artifactId>

    <dependencies>
        <!-- the [business] layer uses the [dao] layer -->
        <dependency>
            <groupId>examples.tax</groupId>
            <artifactId>tax-dao</artifactId>
        </dependency>
    </dependencies>

</project>
  • Lines 18–21: The [business] layer uses the [dao] layer. In the [TaxV5] folder, Maven builds [tax-dao] before [tax-business]; in the [tax-business] folder, it finds [tax-dao] in the local repository, where [mvn install] placed it. Dependencies are transitive: the module also receives Jackson, on which [tax-dao] depends.

The [ITaxBusiness] interface and the [TaxBusiness] class are those from version 4 (package [tax.business]). The test program [TrialBusiness], located in the module’s test sources, performs some tax calculations:

package tax.business;

import tax.dao.FileTax;
import tax.entities.TaxException;

// trial program: a few tax calculations with the FileTax [dao] layer
public class TrialBusiness {

    public static void main(String[] args) {
        try {
            // the brackets file is the one of the tax-dao module (path relative to the current folder)
            ITaxBusiness business = new TaxBusiness(new FileTax("../tax-dao/TaxData.txt"));
            IO.println("Tax(true,2,60000)=" + business.calculateTax(true, 2, 60000) + " euros");
            IO.println("Tax(false,3,60000)=" + business.calculateTax(false, 3, 60000) + " euros");
            IO.println("Tax(false,3,6000)=" + business.calculateTax(false, 3, 6000) + " euros");
            IO.println("Tax(false,3,600000)=" + business.calculateTax(false, 3, 600000) + " euros");
        } catch (TaxException e) {
            // display of the error
            String origin = e.getCause() == null ? ""
                    : ", original exception: " + e.getCause().getMessage();
            IO.println("The following error occurred: [Code=" + e.getCode()
                    + ", Message=" + e.getMessage() + origin + "]");
        }
    }
}
  • line 12: creation of layers [dao] and [business]. The layer [dao] is implemented by the class [FileTax]. Since the program runs in the [tax-business] folder, the relative path [../tax-dao/TaxData.txt] refers to the data file for the [tax-dao] module;
  • lines 13–16: repeated calls to the single method of the [ITaxBusiness] interface.

Results, in the [tax-business] folder:

1
2
3
4
5
> mvn -q test-compile exec:java -Dexec.mainClass=tax.business.TrialBusiness -Dexec.classpathScope=test
Tax(true,2,60000)=4282 euros
Tax(false,3,60000)=4282 euros
Tax(false,3,6000)=0 euros
Tax(false,3,600000)=179275 euros

A married couple with 2 children and a single person with 3 children have the same number of shares (3): their taxes are equal.

6.4.4. Unit tests with JUnit

6.4.4.1. The Principle

The tests performed so far relied on visual verification: we checked that the screen displayed the expected results. This method is impractical when there are many tests to run: humans are prone to fatigue, and their ability to verify results diminishes as the day goes on. The tests must therefore be automated and require no human intervention.

An application evolves over time. With each update, we must verify that it does not “regress”—that is, that it continues to pass the functionality tests performed during its initial development. These tests are called non-regression tests. A moderately large application may require hundreds of tests: in fact, every method in every class of the application is tested. These are called unit tests.

Frameworks have been developed to automate these tests. In Java, the leading framework is JUnit, created in the late 1990s by Kent Beck and Erich Gamma. We use version 6, released in late 2025 (here, version 6.0.3); its API, the one included in the [org.junit.jupiter.api] package, is version 5 of JUnit, also known as JUnit Jupiter (TestNG is another framework based on the same principle). The tests are run by the [mvn test] command from the command line: this is what allows them to be launched automatically, for example, whenever code changes are pushed to a continuous integration server. The IDE (IntelliJ, IDEA, VS—code with the Extension Pack for Java) also run these tests and display their results in a tree structure.

6.4.4.2. A First Example

Let’s examine the principle behind JUnit tests using an example. The Maven project [DemoTests] contains only tests, located in the [src/test/java/demotests] folder. Its [pom.xml] file is as follows:

<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>examples.architectures</groupId>
    <artifactId>demo-tests</artifactId>
    <version>1.0</version>

    <properties>
        <maven.compiler.release>25</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <!-- JUnit Jupiter: the test API and the engine that runs the tests -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>6.0.3</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.15.0</version>
                <configuration>
                    <parameters>true</parameters>
                    <compilerArgs>
                        <arg>-Xlint:all</arg>
                    </compilerArgs>
                </configuration>
            </plugin>
            <!-- mvn test: the Surefire plugin runs the tests -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.5.6</version>
            </plugin>
        </plugins>
    </build>
</project>
  • lines 18–23: The project uses the artifact [junit-jupiter], which includes the test artifact API (the annotations and the [Assertions] class), support for parameterized tests, and the engine that discovers and executes the tests. Its scope is [test];
  • line 33: the compiler preserves the names of the method parameters, which JUnit displays for parameterized tests;
  • lines 40–44: the Surefire plugin, which runs the tests during the [test] phase of Maven.

The test class [Tests1] is as follows:

package demotests;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

// a test class
class Tests1 {

    // the tested method
    private static int sum(int a, int b) {
        return a + b;
    }

    // constructor
    Tests1() {
        IO.println("constructor");
    }

    // executed before each test
    @BeforeEach
    void before() {
        IO.println("before");
    }

    // executed after each test
    @AfterEach
    void after() {
        IO.println("after");
    }

    // a test that succeeds
    @Test
    void t1() {
        IO.println("test1");
        assertEquals(4, sum(2, 2));
    }

    // a test that fails
    @Test
    void t2() {
        IO.println("test2");
        assertEquals(5, sum(2, 2), "2 + 2 is not equal to 5");
    }
}
  • Line 3: The verification methods, or assertions, are static methods of the [Assertions] class. They are imported using a static import so that we can write [assertEquals(...)] instead of [Assertions.assertEquals(...)];
  • line 10: A test class is a regular class, without annotations. It does not need to be public, nor do its methods: package visibility is sufficient;
  • lines 13–15: the method being tested. In a real project, it would be in the project’s main source code ([src/main/java]);
  • lines 18–20: the constructor. It is used only to print a message to the screen: we want to see when it is executed;
  • line 23: the annotation [@BeforeEach] designates a method executed before each test;
  • line 29: the annotation [@AfterEach] designates a method executed after each test;
  • line 35: the annotation [@Test] designates a test method. A test method returns nothing, is not private, and—with one exception we’ll discuss—has no parameters;
  • line 38: the method [assertEquals(expected, actual)] verifies that the obtained value equals the expected value. If this is not the case, it throws a [AssertionFailedError] exception and the test fails. Here, the assertion must pass;
  • line 45: this assertion must fail. The third parameter is the message displayed in case of failure: in JUnit, the message is always the last parameter of the assertions.

We run the tests using the command [mvn test] in the project folder. Below is a screenshot of the JUnit console launcher, which displays the tests in a tree structure; [mvn test] runs the same tests, and IDE displays the same tree. We have abbreviated the call stack (lines [...]):

> mvn test
constructor
before
test1
after
constructor
before
test2
after
╷
└─ JUnit Jupiter ✔
   ├─ Tests2 ✔
   │  ├─ t3() ✔
   │  ├─ t4() ✔
   │  ├─ t5() ✔
   │  ├─ t6() ✔
   │  ├─ t7(int, int, int) ✔
   │  │  ├─ [1] a=1, b=1, sum=2 ✔
   │  │  ├─ [2] a=2, b=3, sum=5 ✔
   │  │  └─ [3] a=-1, b=1, sum=0 ✔
   │  └─ t8(int) ✔
   │     ├─ [1] n=2 ✔
   │     ├─ [2] n=4 ✔
   │     └─ [3] n=6 ✔
   └─ Tests1 ✔
      ├─ t1() ✔
      └─ t2() ✘ 2 + 2 is not equal to 5 ==> expected: <5> but was: <4>

Failures (1):
  JUnit Jupiter:Tests1:t2()
    MethodSource [className = 'demotests.Tests1', methodName = 't2', methodParameterTypes = '']
    => org.opentest4j.AssertionFailedError: 2 + 2 is not equal to 5 ==> expected: <5> but was: <4>
...
       demotests.Tests1.t2(Tests1.java:45)
...
Test run finished after 1350 ms
...
[        12 tests found           ]
[         0 tests skipped         ]
[        12 tests started         ]
[         0 tests aborted         ]
[        11 tests successful      ]
[         1 tests failed          ]
  • Lines 2–5: the output from the [t1] test: the constructor was executed, followed by the [before] method, the test, and finally the [after] method. The console launcher displays the test output as it runs, before the summary tree;
  • Lines 6–9: Output for test [t2]. Note that the constructor was executed again: JUnit creates a new instance of the test class for each test. The tests are thus independent of one another: one test cannot be affected by the state left by another test (in contrast, the annotation [@TestInstance(Lifecycle.PER_CLASS)] requires a single instance for all tests in the class);
  • lines 11–27: the test tree: the JUnit Jupiter engine, the test classes, and then their tests. The ✔ symbol indicates a pass, and the ✘ symbol indicates a failure. The tests in the [Tests2] class, shown below, all passed; JUnit executed them before those of [Tests1]. The execution order of classes and methods is consistent but intentionally non-obvious: tests must not depend on their execution order;
  • line 27: the test [t2] failed. The error message contains the message passed to [assertEquals], the expected value, and the actual value. The messages from JUnit are in English;
  • lines 29–34: details of the failure: the [AssertionFailedError] exception thrown by the assertion, and the call stack at the time of the failure, which points to the faulty line, line 45 of the [Tests1.java] file;
  • lines 38–43: the summary: 12 tests, of which 11 passed and 1 failed. The [mvn test] command then terminates with a build failure (“BUILD FAILURE”) and a non-zero exit code, which allows a script to detect the failure.

To run only a subset of the tests, use the [test] parameter of the Surefire plugin: [mvn test -Dtest=Tests2] runs only the tests in the [Tests2] class, while [mvn test -Dtest=Tests1#t1] runs only the [t1] test. Finally, note the annotations [@BeforeAll] and [@AfterAll], which designate static methods executed only once—before the first test and after the last test of the class— and the annotation [@DisplayName], which gives a test a more descriptive name than that of its method. By default, JUnit executes the tests one after another; parallel execution can be requested in a configuration file [junit-platform.properties].

6.4.4.3. Other assertions

The [Assertions] class has many methods. The [Tests2] class presents a few of them:

package demotests;

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertIterableEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertThrowsExactly;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;

// a few other JUnit assertions
class Tests2 {

    // booleans, null, references
    @Test
    void t3() {
        boolean isTrue = 1 < 2, isFalse = 1 > 2;
        assertTrue(isTrue);
        assertFalse(isFalse);
        Object obj1 = new Object(), obj2 = null, obj3 = obj1;
        assertNotNull(obj1);
        assertNull(obj2);
        assertSame(obj1, obj3);
        assertNotSame(obj1, new Object());
    }

    // reals: equality within a delta
    @Test
    void t4() {
        double d1 = 0.1 + 0.2, d2 = 0.3;
        assertNotEquals(d2, d1);
        assertEquals(d2, d1, 1e-6);
    }

    // strings and collections: all the assertions are checked, even if one of them fails
    @Test
    void t5() {
        String message = "Line no. 3 is incorrect";
        List<Integer> numbers = List.of(1, 2, 3);
        assertAll(
                () -> assertTrue(message.startsWith("Line")),
                () -> assertTrue(message.contains("incorrect")),
                () -> assertEquals(3, numbers.size()),
                () -> assertTrue(numbers.contains(2)),
                () -> assertIterableEquals(List.of(1, 2, 3), numbers));
    }

    // exceptions: the test succeeds if the expected exception is thrown
    @Test
    void t6() {
        List<Integer> numbers = List.of(1, 2, 3);
        assertThrows(IndexOutOfBoundsException.class, () -> numbers.get(3));
        NumberFormatException e =
                assertThrowsExactly(NumberFormatException.class, () -> Integer.parseInt("x"));
        assertNotNull(e.getMessage());
    }

    // a parameterized test: one execution per line of values
    @ParameterizedTest
    @CsvSource({"1, 1, 2", "2, 3, 5", "-1, 1, 0"})
    void t7(int a, int b, int sum) {
        assertEquals(sum, a + b);
    }

    // a parameterized test with a single parameter: one execution per value
    @ParameterizedTest
    @ValueSource(ints = {2, 4, 6})
    void t8(int n) {
        assertEquals(0, n % 2);
    }
}
  • lines 29–30: check whether a Boolean expression is true or false;
  • lines 32–33: check whether a reference is non-null or null;
  • Lines 34–35: Check whether two references refer to the same object;
  • lines 41–43: Calculations involving the real numbers [double] are approximate: [0.1 + 0.2] is not exactly equal to [0.3]. We therefore compare two real numbers to within a certain margin of error, in this case one millionth;
  • lines 51–56: JUnit has no assertions specific to strings: we use [assertTrue] to verify the result of the methods [startsWith] or [contains]. [assertIterableEquals] verifies that two collections have the same elements, in the same order. These assertions are grouped by [assertAll], which takes [() -> ...] lambda expressions (Chapter 7): all assertions are executed, even if one of them fails, and failures are reported together. Without [assertAll], the first failure stops the test. The AssertJ library, often used with JUnit, offers more sophisticated assertions, written in the form [assertThat(message).startsWith("Line")];
  • lines 63–65: [assertThrows(Type.class, action)] executes the action—a lambda expression—and verifies that it throws an exception of type [Type] or a derived type. Here, the method [get] from the list created by [List.of] throws a [ArrayIndexOutOfBoundsException], a class derived from [IndexOutOfBoundsException]: the assertion succeeds. [assertThrowsExactly], on the other hand, requires an exception of exactly type [Type]. Both methods return the thrown exception, which allows its properties to be verified subsequently (line 66);
  • lines 70–74: a parameterized test. The annotation [@ParameterizedTest] replaces [@Test]; the method has parameters, and the annotation [@CsvSource] provides sets of values, one string per set, with the values separated by commas. JUnit converts them to the type of the parameters. The test is executed once per set of values: this is shown in lines 18–20 of the previous results;
  • lines 77–81: for a single-parameter test, the annotation [@ValueSource] directly provides the list of values ([ints], [strings], [doubles]…).

Let’s summarize the main methods of the [Assertions] class:

Method
Checks that
[assertEquals(expected, actual)]
the two values are equal (method [equals])
[assertEquals(real1, real2, delta)]
the two real numbers are equal to within a delta
[assertNotEquals(v1, v2)]
The two values are different
[assertSame(o1, o2)], [assertNotSame(o1, o2)]
The two references may or may not refer to the same object
[assertNull(o)], [assertNotNull(o)]
The reference is null (or not)
[assertTrue(b)], [assertFalse(b)]
The Boolean expression is true (or false)
[assertInstanceOf(Type.class, o)]
the object is of type [Type] or a derived type
[assertArrayEquals(t1, t2)]
both arrays have the same elements, in the same order
[assertIterableEquals(c1, c2)]
Both collections have the same elements, in the same order
[assertAll(action1, action2, ...)]
All action assertions succeed (failures are reported collectively)
[assertThrows(Type.class, action)]
The action throws an exception of type [Type] or a derived type
[assertThrowsExactly(Type.class, action)]
The action throws an exception of type [Type] exactly
[fail(message)]
(always fails)

All of these methods accept an optional final parameter: the message displayed in case of failure.

6.4.4.4. Testing the [dao] Layer

Let's return to the [Tax] application. The [DaoTests] class, which tests the [dao] layer, is located in the [src/test/java/tax/dao] folder of the [tax-dao] module. It belongs to the same package as the classes being tested, which gives it access to their package-visible members. The module’s [pom.xml] does not need to be modified: JUnit is inherited from the parent project. The incorrect data file [TaxDataInvalid.txt] is placed in the module’s folder: this is the current folder during test execution.

package tax.dao;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrowsExactly;
import static org.junit.jupiter.api.Assertions.assertTrue;

import tax.entities.TaxException;
import tax.entities.TaxBracket;
import java.math.BigDecimal;
import java.nio.file.NoSuchFileException;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

// tests of the [dao] layer
class DaoTests {

    // factory of a [dao] layer from the name of its class
    private static ITaxDao createDao(String implementation) {
        return switch (implementation) {
            case "HardwiredTax" -> new HardwiredTax();
            case "FileTax" -> new FileTax("TaxData.txt");
            case "JsonTax" -> new JsonTax("TaxData.json");
            default -> throw new IllegalArgumentException("unknown implementation: " + implementation);
        };
    }

    // the same test for the three implementations
    @ParameterizedTest
    @ValueSource(strings = {"HardwiredTax", "FileTax", "JsonTax"})
    void taxBrackets(String implementation) {
        List<TaxBracket> brackets = createDao(implementation).getTaxBrackets();
        // a few checks: compareTo ignores the scale of BigDecimal (0.191 and 0.1910 are equal)
        assertEquals(7, brackets.size());
        assertEquals(0, new BigDecimal("14753").compareTo(brackets.get(2).limit()));
        assertEquals(0, new BigDecimal("0.191").compareTo(brackets.get(2).coeffR()));
        assertEquals(0, new BigDecimal("1322.92").compareTo(brackets.get(2).coeffN()));
    }

    // the three implementations give the same brackets
    @Test
    void sameBrackets() {
        List<TaxBracket> reference = new HardwiredTax().getTaxBrackets();
        assertEquals(reference, new FileTax("TaxData.txt").getTaxBrackets());
        assertEquals(reference, new JsonTax("TaxData.json").getTaxBrackets());
    }

    // missing file
    @Test
    void missingFile() {
        TaxException e = assertThrowsExactly(TaxException.class,
                () -> new FileTax("TaxDataMissing.txt"));
        assertEquals(FileTax.ACCESS, e.getCode());
        assertInstanceOf(NoSuchFileException.class, e.getCause());
    }

    // file whose line 3 is incorrect (decimal commas)
    @Test
    void incorrectLine() {
        TaxException e = assertThrowsExactly(TaxException.class,
                () -> new FileTax("TaxDataInvalid.txt"));
        assertEquals(FileTax.FIELD2 | FileTax.FIELD3, e.getCode());
        assertTrue(e.getMessage().startsWith("Line no. 3"));
    }

    // missing JSON file
    @Test
    void missingJsonFile() {
        TaxException e = assertThrowsExactly(TaxException.class,
                () -> new JsonTax("TaxDataMissing.json"));
        assertEquals(JsonTax.ACCESS, e.getCode());
    }
}
  • lines 21–28: a method that creates a layer named [dao] based on its class name, using an expression [switch] (Java 14): each [case] returns, after the arrow [->], the value of the expression;
  • lines 31–40: The same test is performed for all three implementations using the [@ValueSource] annotation. It verifies that there are 7 tax brackets and checks the value of bracket #2 (the third one). The [BigDecimal] values are compared with [compareTo] rather than with [equals]: the [equals] method of a [BigDecimal] takes into account the number of decimal places written (its scale, “scale”) and treats 0.191 and 0.1910 as different, whereas [compareTo] compares only the values;
  • Lines 44–48: All three implementations produce exactly the same ranges. [assertEquals] compares the lists using its own method, [equals], which compares elements one by one; the method [equals] of a record, generated by the compiler, compares its components. The comparison succeeds because all three sources write the numbers in the same way;
  • lines 52–57: a missing file triggers a [TaxException] from code [ACCESS], which encapsulates a [NoSuchFileException]. [assertInstanceOf] checks the type of the cause;
  • lines 61–66: the [TaxDataInvalid.txt] file triggers a [TaxException] whose code combines the bits of [FIELD2] and [FIELD3], and whose message begins with “Line no. 3”;
  • lines 70–74: the same verification for the [JsonTax] implementation.

6.4.4.5. Tests for the [business] layer

The [BusinessTests] class in the [tax-business] module tests the [business] layer:

package tax.business;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrowsExactly;

import tax.dao.HardwiredTax;
import tax.dao.ITaxDao;
import tax.entities.TaxBracket;
import java.math.BigDecimal;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

// tests of the [business] layer
class BusinessTests {

    // the [dao] layer and the tested [business] layer: JUnit creates one instance of the class per test
    private final HardwiredTax dao = new HardwiredTax();
    private final ITaxBusiness business = new TaxBusiness(dao);

    // calculations whose result is known
    @ParameterizedTest
    @CsvSource({
        "true, 2, 60000, 4282",
        "false, 3, 60000, 4282",
        "true, 3, 60000, 2959",
        "false, 0, 60000, 11519",
        "false, 3, 6000, 0",
        "false, 3, 600000, 179275"
    })
    void calculateTax(boolean married, int nbChildren, int salary, int tax) {
        assertEquals(tax, business.calculateTax(married, nbChildren, salary));
    }

    // the calculation does not modify the brackets provided by the [dao] layer
    @Test
    void unchangedBrackets() {
        List<TaxBracket> before = List.copyOf(dao.getTaxBrackets());
        business.calculateTax(true, 2, 600000);
        assertEquals(before, dao.getTaxBrackets());
    }

    // negative parameters are rejected
    @Test
    void negativeParameters() {
        assertThrowsExactly(IllegalArgumentException.class, () -> business.calculateTax(true, -1, 60000));
        assertThrowsExactly(IllegalArgumentException.class, () -> business.calculateTax(true, 2, -60000));
    }

    // the [business] layer tested with a fake [dao] layer: a single bracket, a 10 % tax
    // ITaxDao has only one method: a lambda expression is enough to implement it
    @Test
    void withFakeDao() {
        ITaxDao fakeDao =
                () -> List.of(new TaxBracket(BigDecimal.ZERO, new BigDecimal("0.1"), BigDecimal.ZERO));
        ITaxBusiness fakeBusiness = new TaxBusiness(fakeDao);
        // taxable income = 0.72 x 100000 = 72000; tax = 10 % of 72000
        assertEquals(7200, fakeBusiness.calculateTax(false, 0, 100000));
    }
}
  • Lines 19–20: The [business] layer being tested is built on the [dao] layer [HardwiredTax], which do not depend on any files. The fields are initialized when the object is created; since JUnit creates a new instance of the class for each test, each test has brand-new layers;
  • lines 23–34: calculations whose results are known, provided by [@CsvSource]. These are the application’s control values, already obtained by previous versions: they must remain accurate in all future versions. The strings [true] and [false] are converted to [boolean];
  • lines 38–42: The tax calculation does not modify the tax brackets provided by the [dao] layer. The tax brackets are stored before a calculation that uses the last bracket, and then a comparison is made. Since the list is already read-only, [List.copyOf] returns the same list, and any attempt to modify it via the [business] layer would trigger a [UnsupportedOperationException], which would also cause the test to fail. The test keeps track of the old defect described above: if someone reintroduced it, this test would flag it;
  • lines 46–49: negative parameters are rejected;
  • lines 55–56: A dummy [dao] layer, such as the one the developer of the [business] layer would use before the actual [dao] layer is ready: a single tax bracket, a 10% tax on taxable income. Since the [ITaxDao] interface has only one abstract method, it is a functional interface: a lambda expression is sufficient to implement it, without writing a class. Lambda expressions will be covered in Chapter 7;
  • lines 57–59: the [business] layer works with the dummy [dao] layer. The [business] layer is thus tested on its own, independently of the actual implementations of the [dao] layer.

6.4.4.6. Running the Tests

The command [mvn test], when entered in the [TaxV5] folder, runs the tests for all modules; when entered in a module’s folder, it runs only the tests for that module. Here is the test tree for the [tax-dao] module (we have included only the test results):

> mvn -q test
╷
└─ JUnit Jupiter ✔
   └─ DaoTests ✔
      ├─ taxBrackets(String) ✔
      │  ├─ [1] implementation=HardwiredTax ✔
      │  ├─ [2] implementation=FileTax ✔
      │  └─ [3] implementation=JsonTax ✔
      ├─ missingJsonFile() ✔
      ├─ missingFile() ✔
      ├─ sameBrackets() ✔
      └─ incorrectLine() ✔
...
[         7 tests found           ]
[         0 tests skipped         ]
[         7 tests started         ]
[         0 tests aborted         ]
[         7 tests successful      ]
[         0 tests failed          ]

and the test tree for the [tax-business] module:

> mvn -q test
╷
└─ JUnit Jupiter ✔
   └─ BusinessTests ✔
      ├─ withFakeDao() ✔
      ├─ negativeParameters() ✔
      ├─ unchangedBrackets() ✔
      └─ calculateTax(boolean, int, int, int) ✔
         ├─ [1] married=true, nbChildren=2, salary=60000, tax=4282 ✔
         ├─ [2] married=false, nbChildren=3, salary=60000, tax=4282 ✔
         ├─ [3] married=true, nbChildren=3, salary=60000, tax=2959 ✔
         ├─ [4] married=false, nbChildren=0, salary=60000, tax=11519 ✔
         ├─ [5] married=false, nbChildren=3, salary=6000, tax=0 ✔
         └─ [6] married=false, nbChildren=3, salary=600000, tax=179275 ✔
...
[         9 tests found           ]
[         0 tests skipped         ]
[         9 tests started         ]
[         0 tests aborted         ]
[         9 tests successful      ]
[         0 tests failed          ]

All 16 tests passed: each set of values for a parameterized test counts as one test, and JUnit displays them along with the names and values of their parameters (these names are available via the compiler’s [parameters] option). We now consider the layers [dao] and [business] to be operational. The JAR files produced by [mvn package] or [mvn install] contain only the layer code: the test classes, compiled in the [target/test-classes] folder, are not included. Finally, note that [mvn install] runs the tests before installing the modules: a regression prevents the installation of a new version of the layers.

6.4.5. The [ui] layer

The file [pom.xml] from the [tax-ui] module is as follows:

<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!-- the parent project provides the common versions and configuration -->
    <parent>
        <groupId>examples.tax</groupId>
        <artifactId>tax-v5</artifactId>
        <version>5.0</version>
    </parent>

    <artifactId>tax-ui</artifactId>

    <dependencies>
        <!-- the [ui] layer uses the two other layers -->
        <dependency>
            <groupId>examples.tax</groupId>
            <artifactId>tax-business</artifactId>
        </dependency>
        <!-- the Spring dependency injection container -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!-- mvn exec:java runs the main class -->
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <configuration>
                    <mainClass>tax.ui.Main</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
  • lines 18–21: The [ui] layer uses the [business] layer and, by transitivity, the [dao] layer;
  • lines 23–26: the Spring dependency injection container, the artifact [spring-context], which brings along the other Spring modules it needs ([spring-core], [spring-beans]…);
  • lines 32–38: the exec plugin, whose version is determined by the parent. The command [mvn exec:java] executes the class [tax.ui.Main].

The class [Dialog] is the one for version 4 (package [tax.ui]), supplemented with a method that displays an exception and all the exceptions it encapsulates:

    // display of an exception and of all those it wraps
    public static void displayError(Throwable exception) {
        String separator = "-".repeat(40);
        IO.println("Exception chain:\n" + separator);
        for (Throwable e = exception; e != null; e = e.getCause()) {
            IO.println(e.getClass().getName() + ": " + e.getMessage());
            IO.println(separator);
        }
    }
}
  • line 3: a string of 40 dashes, created by the [repeat] method (Java 11);
  • line 5: the loop iterates through the chain of exceptions: each exception provides access, via its [getCause()] method, to the exception it encapsulates, all the way down to the last one, whose cause is [null]. The parameter is of type [Throwable], the superclass of all exceptions;
  • Line 6: For each exception, the full name of its class (including the package) and its message are displayed.

The class [Dialog] is public: future versions of the application, which will switch to the [dao] layer, will be able to reuse the [ui] layer by depending on the [tax-ui] module and calling [Dialog.run(business)].

6.4.6. Integrating Layers via Dependency Injection

In version 4, the main program created the layers [dao] and [business] by naming their implementation classes:

1
2
3
        ITaxDao dao = source.equals("hardwired") ? new HardwiredTax() : new FileTax(source);
        // creation of the [business] layer, which receives the [dao] layer
        business = new TaxBusiness(dao);

Apart from these lines, nothing changes in the application when the implementation of a layer changes, since each layer communicates with the next through an interface. We can take layer independence a step further by entrusting their creation to a dependency injection container and placing the choice of the [dao] layer’s implementation in a configuration file. Changing the implementation of this layer then simply involves modifying that file, without recompiling the application.

Image

In the example above, the main program of the [ui] layer starts the Spring container, [0]. The Spring container creates the [dao] and [1] layers, then the [business] and [2] layers, to which it passes the [dao] layer. Finally, the layer [ui] requests the layer [business] from the container and receives a reference to it: [3].

6.4.6.1. The configuration file

The choice of the [dao] layer implementation is specified in the [tax.properties] file in the [tax-ui] folder:

1
2
3
# configuration of the [Tax] application: implementation of the [dao] layer
dao.implementation=FileTax
dao.file=TaxData.txt
  • line 1: a comment beginning with the character [#];
  • line 2: the class that implements the [dao] layer: [HardwiredTax], [FileTax], or [JsonTax];
  • line 3: the data file, for implementations that read one.

A “properties” file is the traditional Java configuration format: one property per line, in the form [key=value], read by the [java.util.Properties] class. Keys are often prefixed to form groups, such as the prefix [dao.] here. The module contains three other configuration files, which the program uses if their names are passed as arguments:

1
2
3
tax-json.properties      : dao.implementation=JsonTax, dao.file=TaxData.json
tax-hardwired.properties : dao.implementation=HardwiredTax
tax-error.properties     : dao.implementation=FileTax, dao.file=TaxDataMissing.txt

The files [TaxData.txt] and [TaxData.json] have been copied into the [tax-ui] folder: their paths are relative to the current folder, and [mvn exec:java] runs the program in the module’s folder.

6.4.6.2. The Main Program

The main program [Main] for the [tax-ui] module is as follows:

package tax.ui;

import tax.dao.FileTax;
import tax.dao.HardwiredTax;
import tax.dao.ITaxDao;
import tax.dao.JsonTax;
import tax.entities.TaxException;
import tax.business.ITaxBusiness;
import tax.business.TaxBusiness;
import java.io.IOException;
import java.io.Reader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
import org.springframework.context.support.GenericApplicationContext;

// integration of the layers by the Spring container GenericApplicationContext
public class Main {

    public static void main(String[] args) {
        // the configuration file: tax.properties by default, or the one passed as an argument
        String configFile = args.length > 0 ? args[0] : "tax.properties";

        // integration of the layers
        ITaxBusiness business;
        try {
            // reading the configuration, in the current folder
            Properties config = new Properties();
            try (Reader reader = Files.newBufferedReader(Path.of(configFile))) {
                config.load(reader);
            }
            String implementation = config.getProperty("dao.implementation", "HardwiredTax");
            String file = config.getProperty("dao.file", "");

            // the Spring container: the layers are registered in it
            GenericApplicationContext context = new GenericApplicationContext();
            // the [dao] layer: the implementation is chosen by the configuration
            context.registerBean(ITaxDao.class, () -> switch (implementation) {
                case "HardwiredTax" -> new HardwiredTax();
                case "FileTax" -> new FileTax(file);
                case "JsonTax" -> new JsonTax(file);
                default -> throw new TaxException(
                        "Unknown implementation [" + implementation + "] of the [dao] layer");
            });
            // the [business] layer: the container will pass the [dao] layer to its constructor
            context.registerBean(TaxBusiness.class);

            // starting the container: it creates the [dao] layer, then the [business] layer
            context.refresh();
            business = context.getBean(ITaxBusiness.class);
        } catch (IOException | RuntimeException e) {
            // display of the error and stopping the program
            Dialog.displayError(e);
            System.exit(1);
            return;
        }

        // the dialog with the user
        Dialog.run(business);
    }
}
  • line 20: the [main] method of a regular class, in its standard form [public static void main(String[] args)];
  • line 22: the name of the configuration file: [tax.properties] by default, or the one passed as an argument;
  • lines 28–31: reading the configuration. [Files.newBufferedReader] opens the file in UTF-8; the [try] statement with a resource (Java 7) closes it at the end of the block, even if an exception occurs. [Properties.load] reads the lines from [key=value]. A missing file triggers a [NoSuchFileException], which is intercepted on line 51;
  • lines 32–33: [getProperty] returns the value of a key, or the default value provided as the second parameter if the key does not exist;
  • line 36: the Spring container. Spring refers to an object created and managed by the container as a “bean,” and to the container itself as the “application context.” [GenericApplicationContext] is a container that is configured programmatically;
  • lines 38–44: the registration of the [dao] layer. [registerBean(ITaxDao.class, supplier)] indicates that the bean of type [ITaxDao] will be created by the “supplier,” a parameterless lambda expression that returns the object. The bean is a singleton, Spring’s default scope: it will be created only once, and all requests will return the same object; there is only one [dao] layer in the application. The supplier selects the class based on the configuration, using a [switch] expression. An unknown implementation causes a [TaxException];
  • Line 46: The [business] layer registration: The container will create the [TaxBusiness] object itself. To do this, it examines the constructor of the [TaxBusiness] class, sees that it expects a [ITaxDao], and passes it the bean of that type registered above: this is dependency injection, in this case via the constructor. The [dao] layer is a dependency of the [business] layer;
  • line 49: [refresh()] starts the container. Spring then creates all the singleton beans in the order they were registered: the [dao] layer, followed by the [business] layer. A creation error therefore occurs on this line;
  • Line 50: The container is asked for a bean of type [ITaxBusiness]. The container finds the bean [TaxBusiness], which implements this interface. [getBean] throws an exception if no bean has this type, or if multiple beans do. Note that the [ui] layer obtains the [business] layer without specifying the class that implements it;
  • lines 51–56: any error reading the configuration ([IOException]) or creating the layers ([RuntimeException]: Spring exceptions are also unchecked) is displayed along with the exception stack trace, and the program terminates;
  • line 59: user interaction, identical to that in version 4.

Early versions of Spring read the full names of the classes to be instantiated from an XML file and created them using reflection. We chose instead to define the possible implementations in the provider: the configuration file simply selects from among them, and the compiler verifies that the classes exist and have the required constructors. In both cases, the code for the [ui] and [business] layers never specifies the class that implements the [dao] layer. It’s also worth noting that the layer classes know nothing about Spring: none of them import any Spring classes; only the main program does.

6.4.6.3. Executions

In the [tax-ui] folder, following a [mvn install] in the [TaxV5] folder, with the file [tax.properties] (implementation [FileTax]):

1
2
3
4
5
6
7
8
> mvn -q exec:java
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 2 60000
Tax=4282 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 3 60000
Tax=2959 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: n 0 60000
Tax=11519 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: 

With the implementation [JsonTax]:

1
2
3
4
> mvn -q exec:java -Dexec.args="tax-json.properties"
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 2 60000
Tax=4282 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: 

With the [HardwiredTax] implementation:

1
2
3
4
> mvn -q exec:java -Dexec.args="tax-hardwired.properties"
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 2 60000
Tax=4282 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: 

We switched to the [dao] implementation without changing a single line of code or recompiling the application.

With the file [tax-error.properties], which refers to a nonexistent data file:

> mvn -q exec:java -Dexec.args="tax-error.properties"
Oct 03, 2026 7:01:28 PM org.springframework.context.support.GenericApplicationContext refresh
WARNING: Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'tax.dao.ITaxDao': Instantiation of supplied bean failed
Exception chain:
----------------------------------------
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'tax.dao.ITaxDao': Instantiation of supplied bean failed
----------------------------------------
tax.entities.TaxException: Error while reading the file TaxDataMissing.txt
----------------------------------------
java.nio.file.NoSuchFileException: TaxDataMissing.txt: The file [TaxDataMissing.txt] does not exist
----------------------------------------
  • Lines 2–3: Before propagating the exception, Spring writes a warning to its log. Since there is no logging library in the project, this log is handled by the standard logging mechanism of JDK ([java.util.logging]), which writes the date, the throwing class, the message level, and the message itself to the error output;
  • line 10: the original exception, of type [NoSuchFileException], thrown by the [FileTax] class;
  • line 8: the [dao] layer encapsulated it within a [TaxException];
  • line 6: Spring itself encapsulated the [TaxException] exception in a [BeanCreationException] exception, whose message identifies the bean that could not be created: the one of type [ITaxDao]. It is this exception that was propagated up to the main program through the call to [refresh].

The key takeaway from this chain is that it is often the deepest exception—the last one displayed—that is the most significant.

6.4.6.4. A Variation: Configuration via an Annotated Class

Spring applications typically describe their beans in an annotated configuration class. The [tax-ui] module contains an example of this, the [TaxConfig] class:

package tax.ui;

import tax.dao.FileTax;
import tax.dao.HardwiredTax;
import tax.dao.ITaxDao;
import tax.dao.JsonTax;
import tax.entities.TaxException;
import tax.business.ITaxBusiness;
import tax.business.TaxBusiness;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;

// the configuration of the Spring container written as an annotated Java class
@Configuration
// the properties of the tax.properties file of the current folder are accessible through ${...}
@PropertySource("file:tax.properties")
public class TaxConfig {

    // the [dao] layer: the implementation is chosen by the configuration
    @Bean
    ITaxDao dao(@Value("${dao.implementation}") String implementation,
            @Value("${dao.file:}") String file) {
        return switch (implementation) {
            case "HardwiredTax" -> new HardwiredTax();
            case "FileTax" -> new FileTax(file);
            case "JsonTax" -> new JsonTax(file);
            default -> throw new TaxException(
                    "Unknown implementation [" + implementation + "] of the [dao] layer");
        };
    }

    // the [business] layer: Spring passes the method the [dao] layer created by the dao method
    @Bean
    ITaxBusiness business(ITaxDao dao) {
        return new TaxBusiness(dao);
    }
}
  • Line 16: The annotation [@Configuration] indicates that the class describes beans;
  • Line 18: The annotation [@PropertySource] instructs Spring to read the file [tax.properties] itself. The prefix [file:] refers to a file in the file system, relative to the current directory; the prefix [classpath:] would refer to a resource located in the JAR file;
  • Lines 22–32: A method annotated with [@Bean] creates a bean whose name is that of the method ([dao]) and whose type is its return type. Spring calls the method, providing its parameters. The annotation [@Value] requests the value of a property: [${dao.implementation}] is the value of the key [dao.implementation], and [${dao.file:}] is the value of the key [dao.file], with the empty string (placed after the colon) as the default value;
  • Lines 35–38: the [business] layer bean. The method expects a [ITaxDao]: Spring passes it the bean created by the [dao] method. This time, it is injection via a method parameter. To ensure that a singleton bean is created only once, Spring derives a subclass of the configuration class at runtime, which overrides its [@Bean] methods: therefore, neither the class nor these methods should be named [final] or [private].

The main program [MainConfig] uses this configuration:

package tax.ui;

import tax.business.ITaxBusiness;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

// integration of the layers by the Spring container, configured by the TaxConfig class
public class MainConfig {

    public static void main(String[] args) {
        // creation and start of the container; it is closed at the end of the try block
        try (var context = new AnnotationConfigApplicationContext(TaxConfig.class)) {
            // the [business] layer, and the dialog with the user
            ITaxBusiness business = context.getBean(ITaxBusiness.class);
            Dialog.run(business);
        } catch (RuntimeException e) {
            // display of the error and stopping the program
            Dialog.displayError(e);
            System.exit(1);
        }
    }
}
  • Line 11: [AnnotationConfigApplicationContext] is a container configured by annotated classes. Its constructor registers the beans described by the class [TaxConfig] and starts the container. The container implements the [AutoCloseable] interface: the [try] resource closes it at the end of the block, which releases the beans. The keyword [var] (Java 10) allows the compiler to infer the variable’s type;
  • line 13: the [business] layer is generated as before;
  • lines 15–19: error handling.

Results:

1
2
3
4
> mvn -q exec:java -Dexec.mainClass=tax.ui.MainConfig
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 2 60000
Tax=4282 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: 

With an incorrect configuration, the exception chain now has one additional level: Spring reports the failure of the [dao] method via a [BeanInstantiationException] exception, which is encapsulated within the [BeanCreationException] exception. Spring can also automatically discover the classes to instantiate: with the [@ComponentScan] annotation on the configuration class, it creates a bean for each class in a package annotated with [@Component] (or [@Service], [@Repository]). We did not do this here, because it would have required annotating the layer classes, thereby making them dependent on Spring. Spring Boot, in Chapter 13, makes extensive use of this mechanism.

Dependency injection alone deserves a chapter of its own. We have used only the essentials: registering beans ([registerBean], [@Bean]), starting the container ([refresh]), and obtaining a bean ([getBean]). The container can also create a new object for each request (scope [prototype]), or an object per request (HTTP), or per session in a web application (scopes [request] and [session]). You can learn more about this topic in the Spring documentation: The IoC Container.

6.5. Conclusion

This chapter has provided the [Tax] application with the architecture it will retain until the end of the course:

  • a [dao] layer that provides tax brackets through the [ITaxDao] interface;
  • a [business] layer that calculates the tax and interacts with the [dao] layer only through its interface;
  • a [ui] layer that interacts with the user and knows the [business] layer only through its [ITaxBusiness] interface;
  • a main program—the only one that knows the implementation classes—which creates the layers and connects them, in this case using Spring’s dependency injection container and a configuration file;
  • automated unit tests, which verify each layer independently of the others and ensure the application’s non-regression.

The benefits of this architecture will become fully apparent in the following chapters, where the modules [tax-dao], [tax-business], and [tax-ui] from version 5—installed in the local Maven repository by [mvn install]—will be reused as-is as Maven dependencies:

  • Version 6 (Chapter 10, “Accessing Databases with JDBC”): A new implementation of the [dao] layer reads tax brackets from a MySQL database. The [business] and [ui] layers remain unchanged;
  • Version 7 (Chapter 11, “Hibernate”): Another implementation of the [dao] layer uses the ORM Hibernate layer to access the same database;
  • Version 8 (Chapter 12, “Internet Programming”): The [business] layer is installed on a TCP server, which calculates taxes for remote clients whose [ui] layer is from Version 5; The layers are thus distributed across multiple machines, restoring the original meaning of the term “tier”;
  • Version 9 (Chapter 13, “Web Services”): The [business] layer is exposed by a web service REST/JSON written with Spring Boot, whose console client uses the [HttpClient] class from JDK.

Each time, the work will be limited to writing the new layer and modifying the program that integrates the layers: that is the whole point of layered architecture.