domain event handlers

This commit is contained in:
sbriat 2023-07-24 11:25:46 +02:00
parent 979ea5e98f
commit 3ac7460c83
15 changed files with 498 additions and 1 deletions

View File

@ -0,0 +1,7 @@
import { Command, CommandProps } from '@mobicoop/ddd-library';
export class DeleteUserCommand extends Command {
constructor(props: CommandProps<DeleteUserCommand>) {
super(props);
}
}

View File

@ -0,0 +1,21 @@
import { Inject } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { DeleteUserCommand } from './delete-user.command';
import { USER_REPOSITORY } from '@modules/user/user.di-tokens';
import { UserRepositoryPort } from '../../ports/user.repository.port';
import { UserEntity } from '@modules/user/core/domain/user.entity';
@CommandHandler(DeleteUserCommand)
export class DeleteUserService implements ICommandHandler {
constructor(
@Inject(USER_REPOSITORY)
private readonly userRepository: UserRepositoryPort,
) {}
async execute(command: DeleteUserCommand): Promise<boolean> {
const user: UserEntity = await this.userRepository.findOneById(command.id);
user.delete();
const isDeleted: boolean = await this.userRepository.delete(user);
return isDeleted;
}
}

View File

@ -0,0 +1,18 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { USER_MESSAGE_PUBLISHER } from '@modules/user/user.di-tokens';
import { UserCreatedDomainEvent } from '../../domain/events/user-created.domain-events';
@Injectable()
export class PublishMessageWhenUserIsCreatedDomainEventHandler {
constructor(
@Inject(USER_MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
) {}
@OnEvent(UserCreatedDomainEvent.name, { async: true, promisify: true })
async handle(event: UserCreatedDomainEvent): Promise<any> {
this.messagePublisher.publish('user.created', JSON.stringify(event));
}
}

View File

@ -0,0 +1,18 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { USER_MESSAGE_PUBLISHER } from '@modules/user/user.di-tokens';
import { UserDeletedDomainEvent } from '../../domain/events/user-deleted.domain-event';
@Injectable()
export class PublishMessageWhenUserIsDeletedDomainEventHandler {
constructor(
@Inject(USER_MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
) {}
@OnEvent(UserDeletedDomainEvent.name, { async: true, promisify: true })
async handle(event: UserDeletedDomainEvent): Promise<any> {
this.messagePublisher.publish('user.deleted', JSON.stringify(event));
}
}

View File

@ -0,0 +1,18 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { USER_MESSAGE_PUBLISHER } from '@modules/user/user.di-tokens';
import { UserUpdatedDomainEvent } from '../../domain/events/user-updated.domain-events';
@Injectable()
export class PublishMessageWhenUserIsUpdatedDomainEventHandler {
constructor(
@Inject(USER_MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
) {}
@OnEvent(UserUpdatedDomainEvent.name, { async: true, promisify: true })
async handle(event: UserUpdatedDomainEvent): Promise<any> {
this.messagePublisher.publish('user.updated', JSON.stringify(event));
}
}

View File

@ -0,0 +1,7 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
export class UserDeletedDomainEvent extends DomainEvent {
constructor(props: DomainEventProps<UserDeletedDomainEvent>) {
super(props);
}
}

View File

@ -3,6 +3,7 @@ import { v4 } from 'uuid';
import { CreateUserProps, UpdateUserProps, UserProps } from './user.types'; import { CreateUserProps, UpdateUserProps, UserProps } from './user.types';
import { UserCreatedDomainEvent } from './events/user-created.domain-events'; import { UserCreatedDomainEvent } from './events/user-created.domain-events';
import { UserUpdatedDomainEvent } from './events/user-updated.domain-events'; import { UserUpdatedDomainEvent } from './events/user-updated.domain-events';
import { UserDeletedDomainEvent } from './events/user-deleted.domain-event';
export class UserEntity extends AggregateRoot<UserProps> { export class UserEntity extends AggregateRoot<UserProps> {
protected readonly _id: AggregateID; protected readonly _id: AggregateID;
@ -40,6 +41,14 @@ export class UserEntity extends AggregateRoot<UserProps> {
); );
} }
delete(): void {
this.addEvent(
new UserDeletedDomainEvent({
aggregateId: this.id,
}),
);
}
validate(): void { validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database // entity business rules validation to protect it's invariant before saving entity to a database
} }

View File

@ -0,0 +1,44 @@
import {
DatabaseErrorException,
NotFoundException,
RpcExceptionCode,
RpcValidationPipe,
} from '@mobicoop/ddd-library';
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { DeleteUserRequestDto } from './dtos/delete-user.request.dto';
import { DeleteUserCommand } from '@modules/user/core/application/commands/delete-user/delete-user.command';
@UsePipes(
new RpcValidationPipe({
whitelist: true,
forbidUnknownValues: false,
}),
)
@Controller()
export class DeleteUserGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod('UserService', 'Delete')
async delete(data: DeleteUserRequestDto): Promise<void> {
try {
await this.commandBus.execute(new DeleteUserCommand(data));
} catch (error: any) {
if (error instanceof NotFoundException)
throw new RpcException({
code: RpcExceptionCode.NOT_FOUND,
message: error.message,
});
if (error instanceof DatabaseErrorException)
throw new RpcException({
code: RpcExceptionCode.INTERNAL,
message: error.message,
});
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: error.message,
});
}
}
}

