My App

Factorisation

Quand DRY, quand duplication volontaire, YAGNI, principe "rule of three"

Trois similaires est mieux qu'une mauvaise abstraction. La duplication est moins coûteuse que la mauvaise factorisation. On abstrait après avoir vu le pattern, pas avant.

Le principe "rule of three"

  1. Première occurrence : écris-la.
  2. Deuxième occurrence : copie-colle-adapte. Prends note.
  3. Troisième occurrence : c'est maintenant que tu extrais un helper / une abstraction.

Abstraire à la deuxième occurrence, c'est souvent abstraire sur la mauvaise dimension — on n'a pas encore vu les vraies différences.

Quand factoriser (oui)

1. Trois copies quasi identiques

// 3 fois dans le code :
await db.update(guest).set({ ... }).where(and(eq(guest.id, id), eq(guest.organizationId, orgId)));

→ Extraire updateGuestByOrg(db, opts) dans cardex.repository.ts.

2. Logique métier répétée dans plusieurs services

// Dans cardex.service ET booking.service ET chat.service :
if (user.role !== "staff" && user.organizationId !== targetOrgId) throw new ForbiddenError();

→ Extraire dans un middleware Elysia ou une fonction assertSameOrg(user, targetOrgId) dans lib/.

3. Calcul métier critique

// Répété partout :
const serviceFee = Math.round(subtotal * 0.01 * 100) / 100;
const total = subtotal + serviceFee;

computeOrderTotals(items) dans domain/pricing.ts, testé unitairement.

4. State machine

