Image de couverture pour Expliciter ses règles métier avec l'architecture port/adapter

Expliciter ses règles métier avec l'architecture port/adapter

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.