Comment Construire Une API Avec Node, Sans Framework Additionnel (SQL Inclus)

Comment Construire Une API Avec Node, Sans Framework Additionnel (SQL Inclus)

25 juillet 2025 | 20 mins de lecture

Publié sur dev.to et medium.com

Il ne s'agit pas seulement d'un article... Ceci est un guide pratique !

Introduction

À chaque projet technique sa structure de fichiers. Qu'il s'agisse d'une structure traditionnelle MVC, d'une organisation par composants ou pages, par fonctionnalités, ou encore d'une architecture hexagonale ("Ports/Adapters" pour les intimes 😉), beaucoup de possibilités sont envisageables avant même d'avoir commencé à coder.

"On structure notre projet comme le précédent. Copier/Coller : ça marche !"

Souvent considéré comme secondaire profit d'une application qui s'exécute rapidement (et sans erreur), l'architecture choisie a pourtant une place capitale sur le projet, pour assurer la pérennité des développements actuels et à venir...

À l'image des stratégies de gestion de projets qui ont évolué avec le temps (je pense notamment à l'Agilité et ses principes), les organisations de projet se sont démocratisées. Le classique MVC à évolué vers le MV-VM (Model-View/View-Model) pour laisser sa place au MVW ("W" pour "Whatever")... Quoique, depuis quelques années, on entend surtout parler d'architectures "Clean" notamment avec les modèles "Ports/Adapters" et "Onion" (organisation par couches concentriques).

Bien plus qu'un simple "rangement" de composants, de classes, de fonctions ou de modules, dans le dossier associé, il s'agit de rendre la partie métier de votre application indépendante des concepts techniques (la couche d'accès aux données, la configuration HTTP, le routage de l'application, etc.).

NB : Je vous conseille vraiment de vous poser la question de l'architecture, ne serait-ce que pour avoir un horizon sur la structure de fichiers à utiliser lors de vos développements, afin d'éviter une importante refactorisation de votre projet à 90% de complétion 😅

C'est grâce aux stratégies de développements telles que le TDD (Test Driven Development), le DDD (Domain Driven Development) ou encore le BDD (Behavior Driven Development) que les principes SOLID ont vu le jour. En effet, ces méthodes ont mis en lumière les limites des approches traditionnelles et ont favorisé l’adoption de principes tels que SOLID, qui sont aujourd’hui à la base des architectures modernes.

Si j'évoque tout ceci en avant-propos, c'est pour justifier l'orientation technique de mon projet. Dans la suite de cet article ce guide, on va effectivement parler de "comment construire une API avec Node, sans framework additionnel", mais de manière SOLID (et aussi avec quelques principes de POO).

NB : Bien que j'essaie d'appliquer au mieux les principes SOLID, il m'arrive parfois de réfléchir trop longtemps à l'atteinte des ces objectifs : responsabilité unique du composant, réusabilité des propriétés, inversion de contrôle (et injection de dépendances), etc... Dans ces cas-là, je me rappelle que "le mieux est l'ennemie du bien", puis... Je commit ! Peut-être reviendrai-je sur mon développement plus tard (ou peut-être pas).

Hello World 👋

Entrons dans le vif du sujet, à savoir, comment exposer une API REST de gestion d'utilisateurs depuis Node, sans librairie complémentaire.

J'insiste sur cet aspect-là, car il est plutôt simple de créer une application Backend avec Express ou Fastify. D'ailleurs, ces librairies sont plutôt efficaces pour manipuler les requêtes et les réponses HTTP, souvent en simplifiant la sérialisation JSON, la gestion des statuts HTTP, ou le routage REST.

Ci-dessous, on va découvrir (ou redécouvrir) qu'il est possible de faire cela uniquement à partir du module node:http.

import { createServer, IncomingMessage, Server, ServerResponse } from 'node:http';

type ApplicationRequest = IncomingMessage;
type ApplicationResponse = ServerResponse<IncomingMessage> & {
  req: IncomingMessage;
};

interface ApplicationRouter {
  listener: (request: ApplicationRequest, response: ApplicationResponse) => Promise<void>;
}

class HttpProvider {
  constructor(private server: Server) {}

  static handleListeners = (...routers: ApplicationRouter[]) => {
    return async (request: ApplicationRequest, response: ApplicationResponse) => {
      for (const router of routers) {
        await router.listener(request, response);
      }

      try {
        response.setHeader('Content-Type', 'application/json');
        response.statusCode = 400;
        response.end(JSON.stringify({ message: "Cannot Get '/'" }));
      } catch {
        console.log('Ignored Default Behavior');
      }
    };
  };

  start(port: number) {
    this.server.listen(port, () => {
      console.log(`Server Is Running On Port ${port}`);
    });
  }

  stop() {
    this.server.close(() => {
      console.log('Server Is Shutting Down...');
    });
  }
}

const requestHandler = HttpProvider.handleListeners();

const server = createServer(requestHandler);
const app = new HttpProvider(server);

app.start(8080);

Voici une 1ère version de la classe responsable du démarrage de l'application. Son périmètre se restreint à gérer le service (start() / stop()) ainsi que d'écouter les futures requêtes entrantes fournies par les classes de routage. D'ailleurs, avec la fonction handleListeners(), j'anticipe le routage à venir, notamment pour l'implémentation du CRUD de gestion d'utilisateurs.

Le fait de centraliser les Routers depuis cette fonction permet de chaîner plusieurs gestionnaires de routes de manière modulaire, tout en gardant la logique de réponse dans un unique point d’entrée. Par défaut, la fonction terminera son exécution avec une réponse ayant un code de statut équivalent à Bad Request, et le message suivant : Cannot Get "/". * Ce mécanisme n'est pas sans rappeler le comportement d’un middleware "dispatcher", où chaque gestionnaire de route est exécuté à son tour jusqu’à ce qu’une réponse soit envoyée.

import { ApplicationRequest } from '../providers/ApplicationRequest';
import { ApplicationResponse } from '../providers/ApplicationResponse';
import { ApplicationRouter } from '../routers/ApplicationRouter';

class HelloController {
  greeting(_request: ApplicationRequest, response: ApplicationResponse) {
    response.setHeader('Content-Type', 'application/json');
    response.statusCode = 200;
    response.end(JSON.stringify({ message: 'Hi!' }));
    return;
  }
}

class HelloRouter implements ApplicationRouter {
  static PATHNAME = '/api/hello';

  constructor(private helloController: HelloController) {}

  async listener(request: ApplicationRequest, response: ApplicationResponse) {
    const baseUrl = `http://${request.headers.host}`;
    const url = new URL(request.url, baseUrl);

    const strictPathName = new RegExp(`^${HelloRouter.PATHNAME}$`);

    if (request.method === 'GET' && strictPathName.test(url.pathname)) {
      return this.helloController.greeting(request, response);
    }
  }
}

Ci-dessus, deux nouvelles classes venant compléter l'initialisation de l'application. Il s'agit respectivement d'un contrôleur ainsi que du routage associé au endpoint /api/hello. HelloRouter encapsule HelloController (par injection de dépendance), puis appelle la méthode greeting du contrôleur lors de la réception d'une requête GET.

import { createServer } from 'node:http';

import HelloController from './infrastructure/controllers/HelloController';
import HttpProvider from './infrastructure/providers/HttpProvider';
import HelloRouter from './infrastructure/routes/HelloRouter';

const helloController = new HelloController();
const helloRouter = new HelloRouter(helloController);

const requestHandler = HttpProvider.handleListeners(helloRouter);

const server = createServer(requestHandler);
const app = new HttpProvider(server);

app.start(8080);

En instanciant le contrôleur et en définissant le routage, on peut fournir un contexte à la fonction requestHandler qui sera ensuite traité par le serveur. Ainsi, lors de la réception d'une requête HTTP, le serveur Node contrôlera les informations de la requête (le chemin et la méthode REST invoquée) avant d'atterrir dans le cas d'usage par défaut. *

Cette conception me permet de découper finement par fonctionnalité, plutôt que d'avoir un routage global à responsabilités multiples...

