Comment j'organise mes projets Typescript (partie 1)

Comment j'organise mes projets Typescript (partie 1)

Alexandre P. dans Dev - Le 09-06-2024

L'organisation est un point essentiel dans le développement. Cela concerne la liste des pratiques que l'on veut suivre, des méthodos que l'on veut employer. Il s'agit toujours d'un mélange de ce que l'on souhaite utiliser dans nos projets car, rappelons-le, les pratiques sont des outils et il convient toujours d'employer ce qui est adapté à notre besoin. On ne sort pas un marteau pour souder deux fils !

Le Clean Code

Alors, j'admets que je ne suis pas du genre à suivre au pied de la lettre tous les fondamentaux du Clean Code.

Robert C. Martin m'excusera, mais je pense qu'étant donné que je suis seul, j'adapte à mes besoins (vitesse d'exécution, taille des projets...) ses précieux outils et conseils.

Cependant, je ne plaide pas coupable pour autant, car je maintiens qu'un savant mélange des bonnes pratiques permet:

  • de ne pas se perdre
  • d'avancer vite
  • d'avoir un modèle pour chaque item de notre projet
  • de réduire les bugs
  • de savoir ce que l'on fait

Aujourd'hui, nous allons nous attarder sur comment j'organise la logique métier dans mes projets Next.js en Typescript. Il s'agit d'une première partie de plusieurs articles où je m'attaquerai ensuite aux composants, etc...

Mon modèle de Clean Code sur l'implémentation de la logique métier

Bien entendu, il ne s'agit que d'un exemple, et nous savons tous qu'il y a d'innombrables possibilités mais nous devons en choisir certaines, quand nous sommes seuls sinon, nous passerons plus de temps à organiser ce que l'on fait plutôt que de coder des fonctionnalités.

Je vous partage ici le fruit d'une mûre réflexion bâtie sur des dizaines de projets menés à terme. C'est pourquoi je vous parle d'équilibre, car nous pouvons toujours pousser le concept plus loin, mais ces projets n'ayant pas été aboutis, je n'ai pas retenu ces méthodos d'organisation pour vous le partager ici.

Voici comment je procède:

Typescript structure ><

Explications

Avant toute chose, les briques Database Type et Model abstract class sont des outils que j'utilise pour l'interfaçage.

Les classes Service

J'utilise des classes de Service en backend, qui vont me permettre de manipuler des classes de Model abstrait (par exemple le model User ou Company)

Les services peuvent faire des appels croisés par exemple la création d'une Company par CompanyService fera un update d'un User pour renseigner le champs company_id de ce User. On peut y stocker des dépendances de logique métier et opérations multi entités.

Les services s'appuieront sur mes classes de Persist pour communiquer avec la base de données.

Les classes Persist

