Clean Code : Quand les principes se renforcent mutuellement

2024-10-20

ArchitectureClean CodeBest Practices

Introduction

Les principes de Clean Code sont souvent présentés isolément : SOLID, DRY, KISS, YAGNI… Chacun fait sens individuellement, mais leur véritable puissance se révèle dans leurs interactions. Lorsqu’ils sont appliqués ensemble, ces principes se renforcent mutuellement pour créer un système cohérent et robuste.

Dans cet article, nous allons explorer comment trois principes fondamentaux - l’inversion de dépendance (DIP), la source unique de vérité (SSOT) et Don’t Repeat Yourself (DRY) - s’articulent pour produire du code véritablement maintenable.

Le problème : des principes isolés

Code sans principes

Commençons par un exemple typique de code qui ignore ces principes :

// ❌ Mauvais exemple
class UserService {
  async createUser(email: string, password: string) {
    // Validation dupliquée partout
    if (!email.includes('@')) {
      throw new Error('Invalid email');
    }
    if (password.length < 8) {
      throw new Error('Password too short');
    }

    // Logique métier couplée à l'infrastructure
    const hashedPassword = await bcrypt.hash(password, 10);
    const result = await db.query(
      'INSERT INTO users (email, password) VALUES (?, ?)',
      [email, hashedPassword]
    );

    // Envoi d'email couplé
    await fetch('https://mailservice.com/api/send', {
      method: 'POST',
      body: JSON.stringify({
        to: email,
        template: 'welcome',
      }),
    });

    return result;
  }

  async updateUser(id: string, email: string) {
    // Validation re-dupliquée
    if (!email.includes('@')) {
      throw new Error('Invalid email');
    }

    await db.query('UPDATE users SET email = ? WHERE id = ?', [email, id]);
  }
}

Ce code présente plusieurs problèmes :

  • Logique métier couplée à l’infrastructure (database, HTTP)
  • Validation dupliquée
  • Impossible à tester sans base de données réelle
  • Modification d’un service externe = modification de toute l’application

Premier principe : Inversion de dépendance (DIP)

Définition

Le code métier ne doit pas dépendre des détails d’implémentation. Les deux doivent dépendre d’abstractions.

Application

// ✅ Abstractions
interface UserRepository {
  save(user: User): Promise<void>;
  findById(id: string): Promise<User | null>;
  update(user: User): Promise<void>;
}

interface EmailService {
  sendWelcome(email: string): Promise<void>;
}

interface PasswordHasher {
  hash(password: string): Promise<string>;
}

// ✅ Logique métier pure
class UserService {
  constructor(
    private userRepository: UserRepository,
    private emailService: EmailService,
    private passwordHasher: PasswordHasher
  ) {}

  async createUser(email: string, password: string) {
    const hashedPassword = await this.passwordHasher.hash(password);

    const user = new User({
      email,
      password: hashedPassword,
    });

    await this.userRepository.save(user);
    await this.emailService.sendWelcome(email);

    return user;
  }
}

Bénéfices immédiats :

  • Testable avec des mocks
  • Changement d’implémentation sans toucher au métier
  • Dépendances explicites

Deuxième principe : Single Source of Truth (SSOT)

Le problème de la duplication

Même avec l’inversion de dépendance, nous avons encore de la duplication :

// ❌ Validation dupliquée
class UserService {
  async createUser(email: string, password: string) {
    if (!email.includes('@')) throw new Error('Invalid email');
    // ...
  }

  async updateUser(id: string, email: string) {
    if (!email.includes('@')) throw new Error('Invalid email');
    // ...
  }
}

Application de SSOT

Centralisons la logique dans l’entité elle-même :

// ✅ Source unique de vérité
class User {
  private constructor(
    private _id: string,
    private _email: string,
    private _password: string
  ) {}

  static create(email: string, password: string): User {
    User.validateEmail(email);
    User.validatePassword(password);

    return new User(
      generateId(),
      email,
      password
    );
  }

  updateEmail(newEmail: string): void {
    User.validateEmail(newEmail);
    this._email = newEmail;
  }

