@tndhuy/create-app 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +53 -0
  2. package/dist/cli.js +534 -0
  3. package/package.json +37 -0
  4. package/templates/mongo/.env.example +32 -0
  5. package/templates/mongo/Dockerfile +64 -0
  6. package/templates/mongo/docker-compose.yml +35 -0
  7. package/templates/mongo/eslint.config.mjs +35 -0
  8. package/templates/mongo/nest-cli.json +8 -0
  9. package/templates/mongo/package.json +105 -0
  10. package/templates/mongo/src/app.module.ts +59 -0
  11. package/templates/mongo/src/common/decorators/public-api.decorator.ts +9 -0
  12. package/templates/mongo/src/common/decorators/raw-response.decorator.ts +4 -0
  13. package/templates/mongo/src/common/filters/http-exception.filter.spec.ts +95 -0
  14. package/templates/mongo/src/common/filters/http-exception.filter.ts +43 -0
  15. package/templates/mongo/src/common/filters/rpc-exception.filter.ts +18 -0
  16. package/templates/mongo/src/common/index.ts +5 -0
  17. package/templates/mongo/src/common/interceptors/timeout.interceptor.ts +32 -0
  18. package/templates/mongo/src/common/interceptors/transform.interceptor.spec.ts +52 -0
  19. package/templates/mongo/src/common/interceptors/transform.interceptor.ts +25 -0
  20. package/templates/mongo/src/common/middleware/correlation-id.middleware.spec.ts +69 -0
  21. package/templates/mongo/src/common/middleware/correlation-id.middleware.ts +26 -0
  22. package/templates/mongo/src/infrastructure/cache/inject-redis.decorator.ts +4 -0
  23. package/templates/mongo/src/infrastructure/cache/redis.module.ts +9 -0
  24. package/templates/mongo/src/infrastructure/cache/redis.service.spec.ts +174 -0
  25. package/templates/mongo/src/infrastructure/cache/redis.service.ts +121 -0
  26. package/templates/mongo/src/infrastructure/config/config.module.ts +36 -0
  27. package/templates/mongo/src/infrastructure/config/environment.validation.spec.ts +100 -0
  28. package/templates/mongo/src/infrastructure/config/environment.validation.ts +21 -0
  29. package/templates/mongo/src/infrastructure/database/mongodb.module.ts +17 -0
  30. package/templates/mongo/src/infrastructure/health/health.controller.ts +46 -0
  31. package/templates/mongo/src/infrastructure/health/health.module.ts +12 -0
  32. package/templates/mongo/src/infrastructure/health/redis.health-indicator.ts +20 -0
  33. package/templates/mongo/src/instrumentation.spec.ts +24 -0
  34. package/templates/mongo/src/instrumentation.ts +44 -0
  35. package/templates/mongo/src/main.ts +102 -0
  36. package/templates/mongo/src/modules/example/application/commands/create-item.command.ts +3 -0
  37. package/templates/mongo/src/modules/example/application/commands/create-item.handler.spec.ts +49 -0
  38. package/templates/mongo/src/modules/example/application/commands/create-item.handler.ts +20 -0
  39. package/templates/mongo/src/modules/example/application/commands/delete-item.command.ts +3 -0
  40. package/templates/mongo/src/modules/example/application/commands/delete-item.handler.ts +15 -0
  41. package/templates/mongo/src/modules/example/application/dtos/create-item.dto.ts +9 -0
  42. package/templates/mongo/src/modules/example/application/dtos/item.response.dto.ts +9 -0
  43. package/templates/mongo/src/modules/example/application/queries/get-item.handler.spec.ts +49 -0
  44. package/templates/mongo/src/modules/example/application/queries/get-item.handler.ts +16 -0
  45. package/templates/mongo/src/modules/example/application/queries/get-item.query.ts +3 -0
  46. package/templates/mongo/src/modules/example/application/queries/list-items.handler.ts +16 -0
  47. package/templates/mongo/src/modules/example/application/queries/list-items.query.ts +3 -0
  48. package/templates/mongo/src/modules/example/domain/item-name.value-object.spec.ts +49 -0
  49. package/templates/mongo/src/modules/example/domain/item-name.value-object.ts +18 -0
  50. package/templates/mongo/src/modules/example/domain/item.entity.spec.ts +48 -0
  51. package/templates/mongo/src/modules/example/domain/item.entity.ts +19 -0
  52. package/templates/mongo/src/modules/example/domain/item.repository.interface.ts +10 -0
  53. package/templates/mongo/src/modules/example/example.module.ts +31 -0
  54. package/templates/mongo/src/modules/example/infrastructure/.gitkeep +0 -0
  55. package/templates/mongo/src/modules/example/infrastructure/persistence/mongoose-item.repository.ts +42 -0
  56. package/templates/mongo/src/modules/example/infrastructure/persistence/schemas/item.schema.ts +15 -0
  57. package/templates/mongo/src/modules/example/presenter/item.controller.ts +52 -0
  58. package/templates/mongo/src/shared/base/aggregate-root.spec.ts +44 -0
  59. package/templates/mongo/src/shared/base/aggregate-root.ts +20 -0
  60. package/templates/mongo/src/shared/base/domain-event.ts +6 -0
  61. package/templates/mongo/src/shared/base/entity.spec.ts +36 -0
  62. package/templates/mongo/src/shared/base/entity.ts +13 -0
  63. package/templates/mongo/src/shared/base/index.ts +5 -0
  64. package/templates/mongo/src/shared/base/repository.interface.ts +6 -0
  65. package/templates/mongo/src/shared/base/value-object.spec.ts +39 -0
  66. package/templates/mongo/src/shared/base/value-object.ts +13 -0
  67. package/templates/mongo/src/shared/dto/pagination.dto.spec.ts +49 -0
  68. package/templates/mongo/src/shared/dto/pagination.dto.ts +37 -0
  69. package/templates/mongo/src/shared/dto/response.dto.ts +13 -0
  70. package/templates/mongo/src/shared/exceptions/app.exception.spec.ts +59 -0
  71. package/templates/mongo/src/shared/exceptions/app.exception.ts +19 -0
  72. package/templates/mongo/src/shared/exceptions/error-codes.ts +9 -0
  73. package/templates/mongo/src/shared/index.ts +7 -0
  74. package/templates/mongo/src/shared/logger/logger.module.ts +12 -0
  75. package/templates/mongo/src/shared/logger/logger.service.ts +48 -0
  76. package/templates/mongo/src/shared/logger/pino.config.ts +86 -0
  77. package/templates/mongo/src/shared/validation-options.ts +38 -0
  78. package/templates/mongo/src/shared/valueobjects/date.valueobject.spec.ts +40 -0
  79. package/templates/mongo/src/shared/valueobjects/date.valueobject.ts +14 -0
  80. package/templates/mongo/src/shared/valueobjects/id.valueobject.spec.ts +28 -0
  81. package/templates/mongo/src/shared/valueobjects/id.valueobject.ts +14 -0
  82. package/templates/mongo/src/shared/valueobjects/index.ts +4 -0
  83. package/templates/mongo/src/shared/valueobjects/number.valueobject.spec.ts +48 -0
  84. package/templates/mongo/src/shared/valueobjects/number.valueobject.ts +14 -0
  85. package/templates/mongo/src/shared/valueobjects/string.valueobject.spec.ts +37 -0
  86. package/templates/mongo/src/shared/valueobjects/string.valueobject.ts +14 -0
  87. package/templates/mongo/tsconfig.build.json +4 -0
  88. package/templates/mongo/tsconfig.json +23 -0
  89. package/templates/postgres/.env.example +32 -0
  90. package/templates/postgres/Dockerfile +64 -0
  91. package/templates/postgres/eslint.config.mjs +35 -0
  92. package/templates/postgres/nest-cli.json +8 -0
  93. package/templates/postgres/package.json +103 -0
  94. package/templates/postgres/prisma/schema.prisma +14 -0
  95. package/templates/postgres/prisma.config.ts +11 -0
  96. package/templates/postgres/src/app.module.ts +34 -0
  97. package/templates/postgres/src/common/decorators/public-api.decorator.ts +9 -0
  98. package/templates/postgres/src/common/decorators/raw-response.decorator.ts +4 -0
  99. package/templates/postgres/src/common/filters/http-exception.filter.spec.ts +95 -0
  100. package/templates/postgres/src/common/filters/http-exception.filter.ts +43 -0
  101. package/templates/postgres/src/common/filters/rpc-exception.filter.ts +18 -0
  102. package/templates/postgres/src/common/index.ts +5 -0
  103. package/templates/postgres/src/common/interceptors/timeout.interceptor.ts +32 -0
  104. package/templates/postgres/src/common/interceptors/transform.interceptor.spec.ts +52 -0
  105. package/templates/postgres/src/common/interceptors/transform.interceptor.ts +25 -0
  106. package/templates/postgres/src/common/middleware/correlation-id.middleware.spec.ts +69 -0
  107. package/templates/postgres/src/common/middleware/correlation-id.middleware.ts +26 -0
  108. package/templates/postgres/src/infrastructure/cache/inject-redis.decorator.ts +4 -0
  109. package/templates/postgres/src/infrastructure/cache/redis.module.ts +9 -0
  110. package/templates/postgres/src/infrastructure/cache/redis.service.spec.ts +174 -0
  111. package/templates/postgres/src/infrastructure/cache/redis.service.ts +121 -0
  112. package/templates/postgres/src/infrastructure/config/config.module.ts +36 -0
  113. package/templates/postgres/src/infrastructure/config/environment.validation.spec.ts +100 -0
  114. package/templates/postgres/src/infrastructure/config/environment.validation.ts +21 -0
  115. package/templates/postgres/src/infrastructure/database/inject-prisma.decorator.ts +4 -0
  116. package/templates/postgres/src/infrastructure/database/prisma.module.ts +9 -0
  117. package/templates/postgres/src/infrastructure/database/prisma.service.ts +21 -0
  118. package/templates/postgres/src/infrastructure/health/health.controller.ts +46 -0
  119. package/templates/postgres/src/infrastructure/health/health.module.ts +12 -0
  120. package/templates/postgres/src/infrastructure/health/prisma.health-indicator.ts +19 -0
  121. package/templates/postgres/src/infrastructure/health/redis.health-indicator.ts +20 -0
  122. package/templates/postgres/src/instrumentation.spec.ts +24 -0
  123. package/templates/postgres/src/instrumentation.ts +44 -0
  124. package/templates/postgres/src/main.ts +102 -0
  125. package/templates/postgres/src/modules/example/application/commands/create-item.command.ts +3 -0
  126. package/templates/postgres/src/modules/example/application/commands/create-item.handler.spec.ts +49 -0
  127. package/templates/postgres/src/modules/example/application/commands/create-item.handler.ts +20 -0
  128. package/templates/postgres/src/modules/example/application/commands/delete-item.command.ts +3 -0
  129. package/templates/postgres/src/modules/example/application/commands/delete-item.handler.ts +15 -0
  130. package/templates/postgres/src/modules/example/application/dtos/create-item.dto.ts +9 -0
  131. package/templates/postgres/src/modules/example/application/dtos/item.response.dto.ts +9 -0
  132. package/templates/postgres/src/modules/example/application/queries/get-item.handler.spec.ts +49 -0
  133. package/templates/postgres/src/modules/example/application/queries/get-item.handler.ts +16 -0
  134. package/templates/postgres/src/modules/example/application/queries/get-item.query.ts +3 -0
  135. package/templates/postgres/src/modules/example/application/queries/list-items.handler.ts +16 -0
  136. package/templates/postgres/src/modules/example/application/queries/list-items.query.ts +3 -0
  137. package/templates/postgres/src/modules/example/domain/item-name.value-object.spec.ts +49 -0
  138. package/templates/postgres/src/modules/example/domain/item-name.value-object.ts +18 -0
  139. package/templates/postgres/src/modules/example/domain/item.entity.spec.ts +48 -0
  140. package/templates/postgres/src/modules/example/domain/item.entity.ts +19 -0
  141. package/templates/postgres/src/modules/example/domain/item.repository.interface.ts +10 -0
  142. package/templates/postgres/src/modules/example/example.module.ts +26 -0
  143. package/templates/postgres/src/modules/example/infrastructure/.gitkeep +0 -0
  144. package/templates/postgres/src/modules/example/infrastructure/persistence/prisma-item.repository.ts +34 -0
  145. package/templates/postgres/src/modules/example/presenter/item.controller.ts +52 -0
  146. package/templates/postgres/src/shared/base/aggregate-root.spec.ts +44 -0
  147. package/templates/postgres/src/shared/base/aggregate-root.ts +20 -0
  148. package/templates/postgres/src/shared/base/domain-event.ts +6 -0
  149. package/templates/postgres/src/shared/base/entity.spec.ts +36 -0
  150. package/templates/postgres/src/shared/base/entity.ts +13 -0
  151. package/templates/postgres/src/shared/base/index.ts +5 -0
  152. package/templates/postgres/src/shared/base/repository.interface.ts +6 -0
  153. package/templates/postgres/src/shared/base/value-object.spec.ts +39 -0
  154. package/templates/postgres/src/shared/base/value-object.ts +13 -0
  155. package/templates/postgres/src/shared/dto/pagination.dto.spec.ts +49 -0
  156. package/templates/postgres/src/shared/dto/pagination.dto.ts +37 -0
  157. package/templates/postgres/src/shared/dto/response.dto.ts +13 -0
  158. package/templates/postgres/src/shared/exceptions/app.exception.spec.ts +59 -0
  159. package/templates/postgres/src/shared/exceptions/app.exception.ts +19 -0
  160. package/templates/postgres/src/shared/exceptions/error-codes.ts +9 -0
  161. package/templates/postgres/src/shared/index.ts +7 -0
  162. package/templates/postgres/src/shared/logger/logger.module.ts +12 -0
  163. package/templates/postgres/src/shared/logger/logger.service.ts +48 -0
  164. package/templates/postgres/src/shared/logger/pino.config.ts +86 -0
  165. package/templates/postgres/src/shared/validation-options.ts +38 -0
  166. package/templates/postgres/src/shared/valueobjects/date.valueobject.spec.ts +40 -0
  167. package/templates/postgres/src/shared/valueobjects/date.valueobject.ts +14 -0
  168. package/templates/postgres/src/shared/valueobjects/id.valueobject.spec.ts +28 -0
  169. package/templates/postgres/src/shared/valueobjects/id.valueobject.ts +14 -0
  170. package/templates/postgres/src/shared/valueobjects/index.ts +4 -0
  171. package/templates/postgres/src/shared/valueobjects/number.valueobject.spec.ts +48 -0
  172. package/templates/postgres/src/shared/valueobjects/number.valueobject.ts +14 -0
  173. package/templates/postgres/src/shared/valueobjects/string.valueobject.spec.ts +37 -0
  174. package/templates/postgres/src/shared/valueobjects/string.valueobject.ts +14 -0
  175. package/templates/postgres/tsconfig.build.json +4 -0
  176. package/templates/postgres/tsconfig.json +23 -0