Avant d'en finir avec cette 1ère étape, mettons en place une architecture "Clean" (d'après le modèle popularisé par Robert C. Martin) en structurant notre application comme ci-dessus. De cette façon, les classes sont séparées les unes des autres à la manière d'un fichier === une responsabilité.

src/
├── core/
├── infrastructure/
│   ├── controllers/
│   │   └── HelloController.ts
│   ├── providers/
│   │   ├── ApplicationRequest.ts
│   │   ├── ApplicationResponse.ts
│   │   └── HttpProvider.ts
│   └── routes/
│       ├── ApplicationRouter.ts
│       └── HelloRouter.ts
└── index.ts

Dans cet exercice, le conteneur "IoC" est représenté par le fichier src/index.ts. En tant que point d'entrée de l'application, il a pour rôle d'instancier les différents composants et d'appliquer l'inversion de contrôle.

Le Core 🎯

C'est ici que tout commence vraiment / que votre application prend vie !

Le Core regroupe les classes et les algorithmes liés à la logique métier. Globalement, que vous ayez opté pour une architecture hexagonale, ou par couches concentriques, ou tout simplement dérivée des deux autres modèles, vous y retrouverez :

  • Les entités du domaine métier (ou POJO - Plain Old JavaScript Object, pour les nostalgiques du Java ) ;
  • Les services associés à ces classes (c'est-à-dire les algorithmes de création, de récupération de données, ou encore de mise à jour) ;
  • Les interfaces qui définissent comment les serveurs extérieurs (les sources de données, les contrôleurs, etc.) doivent se compter pour que le Core puisse interagir avec eux (sans pour autant les connaître explicitement).

Dans cette seconde partie, on passe aux choses sérieuses en initialisant les premiers éléments du CRUD de gestion d'utilisateurs :

  • La classe métier User ;
  • Le service Les cas d'usage ** pour la création et la récupération d'utilisateurs ;
  • Les interfaces pour l'inversion de contrôle.

** À propos des cas d'usage...

Certaines implémentations (comme l'architecture hexagonale) centralisent toute la logique métier d'un domaine dans un seul service. On y retrouve les méthodes de création, de lecture, de mise à jour et de suppression dans un même fichier, exposées par une interface.

D'autres architectures optent pour une séparation plus fine : un fichier par cas d'usage, disposant d'une méthode unique (execute() ou invoke()). Ce découpage est plus verbeux, mais souvent plus simple à tester, maintenir et faire évoluer.

class User {
  readonly id: string;
  readonly firstName: string;
  readonly lastName: string;
  readonly email: string;

  constructor(id: string, firstName: string, lastName: string, email: string) {
    this.id = id;
    this.firstName = firstName;
    this.lastName = lastName;
    this.email = email;
  }

  toString(): string {
    return JSON.stringify(this);
  }
}

export default User;

On commence par l'entité du domaine. Ici, on modélise un objet métier simple représentant un utilisateur, avec 4 champs essentiels et un constructeur dédié à leur initialisation. L'utilisation du mot-clé readonly (spécifique à TypeScript) permet de garantir l'immuabilité de ces propriétés une fois l'instance créée, renforçant ainsi l'intégrité de l'objet.

NB : En l'absence de logique métier dans les accesseurs, on choisit volontairement de s’affranchir des Getters/Setters afin d’alléger le code et de se concentrer sur sa lisibilité.

import { NewUser } from '../interfaces/NewUser';
import { UserRepository } from '../interfaces/UserRepository';

export class CreateUser {
  constructor(private userRepository: UserRepository) {}

  async execute(newUser: NewUser): Promise<User> {
    const id = Math.random().toString(32).substring(2, 10);

    try {
      const user = new User(id, newUser.firstName, newUser.lastName, newUser.email);
      const createdUser = await this.userRepository.create(user);
      return createdUser;
    } catch (err) {
      throw new Error('Internal Server Error');
    }
  }
}

export class GetAllUsers {
  constructor(private userRepository: UserRepository) {}

  async execute(): Promise<User[]> {
    return this.userRepository.findAll();
  }
}

export class GetOneUser {
  constructor(private userRepository: UserRepository) {}

  async execute(id: string): Promise<User> {
    const user = await this.userRepository.findById(id);
    if (user === null) {
      throw new Error('User Not Found');
    }
    return user;
  }
}

Ci-dessus, vous retrouverez les cas d'usage (pour ne pas parler de service fonctionnel) pour la création et la récupération d'un ou plusieurs utilisateurs.

NB : Ici, pour les bienfaits de l'article, il s'agit d'une seule portion de code ; mais pour une organisation optimale, il serait préférable d'avoir une classe exportée par fichier (préférez export default plutôt que export).

Vous l'aurez remarqué, les cas d'usage n'agissent pas directement sur les données, et c'est normal puisque c'est l'objectif d'un modèle "Clean". En revanche, ils savent comment se comporte le service application (via son interface UserRepository) sans jamais avoir accès à une implémentation concrète. C'est au conteneur "IoC" (cf. src/index.ts) d'injecter les dépendances, permettant d'interconnecter les infrastructures techniques et le Core.

import User from '../domain/User';

// Input DTO
export interface NewUser {
  firstName: string;
  lastName: string;
  email: string;
}

export interface UserRepository {
  findAll: () => Promise<User[]>;
  findById: (id: string) => Promise<User | null>;
  create: (user: User) => Promise<User>;
  // TODO: update: (user: User) => Promise<User>;
  // TODO: delete: (id: string) => Promise<number>;
}

Ces premiers éléments *** (à savoir l'entité du domaine métier, les cas d'usage et l'interface) constituent une base minimale pour l'implémentation du Core, reste à savoir comment fonctionne l'infrastructure...

*** À propos du contenu du Core...

Le Core peut inclure plus d'éléments tels que,

  • Les exceptions métiers, pour gérer les erreurs spécifiques au cas d'usage (UserNotFoundError, UserConflictError, etc.), plutôt qu'une erreur générique technique ;
  • Les "Object-Values" pour une gestion plus fine des champs de l'entité du domaine métier, notamment en ce qui concerne leur validation respective.

NB : Je vous invite à aller consulter les sources du projet sur GitHub pour en savoir plus.

À l'issue de cette seconde partie, voici la structure après implémentation du Core :

src/
├── core/
│   ├── domain/
│   │   └── User.ts
│   ├── interfaces/
│   │   ├── NewUser.ts
│   │   └── UserRepositoryPort.ts
│   └── use-cases/
│       ├── CreateUser.ts
│       ├── GetAllUsers.ts
│       └── GetOneUser.ts
├── infrastructure/
└── index.ts

Infrastructure ⚙️

Point de contact entre la logique métier et le monde extérieur.

Pour pouvoir exposer une API de gestion d'utilisateurs, on va avoir besoin d'un contrôleur, du routage associé aux endpoints, ainsi que de l'implémentation responsable de la lecture et de l'écriture des données.

Prenons les choses dans l'ordre avec le Router. Je vous propose une classe qui implémente l'interface ApplicationRouter et qui dispose d'une méthode unique (listener) prenant la requête et la réponse HTTP en tant que paramètres. C'est cette même fonction qui sera ensuite appelée par le précédent HttpProvider.

import UserController from '../controllers/UserController';
import { ApplicationRequest } from '../providers/ApplicationRequest';
import { ApplicationResponse } from '../providers/ApplicationResponse';
import { ApplicationRouter } from './ApplicationRouter';

class UserRouter implements ApplicationRouter {
  static PATHNAME = '/api/users';

  constructor(private userController: UserController) {}

  async listener(request: ApplicationRequest, response: ApplicationResponse) {
    const baseUrl = `http://${request.headers.host}`;
    const url = new URL(request.url as string, baseUrl);

    const strictPathName = new RegExp(`^${UserRouter.PATHNAME}$`);
    const pathNameWithId = new RegExp(`^${UserRouter.PATHNAME}/[^/]+$`);

    if (request.method === 'GET') {
      if (strictPathName.test(url.pathname)) {
        return this.userController.getAll(request, response);
      }

      if (pathNameWithId.test(url.pathname)) {
        return this.userController.getOne(request, response);
      }
    }

    if (request.method === 'POST' && strictPathName.test(url.pathname)) {
      return this.userController.createOne(request, response);
    }
  }
}

export default UserRouter;

Le Router agit comme un "dispatcher" : il isole la logique de routage en mappant les endpoints vers les méthodes du contrôleur, sans s’occuper de la logique métier.

D'après ce composant, on s'aperçoit que l'application exposera le endpoint /api/user à la fois en GET mais aussi en POST. En fonction du chemin (présence ou non de l'identifiant dans l'URL) et du verbe REST utilisé, l'application exécutera les méthodes associées au contrôleur. Le contrôleur se chargera ensuite d'écrire la réponse HTTP.

En résumé, voici le routage pour la gestion d'utilisateurs :

  • /api/users en GET
  • /api/users/:id en GET
  • /api/users en POST
import { NewUser } from '../../core/interfaces/UserInput';
import CreateUser from '../../core/use-cases/CreateUser';
import GetAllUsers from '../../core/use-cases/GetAllUsers';
import GetOneUser from '../../core/use-cases/GetOneUser';
import { ApplicationRequest } from '../providers/ApplicationRequest';
import { ApplicationResponse } from '../providers/ApplicationResponse';
import UserRouter from '../routes/UserRouter';
import { bodyParser } from '../utils';

class UserController {
  constructor(
    private createUser: CreateUser,
    private getAllUsers: GetAllUsers,
    private getOneUser: GetOneUser
  ) {}

  async getAll(_request: ApplicationRequest, response: ApplicationResponse) {
    const users = await this.getAllUsers.execute();
    response.setHeader('Content-Type', 'application/json');
    response.statusCode = 200;
    response.end(JSON.stringify(users));
    return;
  }

  async getOne(request: ApplicationRequest, response: ApplicationResponse) {
    const baseUrl = `http://${request.headers.host}`;
    const url = new URL(request.url as string, baseUrl);

    const pathNameWithId = new RegExp(`^${UserRouter.PATHNAME}/([^/]+)$`);
    const matches = url.pathname.match(pathNameWithId);

    if (!matches) {
      response.setHeader('Content-Type', 'application/json');
      response.statusCode = 400;
      response.end(JSON.stringify({ message: `Cannot Get ${UserRouter.PATHNAME}` }));
      return;
    }

    const [_input, id] = matches;

    try {
      const user = await this.getOneUser.execute(id);

      response.setHeader('Content-Type', 'application/json');
      response.statusCode = 200;
      response.end(JSON.stringify(user));
      return;
    } catch (err) {
      response.setHeader('Content-Type', 'application/json');
      response.statusCode = 404;
      response.end(JSON.stringify({ message: err.message }));
      return;
    }
  }

  async createOne(request: ApplicationRequest, response: ApplicationResponse) {
    const body = await bodyParser<NewUser>(request);

    try {
      const user = await this.createUser.execute(body);

      response.setHeader('Content-Type', 'application/json');
      response.statusCode = 201;
      response.end(JSON.stringify(user));
      return;
    } catch (err) {
      response.setHeader('Content-Type', 'application/json');
      response.statusCode = 500;
      response.end(JSON.stringify({ message: err.message }));
      return;
    }
  }
}

export default UserController;

Évoquons maintenant le rôle du contrôleur. L'objectif de UserController va être d'invoquer le cas d'usage et décrire la réponse HTTP. En injectant les dépendances CreateUser, GetUser et GetAllUsers le contrôleur peut récupérer les données, pour ensuite enrichir le corps de la réponse HTTP.

Puisqu'on travaille avec Node from scratch sans framework supplémentaire, on va devoir formater les données en chaîne de caractères, et configurer les entêtes de la réponse HTTP afin d'interpréter la valeur retournée au format JSON.

NB : Pour récupérer le corps de la requête HTTP, j'utilise une fonction utilisateur bodyParser (consultable depuis GitHub) ainsi qu'un DTO d'entrée : NewUser.

import { createServer } from 'node:http';

import CreateUser from './core/use-cases/CreateUser';
import GetAllUsers from './core/use-cases/GetAllUsers';
import GetOneUser from './core/use-cases/GetOneUser';

import HelloController from './infrastructure/controllers/HelloController';
import HttpProvider from './infrastructure/providers/HttpProvider';
import HelloRouter from './infrastructure/routes/HelloRouter';

// TODO: const userRepository = new UserRepositoryAdapter();

const getAllUsersUseCase = new GetAllUsers(userRepository);
const getOneUserUseCase = new GetOneUser(userRepository);
const createUserUseCase = new CreateUser(userRepository);

const userController = new UserController(getAllUsersUseCase, getOneUserUseCase, createUserUseCase);
const userRouter = new UserRouter(userController);

const requestHandler = HttpProvider.handleListeners(userRouter);

const server = createServer(requestHandler);
const app = new HttpProvider(server);

app.start(8080);

L'application commence à prendre forme... Mais ne manquerait-il pas la partie responsable de l'accès aux données !? 🤔

Couche d'Accès aux Données 📊

Parlons bien, parlons données ! Avant d'évoquer la possibilité de persister les données en base (directement depuis Node), voici une première implémentation de UserRepository avec des données stockées en mémoire.

import User from '../../core/domain/User';
import UserRepository from '../../core/interfaces/UserRepositoryPort';

class UserRepositoryAdapter implements UserRepository {
  private users: User[] = [];

  async findAll(): Promise<User[]> {
    return this.users;
  }

  async findById(id: string): Promise<User | null> {
    return this.users.find(user => user.id === id) ?? null;
  }

  async create(user: User): Promise<User> {
    this.users = [...this.users, user];
    return user;
  }

  // TODO: async update(user: User): Promise<User> {}
  // TODO: async delete(id: string): Promise<number> {}
}

export default UserRepositoryAdapter;

C'est la dernière pièce qui manquait au puzzle 🧩 En instanciant UserRepositoryAdapter depuis le point d'entrée du projet, on devrait disposer d'une première version d'un CRUD de gestion d'utilisateurs opérationnel (bien que limitée) avec un accès en mémoire (et sans framework additionnel).

NB : Pour rappel, le contrôleur appelle un cas d’usage (CreateUser, GetAllUsers ou GetOneUser), qui s’appuie sur l’interface UserRepository, laquelle est ici concrètement implémentée par UserRepositoryAdapter.

TypeScript nécessite une étape de compilation avant exécution. Bien que Node "commence" à proposer un support natif du .ts, j’ai opté ici pour ESBuild (un bundler ultra-efficace), afin de générer un fichier JavaScript exécutable simplement avec Node : esbuild src/index.ts --bundle --format=esm --outfile=dist/index.js --platform=node --target=node22.14 && node dist/index.js

SQLite From Scratch 🪶

Ne nous contentons pas simplement d'un accès en mémoire, qui est réinitialisé à chaque redémarrage de l'application... Persistons nos données avec SQL.

La version 22.5.0 de Node introduit une API pour communiquer avec une base de données SQL embarquée (cf. SQLite). En introduisant une abstraction pour piloter l'exécution des requêtes SQL, on peut manipuler les données utilisateur avec simplicité et sécurité.

NB : À noter que l'API est susceptible de changer, Node est plutôt explicite là-dessus, avec le message suivant au lancement de l'application : ExperimentalWarning: SQLite is an experimental feature and might change at any time

import { DatabaseSync, SupportedValueType } from 'node:sqlite';

class SqlManager {
  private database;

  constructor(database: DatabaseSync) {
    this.database = database;
  }

  execute(sql: string) {
    this.database.exec(sql);
  }

  query<T>(sql: string) {
    const statement = this.database.prepare(sql);
    return (...params: SupportedValueType[]): T => statement.get(...params) as T;
  }

  queryAll<T>(sql: string) {
    const statement = this.database.prepare(sql);
    return (...params: SupportedValueType[]): Array<T> => statement.all(...params) as Array<T>;
  }

  mutate(sql: string) {
    const statement = this.database.prepare(sql);
    return (...params: SupportedValueType[]): { changes: bigint } => {
      const result = statement.run(...params);
      return { changes: result.changes };
    };
  }
}

export default SqlManager;

Voici l'abstraction SQL que je vous propose. L'API de Node pour SQLite propose deux manières d'exécuter des requêtes SQL,

  • Soit via exec(), pour exécuter simplement une requête en base de données (pour la création d'une table par exemple) ;
  • Soit via prepare(), pour créer une instruction paramétrée d'une requête (idéale pour éviter l'injection SQL) et l'exécuter dans un second temps.

Cette deuxième possibilité est vraiment intéressante (et justifie ma conception ;). Que ce soit query(), queryAll() ou encore mutate(), chacune de ces fonctions se souviennent de la requête SQL (et de ses paramètres) pour ensuite réagir de trois façons distinctes :

  • get() pour exécuter la requête et récupérer une valeur ;
  • all() pour exécuter la requête et récupérer une liste de valeurs ;
  • run() pour récupérer des informations à l'issue de l'exécution de la requête (notamment le nombre d'entités impactées) ;

Chacune de ces opérations s'exécute (avec ou sans paramètre) une seule et unique fois. C'est-à-dire que pour récupérer des valeurs à jour (une nouvelle fois), il faudra monter une instruction paramétrée. D'où l'intérêt de l'abstraction SqlManager !

Maintenant que nous avons compris le fonctionnement de SQLite pour Node, ajoutons une implémentation concrète pour la persistance des données utilisateurs en base :

import { DatabaseSync } from 'node:sqlite';
import SqlManager from '../providers/SqlManager';

export const CREATE_TABLE_USERS = `
  CREATE TABLE IF NOT EXISTS users (
    id VARCHAR(8) PRIMARY KEY,
    first_name VARCHAR(32) NOT NULL,
    last_name VARCHAR(32) NOT NULL,
    email VARCHAR(128) NOT NULL UNIQUE
  );
`;

export const CREATE_USER = `
  INSERT INTO users (id, first_name, last_name, email)
  VALUES (?, ?, ?, ?)
`;

export const SELECT_ALL_USERS = `
  SELECT * FROM users
`;

export const SELECT_USER = `
  SELECT * FROM users WHERE id = ?
`;

export interface UserEntity {
  id: string;
  first_name: string;
  last_name: string;
  email: string;
}

class UserManagement extends SqlManager {
  constructor(database: DatabaseSync) {
    super(database);
    this.createTable();
  }

  createTable() {
    this.execute(CREATE_TABLE_USERS);
  }

  createUser(id: string, firstName: string, lastName: string, email: string) {
    const statement = this.mutate(CREATE_USER);
    const { changes } = statement(id, firstName, lastName, email);
    console.log('createUser::changes', changes);
  }

  selectAllUsers() {
    const statement = this.queryAll<UserEntity>(SELECT_ALL_USERS);
    return statement();
  }

  selectUserById(id: string) {
    const statement = this.query<UserEntity>(SELECT_USER);
    return statement(id);
  }
}

export default UserManagement;

NB : Même remarque que pour les cas d'usage, vous retrouvez ici l'implémentation de la couche de persistance des données, ainsi que les constantes représentant les requêtes SQL ; pour une meilleure organisation, il serait préférable de découper plus finement le code...

Enfin, modifions la classe UserRepository pour pointer vers les données de la base, plutôt qu'à une stratégie d'enregistrer des données en mémoire :

import User from '../../core/domain/User';
import { UserRepository } from '../../core/interfaces/UserRepository';
import { UserEntity } from '../persistence/UserEntity';
import UserManagement from '../persistence/UserManagement';

const toUser = (entity: UserEntity): User =>
  new User(entity.id, entity['first_name'], entity['last_name'], entity.email);

class UserRepositoryAdapter implements UserRepository {
  constructor(private userPersistance: UserManagement) {}

  async findAll(): Promise<User[]> {
    const entities = this.userPersistance.selectAllUsers();
    return entities.map(toUser);
  }

  async findById(id: string): Promise<User | null> {
    const entity = this.userPersistance.selectUserById(id);
    if (!entity) return null;
    return toUser(entity);
  }

  async create(user: User): Promise<User> {
    this.userPersistance.createUser(user.getId(), user.getFirstName(), user.getLastName(), user.getEmail());
    return user;
  }

  // TODO: async update(user: User): Promise<User> {}
  // TODO: async delete(id: string): Promise<number> {}
}

export default UserRepositoryAdapter;

Nous y sommes ! Terminons l'exercice en mettant à jour notre point d'entrée et en lançant l'application.

import { createServer } from 'node:http';
import { DatabaseSync } from 'node:sqlite';

import CreateUser from './core/use-cases/CreateUser';
import GetAllUsers from './core/use-cases/GetAllUsers';
import GetOneUser from './core/use-cases/GetOneUser';

import UserRepositoryAdapter from './infrastructure/adapters/UserRepositoryAdapter';
import UserController from './infrastructure/controllers/UserController';
import UserManagement from './infrastructure/persistence/UserManagement';
import HttpProvider from './infrastructure/providers/HttpProvider';
import UserRouter from './infrastructure/routes/UserRouter';

const database = new DatabaseSync('./foobar.db'); // ':memory:'

const userManagement = new UserManagement(database);
const userRepository = new UserRepositoryAdapter(userManagement);

const getAllUsersUseCase = new GetAllUsers(userRepository);
const getOneUserUseCase = new GetOneUser(userRepository);
const createUserUseCase = new CreateUser(userRepository);

const userController = new UserController(getAllUsersUseCase, getOneUserUseCase, createUserUseCase);
const userRouter = new UserRouter(userController);

const requestHandler = HttpProvider.handleListeners(userRouter);

const server = createServer(requestHandler);
const app = new HttpProvider(server);

app.start(8080);

Conclusion

Nous venons de poser les bases d'une application modulaire et proprement structurée, en nous appuyant sur les principes de l'architecture "Clean". Si vous avez suivi toutes les étapes de ce guide, vous devriez avoir une structure équivalente à celle-ci :

src/
├── core/
│   ├── domain/
│   │   └── User.ts
│   ├── interfaces/
│   │   ├── NewUser.ts
│   │   └── UserRepositoryPort.ts
│   └── use-cases/
│       ├── CreateUser.ts
│       ├── GetAllUsers.ts
│       └── GetOneUser.ts
├── infrastructure/
│   ├── adapters/
│   │   └── UserRepositoryAdapter.ts
│   ├── controllers/
│   │   └── UserController.ts
│   ├── persistence/
│   │   ├── UserEntity.ts
│   │   ├── UserManagement.ts
│   │   └── UserQueries.ts
│   ├── providers/
│   │   ├── ApplicationRequest.ts
│   │   ├── ApplicationResponse.ts
│   │   ├── HttpProvider.ts
│   │   └── SqlManager.ts
│   └── routes/
│       ├── ApplicationRouter.ts
│       └── UserRouter.ts
└── index.ts

On pourrait pousser l'idéologie de l'architecture "Clean" encore plus loin, en optant pour une organisation plus "stricte", en trois parties distinctes (application / domain / infrastructure), mais cette structure constitue déjà une base robuste, conformes aux principes SOLID, et efficace en terme de lisibilité, de testabilité et d'évolutivité.

Ce découpage permet d'adapter ou de remplacer une brique technique sans impacter les autres couches. D'ailleurs, l'ajout progressif de la persistance via SQLite prouve qu'il est possible d'intégrer une technologie sans jamais venir perturber le Core de l'application ; c'est là toute la force de cette architecture !

Au-delà de l'architecture, il est particulièrement intéressant de savoir comment manipuler les interactions HTTP avec Node sans librairie complémentaire. À l'heure où les frameworks sont souvent la réponse aux projets "Quick and Dirty", revenir à l'essentiel offre un regard objectif sur le fonctionnement d'un serveur Web, tout en conservant la maîtrise complète de la stack technique.

Enfin, la plateforme Node continue d'évoluer. L'arrivée de SQLite en natif est une avancée majeure, ouvrant la voie à d'autres possibilités à moyen terme. À quand l'intégration de MongDB ? Quid de l'unification des bases de données via une API standardisée !? Ce qui était jusqu'ici réservé aux environnements alternatifs tels que Deno, devient peu à peu accessible depuis Node lui-même.