  private static validateEmail(email: string): void {
    if (!email.includes('@')) {
      throw new Error('Invalid email format');
    }
    if (email.length > 255) {
      throw new Error('Email too long');
    }
  }

  private static validatePassword(password: string): void {
    if (password.length < 8) {
      throw new Error('Password must be at least 8 characters');
    }
    if (!/[A-Z]/.test(password)) {
      throw new Error('Password must contain uppercase letter');
    }
  }

  get email(): string {
    return this._email;
  }
}

Nouveau service simplifié :

class UserService {
  constructor(
    private userRepository: UserRepository,
    private emailService: EmailService,
    private passwordHasher: PasswordHasher
  ) {}

  async createUser(email: string, password: string) {
    const hashedPassword = await this.passwordHasher.hash(password);

    // La validation est dans User.create
    const user = User.create(email, hashedPassword);

    await this.userRepository.save(user);
    await this.emailService.sendWelcome(user.email);

    return user;
  }

  async updateUser(id: string, newEmail: string) {
    const user = await this.userRepository.findById(id);
    if (!user) throw new Error('User not found');

    // La validation est dans user.updateEmail
    user.updateEmail(newEmail);

    await this.userRepository.update(user);
  }
}

Troisième principe : Don’t Repeat Yourself (DRY)

Au-delà de la duplication de code

DRY ne concerne pas seulement la duplication de code, mais la duplication de connaissance et de décisions.

Duplication de connaissance

// ❌ Duplication de connaissance
class UserRepository {
  async save(user: User) {
    await db.query(
      'INSERT INTO users (id, email, password) VALUES (?, ?, ?)',
      [user.id, user.email, user.password]
    );
  }
}

class UserDTO {
  static fromUser(user: User) {
    return {
      id: user.id,
      email: user.email,
      password: user.password,
    };
  }
}

// Les deux connaissent la structure de User

Solution : Centraliser la connaissance

// ✅ User connaît sa propre structure
class User {
  // ...

  toDatabase(): DatabaseRow {
    return {
      id: this._id,
      email: this._email,
      password: this._password,
      created_at: this._createdAt,
    };
  }

  toDTO(): UserDTO {
    return {
      id: this._id,
      email: this._email,
      // Note: pas de password dans le DTO
    };
  }

  static fromDatabase(row: DatabaseRow): User {
    // Reconstruction depuis la DB
    return new User(
      row.id,
      row.email,
      row.password,
      row.created_at
    );
  }
}

La synergie des trois principes

Comment ils se renforcent

  1. DIP + SSOT : L’inversion de dépendance crée des frontières claires où placer la logique (SSOT)

  2. SSOT + DRY : Une source unique élimine naturellement la duplication de connaissance

  3. DRY + DIP : Moins de duplication = moins de couplage = plus facile d’inverser les dépendances

Exemple complet

// Domain Layer (SSOT)
class User {
  private constructor(
    private _id: string,
    private _email: Email, // Value Object
    private _password: HashedPassword,
    private _createdAt: Date
  ) {}

  static create(email: string, password: string): User {
    return new User(
      generateId(),
      Email.create(email), // Validation déléguée
      HashedPassword.create(password),
      new Date()
    );
  }

  updateEmail(newEmail: string): void {
    this._email = Email.create(newEmail);
  }

  // Serialization (DRY)
  toDatabase(): DatabaseRow {
    return {
      id: this._id,
      email: this._email.value,
      password: this._password.value,
      created_at: this._createdAt,
    };
  }

  static fromDatabase(row: DatabaseRow): User {
    return new User(
      row.id,
      Email.fromString(row.email),
      HashedPassword.fromHash(row.password),
      row.created_at
    );
  }
}

// Value Objects (SSOT + DRY)
class Email {
  private constructor(private _value: string) {}

  static create(email: string): Email {
    if (!email.includes('@')) {
      throw new Error('Invalid email');
    }
    return new Email(email);
  }

  static fromString(email: string): Email {
    return new Email(email); // Déjà validé en DB
  }

  get value(): string {
    return this._value;
  }
}

