POO vs Fonctionnel — 05 — Patterns fonctionnels dans un projet OOP

Comment intégrer progressivement les patterns fonctionnels dans un code OOP existant : fonctions pures, Result type, pipelines, Value Objects, mémoïsation.

05 — Patterns fonctionnels dans un projet OOP

Ce que tu vas apprendre

  • Comment extraire des fonctions pures depuis des méthodes de classes
  • Le pattern Result type pour remplacer les exceptions silencieuses
  • Les pipelines de transformation pour remplacer les boucles imbriquées
  • Les Value Objects comme pont entre POO et FP
  • La mémoïsation pour optimiser sans état global
  • Comment intégrer ces patterns progressivement sans réécrire

Prérequis


Pattern 1 : Extraire les fonctions pures des classes

La première étape pour introduire le FP dans un code OOP existant : identifier les méthodes qui ne dépendent que de leurs arguments — et les sortir de la classe.

typescript// Avant — méthode qui ne dépend pas de this
class InvoiceFormatter {
  private currency: string = "EUR";

  format(invoice: Invoice): string {
    // Cette méthode n'utilise pas `this` — elle est une fonction pure déguisée
    const lines = invoice.items.map(item =>
      `${item.name}: ${(item.price / 100).toFixed(2)}€`
    );
    return [
      `Facture #${invoice.id}`,
      `Date : ${invoice.date.toLocaleDateString("fr-FR")}`,
      ...lines,
      `Total : ${(invoice.total / 100).toFixed(2)}€`,
    ].join("\n");
  }
}

// Après — fonction pure extraite
function formatInvoice(invoice: Invoice): string {
  const lines = invoice.items.map(item =>
    `${item.name}: ${(item.price / 100).toFixed(2)}€`
  );
  return [
    `Facture #${invoice.id}`,
    `Date : ${invoice.date.toLocaleDateString("fr-FR")}`,
    ...lines,
    `Total : ${(invoice.total / 100).toFixed(2)}€`,
  ].join("\n");
}

// Avantage immédiat : testable sans instancier InvoiceFormatter
python# Avant
class InvoiceFormatter:
    def format(self, invoice: dict) -> str:
        lines = [f"{item['name']}: {item['price'] / 100:.2f}€" for item in invoice["items"]]
        return "\n".join([
            f"Facture #{invoice['id']}",
            f"Date : {invoice['date'].strftime('%d/%m/%Y')}",
            *lines,
            f"Total : {invoice['total'] / 100:.2f}€",
        ])

# Après — fonction pure
def format_invoice(invoice: dict) -> str:
    lines = [f"{item['name']}: {item['price'] / 100:.2f}€" for item in invoice["items"]]
    return "\n".join([
        f"Facture #{invoice['id']}",
        f"Date : {invoice['date'].strftime('%d/%m/%Y')}",
        *lines,
        f"Total : {invoice['total'] / 100:.2f}€",
    ])

Règle de détection : si une méthode de classe n'accède jamais à this (ou self) — ou seulement à des attributs qui sont des constantes — c'est une fonction pure qui attend d'être extraite.


Pattern 2 : Result type pour les erreurs prévisibles

Les exceptions sont adaptées aux erreurs inattendues (erreurs de programmation, pannes système). Pour les erreurs métier prévisibles (validation, not found, business rule), le Result type est plus explicite.

typescript// Avant — exceptions pour tout
class UserService {
  async findUser(email: string): Promise<User> {
    if (!email.includes("@")) {
      throw new Error("Email invalide"); // Erreur prévisible — pas une exception
    }
    const user = await this.userRepo.findByEmail(email);
    if (!user) {
      throw new Error("Utilisateur introuvable"); // Prévisible — pas une exception
    }
    return user;
  }
}

// L'appelant ne sait pas quelles erreurs peuvent survenir sans lire l'implémentation
try {
  const user = await service.findUser(email);
} catch (e) {
  // Qu'est-ce qui peut lancer ici ? Validation ? Not found ? Réseau ?
}
typescript// Après — Result type pour les erreurs métier
type UserError =
  | "invalid_email"
  | "user_not_found"
  | "email_taken";

type Result<T, E = string> =
  | { ok: true; value: T }
  | { ok: false; error: E };

class UserService {
  async findUser(email: string): Promise<Result<User, UserError>> {
    if (!email.includes("@")) {
      return { ok: false, error: "invalid_email" };
    }
    const user = await this.userRepo.findByEmail(email);
    if (!user) {
      return { ok: false, error: "user_not_found" };
    }
    return { ok: true, value: user };
  }
}

