… ou comment éviter les bugs grâce aux value objects et notamment au nouveau type DateOnly introduit dans le framework .Net.

Contexte

Dans .NET 6, deux types très attendus (du moins par certains) ont été introduits dans le cadre de la bibliothèque core. DateOnly et TimeOnly permettent aux développeurs de représenter la partie date ou heure d'un DateTime. Ces deux nouveaux types sont des structures (types valeur) et peuvent être utilisés lorsque votre code traite indépendamment des concepts de date ou d'heure.

Les adeptes de Domain-Driven Design et des pratiques crafts (voir l'article Primitive obsession) avaient depuis longtemps créé leur propres (value) objets Date et Time pour les besoins de leurs domaines.

Cet article montre pourquoi ces nouveaux types sont extrêmement pertinents.

En pratique

Prenons l'exemple d'un domaine de l'assurance et le cadre de la gestion des contrats.

Soit une classe Contract représentant ceux-ci. Un contrat possède un numéro, une date de début et une date de fin ainsi que d'autres champs qui ne nous intéresserons pas ici.

Ci-dessous une implémentation anémique de cette classe en C# :

public class Contract {

  public string   Number    { get; set; }
  public DateTime StartDate { get; set; }
  public DateTime EndDate   { get; set; }

}

Ci-dessous, un exemple d'utilisation où l'on crée un nouveau contrat avec comme date de démarrage aujourd'hui et avec une durée de vie d'un an :

Contract newContract  = new Contract();
newContract.Number    = _contractNumberGenerator.CreateNew(user);
newContract.StartDate = DateTime.Today();
newContract.EndDate   = newContract.StartDate.AddYear(1).AddDay(-1);

Dans un modèle complexe, ce simple bout de code peut déjà poser de nombreux problèmes. Commençons à refactorer avec une approche Model-Driven Design.

Model-Driven Design

Dans un premier temps, définissons un certain nombre de règles du domaine.

Un contrat possède toujours un numéro de contrat unique qui est fourni par un générateur qui se base sur des informations utilisateur et possède un format défini. Ce numéro de contrat peut-être défini comme un identifiant: il ne changera jamais tout au long du cycle de vie du contrat. Le détails d'implémentation de ce numéro de contrat ne nous intéresse pas dans le cadre de cet article.

Utilisons ces informations pour refactorer :

[DebuggerDisplay("{ToString()}")
[DomainEntity]
public sealed class Contract {

  public Contract(ContractNumber number) {
    if (number == null) { throw new ArgumentNullException(nameof(number)); }

    Number = number;
  }

  public ContractNumber Number    { get; }
  public DateTime       StartDate { get; set; }
  public DateTime       EndDate   { get; set; }

  public bool Equals(Contract other) {
    if (ReferenceEquals(null, other)) { return false; }
    if (ReferenceEquals(this, other)) { return true; }

    return Number.Equals(other.Number);
  }

  public override bool Equals(object obj) {
    return ReferenceEquals(this, obj) 
        || obj is Contract other && Equals(other);
  }

  public override int GetHashCode() {
    return Number.GetHashCode();
  }

  public override string ToString() {
    return Number.ToString();
  }
}

Nous venons d'introduire l'objet valeur ContractNumber qui va encapsuler les invariants qui lui sont liés. Nous avons rendu le numéro de contrat obligatoire pour un contrat donné. Nous avons également transformé la classe Contract en une entité de domaine en lui définissant son identifiant par l'implémentation de des méthodes Equals et GetHashCode. Enfin, nous avons implémenté la méthode ToString, en association avec l'attribut DebuggerDisplay, pour simplifier le déboggage.

Continuons d'améliorer notre classe Contract...

Un contrat possède forcément une date de début et une date de fin. Celles-ci ne peuvent être modifiées : en cas de changement, un avenant sera créé. De plus, la date de fin de contrat ne peut-être inférieure à la date de début de contrat.

Utilisons ces informations et continuons de refactorer :

public sealed class Contract {

  public Contract(ContractNumber number, DateTime startDate, DateTime endDate) {
    // ...
    if (startDate < endDate) { throw ContractException.EndDateCannotBeLessThanStartDate(startDate, endDate); }

    Number    = number;
    StartDate = startDate;
    EndDate   = endDate;
  }

  public ContractNumber Number    { get; }
  public DateTime       StartDate { get; }
  public DateTime       EndDate   { get; }

  // ...
}

Nous venons de renforcer les règles métiers grâce à l'encapsulation et à la prise en compte de certains invariants.

Explicitez l'implicite

Ok, super. Mais maintenant plusieurs questions se posent. Puis-je faire cela ?

// ...

DateTime startDate   = new DateTime(2021, 12, 27, 17, 12, 42);
DateTime endDate     = new DateTime(2022, 12, 26, 17, 12, 42);
Contract newContract = new Contract(newNumber, startDate, endDate);

Dans le cadre de notre domaine, la réponse est (clairement?) "Non" et de proposer la solution suivante :

// ...

DateTime startDate   = new DateTime(2021, 12, 27);
DateTime endDate     = new DateTime(2022, 12, 26);
Contract newContract = new Contract(newNumber, startDate, endDate);

En effet (a priori) un contrat d'assurance est valide de date à date. On pourrait imaginer cependant une assurance spécialisée dans les évènements sportifs qui aurait des contrats plus limités dans le temps. Tiré par les cheveux ? OK, mais il existe des domaines pour lesquels l'horodatage des dates de début et de fin de contrat est (potentiellement) nécessaire comme pour la location de voiture. Et il existe d'autres domaines pour lesquels cela est moins évident.

On voit clairement que ces dates de contrat, début et fin, que l'on peut retrouver dans divers domaines et diverses sociétés, pourront être de nature assez différente. Outre ces dates, d'autres "dates" doivent être horodatées, d'autres non.

Dans notre code, en fonction des domaines, nous pouvons être confrontés à la gestion de nombreux champs de type date horodaté ou non. Alors pourquoi laisser place à l'incertitude et ne pas rendre l'implicite explicite en utilisant un type date non-horodaté que l'on pourra nommer Date ou bien encore DateOnly ?

Avant de refactorer dans ce sens, ajoutons un champ à notre contrat : la date de création du contrat. Cette date horodatée est immutable et sa partie date doit être inférieure ou égale à la date de début du contrat.

[DomainEntity]
public sealed class Contract {

  public Contract(ContractNumber number, DateOnly startDate, DateOnly endDate, DateTime creationTimestamp) {
    // ...
    if (endDate > startDate) { throw ContractException.EndDateCannotBeGreaterThanStartDate(startDate, endDate); }
    if (creationTimestamp.ToDate() > startDate) { throw ContractException.CreationTimestampDateCannotBeGreaterThanStartDate(creationTimestamp, startDate); }

    Number            = number;
    StartDate         = startDate;
    EndDate           = endDate;
    CreationTimestamp = creationTimestamp;
  }

  public ContractNumber Number            { get; }
  public DateOnly       StartDate         { get; }
  public DateOnly       EndDate           { get; }
  public DateTime       CreationTimestamp { get; }

  // ...
}

En plus des invariants de l'entité, il apparaît clairement que, d'un point de vue métier, certaines dates sont horodatées, d'autres non.

Pour ceux qui ne sont pas encore convaincu, passons au paragraphe suivant.

Evitons les bugs

En plus de rendre le code explicite, d'un point de vue métier, le code devient automatiquement moins fragile !

Il est désormais impossible de créer un contrat avec des dates horodatées là où une date non-horodatées est attendue. Prenons le domaine des contrats d'assurance. Même si les dates de début et de fin de contrat n'étaient pas horodatées, avec la première version du code il était toujours possible, par erreur ou non connaissance du domaine, de faire cela :

// Domain: Insurance

DateTime startDate   = new DateTime(2021, 12, 27, 17, 12, 42);
DateTime endDate     = new DateTime(2022, 12, 26, 17, 12, 42);
Contract newContract = new Contract(newNumber, startDate, endDate);

En quoi cela pose-t-il problème ?

Imaginons un batch qui travaille tous les jours vers 1h00 du matin. Ce batch identifie tous les contrats qui vont expirés dans un mois afin d'envoyer une notification au gestionnaire afin qu'il puisse prendre certaines décisions (contacter le client, ...). Le code du repository de contrats du batch est le suivant :

public Contract[] GetAllContractsToRenewAt(DateTime renewalDate) {
  return _database.Contracts.Where(e => e.EndDate == renewalDate).ToArray();
}

Et son utilisation dans la couche application (ici une implémentation naïve afin de montrer le principe) :

// ..

  DateTime renewalDate = Clock.Instance.GetToday().AddMonth(1);
  Contract[] contractsToRenew =  _contractRepository.GetAllContractsToRenewAt(renewalDate);

// ...

Vous comprenez maintenant où je veux en venir. Si le code ci-dessus est bien utilisé et que par erreur l'un des contrats enregistré possède, par erreur, une date horodatée, celui-ci ne sera jamais remonté par le batch.

Il serait bien sûr possible de patcher le code du repository de la sorte :

public Contract[] GetAllContractsToRenew(DateTime renewalDate) {
  return _database.Contracts.Where(e => e.EndDate.ToDate() == renewalDate.ToDate()).ToArray();

}

Mais dans ce cas, ce genre de traitement, someDateTime.ToDate(), va se retrouver dans tout votre code ! Pas très DRY… et gare à l'oublie !

Avec notre nouveau code et l'utilisation du type DateOnly le problème est définitivement réglé.

Implémentation

Jusqu'à peu, en .Net, vous n'aviez pas le choix. Il fallait développer ce type vous même. Ci-dessous un exemple d'implémentation.

[ValueObject]
public struct DateOnly {

  public static readonly DateOnly MaxValue = DateOnly.FromDateTime(DateTime.MaxValue);

  // ...

  [RehydrationMethod]
  public DateOnly FromDateTime(DateTime d) {
    return new DateOnly(d.Year, d.Month, d.Day);
  }

  private readonly DateTime _target;

  public DateOnly(int year, int month, int day) {
    _target = new DateTime(year, month, day);
  }

  public int Year => _target.Year;
  public int Month => _target.Month;
  public int Day => _target.Day;

  public DateTime ToDateTime(int hour, int minute, int second) {
    return new DateTime(Year, Month, Day, hour, minute, second);
  }

  [DehydrationMethod]
  public DateTime Dehydrate() {
    return new DateTime(Year, Month, Day);
  }

  // ...
}

.Net 6 vient tout juste apporté des améliorations concernant les dates, les heures et les fuseaux horaire. Microsoft a notamment introduit les nouveaux types DateOnly (tient donc) et TimeOnly. L'article Date, Time, and Time Zone Enhancements in .NET 6 vous fera découvrir tout cela.

Conclusion

J'espère que cette présentation du nouveau type .Net DateOnly vous a donné envie de l'utiliser ainsi que d'explorer les nouveautés de la version 6 de .Net.

Le sujet des dates est un sujet complexe qui dépend beaucoup du domaine. Cet article n'a pas pour but d'être exhaustif mais simplement de présenter une façon simple d'améliorer la gestion des dates dans vos applications. A vous de l'adapter à votre domaine.

Références