Les classes Persist manipulent des objets de type Database (qui sont des objets serializables de type primitif car il s'agit des données en base) les types Database utilisent souvent un format snake_case.

A chaque fois que j'ai besoin d'interagir avec un composant métier de mon application (les utilisateurs, les sociétés, les adresses etc...), je passe par mes classes Service et en aucun cas par les classes de type Persist.

Les classes Persist sont uniquement utilisables en backend, elles respectent le SRP (Single Responsability Principle) et se concentrent sur leur scope.

Par exemple, la classe UserPersist ne peut pas écrire dans les tables Company et vice versa.

Les types Database

Les types Database sont les types que j'utilise pour toutes les interfaces entrées/sorties vers la base de données, ou client/server.

J'utilise des types Typescript, car ils sont serializables et s'appuient sur des primitives. Ce qui signifie qu'un JSON.stringify passe sans broncher et qu'ils peuvent être dehydrated pour des rendus de pages serveur etc...

J'utilise ces types pour mes classes d'interfaces comme les classes Persist qui communiquent avec la base ou encore mes hooks react-query lorsque j'appelle mon API.

En dehors de ces composants d'interface, je ne manipule pas mes types Database. Car le type UserDatabase par exemple, n'est qu'un type avec des champs au format snake_case et se concentre sur la gestion des propriétés d'un item User en base.

Notez que j'ai employé le mot clé Database, car au nommage, j'ai préféré UserDatabase plutôt que UserRecord car Record est un mot clé ayant lui-même un sens particulier en Typescript.

Les classes Model

Ma classe abstraite UserModel est beaucoup plus puissante car non seulement, elle est isomorphique (s'utilise en back comme en front), elle passe mes champs au format camelCase, mais en plus elle me donne la possibilité de faire des méthodes très utiles pour la suite.

Par exemple: UserModel peut disposer d'une propriété addresses de type Address[], alors que ces éléments en base sont stockés dans une autre table.

En revanche, je m'organise toujours de manière à ce qu'une classe Model ne fasse jamais la manipulation de la base lui même, afin de rester isomorphique. Le but étant de permettre au frontend de manipuler des classes Model sans avoir à accéder aux données directement.

De même, je donne à mes classes Model les méthodes suivantes, via l'implémentation d'une classe abstraite DatabaseModel:

  • static fromDatabaseObject(typeDatabase): Model
  • toDatabaseObject(): TypeDatabase

Ce qui me permet de toujours serializer mon Model pour en faire un type Database avant persist, ou de les manipuler en tant que classe Model après récupération des types Database depuis un appel API.

Exemple

Nous allons implémenter l'organisation ci-dessus concernant la table adresse des utilisateurs. Chaque utilisateur peut avoir plusieurs adresses stockées en base.

Je vais vous donner un exemple avec une classe AddressService.

Les classes Service manipulent des classes Model et appellent les classes Persist via des interfaces typés en objet Database qui, je le rappelle, sont des primitives.

La chaîne complète ressemble à ceci:

Service_to_Persist.drawio.png

Dans les exemples, je ne mets pas toujours les types de retour car ces derniers sont inféré automatiquement, bien que j'en ai ajouté certaines pour que cela soit plus lisible.

import { AddressPersist } from "@/backend/database/persist/addresses"
import { BookingPersist } from "@/backend/database/persist/bookings"
import { Address } from "@/models/address"
import { AddressDatabase } from "@/types/address"

export class AddressService {
  static async getById(addressId: number): Promise<Address> {
    const address = await AddressPersist.getById(addressId)
    return Address.fromDatabaseObject(address)
  }

  static async listByUserId(userId: number): Promise<Address[]> {
    const userAddresses = await AddressPersist.listByUserId(userId)
    return userAddresses.map((addressDatabase) =>
      Address.fromDatabaseObject(addressDatabase)
    )
  }

  static async insert(newAddress: AddressDatabase): Promise<Address> {
    const address = await AddressPersist.insert(newAddress)
    return Address.fromDatabaseObject(address)
  }

  static async update(
    addressId: number,
    updatedAddress: Partial<AddressDatabase>
  ): Promise<Address> {
    const address = await AddressPersist.update(addressId, updatedAddress)
    return Address.fromDatabaseObject(address)
  }

  static async delete(addressId: number): Promise<boolean> {
    const address = await this.getById(addressId)

    const upcomingBookingsByAddress =
      await BookingPersist.listUpcomingBookingsByAddressId(addressId)

    const hasUpcomingBooking = upcomingBookingsByAddress.length > 0

    if (hasUpcomingBooking) {
      throw new Error("forbidden_delete_address_with_bookings")
    }

    if (address) {
      await AddressPersist.delete(addressId)

      if (address.defaultAddress) {
        const addressList = await AddressPersist.listByUserId(
          address.userId!
        )

        if (addressList?.[0]) {
          await AddressPersist.update(addressList[0].id!, {
            default_address: true
          })
        }
      }
      return true
    }

    return false
  }
}

Comme vous le voyez peut être j'utilise toujours le prefix list... lorsque je vais retourner un Array, cela me permet de comprendre le type et la structure de ce que retourne ma méthode par le nom. Je me dis qu'un get renvoie un Objet, donc list réfère plus facilement à un Array.

Dans l'exemple ci-dessus, la méthode delete, permet de faire des vérification sur les Bookings à venir, et savoir si oui ou non j'ai le droit de supprimer cette Adresse. Vérification que je ne peux pas me permettre de faire directement sur la couche Persist qui ne doit pas agir en dehors de son scope.

Dans le cas où je supprime cette adresse, une autre deviendra l'adresse par défaut si l'utilisateur possède plusieurs adresses.

Par ailleurs, on aurait pu se poser la question de pourquoi je ne prends pas le type Model en input dans le Service, et bien ce type étant une classe complexe et pas un objet serializable, il serait plus difficile de prendre en entrée une composition de clé issue de plusieurs variables ou objets.


Pour mes classes Model, j'étais initialement parti sur une classe abstraite, mais j'ai basculé sur une interface que j'ai nommé DatabaseModel à chaque fois que je code une classe représentant une table dans la base de données, car DatabaseModel implémente les méthodes de serialization. (Notez que les deux approches sont possibles.)

Bien entendu, je n'implémente pas la classe DatabaseModel pour toute classe Model "virtuelle", qui me sert surtout de classe Helper.

export interface DatabaseModelConstructor<T> {
  new (): T
  fromDatabaseObject(databaseObject: any): T
}

export interface DatabaseModel {
  toDatabaseObject(): any
}

Voici ma classe Model pour Address:

import { AddressDatabase } from "@/types/address"

import { DatabaseModel, DatabaseModelConstructor } from "./model"

export class Address implements DatabaseModel {
  id?: number
  street: string = ""
  street2: string = ""
  addressInfo: string = ""
  zipCode: string = ""
  city: string = ""
  country: string = ""
  defaultAddress: boolean = true
  createdAt?: Date
  updatedAt?: Date
  userId: number | null = null

  constructor() {}

  static fromDatabaseObject(addressDatabase: AddressDatabase): Address {
    const address = new Address()

    address.id = addressDatabase.id
    address.street = addressDatabase.street
    address.city = addressDatabase.city
    address.country = addressDatabase.country
    address.zipCode = addressDatabase.zip_code
    address.userId = addressDatabase.user_id
    address.defaultAddress = addressDatabase.default_address
    address.createdAt = addressDatabase.created_at
    if (addressDatabase.updated_at)
      address.updatedAt = addressDatabase.updated_at
    if (addressDatabase.address_info)
      address.addressInfo = addressDatabase.address_info
    if (addressDatabase.street_2) address.street2 = addressDatabase.street_2

    return address
  }

  toDatabaseObject(): AddressDatabase {
    return {
      id: this.id,
      street: this.street,
      street_2: this.street2,
      city: this.city,
      country: this.country,
      zip_code: this.zipCode,
      default_address: this.defaultAddress,
      user_id: this.userId!,
      ...(this.createdAt ? { created_at: this.createdAt } : {})
    }
  }
}

const AddressConstructor: DatabaseModelConstructor<Address> = Address;

Le Model s'utilise comme ceci: Abstract_Model.drawio.png

On peut accessoirement ajouter d'autres méthodes pour compléter cette classes avec des utilitaires.


Voici ma classe Persist pour Address:

import { AddressDatabase } from "@/types/address"

import prisma from "../prismaClient"

export class AddressPersist {
  static async getById(addressId: number) {
    const address = await prisma.address.findFirst({
      where: {
        id: addressId
      }
    })
    return address as AddressDatabase
  }

  static async listByUserId(userId: number) {
    const addresses = await prisma.address.findMany({
      where: {
        id: userId
      }
    })
    return (addresses as AddressDatabase[]) ?? []
  }

  static async insert(newAddress: AddressDatabase) {
    const address = await prisma.address.create({
      data: newAddress
    })
    return address as AddressDatabase
  }

  static async update(
    addressId: number,
    updatedAddress: Partial<AddressDatabase>
  ) {
    const address = await prisma.address.update({
      where: {
        id: addressId
      },
      data: updatedAddress
    })
    return address as AddressDatabase
  }

  static async delete(addressId: number) {
    await prisma.address.delete({
      where: {
        id: addressId
      }
    })
    return true
  }
}

La classe Persist est la couche d'accès à l'ORM, dans mon cas, j'utilise Prisma.


Enfin voici mon type Database, il s'agit du type que j'utilise comme input sur tous les points d'entrées nécessitants une serialization, pour aller vers la base, ou pour un appel API.

export type AddressDatabase = {
  id?: number
  user_id: number
  street: string
  street_2?: string
  address_info?: string
  zip_code: string
  default_address: boolean
  city: string
  country: string
  created_at?: Date
  updated_at?: Date
}

Le champs id n'est pas par défaut car cela me permet d'utiliser ce type pour mes formulaires react-hook-form par la suite.


Bon, vous l'aurez compris, il s'agit d'une organisation parmis tant d'autres et ce modèle est perfectible. Mais je suis satisfait de l'organisation et de l'effet "no brainer" qu'il apporte à mon code car cette structure me permet de savoir instinctivement:

  • où sont les choses
  • qui doit faire quoi
  • qu'est-ce que j'ai le droit de faire depuis un composant

Par ailleurs, afin de délimiter les choses et éviter les erreurs, j'ai ajouté une règle eslint dans mon fichier .estlintrc.json.

{
  "extends": "next/core-web-vitals",
  "rules": {
    "import/no-restricted-paths": [
      "error",
      {
        "zones": [
          {
            "target": "./src/frontend",
            "from": "./src/backend"
          }
        ]
      }
    ]
  }
}

De cette manière je ne risque pas d'importer du code destiné au backend sur mes composants front.

eslint error interception ><

C'est davantage de temps de cerveau disponible pour réfléchir à comment répondre aux problématiques métiers car je ne me pose plus ces questions.

Ces règles délimitent énormément l'impact d'un bloc sur l'autre, cela me permet de repérer et régler un bug plus facilement. Par exemple, si l'écriture en cascade sur différentes tables ne fonctionne pas pour un Service, cela ne signifie pas que l'écriture seule ne fonctionne pas pour la couche Persist.

Ainsi, je sais qu'il appartient à la logique du Service d'apporter les vérifications nécessaires et les champs obligatoires à chaque appel de méthode de la couche Persist.

De toute façon, à plus haut niveau Typescript fait déjà bien son travail de garde fou sur les déclarations.

Voilà, c'était très dense, j'espère que vous avez pu en sortir quelque chose de ce contenu qui demande beaucoup de travail et qui m'a demandé énormément de pratique pour vous le fournir en garantissant son fonctionnement et ses promesses d'organisation.

#code#organisation#clean#typescript#nextjs

user picture
Alexandre P.

Développeur passionné depuis plus de 20 ans, j'ai une appétence particulière pour les défis techniques et changer de technologie ne me fait pas froid aux yeux.


Nous utilisons des cookies sur ce site pour améliorer votre expérience d'utilisateur.