// L'appelant sait exactement ce qui peut échouer
const result = await service.findUser(email);
if (!result.ok) {
  switch (result.error) {
    case "invalid_email": return ctx.json({ error: "Email invalide" }, 400);
    case "user_not_found": return ctx.json({ error: "Introuvable" }, 404);
  }
}
const user = result.value; // TypeScript sait que user est de type User ici

En Python :

pythonfrom typing import Union, Literal
from dataclasses import dataclass

@dataclass
class Ok:
    value: object

@dataclass
class Err:
    error: str

Result = Union[Ok, Err]

class UserService:
    async def find_user(self, email: str) -> Result:
        if "@" not in email:
            return Err("invalid_email")
        user = await self._repo.find_by_email(email)
        if not user:
            return Err("user_not_found")
        return Ok(user)

# Usage
result = await service.find_user(email)
match result:
    case Ok(value=user):
        print(f"Trouvé : {user.name}")
    case Err(error="invalid_email"):
        print("Email invalide")
    case Err(error="user_not_found"):
        print("Introuvable")

Les exceptions devraient être exceptionnelles.

Une recherche qui ne trouve rien n'est pas une exception — c'est un cas prévu. Le Result type le rend visible dans le type de retour.


Pattern 3 : Pipelines de transformation

Les boucles imbriquées avec des mutations de variables locales sont difficiles à lire et à tester. Les pipelines de transformation les remplacent par des chaînes d'opérations composables.

typescript// Avant — boucle impérative avec mutations
function getTopSpenders(
  orders: Order[],
  minAmount: number,
  limit: number
): CustomerSummary[] {
  const customerTotals: Record<string, number> = {};

  for (const order of orders) {
    if (order.status !== "delivered") continue;

    const existing = customerTotals[order.customerId] ?? 0;
    customerTotals[order.customerId] = existing + order.total;
  }

  const summaries: CustomerSummary[] = [];
  for (const [customerId, total] of Object.entries(customerTotals)) {
    if (total >= minAmount) {
      summaries.push({ customerId, total });
    }
  }

  summaries.sort((a, b) => b.total - a.total);
  return summaries.slice(0, limit);
}
typescript// Après — pipeline fonctionnel
function getTopSpenders(
  orders: Order[],
  minAmount: number,
  limit: number
): CustomerSummary[] {
  return orders
    .filter(o => o.status === "delivered")
    .reduce((acc, o) => {
      acc[o.customerId] = (acc[o.customerId] ?? 0) + o.total;
      return acc;
    }, {} as Record<string, number>)
    |> Object.entries(%)           // Pipeline operator (stage 2 TC39)
    .map(([customerId, total]) => ({ customerId, total }))
    .filter(s => s.total >= minAmount)
    .sort((a, b) => b.total - a.total)
    .slice(0, limit);
}

// Sans pipeline operator (TypeScript actuel)
function getTopSpenders(orders: Order[], minAmount: number, limit: number): CustomerSummary[] {
  const totals = orders
    .filter(o => o.status === "delivered")
    .reduce((acc, o) => {
      acc[o.customerId] = (acc[o.customerId] ?? 0) + o.total;
      return acc;
    }, {} as Record<string, number>);

  return Object.entries(totals)
    .map(([customerId, total]) => ({ customerId, total }))
    .filter(s => s.total >= minAmount)
    .sort((a, b) => b.total - a.total)
    .slice(0, limit);
}

En Python :

pythonfrom collections import defaultdict
from itertools import islice

def get_top_spenders(orders: list, min_amount: int, limit: int) -> list:
    # Accumuler les totaux
    totals = defaultdict(int)
    for order in filter(lambda o: o["status"] == "delivered", orders):
        totals[order["customer_id"]] += order["total"]

    # Transformer, filtrer, trier, tronquer
    return sorted(
        [
            {"customer_id": cid, "total": total}
            for cid, total in totals.items()
            if total >= min_amount
        ],
        key=lambda s: s["total"],
        reverse=True,
    )[:limit]

Pattern 4 : Value Objects comme pont entre POO et FP

Les Value Objects combinent les deux paradigmes : ce sont des objets (POO) qui sont immuables et égaux par valeur (FP).

typescript// Un Money est un objet OOP...
class Money {
  private constructor(readonly amount: number, readonly currency: string) {}

  static create(amount: number, currency: string): Money {
    if (amount < 0) throw new Error("Montant négatif");
    return new Money(amount, currency);
  }

  // ...mais ses opérations sont fonctionnellement pures
  add(other: Money): Money {
    if (this.currency !== other.currency) throw new Error("Devises incompatibles");
    return Money.create(this.amount + other.amount, this.currency);
  }