View File

@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class DeleteUserRequestDto {
@IsString()
@IsNotEmpty()
id: string;
}

View File

@ -0,0 +1,50 @@
import { DeleteUserCommand } from '@modules/user/core/application/commands/delete-user/delete-user.command';
import { DeleteUserService } from '@modules/user/core/application/commands/delete-user/delete-user.service';
import { DeleteUserRequestDto } from '@modules/user/interface/grpc-controllers/dtos/delete-user.request.dto';
import { USER_REPOSITORY } from '@modules/user/user.di-tokens';
import { Test, TestingModule } from '@nestjs/testing';
const deleteUserRequest: DeleteUserRequestDto = {
id: '165192d4-398a-4469-a16b-98c02cc6f531',
};
const mockUserEntity = {
delete: jest.fn(),
};
const mockUserRepository = {
findOneById: jest.fn().mockImplementation(() => mockUserEntity),
delete: jest.fn().mockImplementationOnce(() => true),
};
describe('Delete User Service', () => {
let deleteUserService: DeleteUserService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: USER_REPOSITORY,
useValue: mockUserRepository,
},
DeleteUserService,
],
}).compile();
deleteUserService = module.get<DeleteUserService>(DeleteUserService);
});
it('should be defined', () => {
expect(deleteUserService).toBeDefined();
});
describe('execution', () => {
const deleteUserCommand = new DeleteUserCommand(deleteUserRequest);
it('should delete a user', async () => {
const result: boolean = await deleteUserService.execute(
deleteUserCommand,
);
expect(result).toBeTruthy();
});
});
});

View File

@ -0,0 +1,54 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PublishMessageWhenUserIsCreatedDomainEventHandler } from '@modules/user/core/application/event-handlers/publish-message-when-user-is-created.domain-event-handler';
import { USER_MESSAGE_PUBLISHER } from '@modules/user/user.di-tokens';
import { UserCreatedDomainEvent } from '@modules/user/core/domain/events/user-created.domain-events';
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('Publish message when user is created domain event handler', () => {
let publishMessageWhenUserIsCreatedDomainEventHandler: PublishMessageWhenUserIsCreatedDomainEventHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: USER_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
PublishMessageWhenUserIsCreatedDomainEventHandler,
],
}).compile();
publishMessageWhenUserIsCreatedDomainEventHandler =
module.get<PublishMessageWhenUserIsCreatedDomainEventHandler>(
PublishMessageWhenUserIsCreatedDomainEventHandler,
);
});
it('should publish a message', () => {
jest.spyOn(mockMessagePublisher, 'publish');
const userCreatedDomainEvent: UserCreatedDomainEvent = {
id: 'some-domain-event-id',
aggregateId: 'some-aggregate-id',
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '+33611223344',
metadata: {
timestamp: new Date('2023-06-28T05:00:00Z').getTime(),
correlationId: 'some-correlation-id',
},
};
publishMessageWhenUserIsCreatedDomainEventHandler.handle(
userCreatedDomainEvent,
);
expect(publishMessageWhenUserIsCreatedDomainEventHandler).toBeDefined();
expect(mockMessagePublisher.publish).toHaveBeenCalledTimes(1);
expect(mockMessagePublisher.publish).toHaveBeenCalledWith(
'user.created',
'{"id":"some-domain-event-id","aggregateId":"some-aggregate-id","firstName":"John","lastName":"Doe","email":"john.doe@email.com","phone":"+33611223344","metadata":{"timestamp":1687928400000,"correlationId":"some-correlation-id"}}',
);
});
});

