Skip to content

6. Layered Architectures

6.1. Introduction

Let’s revisit the latest version of the tax calculation application (version 3, previous chapter). Stripped down to the essentials, its console program had the following structure:

// creation of an ITax object from a text or JSON file
ITax tax;
try
{
    tax = Path.GetExtension(fileName).ToLower() switch
    {
        ".json" => new JsonTax(path),
        _ => new FileTax(path)
    };
}
catch (TaxException e)
{
    Console.WriteLine($"The following error occurred: [Code={(ErrorCode)e.Code}, Message={e.Message}]");
    return 1;
}
// input loop
while (true)
{
    Console.Write("Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: ");
    string parameters = (Console.ReadLine() ?? "").Trim();
    ...
    // the parameters are correct: we calculate the tax
    Console.WriteLine($"Tax={tax.Calculate(married == "y", nbChildren, salary)} euros");
}

This program combines three processes found in most applications:

  • retrieving data stored in files, databases, etc.: lines 1–14;
  • user interaction: lines 19–20 (input) and 23 (output);
  • the use of a business algorithm, specifically tax calculation: line 23. This calculation was previously performed by the 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 processing steps 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 and property signatures. The classes that implement the interface provide the actual implementation for these methods and properties.

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 the 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: you would then 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.

We can improve this by placing 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

Layer [A] no longer communicates directly with layer [B] but instead with its interface [IB]. In the code for layer [A], 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 obtain 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:

1
2
3
4
5
6
7
8
9
// the [A] layer receives the [B] layer through its constructor
public class A(IB ib)
{
    public void Work()
    {
        ...
        ib.GetData(...);
    }
}
  • Line 2: 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 7: 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.

The 2008 course used the Spring.NET framework and an XML configuration file named [App.config] for this purpose. Spring.NET is no longer maintained; .NET now provides its own container, [Microsoft.Extensions.DependencyInjection], which is used by all ASP.NET Core applications. This is the one we will use in version 5 of the application. The combination of “C# interfaces + dependency injection” brings a decisive improvement to application maintenance 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 explore 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:

using Entities;

namespace Dao;

