Aller au contenu principal

Du chaos des status au pattern State

1. Introduction

Illustration GIF chaos des statuts

Au début, tout paraît simple. On ajoute une colonne status en base, quelques valeurs comme Draft, Paid, Shipped, et on avance. Puis les règles métier arrivent. Une commande ne peut pas être expédiée si elle n’est pas payée, elle ne peut pas être annulée après livraison, et certaines actions deviennent interdites selon son état. Alors on commence à ajouter des if, un peu partout. Dans le contrôleur, dans le service, parfois même dans le front. Ça fonctionne… jusqu’au jour où une commande se retrouve expédiée sans jamais avoir été payée.

À ce moment-là, on essaie souvent de “sécuriser” le système. Certains déplacent la logique dans la base avec des triggers ou des procédures stockées. D’autres dupliquent les vérifications à plusieurs endroits pour être “sûrs”. Et sans s’en rendre compte, on crée un système où la logique métier est dispersée, difficile à lire, encore plus difficile à maintenir. Ce n’est plus vraiment un problème de base de données ou de langage. C’est un problème de modélisation. Et c’est exactement là que le pattern State commence à devenir intéressant.

2. Le principe du pattern State

Pour comprendre le pattern State, on va partir d’un exemple concret que nous allons construire tout au long de cet article.

Imaginons une commande e-commerce avec plusieurs états :

  • Draft
  • Paid
  • Shipped
  • Delivered
  • Cancelled

Chaque état impose des règles :

  • on ne peut pas expédier une commande non payée
  • on ne peut pas annuler une commande livrée
  • certaines actions deviennent impossibles selon l’état

Flux des états d'une commande

À ce stade, beaucoup de développeurs utilisent des if ou des switch pour gérer ces règles. Mais plus le nombre d’états augmente, plus le code devient difficile à maintenir.

Le pattern State propose une autre approche : chaque état devient une classe qui contient son propre comportement.

Modélisation du pattern State

Si vous savez lire un diagramme UML, la logique est assez directe : le contexte (OrderContext) contient une référence vers un état (IOrderState) et délègue toutes ses actions à cet état. Chaque état concret (DraftState, PaidState, etc.) implémente le comportement autorisé et peut décider de changer l’état courant du contexte. Autrement dit, au lieu d’avoir des conditions dans le contexte, ce sont les objets état eux-mêmes qui portent la logique et contrôlent les transitions.

Si cela est clair, alors l’idée essentielle est déjà là : chaque fois qu’il faudra gérer un changement d’état dans une application, ce pattern pourra revenir dans votre boîte à outils. Et les cas d’usage ne manquent pas : commande e-commerce, ticket de support, workflow de validation, abonnement, facture, livraison, réservation, publication de contenu. C’est aussi l’avantage de connaître un design pattern : il finit par devenir une formule réutilisable, un peu comme le MVC que presque tout le monde connaît aujourd’hui.

Illustration GIF logique imbriquée

L’idée, au fond, c’est d’avoir le réflexe de ne plus penser immédiatement à un switch, à une suite de if imbriqués, ou à de la logique métier dispersée dans tous les sens. Et ce n’est pas non plus une invitation à déplacer ce chaos dans la base de donnée avec des triggers ou des procédures stockées. En pratique, dans une approche propre, ce genre de logique applicative a rarement sa place là-dessous : on ne programme pas une base de données applicative. Le pattern State aide justement à remettre cette responsabilité dans le code métier, là où elle reste lisible, testable et modifiable.

3. Implémentation naive avec switch

Commençons par une approche classique que l’on retrouve très souvent en début de projet : une gestion des états basée sur un enum et un switch.

Les exemples qui suivent sont écrits en C#, mais la logique reste la même dans d’autres langages orientés objet comme PHP, Java ....

Voici une implémentation simple :

namespace Junior
{
public enum OrderStatus
{
Draft,
Paid,
Shipped,
Cancelled
}

public class Order
{
public OrderStatus Status { get; private set; } = OrderStatus.Draft;

public void HandleAction(string action)
{
switch (Status)
{
case OrderStatus.Draft:
if (action == "pay")
{
Console.WriteLine("Paiement accepté.");
Status = OrderStatus.Paid;
}
else if (action == "cancel")
{
Console.WriteLine("Commande annulée.");
Status = OrderStatus.Cancelled;
}
else
{
Console.WriteLine("Action impossible depuis Draft.");
}
break;

case OrderStatus.Paid:
if (action == "ship")
{
Console.WriteLine("Commande expédiée.");
Status = OrderStatus.Shipped;
}
else if (action == "cancel")
{
Console.WriteLine("Commande annulée après paiement.");
Status = OrderStatus.Cancelled;
}
else
{
Console.WriteLine("Action impossible depuis Paid.");
}
break;

case OrderStatus.Shipped:
Console.WriteLine(
"Aucune action possible, commande déjà expédiée."
);
break;

case OrderStatus.Cancelled:
Console.WriteLine(
"Commande annulée, aucune action possible."
);
break;
}
}
}
}

