Injection de dépendances et programmation fonctionnelle

Je dois vous avouer que je voue une passion secrète à JavaScript. Je trouve ce langage puissant, facile à prendre en main et très flexible. Cependant, certaines de ses fonctionnalités peuvent paraître compliquées voire inutiles.

Grand seigneur incompris parmi les seigneurs incompris est une fonctionnalité très utile au langage mais ô combien sous-utilisée et sous-estimée, je veux parler des closures (ou “fermeture” en bon françois).

Closures, kézako ?

Très simplement, une closure est une fonction retournée par une autre une fonction et profitant de l’environnement d’exécution de cette dernière.

Ca ressemble à ça :

function sayHelloTo(fullName) {
  return function() {
    console.log(`Hello ${fullname}!`)
  }
}

Et ça s’utilise comme ça :

const sayHelloToRichardStallman = sayHelloTo("Richard Stallman")
sayHelloToRichardStallman() // Log "Hello Richard Stallman!"

But… Why?!!

Une histoire d’états…

Utiliser une closure est une manière de conserver un état lors de l’exécution d’une fonction sans pour autant passer par une variable globale ou une instance de classe. Avec du code, ça donne ça :

Variable globale :

let fullname = null
function sayHello() {
  console.log(`Hello ${fullname}!`)
}

fullName = "Richard Stallman"
sayHello() // Log "Hello Richard Stallman!"

Instance de classe :

class HelloSayer {
  constructor(fullname) {
    this.fullname = fullname
  }

  sayHello() {
    console.log(`Hello ${this.fullname}!`)
  }
}

const sayHelloToRichardStallman = new HelloSayer("Richard Stallman")
sayHelloToRicharStallman.sayHello() // Log "Hello Richard Stallman!"

Closure :

function sayHelloTo(fullName) {
  return function() {
    console.log(`Hello ${fullname}!`)
  }
}

const sayHelloToRichardStallman = sayHelloTo("Richard Stallman")
sayHelloToRichardStallman() // Log "Hello Richard Stallman!"

Cela permet donc de procéder à une phase de “configuration” puis d’utiliser la fonction ainsi configurée lors d’une phase “d’exécution” ! Vous allez me dire : “Mais il y a la programmation orientée objet pour ça !”. Oui mais pas seulement, et on va voir pourquoi :)

… Et de programmation fonctionnelle

La programmation fonctionnelle est un paradigme connu des développeurs depuis longtemps mais revenu à la mode ces dernières années. Sans rentrer dans les détails, la programmation fonctionnelle propose de respecter les principes suivants :

  • des états immuables (immutables) ;
  • minimiser les états partagés.
  • des fonctions citoyens de premier ordre (first-class citizen);
  • des fonctions pures :
    • pas d’effet de bord ;
    • la sortie ne dépend que des entrées ;

Il est ainsi aisé de lire, tester et maintenir une fonction s’inscrivant dans ce paradigme puisqu’on comprend son contexte d’exécution rien qu’à la lecture de sa signature.

La programmation fonctionnelle n’est cependant pas une baguette magique qui va résoudre tous nos soucis. Si elle sait très bien réduire la complexité locale de nos fonctions, ces dernières s’inscrivent dans des applications interagissant avec le monde extérieur (par le biais de requêtes HTTP, de bases de données, etc.).

Un tel contexte nécessite donc de fournir à nos fonctions des contrôleurs, services, schémas, repositories, etc. Bref, les abstractions nécessaires au bon fonctionnement de ces fonctions pures. Se pose alors la question de savoir comment injecter des dépendances, c’est-à-dire nos abstractions, quand on privilégie le paradigme fonctionnel ?

Vous avez dit “injection de dépendances” ?

Le principe d’injection de dépendances est un concept inhérent à toute application correctement conçue. Il incite à découpler notre code en abstractions et n’utiliser que l’interface de ces abstractions, c’est-à-dire la signature des fonctions, sans avoir à s’intéresser à la manière dont celles-ci sont implémentées.

En paradigme orienté objet, il existe plusieurs manières d’injecter des dépendances, pour n’en citer que deux :

Par constructeur :

class ProductService {
  constructor(productRepository) {
    this.productRepository = productRepository
  }

  function getProductById(id) {
    return this.productRepository.findById(id)
  }
}

Par setter :

class ProductService {
  setProductRepository(productRepository) {
    this.productRepository = productRepository
  }

  getProductById(id) {
    return this.productRepository.findById(id)
  }
}

Traduite dans le paradigme fonctionnel où l’utilisation de fonctions pures est reine, l’injection de dépendances n’a qu’un seul chemin possible : les arguments. Ce qui donne :


function getProductById(productRepository, id) {
  return productRepository.findById(id)
}

Et c’est là que le bât blesse. En effet, si l’injection de dépendances est possible, elle n’est néanmoins pas élégante car se mêlent au sein de la même signature des arguments de niveaux d’abstraction hétérogènes :

  • des arguments que j’appellerai structurels : productRepository ;
  • des arguments que j’appellerai dynamiques : id.

On pourrait très bien imaginer une fonction s’appuyant sur 3 arguments structurels et 3 arguments dynamiques, soit une fonction à 6 arguments. Cela compliquerait terriblement la lecture et la compréhension de ce que fait la fonction et de ce dont elle a besoin pour s’exécuter.

