Pour autant que je puisse dire, personne ne présente une solution assez évidente qui incarne le meilleur de la construction en une et en deux étapes.
note : Cette réponse suppose le langage C#, mais les principes peuvent être appliqués dans la plupart des langages.
Tout d'abord, les avantages des deux :
Une étape
La construction en une étape est avantageuse car elle empêche les objets d'exister dans un état invalide, ce qui évite toutes sortes de gestion d'état erronée et tous les bogues qui en découlent. Cependant, certains d'entre nous se sentent bizarres car nous ne voulons pas que nos constructeurs lèvent des exceptions, et c'est parfois ce que nous devons faire lorsque les arguments d'initialisation sont invalides.
public class Person
{
public string Name { get; }
public DateTime DateOfBirth { get; }
public Person(string name, DateTime dateOfBirth)
{
if (string.IsNullOrWhitespace(name))
{
throw new ArgumentException(nameof(name));
}
if (dateOfBirth > DateTime.UtcNow) // side note: bad use of DateTime.UtcNow
{
throw new ArgumentOutOfRangeException(nameof(dateOfBirth));
}
this.Name = name;
this.DateOfBirth = dateOfBirth;
}
}
Méthode de validation en deux étapes
La construction en deux étapes est avantageuse car elle permet à notre validation d'être exécutée en dehors du constructeur, ce qui évite de devoir lancer des exceptions dans le constructeur. Cependant, cela nous laisse avec des instances "invalides", ce qui signifie qu'il y a un état que nous devons suivre et gérer pour l'instance, ou nous la jetons immédiatement après l'allocation du tas. Cela soulève la question : Pourquoi effectuons-nous une allocation au tas, et donc une collecte de mémoire, sur un objet que nous n'utilisons même pas ?
public class Person
{
public string Name { get; }
public DateTime DateOfBirth { get; }
public Person(string name, DateTime dateOfBirth)
{
this.Name = name;
this.DateOfBirth = dateOfBirth;
}
public void Validate()
{
if (string.IsNullOrWhitespace(Name))
{
throw new ArgumentException(nameof(Name));
}
if (DateOfBirth > DateTime.UtcNow) // side note: bad use of DateTime.UtcNow
{
throw new ArgumentOutOfRangeException(nameof(DateOfBirth));
}
}
}
Single-Stage via un constructeur privé
Alors, comment pouvons-nous garder les exceptions hors de nos constructeurs, et nous empêcher d'effectuer une allocation au tas sur des objets qui seront immédiatement jetés ? C'est assez simple : nous rendons le constructeur privé et créons des instances par le biais d'une méthode statique désignée pour effectuer une instanciation, et donc une allocation au tas, seulement après validation.
public class Person
{
public string Name { get; }
public DateTime DateOfBirth { get; }
private Person(string name, DateTime dateOfBirth)
{
this.Name = name;
this.DateOfBirth = dateOfBirth;
}
public static Person Create(
string name,
DateTime dateOfBirth)
{
if (string.IsNullOrWhitespace(Name))
{
throw new ArgumentException(nameof(name));
}
if (dateOfBirth > DateTime.UtcNow) // side note: bad use of DateTime.UtcNow
{
throw new ArgumentOutOfRangeException(nameof(DateOfBirth));
}
return new Person(name, dateOfBirth);
}
}
Async Single-Stage via un constructeur privé
Outre les avantages susmentionnés en matière de validation et de prévention de l'allocation de tas, la méthodologie précédente nous offre un autre avantage intéressant : le support asynchrone. Cela s'avère très utile dans le cas d'une authentification en plusieurs étapes, par exemple lorsque vous devez récupérer un jeton de porteur avant d'utiliser votre API. De cette façon, vous ne vous retrouvez pas avec un client d'API invalide et déconnecté, mais vous pouvez simplement recréer le client d'API si vous recevez une erreur d'autorisation lors de la tentative d'exécution d'une requête.
public class RestApiClient
{
public RestApiClient(HttpClient httpClient)
{
this.httpClient = new httpClient;
}
public async Task<RestApiClient> Create(string username, string password)
{
if (username == null)
{
throw new ArgumentNullException(nameof(username));
}
if (password == null)
{
throw new ArgumentNullException(nameof(password));
}
var basicAuthBytes = Encoding.ASCII.GetBytes($"{username}:{password}");
var basicAuthValue = Convert.ToBase64String(basicAuthBytes);
var authenticationHttpClient = new HttpClient
{
BaseUri = new Uri("https://auth.example.io"),
DefaultRequestHeaders = {
Authentication = new AuthenticationHeaderValue("Basic", basicAuthValue)
}
};
using (authenticationHttpClient)
{
var response = await httpClient.GetAsync("login");
var content = response.Content.ReadAsStringAsync();
var authToken = content;
var restApiHttpClient = new HttpClient
{
BaseUri = new Uri("https://api.example.io"), // notice this differs from the auth uri
DefaultRequestHeaders = {
Authentication = new AuthenticationHeaderValue("Bearer", authToken)
}
};
return new RestApiClient(restApiHttpClient);
}
}
}
Les inconvénients de cette méthode sont peu nombreux, d'après mon expérience.
En général, l'utilisation de cette méthodologie signifie que vous ne pouvez plus utiliser la classe comme DTO car la désérialisation vers un objet sans constructeur public par défaut est difficile, au mieux. Cependant, si vous utilisiez l'objet comme DTO, vous ne devriez pas vraiment valider l'objet lui-même, mais plutôt invalider les valeurs de l'objet lorsque vous tentez de les utiliser, puisque techniquement les valeurs ne sont pas "invalides" par rapport au DTO.
Cela signifie également que vous finirez par créer des méthodes ou des classes d'usine lorsque vous devez permettre à un conteneur IOC de créer l'objet, car sinon le conteneur ne saura pas comment instancier l'objet. Cependant, dans de nombreux cas, les méthodes d'usine finissent par être l'un des éléments suivants Create
les méthodes elles-mêmes.