Patterns Event Sourcing : Au-Delà des Bases
Patterns avancés pour implémenter Event Sourcing en production. Stratégies de snapshotting, patterns de projection et gestion de l'évolution des schémas avec exemples pratiques.
Le Repository Pattern est l'un des patterns les plus débattus en DDD. Populaire dans les années 2000, il est aujourd'hui remis en question avec l'évolution des ORMs et des architectures cloud-native.
Voici une analyse moderne de quand, comment et pourquoi utiliser le Repository Pattern en 2024.
La recommandation est contextuelle :
// Au lieu de Repository générique avec CRUD complet
interface GenericRepository<T> {
create(entity: T): Promise<T>;
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
update(entity: T): Promise<T>;
delete(id: string): Promise<void>;
}
// Préférez des repositories spécialisés par agrégat
interface CustomerRepository {
// Méthodes métier expressives
findByEmail(email: Email): Promise<Customer | null>;
findActiveCustomersInRegion(region: Region): Promise<Customer[]>;
save(customer: Customer): Promise<void>;
nextIdentity(): CustomerId;
}
// Domain/Queries/CustomerQueries.ts
export class FindCustomersByRegionQuery {
constructor(
public readonly region: Region,
public readonly includeInactive: boolean = false,
public readonly pagination?: Pagination
) {}
}
// Infrastructure/CustomerRepository.ts
export class SqlCustomerRepository implements CustomerRepository {
async findByQuery(query: FindCustomersByRegionQuery): Promise<Customer[]> {
let sqlQuery = this.db
.select()
.from(customers)
.where(eq(customers.region, query.region.value));
if (!query.includeInactive) {
sqlQuery = sqlQuery.where(eq(customers.isActive, true));
}
if (query.pagination) {
sqlQuery = sqlQuery
.limit(query.pagination.pageSize)
.offset(query.pagination.offset);
}
const rows = await sqlQuery;
return rows.map(Customer.fromDatabase);
}
}
export class EventSourcedOrderRepository implements OrderRepository {
constructor(
private eventStore: EventStore,
private snapshotStore: SnapshotStore
) {}
async findById(orderId: OrderId): Promise<Order | null> {
// Tentative de récupération depuis snapshot
const snapshot = await this.snapshotStore.get(orderId.value);
const fromVersion = snapshot?.version ?? 0;
// Récupération des événements depuis le snapshot
const events = await this.eventStore.getEvents(
orderId.value,
fromVersion
);
if (!events.length && !snapshot) {
return null;
}
// Reconstruction de l'agrégat
let order = snapshot ?
Order.fromSnapshot(snapshot) :
new Order(orderId);
return order.replayEvents(events);
}
async save(order: Order): Promise<void> {
const uncommittedEvents = order.getUncommittedEvents();
await this.eventStore.saveEvents(
order.id.value,
uncommittedEvents,
order.version
);
// Snapshot si nécessaire
if (this.shouldCreateSnapshot(order.version)) {
await this.snapshotStore.save(order.createSnapshot());
}
order.markEventsAsCommitted();
}
}
// Application/Services/CustomerService.ts
export class CustomerService {
constructor(private orm: PrismaClient) {}
async registerCustomer(command: RegisterCustomerCommand): Promise<Customer> {
// Validation métier
const existingCustomer = await this.orm.customer.findUnique({
where: { email: command.email }
});
if (existingCustomer) {
throw new CustomerAlreadyExistsError();
}
// Création avec logique métier
const customer = Customer.create(command);
// Persistance directe
await this.orm.customer.create({
data: customer.toPersistence()
});
return customer;
}
}
// Write side - Repository pour commandes
interface CustomerWriteRepository {
save(customer: Customer): Promise<void>;
findByIdForUpdate(id: CustomerId): Promise<Customer | null>;
}
// Read side - Query handlers directs
export class CustomerQueryHandler {
constructor(private readDb: ReadOnlyDatabase) {}
async getCustomerProfile(query: GetCustomerProfileQuery): Promise<CustomerProfile> {
// Requête optimisée pour lecture
const result = await this.readDb.query(`
SELECT c.id, c.name, c.email,
COUNT(o.id) as order_count,
SUM(o.total) as total_spent
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE c.id = ?
GROUP BY c.id
`, [query.customerId]);
return CustomerProfile.fromQueryResult(result[0]);
}
}
// Plus simple que Repository, plus expressif que ORM direct
export class CustomerDataAccess {
constructor(private db: Database) {}
async findActiveCustomersWithRecentOrders(
daysBack: number = 30
): Promise<CustomerWithOrderSummary[]> {
return this.db.query(`
SELECT
c.*,
COUNT(o.id) as recent_order_count,
MAX(o.created_at) as last_order_date
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id
WHERE c.is_active = true
AND o.created_at >= DATE_SUB(NOW(), INTERVAL ? DAY)
GROUP BY c.id
HAVING recent_order_count > 0
`, [daysBack]);
}
}
// ✅ Bon cas d'usage : Domaine complexe
interface OrderRepository {
// Méthodes métier spécifiques
findOrdersRequiringApproval(): Promise<Order[]>;
findOverdueOrders(asOfDate: Date): Promise<Order[]>;
findOrdersByCustomerCredit(creditLevel: CreditLevel): Promise<Order[]>;
// Pas de méthodes génériques CRUD
}
// ❌ Mauvais cas d'usage : CRUD simple
interface ProductRepository {
create(product: Product): Promise<Product>;
findById(id: string): Promise<Product>;
update(product: Product): Promise<Product>;
delete(id: string): Promise<void>;
findAll(): Promise<Product[]>; // Red flag !
}
// Au lieu de mocks complexes
const mockRepository = {
findByEmail: jest.fn(),
save: jest.fn()
};
// Préférez des tests d'intégration avec base en mémoire
describe('CustomerService Integration', () => {
let service: CustomerService;
let testDb: TestDatabase;
beforeEach(async () => {
testDb = await createTestDatabase();
service = new CustomerService(testDb.orm);
});
test('should register new customer', async () => {
const command = new RegisterCustomerCommand('test@example.com');
const customer = await service.registerCustomer(command);
// Vérification en base
const persisted = await testDb.orm.customer.findUnique({
where: { id: customer.id.value }
});
expect(persisted).toBeDefined();
});
});
// Port (interface) minimaliste
interface CustomerPersistence {
findByEmail(email: Email): Promise<Customer | null>;
save(customer: Customer): Promise<void>;
}
// Adapter simple
export class PrismaCustomerPersistence implements CustomerPersistence {
constructor(private prisma: PrismaClient) {}
async findByEmail(email: Email): Promise<Customer | null> {
const row = await this.prisma.customer.findUnique({
where: { email: email.value }
});
return row ? Customer.fromPersistence(row) : null;
}
async save(customer: Customer): Promise<void> {
const data = customer.toPersistence();
await this.prisma.customer.upsert({
where: { id: data.id },
create: data,
update: data
});
}
}
// Repository instrumenté pour observabilité
export class InstrumentedCustomerRepository implements CustomerRepository {
constructor(
private inner: CustomerRepository,
private metrics: MetricsClient
) {}
async findByEmail(email: Email): Promise<Customer | null> {
const timer = this.metrics.startTimer('customer_repository.find_by_email');
try {
const result = await this.inner.findByEmail(email);
this.metrics.increment('customer_repository.find_by_email.success');
return result;
} catch (error) {
this.metrics.increment('customer_repository.find_by_email.error');
throw error;
} finally {
timer.end();
}
}
}
Le Repository Pattern en 2024 doit être :
Ma recommandation finale :
L'architecture doit servir le business, pas l'inverse. Choisissez la solution qui maximise la vélocité de votre équipe tout en maintenant la qualité du code.
Patterns avancés pour implémenter Event Sourcing en production. Stratégies de snapshotting, patterns de projection et gestion de l'évolution des schémas avec exemples pratiques.
Appliquer le pattern ports et adapters dans les systèmes distribués. Comment structurer les microservices pour la testabilité, la maintenabilité et la clarté du domaine.
Comment concevoir des aggregates robustes qui maintiennent les invariants métier sans sacrifier la performance. Exemples de dimensionnement, composition et gestion du cycle de vie.