Pourquoi cette approche devient vite limitée

Illustration GIF complexité croissante

Cette solution fonctionne pour un cas simple, mais elle atteint rapidement ses limites dès que le nombre d’états ou d’actions augmente. Toute la logique métier est centralisée dans une seule méthode, ce qui rend le code difficile à lire et à faire évoluer. Chaque nouvelle règle impose de modifier le switch, souvent à plusieurs endroits, avec des conditions imbriquées de plus en plus complexes. Si demain un nouvel état comme Delivered apparaît, avec ses propres règles, le bloc central s’alourdit encore. La moindre évolution devient plus risquée, avec davantage d’effets de bord possibles. En pratique, plus le système grandit, plus ce type de code devient rigide, verbeux et source d’erreurs, car il mélange gestion des états et logique métier dans un seul bloc.

C’est exactement le type de situation pour lequel le pattern State a été conçu.

4. Refactor vers le pattern State

L’objectif est simple : supprimer le switch et distribuer le comportement dans des classes représentant chaque état.

Structure du module

Avant d’écrire le code, on pose une structure claire :

StatePattern/

├── Context/
│ └── OrderContext.cs

├── States/
│ ├── Abstractions/
│ │ ├── IOrderState.cs
│ │ └── OrderStateBase.cs
│ │
│ ├── DraftState.cs
│ ├── PaidState.cs
│ ├── ShippedState.cs
│ └── CancelledState.cs

Étape 1. Définir le contrat

Tous les états doivent répondre aux mêmes actions.

public interface IOrderState
{
string Name { get; }

void Pay(OrderContext context);
void Ship(OrderContext context);
void Cancel(OrderContext context);
}

Étape 2. Éviter la duplication avec une base commune

Ce n’est pas obligatoire. L’interface seule aurait pu suffire. Mais ici, une base commune évite de répéter les mêmes messages d’erreur et garde le code plus DRY.

public abstract class OrderStateBase : IOrderState
{
public abstract string Name { get; }

public virtual void Pay(OrderContext context)
{
Console.WriteLine("Action Pay impossible depuis cet état.");
}

public virtual void Ship(OrderContext context)
{
Console.WriteLine("Action Ship impossible depuis cet état.");
}

public virtual void Cancel(OrderContext context)
{
Console.WriteLine("Action Cancel impossible depuis cet état.");
}
}

Étape 3. Le contexte

Le contexte ne contient plus de logique métier. Il délègue.

public class OrderContext
{
private IOrderState _state;

public string CurrentStateName => _state.Name;

public OrderContext()
{
_state = new DraftState();
}

public void SetState(IOrderState state)
{
_state = state;
}

public void Pay() => _state.Pay(this);
public void Ship() => _state.Ship(this);
public void Cancel() => _state.Cancel(this);
}

Étape 4. Les états concrets

Chaque état devient responsable de ses propres règles.

DraftState

public class DraftState : OrderStateBase
{
public override string Name => "Draft";

public override void Pay(OrderContext context)
{
Console.WriteLine("Paiement accepté.");
context.SetState(new PaidState());
}

public override void Cancel(OrderContext context)
{
Console.WriteLine("Commande annulée.");
context.SetState(new CancelledState());
}
}

PaidState

public class PaidState : OrderStateBase
{
public override string Name => "Paid";

public override void Ship(OrderContext context)
{
Console.WriteLine("Commande expédiée.");
context.SetState(new ShippedState());
}

public override void Cancel(OrderContext context)
{
Console.WriteLine("Commande annulée après paiement.");
context.SetState(new CancelledState());
}
}

ShippedState

public class ShippedState : OrderStateBase
{
public override string Name => "Shipped";

// Aucun override nécessaire ici
}

CancelledState

public class CancelledState : OrderStateBase
{
public override string Name => "Cancelled";

// Aucun override nécessaire ici
}

Étape 5. Tester le comportement dans un Main()

Maintenant que les états sont en place, il ne reste plus qu’à tester le comportement du contexte.

var order1 = new OrderContext();

Console.WriteLine("SCÉNARIO 1 : Flux normal (Draft → Paid → Shipped)");
Console.WriteLine($"État initial : {order1.CurrentStateName}");

