DEV Community

Rodolphe D.
Rodolphe D.

Posted on

Pourquoi ton code sent le copier-coller (DRY)

TL;DR

Tu valides la même facture à 3 endroits différents et tu recalcules le même total à 4 endroits différents. Le jour où tu changes une règle, tu dois te souvenir des 7. DRY, c'est juste : "dis-le une fois, utilise-le partout." On refactore un InvoicesController Adonis qui pue le copier-coller pour le rendre propre.


Il y a deux semaines, j'ai décidé de changer une règle bête sur mon appli de factures : la TVA passe de 20% à 5.5% pour certains clients. Simple, non ? J'ai modifié le calcul, j'ai testé sur la page de création... et ça marchait parfaitement.

Sauf que sur la page d'édition, le total affichait toujours l'ancienne TVA.

Pourquoi ? Parce que j'avais oublié qu'elle avait sa propre copie du calcul. Écrite trois semaines plus tôt, par un moi plus jeune et plus insouciant, qui avait fait un copier-coller "juste pour aller vite."

Bienvenue dans le syndrome du copier-coller de la honte : ce moment où tu réalises que ton bug n'est pas dans ton code, il est dans tes trois codes identiques qui ont divergé sans te prévenir.


Imagine que ton adresse est écrite à la main sur 15 formulaires papier différents — ta banque, ton assurance, ton abonnement internet, ta mutuelle, etc.

Tu déménages. Tu dois retrouver les 15 formulaires et corriger chacun. Tu en oublies un — ta banque, disons — et six mois plus tard elle envoie encore ton relevé chez l'ancien locataire, qui commence à te détester.

DRY (Don't Repeat Yourself), c'est exactement ça appliqué au code : au lieu d'avoir ton adresse écrite 15 fois, tu as un seul endroit où elle est écrite, et les 15 formulaires vont la chercher là. Tu déménages une fois, tu corriges une fois, tout le monde est à jour.

Ce n'est pas une religion du "zéro duplication." C'est juste : arrête d'avoir plusieurs sources de vérité pour la même vérité.

Le code "avant" (le péché)

Voici à peu près ce que contenait mon InvoicesController.ts :

// app/controllers/invoices_controller.ts
export default class InvoicesController {
  async store({ request, response }: HttpContext) {
    const data = request.body()

    // Validation copiée-collée
    if (!data.clientName || !data.clientEmail || !data.items?.length) {
      return response.badRequest('Champs manquants')
    }

    const total = data.items.reduce(
      (sum: number, item: any) => sum + item.quantity * item.price,
      0
    )

    const invoice = await Invoice.create({ ...data, total })
    return response.created(invoice)
  }

  async update({ request, response, params }: HttpContext) {
    const data = request.body()

    // Exactement la même validation, copiée depuis store()
    if (!data.clientName || !data.clientEmail || !data.items?.length) {
      return response.badRequest('Champs manquants')
    }

    // Exactement le même calcul, copié depuis store()
    const total = data.items.reduce(
      (sum: number, item: any) => sum + item.quantity * item.price,
      0
    )

    const invoice = await Invoice.findOrFail(params.id)
    invoice.merge({ ...data, total })
    await invoice.save()
    return response.ok(invoice)
  }
}
Enter fullscreen mode Exit fullscreen mode

Et pour faire bonne mesure, InvoiceForm.vue refaisait encore ce calcul pour afficher un aperçu en temps réel :

<script setup>
const previewTotal = computed(() =>
  form.items.reduce((sum, item) => sum + item.quantity * item.price, 0)
)
</script>
Enter fullscreen mode Exit fullscreen mode

Trois copies. Une seule vérité qui aurait dû exister.

Le refactor "après" (la rédemption)

Étape 1 — le calcul du total devient une propriété du modèle, calculée une seule fois, à la source :

// app/models/invoice.ts
export default class Invoice extends BaseModel {
  @hasMany(() => InvoiceItem)
  declare items: HasMany<typeof InvoiceItem>

  get total() {
    return this.items.reduce((sum, item) => sum + item.quantity * item.price, 0)
  }
}
Enter fullscreen mode Exit fullscreen mode

Étape 2 — la validation devient un schéma VineJS partagé, écrit une fois, importé partout :

// app/validators/invoice_validator.ts
import vine from '@vinejs/vine'

const itemSchema = vine.object({
  name: vine.string(),
  quantity: vine.number().positive(),
  price: vine.number().positive(),
})

export const invoiceValidator = vine.compile(
  vine.object({
    clientName: vine.string().minLength(1),
    clientEmail: vine.string().email(),
    items: vine.array(itemSchema).minLength(1),
  })
)
Enter fullscreen mode Exit fullscreen mode

Étape 3 — le controller n'a plus qu'à appeler ces deux briques, sans jamais réécrire la logique :

// app/controllers/invoices_controller.ts
export default class InvoicesController {
  async store({ request, response }: HttpContext) {
    const data = await request.validateUsing(invoiceValidator)
    const invoice = await Invoice.create(data)
    return response.created(invoice)
  }

  async update({ request, response, params }: HttpContext) {
    const data = await request.validateUsing(invoiceValidator)
    const invoice = await Invoice.findOrFail(params.id)
    invoice.merge(data)
    await invoice.save()
    return response.ok(invoice)
  }
}
Enter fullscreen mode Exit fullscreen mode

Étape 4 — côté Vue, on arrête de recalculer, on affiche simplement ce que le backend renvoie via les props Inertia :

<script setup>
const props = defineProps<{ invoice: Invoice }>()
</script>

<template>
  <p>Total : {{ invoice.total }}</p>
</template>
Enter fullscreen mode Exit fullscreen mode

Résultat : si un jour la règle de TVA change, je la change à un seul endroit, et store, update et l'affichage Vue sont automatiquement synchronisés. Plus de courrier envoyé chez l'ancien locataire.

Le piège à éviter (petite mise en garde)

DRY ne veut pas dire "zéro duplication à tout prix." Si deux bouts de code se ressemblent aujourd'hui mais évoluent pour des raisons différentes — par exemple la validation d'une facture et la validation d'un profil client n'ont aucune raison de partager un schéma générique juste parce qu'elles valident toutes les deux des emails — les fusionner créerait plus de problèmes que ça n'en résout. On y reviendra dans la Partie 4, quand DRY se bat avec KISS.


La semaine prochaine, Partie 2 : comment j'ai transformé un simple changement de statut (draft → pending → paid) en machine à états digne de la NASA. Spoiler : c'était une mauvaise idée.

Top comments (0)