View File

@ -0,0 +1,54 @@
import { Test, TestingModule } from '@nestjs/testing';
import { USER_MESSAGE_PUBLISHER } from '@modules/user/user.di-tokens';
import { UserCreatedDomainEvent } from '@modules/user/core/domain/events/user-created.domain-events';
import { PublishMessageWhenUserIsDeletedDomainEventHandler } from '@modules/user/core/application/event-handlers/publish-message-when-user-is-deleted.domain-event-handler';
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('Publish message when user is deleted domain event handler', () => {
let publishMessageWhenUserIsDeletedDomainEventHandler: PublishMessageWhenUserIsDeletedDomainEventHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: USER_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
PublishMessageWhenUserIsDeletedDomainEventHandler,
],
}).compile();
publishMessageWhenUserIsDeletedDomainEventHandler =
module.get<PublishMessageWhenUserIsDeletedDomainEventHandler>(
PublishMessageWhenUserIsDeletedDomainEventHandler,
);
});
it('should publish a message', () => {
jest.spyOn(mockMessagePublisher, 'publish');
const userDeletedDomainEvent: UserCreatedDomainEvent = {
id: 'some-domain-event-id',
aggregateId: 'some-aggregate-id',
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '+33611223344',
metadata: {
timestamp: new Date('2023-06-28T05:00:00Z').getTime(),
correlationId: 'some-correlation-id',
},
};
publishMessageWhenUserIsDeletedDomainEventHandler.handle(
userDeletedDomainEvent,
);
expect(publishMessageWhenUserIsDeletedDomainEventHandler).toBeDefined();
expect(mockMessagePublisher.publish).toHaveBeenCalledTimes(1);
expect(mockMessagePublisher.publish).toHaveBeenCalledWith(
'user.deleted',
'{"id":"some-domain-event-id","aggregateId":"some-aggregate-id","firstName":"John","lastName":"Doe","email":"john.doe@email.com","phone":"+33611223344","metadata":{"timestamp":1687928400000,"correlationId":"some-correlation-id"}}',
);
});
});

View File

@ -0,0 +1,54 @@
import { Test, TestingModule } from '@nestjs/testing';
import { USER_MESSAGE_PUBLISHER } from '@modules/user/user.di-tokens';
import { PublishMessageWhenUserIsUpdatedDomainEventHandler } from '@modules/user/core/application/event-handlers/publish-message-when-user-is-updated.domain-event-handler';
import { UserUpdatedDomainEvent } from '@modules/user/core/domain/events/user-updated.domain-events';
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('Publish message when user is updated domain event handler', () => {
let publishMessageWhenUserIsUpdatedDomainEventHandler: PublishMessageWhenUserIsUpdatedDomainEventHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: USER_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
PublishMessageWhenUserIsUpdatedDomainEventHandler,
],
}).compile();
publishMessageWhenUserIsUpdatedDomainEventHandler =
module.get<PublishMessageWhenUserIsUpdatedDomainEventHandler>(
PublishMessageWhenUserIsUpdatedDomainEventHandler,
);
});
it('should publish a message', () => {
jest.spyOn(mockMessagePublisher, 'publish');
const userUpdatedDomainEvent: UserUpdatedDomainEvent = {
id: 'some-domain-event-id',
aggregateId: 'some-aggregate-id',
firstName: 'Jane',
lastName: 'Doe',
email: 'jane.doe@email.com',
phone: '+33611223344',
metadata: {
timestamp: new Date('2023-06-28T05:00:00Z').getTime(),
correlationId: 'some-correlation-id',
},
};
publishMessageWhenUserIsUpdatedDomainEventHandler.handle(
userUpdatedDomainEvent,
);
expect(publishMessageWhenUserIsUpdatedDomainEventHandler).toBeDefined();
expect(mockMessagePublisher.publish).toHaveBeenCalledTimes(1);
expect(mockMessagePublisher.publish).toHaveBeenCalledWith(
'user.updated',
'{"id":"some-domain-event-id","aggregateId":"some-aggregate-id","firstName":"Jane","lastName":"Doe","email":"jane.doe@email.com","phone":"+33611223344","metadata":{"timestamp":1687928400000,"correlationId":"some-correlation-id"}}',
);
});
});