order1.Pay();
order1.Ship();

Console.WriteLine($"État final : {order1.CurrentStateName}");

Console.WriteLine("----------------------------------------------------");

var order2 = new OrderContext();

Console.WriteLine("SCÉNARIO 2 : Tentative d’action invalide");
Console.WriteLine($"État initial : {order2.CurrentStateName}");

order2.Ship(); // impossible depuis Draft
order2.Cancel(); // passe en Cancelled
order2.Pay(); // impossible après annulation

Console.WriteLine($"État final : {order2.CurrentStateName}");

Et voici le résultat observé dans la console :

Résultat du test du pattern State

Évolution du besoin

Illustration GIF évolution du besoin

Maintenant, imaginons une situation classique : le manager demande d’ajouter un nouvel état, par exemple Delivered.

Avec une approche basée sur switch, il aurait fallu :

  • modifier le bloc central
  • ajouter de nouvelles conditions
  • vérifier que rien n’est cassé ailleurs

Bref, toucher à un code déjà fragile.

Avec le pattern State

Ici, c’est beaucoup plus simple.

Il suffit d’ajouter une nouvelle classe :

public class DeliveredState : OrderStateBase
{
public override string Name => "Delivered";
}

La première étape consiste à enrichir le contrat commun des états :

public interface IOrderState
{
// ...
void Deliver(OrderContext context);
}

Ensuite, il faut exposer la nouvelle action dans le contexte :

public class OrderContext
{
// ...
public void Deliver() => _state.Deliver(this);
}

Enfin, la transition se déclare dans l’état où elle a réellement du sens :

public class ShippedState : OrderStateBase
{
public override string Name => "Shipped";

public override void Deliver(OrderContext context)
{
Console.WriteLine("Commande livrée.");
context.SetState(new DeliveredState());
}
}

Il faut donc penser la modification de manière cohérente : si une nouvelle action apparaît, elle doit d’abord être ajoutée au contrat commun des états, puis à la classe de base, puis au contexte. Une fois ce cadre posé, chaque état peut soit implémenter cette action, soit la refuser par défaut. C’est justement là que le pattern State devient intéressant : l’évolution n’est plus une retouche risquée dans un bloc central, mais une extension locale, explicite et alignée sur le modèle.

Voici le schéma qu’il faudra garder en tête

Nouveau besoin = mise à jour du contrat + ajout de l’état concerné + définition de la transition là où elle a du sens.

5. Intégration avec une base de données

Un des points intéressants du pattern State, c’est qu’il s’intègre très naturellement avec une base de données. On ne cherche pas à stocker de la logique complexe en base, mais simplement à persister un état, que l’application va ensuite transformer en comportement.

Dans un vrai projet, une commande est souvent stockée en base avec un simple statut :

var orderFromDb = repository.GetById(1); // "Paid"

Ici, il ne s’agit encore que d’une donnée persistée. À ce stade, aucune logique métier n’est exécutée.

Adapter le contexte

Pour gérer ce cas, le contexte peut être surchargé afin d’être initialisé avec un état déjà existant :

public OrderContext(IOrderState state)
{
_state = state;
}

Cela permet de créer un contexte aligné avec la réalité de la base.

Mapper le statut vers un état

Il faut ensuite un mapper qui transforme la valeur stockée en base en un état concret :

var state = OrderStateMapper.Map(orderFromDb.Status);

Ce mapper fait le lien entre la base et le pattern State.

public static class OrderStateMapper
{
public static IOrderState Map(string status)
{
return status switch
{
"Draft" => new DraftState(),
"Paid" => new PaidState(),
"Shipped" => new ShippedState(),
"Cancelled" => new CancelledState(),
_ => throw new Exception("Unknown state")
};
}
}

Utilisation complète

Voilà comment cela devrait ressembler :

var orderFromDb = repository.GetById(1); // "Paid"

var state = OrderStateMapper.Map(orderFromDb.Status);

var context = new OrderContext(state);

// logique métier
context.Ship();

// sauvegarde
repository.UpdateStatus(1, context.CurrentStateName);

Résumé

  • on récupère un statut depuis la base
  • on le transforme en état via un mapper
  • le contexte applique la logique métier
  • on persiste le nouvel état

Le principe est simple : la base stocke une valeur, le code reconstruit le comportement.

Si cette structure revient souvent dans un projet, elle peut aussi devenir un bon Skill, pour qu’un agent la réapplique proprement sans repartir de zéro. L’article C’est quoi les Skills IA ? va plus loin sur ce point.