TL;DR
Mes factures ont 3 statuts (draft, pending, paid) et 2 transitions possibles. J'ai quand même écrit une machine à états avec classes abstraites, factory, registre de guards et bus d'événements : 5 fichiers, ~150 lignes. KISS, c'est : "la solution la plus simple qui résout le problème d'aujourd'hui gagne." On remplace tout ça par un type, deux méthodes et une ligne de Vue.
Dans l'Invoice App, une facture a une vie très simple. Elle naît en brouillon (draft), on l'envoie (pending), le client paie (paid). Fin. Trois statuts, deux flèches :
stateDiagram-v2
direction LR
draft --> pending : Save & Send
pending --> paid : Mark as Paid
Sauf que la semaine dernière, j'étais encore tout fier de mon refactor DRY. J'avais goûté à l'abstraction, et comme tout débutant qui vient de découvrir un marteau, je voyais des clous partout.
Alors je me suis dit : "Un changement de statut, c'est une machine à états. Les vrais devs font des machines à états." Trois heures plus tard, j'avais une classe abstraite, trois classes concrètes, une factory, un registre de guards, des hooks onEnter / onExit et un système d'événements.
Pour faire passer un booléen déguisé de pending à paid.
Bienvenue dans le syndrome de l'architecte en herbe : ce moment où ton code est prêt pour la NASA, alors que ton problème tient sur un Post-it.
Imagine que tu veux allumer la lumière de ta cuisine. Tu as deux options.
Option A : un interrupteur. Clic, lumière.
Option B : une machine de Rube Goldberg. Une bille dévale une rampe, fait tomber un domino, qui tire une ficelle, qui lève un levier, qui appuie sur l'interrupteur. C'est impressionnant. Tes amis filment. Et le jour où la lumière ne s'allume pas, tu passes vingt minutes à chercher quel domino est tombé de travers.
KISS (Keep It Simple, Stupid), c'est choisir l'interrupteur. Pas parce que la machine est "mal", mais parce que ton problème, c'est d'allumer une lumière, pas de gagner un concours d'ingéniosité.
Le "stupid" ne vise pas toi. Il vise le code : un code bête est un code que n'importe qui comprend en 30 secondes, y compris toi à 2h du matin quand la prod est en feu.
Le code "avant" (le péché)
Je t'épargne la totalité. Voici juste les morceaux les plus gênants, répartis dans un dossier app/state_machine/ créé spécialement pour l'occasion.
La classe abstraite, parce qu'on ne sait jamais :
// app/state_machine/invoice_state.ts
export type InvoiceStatusName = 'draft' | 'pending' | 'paid'
export abstract class InvoiceState {
constructor(protected machine: InvoiceStateMachine) {}
abstract get name(): InvoiceStatusName
abstract allowedTransitions(): InvoiceStatusName[]
async onEnter(): Promise<void> {}
async onExit(): Promise<void> {}
canTransitionTo(target: InvoiceStatusName) {
return this.allowedTransitions().includes(target)
}
}
export class DraftState extends InvoiceState {
get name() { return 'draft' as const }
allowedTransitions() { return ['pending' as const] }
}
export class PendingState extends InvoiceState {
get name() { return 'pending' as const }
allowedTransitions() { return ['paid' as const] }
async onEnter() { this.machine.emit('invoice:sent', this.machine.invoice) }
}
export class PaidState extends InvoiceState {
get name() { return 'paid' as const }
allowedTransitions() { return [] }
async onEnter() { this.machine.emit('invoice:paid', this.machine.invoice) }
}
La factory, pour fabriquer les états (tous les trois) :
// app/state_machine/invoice_state_factory.ts
export class InvoiceStateFactory {
private registry = new Map<InvoiceStatusName, new (m: InvoiceStateMachine) => InvoiceState>([
['draft', DraftState],
['pending', PendingState],
['paid', PaidState],
])
create(name: InvoiceStatusName, machine: InvoiceStateMachine) {
const StateClass = this.registry.get(name)
if (!StateClass) throw new Error(`Unknown state: ${name}`)
return new StateClass(machine)
}
}
La machine, avec son petit bus d'événements maison que personne n'écoute :
// app/state_machine/invoice_state_machine.ts
export class InvoiceStateMachine {
private current: InvoiceState
private listeners = new Map<string, Array<(invoice: Invoice) => void>>()
constructor(
public invoice: Invoice,
private factory: InvoiceStateFactory,
private guards: TransitionGuardRegistry
) {
this.current = this.factory.create(invoice.status, this)
}
async transitionTo(target: InvoiceStatusName) {
if (!this.current.canTransitionTo(target)) {
throw new Error(`Transition ${this.current.name} -> ${target} interdite`)
}
await this.guards.check(this.current.name, target, this.invoice)
await this.current.onExit()
this.current = this.factory.create(target, this)
this.invoice.status = target
await this.current.onEnter()
}
on(event: string, cb: (invoice: Invoice) => void) { /* ... */ }
emit(event: string, invoice: Invoice) { /* ... */ }
}
Et enfin, le controller, qui doit maintenant assembler tout ce petit monde :
// app/controllers/invoices_controller.ts
async markAsPaid({ params, response }: HttpContext) {
const invoice = await Invoice.findOrFail(params.id)
const machine = new InvoiceStateMachine(
invoice,
new InvoiceStateFactory(),
new TransitionGuardRegistry()
)
await machine.transitionTo('paid')
await invoice.save()
return response.redirect().back()
}
Faisons les comptes : 5 fichiers, une centaine et demie de lignes, trois design patterns. Le registre de guards ne contient aucun guard. Les événements invoice:sent et invoice:paid ne sont écoutés par personne. Et pour comprendre "est-ce qu'une facture payée peut redevenir brouillon ?", il faut ouvrir trois fichiers.
La réponse tient pourtant en un mot : non.
Le refactor "après" (la rédemption)
Étape 1 — on rm -rf app/state_machine/. Honnêtement, c'était l'étape la plus satisfaisante.
Étape 2 — le statut devient un simple type sur le modèle, et les règles deviennent deux méthodes qui disent exactement ce qu'elles font :
// app/models/invoice.ts
import { Exception } from '@adonisjs/core/exceptions'
export type InvoiceStatus = 'draft' | 'pending' | 'paid'
export default class Invoice extends BaseModel {
@column()
declare status: InvoiceStatus
@computed()
get isEditable() {
return this.status !== 'paid'
}
send() {
if (this.status !== 'draft') {
throw new Exception('Seul un brouillon peut être envoyé', { status: 422 })
}
this.status = 'pending'
}
markAsPaid() {
if (this.status !== 'pending') {
throw new Exception('Seule une facture en attente peut être payée', { status: 422 })
}
this.status = 'paid'
}
}
Toute la "machine à états" est là, lisible en un coup d'œil. Une facture payée peut-elle redevenir brouillon ? Aucune méthode ne le permet. Réponse en 5 secondes, un seul fichier ouvert.
Étape 3 — le controller redevient un controller :
// app/controllers/invoices_controller.ts
async markAsPaid({ params, response }: HttpContext) {
const invoice = await Invoice.findOrFail(params.id)
invoice.markAsPaid()
await invoice.save()
return response.redirect().back()
}
// start/routes.ts
router.patch('/invoices/:id/pay', [InvoicesController, 'markAsPaid'])
Étape 4 — côté Vue, le bouton "Mark as Paid" du design Frontend Mentor n'apparaît que quand il a un sens :
<script setup lang="ts">
import { router } from '@inertiajs/vue3'
const props = defineProps<{ invoice: Invoice }>()
const markAsPaid = () => router.patch(`/invoices/${props.invoice.id}/pay`)
</script>
<template>
<button v-if="invoice.isEditable">Edit</button>
<button v-if="invoice.status === 'pending'" @click="markAsPaid">
Mark as Paid
</button>
</template>
💡 Petit détail Adonis au passage : sans le décorateur
@computed(), un getter n'est pas sérialisé, doncisEditablen'arriverait jamais dans tes props Inertia.
Bilan : de 5 fichiers et ~150 lignes à une vingtaine de lignes dans le modèle. Même comportement, mêmes garde-fous, zéro domino.
Le piège à éviter (petite mise en garde)
KISS ne veut pas dire "simpliste", ni "les machines à états c'est mal." Le jour où mes factures auront 8 statuts (overdue, partially_paid, refunded, cancelled...), des transitions qui déclenchent des emails, des webhooks Stripe et des règles métier croisées, une vraie machine à états (ou une lib comme XState) deviendra la solution simple. À ce moment-là, ce sont mes if imbriqués qui seront la machine de Rube Goldberg.
La question à se poser n'est pas "quelle est la solution la plus élégante ?" mais "quelle est la solution la plus simple qui résout le problème que j'ai vraiment, là, maintenant ?" Et ce "maintenant" est justement le sujet de la semaine prochaine.
La semaine prochaine, Partie 3 : YAGNI. Ou comment j'ai passé un week-end entier à rendre l'appli multi-devises, multi-langues et compatible export comptable... pour une appli de factures dont le seul utilisateur, c'est moi. Spoiler : je facture toujours en euros.
Top comments (0)