// the interface of the [dao] layer
public interface ITaxDao
{
    // the tax brackets
    TaxBracket[] TaxBrackets { get; }
}
  • Line 3: The [dao] layer is placed in the [Dao] namespace. We use file-scope namespaces (C# 10): the declaration [namespace Dao;] applies to the entire file, without curly braces or additional indentation;
  • Line 9: The [ITaxDao] interface defines the read-only property [TaxBrackets], which provides the tax brackets to the [business] layer;
  • line 1: imports the namespace in which the type [TaxBracket] is defined:
1
2
3
4
namespace Entities;

// a tax bracket: an immutable structure
public readonly record struct TaxBracket(decimal Limit, decimal CoeffR, decimal CoeffN);

As in versions 2 and 3, a tax bracket is represented by a [readonly record struct] (C# 10): a read-only structure for which the compiler generates the constructor (from the parameters placed after the type name), the read-only properties [Limit], [CoeffR], and [CoeffN], value equality, and the [ToString] method. We write [new TaxBracket(4962M, 0M, 0M)] to create a bracket. The 2008 course used a structure with modifiable properties; we will see that the inability to modify a bracket eliminates a side effect of the old layer [business].

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 this, 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
namespace Business;

// the interface of the [business] layer
public interface ITaxBusiness
{
    int CalculateTax(bool married, int nbChildren, int salary);
}
  • Line 1: Everything related to the [business] layer is placed in the [Business] namespace;
  • 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.

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 single console project, created by:

> dotnet new console -n TaxV4 -o TaxV4

Each layer occupies a separate folder:

TaxV4/
  TaxV4.csproj
  Program.cs                 integration of the layers
  TaxData.txt                the tax brackets
  TaxDataInvalid.txt         an incorrect brackets file
  Entities/
    TaxBracket.cs
    TaxException.cs
  Dao/
    ITaxDao.cs
    HardwiredTax.cs
    FileTax.cs
  Business/
    ITaxBusiness.cs
    TaxBusiness.cs
  Ui/
    Dialog.cs
  • The [Entities] folder contains the types shared by the [ui], [business], and [dao] layers: the structure [TaxBracket] and the exception [TaxException], carried over from version 3;
  • the [Dao] folder contains the interface and classes for 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 [Business] folder contains the interface and class for the [business] layer;
  • The [Ui] folder contains the class for the [ui] layer;
  • The file [Program.cs] creates the layers and links them together;
  • the file [TaxData.txt] contains the tax brackets read by the [FileTax] implementation of the [dao] layer.

The folder names are irrelevant to the compiler: all [.cs] files in the project folder and its subfolders are compiled. This is a convention that makes the project easier to read; it is supplemented by naming the namespace after the folder.

The file [TaxV4.csproj] is the one generated by [dotnet new], supplemented with an instruction to copy the data files:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <!-- the data files are copied next to the executable -->
    <None Update="TaxData*.txt" CopyToOutputDirectory="PreserveNewest" />
  </ItemGroup>

</Project>
  • Line 12: The [TaxData*.txt] files are copied next to the executable during compilation, into the [bin/Debug/net10.0] folder. The [dao] layer looks for them in this location, regardless of whether the program is launched via [dotnet run] from the project folder or directly from another folder.

6.3.2. The application’s entities

Image

We refer to entities as types that span multiple layers. These are generally the classes and structures that encapsulate the data from the [dao] layer. These entities generally trace back to the [ui] layer. The application entities are the structure [TaxBracket], which has already been presented, and the exception [TaxException] from version 3, along with the list of its error codes:

namespace Entities;

// the error codes of the [dao] layer: powers of 2, which can be combined
[Flags]
public enum ErrorCode { Access = 1, Line = 2, Field1 = 4, Field2 = 8, Field3 = 16 }

// the exception thrown by the [dao] layer
public class TaxException : Exception
{
    // error code (a combination of ErrorCode values)
    public int Code { get; init; }

    // constructors
    public TaxException(string message) : base(message) { }
    public TaxException(string message, Exception inner) : base(message, inner) { }
}
  • lines 4–5: the error codes. The attribute [Flags] indicates that the values in the enumeration are bits that can be combined using the operator [|]: the value [ErrorCode.Field2 | ErrorCode.Field3] (8 + 16 = 24) means that fields 2 and 3 of a line are incorrect. The [ToString] method for such an enumeration then displays “Field2, Field3.” The value [Json] from version 3 has been removed, since this version does not read the JSON file;
  • line 8: an application-specific exception derived from the [Exception] class;
  • line 11: the error code, an integer that combines [ErrorCode] values, is a [init] property (C# 9): it can only be assigned a value when the object is created, for example with an initializer [new TaxException(...) { Code = ... }];
  • Lines 14–15: two constructors, one with a message, the other with a message and the original exception, which will be accessible via the property [InnerException]. They simply call the corresponding constructor of the parent class [Exception] ([: base(...)]).

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

6.3.3. The [dao] layer

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

using Entities;

namespace Dao;

// the interface of the [dao] layer
public interface ITaxDao
{
    // the tax brackets
    TaxBracket[] TaxBrackets { get; }
}

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

using Entities;

namespace Dao;

// a [dao] layer whose data is hard-coded
public class HardwiredTax : ITaxDao
{
    // tax brackets (2004 tax scale on 2003 income)
    public TaxBracket[] TaxBrackets { get; } =
    [
        new(4962M, 0M, 0M),
        new(8382M, 0.068M, 291.09M),
        new(14753M, 0.191M, 1322.92M),
        new(23888M, 0.283M, 2668.39M),
        new(38868M, 0.374M, 4846.98M),
        new(47932M, 0.426M, 6883.66M),
        new(0M, 0.481M, 9505.54M),
    ];
}
  • line 6: the [HardwiredTax] class implements the [ITaxDao] interface;
  • line 9: implementation of the [TaxBrackets] property of the interface. This is an automatic, read-only property, initialized by a property initializer (C# 6);
  • Lines 11–17: The initial value is a collection expression (C# 12): the list of elements enclosed in square brackets creates an array named [TaxBracket[]]. Each element is created by ***[new(...)]***: the type of the created object is not repeated; it is inferred from the expected type, [TaxBracket] (C# 9).

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:

using System.Globalization;
using Entities;
using static Entities.ErrorCode;

namespace Dao;

// a [dao] layer that reads the tax brackets from a text file
// one line per bracket: limit:coeffR:coeffN (decimal point)
public class FileTax : ITaxDao
{
    // data file, relative to the folder of the executable
    public string FileName { get; }

    // tax brackets
    public TaxBracket[] TaxBrackets { get; }

    // constructor
    public FileTax(string fileName)
    {
        // we store the name of the file
        FileName = fileName;
        // the brackets read
        List<TaxBracket> brackets = [];
        // reading the file line by line
        string path = Path.Combine(AppContext.BaseDirectory, fileName);
        int lineNumber = 0;
        try
        {
            if (!File.Exists(path))
            {
                throw new FileNotFoundException($"The file [{fileName}] does not exist", fileName);
            }
            foreach (string lineRead in File.ReadLines(path))
            {
                lineNumber++;
                // empty lines are ignored
                string line = lineRead.Trim();
                if (line == "")
                {
                    continue;
                }
                // line split into three fields separated by :
                string[] fields = line.Split(':', StringSplitOptions.TrimEntries);
                ErrorCode code = 0;
                decimal limit = 0, coeffR = 0, coeffN = 0;
                if (fields.Length != 3)
                {
                    code = Line;
                }
                else
                {
                    // conversions of the 3 fields: each error adds its code
                    if (!TryParse(fields[0], out limit)) code |= Field1;
                    if (!TryParse(fields[1], out coeffR)) code |= Field2;
                    if (!TryParse(fields[2], out coeffN)) code |= Field3;
                }
                // error?
                if (code != 0)
                {
                    throw new TaxException($"Line no. {lineNumber} is incorrect: [{line}]") { Code = (int)code };
                }
                // we store the new tax bracket
                brackets.Add(new TaxBracket(limit, coeffR, coeffN));
            }
        }
        catch (Exception e) when (e is IOException or UnauthorizedAccessException)
        {
            // file access error: we wrap it in a TaxException
            throw new TaxException($"Error while reading the file {fileName}", e)
            {
                Code = (int)Access
            };
        }
        // we return the list as an array
        TaxBrackets = [.. brackets];
    }

    // a number in invariant format: digits and decimal point
    private static bool TryParse(string field, out decimal result) =>
        decimal.TryParse(field, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out result);
}
  • Line 3: The [using static] directive (C# 6) allows you to use static members and nested types of a type without a prefix. The values of an enumeration are constants, and therefore static members: we write [Field1] instead of [ErrorCode.Field1];
  • line 9: the class [FileTax] implements the interface [ITaxDao];
  • line 15: the property of the [ITaxDao] interface. It does not have a [set]: it can only be assigned a value in the constructor (line 75);
  • line 18: the constructor takes the name of the tax bracket file;
  • line 23: the tax brackets read are accumulated in a list, initialized by an empty collection expression;
  • line 25: the file is searched for in the executable’s folder, [AppContext.BaseDirectory], where the file [.csproj] copied it. If [fileName] is an absolute path, [Path.Combine] leaves it as is;
  • lines 29–31: if the file does not exist, a [FileNotFoundException] is triggered with our own message. The exception message that .NET would otherwise throw contains the full path to the file;
  • Line 33: [File.ReadLines] reads the file line by line: the lines are read as the [foreach] loop progresses, and the file is closed at the end of the loop, even if an exception is thrown;
  • line 43: the line is split at the colon; the [StringSplitOptions.TrimEntries] option removes the spaces around each field;
  • Lines 46–48: A line that does not have three fields is invalid;
  • lines 53–55: conversion of the three fields. Each incorrect field adds a bit to the error code using the [|=] operator;
  • lines 58–60: an incorrect line triggers a [TaxException], whose message provides the line number and content. The code, of type [ErrorCode], is converted to an integer by [(int)code];
  • line 63: the bracket read is added to the list;
  • line 66: the exception filter [when] (C# 6) intercepts only I/O errors: [IOException], from which [FileNotFoundException] is derived, and [UnauthorizedAccessException] (insufficient permissions). The [e is IOException or UnauthorizedAccessException] pattern (C# 9) checks for both types. The [TaxException] thrown on line 60 is not caught: it is propagated as-is to the caller;
  • lines 69–71: the I/O error is wrapped in a [TaxException] with code [Access]; the original exception is retained in its [InnerException] property;
  • line 75: the list is copied into an array. The [..] operator (“spread”) of a collection expression inserts all elements of another collection;
  • Lines 79–80: Conversion of a field to [decimal]. The [NumberStyles.AllowDecimalPoint] style accepts only digits and a decimal point, and the invariant culture [CultureInfo.InvariantCulture] requires a period as the decimal separator. Thus, “0,191” is rejected, even though a fr-FR culture would have accepted it. The method is written with a [=>] expression body (C# 6).

6.3.4. The [business] layer

Let’s review the interface for this layer:

1
2
3
4
5
6
7
namespace Business;

// the interface of the [business] layer
public interface ITaxBusiness
{
    int CalculateTax(bool married, int nbChildren, int salary);
}

Its implementation, [TaxBusiness], is as follows:

using Dao;
using Entities;

namespace Business;

// the [business] layer receives the [dao] layer through its constructor (primary constructor)
public class TaxBusiness(ITaxDao dao) : ITaxBusiness
{
    // the tax brackets, requested once and for all from the [dao] layer
    private readonly TaxBracket[] brackets = dao.TaxBrackets;

    // tax calculation
    public int CalculateTax(bool married, int nbChildren, int salary)
    {
        // checking the parameters
        ArgumentOutOfRangeException.ThrowIfNegative(nbChildren);
        ArgumentOutOfRangeException.ThrowIfNegative(salary);
        // number of shares
        decimal shares = nbChildren / 2M + (married ? 2 : 1);
        if (nbChildren >= 3)
        {
            shares += 0.5M;
        }
        // taxable income and family quotient
        decimal income = 0.72M * salary;
        decimal qf = income / shares;
        // 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[^1];
        for (int i = 0; i < brackets.Length - 1; i++)
        {
            if (qf <= brackets[i].Limit)
            {
                bracket = brackets[i];
                break;
            }
        }
        // tax (integer part)
        return (int)(income * bracket.CoeffR - shares * bracket.CoeffN);
    }
}
  • Line 7: The class [TaxBusiness] implements the interface [ITaxBusiness]. The [business] layer must collaborate with the [dao] layer: it therefore receives a reference to the object that implements the [ITaxDao] interface. This reference is the parameter [dao] of the class’s primary constructor (C# 12): parameters listed after the class name are those of its constructor, and they can be used throughout the class body;
  • Line 10: 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 read-only 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 16–17: The [business] layer does not trust its callers. The static methods [ThrowIfNegative] (.NET 8) throw a [ArgumentOutOfRangeException] if their parameter is negative; the exception message contains the parameter’s name;
  • lines 19–22: the number of shares. The division [nbChildren / 2M] is a decimal division because [2M] is a [decimal]: 3 children equal 1.5 shares;
  • lines 25–26: taxable income and the family quotient;
  • lines 29–35: we look for the first bracket whose threshold is greater than or equal to the family quotient. The last bracket, [brackets[^1]] (the index [^1] refers to the last element, C# 8), is used if no other bracket fits: its threshold (0) is not used;
  • line 39: the tax, truncated to its integer part.

The 2008 course code wrote [taxBrackets[taxBrackets.Length - 1].Limit = QF + 1] before searching for the bracket, so that the search loop would always terminate. This instruction modified the array provided by the [dao] layer: a side effect on data shared by all layers. In fact, it could no longer be written this way, since a [TaxBracket] is now read-only. The modern code simply takes the last bracket when no limit 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:

using Business;

namespace Ui;

// the console dialog with the user: it only knows the interface of the [business] layer
public static class Dialog
{
    // help displayed in case of incorrect input
    private const string Syntax = """
        syntax: Married NbChildren Salary
        Married: y for married, n for not married
        NbChildren: number of children
        Salary: annual salary in euros
        """;

    // dialog loop: input of the parameters, calculation, display
    public static void Run(ITaxBusiness business)
    {
        while (true)
        {
            // we ask for the tax calculation parameters
            Console.Write("Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: ");
            string parameters = Console.ReadLine()?.Trim() ?? "";
            // anything to do?
            if (parameters == "")
            {
                break;
            }
            // checking the number of parameters
            string[] fields = parameters.Split(' ', StringSplitOptions.RemoveEmptyEntries);
            if (fields.Length != 3)
            {
                Console.WriteLine(Syntax);
                continue;
            }
            // married
            string married = fields[0].ToLower();
            if (married != "y" && married != "n")
            {
                Console.WriteLine($"{Syntax}\nIncorrect Married argument: type y or n");
                continue;
            }
            // number of children
            if (!int.TryParse(fields[1], out int nbChildren) || nbChildren < 0)
            {
                Console.WriteLine($"{Syntax}\nIncorrect NbChildren argument: type a positive or zero integer");
                continue;
            }
            // salary
            if (!int.TryParse(fields[2], out int salary) || salary < 0)
            {
                Console.WriteLine($"{Syntax}\nIncorrect Salary argument: type a positive or zero integer");
                continue;
            }
            // the parameters are correct: we ask the [business] layer for the calculation
            Console.WriteLine($"Tax={business.CalculateTax(married == "y", nbChildren, salary)} euros");
        }
    }
}
  • line 6: the [Dialog] class is static: it has no state of its own and contains only static members;
  • lines 9–14: The help message is a raw string literal (C# 11), delimited by three quotation marks. It can span multiple lines; the common indentation of the lines, determined by the position of the closing quotation marks, is removed;
  • line 17: the [Run] method receives the [business] layer in the form of its [ITaxBusiness] interface. It knows nothing about the class that implements it or the [dao] layer;
  • Line 23: [Console.ReadLine] produces [null] at the end of the input stream; the operator [?.] then produces [null] without calling [Trim], and the operator [??] replaces [null] with the empty string: an end-of-stream terminates the dialog just like an empty line;
  • line 30: the entered line is split by spaces; the [StringSplitOptions.RemoveEmptyEntries] option ignores multiple spaces;
  • lines 44–50: [int.TryParse(fields[1], out int nbChildren)] declares the output variable within the call itself (C# 7);
  • line 56: 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 [Program.cs]:

using Dao;
using Entities;
using Business;
using Ui;

// integration of the layers: this is the only place where the implementation classes are named
// argument: "hardwired" for HardwiredTax, otherwise the file read by FileTax (TaxData.txt by default)
string source = args.Length > 0 ? args[0] : "TaxData.txt";
ITaxBusiness business;
try
{
    // creation of the [dao] layer
    ITaxDao dao = source == "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 of the program
    string origin = e.InnerException is null ? "" : $", original exception: {e.InnerException.Message}";
    Console.WriteLine($"The following error occurred: [Code={(ErrorCode)e.Code}, Message={e.Message}{origin}]");
    return 1;
}

// the [ui] layer: the dialog with the user
Dialog.Run(business);
return 0;
  • line 8: the program argument selects the implementation of layer [dao]: [hardwired] for class [HardwiredTax]; otherwise, the name of the file read by class [FileTax], with [TaxData.txt] as the default. Note that with high-level instructions, the command-line arguments are stored in the array [args];
  • line 13: creation of the layer [dao]. This is the only place in the program where the classes [HardwiredTax] and [FileTax] are named;
  • line 15: creation of layer [business], which receives layer [dao];
  • Lines 17–22: The creation of layer [dao] may trigger a [TaxException]. The error is then displayed, along with the message from the original exception (if any), and the program terminates with exit code 1. As in version 3, the code is converted to [ErrorCode] to be displayed in plain text;
  • Line 26: The layers are ready: the [ui] layer interacts with the user.

6.3.7. Executions

Using the file [TaxData.txt] (implementation [FileTax]), and testing input validation:

> dotnet run
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
> dotnet run -- 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:

> dotnet run -- TaxDataMissing.txt
The following error occurred: [Code=Access, Message=Error while reading the file TaxDataMissing.txt, original exception: The file [TaxDataMissing.txt] does not exist]

The error code is [Access], and the message from the original exception, [FileNotFoundException], is displayed.

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:

> dotnet run -- 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 [ToString] method of an enumeration [Flags] 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]:

ITaxDao dao = source == "hardwired" ? new HardwiredTax() : new FileTax(source);
business = new TaxBusiness(dao);

Changing the implementation of layer [dao] changes only line 1. Once the layers have been created, the code for the [ui] layer is the same in both cases: the [business] layer is accessed 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 is based on the previous one with the following changes:

  • The layers [dao] and [business] are each placed in a class library (DLL) and tested using the unit testing framework MSTest;
  • The [dao] layer receives a third implementation, [JsonTax], which reads the tax brackets from a JSON file;
  • the integration of the layers is handled by the dependency injection container in .NET, 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 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, the developer 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 project or a DLL) rather than source code to be copied. Ultimately, the application is delivered as an executable (that of the [ui] layer) and class libraries (the other layers).

6.4.1. The TaxV5 Solution

Version 5 is a solution consisting of four projects: one per layer, plus a unit test project. It is built using the following commands, entered in the [TaxV5] folder:

1
2
3
4
5
6
7
8
9
> dotnet new classlib -n Tax.Dao -o Tax.Dao
> dotnet new classlib -n Tax.Business -o Tax.Business
> dotnet new console -n Tax.Ui -o Tax.Ui
> dotnet new mstest -n Tax.Tests -o Tax.Tests
> dotnet new sln --format slnx -n TaxV5
> dotnet sln TaxV5.slnx add Tax.Dao Tax.Business Tax.Ui Tax.Tests
> dotnet add Tax.Business reference Tax.Dao
> dotnet add Tax.Ui reference Tax.Business Tax.Dao
> dotnet add Tax.Tests reference Tax.Business Tax.Dao
  • [dotnet new classlib] creates a class library project: compiling it produces a DLL file, not an executable. The template creates a [Class1.cs] file, which we delete;
  • [dotnet new mstest] creates a test project, MSTest, which is described later;
  • [dotnet new sln --format slnx] creates the solution file [TaxV5.slnx], in the XML format introduced by the SDK in .NET 9. This is the default format of [dotnet new sln] since .NET 10; the [--format slnx] option therefore merely makes it explicit; [dotnet sln add] adds the projects to it;
  • [dotnet add reference] adds a project-to-project reference: the [business] layer uses the [dao] layer, the [ui] layer uses the other two, as do the tests.

This results in the following tree structure:

TaxV5/
  TaxV5.slnx
  Tax.Dao/                       library: entities and [dao] layer
    Tax.Dao.csproj
    TaxData.txt
    TaxData.json
    Entities/
      TaxBracket.cs
      TaxException.cs
    Dao/
      ITaxDao.cs
      HardwiredTax.cs
      FileTax.cs
      JsonTax.cs
  Tax.Business/                  library: [business] layer
    Tax.Business.csproj
    ITaxBusiness.cs
    TaxBusiness.cs
  Tax.Ui/                        executable: [ui] layer and integration of the layers
    Tax.Ui.csproj
    Dialog.cs
    Program.cs
    appsettings.json
    appsettings-json.json
    appsettings-hardwired.json
    appsettings-error.json
  Tax.Tests/                     MSTest unit tests
    Tax.Tests.csproj
    MSTestSettings.cs
    DaoTests.cs
    BusinessTests.cs
    TaxDataInvalid.txt
  Trials/                        two trial programs
    trial-dao.cs
    trial-business.cs

The solution file is as follows:

1
2
3
4
5
6
<Solution>
  <Project Path="Tax.Dao/Tax.Dao.csproj" />
  <Project Path="Tax.Business/Tax.Business.csproj" />
  <Project Path="Tax.Tests/Tax.Tests.csproj" />
  <Project Path="Tax.Ui/Tax.Ui.csproj" />
</Solution>

The command [dotnet build TaxV5.slnx] compiles the four projects in the order specified by their references: [Tax.Dao] first, then [Tax.Business], then [Tax.Ui], and finally [Tax.Tests].

6.4.2. The [dao] layer

The [Tax.Dao] project contains the [dao] layer and the application entities. Its project file is as follows:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <!-- the data files are copied next to the executable
         (and next to that of any project referencing this one) -->
    <None Update="TaxData.txt" CopyToOutputDirectory="PreserveNewest" />
    <None Update="TaxData.json" CopyToOutputDirectory="PreserveNewest" />
  </ItemGroup>

</Project>
  • lines 4–6: the properties generated by [dotnet new classlib]. There is no [OutputType] tag: the project produces a [Tax.Dao.dll] library;
  • Lines 12–13: The data files are copied to the build folder. Files marked in this way are also copied to the build folder of any project that references [Tax.Dao]: the projects [Tax.Ui] and [Tax.Tests] will have the data files without having to declare them.

Entities

The structure [TaxBracket] and the class [TaxException] are those of version 4. Only the enumeration [ErrorCode] is removed from the file: [TaxException] thus becomes common to all implementations of the [dao] layer, without being tied to the codes of any one of them:

namespace Entities;

// the exception of the [Tax] application, whatever the layer that throws it
public class TaxException : Exception
{
    // error code
    public int Code { get; init; }

    // constructors
    public TaxException(string message) : base(message) { }
    public TaxException(string message, Exception inner) : base(message, inner) { }
}
  • Line 7: The error code remains a simple integer: each implementation of the [dao] layer defines its own codes.

The [FileTax] implementation

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

using System.Globalization;
using Entities;

namespace Dao;

// a [dao] layer that reads the tax brackets from a text file
// one line per bracket: limit:coeffR:coeffN (decimal point)
public class FileTax : ITaxDao
{
    // error codes specific to this implementation: they can be combined
    [Flags]
    public enum ErrorCodes { Access = 1, Line = 2, Field1 = 4, Field2 = 8, Field3 = 16 }

    // data file, relative to the folder of the executable
    public string FileName { get; }
  • lines 11–12: The error codes, previously in the [ErrorCode] enumeration within the [Entities] namespace, have been moved to the [FileTax] class, in the form of a nested enumeration [ErrorCodes]: these are, in fact, codes specific to this implementation of the [ITaxDao] interface ([JsonTax] has its own). They are written as [ErrorCodes.Field1] within the class and [FileTax.ErrorCodes.Field1] outside of it. The [using static] directive from version 4 is no longer needed.

Further down in the constructor:

                // error?
                if (code != 0)
                {
                    throw new TaxException($"Line no. {lineNumber} is incorrect: [{line}]") { Code = (int)code };
                }
                // we store the new tax bracket
                brackets.Add(new TaxBracket(limit, coeffR, coeffN));
            }
        }
        catch (Exception e) when (e is IOException or UnauthorizedAccessException)
        {
            // file access error: we wrap it in a TaxException
            throw new TaxException($"Error while reading the file {fileName}", e)
            {
                Code = (int)ErrorCodes.Access
            };
        }
        // we return the list as an array
        TaxBrackets = [.. brackets];
    }
  • lines 63, 72: errors are encapsulated in a [TaxException]. The error code, of type [ErrorCodes], is converted to an integer by [(int)code].

The [JsonTax] Implementation

The previous chapter showed how to read data in the JSON format using [System.Text.Json]. The third implementation of the [dao] layer reads the tax brackets from the following [TaxData.json] file:

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:

using System.Text.Json;
using System.Text.Json.Serialization;
using Entities;

namespace Dao;

// a [dao] layer that reads the tax brackets from a JSON file
public class JsonTax : ITaxDao
{
    // error codes specific to this implementation
    public enum ErrorCodes { Access = 1, Format = 2 }

    // data file, relative to the folder of the executable
    public string FileName { get; }

    // tax brackets
    public TaxBracket[] TaxBrackets { get; }

    // constructor
    public JsonTax(string fileName)
    {
        FileName = fileName;
        string path = Path.Combine(AppContext.BaseDirectory, fileName);
        string json;
        try
        {
            if (!File.Exists(path))
            {
                throw new FileNotFoundException($"The file [{fileName}] does not exist", fileName);
            }
            json = File.ReadAllText(path);
        }
        catch (Exception e) when (e is IOException or UnauthorizedAccessException)
        {
            throw new TaxException($"Error while reading the file {fileName}", e)
            {
                Code = (int)ErrorCodes.Access
            };
        }
        try
        {
            // deserialization with the code generated at compile time (see BracketsJsonContext)
            TaxBrackets = JsonSerializer.Deserialize(json, BracketsJsonContext.Default.TaxBracketArray)
                ?? throw new JsonException("the file does not contain an array");
        }
        catch (JsonException e)
        {
            throw new TaxException($"The file {fileName} is not a valid JSON file of tax brackets", e)
            {
                Code = (int)ErrorCodes.Format
            };
        }
    }
}

// the JSON (de)serialization code of the TaxBracket[] type is generated at compile time
// "Web" options: property names in camelCase, case ignored when reading
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
[JsonSerializable(typeof(TaxBracket[]))]
internal partial class BracketsJsonContext : JsonSerializerContext;
  • Line 11: Error codes for this implementation: file access error or incorrect content;
  • Lines 27–31: The file is read in its entirety into a string, with the same I/O error handling as in [FileTax];
  • line 43: the JSON string is converted into an array [TaxBracket[]]. The second parameter, [BracketsJsonContext.Default.TaxBracketArray], provides the deserialization code for this type;
  • Line 44: [Deserialize] returns [null] if the file contains the literal JSON [null]; the expression [?? throw] (C# 7) then throws an exception;
  • lines 46–50: a file that is not a JSON array of brackets causes a [JsonException], wrapped in a [TaxException] of code [Format];
  • lines 58–60: without a parameter of this type, [JsonSerializer] discovers the properties of [TaxBracket] at runtime, through reflection. Here, the [JsonSerializable] attribute instructs the compiler to generate, within the partial class [BracketsJsonContext], the (de)serialization code for the type [TaxBracket[]]. This code is faster and also works in applications compiled to native code (AOT), where reflection is disabled—which is the default behavior for the single-file applications of .NET 10 that we will be using. The [JsonSerializerDefaults.Web] option maps the camelCase JSON names ([limit]) to C# properties ([Limit]). Note that [System.Text.Json] can construct a positional [record struct] by calling its constructor.

A Test Program

Before writing automated tests, let’s perform an initial visual test of the [dao] layer. .NET 10 allows a single-file application to reference a project using the [#:project] directive. The [Trials/trial-dao.cs] program displays the brackets provided by the three implementations:

#:project ../Tax.Dao/Tax.Dao.csproj
using Dao;
using Entities;

// the tax brackets provided by the three implementations of the [dao] layer
try
{
    ITaxDao[] daos = [new HardwiredTax(), new FileTax("TaxData.txt"), new JsonTax("TaxData.json")];
    foreach (ITaxDao dao in daos)
    {
        Console.WriteLine($"--- {dao.GetType().Name}");
        foreach (TaxBracket t in dao.TaxBrackets)
        {
            Console.WriteLine($"{t.Limit}:{t.CoeffR}:{t.CoeffN}");
        }
    }
}
catch (TaxException e)
{
    // display of the error
    string origin = e.InnerException is null ? "" : $", original exception: {e.InnerException.Message}";
    Console.WriteLine($"The following error occurred: [Code={e.Code}, Message={e.Message}{origin}]");
}
  • Line 1: The program uses the [Tax.Dao] project. This project is compiled along with the program, and its data files are copied next to the test program’s executable. Note: If you subsequently modify the referenced project without modifying the program, [dotnet run trial-dao.cs] may reuse the executable that has already been built; the option [--no-cache] then forces a new compilation;
  • line 8: an array of the three implementations of the [ITaxDao] interface;
  • line 11: [GetType] returns the actual type of the object, whose class name is [Name];
  • lines 18–22: handling a potential [TaxException].

Results:

> dotnet run trial-dao.cs
--- HardwiredTax
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
--- FileTax
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
--- JsonTax
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

All three implementations return the same brackets. The display of the numbers [decimal] follows the user’s en-US locale: decimal point.

6.4.3. The [business] layer

The project file [Tax.Business.csproj] is as follows:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <!-- the [business] layer uses the [dao] layer -->
    <ProjectReference Include="../Tax.Dao/Tax.Dao.csproj" />
  </ItemGroup>

</Project>
  • Line 11: the reference to the [Tax.Dao] project added by [dotnet add reference]. The 2008 course had the compiled DLL reference the layer [dao], which had to be retrieved from a folder named [bin]; a project-to-project reference compiles [Tax.Dao] before [Tax.Business] and always uses its latest version.

The [ITaxBusiness] interface and the [TaxBusiness] class are from version 4. The test program [Trials/trial-business.cs] performs some tax calculations:

#:project ../Tax.Business/Tax.Business.csproj
using Dao;
using Entities;
using Business;

// a few tax calculations with the FileTax [dao] layer
try
{
    ITaxBusiness business = new TaxBusiness(new FileTax("TaxData.txt"));
    Console.WriteLine($"Tax(true,2,60000)={business.CalculateTax(true, 2, 60000)} euros");
    Console.WriteLine($"Tax(false,3,60000)={business.CalculateTax(false, 3, 60000)} euros");
    Console.WriteLine($"Tax(false,3,6000)={business.CalculateTax(false, 3, 6000)} euros");
    Console.WriteLine($"Tax(false,3,600000)={business.CalculateTax(false, 3, 600000)} euros");
}
catch (TaxException e)
{
    // display of the error
    string origin = e.InnerException is null ? "" : $", original exception: {e.InnerException.Message}";
    Console.WriteLine($"The following error occurred: [Code={e.Code}, Message={e.Message}{origin}]");
}
  • Line 1: The program references the project [Tax.Business]; since this project references [Tax.Dao], the program also has access to the layer [dao] and its features;
  • Line 9: Creation of the [dao] and [business] layers. The [dao] layer is implemented by the [FileTax] class;
  • Lines 10–13: Repeated calls to the single method of the [ITaxBusiness] interface.

Results:

1
2
3
4
5
> dotnet run trial-business.cs
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 MSTest

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. The 2008 course used NUnit 2.4 and its graphical user interface. Here we use MSTest, Microsoft’s testing framework, in version 4 (NUnit and xUnit are two other widely used frameworks that operate on the same principle). Tests are run using the [dotnet test] command from the command line: this allows them to be launched automatically, for example, whenever code changes are deployed to a continuous integration server. The integrators (Visual Studio, VS Code, Rider) also feature a test explorer that displays tests in a tree structure.

6.4.4.2. A First Example

Let’s examine the principle of MSTest tests using an example. We create a test project using the command:

> dotnet new mstest -n DemoTests -o DemoTests

The template creates a project file, which we modify to use version 4.0.2 of MSTest:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="MSTest" Version="4.0.2" />
  </ItemGroup>

  <ItemGroup>
    <Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
  </ItemGroup>

</Project>
  • Line 11: The project uses the NuGet [MSTest] package. This package includes the framework (the attributes and the [Assert] class), the adapter that allows [dotnet test] to find and run the tests, and code analyzers that issue warnings for poorly written tests;
  • Line 15: a global [using]: the [Microsoft.VisualStudio.TestTools.UnitTesting] namespace from MSTest is imported into all files in the project.

The template also creates a [MSTestSettings.cs] file, which specifies that the tests should run in parallel. We replace it with the following so that the tests run one after the other and their results appear in a consistent order:

// the tests are run one after the other (display in a stable order)
[assembly: DoNotParallelize]
  • Line 2: an attribute that applies to the entire assembly (DLL) and not to a specific class.

The test class [Tests1] is as follows:

namespace DemoTests;

// a test class
[TestClass]
public class Tests1
{
    // the tested method
    private static int Sum(int a, int b) => a + b;

    // constructor
    public Tests1()
    {
        Console.WriteLine("constructor");
    }

    // executed before each test
    [TestInitialize]
    public void Before()
    {
        Console.WriteLine("before");
    }

    // executed after each test
    [TestCleanup]
    public void After()
    {
        Console.WriteLine("after");
    }

    // a test that succeeds
    [TestMethod]
    public void T1()
    {
        Console.WriteLine("test1");
        Assert.AreEqual(4, Sum(2, 2));
    }

    // a test that fails
    [TestMethod]
    public void T2()
    {
        Console.WriteLine("test2");
        Assert.AreEqual(5, Sum(2, 2), "2 + 2 is not equal to 5");
    }
}
  • Line 4: The attribute [TestClass] indicates that the class contains tests. It must be public;
  • line 8: the method being tested. In a real project, it would be in another project, referenced by the test project;
  • Lines 11–13: the constructor. It is used only to display a message on the screen: we want to see when it is executed;
  • line 17: the attribute [TestInitialize] designates a method executed before each test;
  • line 24: the attribute [TestCleanup] designates a method executed after each test;
  • line 31: the attribute [TestMethod] designates a test method. A test method is public, returns nothing (or a [Task] if it is asynchronous), and, with one exception we’ll discuss, has no parameters;
  • line 35: The method [Assert.AreEqual(expected, actual)] verifies that the obtained value equals the expected value. If this is not the case, it throws an exception and the test fails. Here, the assertion must pass;
  • line 43: this assertion must fail. The third parameter is the message displayed in the event of a failure.

The tests are run using the [dotnet test] command in the project folder. The [--logger "console;verbosity=detailed"] option displays the details of each test, including its on-screen output:

> dotnet test --logger "console;verbosity=detailed"
Test run for bin/Debug/net10.0/DemoTests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.2 (x64)

Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
bin/Debug/net10.0/DemoTests.dll
  Passed T1 [14 ms]
  Standard Output Messages:
 constructor
 before
 test1
 after


  Failed T2 [13 ms]
  Error Message:
   Assert.AreEqual failed. Expected:<5>. Actual:<4>. 'expected' expression: '5', 'actual' expression: 'Sum(2, 2)'. 2 + 2 is not equal to 5
  Stack Trace:
     (stack trace removed)
  Standard Output Messages:
 constructor
 before
 test2
 after


  Passed T3 [1 ms]
  Passed T4 [1 ms]
  Passed T5 [1 ms]
  Passed T6 [1 ms]
  Passed T7 (1,1,2) [< 1 ms]
  Passed T7 (2,3,5) [< 1 ms]
  Passed T7 (-1,1,0) [< 1 ms]

Test Run Failed.
Total tests: 9
     Passed: 8
     Failed: 1
 Total time: 0.8964 Seconds
  • line 8: the [T1] test passed;
  • lines 10–13: its screen outputs: the constructor was executed, followed by the [Before] method, the test, and finally the [After] method;
  • line 16: the [T2] test failed;
  • line 18: the error message: the expected value, the actual value, the expressions that produced them, and the message passed to [Assert.AreEqual];
  • line 19: MSTest also displays the call stack at the time of the failure, which leads to the faulty line. We have removed it from the output;
  • Lines 22–25: The output from the [T2] test. Note that the constructor was executed again: MSTest 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. NUnit, on the other hand, executed the constructor only once;
  • lines 28–34: the tests for the [Tests2] class, shown below;
  • lines 36–39: the results: 8 tests passed and 1 failed. The [dotnet test] command then terminates with a non-zero exit code, which allows a script to detect the failure.

To run only a subset of the tests, use the [--filter] option: [dotnet test --filter Name=T1] runs only the [T1] test, while [dotnet test --filter ClassName=DemoTests.Tests2] runs the tests in the [Tests2] class.

6.4.4.3. Other assertions

The [Assert] class has many methods. The [Tests2] class includes a few of them:

namespace DemoTests;

// a few other MSTest assertions
[TestClass]
public class Tests2
{
    // booleans, null, references
    [TestMethod]
    public void T3()
    {
        bool isTrue = 1 < 2, isFalse = 1 > 2;
        Assert.IsTrue(isTrue);
        Assert.IsFalse(isFalse);
        object obj1 = new(), obj3 = obj1;
        object? obj2 = null;
        Assert.IsNotNull(obj1);
        Assert.IsNull(obj2);
        Assert.AreSame(obj1, obj3);
        Assert.AreNotSame(obj1, new object());
    }

    // reals: equality within a delta
    [TestMethod]
    public void T4()
    {
        double d1 = 0.1 + 0.2, d2 = 0.3;
        Assert.AreNotEqual(d2, d1);
        Assert.AreEqual(d2, d1, 1e-6);
    }

    // strings and collections
    [TestMethod]
    public void T5()
    {
        string message = "Line no. 3 is incorrect";
        Assert.StartsWith("Line", message);
        Assert.Contains("incorrect", message);
        int[] numbers = [1, 2, 3];
        Assert.HasCount(3, numbers);
        Assert.Contains(2, numbers);
    }

    // exceptions: the test succeeds if the expected exception is thrown
    [TestMethod]
    public void T6()
    {
        int[] numbers = [1, 2, 3];
        Assert.ThrowsExactly<IndexOutOfRangeException>(() => numbers[3]);
        FormatException e = Assert.ThrowsExactly<FormatException>(() => int.Parse("x"));
        Assert.IsNotNull(e.Message);
    }

    // a parameterized test: one execution per [DataRow] attribute
    [TestMethod]
    [DataRow(1, 1, 2)]
    [DataRow(2, 3, 5)]
    [DataRow(-1, 1, 0)]
    public void T7(int a, int b, int sum)
    {
        Assert.AreEqual(sum, a + b);
    }
}
  • lines 12–13: check whether a Boolean expression is true or false;
  • lines 16–17: check whether a reference is non-null or null;
  • lines 18–19: check whether two references point to the same object;
  • lines 27–28: calculations on 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 36–40: assertions on strings and collections, added by recent versions of MSTest. They produce more precise error messages than [Assert.IsTrue(message.StartsWith("Line"))];
  • lines 48–49: [Assert.ThrowsExactly<T>(action)] executes the action—an anonymous function [() => ...]—and verifies that it throws an exception of exactly type [T]. It returns the thrown exception, which allows its properties to be verified afterward. The variant [Assert.Throws<T>] also accepts exceptions of types derived from [T];
  • lines 55–58: a parameterized test. The test method has parameters, and each [DataRow] attribute provides a set of values for these parameters. The test is executed once per [DataRow] attribute: this is what lines [T7 (1,1,2)], [T7 (2,3,5)], and [T7 (-1,1,0)] of the previous results showed.

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

Method
Checks that
Assert.AreEqual(expected, actual)
the two values are equal (Equals method)
Assert.AreEqual(actual1, actual2, delta)
the two actual values are equal to within delta
Assert.AreNotEqual(v1, v2)
The two values are different
Assert.AreSame(o1, o2), Assert.AreNotSame(o1, o2)
The two references may or may not refer to the same object
Assert.IsNull(o), Assert.IsNotNull(o)
the reference is null (or not)
Assert.IsTrue(b), Assert.IsFalse(b)
the Boolean expression is true (or false)
Assert.IsInstanceOfType<T>(o)
the object is of type T or a derived type
Assert.HasCount(n, collection)
the collection has n elements
Assert.Contains(element, collection)
the collection (or string) contains the element
Assert.StartsWith(start, string)
the string begins with start
Assert.ThrowsExactly<T>(action)
The action throws an exception of type T exactly
CollectionAssert.AreEqual(c1, c2)
The two collections have the same elements, in the same order

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

6.4.4.4. Tests for the [dao] layer

Let’s return to the [Tax] application. The [Tax.Tests] project was created by [dotnet new mstest]. Its project file is as follows:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <!-- the MSTest test framework -->
    <PackageReference Include="MSTest" Version="4.0.2" />
  </ItemGroup>

  <ItemGroup>
    <!-- the tested layers -->
    <ProjectReference Include="../Tax.Dao/Tax.Dao.csproj" />
    <ProjectReference Include="../Tax.Business/Tax.Business.csproj" />
  </ItemGroup>

  <ItemGroup>
    <Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
  </ItemGroup>

  <ItemGroup>
    <!-- an incorrect data file, to test the errors -->
    <None Update="TaxDataInvalid.txt" CopyToOutputDirectory="PreserveNewest" />
  </ItemGroup>

</Project>
  • Lines 17–18: the layers being tested;
  • line 27: an incorrect data file, used to test for errors. The files [TaxData.txt] and [TaxData.json] are copied using the reference to the [Tax.Dao] project.

The file [MSTestSettings.cs] belongs to project [DemoTests]. The class [DaoTests] tests the layer [dao]:

using Dao;
using Entities;

namespace Tests;

// tests of the [dao] layer
[TestClass]
public class DaoTests
{
    // factory of a [dao] layer from the name of its class
    private static ITaxDao CreateDao(string implementation) => implementation switch
    {
        "HardwiredTax" => new HardwiredTax(),
        "FileTax" => new FileTax("TaxData.txt"),
        "JsonTax" => new JsonTax("TaxData.json"),
        _ => throw new ArgumentException($"unknown implementation: {implementation}")
    };

    // the same test for the three implementations
    [TestMethod]
    [DataRow("HardwiredTax")]
    [DataRow("FileTax")]
    [DataRow("JsonTax")]
    public void TaxBrackets(string implementation)
    {
        TaxBracket[] brackets = CreateDao(implementation).TaxBrackets;
        // a few checks
        Assert.HasCount(7, brackets);
        Assert.AreEqual(14753M, brackets[2].Limit);
        Assert.AreEqual(0.191M, brackets[2].CoeffR);
        Assert.AreEqual(1322.92M, brackets[2].CoeffN);
    }

    // the three implementations give the same brackets
    [TestMethod]
    public void SameBrackets()
    {
        TaxBracket[] reference = new HardwiredTax().TaxBrackets;
        CollectionAssert.AreEqual(reference, new FileTax("TaxData.txt").TaxBrackets);
        CollectionAssert.AreEqual(reference, new JsonTax("TaxData.json").TaxBrackets);
    }

    // missing file
    [TestMethod]
    public void MissingFile()
    {
        TaxException e = Assert.ThrowsExactly<TaxException>(() => new FileTax("TaxDataMissing.txt"));
        Assert.AreEqual((int)FileTax.ErrorCodes.Access, e.Code);
        Assert.IsInstanceOfType<FileNotFoundException>(e.InnerException);
    }

    // file whose line 3 is incorrect (decimal commas)
    [TestMethod]
    public void IncorrectLine()
    {
        TaxException e = Assert.ThrowsExactly<TaxException>(() => new FileTax("TaxDataInvalid.txt"));
        Assert.AreEqual((int)(FileTax.ErrorCodes.Field2 | FileTax.ErrorCodes.Field3), e.Code);
        Assert.StartsWith("Line no. 3", e.Message);
    }

    // missing JSON file
    [TestMethod]
    public void MissingJsonFile()
    {
        TaxException e = Assert.ThrowsExactly<TaxException>(() => new JsonTax("TaxDataMissing.json"));
        Assert.AreEqual((int)JsonTax.ErrorCodes.Access, e.Code);
    }
}
  • lines 11–16: a method that creates a [dao] layer based on its class name, using a [switch] expression (C# 8);
  • lines 21–24: the same test is run for all three implementations. It verifies that there are 7 brackets and checks the value of bracket #2 (the third one). The expected values are written with the suffix [M]: these are [decimal], just like the properties being compared;
  • lines 36–40: All three implementations return exactly the same brackets. [CollectionAssert.AreEqual] compares the arrays element by element; the comparison of two [TaxBracket] values is a value comparison, generated by the compiler for a [record struct];
  • lines 45–49: a missing file triggers a [TaxException] with code [Access], which encapsulates a [FileNotFoundException];
  • lines 54–58: the file [TaxDataInvalid.txt] results in a [TaxException] whose code combines the bits of [Field2] and [Field3];
  • lines 63–66: the same verification for the [JsonTax] implementation.

6.4.4.5. Tests for the [business] layer

The [BusinessTests] class tests the [business] layer:

using Dao;
using Entities;
using Business;

namespace Tests;

// tests of the [business] layer
[TestClass]
public class BusinessTests
{
    // the [dao] layer and the tested [business] layer: MSTest creates one instance of the class per test
    private readonly HardwiredTax dao = new();
    private readonly ITaxBusiness business;

    public BusinessTests()
    {
        business = new TaxBusiness(dao);
    }

    // calculations whose result is known
    [TestMethod]
    [DataRow(true, 2, 60000, 4282)]
    [DataRow(false, 3, 60000, 4282)]
    [DataRow(true, 3, 60000, 2959)]
    [DataRow(false, 0, 60000, 11519)]
    [DataRow(false, 3, 6000, 0)]
    [DataRow(false, 3, 600000, 179275)]
    public void CalculateTax(bool married, int nbChildren, int salary, int tax)
    {
        Assert.AreEqual(tax, business.CalculateTax(married, nbChildren, salary));
    }

    // the calculation does not modify the brackets provided by the [dao] layer
    [TestMethod]
    public void UnchangedBrackets()
    {
        TaxBracket[] before = [.. dao.TaxBrackets];
        business.CalculateTax(true, 2, 600000);
        CollectionAssert.AreEqual(before, dao.TaxBrackets);
    }

    // negative parameters are rejected
    [TestMethod]
    public void NegativeParameters()
    {
        Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => business.CalculateTax(true, -1, 60000));
        Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => business.CalculateTax(true, 2, -60000));
    }

    // a fake [dao] layer: a single bracket, a 10% tax
    private class FakeDao : ITaxDao
    {
        public TaxBracket[] TaxBrackets { get; } = [new(0M, 0.1M, 0M)];
    }

    // the [business] layer tested with the fake [dao] layer
    [TestMethod]
    public void WithFakeDao()
    {
        ITaxBusiness fakeBusiness = new TaxBusiness(new FakeDao());
        // taxable income = 0.72 x 100000 = 72000; tax = 10% of 72000
        Assert.AreEqual(7200, fakeBusiness.CalculateTax(false, 0, 100000));
    }
}
  • Lines 12–17: The [business] layer being tested is built on the [dao] layer [HardwiredTax], which does not depend on any file. Since MSTest creates a new instance of the class for each test, each test has brand-new layers;
  • lines 22–28: calculations whose results are known. These are the application’s control values, already obtained by previous versions: they must remain accurate in all future versions;
  • lines 35–39: the tax calculation does not modify the tax brackets provided by the [dao] layer. A copy ([.. dao.TaxBrackets]) is made before a calculation that uses the latest bracket, and then the results are compared. The test keeps track of the defect in the 2008 version: if someone were to reintroduce it, this test would flag it;
  • lines 44–47: negative parameters are rejected;
  • lines 51–53: a dummy layer [dao], like the one the developer of layer [business] would use before the real layer [dao] is ready: a single tax bracket, a tax rate of 10% of taxable income. This is a class nested within the test class;
  • lines 58–62: 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. Test Execution

In the [Tax.Tests] folder:

> dotnet test --logger "console;verbosity=normal"
Test run for bin/Debug/net10.0/Tax.Tests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.2 (x64)

Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
  Passed CalculateTax (True,2,60000,4282) [1 ms]
  Passed CalculateTax (False,3,60000,4282) [< 1 ms]
  Passed CalculateTax (True,3,60000,2959) [< 1 ms]
  Passed CalculateTax (False,0,60000,11519) [< 1 ms]
  Passed CalculateTax (False,3,6000,0) [< 1 ms]
  Passed CalculateTax (False,3,600000,179275) [< 1 ms]
  Passed UnchangedBrackets [< 1 ms]
  Passed NegativeParameters [< 1 ms]
  Passed WithFakeDao [< 1 ms]
  Passed TaxBrackets ("HardwiredTax") [9 ms]
  Passed TaxBrackets ("FileTax") [2 ms]
  Passed TaxBrackets ("JsonTax") [36 ms]
  Passed SameBrackets [1 ms]
  Passed MissingFile [2 ms]
  Passed IncorrectLine [< 1 ms]
  Passed MissingJsonFile [< 1 ms]

Test Run Successful.
Total tests: 16
     Passed: 16
 Total time: 0.8513 Seconds

All 16 tests passed: each attribute [DataRow] counts as one test. We now consider the layers [dao] and [business] to be operational. Unlike in 2008, there is no need to rebuild the DLL layers to exclude the test classes: the tests are in a separate project, and the libraries [Tax.Dao.dll] and [Tax.Business.dll] contain only the layer code. The command [dotnet test TaxV5.slnx], located in the solution folder, runs the tests for all test projects in the solution.

6.4.5. The [ui] layer

The project file [Tax.Ui.csproj] is as follows:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <!-- the layers used -->
    <ProjectReference Include="../Tax.Dao/Tax.Dao.csproj" />
    <ProjectReference Include="../Tax.Business/Tax.Business.csproj" />
  </ItemGroup>

  <ItemGroup>
    <!-- dependency injection container and reading of a JSON configuration file -->
    <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.12" />
  </ItemGroup>

  <ItemGroup>
    <!-- the configuration files are copied next to the executable -->
    <None Update="appsettings*.json" CopyToOutputDirectory="PreserveNewest" />
  </ItemGroup>

</Project>
  • Line 4: The project generates an executable;
  • Lines 12–13: The [ui] layer uses the [business] and [dao] layers;
  • Lines 18–19: two NuGet packages—the dependency injection container and the JSON configuration file reader. They were added by the commands [dotnet add package Microsoft.Extensions.DependencyInjection --version 10.0.12] and [dotnet add package Microsoft.Extensions.Configuration.Json --version 10.0.12];
  • Line 24: The configuration files are copied next to the executable.

The [Dialog] class is the one from version 4, supplemented with a method that displays an exception and all the exceptions it encapsulates:

    // display of an exception and of all the ones it wraps
    public static void DisplayError(Exception exception)
    {
        string separator = new('-', 40);
        Console.WriteLine($"Exception chain:\n{separator}");
        for (Exception? e = exception; e != null; e = e.InnerException)
        {
            Console.WriteLine($"{e.GetType().FullName}: {e.Message}");
            Console.WriteLine(separator);
        }
    }
}
  • line 63: a string of 40 dashes, created by the [string(char c, int count)] constructor;
  • line 65: the loop iterates through the chain of exceptions: each exception provides access, via its property [InnerException], to the exception it encapsulates, up to the last one where [InnerException] equals [null]. The type [Exception?] indicates that the variable [e] can be equal to [null];
  • Line 67: For each exception, the full name of its class (including the namespace) and its message are displayed.

The class [Dialog] is public: Subsequent versions of the application, which will change the [dao] layer, will reuse layer [ui] by referencing project [Tax.Ui] 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:

ITaxDao dao = source == "hardwired" ? new HardwiredTax() : new FileTax(source);
business = new TaxBusiness(dao);

Apart from these lines, nothing in the application changes when a layer’s implementation 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 [ui] layer requests [0] the [business] layer from the dependency injection container. To create it, the container first creates the layer [dao] [1], then the layer [business] [2], to which it passes the layer [dao]. Finally, it returns a reference to the layer [business] [3] to the layer [ui].

6.4.6.1. The configuration file

The choice of the [dao] layer implementation is specified in the [appsettings.json] file:

1
2
3
4
5
6
{
  "Dao": {
    "Implementation": "FileTax",
    "File": "TaxData.txt"
  }
}
  • Line 2: A [Dao] section contains the configuration for the [dao] layer;
  • Line 3: The class that implements the [dao] layer: [HardwiredTax], [FileTax], or [JsonTax];
  • Line 4: the data file, for implementations that read one.

[appsettings.json] is the standard name for the configuration file of .NET applications, particularly ASP.NET Core applications. It replaces the XML [App.config] files from the .NET Framework, which were used by Spring.NET during the 2008 academic year. The project contains three other configuration files, which the program uses if their names are passed as arguments:

1
2
3
appsettings-json.json      : "Implementation": "JsonTax", "File": "TaxData.json"
appsettings-hardwired.json : "Implementation": "HardwiredTax"
appsettings-error.json     : "Implementation": "FileTax", "File": "TaxDataMissing.txt"

6.4.6.2. The main program

The main program [Program.cs] from the [Tax.Ui] project is as follows:

using Dao;
using Entities;
using Business;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Ui;

// the configuration file: appsettings.json by default, or the one passed as an argument
string configFile = args.Length > 0 ? args[0] : "appsettings.json";

// integration of the layers
ITaxBusiness business;
try
{
    // reading the configuration, next to the executable
    IConfiguration config = new ConfigurationBuilder()
        .SetBasePath(AppContext.BaseDirectory)
        .AddJsonFile(configFile, optional: false)
        .Build();
    string implementation = config["Dao:Implementation"] ?? "HardwiredTax";
    string file = config["Dao:File"] ?? "";

    // registration of the layers in the container
    var services = new ServiceCollection();
    // the [dao] layer: the implementation is chosen by the configuration
    services.AddSingleton<ITaxDao>(_ => implementation switch
    {
        "HardwiredTax" => new HardwiredTax(),
        "FileTax" => new FileTax(file),
        "JsonTax" => new JsonTax(file),
        _ => throw new TaxException($"Unknown implementation [{implementation}] of the [dao] layer")
    });
    // the [business] layer: the container will pass it the [dao] layer
    services.AddSingleton<ITaxBusiness, TaxBusiness>();

    // creation of the container, then of the [business] layer (and therefore of the [dao] layer)
    ServiceProvider provider = services.BuildServiceProvider();
    business = provider.GetRequiredService<ITaxBusiness>();
}
catch (Exception e)
{
    // display of the error and stopping of the program
    Dialog.DisplayError(e);
    return 1;
}

// the dialog with the user
Dialog.Run(business);
return 0;
  • line 9: the name of the configuration file: [appsettings.json] by default, or the one passed as an argument;
  • lines 16–19: reading the configuration. [ConfigurationBuilder] assembles a configuration from sources: in this case, a single JSON file ([AddJsonFile]), located in the executable’s folder ([SetBasePath]). The parameter [optional: false] (a named parameter, C# 4) makes the file mandatory: its absence causes a [FileNotFoundException];
  • lines 20–21: the configuration is read as a dictionary. The key [Dao:Implementation] refers to the property [Implementation] of the section [Dao]; the level separator is the colon. The indexer returns [null] if the key does not exist: the operator [??] then provides a default value;
  • line 24: the service collection, that is, the list of types that the container can create and how to create them;
  • lines 26–31: the [dao] layer registration. [AddSingleton<ITaxDao>(factory)] indicates that when a [ITaxDao] is requested, the container will call the [factory] function to create it. "Singleton" means that the object will be created only once, upon the first request, and that all subsequent requests will return the same object: there is only one [dao] layer in the application. The factory selects the class based on the configuration, using the expression [switch]; its parameter, the container itself, is not used here, hence the name [_] (“discard”). An unknown implementation results in a [TaxException];
  • line 34: the [business] layer registration: when a [ITaxBusiness] is requested, the container will create a [TaxBusiness] object. To do this, it examines the constructor of the [TaxBusiness] class, sees that it expects a [ITaxDao], and passes it the [dao] layer registered above: this is dependency injection. The [dao] layer is a dependency of the [business] layer;
  • line 37: creation of the container from the collection of services;
  • line 38: the container is asked for the [business] layer. The container creates the [dao] layer, then the [business] layer. [GetRequiredService] throws an exception if the requested type has not been registered. Note that the layer [ui] obtains the layer [business] without specifying the class that implements it;
  • lines 40–44: any configuration or layer creation errors are displayed along with the exception stack trace, and the program terminates;
  • line 48: the user interface, identical to that of version 4.

The 2008 course included the full names of the classes to be instantiated ([Dao.FileTax, TaxV5-dao]) in the configuration file: Spring.NET created them using reflection. We chose instead to define the possible implementations in the factory: 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 layers [ui] and [business] never specifies the class that implements layer [dao].

6.4.6.3. Executions

With the file [appsettings.json] (implementation [FileTax]):

1
2
3
4
5
6
7
8
> dotnet run
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
> dotnet run -- appsettings-json.json
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 implementation [HardwiredTax]:

1
2
3
4
> dotnet run -- appsettings-hardwired.json
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 [appsettings-error.json], which refers to a nonexistent data file:

1
2
3
4
5
6
7
> dotnet run -- appsettings-error.json
Exception chain:
----------------------------------------
Entities.TaxException: Error while reading the file TaxDataMissing.txt
----------------------------------------
System.IO.FileNotFoundException: The file [TaxDataMissing.txt] does not exist
----------------------------------------
  • line 6: the original exception, of type [FileNotFoundException], thrown by the class [FileTax];
  • line 4: the [dao] layer encapsulated it in a [TaxException]. It is this exception that was propagated up to the main program via the [GetRequiredService] call.

The .NET container passes through exceptions thrown during object creation without modification. With Spring.NET, the same error produced a chain of eight exceptions, six of which were caused by the framework ([ConfigurationErrorsException], [ObjectCreationException], [TargetInvocationException], etc.), followed by the two exceptions mentioned above. The key takeaway from this chain is that it is often the deepest exception—the last one displayed—that is the most significant.

Dependency injection alone deserves a chapter of its own. We used only the essentials: registering services ([AddSingleton]), building the container ([BuildServiceProvider]), and obtaining a service ([GetRequiredService]). The container can also create a new object for each request ([AddTransient]) or an object per “scope,” such as per request (HTTP) in a web application ([AddScoped]). We’ll see this again in the ASP.NET Core applications, where it is ubiquitous. You can explore this topic further in Microsoft’s documentation: Dependency Injection .NET.

6.5. Conclusion

This chapter has given the [Tax] application 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 layer [ui] that interacts with the user and knows the layer [business] only through its interface [ITaxBusiness];
  • a main program—the only one that knows the implementation classes—which creates the layers and links them, in this case using a 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 libraries [Tax.Dao], [Tax.Business], and [Tax.Ui] from version 5 will be reused as-is via project references:

  • Version 6 (chapter ADO.NET): A new implementation, [MySqlTax], of the [dao] layer reads the tax brackets from a MySQL database. The layers [business] and [ui] remain unchanged;
  • Version 7 (Entity Framework Core chapter): A [EfTax] implementation of the [dao] layer uses the ORM Entity Framework Core to access the same database;
  • Version 8 (Internet chapter): The [business] layer is installed on a TCP server, which calculates taxes for remote clients; this is therefore a “three-tier” architecture in the strict sense;
  • Version 9 (Web Services chapter): The [business] layer is exposed by a web service REST/JSON written with ASP.NET Core, whose console client uses [HttpClient].

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.