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 !
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:
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...
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:
Avant toute chose, les briques Database Type et Model abstract class sont des outils que j'utilise pour l'interfaçage.
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 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 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.
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:
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.
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:
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:
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:
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.
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.
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.