@@ -0,0 +1,102 @@
1
+ import otelSdk from './instrumentation';
2
+ import { NestFactory, Reflector } from '@nestjs/core';
3
+ import { NestExpressApplication } from '@nestjs/platform-express';
4
+ import { VersioningType, ValidationPipe } from '@nestjs/common';
5
+ import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
6
+ import { Logger } from 'nestjs-pino';
7
+ import { apiReference } from '@scalar/nestjs-api-reference';
8
+ import basicAuth from 'express-basic-auth';
9
+ import { AppModule } from './app.module';
10
+ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
11
+ import { TransformInterceptor } from './common/interceptors/transform.interceptor';
12
+ import { TimeoutInterceptor } from './common/interceptors/timeout.interceptor';
13
+ import { validationOptions } from './shared/validation-options';
14
+ import { LoggerService } from './shared/logger/logger.service';
15
+
16
+ async function bootstrap() {
17
+ otelSdk?.start();
18
+
19
+ const app = await NestFactory.create<NestExpressApplication>(AppModule, {
20
+ bufferLogs: true,
21
+ });
22
+
23
+ app.useLogger(app.get(Logger));
24
+
25
+ app.set('trust proxy', 1);
26
+
27
+ const apiVersion = process.env.API_VERSION ?? '1';
28
+
29
+ app.setGlobalPrefix('api', {
30
+ exclude: ['/health', '/health/ready', '/health/live'],
31
+ });
32
+
33
+ app.enableVersioning({ type: VersioningType.URI, defaultVersion: apiVersion });
34
+
35
+ app.useGlobalPipes(new ValidationPipe(validationOptions));
36
+
37
+ const reflector = app.get(Reflector);
38
+ const loggerService = app.get(LoggerService);
39
+
40
+ app.useGlobalFilters(new HttpExceptionFilter(loggerService));
41
+
42
+ app.useGlobalInterceptors(
43
+ new TransformInterceptor(reflector),
44
+ new TimeoutInterceptor(),
45
+ );
46
+
47
+ // Swagger document
48
+ const documentBuilder = new DocumentBuilder()
49
+ .setTitle(process.env.APP_NAME ?? 'NestJS Backend Template')
50
+ .setDescription(process.env.APP_DESCRIPTION ?? 'API Documentation')
51
+ .setVersion(apiVersion)
52
+ .addBearerAuth()
53
+ .build();
54
+ const document = SwaggerModule.createDocument(app, documentBuilder);
55
+
56
+ // Serve raw spec at /docs/json
57
+ SwaggerModule.setup('docs/json', app, document, {
58
+ jsonDocumentUrl: '/docs/json',
59
+ });
60
+
61
+ // Basic auth for /docs
62
+ const docsUser = process.env.DOCS_USER ?? 'admin';
63
+ const docsPass =
64
+ process.env.DOCS_PASS ??
65
+ (process.env.NODE_ENV === 'production' ? undefined : 'admin');
66
+ if (docsPass) {
67
+ app.use(
68
+ '/docs',
69
+ basicAuth({ users: { [docsUser]: docsPass }, challenge: true }),
70
+ );
71
+ }
72
+
73
+ // Scalar API reference at /docs
74
+ app.use(
75
+ '/docs',
76
+ apiReference({
77
+ content: document,
78
+ spec: { content: document },
79
+ hiddenClients: ['fetch', 'xhr'],
80
+ configuration: { agent: { disabled: true } },
81
+ }),
82
+ );
83
+
84
+ const port = parseInt(process.env.PORT ?? '3000', 10);
85
+ const httpServer = await app.listen(port);
86
+
87
+ process.on('SIGTERM', () => {
88
+ loggerService.log('SIGTERM signal received: closing HTTP server');
89
+ setTimeout(() => {
90
+ void otelSdk?.shutdown().catch(() => undefined);
91
+ httpServer.close(() => {
92
+ loggerService.log('HTTP server closed');
93
+ process.exit(0);
94
+ });
95
+ }, 15000);
96
+ });
97
+
98
+ const logger = app.get(Logger);
99
+ logger.log(`Application running on http://localhost:${port}`);
100
+ }
101
+
102
+ void bootstrap();
@@ -0,0 +1,3 @@
1
+ export class CreateItemCommand {
2
+ constructor(public readonly name: string) {}
3
+ }
@@ -0,0 +1,49 @@
1
+ import { CreateItemHandler } from './create-item.handler';
2
+ import { CreateItemCommand } from './create-item.command';
3
+ import { IItemRepository } from '../../domain/item.repository.interface';
4
+ import { Item } from '../../domain/item.entity';
5
+
6
+ describe('CreateItemHandler', () => {
7
+ let handler: CreateItemHandler;
8
+ let mockRepository: jest.Mocked<IItemRepository>;
9
+
10
+ beforeEach(() => {
11
+ mockRepository = {
12
+ findById: jest.fn(),
13
+ findAll: jest.fn(),
14
+ save: jest.fn().mockResolvedValue(undefined),
15
+ delete: jest.fn(),
16
+ };
17
+
18
+ handler = new CreateItemHandler(mockRepository);
19
+ });
20
+
21
+ it('should call repository.save with an Item constructed from the command name', async () => {
22
+ const command = new CreateItemCommand('Widget');
23
+
24
+ await handler.execute(command);
25
+
26
+ expect(mockRepository.save).toHaveBeenCalledTimes(1);
27
+ const savedItem: Item = mockRepository.save.mock.calls[0][0];
28
+ expect(savedItem).toBeInstanceOf(Item);
29
+ expect(savedItem.name.value).toBe('Widget');
30
+ });
31
+
32
+ it('should return a non-empty string id after saving', async () => {
33
+ const command = new CreateItemCommand('Gadget');
34
+
35
+ const result = await handler.execute(command);
36
+
37
+ expect(typeof result).toBe('string');
38
+ expect(result.length).toBeGreaterThan(0);
39
+ });
40
+
41
+ it('should generate a unique id for each execution', async () => {
42
+ const command = new CreateItemCommand('Widget');
43
+
44
+ const id1 = await handler.execute(command);
45
+ const id2 = await handler.execute(command);
46
+
47
+ expect(id1).not.toBe(id2);
48
+ });
49
+ });
@@ -0,0 +1,20 @@
1
+ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
2
+ import { Inject } from '@nestjs/common';
3
+ import { CreateItemCommand } from './create-item.command';
4
+ import { ITEM_REPOSITORY, IItemRepository } from '../../domain/item.repository.interface';
5
+ import { Item } from '../../domain/item.entity';
6
+ import { ItemName } from '../../domain/item-name.value-object';
7
+
8
+ @CommandHandler(CreateItemCommand)
9
+ export class CreateItemHandler implements ICommandHandler<CreateItemCommand> {
10
+ constructor(
11
+ @Inject(ITEM_REPOSITORY) private readonly itemRepository: IItemRepository,
12
+ ) {}
13
+
14
+ async execute(command: CreateItemCommand): Promise<string> {
15
+ const id = crypto.randomUUID();
16
+ const item = Item.create(id, ItemName.create(command.name));
17
+ await this.itemRepository.save(item);
18
+ return id;
19
+ }
20
+ }
@@ -0,0 +1,3 @@
1
+ export class DeleteItemCommand {
2
+ constructor(public readonly id: string) {}
3
+ }
@@ -0,0 +1,15 @@
1
+ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
2
+ import { Inject } from '@nestjs/common';
3
+ import { DeleteItemCommand } from './delete-item.command';
4
+ import { ITEM_REPOSITORY, IItemRepository } from '../../domain/item.repository.interface';
5
+
6
+ @CommandHandler(DeleteItemCommand)
7
+ export class DeleteItemHandler implements ICommandHandler<DeleteItemCommand> {
8
+ constructor(
9
+ @Inject(ITEM_REPOSITORY) private readonly itemRepository: IItemRepository,
10
+ ) {}
11
+
12
+ async execute(command: DeleteItemCommand): Promise<void> {
13
+ await this.itemRepository.delete(command.id);
14
+ }
15
+ }
@@ -0,0 +1,9 @@
1
+ import { ApiProperty } from '@nestjs/swagger';
2
+ import { IsNotEmpty, IsString } from 'class-validator';
3
+
4
+ export class CreateItemDto {
5
+ @ApiProperty({ description: 'The name of the item', example: 'My Item' })
6
+ @IsString()
7
+ @IsNotEmpty()
8
+ name: string;
9
+ }
@@ -0,0 +1,9 @@
1
+ import { ApiProperty } from '@nestjs/swagger';
2
+
3
+ export class ItemResponseDto {
4
+ @ApiProperty({ description: 'Unique item identifier', example: '550e8400-e29b-41d4-a716-446655440000' })
5
+ id: string;
6
+
7
+ @ApiProperty({ description: 'The name of the item', example: 'My Item' })
8
+ name: string;
9
+ }
@@ -0,0 +1,49 @@
1
+ import { GetItemHandler } from './get-item.handler';
2
+ import { GetItemQuery } from './get-item.query';
3
+ import { IItemRepository } from '../../domain/item.repository.interface';
4
+ import { Item } from '../../domain/item.entity';
5
+ import { ItemName } from '../../domain/item-name.value-object';
6
+
7
+ describe('GetItemHandler', () => {
8
+ let handler: GetItemHandler;
9
+ let mockRepository: jest.Mocked<IItemRepository>;
10
+
11
+ beforeEach(() => {
12
+ mockRepository = {
13
+ findById: jest.fn(),
14
+ findAll: jest.fn(),
15
+ save: jest.fn(),
16
+ delete: jest.fn(),
17
+ };
18
+
19
+ handler = new GetItemHandler(mockRepository);
20
+ });
21
+
22
+ it('should delegate to repository.findById with the query id', async () => {
23
+ const item = Item.create('item-1', ItemName.create('Widget'));
24
+ mockRepository.findById.mockResolvedValue(item);
25
+
26
+ const query = new GetItemQuery('item-1');
27
+ await handler.execute(query);
28
+
29
+ expect(mockRepository.findById).toHaveBeenCalledTimes(1);
30
+ expect(mockRepository.findById).toHaveBeenCalledWith('item-1');
31
+ });
32
+
33
+ it('should return the item from the repository when it exists', async () => {
34
+ const item = Item.create('item-2', ItemName.create('Gadget'));
35
+ mockRepository.findById.mockResolvedValue(item);
36
+
37
+ const result = await handler.execute(new GetItemQuery('item-2'));
38
+
39
+ expect(result).toBe(item);
40
+ });
41
+
42
+ it('should return null when the repository returns null', async () => {
43
+ mockRepository.findById.mockResolvedValue(null);
44
+
45
+ const result = await handler.execute(new GetItemQuery('nonexistent'));
46
+
47
+ expect(result).toBeNull();
48
+ });
49
+ });
@@ -0,0 +1,16 @@
1
+ import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
2
+ import { Inject } from '@nestjs/common';
3
+ import { GetItemQuery } from './get-item.query';
4
+ import { ITEM_REPOSITORY, IItemRepository } from '../../domain/item.repository.interface';
5
+ import { Item } from '../../domain/item.entity';
6
+
7
+ @QueryHandler(GetItemQuery)
8
+ export class GetItemHandler implements IQueryHandler<GetItemQuery> {
9
+ constructor(
10
+ @Inject(ITEM_REPOSITORY) private readonly itemRepository: IItemRepository,
11
+ ) {}
12
+
13
+ async execute(query: GetItemQuery): Promise<Item | null> {
14
+ return this.itemRepository.findById(query.id);
15
+ }
16
+ }
@@ -0,0 +1,3 @@
1
+ export class GetItemQuery {
2
+ constructor(public readonly id: string) {}
3
+ }
@@ -0,0 +1,16 @@
1
+ import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
2
+ import { Inject } from '@nestjs/common';
3
+ import { ListItemsQuery } from './list-items.query';
4
+ import { ITEM_REPOSITORY, IItemRepository } from '../../domain/item.repository.interface';
5
+ import { Item } from '../../domain/item.entity';
6
+
7
+ @QueryHandler(ListItemsQuery)
8
+ export class ListItemsHandler implements IQueryHandler<ListItemsQuery> {
9
+ constructor(
10
+ @Inject(ITEM_REPOSITORY) private readonly itemRepository: IItemRepository,
11
+ ) {}
12
+
13
+ async execute(_query: ListItemsQuery): Promise<Item[]> {
14
+ return this.itemRepository.findAll();
15
+ }
16
+ }
@@ -0,0 +1,3 @@
1
+ export class ListItemsQuery {
2
+ constructor() {}
3
+ }
@@ -0,0 +1,49 @@
1
+ import { ItemName } from './item-name.value-object';
2
+
3
+ describe('ItemName value object', () => {
4
+ it('should create an ItemName with a valid non-empty string', () => {
5
+ const name = ItemName.create('Widget');
6
+
7
+ expect(name.value).toBe('Widget');
8
+ });
9
+
10
+ it('should trim leading and trailing whitespace on creation', () => {
11
+ const name = ItemName.create(' Trimmed ');
12
+
13
+ expect(name.value).toBe('Trimmed');
14
+ });
15
+
16
+ it('should throw when given an empty string', () => {
17
+ expect(() => ItemName.create('')).toThrow('ItemName cannot be empty');
18
+ });
19
+
20
+ it('should throw when given a whitespace-only string', () => {
21
+ expect(() => ItemName.create(' ')).toThrow('ItemName cannot be empty');
22
+ });
23
+
24
+ it('should be equal to another ItemName with the same trimmed value', () => {
25
+ const name1 = ItemName.create('Widget');
26
+ const name2 = ItemName.create('Widget');
27
+
28
+ expect(name1.equals(name2)).toBe(true);
29
+ });
30
+
31
+ it('should not be equal to an ItemName with a different value', () => {
32
+ const name1 = ItemName.create('Widget');
33
+ const name2 = ItemName.create('Gadget');
34
+
35
+ expect(name1.equals(name2)).toBe(false);
36
+ });
37
+
38
+ it('should return false when compared with undefined', () => {
39
+ const name = ItemName.create('Widget');
40
+
41
+ expect(name.equals(undefined)).toBe(false);
42
+ });
43
+
44
+ it('should return true when compared with itself', () => {
45
+ const name = ItemName.create('Widget');
46
+
47
+ expect(name.equals(name)).toBe(true);
48
+ });
49
+ });
@@ -0,0 +1,18 @@
1
+ import { ValueObject } from '../../../shared/base/value-object';
2
+
3
+ interface ItemNameProps {
4
+ value: string;
5
+ }
6
+
7
+ export class ItemName extends ValueObject<ItemNameProps> {
8
+ static create(value: string): ItemName {
9
+ if (!value || value.trim().length === 0) {
10
+ throw new Error('ItemName cannot be empty');
11
+ }
12
+ return new ItemName({ value: value.trim() });
13
+ }
14
+
15
+ get value(): string {
16
+ return this.props.value;
17
+ }
18
+ }
@@ -0,0 +1,48 @@
1
+ import { Item } from './item.entity';
2
+ import { ItemName } from './item-name.value-object';
3
+
4
+ describe('Item entity', () => {
5
+ it('should create an item via static factory with correct id and name', () => {
6
+ const name = ItemName.create('Widget');
7
+ const item = Item.create('item-1', name);
8
+
9
+ expect(item.id).toBe('item-1');
10
+ expect(item.name).toBe(name);
11
+ expect(item.name.value).toBe('Widget');
12
+ });
13
+
14
+ it('should expose the name value object through the name accessor', () => {
15
+ const name = ItemName.create('Gadget');
16
+ const item = Item.create('item-2', name);
17
+
18
+ expect(item.name.value).toBe('Gadget');
19
+ });
20
+
21
+ it('should be equal to another item with the same id', () => {
22
+ const name = ItemName.create('Widget');
23
+ const item1 = Item.create('same-id', name);
24
+ const item2 = Item.create('same-id', ItemName.create('DifferentName'));
25
+
26
+ expect(item1.equals(item2)).toBe(true);
27
+ });
28
+
29
+ it('should not be equal to an item with a different id', () => {
30
+ const name = ItemName.create('Widget');
31
+ const item1 = Item.create('id-a', name);
32
+ const item2 = Item.create('id-b', name);
33
+
34
+ expect(item1.equals(item2)).toBe(false);
35
+ });
36
+
37
+ it('should be equal to itself (reference equality)', () => {
38
+ const item = Item.create('item-ref', ItemName.create('Test'));
39
+
40
+ expect(item.equals(item)).toBe(true);
41
+ });
42
+
43
+ it('should return false when compared with undefined', () => {
44
+ const item = Item.create('item-undef', ItemName.create('Test'));
45
+
46
+ expect(item.equals(undefined)).toBe(false);
47
+ });
48
+ });
@@ -0,0 +1,19 @@
1
+ import { AggregateRoot } from '../../../shared/base/aggregate-root';
2
+ import { ItemName } from './item-name.value-object';
3
+
4
+ export class Item extends AggregateRoot<string> {
5
+ private _name: ItemName;
6
+
7
+ private constructor(id: string, name: ItemName) {
8
+ super(id);
9
+ this._name = name;
10
+ }
11
+
12
+ static create(id: string, name: ItemName): Item {
13
+ return new Item(id, name);
14
+ }
15
+
16
+ get name(): ItemName {
17
+ return this._name;
18
+ }
19
+ }
@@ -0,0 +1,10 @@
1
+ import { Item } from './item.entity';
2
+
3
+ export const ITEM_REPOSITORY = Symbol('IItemRepository');
4
+
5
+ export interface IItemRepository {
6
+ findById(id: string): Promise<Item | null>;
7
+ findAll(): Promise<Item[]>;
8
+ save(item: Item): Promise<void>;
9
+ delete(id: string): Promise<void>;
10
+ }
@@ -0,0 +1,31 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { CqrsModule } from '@nestjs/cqrs';
3
+ import { MongooseModule } from '@nestjs/mongoose';
4
+ import { ItemController } from './presenter/item.controller';
5
+ import { CreateItemHandler } from './application/commands/create-item.handler';
6
+ import { DeleteItemHandler } from './application/commands/delete-item.handler';
7
+ import { GetItemHandler } from './application/queries/get-item.handler';
8
+ import { ListItemsHandler } from './application/queries/list-items.handler';
9
+ import { MongooseItemRepository } from './infrastructure/persistence/mongoose-item.repository';
10
+ import { ItemSchemaClass, ItemSchema } from './infrastructure/persistence/schemas/item.schema';
11
+ import { ITEM_REPOSITORY } from './domain/item.repository.interface';
12
+
13
+ const CommandHandlers = [CreateItemHandler, DeleteItemHandler];
14
+ const QueryHandlers = [GetItemHandler, ListItemsHandler];
15
+
16
+ @Module({
17
+ imports: [
18
+ CqrsModule,
19
+ MongooseModule.forFeature([{ name: ItemSchemaClass.name, schema: ItemSchema }]),
20
+ ],
21
+ controllers: [ItemController],
22
+ providers: [
23
+ ...CommandHandlers,
24
+ ...QueryHandlers,
25
+ {
26
+ provide: ITEM_REPOSITORY,
27
+ useClass: MongooseItemRepository,
28
+ },
29
+ ],
30
+ })
31
+ export class ExampleModule {}
@@ -0,0 +1,42 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { InjectModel } from '@nestjs/mongoose';
3
+ import { Model } from 'mongoose';
4
+ import { IItemRepository } from '../../domain/item.repository.interface';
5
+ import { Item } from '../../domain/item.entity';
6
+ import { ItemName } from '../../domain/item-name.value-object';
7
+ import { ItemSchemaClass, ItemDocument } from './schemas/item.schema';
8
+
9
+ @Injectable()
10
+ export class MongooseItemRepository implements IItemRepository {
11
+ constructor(
12
+ @InjectModel(ItemSchemaClass.name)
13
+ private readonly itemModel: Model<ItemDocument>,
14
+ ) {}
15
+
16
+ async findById(id: string): Promise<Item | null> {
17
+ const record = await this.itemModel.findById(id).lean().exec();
18
+ if (!record) return null;
19
+ return Item.create(record._id as string, ItemName.create(record.name));
20
+ }
21
+
22
+ async findAll(): Promise<Item[]> {
23
+ const records = await this.itemModel.find().lean().exec();
24
+ return records.map((r) =>
25
+ Item.create(r._id as string, ItemName.create(r.name)),
26
+ );
27
+ }
28
+
29
+ async save(item: Item): Promise<void> {
30
+ await this.itemModel
31
+ .findByIdAndUpdate(
32
+ item.id,
33
+ { name: item.name.value },
34
+ { upsert: true, new: true },
35
+ )
36
+ .exec();
37
+ }
38
+
39
+ async delete(id: string): Promise<void> {
40
+ await this.itemModel.findByIdAndDelete(id).exec();
41
+ }
42
+ }
@@ -0,0 +1,15 @@
1
+ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
2
+ import { HydratedDocument } from 'mongoose';
3
+
4
+ export type ItemDocument = HydratedDocument<ItemSchemaClass>;
5
+
6
+ @Schema({ collection: 'items', timestamps: true })
7
+ export class ItemSchemaClass {
8
+ @Prop({ required: true })
9
+ _id: string;
10
+
11
+ @Prop({ required: true })
12
+ name: string;
13
+ }
14
+
15
+ export const ItemSchema = SchemaFactory.createForClass(ItemSchemaClass);
@@ -0,0 +1,52 @@
1
+ import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
2
+ import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
3
+ import { CommandBus, QueryBus } from '@nestjs/cqrs';
4
+ import { CreateItemDto } from '../application/dtos/create-item.dto';
5
+ import { ItemResponseDto } from '../application/dtos/item.response.dto';
6
+ import { CreateItemCommand } from '../application/commands/create-item.command';
7
+ import { DeleteItemCommand } from '../application/commands/delete-item.command';
8
+ import { GetItemQuery } from '../application/queries/get-item.query';
9
+ import { ListItemsQuery } from '../application/queries/list-items.query';
10
+ import { PaginationDto } from '../../../shared';
11
+
12
+ @ApiTags('example')
13
+ @Controller('items')
14
+ export class ItemController {
15
+ constructor(
16
+ private readonly commandBus: CommandBus,
17
+ private readonly queryBus: QueryBus,
18
+ ) {}
19
+
20
+ @Post()
21
+ @ApiOperation({ summary: 'Create a new item' })
22
+ @ApiResponse({ status: 201, description: 'Item created successfully', type: ItemResponseDto })
23
+ @ApiResponse({ status: 400, description: 'Validation failed' })
24
+ create(@Body() dto: CreateItemDto) {
25
+ return this.commandBus.execute(new CreateItemCommand(dto.name));
26
+ }
27
+
28
+ @Delete(':id')
29
+ @ApiOperation({ summary: 'Delete an item by ID' })
30
+ @ApiParam({ name: 'id', description: 'Item UUID' })
31
+ @ApiResponse({ status: 200, description: 'Item deleted successfully' })
32
+ @ApiResponse({ status: 404, description: 'Item not found' })
33
+ delete(@Param('id') id: string) {
34
+ return this.commandBus.execute(new DeleteItemCommand(id));
35
+ }
36
+
37
+ @Get(':id')
38
+ @ApiOperation({ summary: 'Get an item by ID' })
39
+ @ApiParam({ name: 'id', description: 'Item UUID' })
40
+ @ApiResponse({ status: 200, description: 'Item found', type: ItemResponseDto })
41
+ @ApiResponse({ status: 404, description: 'Item not found' })
42
+ findOne(@Param('id') id: string) {
43
+ return this.queryBus.execute(new GetItemQuery(id));
44
+ }
45
+
46
+ @Get()
47
+ @ApiOperation({ summary: 'List all items with pagination' })
48
+ @ApiResponse({ status: 200, description: 'List of items', type: [ItemResponseDto] })
49
+ findAll(@Query() pagination: PaginationDto) {
50
+ return this.queryBus.execute(new ListItemsQuery());
51
+ }
52
+ }
@@ -0,0 +1,44 @@
1
+ import { AggregateRoot } from './aggregate-root';
2
+ import { DomainEvent } from './domain-event';
3
+
4
+ class TestEvent extends DomainEvent {
5
+ constructor() {
6
+ super();
7
+ }
8
+ }
9
+
10
+ class TestAggregate extends AggregateRoot<string> {
11
+ constructor(id: string) {
12
+ super(id);
13
+ }
14
+ }
15
+
16
+ describe('AggregateRoot', () => {
17
+ it('should extend Entity and return the id', () => {
18
+ const aggregate = new TestAggregate('agg-1');
19
+ expect(aggregate.id).toBe('agg-1');
20
+ });
21
+
22
+ it('should collect a domain event via addDomainEvent', () => {
23
+ const aggregate = new TestAggregate('agg-1');
24
+ const event = new TestEvent();
25
+ aggregate.addDomainEvent(event);
26
+ const events = aggregate.pullDomainEvents();
27
+ expect(events).toHaveLength(1);
28
+ expect(events[0]).toBe(event);
29
+ });
30
+
31
+ it('should clear domain events after pullDomainEvents', () => {
32
+ const aggregate = new TestAggregate('agg-1');
33
+ aggregate.addDomainEvent(new TestEvent());
34
+ aggregate.pullDomainEvents();
35
+ const secondPull = aggregate.pullDomainEvents();
36
+ expect(secondPull).toHaveLength(0);
37
+ });
38
+
39
+ it('should support equals comparison via inherited Entity method', () => {
40
+ const a1 = new TestAggregate('agg-1');
41
+ const a2 = new TestAggregate('agg-1');
42
+ expect(a1.equals(a2)).toBe(true);
43
+ });
44
+ });