Aller au contenu

Mappers

Introduction

Dans un service Feelbat, une même information est représentée différemment selon la couche qui la manipule : une entité ORM en base, un agrégat dans le domaine, un DTO GraphQL exposé au client.

Un mapper est l'objet qui traduit une représentation en une autre. Il vit à la frontière entre deux couches et connaît les deux formats, afin qu'aucune couche n'ait à connaître la représentation de l'autre.

Cette page prolonge la page Ports & Adapters, où le mapping a été évoqué au niveau des adapters.

Objectif

À la fin de cette page, vous comprendrez :

  • pourquoi les données changent de forme entre les couches ;
  • où les mappers se placent dans un service ;
  • comment un mapper de persistance, d'interface et de domaine s'écrivent ;
  • pourquoi un mapper ne doit contenir aucune règle métier ;
  • les conventions de nommage à respecter.

Pourquoi des mappers

Chaque couche possède sa propre représentation, adaptée à ses contraintes.

Couche Représentation Contrainte
Infrastructure Entité ORM (OrmRemoteAction) Correspondre au schéma de base
Domaine Agrégat et value objects (RemoteAction) Garantir les invariants métier
Interface DTO GraphQL (RemoteActionOutputGql) Correspondre au contrat exposé

Sans mapper, ces représentations finiraient par fuir d'une couche à l'autre : un agrégat exposé directement en GraphQL, ou une entité ORM manipulée par le domaine. Le mapper isole cette traduction en un seul endroit et empêche ce couplage.

Où vivent les mappers

Un service comporte plusieurs mappers, un par frontière traversée.

flowchart LR
    Gql[DTO GraphQL] <-->|Mapper d'interface| AppDto[DTO application]
    AppDto --> Service[Service applicatif]
    Service --> Aggregate[Agrégat]
    Aggregate <-->|Mapper de persistance| Orm[Entité ORM]
Frontière Rôle du mapper Exemple
Interface ⇆ Application Traduire le DTO exposé et le DTO applicatif toGqlRemoteActionOutput
Domaine ⇆ Infrastructure Traduire l'agrégat et l'entité ORM RemoteActionMapper
Interne au domaine Traduire un agrégat vers un autre format métier toSensorPostActions

Mapper de persistance

Le mapper de persistance traduit entre l'entité ORM et l'agrégat. C'est le plus important, car c'est lui qui reconstruit un objet de domaine valide à partir de données brutes.

Il expose deux sens de conversion (apps/api/remote-action/src/remote-action/infra/orm/mapper/remote-action.mapper.ts) :

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),
      targetType: RemoteActionTargetType.from(entity.target_type),
      payload: entity.payload as RemoteActionPayload,
      completedAt: entity.completed_at,
      createdAt: entity.created_at,
      updatedAt: entity.updated_at,
    });
  }

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

Deux points à retenir :

  • toDomain() reconstruit les value objects via leurs fabriques from(). La validation du domaine s'exécute donc à chaque chargement : une donnée corrompue en base est détectée dès sa lecture.
  • toOrm() aplatit l'agrégat en valeurs primitives attendues par la base.

Traduire les énumérations

Quand la base et le domaine expriment une même notion différemment, le mapper porte la correspondance. ConfigurationMapper (apps/api/configuration/src/infra/orm/mapper/configuration.mapper.ts) traduit par exemple le type de cible stocké en un value object dédié :

private static toDomainTarget(entity: OrmConfiguration): ConfigurationTarget {
  switch (entity.target_type) {
    case CONFIGURATION_TARGET_TYPE.GLOBAL:
      return ConfigurationTarget.global();
    case CONFIGURATION_TARGET_TYPE.PROJECT:
      return ConfigurationMapper.toProjectTarget(entity);
    case CONFIGURATION_TARGET_TYPE.SENSOR:
      return ConfigurationMapper.toSensorTarget(entity);
    case CONFIGURATION_TARGET_TYPE.MODULE:
      return ConfigurationMapper.toModuleTarget(entity);
    default:
      throw new Error('Unsupported configuration target type');
  }
}

Le default qui lève une erreur est important : un cas inconnu échoue immédiatement plutôt que de produire un objet de domaine incohérent.

Mapper d'interface

Le mapper d'interface traduit entre le DTO exposé (GraphQL) et le DTO applicatif. Il protège le domaine du contrat public : la forme exposée au client peut évoluer sans impacter l'application.

Chez Feelbat, ces mappers sont souvent de simples fonctions. Le cas le plus simple est une projection directe (apps/api/configuration/src/interfaces/graphql/mapper/vibration-norm-config.mapper.ts) :

export function toGqlVibrationNormConfigOutput(
  output: VibrationNormConfigOutput,
): VibrationNormConfigOutputGql {
  return {
    id: output.id,
    name: output.name,
    type: output.type,
    points: output.points,
    template_type: output.templateType,
    description: output.description,
  };
}

Traduire une union discriminée