// Application Layer (DIP)
class UserService {
  constructor(
    private userRepository: UserRepository, // Abstraction
    private emailService: EmailService,     // Abstraction
  ) {}

  async createUser(email: string, password: string): Promise<User> {
    const user = User.create(email, password);
    await this.userRepository.save(user);
    await this.emailService.sendWelcome(user.email);
    return user;
  }
}

// Infrastructure Layer (DIP)
class PostgresUserRepository implements UserRepository {
  async save(user: User): Promise<void> {
    const data = user.toDatabase(); // SSOT
    await this.db.query(
      'INSERT INTO users (id, email, password, created_at) VALUES ($1, $2, $3, $4)',
      [data.id, data.email, data.password, data.created_at]
    );
  }
}

Bénéfices de l’approche combinée

1. Testabilité maximale

describe('UserService', () => {
  it('should create user and send welcome email', async () => {
    const mockRepo = new InMemoryUserRepository();
    const mockEmail = new MockEmailService();
    const service = new UserService(mockRepo, mockEmail);

    await service.createUser('test@example.com', 'Password123');

    expect(mockRepo.users).toHaveLength(1);
    expect(mockEmail.sentEmails).toHaveLength(1);
  });
});

2. Évolutivité

Changer de base de données :

// Avant : PostgreSQL
const userRepo = new PostgresUserRepository(pgClient);

// Après : MongoDB
const userRepo = new MongoUserRepository(mongoClient);

// UserService ne change pas !
const service = new UserService(userRepo, emailService);

3. Maintenabilité

Modification de la validation email :

// ✅ Un seul endroit à modifier
class Email {
  static create(email: string): Email {
    // Nouvelle règle : bloquer les emails temporaires
    if (email.includes('tempmail')) {
      throw new Error('Temporary emails not allowed');
    }
    if (!email.includes('@')) {
      throw new Error('Invalid email');
    }
    return new Email(email);
  }
}

// Tous les use cases bénéficient automatiquement

Pièges à éviter

Sur-ingénierie

// ❌ Trop d'abstraction
interface EmailValidator {
  validate(email: string): boolean;
}

interface PasswordValidator {
  validate(password: string): boolean;
}

class UserFactory {
  constructor(
    private emailValidator: EmailValidator,
    private passwordValidator: PasswordValidator
  ) {}
}

// Pour créer un simple User !

Règle : N’introduire une abstraction que quand vous avez 2+ implémentations concrètes ou un besoin de test.

Mauvaise frontière de SSOT

// ❌ Validation dans le controller
class UserController {
  async create(req: Request) {
    if (!req.body.email.includes('@')) {
      return res.status(400).send('Invalid email');
    }
    // ...
  }
}

// ❌ Validation dans le repository
class UserRepository {
  async save(user: User) {
    if (!user.email.includes('@')) {
      throw new Error('Invalid email');
    }
    // ...
  }
}

Règle : La validation métier appartient au domaine (entities/value objects).

Conclusion

Les principes de Clean Code ne sont pas des règles isolées à appliquer mécaniquement. Ils forment un système cohérent où chaque principe renforce les autres :

  • DIP crée des frontières claires et testables
  • SSOT centralise la connaissance dans ces frontières
  • DRY élimine la duplication et renforce la cohésion

Ensemble, ils produisent un code :

  • Testable : grâce à l’inversion de dépendance
  • Maintenable : grâce à la source unique de vérité
  • Évolutif : grâce à l’élimination de la duplication

La prochaine fois que vous écrivez du code, ne pensez pas “j’applique SOLID” ou “j’applique DRY”. Pensez plutôt : “où est la connaissance ?”, “qui devrait en être responsable ?”, et “comment minimiser les dépendances ?”. Les principes suivront naturellement.

Points clés à retenir :

  • Les principes Clean Code se renforcent mutuellement
  • DIP + SSOT + DRY forment un système cohérent
  • La connaissance doit avoir un propriétaire unique et clair
  • L’abstraction sans besoin réel est de la sur-ingénierie
  • Pensez “responsabilité” avant “pattern”