View File

@ -1,5 +1,11 @@
import { UserCreatedDomainEvent } from '@modules/user/core/domain/events/user-created.domain-events';
import { UserDeletedDomainEvent } from '@modules/user/core/domain/events/user-deleted.domain-event';
import { UserUpdatedDomainEvent } from '@modules/user/core/domain/events/user-updated.domain-events';
import { UserEntity } from '@modules/user/core/domain/user.entity'; import { UserEntity } from '@modules/user/core/domain/user.entity';
import { CreateUserProps } from '@modules/user/core/domain/user.types'; import {
CreateUserProps,
UpdateUserProps,
} from '@modules/user/core/domain/user.types';
const createUserProps: CreateUserProps = { const createUserProps: CreateUserProps = {
firstName: 'John', firstName: 'John',
@ -8,10 +14,41 @@ const createUserProps: CreateUserProps = {
phone: '+33611223344', phone: '+33611223344',
}; };
const updateUserProps: UpdateUserProps = {
firstName: 'Jane',
lastName: 'Dane',
email: 'jane.dane@email.com',
};
describe('User entity create', () => { describe('User entity create', () => {
it('should create a new user entity', async () => { it('should create a new user entity', async () => {
const userEntity: UserEntity = UserEntity.create(createUserProps); const userEntity: UserEntity = UserEntity.create(createUserProps);
expect(userEntity.id.length).toBe(36); expect(userEntity.id.length).toBe(36);
expect(userEntity.getProps().email).toBe('john.doe@email.com'); expect(userEntity.getProps().email).toBe('john.doe@email.com');
expect(userEntity.domainEvents.length).toBe(1);
expect(userEntity.domainEvents[0]).toBeInstanceOf(UserCreatedDomainEvent);
});
});
describe('User entity update', () => {
it('should update a user entity', async () => {
const userEntity: UserEntity = UserEntity.create(createUserProps);
userEntity.update(updateUserProps);
expect(userEntity.getProps().firstName).toBe('Jane');
expect(userEntity.getProps().lastName).toBe('Dane');
expect(userEntity.getProps().email).toBe('jane.dane@email.com');
// 2 events because UserEntity.create sends a UserCreatedDomainEvent
expect(userEntity.domainEvents.length).toBe(2);
expect(userEntity.domainEvents[1]).toBeInstanceOf(UserUpdatedDomainEvent);
});
});
describe('User entity delete', () => {
it('should delete a user entity', async () => {
const userEntity: UserEntity = UserEntity.create(createUserProps);
userEntity.delete();
// 2 events because UserEntity.create sends a UserCreatedDomainEvent
expect(userEntity.domainEvents.length).toBe(2);
expect(userEntity.domainEvents[1]).toBeInstanceOf(UserDeletedDomainEvent);
}); });
}); });

View File

@ -0,0 +1,99 @@
import {
DatabaseErrorException,
NotFoundException,
} from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { DeleteUserGrpcController } from '@modules/user/interface/grpc-controllers/delete-user.grpc.controller';
import { DeleteUserRequestDto } from '@modules/user/interface/grpc-controllers/dtos/delete-user.request.dto';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const deleteUserRequest: DeleteUserRequestDto = {
id: '78153e03-4861-4f58-a705-88526efee53b',
};
const mockCommandBus = {
execute: jest
.fn()
.mockImplementationOnce(() => ({}))
.mockImplementationOnce(() => {
throw new NotFoundException();
})
.mockImplementationOnce(() => {
throw new DatabaseErrorException();
})
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('Delete User Grpc Controller', () => {
let deleteUserGrpcController: DeleteUserGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
DeleteUserGrpcController,
],
}).compile();
deleteUserGrpcController = module.get<DeleteUserGrpcController>(
DeleteUserGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(deleteUserGrpcController).toBeDefined();
});
it('should delete a user', async () => {
jest.spyOn(mockCommandBus, 'execute');
await deleteUserGrpcController.delete(deleteUserRequest);
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if user does not exist', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await deleteUserGrpcController.delete(deleteUserRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.NOT_FOUND);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if a database error occurs', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await deleteUserGrpcController.delete(deleteUserRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.INTERNAL);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a generic RpcException', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await deleteUserGrpcController.delete(deleteUserRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});