Cf. ADR-05 : chaque machine a son fichier dédié domain/*.machine.ts. Même pour une seule utilisation actuelle, si c'est un domaine à transitions, on crée la machine dès le départ.

Quand NE PAS factoriser

1. Deux occurrences seulement

Attends la troisième. Tu ne vois pas encore les vraies différences.

2. Des parties similaires mais avec des responsabilités différentes

// Service cardex : envoie un check-in email
await sendMail({ to: guest.email, template: "checkIn", data: {...} });

// Service auth : envoie un magic link
await sendMail({ to: user.email, template: "magicLink", data: {...} });

Ne factorise pas en sendEmailForUser(user, template, data) — ce sont deux contextes différents qui peuvent diverger. Le helper commun c'est juste sendMail, déjà existant.

3. Des modèles "similaires" qui pourraient diverger

restaurant_booking, room_service_order, laundry_order, spa_booking

4 tables avec des colonnes communes (status, payment_status, total_amount). Tentant de faire une table abstraite booking avec un champ type.

Non. Chaque type a ses colonnes spécifiques (items JSONB pour room-service, date+time+peopleCount pour restaurant, therapistId pour spa). Abstraire = forcer des NULL partout, ajouter des contraintes conditionnelles complexes, et quand un type diverge vraiment on doit défaire l'abstraction.

Duplication assumée. Chaque state machine est son propre fichier. Chaque service est son propre fichier. Coût : 4 × 150 lignes au lieu de 1 × 300. Gain : clarté, pas de piège conditionnel.

4. Les composants UI de 5 lignes

// N'abstrais pas ça :
<div className="flex items-center gap-2">
  <Icon /> <span>{label}</span>
</div>

Sauf si répété 5+ fois avec les mêmes className exactes. Sinon, c'est pas une abstraction, c'est du bruit.

Anti-patterns à éviter

1. Le "Kitchen sink"

Un fichier lib/utils.ts ou helpers.ts qui contient 40 fonctions sans lien.

Au lieu de ça : un fichier par responsabilité :

packages/api/src/lib/
├── hash-email.ts
├── format-currency.ts
├── slugify.ts
├── redis.ts
├── errors.ts
└── pagination.ts

Chaque fichier fait une chose, a un nom qui dit ce qu'il fait, et peut être importé individuellement.

2. L'abstraction "fourre-tout"

// ❌
function doActionForEntity(
  entityType: "guest" | "room" | "booking",
  action: "create" | "update" | "delete",
  data: Record<string, unknown>,
) {
  // ...
}

C'est un mauvais abstraction. Chaque entité a ses contraintes, ses validations, ses side-effects. On ne gagne rien à fusionner.

createGuest(), updateGuest(), createRoom(), etc. Un service par module.

3. Le pré-générique

// ❌
function processItem<T extends BaseItem>(item: T, options: ProcessOptions): Result<T> { ... }

Si c'est utilisé une seule fois avec un type concret, pas besoin du générique. On ajoute le générique quand on a le 2e call site avec un type différent.

4. Le wrapper inutile

// ❌
export function myFetch(url: string) {
  return fetch(url);
}

// ❌
export const logInfo = (msg: string) => logger.info(msg);

Passe directement par fetch ou logger.info. Les wrappers qui n'ajoutent rien au comportement ajoutent du bruit.

Exception : si on prévoit d'ajouter un comportement (retry, trace, auth) — mais alors on l'ajoute maintenant, pas "pour plus tard".

Checklist avant d'abstraire

Avant d'écrire un helper / fonction / classe abstraite, répondre :

  • Est-ce qu'il y a au moins 3 call sites ?
  • Est-ce qu'ils ont vraiment la même responsabilité (pas juste la même forme) ?
  • Est-ce que je peux tester l'abstraction indépendamment (pas juste à travers les call sites) ?
  • Est-ce que le nom est évident pour un nouveau dev ? Si je galère à nommer, c'est que le concept n'est pas clair.
  • Est-ce que l'abstraction simplifie les call sites, ou les complique (params supplémentaires, options object, etc.) ?

Si un des 5 est non, attends.

Si tu te trompes

C'est plus facile de factoriser du code dupliqué que de défaire une mauvaise abstraction. La mauvaise abstraction contamine 10 call sites qui dépendent de sa forme actuelle — chaque changement casse tout.

La duplication, elle, se modifie indépendamment. Si un call site a besoin de diverger, il diverge. Si on veut plus tard factoriser, on compare les 3 copies et on extrait le VRAI commun.

Exemples concrets Bell

Bon : les 8 machines d'état

Chaque machine (check-in, room-service, laundry, spa, restaurant, ticket, reservation, room-status) est un fichier dédié avec son propre type de transitions. On ne factorise pas dans domain/state-machine.ts générique.

  • Pourquoi pas : chaque machine a ses propres états, ses guards, ses side effects. Abstraire = générique avec Record<string, string[]> sans contraintes de types, on perd la sécurité.
  • Coût : ~50 lignes par fichier × 8 = 400 lignes.
  • Bénéfice : types inférés stricts, lisibilité max.

Bon : runAdapterContractTests helper

Tous les adapters PMS partagent l'interface IntegrationAdapter. On extrait un helper qui valide cette interface pour n'importe quel adapter.

  • Pourquoi : le contrat est stable (c'est l'interface elle-même), chaque adapter le teste de la même façon.
  • Bénéfice : 1 helper = N adapters testés gratuitement.

Mauvais : abstraire les 4 createXxxBooking

Tentant parce que similaires. Mais chaque service a :

  • Des schemas TypeBox différents
  • Des calculs de prix spécifiques (delivery fee pour room-service, duration pour spa, etc.)
  • Des bridges PMS spécifiques (room-service → order items, spa → spa booking dedicated endpoint)
  • Des state machines distinctes

On duplique. 4 fichiers × ~200 lignes. Clair. Maintenable.

Bon : pagination.ts helper

packages/api/src/lib/pagination.ts
export function parsePagination(query: { limit?: number; cursor?: string }) {
  return {
    limit: Math.min(query.limit ?? 20, 100),
    cursor: query.cursor ?? null,
  };
}

export function encodeCursor(value: Date): string { /* ... */ }
export function decodeCursor(cursor: string): Date { /* ... */ }

Utilisé dans 6+ endpoints (cardex, rooms, bookings, chats, tickets, analytics). Abstraction OK dès le départ car c'est un pattern technique universel.

Lien

On this page