function doSomething(repositoryA, repositoryB, domainA, domainB, id, data) {
  // ...
}

Outre la problématique de lisibilité de la signature d’une unique fonction, se pose aussi la question de la lisibilité d’un module de fonctions. Ainsi, si un module expose N fonctions, la plupart dépendant d’arguments structurels, alors la signature publique de ces fonctions devra faire mention de celles-ci. C’est-à-dire dupliquer des informations dans chaque signature…

const productModule = {
  create(productRepository, logService, data) {
    const id = productRepository.add(data)
    logService.info(`Created product ${id}`)
  },

  update(productRepository, logService, id, data) {
    productRepository.update(id, data)
    logService.info(`Updated product ${id}`)
  },

  remove(productRepository, logService, id) {
    productRepository.remove(id)
    logService.info(`Removed product ${id}`)
  }
}

Comment distinguer la nature de chacune des dépendances ainsi passées de sorte de clarifier la lecture de nos signatures ? Facile, c’est une question de cycle de vie !

Arguments structurels : un cycle de vie long

Un argument structurel est une dépendance qui n’a besoin d’être associée qu’une seule fois (souvent au démarrage de l’application). Ainsi une fois cet état enregistré, il n’a plus besoin d’être de nouveau passé puisque l’état est préservé.

Le cycle de vie d’un argument structurel est donc très long : il naît à la naissance du processus et meurt avec ce dernier.

Arguments dynamiques : un cycle de vie court

Un argument dynamique est une dépendance dont le cycle de vie est bien plus court. Il a besoin d’être associé de nombreuses fois lors du cycle de vie de l’application. Dans l’exemple proposé ci-dessus, cette association se fera à chaque sollicitation de la fonction “getProductById”.

Le cycle de vie d’un argument dynamique est donc très court : il naît et meurt selon une règle opérationnelle (exemple : si la référence à “productRepository” ne change pas entre chaque appel à “remove”, l’argument “id” lui sera très probablement).

C’est en partant du constat de l’existence de deux cycles de vie différents que les closures nous permettent d’écrire un code plus clair et mieux structuré.

Deux cycles de vie, une closure

Les closures, vous vous souvenez ? Ces fonctions invocables en deux temps : une phase de “configuration” et une phase “d’exécution”, qu’on pourrait aussi appeler phase “structurelle” et phase “dynamique” !

Ainsi, si on implémente le principe d’injection de dépendances via des closures, on aboutit au code suivant :

function ProductService(productRepository, logger) {
  return {
    create(data) {
      const id = productRepository.add(data)
      logger.info(`Created product ${id}`)
    },

    update(id, data) {
      productRepository.update(id, data)
      logger.info(`Updated product ${id}`)
    },

    remove(id) {
      productRepository.remove(id)
      logger.info(`Removed product ${id}`)
    }
  }
}

Qui peut être utilisé plus tard de la manière suivante :

const db = require("./db");
const logger = require("./logger");
const productRepository = require("./repository")(db);
const productService = require("./services")(productRepository, logger)

// Somewhere else in the code...

productService.create(someData)

Et en voilà du JavaScript qu’il est beau ! Un exemple grandeur nature est disponible sur le GitHub de la tribu WOAPI.

Et l’instruction “require” dans tout ça ?

En tant que développeur Node.js, je pourrais être tenté de ne pas utiliser les closures au profit de l’instruction require, et ce en vue d’injecter les arguments structurels. C’est une pratique extrêmement répandue mais que je déconseille (en ce qui concerne les arguments structurels tout du moins).

En effet, importer un module via require crée un couplage fort entre module appelant et dépendance structurelle. Il sera difficile de tester un comportement du module A sans que ne soit exécutées des instructions du module B importé via require. Cela contrevient à la pratique des tests unitaires, souvent exécutés en isolation de leur contexte.

Il existe certes des paquets npm permettant de mocker les dépendances injectées via require comme proxyquire mais cela complique inutilement l’écriture des tests quand on sait qu’une solution native à JavaScript est disponible.

Les closures permettent donc de récupérer la maîtrise de l’injection des dépendances structurelles, notamment dans le code de test.

Takeaways

On a pu voir qu’il existe deux types d’arguments : structurels et dynamiques. Cette dichotomie implique deux responsabilités distinctes qu’il convient donc de traduire dans le code afin d’aboutir à une structure plus claire. Les marqueurs de cette distinction sont identifiables d’après le moment où la référence à ces arguments est modifiée :

  • souvent : le cycle de vie est court, c’est donc un argument dynamique ;
  • rarement : le cycle de vie est long, c’est donc un argument structurel.

Dès lors, l’utilisation d’une closure nous permet de distinguer ces deux états. On peut ainsi respecter les principes du paradigme de programmation fonctionnelle tout en assouplissant notre design via l’injection de dépendances, le meilleur des deux mondes ! En outre, cela nous permet de reprendre le contrôle de nos dépendances à l’exécution des tests et de mocker plus facilement (quand cela est nécessaire) les interfaces qui nécessitent de l’être.

Et vous, quand est-ce que vous reprenez le contrôle de vos dépendances ?

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *


Ce formulaire est protégé par Google Recaptcha