En tant que développeur, lorsque je relis du code, je constate régulièrement des problèmes de compréhension de règles métiers. Cela est bien souvent accompagné d’une maintenabilité difficile.
Pourquoi ?
Bien souvent, on devine ce que l’application fait en lisant les longues lignes de code et en interprétant ce qui a été écrit…
Il est possible de limiter ces problèmes dès la conception d’une fonctionnalité.
Dans cet article, je vais appliquer une approche d’organisation de code que j’affectionne particulièrement: l’architecture “port/adapter”.
A travers un discussion avec le produit, nous construirons pas à pas une application de suivi d’activités sportives.
J’utiliserai Typescript pour partager des exemples de code
TLDR:
L’architecture “port/adapter” permet d’expliciter les règles métiers en abstrayant les dépendances externes de l’applicatio.
Il n’est pas nécessaire de directement mettre en place toute une infrastructure (base de données, cache, frameworks, etc) pour commencer à créer un application pertinente.
Cette approche facilite également la testabilité du code et des approches de type TDD
Vous travaillez sur une nouvellle application de suivi d’activités sportives.
L’objectif est de fournir des rapports de santé pertinents et personnalisés aux utilisateurs.
Vous discutez donc avec le produit pour définir les besoins métiers et co-construire les fonctionnalités.
PO :
Le système renvoit des informations pertinentes selon l’état de forme d’un sportif.
Dit autrement, on a besoin de développer une fonctionnalité qui permet de calculer et renvoyer l’état de santé d’un sportif.
On se met alors d’accord sur le nom de la fonctionnalité : HealthInsightsCalculator
Tout système qui utilisera notre Service devra donc fournir un identifiant d’utilisateur UserId
et recevra un objet du domaine métier HealthInsight
Définissons ce “contrat” via une interface en Typescript
import { HealthInsight } from '@domain/HealthInsight'
export interface HealthInsightsCalculatorPort {
calculateInsights(userId: string): Promise<HealthInsight>
}
En accord avec le produit, nous définissons les informations à retourner
import { GoalProgress } from '@domain/GoalProgress'
import { UserId } from '@domain/User'
export type WeeklyTrend = 'improving' | 'stable' | 'declining'
export interface HealthInsight {
userId: UserId
goalProgress: GoalProgress[]
recommendations: string[]
weeklyTrend: WeeklyTrend
generatedAt: Date
}
export interface GoalProgress {
goalId: string
progressPercentage: number
estimatedCompletionDate: Date
onTrack: boolean
}
Un exemple de données renvoyée serait
{
"userId": "123",
"goalProgress": [
{
"goalId": "123",
"progressPercentage": 60,
"estimatedCompletionDate": "2025-07-16T07:00:00.000Z",
"onTrack": true
}
],
"recommendations": ["Vos efforts vont payer, vous êtes sur la bonne voie !"],
"weeklyTrend": "improving",
"generatedAt": "2025-07-06T07:00:00.000Z"
}
Via l’écriture de l’interface nous venons d’expliciter les frontières de notre fonctionnalité. Nous avons défini la façon dont les systèmes externes (automatisés ou humain) peuvent intéragir.
En architecture port/adapter, nous venons de définir le “port driving” ainsi que des domaines métiers.
Nous n’avons pas défini comment la logique métier va être implémentée
En discutant toujours avec le métier, on peut noter certains points intéressants:
Produit:
On calculera ce score à partir des activités passées et ses objectifs
Produit:
Cela serait bien de prévenir l’utilisateur par email et/ou notification sur son état de santé
D’après ces éléments, on peut en conclure que nous aurons des dépendances à des systèmes externes afin de récupérer ces informations
import { Activity } from '@domain/Activity'
export interface ActivityRespositoryPort {
findByUserId(userId: string): Promise<Activity[]>
}
import { UserId } from '@domain/User'
export type ActivityId = string
export type ActivityType = 'running' | 'cycling' | 'swimming' | 'walking'
export type ActivityIntensity = 'low' | 'medium' | 'high'
export interface Activity {
id: ActivityId
userId: UserId
type: ActivityType
duration: number
calories: number
date: Date
intensity: 'low' | 'medium' | 'high'
}
import { HealthGoal } from '@domain/HealthGoal'
export interface GoalRepositoryPort {
findByUserId(userId: string): Promise<HealthGoal[]>
}
import { UserId } from '@domain/User'
export type HealthGoalId = string
export type HealthGoalType = 'weight_loss' | 'muscle_gain' | 'endurance' | 'general_fitness'
export interface HealthGoal {
id: HealthGoalId
userId: UserId
type: HealthGoalType
targetValue: number
currentValue: number
deadline: Date
}
import { HealthInsight } from '@domain/HealthInsight'
import { UserId } from '@domain/User'
export interface NotificationServicePort {
sendInsightNotification(userId: UserId, insight: HealthInsight): Promise<void>
}
Nous venons de définir les interactions avec des systèmes externes via des interfaces.
Nous avons également défini quels objets métiers seront utilisés.
En architecture port/adapter nous venons de définir des “driving ports”
Requirements:
Nous n’avons pas défini si les données étaient stockées en base de données ou bien via API.
Aucune implémentation concrete des ports n’a encore été faite. La définition de ces ports permet justement de s’abstraire de l’implémentation concrète des interface.
On parle d’inversion de dépendance
Maintenant que nous avons défini tous les domaines métiers et autre ports pour intéragir avec les systèmes externes, il est temps d’implémenter notre logique métier à travers un “Usecase”
Commençons par définir notre usecase.
Ce dernier implémente notre “port driving” et dépendra d’autres services externes via les “ports driven”
import { HealthInsightsCalculatorPort } from '@application/port/driving/HealthInsightsCalculatorPort'
import { ActivityRepositoryPort } from '@application/port/driven/ActivityRepositoryPort'
import { GoalRepositoryPort } from '@application/port/driven/GoalRepositoryPort'
import { NotificationServicePort } from '@application/port/driven/NotificationServicePort'
import { HealthInsight } from '@domain/HealthInsight'
import { UserId } from '@domain/User'
export class HealthInsightsCalculatorUsecase implements HealthInsightsCalculatorPort {
constructor(
private readonly activityRepo: ActivityRepositoryPort,
private readonly goalRepo: GoalRepositoryPort,
private readonly notificationService: NotificationServicePort
) {}
async calculateInsights(userId: UserId): Promise<HealthInsight> {
// TODO : logic here
}
}
La vraie valeur produit se situe dans les calculs que cette classe va implémenter
Remarques:
Pour des raisons de simplicité et compréhension du code, les logiques de calcul ont été implémentées dans des méthodes privées.
On peut très bien imaginer des services dédiés pour plus de composition et testabilité
async calculateInsights(userId: UserId): Promise<HealthInsight> {
const activities = await this.activityRepo.findByUserId(userId);
const goals = await this.goalRepo.findByUserId(userId);
const goalProgress = await this.calculateGoalProgress(goals);
const recommendations = this.generateRecommendations(activities);
const weeklyTrend = this.calculateWeeklyTrend(activities);
const insight: HealthInsight = {
userId,
goalProgress,
recommendations,
weeklyTrend,
generatedAt: new Date(),
};
// Send notification
await this.notificationService.sendInsightNotification(userId, insight);
return insight;
}
Pour chacun des objectifs du user, on calcule le pourcentage de progression ainsi que la date estimée d’atteinte de l’objectif
private async calculateGoalProgress(
goals: HealthGoal[],
): Promise<GoalProgress[]> {
return goals.map((goal) => this.calculateSingleGoalProgress(goal));
}
private calculateSingleGoalProgress(goal: HealthGoal): GoalProgress {
const progress = (goal.currentValue / goal.targetValue) * 100;
const daysRemaining = Math.ceil(
(goal.deadline.getTime() - Date.now()) / (24 * 60 * 60 * 1000),
);
const estimatedCompletionDate = new Date(
Date.now() + daysRemaining * 24 * 60 * 60 * 1000,
);
return {
goalId: goal.id,
progressPercentage: Math.min(100, progress),
estimatedCompletionDate,
onTrack: progress >= 50 && daysRemaining > 0,
};
}
private generateRecommendations(activities: Activity[]): string[] {
const recommendations: string[] = [];
if (activities.length === 0) {
recommendations.push("Start with 30 minutes of light walking daily");
return recommendations;
}
const recentActivities = activities.filter(
(a) => a.date >= new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
);
if (recentActivities.length < 3) {
recommendations.push("Try to be active at least 3 times per week");
}
const hasCardio = recentActivities.some((a) =>
["running", "cycling", "swimming"].includes(a.type),
);
if (!hasCardio) {
recommendations.push(
"Add cardio exercises like running or cycling to improve cardiovascular health",
);
}
return recommendations;
}
private calculateWeeklyTrend(activities: Activity[]): WeeklyTrend {
const thisWeek = activities.filter(
(a) => a.date >= new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
);
const lastWeek = activities.filter(
(a) =>
a.date >= new Date(Date.now() - 14 * 24 * 60 * 60 * 1000) &&
a.date < new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
);
const thisWeekScore = thisWeek.reduce((sum, a) => sum + a.calories, 0);
const lastWeekScore = lastWeek.reduce((sum, a) => sum + a.calories, 0);
if (thisWeekScore > lastWeekScore * 1.1) return "improving";
if (thisWeekScore < lastWeekScore * 0.9) return "declining";
return "stable";
}
Le code est facilement testable et maintenable via les interfaces.
On peut utiliser des implémentations InMemory de nos “driven ports” en attendant de pouvoir communiquer avec des vraies services externes (ex: base de dnones, API).
On peut simuler plusieurs contextes facilement via le constructor
import { ActivityRepositoryPort } from '@application/port/driven/ActivityRepositoryPort'
import { Activity } from '@domain/Activity'
import { UserId } from '@domain/User'
export class InMemoryActivityRepositoryAdapter implements ActivityRepositoryPort {
constructor(private readonly activities: Activity[]) {}
async findByUserId(userId: UserId): Promise<Activity[]> {
return this.activities.filter((a) => a.userId === userId)
}
}
Exemple
import { Activity } from './domain/Activity'
import { InMemoryActivityRepositoryAdapter } from './infrastructure/InMemoryActivityRepositoryAdapter'
const activities: Activity[] = [
{
id: '1',
userId: '1',
type: 'walking',
duration: 30,
calories: 100,
date: new Date(),
intensity: 'low',
},
]
const repo = new InMemoryActivityRepositoryAdapter(activities)
repo.findByUserId('1').then(console.log)
En architecture port/adapter, les services qui implémentent les “ports” sont appelés “adapter”.
En effet, tout système voulant s’interfacer avec notre usecase doit s’adapter à ses contraintes.
C’est ici qu’on retrouve très fréquemment des mappers ou objets DTO qui permettent de convertir un domaine non métier (ex: réponse Json, résultats en base de données) vers le domaine métier attendu.
Comme notre usecase dépend d’autres services via des interfaces, il est aisé de controller les effets de bords.
On peut utiliser des mocks ou mieux, des implementation “in-memory” par exemple
Nous allons tester les recommandations quand un utilisateur ne fait aucune activité ou pas assez selon notre algorithme.
import { describe, expect, it } from 'vitest'
import { InMemoryActivityRepositoryAdapter } from '@infrastructure/InMemoryActivityRepositoryAdapter'
import { InMemoryGoalRepositoryAdapter } from '@infrastructure/InMemoryGoalRepositoryAdapter'
import { InMemoryNotificationServiceAdapter } from '@infrastructure/InMemoryNotificationServiceAdapter'
import { HealthInsightsCalculatorUsecase } from './HealthInsightsCalculatorUsecase'
import { Activity } from '@/domain/Activity'
describe('HealthInsightsCalculatorUsecase', () => {
describe('Recommandations', () => {
it('should have a recommandation to walk with no activities', async () => {
// Arrange
const usecase = new HealthInsightsCalculatorUsecase(
new InMemoryActivityRepositoryAdapter([]),
new InMemoryGoalRepositoryAdapter([]),
new InMemoryNotificationServiceAdapter()
)
// Act
const insights = await usecase.calculateInsights('1')
// assert
expect(insights.recommendations.length).toBe(1)
expect(insights.recommendations).toContain('Start with 30 minutes of light walking daily')
})
it('should have a recommandation to make 3 activities by week with at least one cardio', async () => {
// Arrange
const activities: Activity[] = [
{
id: '1',
userId: '1',
type: 'walking',
duration: 30,
calories: 100,
date: new Date(),
intensity: 'low',
},
]
const usecase = new HealthInsightsCalculatorUsecase(
new InMemoryActivityRepositoryAdapter(activities),
new InMemoryGoalRepositoryAdapter([]),
new InMemoryNotificationServiceAdapter()
)
// Act
const insights = await usecase.calculateInsights('1')
// assert
expect(insights.recommendations.length).toBe(2)
expect(insights.recommendations).toContain('Try to be active at least 3 times per week')
expect(insights.recommendations).toContain(
'Add cardio exercises like running or cycling to improve cardiovascular health'
)
})
})
})
- domain: contient toute les concepts métier. Là où est défini le quoi
- application: contient toute les interfaces et usecases. Là où sont définis les intentions
- infrastructure: contient toute les implémentation externes. Là où sont implémenté les dépendances et effets de bords
Cette façon de programmer est devenue mon approche par défaut car ses avantages sont nombreux
Vous trouvez le code d’exemple de l’application sur Github.
- Logique métier et intentions explicites
- Code robuste : facilité pour écrire tests unitaire rapidement (approche TDD très adaptée)
- Séparation des responsabilité : le code gérant l’infrastructure est dissocié du code métier, le code est plus simple à maintenir
- Plus de fichiers à définir : chaque implémentation concrète a un “port”
- courbe apprentissage plus longue car il faut pratiquer et échanger avec d’autres développeurs expérimentés. Peu enseigné à l’école.
Attention, l’architecture port/adapter n’est pas la seule architecture qui permet de bien isoler son code et expliciter sa logique métier. On peut citer aussi l’architecture cleancode.
Si cette approche vous convient et que vous cherchez à aller plus loin, vous plongerez très vite dans le monde du DDD (Domain Drivent Design) et d’autres pratiques complémentaires à l’architecture port/adapter comme le CQRS (Command Query Responsability Segregation)
Documentée en 2005 dans le blog d’Alistair Cockburn, l’architecture Hexagonale (renommée Port/Adapter depuis) permet de concevoir des applications de qualité avec une logique métier explicitée.
Citation d’Alistair Cockburn :
Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases.