Aller au contenu

Ports & Adapters

Introduction

L'architecture hexagonale explique pourquoi le domaine doit rester isolé des détails techniques. Cette page explique comment cette isolation est réalisée concrètement dans les services Feelbat, à travers les ports et les adapters.

Un port est un contrat. Un adapter est une implémentation technique de ce contrat. Le branchement entre les deux est assuré par l'injection de dépendances de NestJS.

Objectif

À la fin de cette page, vous comprendrez :

  • la différence entre un port et un adapter ;
  • la distinction entre adapters primaires et secondaires ;
  • comment un adapter s'écrit dans un service Feelbat ;
  • comment un port est branché à son implémentation via NestJS ;
  • les conventions de nommage et d'organisation à respecter.

Port et adapter

Un port est une interface. Il décrit un besoin en termes de domaine, sans dire comment il est satisfait.

Un adapter est une classe qui implémente ce port avec une technologie précise (TypeORM, Kafka, GraphQL...).

Le domaine et l'application dépendent uniquement du port. L'adapter dépend du port et de la technologie. C'est l'inversion de dépendances décrite dans la page architecture hexagonale.

graph LR
    Service[Service applicatif] -->|dépend de| Port[Port]
    Adapter[Adapter] -->|implémente| Port
    Adapter -->|utilise| Techno[(Technologie)]

Deux familles d'adapters

Un hexagone a deux côtés. On distingue donc deux familles d'adapters selon le sens du flux.

Famille Aussi appelée Rôle Exemples Feelbat
Adapters primaires driving / inbound Déclenchent un cas d'usage Resolver GraphQL, controller Kafka
Adapters secondaires driven / outbound Sont appelés par l'application Repository ORM, dispatcher Kafka

Une même technologie peut apparaître des deux côtés. Kafka est par exemple primaire lorsqu'il déclenche un traitement (consumer) et secondaire lorsqu'il sert à publier un message (producer).

Un port est une interface

Les ports sont déclarés dans la couche application, dans un dossier ports.

Un port de persistance (apps/api/remote-action/src/remote-action/application/ports/remote-action-repository.port.ts) exprime un besoin métier, jamais une contrainte technique :

export interface RemoteActionRepositoryPort {
  create(options: CreateRemoteActionOptions): Promise<RemoteAction>;
  save(remoteAction: RemoteAction): Promise<RemoteAction>;
  saveMany(remoteActions: RemoteAction[]): Promise<void>;
  findById(id: string): Promise<RemoteAction | null>;
  findMany(filters: RemoteActionFilters): Promise<RemoteAction[]>;
  findPending(targetId: TargetId): Promise<RemoteAction[]>;
  count(filters: RemoteActionFilters): Promise<number>;
}

Le contrat manipule des agrégats (RemoteAction) et des value objects (TargetId). Il ne mentionne ni PostgreSQL, ni TypeORM, ni SQL.

Adapters primaires

Un adapter primaire traduit une requête externe en appel au service applicatif. Il ne contient aucune règle métier.

Resolver GraphQL

Le resolver (apps/api/remote-action/src/remote-action/interfaces/remote-action.resolver.ts) reçoit la requête GraphQL, extrait le contexte utilisateur et délègue au service :

@Resolver()
export class RemoteActionResolver {
  public constructor(private readonly app: RemoteActionService) {}

  @Mutation(() => RemoteActionOutputGql)
  @GqlAuthGuard()
  public async requestRemoteAction(
    @Args('input') input: RequestRemoteActionInputGql,
    @GqlUserContext() user: GqlUser,
  ): Promise<RemoteActionOutputGql> {
    const out: RemoteActionOutput = await this.app.requestRemoteAction({
      userId: user.userId,
      role: user.role,
      targetId: input.targetId,
      targetType: input.targetType,
      type: input.type,
      payload: input.payload,
    });

    return toGqlRemoteActionOutput(out);
  }
}

Le resolver ne fait que trois choses : recevoir, déléguer, formater la réponse.

Controller Kafka

Un message Kafka entrant est aussi un point d'entrée. Le controller (apps/api/remote-action/src/remote-action/interfaces/remote-action.kafka.ts) écoute un topic et appelle le même service applicatif :

@KafkaController()
export class RemoteActionKafkaController {
  public constructor(private readonly app: RemoteActionService) {}

  @KafkaTopic(KAFKA_TOPICS.MQTT_REMOTE_ACTION_KAFKA_TOPICS.REMOTE_ACTION_EXECUTED)
  public async onRemoteActionExecuted(
    @KafkaPayload() data: KafkaPayloads.RemoteActionExecutedPayloadKafka,
  ): Promise<void> {
    await this.app.handleRemoteActionExecuted({
      deviceId: data.deviceId,
      remainingOps: data.remainingOps,
    });
  }
}

