Retour au blog
NestJS

Inversion de dépendances avec Nest.js (ou comment enfoncer les portes ouvertes)

Parce que l'injection, c'est trop cool, à condition de s'injecter les bons trucs

Entre vous et moi, vous la connaissez, cette sensation quand vous découvrez que votre plat préféré au restaurant est en fait un surgelé avec des morceaux de plastique dedans ? (si vous avez pensé à Buffalo Grill... c'est... vous que ça regarde)

C'est un peu ce que je ressens quand je vois du code sans inversion de dépendances. L'apparence est là, ça fonctionne, mais au fond, on sait tous que c'est de l'arnaque.

Ca marche bien comme ça

Mise en situation : vous êtes le seul dev de la boite, et vous avez implémenté un système de commande de repas préparés par votre grand mère, experte en kebab salade oignon tomate sauce blanche, et vous avez un super système de notifications lorsque la commande est prête :

@Controller('notifications')
export class NotificationController {
  private twilioClient = new Twilio(process.env.TWILIO_SID, process.env.TWILIO_TOKEN);
  
  @Post('order-ready')
  async notifyOrderReady(@Body() data: OrderReadyDto) {
    try {
      await this.twilioClient.messages.create({
        body: `Votre commande #${data.orderId} est prête !`,
        from: process.env.TWILIO_NUMBER,
        to: data.customerPhone
      });

      // On balance direct dans la base sans ORM parce que pourquoi pas ?
      const connection = await createConnection();
      // Qu'est ce que tu vas faire ? Tu vas me trainer en justice ?
      await connection.query(
        'INSERT INTO notifications (order_id, type, status) VALUES ($1, $2, $3)',
        [data.orderId, 'SMS', 'SENT']
      );
      
    } catch (error) {
      console.log('Oups:', error); // Ahah.
      throw error;
    }
  }
}

Ca fonctionne bien, mais arrive le jour fatidique : Mamie est décédée.

Au delà des soucis de qualité que ça va représenter, la boite est reprise par votre oncle - qui veut industrialiser les kebab de mamie, et votre cousin product owner vous rejoint dans l'aventure en même temps.

Ca ne loupe pas, à peine arriver, il vous prend déjà la tête :

"Ouais les SMS c'est cool, mais les mails c'est moins polluant pour la planète et on pourrait faire de la comm Tiktok là dessus mais ce sera pas du greenwashing lol (mdr).

Et les notifications Push c'est l'avenir.

Et je veux qu'on track absolument tout ce qui se passe (pour le coup, on ne peut que lui donner raison)"

C'est le PO, et le fils du patron.

Soyez raisonnable.

Acceptez l'évidence : vous devez faire comme tous les devs du monde entier : dire "oui".

Et dire "merci", aussi.

Contractualisons

En refactorisant (refactorant ?) (modifiant ?) (changeant ?) tout ça, on pourrait gagner des points de karma :

// Les contrats
interface INotificationService {
  send(notification: NotificationDto): Promise<NotificationResult>;
}

interface INotificationTracker {
  track(notification: NotificationDto, result: NotificationResult): Promise<void>;
}

// Le DTO
interface NotificationDto {
  type: 'SMS' | 'EMAIL' | 'PUSH';
  orderId: string;
  recipient: string;
  content: {
    title?: string;
    body: string;
  };
}

// Les implémentations concrètes
@Injectable()
class TwilioNotificationService implements INotificationService {
  constructor(
    private readonly twilioClient: Twilio,
    @Inject('CONFIG')
    private readonly config: INotificationConfig
  ) {}

  async send(notification: NotificationDto): Promise<NotificationResult> {
    if (notification.type !== 'SMS') {
      throw new UnsupportedNotificationTypeError();
    }

    const result = await this.twilioClient.messages.create({
      body: notification.content.body,
      from: this.config.smsFromNumber,
      to: notification.recipient
    });

    return {
      id: result.sid,
      status: result.status,
      provider: 'TWILIO'
    };
  }
}

@Injectable()
class DatabaseNotificationTracker implements INotificationTracker {
  constructor(
    @InjectRepository(NotificationEntity)
    private readonly notificationRepo: Repository<NotificationEntity>
  ) {}

  async track(notification: NotificationDto, result: NotificationResult): Promise<void> {
    await this.notificationRepo.save({
      orderId: notification.orderId,
      type: notification.type,
      status: result.status,
      providerId: result.id,
      provider: result.provider,
      metadata: result
    });
  }
}

// Le controller qui fait plaisir
@Controller('notifications')
export class NotificationController {
  constructor(
    @Inject('NOTIFICATION_SERVICE')
    private readonly notificationService: INotificationService,
    @Inject('NOTIFICATION_TRACKER')
    private readonly notificationTracker: INotificationTracker
  ) {}
  
  @Post('order-ready')
  async notifyOrderReady(@Body() data: OrderReadyDto) {
    const notification: NotificationDto = {
      type: data.preferredChannel || 'SMS',
      orderId: data.orderId,
      recipient: data.customerContact,
      content: {
        body: `Votre commande #${data.orderId} est prête !`
      }
    };

    const result = await this.notificationService.send(notification);
    await this.notificationTracker.track(notification, result);
    
    return result;
  }
}

// Le module qui orchestre tout ça
@Module({
  controllers: [NotificationController],
  providers: [
    {
      provide: 'NOTIFICATION_SERVICE',
      useClass: process.env.SMS_PROVIDER === 'MESSAGEBIRD' 
        ? MessageBirdNotificationService 
        : TwilioNotificationService
    },
    {
      provide: 'NOTIFICATION_TRACKER',
      useClass: process.env.ENABLE_DATADOG 
        ? CompositeTracker // Combine DB + Datadog
        : DatabaseNotificationTracker
    }
  ]
})
export class NotificationModule {}

Besoin d'aide sur ce sujet ?

Discutons de comment je peux vous accompagner.

Faire le diagnostic