  scale(factor: number): Money {
    return Money.create(Math.round(this.amount * factor), this.currency);
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }
}

// Usage fonctionnel — pipeline sur des Money
const orderAmounts: Money[] = items.map(i => Money.create(i.price, "EUR"));
const total = orderAmounts.reduce(
  (acc, m) => acc.add(m),
  Money.create(0, "EUR")
);
const withTax = total.scale(1.2); // TVA 20% — nouveau Money, total inchangé

Pattern 5 : Mémoïsation pour les fonctions pures coûteuses

La mémoïsation est une technique FP qui met en cache le résultat d'une fonction pure selon ses arguments. Puisque la fonction est pure (même input → même output), le cache est toujours valide.

typescriptfunction memoize<T extends unknown[], R>(fn: (...args: T) => R): (...args: T) => R {
  const cache = new Map<string, R>();

  return (...args: T): R => {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key)!;

    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

// Calcul coûteux — appelé avec les mêmes paramètres souvent
function computePricing(productId: string, quantity: number, discount: number): number {
  // Requête pricing engine, calculs complexes...
  return Math.round(BASE_PRICES[productId] * quantity * (1 - discount));
}

const memoizedPricing = memoize(computePricing);

// Premier appel — calcule
memoizedPricing("prod-1", 10, 0.1); // 900

// Deuxième appel avec les mêmes args — retourne le cache instantanément
memoizedPricing("prod-1", 10, 0.1); // 900 (depuis le cache)

En Python avec functools.lru_cache :

pythonfrom functools import lru_cache

@lru_cache(maxsize=256)
def compute_pricing(product_id: str, quantity: int, discount: float) -> int:
    # Calcul coûteux
    base_prices = {"prod-1": 100, "prod-2": 200}
    return round(base_prices[product_id] * quantity * (1 - discount))

# Premier appel — calcule et met en cache
compute_pricing("prod-1", 10, 0.1)  # 900

# Deuxième appel — retourne le cache
compute_pricing("prod-1", 10, 0.1)  # 900 (cache hit)

# Voir les stats du cache
print(compute_pricing.cache_info())
# CacheInfo(hits=1, misses=1, maxsize=256, currsize=1)

Condition d'application : la mémoïsation ne fonctionne que sur des fonctions pures. Une fonction qui dépend d'un état externe ne peut pas être mémoïsée correctement — le cache retournerait une valeur obsolète.


Stratégie d'introduction progressive

Voici l'ordre recommandé pour introduire ces patterns dans une codebase existante :

Étape 1 Étape 2 Étape 3
Extraire fonctions pures Result type pour erreurs métier Pipelines + Value Objects

Étape 1 — Extraire les fonctions pures : repérer les méthodes qui n'utilisent pas this/self, les sortir en fonctions libres. Impact minimal, gain immédiat en testabilité.

Étape 2 — Result type : remplacer les throw sur les erreurs métier prévisibles par des Result types. Commence par un seul service pour tester l'adoption dans l'équipe.

Étape 3 — Pipelines et Value Objects : remplacer les boucles mutables par des pipelines filter/map/reduce. Introduire des Value Objects pour les concepts métier qui ont des règles de validation.


Résumé de la série

Article Ce qu'il apporte
00 — Introduction Histoire et philosophie des deux paradigmes
01 — Piliers POO Encapsulation, héritage, polymorphisme — forces et limites
02 — Piliers FP Pureté, immutabilité, composition, HOF, currying
03 — Comparaison Le même problème, deux solutions côte à côte
04 — Hybride TypeScript et Python — les deux en même temps
05 — Patterns pratiques Introduire le FP dans un projet OOP existant

La programmation fonctionnelle et la POO ne sont pas des religions. Ce sont des outils. Les projets qui combinent les deux de façon cohérente — POO pour le domaine, FP pour les transformations — produisent du code plus lisible, plus testable et plus facile à faire évoluer.


Sources

  • Wlaschin, S. (2018). Domain Modeling Made Functional. Pragmatic Bookshelf.
  • Fowler, M. (2018). Refactoring: Improving the Design of Existing Code (2nd ed.). Addison-Wesley.
  • Martin, R. C. (2017). Clean Architecture. Prentice Hall.
  • Meijer, E. (2011). Reactive Extensions (Rx). Microsoft Research. (pour les patterns de pipelines)
  • van Laarhoven, T. (2009). CPS based functional references. (sur la composition de lenses — lecture avancée)
  • Python Software Foundation. functools — Higher-order functions and operations on callable objects. docs.python.org.
  • Microsoft. TypeScript Handbook — Utility Types. typescriptlang.org.

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.