Dans l'article précédent nous avons initialisé notre monorepo, la CI, le framework de test et préparé la structure de notre projet et plus précisément de notre Architecture Hexagonale pour la lib core.
Dans ce nouvel article de la série notre objectif va être de mettre en place l'Architecture Hexagonale et de montrer comment grâce à elle nous allons pouvoir développer et créer de la logique métier sans UI (donc sans ouvrir le navigateur ou l'app mobile). Pour cela nous allons travailler en TDD (Test-Driven Development, vous pouvez voir mon article à ce sujet) et utiliser le feedback des tests.
L'Architecture Hexagonale
La structure cible
Pour rappel, voici la structure que l'on va mettre en place à l'issue de cet article :
- src
- wallet
- __ tests __
- wallet.service.test.ts
- domain
- wallet.ts
- wallet.repository.ts
- wallet.service.ts
- infrastructure
- in-memory-wallet.repository.ts
- local-storage-wallet.repository.ts
- mmkv-wallet.repository.ts
- user-interface
- wallet.store.ts
Chose promise, chose due ! Nous allons maintenant rentrer dans le détail de chaque fichier, à quoi ils servent et ce qu'ils contient.
Développer en TDD
Lorsqu'on travaille en TDD on commence par le test et ce test va nous guider vers un objectif. Il va nous assurer qu'on suit le bon chemin à l'aide de la boucle de feedback régulière qu'on obtient à l'aide des tests. Pour en savoir plus sur la méthodologie à suivre pour faire du TDD je vous invite à nouveau à lire mon article à ce sujet.
Nous allons commencer par travailler sur l'entité Wallet qui correspond à un portefeuille qui a un solde négatif ou positif (par exemple on peut avoir le portefeuille "Compte Principal Julien" qui a un solde positif de 1000€).
Voici les tests mis en place pour cette entité :
describe('Wallet Service', () => {
let service: WalletService
beforeEach(() => {
const repository = new InMemoryWalletRepository()
service = new WalletService(repository)
})
test('getAll > should retrieve all wallets', async () => {
const newWallet = { id: '1', name: 'Wallet 1', balance: 0 }
await service.create(newWallet)
const retrievedWallets = await service.getAll()
expect(retrievedWallets).toEqual([newWallet])
})
test('get > should retrieve a wallet according to an id', async () => {
const newWallet = { id: '1', name: 'Wallet 1', balance: 0 }
await service.create(newWallet)
const retrievedWallet = await service.get(newWallet.id)
expect(retrievedWallet).toEqual(newWallet)
})
test('create > shoudl create a wallet', async () => {
const newWallet = { id: '1', name: 'Wallet 1', balance: 0 }
const createdWallet = await service.create(newWallet)
const retrievedWallets = await service.getAll()
const retrievedWallet = await service.get(createdWallet.id)
expect(createdWallet).toEqual(newWallet)
expect(retrievedWallets).toEqual([newWallet])
expect(retrievedWallet).toEqual(newWallet)
})
test('update > should update the specified wallet', async () => {
const newWallet = { id: '1', name: 'Wallet 1', balance: 0 }
const updatedWallet = { id: '1', name: 'Wallet 1', balance: 100 }
await service.create(newWallet)
const retrievedWallet = await service.get(newWallet.id)
const modifiedWallet = await service.update(updatedWallet)
const retrievedModifiedWallet = await service.get(modifiedWallet.id)
expect(retrievedWallet).toEqual(newWallet)
expect(modifiedWallet).toEqual(updatedWallet)
expect(retrievedModifiedWallet).toEqual(updatedWallet)
})
test('delete > should delete a wallet according to an id', async () => {
const newWallet = { id: '1', name: 'Wallet 1', balance: 0 }
await service.create(newWallet)
const retrievedWallet = await service.get(newWallet.id)
await service.delete(newWallet.id)
const retrievedWallets = await service.getAll()
expect(retrievedWallet).toEqual(newWallet)
expect(retrievedWallets).toEqual([])
})
})
On peut comprendre via ces tests que les cas d'utilisations de notre entité sont :
- getAll, récupération de tous les portefeuilles
- get, récupération d'un portefeuille en particulier
- create, création d'un portefeuille
- update, mise à jour d'un portefeuille
- delete, suppression d'un portefeuille
Nous allons voir maintenant comme réussir à mettre en place ces tests.
Domain
Nous allons commencé par créer le contenu de la partie Domain. Dans cette partie nous allons retrouver tout ce qui représente le problème à résoudre (problème métier). C'est une partie qui doit être totalement indépendante.
L'entité
Commençons par créer notre entité Wallet correspondant à un portefeuille.
type Wallet = {
// un identifiant unique (ex: 4d0c2e72-be1a-4e2c-a189-2f321fcdc3a4)
id: string
// un nom (ex: Compte Principal Julien)
name: string
// un nombre positif ou négatif pour le solde (ex: +1000€)
balance: number
}
Le repository
Maintenant que notre entité est définie, nous allons définir une interface que l'on appelle également port qui va préciser comment interagir avec cette entité. Nous utilisons ici un modèle de conception d'inversion de dépendances qui nous permet de rester totalement libre sur les outils à utiliser pour respecter cette interface. Nous pourrons très bien implémenté cette interface en utilisant une base de données, une API ou un localStorage par exemple, le domaine s'en fiche.
interface WalletRepository {
getAll(): Promise<Wallet[]>
get(walletId: string): Promise<Wallet | undefined>
create(wallet: Wallet): Promise<Wallet>
update(wallet: Wallet): Promise<Wallet>
delete(walletId: string): Promise<void>
}
Le service
Nous avons notre entité et nous savons commencer interagir avec, maintenant nous allons créer un service qui va consumer une implémentation du de notre interface repository (partie suivante dans l'infrastructure).
class WalletService implements WalletRepository {
constructor(private repository: WalletRepository) {}
getAll() {
return this.repository.getAll()
}
get(walletId: string) {
return this.repository.get(walletId)
}
create(wallet: Wallet) {
return this.repository.create(wallet)
}
update(wallet: Wallet) {
return this.repository.update(wallet)
}
delete(walletId: string) {
return this.repository.delete(walletId)
}
}
Infrastructure
L'infrastructure est composée des différentes implémentations des ports du domaine, on les appelle également Adapters. Ici, nous aurons du code spécifique pour consommer une technologie concrète (une base de données, une API, etc.). C'est une partie qui ne doit dépendre uniquement du domaine.
L'implémentation du repository
Nous allons maintenant voir l'une des implémentation possible de notre WalletRepository. Pour commencer nous allons faire du in-memory, pratique notamment pour la mise en place des premiers tests de nos cas d'utilisations.
class InMemoryWalletRepository implements WalletRepository {
private wallets: Wallet[] = []
getAll() {
return Promise.resolve(this.wallets)
}
get(walletId: string) {
return Promise.resolve(this.wallets.find((wallet) => wallet.id === walletId))
}
create(wallet: Wallet) {
this.wallets.push(wallet)
return Promise.resolve(wallet)
}
update(wallet: Wallet) {
const index = this.wallets.findIndex((w) => w.id === wallet.id)
this.wallets[index] = wallet
return Promise.resolve(wallet)
}
delete(walletId: string) {
const index = this.wallets.findIndex((w) => w.id === walletId)
this.wallets.splice(index, 1)
return Promise.resolve()
}
}
Comment dis précédemment, il s'agit d'une des multiples implémentation possible de notre WalletRepository. Nous pouvons très bien imaginer plus tard mettre en place un LocalStorageWalletRepository ou bien un SupabaseWalletRepostory.
Vous pouvez consulter mon répertoire public de broney sur GitHub pour voir mon implémentation de ces 2 repository et notamment de comment j'ai adapté ma série de test pour garantir leur bon fonctionnement.
User Interface
La partie user interface est composée de tous les adaptateurs qui constituent les points d'entrée de l'application. Les utilisateurs utilisent ces adaptateurs pour pouvoir interagir avec le coeur de l'application. Dans notre cas nous allons régulièrement utiliser des stores en utilisant la libraire Zustand. Il s'agit d'une libraire JS minimaliste pour la gestion d'états (une solution plus complexe serait par exemple Redux).
Voyons voir comment articuler notre store Zustand pour permettre à l'utilisateur d'interagir avec le coeur de l'application.
import { createStore } from 'zustand/vanilla'
import { InMemoryWalletRepository } from '../infrastructure/in-memory-wallet.repository'
import { WalletService } from '../domain/wallet.service'
import { Wallet } from '../domain/wallet'
const repository = new InMemoryWalletRepository()
const service = new WalletService(repository)
type States = {
wallets: Wallet[]
currentWallet: Wallet | undefined
}
type Actions = {
load: () => void
setCurrentWallet: (wallet: Wallet) => void
getWallet: (walletId: string) => void
createWallet: (wallet: Wallet) => void
updateWallet: (wallet: Wallet) => void
deleteWallet: (walletId: string) => void
}
export const walletStore = createStore<States & Actions>()((set) => ({
wallets: [],
currentWallet: undefined,
load: async () => {
const allWallets = await service.getAll()
set({ wallets: allWallets })
},
setCurrentWallet: (wallet) => set({ currentWallet: wallet }),
getWallet: async (walletId: string) => {
const wallet = await service.get(walletId)
set({ currentWallet: wallet })
},
createWallet: async (wallet: Wallet) => {
const newWallet = await service.create(wallet)
set((state) => ({ wallets: [...state.wallets, newWallet] }))
},
updateWallet: async (wallet: Wallet) => {
const updatedWallet = await service.update(wallet)
set((state) => ({
wallets: state.wallets.map((w) => (w.id === updatedWallet.id ? updatedWallet : w)),
currentWallet: state.currentWallet?.id === updatedWallet.id ? updatedWallet : state.currentWallet,
}))
},
deleteWallet: async (walletId: string) => {
await service.delete(walletId)
set((state) => ({
wallets: state.wallets.filter((w) => w.id !== walletId),
currentWallet: state.currentWallet?.id === walletId ? undefined : state.currentWallet,
}))
},
}))
Avec ce store on remarque qu'on va pouvoir facilement, dans n'importe quel environnement JavaScript, charger, définir, récupérer, créer, mettre à jour et supprimer des portefeuilles, tout en maintenant un état global pour l'ensemble des portefeuilles et du portefeuille courant.
Conclusion
Nous avons maintenant terminé ce deuxième article de cette série sur le développement d'une application web et mobile avec l'Architecture Hexagonale et le partage de la logique métier et des composants UI.
Dans cette deuxième partie nous avons vu comment travailler en TDD et surtout comment écrire de la logique métier sans avoir à ouvrir une quelque interface à l'exception du terminal pour les retours de tests.
Nous avons également eu un aperçu de comment nous allons interagir avec nos applications avec le coeur de l'application, via notre store Zustand. Nous irons plus loin à ce sujet dans le prochain article, la troisième partie : Partager de la logique métier et des composants entre le Web et le Mobile.