Context Scope

Architecture Hexagonale dans les Microservices : Stratégie d'Implémentation

📅 Publié le 28 December 2024 • ⏱️ 5 min de lecture

Architecture Hexagonale dans les Microservices : Stratégie d'Implémentation

L'Architecture Hexagonale, aussi connue sous le nom de "Ports and Adapters", trouve une application particulièrement pertinente dans le contexte des microservices. Cette approche permet de maintenir la séparation des préoccupations tout en facilitant les tests et l'évolutivité.

Rappel sur l'Architecture Hexagonale

L'architecture hexagonale, conceptualisée par Alistair Cockburn, place la logique métier au centre et isole les préoccupations techniques (base de données, frameworks web, etc.) à la périphérie.

Principes fondamentaux

  1. Domaine au centre : La logique métier ne dépend d'aucune technologie externe
  2. Ports : Interfaces définies par le domaine pour communiquer avec l'extérieur
  3. Adapters : Implémentations concrètes qui s'adaptent aux ports
  4. Inversion de dépendances : Les détails dépendent des abstractions

Application aux Microservices

Dans un contexte microservices, chaque service peut adopter cette architecture pour maintenir sa cohérence interne.

Structure d'un microservice hexagonal

user-service/
├── domain/
│   ├── entities/
│   │   └── User.ts
│   ├── services/
│   │   └── UserService.ts
│   └── ports/
│       ├── UserRepository.ts
│       └── NotificationService.ts
├── adapters/
│   ├── inbound/
│   │   ├── rest/
│   │   │   └── UserController.ts
│   │   └── messaging/
│   │       └── UserEventHandler.ts
│   └── outbound/
│       ├── persistence/
│       │   └── MongoUserRepository.ts
│       └── notification/
│           └── EmailNotificationService.ts
└── config/
    └── DependencyContainer.ts

Implémentation pratique

1. Définition du domaine

// domain/entities/User.ts
export class User {
  constructor(
    private readonly id: UserId,
    private email: Email,
    private profile: UserProfile
  ) {}

  updateEmail(newEmail: Email): void {
    // Logique métier pour la validation
    if (!newEmail.isValid()) {
      throw new InvalidEmailError();
    }
    
    this.email = newEmail;
  }

  // Autres méthodes métier...
}

// domain/services/UserService.ts
export class UserService {
  constructor(
    private userRepository: UserRepository,
    private notificationService: NotificationService
  ) {}

  async registerUser(command: RegisterUserCommand): Promise<User> {
    // Validation métier
    if (await this.userRepository.existsByEmail(command.email)) {
      throw new UserAlreadyExistsError();
    }

    const user = new User(
      UserId.generate(),
      command.email,
      UserProfile.fromCommand(command)
    );

    await this.userRepository.save(user);
    await this.notificationService.sendWelcomeEmail(user);

    return user;
  }
}

2. Ports (interfaces)

// domain/ports/UserRepository.ts
export interface UserRepository {
  save(user: User): Promise<void>;
  findById(id: UserId): Promise<User | null>;
  existsByEmail(email: Email): Promise<boolean>;
  delete(id: UserId): Promise<void>;
}

// domain/ports/NotificationService.ts
export interface NotificationService {
  sendWelcomeEmail(user: User): Promise<void>;
  sendPasswordResetEmail(user: User, token: string): Promise<void>;
}

3. Adapters entrants

// adapters/inbound/rest/UserController.ts
@Controller('/users')
export class UserController {
  constructor(private userService: UserService) {}

  @Post()
  async createUser(@Body() request: CreateUserRequest): Promise<UserResponse> {
    const command = new RegisterUserCommand(
      new Email(request.email),
      request.firstName,
      request.lastName
    );

    const user = await this.userService.registerUser(command);
    return UserResponse.fromUser(user);
  }
}

// adapters/inbound/messaging/UserEventHandler.ts
@EventHandler()
export class UserEventHandler {
  constructor(private userService: UserService) {}

  @EventPattern('user.registration.requested')
  async handleUserRegistration(event: UserRegistrationRequestedEvent): Promise<void> {
    const command = RegisterUserCommand.fromEvent(event);
    await this.userService.registerUser(command);
  }
}

4. Adapters sortants

// adapters/outbound/persistence/MongoUserRepository.ts
@Repository()
export class MongoUserRepository implements UserRepository {
  constructor(private mongoClient: MongoClient) {}

  async save(user: User): Promise<void> {
    const document = {
      _id: user.id.value,
      email: user.email.value,
      profile: user.profile.toDocument(),
      createdAt: new Date()
    };

    await this.mongoClient
      .db('users')
      .collection('users')
      .replaceOne({ _id: document._id }, document, { upsert: true });
  }

  async findById(id: UserId): Promise<User | null> {
    const document = await this.mongoClient
      .db('users')
      .collection('users')
      .findOne({ _id: id.value });

    return document ? User.fromDocument(document) : null;
  }
}

Communication inter-microservices

Pattern Anti-Corruption Layer

Pour protéger votre domaine des changements dans les services externes :

// adapters/outbound/external/OrderServiceAdapter.ts
export class OrderServiceAdapter implements OrderService {
  constructor(private httpClient: HttpClient) {}