Quand la sortie dépend d'un type, le mapper aiguille selon ce type. toGqlRemoteActionOutput (apps/api/remote-action/src/remote-action/interfaces/dto/gql/mapper/gql-remote-action.mapper.ts) choisit la bonne forme de payload :

export function toGqlRemoteActionOutput(
  input: RemoteActionOutput,
): RemoteActionOutputGql {
  switch (input.type) {
    case REMOTE_ACTION_TYPE.FREQUENCY:
      return toGqlRemoteActionFrequencyOutput(input);
    case REMOTE_ACTION_TYPE.SYNC:
      return toGqlRemotectionActionSyncOutput(input);
    case REMOTE_ACTION_TYPE.CHANGE_MODE:
      return toGqlRemoteActionChangeModeOutput(input);
    default:
      throw new Error(`Unknown remote action type: ${input.type}`);
  }
}

Ici encore, un type inconnu provoque une erreur explicite plutôt qu'une réponse silencieusement incomplète.

Mapper de domaine

Certaines traductions restent internes au domaine : transformer un agrégat en un autre format métier attendu par un autre contexte.

toSensorPostActions (apps/api/remote-action/src/remote-action/domains/mappers/post-action.mapper.ts) transforme des agrégats RemoteAction en la forme SensorPostAction attendue par le domaine des capteurs :

export function toSensorPostActions(
  actions: RemoteAction[],
): SensorPostAction[] {
  return actions.map((action): SensorPostAction => {
    if (action.type === REMOTE_ACTION_TYPE.FREQUENCY) {
      return {
        type: 'frequency',
        value: (action.payload as FrequencyPayload).value,
      };
    }

    // Autres types (ex. change_mode) traduits vers leur forme SensorPostAction.
    // ...
  });
}

Ce mapper reste une traduction de formes. La décision de quelles actions transmettre appartient, elle, au service applicatif et à l'agrégat.

Deux styles de mapper

Le dépôt utilise deux formes selon le contexte, sans que l'une soit préférable en toutes circonstances :

  • Classe avec méthodes statiques (RemoteActionMapper, ConfigurationMapper), pratique quand plusieurs conversions liées cohabitent (toDomain, toOrm, et leurs helpers privés).
  • Fonctions autonomes (toGqlRemoteActionOutput, toSensorPostActions), légères et faciles à composer, souvent utilisées côté interface et domaine.

Dans les deux cas, un mapper reste sans état et sans effet de bord.

Un mapper ne contient pas de règle métier

C'est la limite à ne jamais franchir. Un mapper traduit une forme en une autre ; il ne décide de rien.

Le mapper fait Le mapper ne fait pas
Recopier et convertir des champs Appliquer une règle métier
Reconstruire des value objects Modifier l'état d'un agrégat
Échouer sur un cas inconnu Décider quelles données traiter

Les règles métier vivent dans les agrégats et les value objects, comme décrit dans la page Domain-Driven Design. Une validation observée dans un mapper (from(), throw sur un cas inconnu) est une vérification d'intégrité de la traduction, pas une décision métier.

Warning

Si un mapper commence à choisir un comportement selon des conditions métier, cette logique doit être déplacée dans le domaine.

Conventions de nommage

Élément Convention Emplacement
Mapper de persistance classe ...Mapper avec toDomain / toOrm infra/orm/mapper/
Mapper d'interface fonction toGql... interfaces/.../mapper/
Mapper de domaine fonction descriptive (toSensorPostActions) domains/mappers/

Note

Le sens de conversion se lit dans le nom : toDomain, toOrm, toGql.... Cette convention rend explicite la frontière traversée.

Erreurs classiques

  • Exposer un agrégat directement en GraphQL sans passer par un mapper.
  • Faire manipuler une entité ORM par le domaine ou par un resolver.
  • Mettre une règle métier ou un effet de bord dans un mapper.
  • Ignorer silencieusement un cas inconnu au lieu de lever une erreur.
  • Créer un mapper unique et global au lieu d'un mapper par frontière.

Bonnes pratiques

Conventions Feelbat à respecter :

  • Un mapper est sans état et sans effet de bord.
  • toDomain() reconstruit les value objects pour revalider la donnée.
  • Chaque frontière possède son propre mapper.
  • Un cas inconnu échoue explicitement.
  • Aucune règle métier ne vit dans un mapper.

Pour aller plus loin

La règle d'or

Un mapper traduit une forme, il ne décide de rien.

Toute décision appartient au domaine ; le mapper se contente de faire passer les données d'une couche à l'autre.

À retenir

  • Chaque couche a sa propre représentation d'une même information.
  • Un mapper traduit d'une représentation à une autre, à une frontière précise.
  • toDomain() reconstruit des value objects et revalide donc la donnée au chargement.
  • Un mapper reste sans état, sans effet de bord et sans règle métier.
  • Un cas inconnu doit échouer explicitement plutôt que produire un objet incohérent.