Deux adapters primaires différents (GraphQL et Kafka) déclenchent la même application. Le domaine ne sait pas d'où vient la demande.

Adapters secondaires

Un adapter secondaire implémente un port dont l'application a besoin pour agir sur l'extérieur.

Repository ORM

L'adapter de persistance (apps/api/remote-action/src/remote-action/infra/orm/orm-remote-action.repository.ts) implémente le port de repository avec TypeORM :

@Injectable()
export class OrmRemoteActionRepository implements RemoteActionRepositoryPort {
  public constructor(
    @InjectRepository(OrmRemoteAction, RsManagerDatabaseModule.Name)
    private readonly repository: Repository<OrmRemoteAction>,
  ) {}

  public async save(remoteAction: RemoteAction): Promise<RemoteAction> {
    const entity: OrmRemoteAction = RemoteActionMapper.toOrm(remoteAction);
    const saved: OrmRemoteAction = await this.repository.save(entity);
    return RemoteActionMapper.toDomain(saved);
  }

  public async findById(id: string): Promise<RemoteAction | null> {
    const entity: OrmRemoteAction | null = await this.repository.findOne({
      where: { id },
    });

    if (!entity) {
      return null;
    }

    return RemoteActionMapper.toDomain(entity);
  }

  // ... create, saveMany, findMany, findPending, count
}

Point important : l'adapter implémente le port (implements RemoteActionRepositoryPort) et renvoie des agrégats, jamais des entités ORM. La conversion entre les deux mondes est confiée à un mapper.

Dispatcher Kafka

Le port RemoteActionDispatcherPort exprime le besoin de « publier une action ». Son adapter (apps/api/remote-action/src/remote-action/infra/messaging/kafka-remote-action.dispatcher.ts) le réalise avec Kafka :

@Injectable()
export class KafkaRemoteActionDispatcher implements RemoteActionDispatcherPort {
  public constructor(private readonly kafkaService: KafkaProducerService) {}

  public async dispatchAction(
    message: RemoteActionDispatchInput,
  ): Promise<void> {
    await this.kafkaService.emit(
      KAFKA_TOPICS.MQTT_REMOTE_ACTION_KAFKA_TOPICS.REMOTE_ACTION_REQUESTED,
      {
        actionId: message.actionId,
        deviceId: message.deviceId,
        family: message.family,
        payload: message.payload,
        type: message.type.value,
        pendingsOps: message.pendingsOps.map((op) => op.value),
      },
    );
  }

  // ... dispatchActionCompleted, dispatchPostActionUpdate
}

Le service applicatif appelle dispatcher.dispatchAction(...) sans savoir que Kafka est utilisé derrière.

Le mapping à la frontière

Aux frontières de l'hexagone, les données changent de forme : entité ORM d'un côté, agrégat de l'autre. Cette traduction est isolée dans un mapper, pour que ni le domaine ni la technologie ne connaissent la représentation de l'autre.

export class RemoteActionMapper {
  static toDomain(entity: OrmRemoteAction): RemoteAction {
    return RemoteAction.rehydrate({
      id: RemoteActionId.from(entity.id),
      type: RemoteActionType.from(entity.type),
      targetId: TargetId.from(entity.target_id),
      status: RemoteActionStatus.from(entity.status),
      // ...
    });
  }

  static toOrm(action: RemoteAction): OrmRemoteAction {
    const orm: OrmRemoteAction = new OrmRemoteAction();
    orm.id = action.id;
    orm.status = action.status;
    // ...
    return orm;
  }
}

Le rôle et les conventions des mappers sont détaillés dans une page dédiée : Mappers.

Le branchement des ports

Un port est une interface TypeScript. Or une interface n'existe pas à l'exécution : NestJS ne peut donc pas l'injecter directement. Feelbat utilise pour cela des tokens d'injection.

Déclarer un token

Chaque port dispose d'un token Symbol (apps/api/remote-action/src/remote-action/application/ports/tokens.ts) :

export const REMOTE_ACTION_REPO = Symbol('REMOTE_ACTION_REPO');
export const REMOTE_ACTION_TARGET = Symbol('REMOTE_ACTION_TARGET');
export const REMOTE_ACTION_CHECKER = Symbol('REMOTE_ACTION_CHECKER');
export const REMOTE_ACTION_DISPATCHER = Symbol('REMOTE_ACTION_DISPATCHER');

Injecter par le token

Le service applicatif dépend du port et le reçoit via son token. Le type utilisé est bien le port, pas l'adapter :

@Injectable()
export class RemoteActionService {
  public constructor(
    @Inject(REMOTE_ACTION_REPO)
    private readonly remoteActionRepository: RemoteActionRepositoryPort,
    @Inject(REMOTE_ACTION_DISPATCHER)
    private readonly dispatcher: RemoteActionDispatcherPort,
    // ...
  ) {}
}