  async getUserOrders(userId: UserId): Promise<UserOrder[]> {
    try {
      const response = await this.httpClient.get(
        `/orders/by-user/${userId.value}`
      );

      // Transformation des données externes vers notre modèle
      return response.data.map(this.transformToUserOrder);
    } catch (error) {
      if (error.status === 404) {
        return [];
      }
      throw new ExternalServiceError('Order service unavailable');
    }
  }

  private transformToUserOrder(externalOrder: any): UserOrder {
    // Logique de transformation et validation
    return new UserOrder(
      new OrderId(externalOrder.id),
      new Money(externalOrder.total_amount, externalOrder.currency),
      new Date(externalOrder.created_at)
    );
  }
}

Tests dans l'architecture hexagonale

Tests unitaires du domaine

describe('UserService', () => {
  let userService: UserService;
  let mockUserRepository: jest.Mocked<UserRepository>;
  let mockNotificationService: jest.Mocked<NotificationService>;

  beforeEach(() => {
    mockUserRepository = {
      save: jest.fn(),
      findById: jest.fn(),
      existsByEmail: jest.fn(),
      delete: jest.fn(),
    };

    mockNotificationService = {
      sendWelcomeEmail: jest.fn(),
      sendPasswordResetEmail: jest.fn(),
    };

    userService = new UserService(mockUserRepository, mockNotificationService);
  });

  it('should register a new user successfully', async () => {
    // Arrange
    const command = new RegisterUserCommand(
      new Email('test@example.com'),
      'John',
      'Doe'
    );
    mockUserRepository.existsByEmail.mockResolvedValue(false);

    // Act
    const result = await userService.registerUser(command);

    // Assert
    expect(mockUserRepository.save).toHaveBeenCalledWith(
      expect.any(User)
    );
    expect(mockNotificationService.sendWelcomeEmail).toHaveBeenCalled();
  });
});

Tests d'intégration

describe('UserController Integration', () => {
  let app: INestApplication;
  let userRepository: UserRepository;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [UserModule],
    })
    .overrideProvider(UserRepository)
    .useClass(InMemoryUserRepository)
    .compile();

    app = moduleRef.createNestApplication();
    userRepository = moduleRef.get(UserRepository);
    await app.init();
  });

  it('should create user via REST API', async () => {
    const response = await request(app.getHttpServer())
      .post('/users')
      .send({
        email: 'test@example.com',
        firstName: 'John',
        lastName: 'Doe'
      })
      .expect(201);

    expect(response.body.email).toBe('test@example.com');
  });
});

Déploiement et monitoring

Configuration par environnement

// config/DependencyContainer.ts
export class DependencyContainer {
  static configure(environment: Environment): Container {
    const container = new Container();

    // Repositories
    if (environment === 'production') {
      container.bind(UserRepository).to(MongoUserRepository);
    } else {
      container.bind(UserRepository).to(InMemoryUserRepository);
    }

    // External services
    container.bind(NotificationService).to(EmailNotificationService);
    container.bind(OrderService).to(OrderServiceAdapter);

    // Domain services
    container.bind(UserService).toSelf();

    return container;
  }
}

Health checks

// adapters/inbound/rest/HealthController.ts
@Controller('/health')
export class HealthController {
  constructor(
    private userRepository: UserRepository,
    private notificationService: NotificationService
  ) {}

  @Get()
  async checkHealth(): Promise<HealthStatus> {
    const checks = await Promise.allSettled([
      this.userRepository.healthCheck(),
      this.notificationService.healthCheck(),
    ]);

    return {
      status: checks.every(check => check.status === 'fulfilled') 
        ? 'healthy' 
        : 'unhealthy',
      dependencies: {
        database: checks[0].status,
        notification: checks[1].status,
      }
    };
  }
}

Avantages dans un contexte microservices

  1. Testabilité : Isolation complète du domaine
  2. Évolutivité : Changement facile des adapters
  3. Indépendance technologique : Chaque service peut évoluer séparément
  4. Maintenabilité : Séparation claire des responsabilités

Défis et solutions

Complexité perçue

  • Problème : Plus de couches = plus de complexité
  • Solution : Automatisation avec des générateurs de code

Over-engineering

  • Problème : Architecture trop lourde pour des services simples
  • Solution : Adapter le niveau d'abstraction à la complexité métier

Performance

  • Problème : Couches supplémentaires = overhead
  • Solution : Profiling et optimisation ciblée

Conclusion

L'architecture hexagonale dans les microservices offre un excellent équilibre entre flexibilité et maintenabilité. Elle permet de :

  • Maintenir la logique métier pure et testable
  • Faciliter l'évolution technologique
  • Améliorer la résilience du système global

L'investissement initial en complexité est largement compensé par les bénéfices à long terme, particulièrement dans des systèmes distribués complexes.

📚 À lire aussi

Pattern Repository : Approche Moderne

Repenser le pattern repository pour les applications contemporaines. Quand l'utiliser, comment l'implémenter efficacement, et les alternatives à considérer à l'ère des ORMs et architectures cloud-native.

#tactical #patterns #infrastructure

Bounded Contexts : Guide Complet de Mise en Œuvre

Comment identifier, concevoir et implémenter des bounded contexts efficaces dans des domaines complexes. Patterns stratégiques, techniques de context mapping et exemples concrets.

#strategic #bounded-context #architecture