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.