Associer le token à l'adapter

Le module associe chaque token à une implémentation concrète (apps/api/remote-action/src/remote-action/remote-action.module.ts) :

@Module({
  providers: [
    RemoteActionService,
    RemoteActionResolver,
    RemoteActionKafkaController,

    { provide: REMOTE_ACTION_REPO, useClass: OrmRemoteActionRepository },
    { provide: REMOTE_ACTION_DISPATCHER, useClass: KafkaRemoteActionDispatcher },
    { provide: REMOTE_ACTION_CHECKER, useClass: RemoteActionAuthz },
    { provide: REMOTE_ACTION_TARGET, useClass: OrmRemoteActionTarget },
  ],
})
export class RemoteActionModule {}

C'est ici, et seulement ici, que le choix technique est fait. Changer d'implémentation (par exemple remplacer OrmRemoteActionRepository par une version MongoDB) se limite à modifier la ligne useClass. Le service, le domaine et les tests restent inchangés.

Tip

Pour tester le service applicatif, il suffit de fournir un useClass (ou useValue) pointant vers un adapter factice. Le domaine étant découplé de la technologie, aucun accès réel à la base ou à Kafka n'est nécessaire.

Vue d'ensemble

Le schéma ci-dessous rassemble les deux familles d'adapters autour de l'application.

flowchart LR
    subgraph Primaires
        Resolver[GraphQL Resolver]
        KafkaIn[Kafka Controller]
    end

    subgraph Application
        Service[RemoteActionService]
        RepoPort[RemoteActionRepositoryPort]
        DispPort[RemoteActionDispatcherPort]
    end

    subgraph Secondaires
        OrmRepo[OrmRemoteActionRepository]
        KafkaOut[KafkaRemoteActionDispatcher]
    end

    Resolver --> Service
    KafkaIn --> Service
    Service --> RepoPort
    Service --> DispPort
    OrmRepo -->|implémente| RepoPort
    KafkaOut -->|implémente| DispPort
    OrmRepo --> Db[(PostgreSQL)]
    KafkaOut --> Kafka[(Kafka)]

Un port par besoin, pas par technologie

Un port ne se limite pas à la persistance. Tout besoin sortant de l'application mérite son port.

Le port RemoteActionTargetPort exprime par exemple le besoin de « retrouver la cible d'une action » (un capteur, une feelbox), indépendamment de la manière dont cette cible est récupérée :

export interface RemoteActionTargetPort {
  findTargetWithId(input: FindTargetInput): Promise<RemoteActionTarget | null>;
  findTargetWithDeviceId(
    input: FindTargetWithDeviceIdInput,
  ): Promise<RemoteActionTarget | null>;
}

Nommer les ports par le besoin métier (...Repository, ...Dispatcher, ...Target, ...Authz) plutôt que par la technologie garde le domaine lisible et stable.

Conventions de nommage

Élément Convention Emplacement
Port interface suffixée ...Port application/ports/
Token Symbol('...') application/ports/tokens.ts
Adapter de persistance préfixe Orm... infra/orm/
Adapter de messagerie préfixe Kafka... infra/messaging/
Adapter primaire resolver ou controller interfaces/
Mapper suffixe ...Mapper à la frontière concernée

Warning

L'arborescence exacte peut varier légèrement d'un service à l'autre (par exemple app/ ou application/), mais la séparation reste la même : les ports dans l'application, les adapters dans l'infrastructure et les interfaces.

Erreurs classiques

  • Injecter directement un adapter (OrmRemoteActionRepository) au lieu du port.
  • Mettre de la logique métier dans un resolver ou un controller Kafka.
  • Faire renvoyer des entités ORM par un repository au lieu d'agrégats.
  • Nommer un port d'après sa technologie (TypeOrmRepositoryPort).
  • Faire le choix d'implémentation ailleurs que dans le module.

Bonnes pratiques

Conventions Feelbat à respecter :

  • Un port exprime un besoin métier, jamais une technologie.
  • Un adapter implémente explicitement son port (implements ...Port).
  • L'application dépend des ports via des tokens Symbol.
  • Le choix d'implémentation est centralisé dans le module.
  • La conversion entre technologie et domaine passe par un mapper.

Pour aller plus loin

La règle d'or

L'application ne connaît que des ports.

Les technologies vivent dans les adapters, et le seul endroit qui les relie au domaine est le module.

À retenir

  • Un port est un contrat, un adapter en est l'implémentation technique.
  • Les adapters primaires déclenchent l'application ; les adapters secondaires sont appelés par elle.
  • Les ports sont injectés via des tokens Symbol, car une interface n'existe pas à l'exécution.
  • Le module est le seul endroit où le choix d'implémentation est fait.
  • Cette organisation rend un service testable et permet de changer une technologie sans toucher au domaine.