├── libs ├── health │ ├── src │ │ ├── index.ts │ │ ├── health.module.ts │ │ └── health.controller.ts │ └── tsconfig.lib.json ├── monads │ ├── src │ │ ├── index.ts │ │ ├── types.ts │ │ ├── option.ts │ │ └── result.ts │ └── tsconfig.lib.json └── events │ ├── src │ ├── event-type.ts │ ├── order-line-status.ts │ ├── exported-event-codec.service.ts │ ├── index.ts │ ├── events.module.ts │ ├── exported-event.ts │ ├── order-created-event.ts │ ├── exported-event-codec.ts │ └── order-line-updated.event.ts │ └── tsconfig.lib.json ├── .prettierrc ├── resources ├── data │ ├── cancel-order-line-request.json │ └── create-order-request.json └── images │ └── architecture-overview.png ├── .docker ├── debezium │ ├── Dockerfile │ └── application.properties ├── order-db │ ├── Dockerfile │ └── initdb.sql ├── shipment-db │ ├── Dockerfile │ └── initdb.sql ├── rabbitmq │ ├── Dockerfile │ └── bootstrap.sh ├── order-service │ └── Dockerfile └── shipment-service │ └── Dockerfile ├── tsconfig.build.json ├── apps ├── order-service │ ├── src │ │ ├── errors │ │ │ └── entity-not-found.ts │ │ ├── service │ │ │ ├── services.module.ts │ │ │ └── order.service.ts │ │ ├── event │ │ │ ├── order-created.event.ts │ │ │ ├── events.module.ts │ │ │ ├── order-line-updated.event.ts │ │ │ ├── order-line-updated-event.listener.ts │ │ │ └── order-created-event.listener.ts │ │ ├── rest │ │ │ ├── order-response.dto.ts │ │ │ ├── update-order-line.dto.ts │ │ │ ├── entity-not-found-exception.filter.ts │ │ │ ├── create-order-request.dto.ts │ │ │ ├── rest.module.ts │ │ │ ├── order.controller.ts │ │ │ └── order-mapper.service.ts │ │ ├── model │ │ │ ├── outbox-event.entity.ts │ │ │ ├── order-line.entity.ts │ │ │ └── purchase-order.entity.ts │ │ ├── config │ │ │ ├── config.module.ts │ │ │ ├── config.service.ts │ │ │ ├── server.config.ts │ │ │ └── database.config.ts │ │ ├── main.ts │ │ └── main.module.ts │ ├── test │ │ ├── jest-e2e.json │ │ ├── jest.json │ │ └── app.e2e-spec.ts │ └── tsconfig.app.json └── shipment-service │ ├── test │ ├── jest-e2e.json │ └── jest.json │ ├── tsconfig.app.json │ └── src │ ├── model │ ├── consumed-message.entity.ts │ └── shipment.entity.ts │ ├── service │ ├── services.module.ts │ ├── type-guard.service.ts │ └── message-log.service.ts │ ├── event │ ├── exported-event.handler.ts │ ├── events.module.ts │ ├── order-line-updated-event.handler.ts │ ├── order-created-event.handler.ts │ ├── providers.ts │ └── rabbitmq-consumer.service.ts │ ├── config │ ├── server.config.ts │ ├── config.module.ts │ ├── config.service.ts │ ├── rabbitmq.config.ts │ └── database.config.ts │ ├── main.ts │ └── main.module.ts ├── .eslintrc.js ├── tsconfig.json ├── Taskfile.yml ├── nest-cli.json ├── .gitignore ├── package.json ├── docker-compose.yml └── README.md /libs/health/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './health.module'; 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /libs/monads/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './result'; 2 | export * from './option'; 3 | -------------------------------------------------------------------------------- /resources/data/cancel-order-line-request.json: -------------------------------------------------------------------------------- 1 | { 2 | "newStatus" : "CANCELLED" 3 | } 4 | -------------------------------------------------------------------------------- /.docker/debezium/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM quay.io/debezium/server:2.5 2 | 3 | COPY ./application.properties /debezium/conf/ -------------------------------------------------------------------------------- /.docker/order-db/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM quay.io/debezium/postgres:16 2 | 3 | COPY initdb.sql /docker-entrypoint-initdb.d/ -------------------------------------------------------------------------------- /.docker/shipment-db/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM quay.io/debezium/postgres:16 2 | 3 | COPY initdb.sql /docker-entrypoint-initdb.d/ -------------------------------------------------------------------------------- /libs/monads/src/types.ts: -------------------------------------------------------------------------------- 1 | export type Lazy = () => T; 2 | 3 | export type Refinement = (a: A) => a is B; 4 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /resources/images/architecture-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebarf/nodejs-outbox/HEAD/resources/images/architecture-overview.png -------------------------------------------------------------------------------- /libs/events/src/event-type.ts: -------------------------------------------------------------------------------- 1 | export enum EventType { 2 | OrderCreated = 'OrderCreated', 3 | OrderLineUpdated = 'OrderLineUpdated', 4 | } 5 | -------------------------------------------------------------------------------- /libs/events/src/order-line-status.ts: -------------------------------------------------------------------------------- 1 | export enum OrderLineStatus { 2 | Entered = 'ENTERED', 3 | Cancelled = 'CANCELLED', 4 | Shipped = 'SHIPPED', 5 | } 6 | -------------------------------------------------------------------------------- /apps/order-service/src/errors/entity-not-found.ts: -------------------------------------------------------------------------------- 1 | export class EntityNotFoundException extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = 'EntityNotFoundException'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /libs/health/src/health.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { HealthController } from './health.controller'; 3 | 4 | @Module({ 5 | controllers: [HealthController], 6 | }) 7 | export class HealthModule {} 8 | -------------------------------------------------------------------------------- /libs/health/src/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | 3 | @Controller() 4 | export class HealthController { 5 | @Get('health') 6 | health() { 7 | return { health: true }; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /apps/order-service/src/service/services.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { OrderService } from './order.service'; 3 | 4 | @Module({ providers: [OrderService], exports: [OrderService] }) 5 | export class ServicesModule {} 6 | -------------------------------------------------------------------------------- /libs/events/src/exported-event-codec.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ExportedEventCodec } from './exported-event-codec'; 3 | 4 | @Injectable() 5 | export class ExportedEventCodecService extends ExportedEventCodec {} 6 | -------------------------------------------------------------------------------- /apps/order-service/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /apps/shipment-service/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /libs/events/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './events.module'; 2 | export * from './event-type'; 3 | export * from './exported-event-codec'; 4 | export * from './exported-event-codec.service'; 5 | export * from './order-created-event'; 6 | export * from './order-line-updated.event'; 7 | -------------------------------------------------------------------------------- /apps/order-service/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "outDir": "../../dist" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/shipment-service/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "outDir": "../../dist" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /libs/events/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "../../dist/libs/events" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /libs/health/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "../../dist/libs/health" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /libs/monads/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "../../dist/libs/monads" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /.docker/rabbitmq/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rabbitmq:3-management-alpine 2 | 3 | ENV RABBITMQ_ADMIN_USER admin 4 | ENV RABBITMQ_ADMIN_PASSWORD admin 5 | ENV RABBITMQ_PID_FILE /var/lib/rabbitmq/mnesia/rabbitmq 6 | 7 | ADD bootstrap.sh /bootstrap.sh 8 | RUN chmod +x /bootstrap.sh 9 | 10 | CMD ["/bootstrap.sh"] -------------------------------------------------------------------------------- /libs/events/src/events.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ExportedEventCodecService } from './exported-event-codec.service'; 3 | @Module({ 4 | providers: [ExportedEventCodecService], 5 | exports: [ExportedEventCodecService], 6 | }) 7 | export class ExportedEventsModule {} 8 | -------------------------------------------------------------------------------- /apps/shipment-service/src/model/consumed-message.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryKey, Property } from '@mikro-orm/core'; 2 | import { UUID } from 'crypto'; 3 | 4 | @Entity() 5 | export class ConsumedMessage { 6 | @PrimaryKey() 7 | eventId!: UUID; 8 | 9 | @Property() 10 | timeOfReceiving = new Date(); 11 | } 12 | -------------------------------------------------------------------------------- /apps/order-service/src/event/order-created.event.ts: -------------------------------------------------------------------------------- 1 | import { PurchaseOrder } from '../model/purchase-order.entity'; 2 | 3 | export const OrderCreatedSymbol = Symbol('OrderCreated'); 4 | 5 | export class OrderCreatedEvent { 6 | readonly order: PurchaseOrder; 7 | 8 | constructor(order: PurchaseOrder) { 9 | this.order = order; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apps/shipment-service/src/model/shipment.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryKey, Property } from '@mikro-orm/core'; 2 | 3 | @Entity() 4 | export class Shipment { 5 | @PrimaryKey() 6 | id = crypto.randomUUID(); 7 | 8 | @Property() 9 | customerId!: number; 10 | 11 | @Property() 12 | orderId!: string; 13 | 14 | @Property() 15 | orderDate!: Date; 16 | } 17 | -------------------------------------------------------------------------------- /apps/shipment-service/src/service/services.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MessageLogService } from './message-log.service'; 3 | import { TypeGuardService } from './type-guard.service'; 4 | 5 | @Module({ 6 | providers: [MessageLogService, TypeGuardService], 7 | exports: [MessageLogService, TypeGuardService], 8 | }) 9 | export class ServicesModule {} 10 | -------------------------------------------------------------------------------- /apps/order-service/test/jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": [ 3 | "js", 4 | "json", 5 | "ts" 6 | ], 7 | "rootDir": ".", 8 | "testRegex": ".*\\.spec\\.ts$", 9 | "transform": { 10 | "^.+\\.(t|j)s$": "ts-jest" 11 | }, 12 | "collectCoverageFrom": [ 13 | "**/*.(t|j)s" 14 | ], 15 | "coverageDirectory": "./coverage", 16 | "testEnvironment": "node", 17 | "roots": [ 18 | "/apps/" 19 | ] 20 | } -------------------------------------------------------------------------------- /apps/shipment-service/test/jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": [ 3 | "js", 4 | "json", 5 | "ts" 6 | ], 7 | "rootDir": ".", 8 | "testRegex": ".*\\.spec\\.ts$", 9 | "transform": { 10 | "^.+\\.(t|j)s$": "ts-jest" 11 | }, 12 | "collectCoverageFrom": [ 13 | "**/*.(t|j)s" 14 | ], 15 | "coverageDirectory": "./coverage", 16 | "testEnvironment": "node", 17 | "roots": [ 18 | "/apps/" 19 | ] 20 | } -------------------------------------------------------------------------------- /resources/data/create-order-request.json: -------------------------------------------------------------------------------- 1 | { 2 | "customerId" : 3, 3 | "orderDate" : "2024-01-01T12:00:00", 4 | "lineItems" : [ 5 | { 6 | "item" : "Debezium in Action", 7 | "quantity" : 2, 8 | "totalPrice" : 39.98 9 | }, 10 | { 11 | "item" : "Debezium for Dummies", 12 | "quantity" : 1, 13 | "totalPrice" : 29.99 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /apps/order-service/src/rest/order-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { OrderLineStatus } from '../model/order-line.entity'; 2 | 3 | export interface OrderLineItemResponseDto { 4 | id: string; 5 | item: string; 6 | quantity: number; 7 | totalPrice: number; 8 | status: OrderLineStatus; 9 | } 10 | 11 | export interface OrderResponseDto { 12 | id: string; 13 | customerId: number; 14 | orderDate: Date; 15 | lineItems: OrderLineItemResponseDto[]; 16 | } 17 | -------------------------------------------------------------------------------- /apps/order-service/src/rest/update-order-line.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum, IsUUID } from 'class-validator'; 2 | import { UUID } from 'node:crypto'; 3 | import { OrderLineStatus } from '../model/order-line.entity'; 4 | 5 | export class UpdateOrderLineParamsDto { 6 | @IsUUID('4') 7 | orderId: UUID; 8 | @IsUUID('4') 9 | orderLineId: UUID; 10 | } 11 | 12 | export class UpdateOrderLineDto { 13 | @IsEnum(OrderLineStatus) 14 | newStatus: OrderLineStatus; 15 | } 16 | -------------------------------------------------------------------------------- /.docker/order-service/Dockerfile: -------------------------------------------------------------------------------- 1 | ### Stage 1 - Buid artifact 2 | FROM node:20-alpine AS builder 3 | 4 | WORKDIR /home/node 5 | 6 | COPY --chown=node:node . . 7 | RUN npm ci 8 | RUN npm run order:build 9 | 10 | ### Stage 2 - Optimize artifact size 11 | FROM node:20-alpine as prod 12 | 13 | ENV NODE_ENV production 14 | 15 | COPY --chown=node:node --from=builder /home/node/dist ./dist 16 | COPY --chown=node:node --from=builder /home/node/node_modules node_modules 17 | 18 | CMD ["node", "dist/apps/order-service/src/main"] -------------------------------------------------------------------------------- /.docker/shipment-service/Dockerfile: -------------------------------------------------------------------------------- 1 | ### Stage 1 - Buid artifact 2 | FROM node:20-alpine AS builder 3 | 4 | WORKDIR /home/node 5 | 6 | COPY --chown=node:node . . 7 | RUN npm ci 8 | RUN npm run shipment:build 9 | 10 | ### Stage 2 - Optimize artifact size 11 | FROM node:20-alpine as prod 12 | 13 | ENV NODE_ENV production 14 | 15 | COPY --chown=node:node --from=builder /home/node/dist ./dist 16 | COPY --chown=node:node --from=builder /home/node/node_modules node_modules 17 | 18 | CMD ["node", "dist/apps/shipment-service/src/main"] -------------------------------------------------------------------------------- /apps/order-service/src/rest/entity-not-found-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, NotFoundException } from '@nestjs/common'; 2 | import { EntityNotFoundException } from '../errors/entity-not-found'; 3 | import { BaseExceptionFilter } from '@nestjs/core'; 4 | 5 | @Catch(EntityNotFoundException) 6 | export class HttpEntityNotFoundExceptionFilter extends BaseExceptionFilter { 7 | catch(exception: EntityNotFoundException, host: ArgumentsHost) { 8 | return super.catch(new NotFoundException(exception.message), host); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /apps/order-service/src/event/events.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ExportedEventsModule } from '@libs/events'; 3 | import { OrderCreatedEventListener } from './order-created-event.listener'; 4 | import { OrderLineUpdatedEventListener } from './order-line-updated-event.listener'; 5 | import { ConfigModule } from '../config/config.module'; 6 | 7 | @Module({ 8 | imports: [ExportedEventsModule, ConfigModule], 9 | providers: [OrderCreatedEventListener, OrderLineUpdatedEventListener], 10 | }) 11 | export class EventsModule {} 12 | -------------------------------------------------------------------------------- /.docker/shipment-db/initdb.sql: -------------------------------------------------------------------------------- 1 | -- Create the schema that we'll use to populate data and watch the effect in the WAL 2 | CREATE SCHEMA shipments; 3 | SET search_path TO shipments; 4 | 5 | CREATE TABLE consumed_message ( 6 | event_id uuid NOT NULL, 7 | time_of_receiving timestamp NULL, 8 | CONSTRAINT consumed_message__pk PRIMARY KEY (event_id) 9 | ); 10 | 11 | CREATE TABLE shipment ( 12 | id uuid NOT NULL, 13 | customer_id integer NOT NULL, 14 | order_date timestamp NULL, 15 | order_id uuid NOT NULL, 16 | CONSTRAINT shipment__pk PRIMARY KEY (id) 17 | ); 18 | 19 | -------------------------------------------------------------------------------- /apps/order-service/src/model/outbox-event.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, JsonType, PrimaryKey, Property } from '@mikro-orm/core'; 2 | import * as crypto from 'node:crypto'; 3 | 4 | @Entity() 5 | export class OutboxEvent { 6 | @PrimaryKey({ type: 'uuid' }) 7 | id = crypto.randomUUID(); 8 | 9 | @Property() 10 | aggregateType!: string; 11 | 12 | @Property() 13 | aggregateId!: string; 14 | 15 | @Property({ type: JsonType }) 16 | payload!: Record; 17 | 18 | @Property() 19 | type!: string; 20 | 21 | @Property() 22 | timestamp = new Date(); 23 | } 24 | -------------------------------------------------------------------------------- /apps/order-service/src/rest/create-order-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsDateString, 3 | IsNumber, 4 | IsString, 5 | ValidateNested, 6 | } from 'class-validator'; 7 | 8 | class CreateOrderLineItemRequestDto { 9 | @IsString() 10 | item: string; 11 | 12 | @IsNumber() 13 | quantity: number; 14 | 15 | @IsNumber() 16 | totalPrice: number; 17 | } 18 | 19 | export class CreateOrderRequestDto { 20 | @IsNumber() 21 | customerId: number; 22 | 23 | @IsDateString() 24 | orderDate: string; 25 | 26 | @ValidateNested() 27 | lineItems: CreateOrderLineItemRequestDto[]; 28 | } 29 | -------------------------------------------------------------------------------- /libs/events/src/exported-event.ts: -------------------------------------------------------------------------------- 1 | import { Equals } from 'class-validator'; 2 | import { EventType } from './event-type'; 3 | 4 | export abstract class ExportedEvent { 5 | abstract readonly eventType: T; 6 | } 7 | 8 | export function exportedEventBaseline(eventType: T) { 9 | abstract class ExportedEventBaseline extends ExportedEvent { 10 | @Equals(eventType) 11 | readonly eventType: T; 12 | 13 | constructor(eventType: T) { 14 | super(); 15 | this.eventType = eventType; 16 | } 17 | } 18 | 19 | return ExportedEventBaseline; 20 | } 21 | -------------------------------------------------------------------------------- /apps/order-service/src/config/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule as BaseConfigModule } from '@nestjs/config'; 3 | import databaseConfig from './database.config'; 4 | import serverConfig from './server.config'; 5 | import { ConfigService } from './config.service'; 6 | 7 | @Module({ 8 | imports: [ 9 | BaseConfigModule.forRoot({ 10 | ignoreEnvFile: true, 11 | cache: true, 12 | load: [databaseConfig, serverConfig], 13 | }), 14 | ], 15 | providers: [ConfigService], 16 | exports: [ConfigService], 17 | }) 18 | export class ConfigModule {} 19 | -------------------------------------------------------------------------------- /apps/shipment-service/src/event/exported-event.handler.ts: -------------------------------------------------------------------------------- 1 | import { EventType } from '@libs/events'; 2 | import { ExportedEvent } from '@libs/events/exported-event'; 3 | import { Result } from '@libs/monads'; 4 | 5 | export abstract class ExportedEventHandler< 6 | U extends EventType, 7 | T extends ExportedEvent = ExportedEvent, 8 | > { 9 | abstract parse(plainEvent: Record): Result; 10 | abstract handle(event: T): void; 11 | 12 | handlePlain(plainEvent: Record): Result { 13 | return this.parse(plainEvent).map(this.handle.bind(this)); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /apps/order-service/src/config/config.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from '@nestjs/common'; 2 | import dbConfig from './database.config'; 3 | import srvConfig from './server.config'; 4 | import { ConfigType } from '@nestjs/config'; 5 | 6 | export class ConfigService { 7 | constructor( 8 | @Inject(dbConfig.KEY) 9 | private readonly databaseConfig: ConfigType, 10 | 11 | @Inject(srvConfig.KEY) 12 | private readonly serverConfig: ConfigType, 13 | ) {} 14 | 15 | get database() { 16 | return this.databaseConfig; 17 | } 18 | 19 | get server() { 20 | return this.serverConfig; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /apps/order-service/src/config/server.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { plainToInstance } from 'class-transformer'; 3 | import { IsNumberString, validateSync } from 'class-validator'; 4 | 5 | class ServerConfig { 6 | @IsNumberString() 7 | SERVER_PORT: string; 8 | } 9 | 10 | export default registerAs('server', () => { 11 | const instance = plainToInstance(ServerConfig, process.env); 12 | const validationErrors = validateSync(instance); 13 | 14 | if (validationErrors.length > 0) { 15 | throw new Error(validationErrors.toString()); 16 | } 17 | 18 | return { 19 | port: +instance.SERVER_PORT, 20 | }; 21 | }); 22 | -------------------------------------------------------------------------------- /apps/shipment-service/src/service/type-guard.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import * as clv from 'class-validator'; 3 | import { UUID } from 'crypto'; 4 | 5 | @Injectable() 6 | export class TypeGuardService { 7 | isUUID(value: unknown): value is UUID { 8 | return clv.isUUID(value); 9 | } 10 | 11 | isRecord(value: unknown): value is Record { 12 | return value !== null && !Array.isArray(value) && typeof value === 'object'; 13 | } 14 | 15 | isEnum>(entity: T) { 16 | return (value: unknown): value is T[keyof T] => clv.isEnum(value, entity); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/order-service/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { MainModule } from './main.module'; 3 | import { Logger, ValidationPipe } from '@nestjs/common'; 4 | import { ConfigService } from './config/config.service'; 5 | 6 | async function bootstrap() { 7 | const app = await NestFactory.create(MainModule); 8 | const config = app.get(ConfigService); 9 | 10 | app.enableShutdownHooks(); 11 | app.useGlobalPipes(new ValidationPipe({ transform: true })); 12 | 13 | await app.listen(config.server.port); 14 | 15 | const appUrl = await app.getUrl(); 16 | Logger.log(`Server listening on ${appUrl}`, 'Order'); 17 | } 18 | bootstrap(); 19 | -------------------------------------------------------------------------------- /apps/shipment-service/src/config/server.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { plainToInstance } from 'class-transformer'; 3 | import { IsNumberString, validateSync } from 'class-validator'; 4 | 5 | class ServerConfig { 6 | @IsNumberString() 7 | SERVER_PORT: string; 8 | } 9 | 10 | export default registerAs('server', () => { 11 | const instance = plainToInstance(ServerConfig, process.env); 12 | const validationErrors = validateSync(instance); 13 | 14 | if (validationErrors.length > 0) { 15 | throw new Error(validationErrors.toString()); 16 | } 17 | 18 | return { 19 | port: +instance.SERVER_PORT, 20 | }; 21 | }); 22 | -------------------------------------------------------------------------------- /apps/shipment-service/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { MainModule } from './main.module'; 3 | import { ConfigService } from './config/config.service'; 4 | import { Logger, ValidationPipe } from '@nestjs/common'; 5 | 6 | async function bootstrap() { 7 | const app = await NestFactory.create(MainModule); 8 | const config = app.get(ConfigService); 9 | 10 | app.enableShutdownHooks(); 11 | app.useGlobalPipes(new ValidationPipe({ transform: true })); 12 | 13 | await app.listen(config.server.port); 14 | 15 | const appUrl = await app.getUrl(); 16 | Logger.log(`Server listening on ${appUrl}`, 'Shipment'); 17 | } 18 | bootstrap(); 19 | -------------------------------------------------------------------------------- /apps/shipment-service/src/config/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule as BaseConfigModule } from '@nestjs/config'; 3 | import databaseConfig from './database.config'; 4 | import serverConfig from './server.config'; 5 | import rabbitMQConfig from './rabbitmq.config'; 6 | import { ConfigService } from './config.service'; 7 | 8 | @Module({ 9 | imports: [ 10 | BaseConfigModule.forRoot({ 11 | ignoreEnvFile: true, 12 | cache: true, 13 | load: [databaseConfig, serverConfig, rabbitMQConfig], 14 | }), 15 | ], 16 | providers: [ConfigService], 17 | exports: [ConfigService], 18 | }) 19 | export class ConfigModule {} 20 | -------------------------------------------------------------------------------- /apps/order-service/src/rest/rest.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { OrderController } from './order.controller'; 3 | import { OrderMapperService } from './order-mapper.service'; 4 | import { ServicesModule } from '../service/services.module'; 5 | import { APP_FILTER } from '@nestjs/core'; 6 | import { HttpEntityNotFoundExceptionFilter } from './entity-not-found-exception.filter'; 7 | 8 | @Module({ 9 | imports: [ServicesModule], 10 | controllers: [OrderController], 11 | providers: [ 12 | OrderMapperService, 13 | { 14 | provide: APP_FILTER, 15 | useClass: HttpEntityNotFoundExceptionFilter, 16 | }, 17 | ], 18 | }) 19 | export class RestModule {} 20 | -------------------------------------------------------------------------------- /apps/order-service/src/model/order-line.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; 2 | import * as crypto from 'node:crypto'; 3 | import { PurchaseOrder } from './purchase-order.entity'; 4 | 5 | @Entity() 6 | export class OrderLine { 7 | @PrimaryKey() 8 | id = crypto.randomUUID(); 9 | 10 | @Property() 11 | quantity!: number; 12 | 13 | @Property() 14 | totalPrice!: number; 15 | 16 | @Property() 17 | item!: string; 18 | 19 | @Enum(() => OrderLineStatus) 20 | status!: OrderLineStatus; 21 | 22 | @ManyToOne(() => PurchaseOrder) 23 | order!: PurchaseOrder; 24 | } 25 | 26 | export enum OrderLineStatus { 27 | Entered = 'ENTERED', 28 | Cancelled = 'CANCELLED', 29 | Shipped = 'SHIPPED', 30 | } 31 | -------------------------------------------------------------------------------- /apps/order-service/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { MainModule } from '../src/main.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [MainModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/health') 21 | .expect(200) 22 | .expect({ health: true }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /apps/shipment-service/src/service/message-log.service.ts: -------------------------------------------------------------------------------- 1 | import { EntityManager } from '@mikro-orm/postgresql'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { UUID } from 'crypto'; 4 | import { ConsumedMessage } from '../model/consumed-message.entity'; 5 | 6 | @Injectable() 7 | export class MessageLogService { 8 | constructor(private readonly entityManager: EntityManager) {} 9 | 10 | markAsProcessed(eventId: UUID) { 11 | const message = new ConsumedMessage(); 12 | message.eventId = eventId; 13 | 14 | this.entityManager.persist(message); 15 | } 16 | 17 | async wasAlreadyProcessed(eventId: UUID) { 18 | const message = await this.entityManager.findOne(ConsumedMessage, { 19 | eventId, 20 | }); 21 | return !!message; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /apps/order-service/src/event/order-line-updated.event.ts: -------------------------------------------------------------------------------- 1 | import { UUID } from 'crypto'; 2 | import { OrderLineStatus } from '../model/order-line.entity'; 3 | 4 | export const OrderLineUpdatedSymbol = Symbol('OrderLineUpdated'); 5 | 6 | export class OrderLineUpdatedEvent { 7 | readonly orderId: UUID; 8 | readonly orderLineId: UUID; 9 | readonly newStatus: OrderLineStatus; 10 | readonly oldStatus: OrderLineStatus; 11 | 12 | constructor({ 13 | newStatus, 14 | oldStatus, 15 | orderId, 16 | orderLineId, 17 | }: { 18 | orderId: UUID; 19 | orderLineId: UUID; 20 | newStatus: OrderLineStatus; 21 | oldStatus: OrderLineStatus; 22 | }) { 23 | this.newStatus = newStatus; 24 | this.oldStatus = oldStatus; 25 | this.orderId = orderId; 26 | this.orderLineId = orderLineId; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/shipment-service/src/config/config.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from '@nestjs/common'; 2 | import dbConfig from './database.config'; 3 | import srvConfig from './server.config'; 4 | import rabbitConfig from './rabbitmq.config'; 5 | import { ConfigType } from '@nestjs/config'; 6 | 7 | export class ConfigService { 8 | constructor( 9 | @Inject(dbConfig.KEY) 10 | private readonly databaseConfig: ConfigType, 11 | 12 | @Inject(srvConfig.KEY) 13 | private readonly serverConfig: ConfigType, 14 | 15 | @Inject(rabbitConfig.KEY) 16 | private readonly rabbitMQConfig: ConfigType, 17 | ) {} 18 | 19 | get database() { 20 | return this.databaseConfig; 21 | } 22 | 23 | get server() { 24 | return this.serverConfig; 25 | } 26 | 27 | get rabbit() { 28 | return this.rabbitMQConfig; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/shipment-service/src/event/events.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { RabbitMqConsumerService } from './rabbitmq-consumer.service'; 3 | import { ServicesModule } from '../service/services.module'; 4 | import { ConfigModule } from '../config/config.module'; 5 | import { ExportedEventsModule } from '@libs/events'; 6 | import { ExportedEventHandlerResolverProvider } from './providers'; 7 | import { OrderCreatedEventHandler } from './order-created-event.handler'; 8 | import { OrderLineUpdatedEventHandler } from './order-line-updated-event.handler'; 9 | 10 | @Module({ 11 | imports: [ServicesModule, ConfigModule, ExportedEventsModule], 12 | providers: [ 13 | RabbitMqConsumerService, 14 | OrderCreatedEventHandler, 15 | OrderLineUpdatedEventHandler, 16 | ExportedEventHandlerResolverProvider, 17 | ], 18 | }) 19 | export class EventsModule {} 20 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | "@typescript-eslint/no-unused-vars": [ 25 | "error", 26 | { 27 | "argsIgnorePattern": "^_", 28 | "varsIgnorePattern": "^_" 29 | } 30 | ] 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /.docker/rabbitmq/bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Create RabbitMQ resources. 4 | ( rabbitmqctl wait --timeout 60 $RABBITMQ_PID_FILE ; \ 5 | rabbitmqctl add_user $RABBITMQ_ADMIN_USER $RABBITMQ_ADMIN_PASSWORD 2>/dev/null ; \ 6 | rabbitmqctl set_user_tags $RABBITMQ_ADMIN_USER administrator ; \ 7 | rabbitmqctl set_permissions -p / $RABBITMQ_ADMIN_USER ".*" ".*" ".*" ; \ 8 | rabbitmqadmin declare exchange name=order.events type=fanout -u $RABBITMQ_ADMIN_USER -p $RABBITMQ_ADMIN_PASSWORD ; \ 9 | rabbitmqadmin declare queue name=shipment.orders durable=true -u $RABBITMQ_ADMIN_USER -p $RABBITMQ_ADMIN_PASSWORD ; \ 10 | rabbitmqadmin declare binding source="order.events" destination_type="queue" destination="shipment.orders" -u $RABBITMQ_ADMIN_USER -p $RABBITMQ_ADMIN_PASSWORD ; ) & 11 | 12 | # $@ is used to pass arguments to the rabbitmq-server command. 13 | # For example if you use it like this: docker run -d rabbitmq arg1 arg2, 14 | # it will be as you run in the container rabbitmq-server arg1 arg2. 15 | rabbitmq-server $@ -------------------------------------------------------------------------------- /libs/events/src/order-created-event.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsUUID, IsISO8601 } from 'class-validator'; 2 | import { EventType } from './event-type'; 3 | import { exportedEventBaseline } from './exported-event'; 4 | import { UUID } from 'crypto'; 5 | 6 | export class OrderCreatedExportedEvent extends exportedEventBaseline( 7 | EventType.OrderCreated, 8 | ) { 9 | @IsUUID('4') 10 | readonly id: UUID; 11 | 12 | @IsNumber() 13 | readonly customerId: number; 14 | 15 | @IsISO8601() 16 | readonly orderDate: string; 17 | 18 | constructor(id: UUID, customerId: number, orderDate: string) { 19 | super(EventType.OrderCreated); 20 | 21 | this.id = id; 22 | this.customerId = customerId; 23 | this.orderDate = orderDate; 24 | } 25 | 26 | static of({ 27 | id, 28 | customerId, 29 | orderDate, 30 | }: { 31 | id: UUID; 32 | customerId: number; 33 | orderDate: string; 34 | }) { 35 | return new OrderCreatedExportedEvent(id, customerId, orderDate); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /apps/shipment-service/src/event/order-line-updated-event.handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EventType, 3 | ExportedEventCodecService, 4 | OrderLineUpdatedExportedEvent, 5 | } from '@libs/events'; 6 | import { ExportedEventHandler } from './exported-event.handler'; 7 | import { Result } from '@libs/monads'; 8 | import { Injectable, Logger } from '@nestjs/common'; 9 | 10 | @Injectable() 11 | export class OrderLineUpdatedEventHandler extends ExportedEventHandler { 12 | private readonly logger = new Logger('OrderLineUpdatedEventHandler'); 13 | 14 | constructor(private readonly exportedEventCodec: ExportedEventCodecService) { 15 | super(); 16 | } 17 | 18 | parse( 19 | plain: Record, 20 | ): Result { 21 | return this.exportedEventCodec.parse(plain, OrderLineUpdatedExportedEvent); 22 | } 23 | 24 | handle(event: OrderLineUpdatedExportedEvent): void { 25 | this.logger.warn(`Processing of ${event.eventType} not yet implemented`); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/shipment-service/src/config/rabbitmq.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { plainToInstance } from 'class-transformer'; 3 | import { IsNumberString, IsString, validateSync } from 'class-validator'; 4 | 5 | class RabbitMQConfig { 6 | @IsString() 7 | RABBITMQ_HOST: string; 8 | 9 | @IsNumberString() 10 | RABBITMQ_PORT: string; 11 | 12 | @IsString() 13 | RABBITMQ_USER: string; 14 | 15 | @IsString() 16 | RABBITMQ_PASSWORD: string; 17 | 18 | @IsString() 19 | RABBITMQ_ORDER_QUEUE: string; 20 | } 21 | 22 | export default registerAs('rabbitMQ', () => { 23 | const instance = plainToInstance(RabbitMQConfig, process.env); 24 | const validationErrors = validateSync(instance); 25 | 26 | if (validationErrors.length > 0) { 27 | throw new Error(validationErrors.toString()); 28 | } 29 | 30 | return { 31 | host: instance.RABBITMQ_HOST, 32 | port: +instance.RABBITMQ_PORT, 33 | user: instance.RABBITMQ_USER, 34 | password: instance.RABBITMQ_PASSWORD, 35 | orderQueue: instance.RABBITMQ_ORDER_QUEUE, 36 | }; 37 | }); 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": true, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false, 20 | "paths": { 21 | "@libs/health": [ 22 | "libs/health/src" 23 | ], 24 | "@libs/health/*": [ 25 | "libs/health/src/*" 26 | ], 27 | "@libs/events": [ 28 | "libs/events/src" 29 | ], 30 | "@libs/events/*": [ 31 | "libs/events/src/*" 32 | ], 33 | "@libs/monads": [ 34 | "libs/monads/src" 35 | ], 36 | "@libs/monads/*": [ 37 | "libs/monads/src/*" 38 | ] 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /apps/order-service/src/model/purchase-order.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Collection, 3 | Entity, 4 | OneToMany, 5 | PrimaryKey, 6 | Property, 7 | } from '@mikro-orm/core'; 8 | import * as crypto from 'node:crypto'; 9 | import { OrderLine, OrderLineStatus } from './order-line.entity'; 10 | import { EntityNotFoundException } from '../errors/entity-not-found'; 11 | 12 | @Entity() 13 | export class PurchaseOrder { 14 | @PrimaryKey() 15 | id = crypto.randomUUID(); 16 | 17 | @Property() 18 | customerId!: number; 19 | 20 | @Property() 21 | orderDate = new Date(); 22 | 23 | @OneToMany(() => OrderLine, 'order') 24 | lineItems = new Collection(this); 25 | 26 | updateLineItemStatus(lineItemId: string, newStatus: OrderLineStatus) { 27 | const lineItem = this.lineItems.find( 28 | (lineItem) => lineItem.id === lineItemId, 29 | ); 30 | if (typeof lineItem === 'undefined' || lineItem === null) { 31 | throw new EntityNotFoundException( 32 | `Order line with id ${lineItemId} was not found`, 33 | ); 34 | } 35 | 36 | const oldStatus = lineItem.status; 37 | lineItem.status = newStatus; 38 | 39 | return oldStatus; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /apps/order-service/src/config/database.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { plainToInstance } from 'class-transformer'; 3 | import { 4 | IsBooleanString, 5 | IsNumberString, 6 | IsString, 7 | validateSync, 8 | } from 'class-validator'; 9 | 10 | class DatabaseConfig { 11 | @IsString() 12 | DB_NAME: string; 13 | 14 | @IsString() 15 | DB_SCHEMA: string; 16 | 17 | @IsString() 18 | DB_HOST: string; 19 | 20 | @IsNumberString() 21 | DB_PORT: string; 22 | 23 | @IsString() 24 | DB_USER: string; 25 | 26 | @IsString() 27 | DB_PASSWORD: string; 28 | 29 | @IsBooleanString() 30 | DB_DEBUG: string; 31 | } 32 | 33 | export default registerAs('database', () => { 34 | const instance = plainToInstance(DatabaseConfig, process.env); 35 | const validationErrors = validateSync(instance); 36 | 37 | if (validationErrors.length > 0) { 38 | throw new Error(validationErrors.toString()); 39 | } 40 | 41 | return { 42 | name: instance.DB_NAME, 43 | schema: instance.DB_SCHEMA, 44 | host: instance.DB_HOST, 45 | port: +instance.DB_PORT, 46 | user: instance.DB_USER, 47 | password: instance.DB_PASSWORD, 48 | debug: instance.DB_DEBUG === 'true', 49 | }; 50 | }); 51 | -------------------------------------------------------------------------------- /apps/shipment-service/src/config/database.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { plainToInstance } from 'class-transformer'; 3 | import { 4 | IsBooleanString, 5 | IsNumberString, 6 | IsString, 7 | validateSync, 8 | } from 'class-validator'; 9 | 10 | class DatabaseConfig { 11 | @IsString() 12 | DB_NAME: string; 13 | 14 | @IsString() 15 | DB_SCHEMA: string; 16 | 17 | @IsString() 18 | DB_HOST: string; 19 | 20 | @IsNumberString() 21 | DB_PORT: string; 22 | 23 | @IsString() 24 | DB_USER: string; 25 | 26 | @IsString() 27 | DB_PASSWORD: string; 28 | 29 | @IsBooleanString() 30 | DB_DEBUG: string; 31 | } 32 | 33 | export default registerAs('database', () => { 34 | const instance = plainToInstance(DatabaseConfig, process.env); 35 | const validationErrors = validateSync(instance); 36 | 37 | if (validationErrors.length > 0) { 38 | throw new Error(validationErrors.toString()); 39 | } 40 | 41 | return { 42 | name: instance.DB_NAME, 43 | schema: instance.DB_SCHEMA, 44 | host: instance.DB_HOST, 45 | port: +instance.DB_PORT, 46 | user: instance.DB_USER, 47 | password: instance.DB_PASSWORD, 48 | debug: instance.DB_DEBUG === 'true', 49 | }; 50 | }); 51 | -------------------------------------------------------------------------------- /libs/events/src/exported-event-codec.ts: -------------------------------------------------------------------------------- 1 | import { validateSync } from 'class-validator'; 2 | import { 3 | ClassConstructor, 4 | instanceToPlain, 5 | plainToInstance, 6 | } from 'class-transformer'; 7 | import { EventType } from './event-type'; 8 | import { ExportedEvent } from './exported-event'; 9 | import { Result, failure, success } from '@libs/monads'; 10 | 11 | export class ExportedEventCodec { 12 | parse>( 13 | event: Record, 14 | ctor: ClassConstructor, 15 | ): Result { 16 | const instance = plainToInstance(ctor, event); 17 | 18 | const validationErrors = validateSync(instance); 19 | if (validationErrors.length > 0) { 20 | return failure(new Error(validationErrors.toString())); 21 | } 22 | 23 | return success(instance); 24 | } 25 | 26 | serialize(event: ExportedEvent) { 27 | return this.toString(event); 28 | } 29 | 30 | toString(event: ExportedEvent) { 31 | const plain = instanceToPlain(event); 32 | return JSON.stringify(plain); 33 | } 34 | 35 | toPlain(event: ExportedEvent) { 36 | return instanceToPlain(event); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/shipment-service/src/event/order-created-event.handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EventType, 3 | ExportedEventCodecService, 4 | OrderCreatedExportedEvent, 5 | } from '@libs/events'; 6 | import { ExportedEventHandler } from './exported-event.handler'; 7 | import { Result } from '@libs/monads'; 8 | import { Injectable } from '@nestjs/common'; 9 | import { Shipment } from '../model/shipment.entity'; 10 | import { EntityManager } from '@mikro-orm/postgresql'; 11 | 12 | @Injectable() 13 | export class OrderCreatedEventHandler extends ExportedEventHandler { 14 | constructor( 15 | private readonly exportedEventCodec: ExportedEventCodecService, 16 | private readonly entityManager: EntityManager, 17 | ) { 18 | super(); 19 | } 20 | 21 | parse( 22 | plain: Record, 23 | ): Result { 24 | return this.exportedEventCodec.parse(plain, OrderCreatedExportedEvent); 25 | } 26 | 27 | handle(event: OrderCreatedExportedEvent): void { 28 | const shipment = new Shipment(); 29 | 30 | shipment.customerId = event.customerId; 31 | shipment.orderDate = new Date(event.orderDate); 32 | shipment.orderId = event.id; 33 | 34 | this.entityManager.persist(shipment); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.docker/order-db/initdb.sql: -------------------------------------------------------------------------------- 1 | -- Create the schema that we'll use to populate data and watch the effect in the WAL 2 | CREATE SCHEMA orders; 3 | SET search_path TO orders; 4 | 5 | CREATE TABLE purchase_order ( 6 | id uuid NOT NULL, 7 | customer_id integer NOT NULL, 8 | order_date timestamp NOT NULL, 9 | CONSTRAINT purchase_order__pk PRIMARY KEY (id) 10 | ); 11 | 12 | CREATE TABLE order_line ( 13 | id uuid NOT NULL, 14 | quantity integer NOT NULL, 15 | total_price decimal(12, 2) NOT NULL, 16 | order_id uuid NOT NULL, 17 | item varchar(255) NOT NULL, 18 | "status" varchar(255) NOT NULL, 19 | CONSTRAINT order_line__pk PRIMARY KEY (id), 20 | CONSTRAINT order_line__status__enum_check CHECK (((status)::text = ANY ((ARRAY['ENTERED'::character varying, 'CANCELLED'::character varying, 'SHIPPED'::character varying])::text[]))) 21 | ); 22 | ALTER TABLE order_line ADD CONSTRAINT order_line__purchase_order__fk FOREIGN KEY (order_id) REFERENCES purchase_order(id); 23 | 24 | CREATE TABLE outbox_event ( 25 | id uuid NOT NULL, 26 | "timestamp" timestamp NOT NULL, 27 | aggregate_id varchar(255) NOT NULL, 28 | aggregate_type varchar(255) NOT NULL, 29 | payload jsonb NOT NULL, 30 | "type" varchar(255) NOT NULL, 31 | CONSTRAINT outbox_event__pk PRIMARY KEY (id) 32 | ); 33 | ALTER TABLE outbox_event REPLICA IDENTITY FULL; -------------------------------------------------------------------------------- /apps/order-service/src/rest/order.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Param, Post, Put } from '@nestjs/common'; 2 | import { 3 | UpdateOrderLineDto, 4 | UpdateOrderLineParamsDto, 5 | } from './update-order-line.dto'; 6 | import { OrderMapperService } from './order-mapper.service'; 7 | import { CreateOrderRequestDto } from './create-order-request.dto'; 8 | import { OrderService } from '../service/order.service'; 9 | 10 | @Controller('orders') 11 | export class OrderController { 12 | constructor( 13 | private readonly orderService: OrderService, 14 | private readonly orderMapper: OrderMapperService, 15 | ) {} 16 | 17 | @Post() 18 | async createOrder(@Body() createOrderDto: CreateOrderRequestDto) { 19 | const order = this.orderMapper.fromCreateRequest(createOrderDto); 20 | const persistedOrder = await this.orderService.addOrder(order); 21 | 22 | return this.orderMapper.toResponse(persistedOrder); 23 | } 24 | 25 | @Put('/:orderId/lines/:orderLineId') 26 | async updateOrderLine( 27 | @Param() params: UpdateOrderLineParamsDto, 28 | @Body() updateOrderLineDto: UpdateOrderLineDto, 29 | ) { 30 | const order = await this.orderService.updateOrderLine({ 31 | orderId: params.orderId, 32 | orderLineId: params.orderLineId, 33 | orderLineStatus: updateOrderLineDto.newStatus, 34 | }); 35 | return this.orderMapper.toResponse(order); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /apps/order-service/src/event/order-line-updated-event.listener.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { OnEvent } from '@nestjs/event-emitter'; 3 | import { 4 | OrderLineUpdatedEvent, 5 | OrderLineUpdatedSymbol, 6 | } from './order-line-updated.event'; 7 | import { OutboxEvent } from '../model/outbox-event.entity'; 8 | import { EntityManager } from '@mikro-orm/postgresql'; 9 | import { 10 | OrderLineUpdatedExportedEvent, 11 | ExportedEventCodecService, 12 | } from '@libs/events'; 13 | 14 | @Injectable() 15 | export class OrderLineUpdatedEventListener { 16 | constructor( 17 | private readonly entityManager: EntityManager, 18 | private readonly exportedEventCode: ExportedEventCodecService, 19 | ) {} 20 | 21 | private toExportedEvent( 22 | event: OrderLineUpdatedEvent, 23 | ): OrderLineUpdatedExportedEvent { 24 | return OrderLineUpdatedExportedEvent.of(event); 25 | } 26 | 27 | @OnEvent(OrderLineUpdatedSymbol) 28 | handleOrderLineUpdatedEvent(event: OrderLineUpdatedEvent) { 29 | const exportedEvent = this.toExportedEvent(event); 30 | const outboxEvent = new OutboxEvent(); 31 | 32 | outboxEvent.aggregateId = `${exportedEvent.orderId}`; 33 | outboxEvent.aggregateType = 'order'; 34 | outboxEvent.type = exportedEvent.eventType; 35 | outboxEvent.payload = this.exportedEventCode.toPlain(exportedEvent); 36 | 37 | this.entityManager.persist(outboxEvent); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /apps/shipment-service/src/main.module.ts: -------------------------------------------------------------------------------- 1 | import { HealthModule } from '@libs/health'; 2 | import { Logger, Module } from '@nestjs/common'; 3 | import { ConfigModule } from './config/config.module'; 4 | import { MikroOrmModule } from '@mikro-orm/nestjs'; 5 | import { ConfigService } from './config/config.service'; 6 | import { PostgreSqlDriver } from '@mikro-orm/postgresql'; 7 | import { SqlHighlighter } from '@mikro-orm/sql-highlighter'; 8 | import { EventsModule } from './event/events.module'; 9 | 10 | const logger = new Logger('Database'); 11 | 12 | @Module({ 13 | imports: [ 14 | HealthModule, 15 | ConfigModule, 16 | EventsModule, 17 | MikroOrmModule.forRootAsync({ 18 | imports: [ConfigModule], 19 | inject: [ConfigService], 20 | useFactory: (config: ConfigService) => ({ 21 | entities: ['./dist/apps/shipment-service/src/model'], 22 | entitiesTs: ['./src/model'], 23 | forceUtcTimezone: true, 24 | driver: PostgreSqlDriver, 25 | host: config.database.host, 26 | dbName: config.database.name, 27 | schema: config.database.schema, 28 | port: config.database.port, 29 | user: config.database.user, 30 | password: config.database.password, 31 | debug: config.database.debug, 32 | logger: logger.log.bind(logger), 33 | highlighter: new SqlHighlighter(), 34 | }), 35 | }), 36 | ], 37 | }) 38 | export class MainModule {} 39 | -------------------------------------------------------------------------------- /libs/events/src/order-line-updated.event.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum, IsUUID } from 'class-validator'; 2 | import { EventType } from './event-type'; 3 | import { exportedEventBaseline } from './exported-event'; 4 | import { OrderLineStatus } from './order-line-status'; 5 | import { UUID } from 'crypto'; 6 | 7 | export class OrderLineUpdatedExportedEvent extends exportedEventBaseline( 8 | EventType.OrderLineUpdated, 9 | ) { 10 | @IsUUID('4') 11 | readonly orderId: UUID; 12 | 13 | @IsUUID('4') 14 | readonly orderLineId: UUID; 15 | 16 | @IsEnum(OrderLineStatus) 17 | readonly newStatus: OrderLineStatus; 18 | 19 | @IsEnum(OrderLineStatus) 20 | readonly oldStatus: OrderLineStatus; 21 | 22 | constructor( 23 | orderId: UUID, 24 | orderLineId: UUID, 25 | newStatus: OrderLineStatus, 26 | oldStatus: OrderLineStatus, 27 | ) { 28 | super(EventType.OrderLineUpdated); 29 | 30 | this.orderId = orderId; 31 | this.orderLineId = orderLineId; 32 | this.newStatus = newStatus; 33 | this.oldStatus = oldStatus; 34 | } 35 | 36 | static of({ 37 | orderId, 38 | orderLineId, 39 | newStatus, 40 | oldStatus, 41 | }: { 42 | orderId: UUID; 43 | orderLineId: UUID; 44 | newStatus: OrderLineStatus; 45 | oldStatus: OrderLineStatus; 46 | }) { 47 | return new OrderLineUpdatedExportedEvent( 48 | orderId, 49 | orderLineId, 50 | newStatus, 51 | oldStatus, 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /apps/shipment-service/src/event/providers.ts: -------------------------------------------------------------------------------- 1 | import { Provider } from '@nestjs/common'; 2 | import { OrderCreatedEventHandler } from './order-created-event.handler'; 3 | import { OrderLineUpdatedEventHandler } from './order-line-updated-event.handler'; 4 | import { EventType } from '@libs/events'; 5 | import { Option, fromNullish } from '@libs/monads'; 6 | import { ExportedEventHandler } from './exported-event.handler'; 7 | import { ExportedEvent } from '@libs/events/exported-event'; 8 | 9 | export const ExportedEventHandlerResolverToken = Symbol( 10 | 'ExportedEventHandlerResolver', 11 | ); 12 | 13 | export type ExportedEventHandlerResolver = { 14 | resolve: ( 15 | eventType: EventType, 16 | ) => Option>>; 17 | }; 18 | 19 | export const ExportedEventHandlerResolverProvider: Provider = { 20 | provide: ExportedEventHandlerResolverToken, 21 | useFactory: ( 22 | orderCreatedEventHandler: OrderCreatedEventHandler, 23 | orderLineUpdatedEventHandler: OrderLineUpdatedEventHandler, 24 | ) => { 25 | const handlerMap = new Map>([ 26 | [EventType.OrderCreated, orderCreatedEventHandler], 27 | [EventType.OrderLineUpdated, orderLineUpdatedEventHandler], 28 | ]); 29 | 30 | return { 31 | resolve: (eventType: EventType) => fromNullish(handlerMap.get(eventType)), 32 | }; 33 | }, 34 | inject: [OrderCreatedEventHandler, OrderLineUpdatedEventHandler], 35 | }; 36 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | tasks: 4 | build: 5 | cmds: 6 | - docker compose build 7 | 8 | up: 9 | cmds: 10 | - docker compose up 11 | 12 | bootstrap: 13 | cmds: 14 | - docker compose up --build 15 | 16 | create-order: 17 | cmds: 18 | - > 19 | curl -X POST -H "Content-Type: application/json" --data @./resources/data/create-order-request.json http://localhost:3000/orders 20 | 21 | cancel-order-line: 22 | cmds: 23 | - > 24 | curl -X PUT -H "Content-Type: application/json" --data @./resources/data/cancel-order-line-request.json http://localhost:3000/orders/{{.ORDER_ID}}/lines/{{.ORDER_LINE_ID}} 25 | 26 | shipment-service-logs: 27 | cmds: 28 | - docker compose logs --follow shipment-service 29 | 30 | order-service-logs: 31 | cmds: 32 | - docker compose logs --follow order-service 33 | 34 | rabbitmq-logs: 35 | cmds: 36 | - docker compose logs --follow rabbitmq 37 | 38 | order-db-logs: 39 | cmds: 40 | - docker compose logs --follow order-db 41 | 42 | order-db-session: 43 | cmds: 44 | - docker compose run --rm order-db psql -d postgres://postgres:postgres@order-db/orderdb 45 | 46 | shipment-db-logs: 47 | cmds: 48 | - docker compose logs --follow shipment-db 49 | 50 | shipment-db-session: 51 | cmds: 52 | - docker compose run --rm shipment-db psql -d postgres://postgres:postgres@shipment-db/shipmentdb 53 | 54 | debezium-logs: 55 | cmds: 56 | - docker compose logs --follow debezium-server -------------------------------------------------------------------------------- /apps/order-service/src/event/order-created-event.listener.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { OnEvent } from '@nestjs/event-emitter'; 3 | import { OrderCreatedEvent, OrderCreatedSymbol } from './order-created.event'; 4 | import { EntityManager } from '@mikro-orm/postgresql'; 5 | import { OutboxEvent } from '../model/outbox-event.entity'; 6 | import { 7 | OrderCreatedExportedEvent, 8 | ExportedEventCodecService, 9 | } from '@libs/events'; 10 | 11 | @Injectable() 12 | export class OrderCreatedEventListener { 13 | constructor( 14 | private readonly entityManager: EntityManager, 15 | private readonly exportedEventCodec: ExportedEventCodecService, 16 | ) {} 17 | 18 | private toExportedEvent(event: OrderCreatedEvent): OrderCreatedExportedEvent { 19 | const { order } = event; 20 | const exportedEvent = OrderCreatedExportedEvent.of({ 21 | customerId: order.customerId, 22 | id: order.id, 23 | orderDate: order.orderDate.toISOString(), 24 | }); 25 | 26 | return exportedEvent; 27 | } 28 | 29 | @OnEvent(OrderCreatedSymbol) 30 | handleOrderCreatedEvent(event: OrderCreatedEvent) { 31 | const exportedEvent = this.toExportedEvent(event); 32 | const outboxEvent = new OutboxEvent(); 33 | 34 | outboxEvent.aggregateId = `${exportedEvent.id}`; 35 | outboxEvent.aggregateType = 'order'; 36 | outboxEvent.type = exportedEvent.eventType; 37 | outboxEvent.payload = this.exportedEventCodec.toPlain(exportedEvent); 38 | 39 | this.entityManager.persist(outboxEvent); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /apps/order-service/src/main.module.ts: -------------------------------------------------------------------------------- 1 | import { Logger, Module } from '@nestjs/common'; 2 | import { MikroOrmModule } from '@mikro-orm/nestjs'; 3 | import { PostgreSqlDriver } from '@mikro-orm/postgresql'; 4 | import { SqlHighlighter } from '@mikro-orm/sql-highlighter'; 5 | import { ConfigModule } from './config/config.module'; 6 | import { ConfigService } from './config/config.service'; 7 | import { EventEmitterModule } from '@nestjs/event-emitter'; 8 | import { EventsModule } from './event/events.module'; 9 | import { RestModule } from './rest/rest.module'; 10 | import { HealthModule } from '@libs/health'; 11 | 12 | const logger = new Logger('Database'); 13 | 14 | @Module({ 15 | imports: [ 16 | EventEmitterModule.forRoot(), 17 | EventsModule, 18 | HealthModule, 19 | RestModule, 20 | MikroOrmModule.forRootAsync({ 21 | imports: [ConfigModule], 22 | inject: [ConfigService], 23 | useFactory: (config: ConfigService) => ({ 24 | entities: ['./dist/apps/order-service/src/model'], 25 | entitiesTs: ['./src/model'], 26 | forceUtcTimezone: true, 27 | driver: PostgreSqlDriver, 28 | host: config.database.host, 29 | dbName: config.database.name, 30 | schema: config.database.schema, 31 | port: config.database.port, 32 | user: config.database.user, 33 | password: config.database.password, 34 | debug: config.database.debug, 35 | logger: logger.log.bind(logger), 36 | highlighter: new SqlHighlighter(), 37 | }), 38 | }), 39 | ], 40 | }) 41 | export class MainModule {} 42 | -------------------------------------------------------------------------------- /.docker/debezium/application.properties: -------------------------------------------------------------------------------- 1 | # Sink connector config - RabbitMQ 2 | debezium.sink.type=rabbitmq 3 | debezium.sink.rabbitmq.connection.host=${RABBITMQ_HOSTNAME} 4 | debezium.sink.rabbitmq.connection.port=${RABBITMQ_PORT} 5 | debezium.sink.rabbitmq.connection.username=${RABBITMQ_USER} 6 | debezium.sink.rabbitmq.connection.password=${RABBITMQ_PASSWORD} 7 | 8 | # Source connector config - PostgreSQL 9 | debezium.source.connector.class=io.debezium.connector.postgresql.PostgresConnector 10 | debezium.source.plugin.name=pgoutput 11 | debezium.source.offset.storage.file.filename=data/offsets.dat 12 | debezium.source.offset.flush.interval.ms=0 13 | debezium.source.database.hostname=${DATABASE_HOSTNAME} 14 | debezium.source.database.port=${DATABASE_PORT} 15 | debezium.source.database.user=${DATABASE_USER} 16 | debezium.source.database.password=${DATABASE_PASSWORD} 17 | debezium.source.database.dbname=${DATABASE_NAME} 18 | debezium.source.topic.prefix=tutorial 19 | debezium.source.schema.include.list=${DATABASE_SCHEMA} 20 | debezium.source.table.include.list=${DATABASE_SCHEMA}.${DATABASE_OUTBOX_TABLE} 21 | debezium.source.tombstones.on.delete=false 22 | debezium.source.transforms=outbox 23 | debezium.source.transforms.outbox.type=io.debezium.transforms.outbox.EventRouter 24 | debezium.source.transforms.outbox.route.topic.replacement=\\${routedByValue}.events 25 | debezium.source.transforms.outbox.table.field.event.key=aggregate_id 26 | debezium.source.transforms.outbox.table.expand.json.payload=true 27 | debezium.source.transforms.outbox.route.by.field=aggregate_type 28 | debezium.source.transforms.outbox.table.fields.additional.placement=type:header:eventType 29 | 30 | # Format config 31 | debezium.format.key=json 32 | debezium.format.value=json -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "apps/order-service/src", 5 | "compilerOptions": { 6 | "deleteOutDir": true, 7 | "builder": "tsc", 8 | "tsConfigPath": "apps/order-service/tsconfig.app.json" 9 | }, 10 | "monorepo": true, 11 | "root": "apps/order-service", 12 | "projects": { 13 | "order-service": { 14 | "type": "application", 15 | "root": "apps/order-service", 16 | "entryFile": "main", 17 | "sourceRoot": "apps/order-service/src", 18 | "compilerOptions": { 19 | "tsConfigPath": "apps/order-service/tsconfig.app.json" 20 | } 21 | }, 22 | "shipment-service": { 23 | "type": "application", 24 | "root": "apps/shipment-service", 25 | "entryFile": "main", 26 | "sourceRoot": "apps/shipment-service/src", 27 | "compilerOptions": { 28 | "tsConfigPath": "apps/shipment-service/tsconfig.app.json" 29 | } 30 | }, 31 | "health": { 32 | "type": "library", 33 | "root": "libs/health", 34 | "entryFile": "index", 35 | "sourceRoot": "libs/health/src", 36 | "compilerOptions": { 37 | "tsConfigPath": "libs/health/tsconfig.lib.json" 38 | } 39 | }, 40 | "events": { 41 | "type": "library", 42 | "root": "libs/events", 43 | "entryFile": "index", 44 | "sourceRoot": "libs/events/src", 45 | "compilerOptions": { 46 | "tsConfigPath": "libs/events/tsconfig.lib.json" 47 | } 48 | }, 49 | "monads": { 50 | "type": "library", 51 | "root": "libs/monads", 52 | "entryFile": "index", 53 | "sourceRoot": "libs/monads/src", 54 | "compilerOptions": { 55 | "tsConfigPath": "libs/monads/tsconfig.lib.json" 56 | } 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /libs/monads/src/option.ts: -------------------------------------------------------------------------------- 1 | interface Match { 2 | some: (val: A) => B; 3 | none: () => B; 4 | } 5 | 6 | export interface OptionBaseline { 7 | tag: 'some' | 'none'; 8 | 9 | match(matchPattern: Match): U; 10 | 11 | map(mapper: (value: T) => U): Option; 12 | 13 | chain(mapper: (value: T) => Option): Option; 14 | } 15 | 16 | class Some implements OptionBaseline { 17 | tag: 'some'; 18 | 19 | constructor(readonly value: T) {} 20 | 21 | match(matchPattern: Match): U { 22 | return matchPattern.some(this.value); 23 | } 24 | 25 | map(mapper: (value: T) => U): Option { 26 | return some(mapper(this.value)); 27 | } 28 | 29 | chain(mapper: (value: T) => Option): Option { 30 | return mapper(this.value); 31 | } 32 | } 33 | 34 | class None implements OptionBaseline { 35 | tag: 'none'; 36 | 37 | match(matchPattern: Match): U { 38 | return matchPattern.none(); 39 | } 40 | 41 | map(_mapper: (value: T) => U): Option { 42 | return none(); 43 | } 44 | 45 | chain(_mapper: (value: T) => Option): Option { 46 | return none(); 47 | } 48 | } 49 | 50 | export type Option = Some | None; 51 | 52 | export function some(value: T): Option { 53 | return new Some(value); 54 | } 55 | 56 | export function none(): Option { 57 | return new None(); 58 | } 59 | 60 | export function isSome(option: Option): option is Some { 61 | return option instanceof Some; 62 | } 63 | 64 | export function isNone(option: Option): option is None { 65 | return option instanceof None; 66 | } 67 | 68 | export function fromNullish( 69 | value: T | null | undefined, 70 | ): Option> { 71 | return typeof value === 'undefined' || value === null ? none() : some(value); 72 | } 73 | -------------------------------------------------------------------------------- /apps/order-service/src/rest/order-mapper.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { CreateOrderRequestDto } from './create-order-request.dto'; 3 | import { 4 | OrderLineItemResponseDto, 5 | OrderResponseDto, 6 | } from './order-response.dto'; 7 | import { PurchaseOrder } from '../model/purchase-order.entity'; 8 | import { OrderLine, OrderLineStatus } from '../model/order-line.entity'; 9 | 10 | @Injectable() 11 | export class OrderMapperService { 12 | fromCreateRequest(reqDto: CreateOrderRequestDto): PurchaseOrder { 13 | const lineItems = reqDto.lineItems.map((item) => { 14 | const orderLine = new OrderLine(); 15 | 16 | orderLine.item = item.item; 17 | orderLine.quantity = item.quantity; 18 | orderLine.totalPrice = item.totalPrice; 19 | orderLine.status = OrderLineStatus.Entered; 20 | 21 | return orderLine; 22 | }); 23 | 24 | const purchaseOrder = new PurchaseOrder(); 25 | 26 | purchaseOrder.customerId = reqDto.customerId; 27 | purchaseOrder.orderDate = new Date(reqDto.orderDate); 28 | purchaseOrder.lineItems.set(lineItems); 29 | 30 | return purchaseOrder; 31 | } 32 | 33 | toResponse(order: PurchaseOrder): OrderResponseDto { 34 | const lineItems: OrderLineItemResponseDto[] = order.lineItems 35 | .toArray() 36 | .map((l) => { 37 | const lineItemDto: OrderLineItemResponseDto = { 38 | id: l.id, 39 | item: l.item, 40 | quantity: l.quantity, 41 | status: l.status, 42 | totalPrice: l.totalPrice, 43 | }; 44 | 45 | return lineItemDto; 46 | }); 47 | 48 | const { customerId, id, orderDate } = order; 49 | 50 | const responseDto: OrderResponseDto = { 51 | customerId, 52 | id, 53 | orderDate, 54 | lineItems, 55 | }; 56 | 57 | return responseDto; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /apps/order-service/src/service/order.service.ts: -------------------------------------------------------------------------------- 1 | import { EntityManager } from '@mikro-orm/postgresql'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { EventEmitter2 } from '@nestjs/event-emitter'; 4 | import { UUID } from 'node:crypto'; 5 | import { PurchaseOrder } from '../model/purchase-order.entity'; 6 | import { 7 | OrderCreatedEvent, 8 | OrderCreatedSymbol, 9 | } from '../event/order-created.event'; 10 | import { OrderLineStatus } from '../model/order-line.entity'; 11 | import { 12 | OrderLineUpdatedEvent, 13 | OrderLineUpdatedSymbol, 14 | } from '../event/order-line-updated.event'; 15 | import { EntityNotFoundException } from '../errors/entity-not-found'; 16 | 17 | @Injectable() 18 | export class OrderService { 19 | constructor( 20 | private readonly entityManager: EntityManager, 21 | private readonly eventEmitter: EventEmitter2, 22 | ) {} 23 | 24 | async addOrder(order: PurchaseOrder): Promise { 25 | this.entityManager.persist(order); 26 | this.eventEmitter.emit(OrderCreatedSymbol, new OrderCreatedEvent(order)); 27 | 28 | await this.entityManager.flush(); 29 | 30 | return order; 31 | } 32 | 33 | async updateOrderLine({ 34 | orderId, 35 | orderLineId, 36 | orderLineStatus, 37 | }: { 38 | orderId: UUID; 39 | orderLineId: UUID; 40 | orderLineStatus: OrderLineStatus; 41 | }): Promise { 42 | const order = await this.entityManager.findOneOrFail( 43 | PurchaseOrder, 44 | orderId, 45 | { 46 | populate: ['lineItems'], 47 | failHandler: () => 48 | new EntityNotFoundException(`Order with id ${orderId} was not found`), 49 | }, 50 | ); 51 | 52 | const orderLineOldStatus = order.updateLineItemStatus( 53 | orderLineId, 54 | orderLineStatus, 55 | ); 56 | 57 | this.eventEmitter.emit( 58 | OrderLineUpdatedSymbol, 59 | new OrderLineUpdatedEvent({ 60 | newStatus: orderLineStatus, 61 | oldStatus: orderLineOldStatus, 62 | orderId, 63 | orderLineId, 64 | }), 65 | ); 66 | 67 | await this.entityManager.flush(); 68 | 69 | return order; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodejs-outbox", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"apps/**/*.ts\" \"libs/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 15 | "order:build": "nest build order-service", 16 | "order:start": "nest start order-service", 17 | "order:start:dev": "nest start order-service --watch", 18 | "order:start:debug": "nest start order-service --debug --watch", 19 | "order:start:prod": "node dist/apps/order-service/main", 20 | "order:test": "jest --config ./apps/order-service/test/jest.json", 21 | "order:test:watch": "jest --config ./apps/order-service/test/jest.json --watch", 22 | "order:test:cov": "jest --config ./apps/order-service/test/jest.json --coverage", 23 | "order:test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 24 | "order:test:e2e": "jest --config ./apps/order-service/test/jest-e2e.json", 25 | "shipment:build": "nest build shipment-service", 26 | "shipment:start": "nest start shipment-service", 27 | "shipment:start:dev": "nest start shipment-service --watch", 28 | "shipment:start:debug": "nest start shipment-service --debug --watch", 29 | "shipment:start:prod": "node dist/apps/shipment-service/main", 30 | "shipment:test": "jest --config ./apps/shipment-service/test/jest.json", 31 | "shipment:test:watch": "jest --config ./apps/shipment-service/test/jest.json --watch", 32 | "shipment:test:cov": "jest --config ./apps/shipment-service/test/jest.json --coverage", 33 | "shipment:test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 34 | "shipment:test:e2e": "jest --config ./apps/shipment-service/test/jest-e2e.json" 35 | }, 36 | "dependencies": { 37 | "@mikro-orm/core": "^6.1.12", 38 | "@mikro-orm/nestjs": "^5.2.3", 39 | "@mikro-orm/postgresql": "^6.1.12", 40 | "@mikro-orm/sql-highlighter": "^1.0.1", 41 | "@nestjs/common": "^10.0.0", 42 | "@nestjs/config": "^3.2.1", 43 | "@nestjs/core": "^10.0.0", 44 | "@nestjs/event-emitter": "^2.0.4", 45 | "@nestjs/platform-express": "^10.0.0", 46 | "amqp-connection-manager": "^4.1.14", 47 | "amqplib": "^0.10.4", 48 | "class-transformer": "^0.5.1", 49 | "class-validator": "^0.14.1", 50 | "reflect-metadata": "^0.2.0", 51 | "rxjs": "^7.8.1" 52 | }, 53 | "devDependencies": { 54 | "@nestjs/cli": "^10.0.0", 55 | "@nestjs/schematics": "^10.0.0", 56 | "@nestjs/testing": "^10.0.0", 57 | "@types/amqplib": "^0.10.5", 58 | "@types/express": "^4.17.17", 59 | "@types/jest": "^29.5.2", 60 | "@types/node": "^20.3.1", 61 | "@types/supertest": "^6.0.0", 62 | "@typescript-eslint/eslint-plugin": "^6.0.0", 63 | "@typescript-eslint/parser": "^6.0.0", 64 | "eslint": "^8.42.0", 65 | "eslint-config-prettier": "^9.0.0", 66 | "eslint-plugin-prettier": "^5.0.0", 67 | "jest": "^29.5.0", 68 | "prettier": "^3.0.0", 69 | "source-map-support": "^0.5.21", 70 | "supertest": "^6.3.3", 71 | "ts-jest": "^29.1.0", 72 | "ts-loader": "^9.4.3", 73 | "ts-node": "^10.9.1", 74 | "tsconfig-paths": "^4.2.0", 75 | "typescript": "^5.1.3" 76 | } 77 | } -------------------------------------------------------------------------------- /libs/monads/src/result.ts: -------------------------------------------------------------------------------- 1 | import { Lazy, Refinement } from './types'; 2 | 3 | interface Match { 4 | success: (value: T) => U; 5 | failure: (value: E) => U; 6 | } 7 | 8 | interface ResultBaseline { 9 | readonly tag: 'success' | 'failure'; 10 | 11 | match(matchPattern: Match): U; 12 | 13 | map(mapper: (value: T) => U): Result; 14 | 15 | mapFailure(mapper: (failure: E) => F): Result; 16 | 17 | chain(mapper: (value: T) => Result): Result; 18 | 19 | orElse(mapper: (failure: E) => Result): Result; 20 | 21 | filterOrElse( 22 | refinement: Refinement, 23 | onFalse: () => E, 24 | ): Result; 25 | } 26 | 27 | class Success implements ResultBaseline { 28 | readonly tag = 'success'; 29 | 30 | constructor(readonly value: T) {} 31 | 32 | match(matchPattern: Match): U { 33 | return matchPattern.success(this.value); 34 | } 35 | 36 | map(mapper: (value: T) => U): Result { 37 | return success(mapper(this.value)); 38 | } 39 | 40 | mapFailure(_mapper: (failure: E) => F): Result { 41 | return success(this.value); 42 | } 43 | 44 | chain(mapper: (value: T) => Result): Result { 45 | return mapper(this.value); 46 | } 47 | 48 | orElse(_mapper: (failure: E) => Result): Result { 49 | return success(this.value); 50 | } 51 | 52 | filterOrElse( 53 | refinement: Refinement, 54 | onFalse: () => E, 55 | ): Result { 56 | const matchShape = refinement(this.value); 57 | if (matchShape) { 58 | return success(this.value); 59 | } 60 | return failure(onFalse()); 61 | } 62 | } 63 | 64 | class Failure implements ResultBaseline { 65 | readonly tag = 'failure'; 66 | 67 | constructor(readonly failure: E) {} 68 | 69 | match(matchPattern: Match): U { 70 | return matchPattern.failure(this.failure); 71 | } 72 | 73 | map(_mapper: (value: T) => U): Result { 74 | return failure(this.failure); 75 | } 76 | 77 | mapFailure(mapper: (failure: E) => F): Result { 78 | return failure(mapper(this.failure)); 79 | } 80 | 81 | chain(_mapper: (value: T) => Result): Result { 82 | return failure(this.failure); 83 | } 84 | 85 | orElse(mapper: (failure: E) => Result): Result { 86 | return mapper(this.failure); 87 | } 88 | 89 | filterOrElse( 90 | _refinement: Refinement, 91 | _onFalse: (a: E) => E, 92 | ): Result { 93 | return failure(this.failure); 94 | } 95 | } 96 | 97 | export type Result = Success | Failure; 98 | 99 | export function success(value: T): Result { 100 | return new Success(value); 101 | } 102 | 103 | export function failure(value: E): Result { 104 | return new Failure(value); 105 | } 106 | 107 | export function isSuccess(result: Result): result is Success { 108 | return result instanceof Success; 109 | } 110 | 111 | export function isFailure(result: Result): result is Failure { 112 | return result instanceof Failure; 113 | } 114 | 115 | export function tryCatch( 116 | fn: Lazy, 117 | onError: (err: unknown) => E, 118 | ): Result { 119 | try { 120 | const value = fn(); 121 | return success(value); 122 | } catch (error) { 123 | const mappedError = onError(error); 124 | return failure(mappedError); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | name: nodejs-outbox 3 | 4 | services: 5 | rabbitmq: 6 | build: 7 | context: ./.docker/rabbitmq 8 | dockerfile: Dockerfile 9 | container_name: rabbitmq 10 | ports: 11 | - 5672:5672 12 | - 15672:15672 13 | - 5552:5552 14 | healthcheck: 15 | test: "rabbitmq-diagnostics check_port_connectivity" 16 | interval: 2s 17 | timeout: 20s 18 | retries: 10 19 | environment: 20 | RABBITMQ_DEFAULT_PASS: root 21 | RABBITMQ_DEFAULT_USER: root 22 | 23 | order-db: 24 | build: 25 | context: ./.docker/order-db 26 | dockerfile: Dockerfile 27 | container_name: order-db 28 | ports: 29 | - 5432:5432 30 | healthcheck: 31 | test: "pg_isready -U postgres -d orderdb" 32 | interval: 2s 33 | timeout: 20s 34 | retries: 10 35 | environment: 36 | - POSTGRES_USER=postgres 37 | - POSTGRES_PASSWORD=postgres 38 | - POSTGRES_DB=orderdb 39 | 40 | shipment-db: 41 | build: 42 | context: ./.docker/shipment-db 43 | dockerfile: Dockerfile 44 | container_name: shipment-db 45 | ports: 46 | - 5433:5432 47 | healthcheck: 48 | test: "pg_isready -U postgres -d shipmentdb" 49 | interval: 2s 50 | timeout: 20s 51 | retries: 10 52 | environment: 53 | - POSTGRES_USER=postgres 54 | - POSTGRES_PASSWORD=postgres 55 | - POSTGRES_DB=shipmentdb 56 | 57 | debezium-server: 58 | build: 59 | context: ./.docker/debezium 60 | dockerfile: Dockerfile 61 | container_name: debezium-server 62 | ports: 63 | - 8080:8080 64 | depends_on: 65 | rabbitmq: 66 | condition: service_healthy 67 | order-db: 68 | condition: service_started 69 | environment: 70 | - DATABASE_HOSTNAME=order-db 71 | - DATABASE_PORT=5432 72 | - DATABASE_USER=postgres 73 | - DATABASE_PASSWORD=postgres 74 | - DATABASE_NAME=orderdb 75 | - DATABASE_SCHEMA=orders 76 | - DATABASE_OUTBOX_TABLE=outbox_event 77 | - RABBITMQ_HOSTNAME=rabbitmq 78 | - RABBITMQ_PORT=5672 79 | - RABBITMQ_USER=root 80 | - RABBITMQ_PASSWORD=root 81 | 82 | order-service: 83 | build: 84 | context: . 85 | dockerfile: ./.docker/order-service/Dockerfile 86 | container_name: order-service 87 | ports: 88 | - 3000:3000 89 | depends_on: 90 | debezium-server: 91 | condition: service_started 92 | order-db: 93 | condition: service_healthy 94 | healthcheck: 95 | test: "wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1" 96 | interval: 2s 97 | timeout: 20s 98 | retries: 10 99 | environment: 100 | - SERVER_PORT=3000 101 | - DB_NAME=orderdb 102 | - DB_SCHEMA=orders 103 | - DB_PORT=5432 104 | - DB_HOST=order-db 105 | - DB_USER=postgres 106 | - DB_PASSWORD=postgres 107 | - DB_DEBUG=true 108 | 109 | shipment-service: 110 | build: 111 | context: . 112 | dockerfile: ./.docker/shipment-service/Dockerfile 113 | container_name: shipment-service 114 | ports: 115 | - 3001:3000 116 | depends_on: 117 | rabbitmq: 118 | condition: service_healthy 119 | shipment-db: 120 | condition: service_healthy 121 | healthcheck: 122 | test: "wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1" 123 | interval: 2s 124 | timeout: 20s 125 | retries: 10 126 | environment: 127 | - SERVER_PORT=3001 128 | - DB_NAME=shipmentdb 129 | - DB_SCHEMA=shipments 130 | - DB_PORT=5432 131 | - DB_HOST=shipment-db 132 | - DB_USER=postgres 133 | - DB_PASSWORD=postgres 134 | - DB_DEBUG=true 135 | - RABBITMQ_HOST=rabbitmq 136 | - RABBITMQ_PORT=5672 137 | - RABBITMQ_USER=root 138 | - RABBITMQ_PASSWORD=root 139 | - RABBITMQ_ORDER_QUEUE=shipment.orders 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Transactional outbox pattern 2 | 3 | Implementation of _transactional outbox pattern_, a method that allows to atomically perform a local business transaction and send domain events to a message broker. The example leverages 4 | - [NodeJS](https://nodejs.org/en) as services runtime. 5 | - [Debezium](https://debezium.io) as CDC platform. 6 | - [RabbitMQ](https://www.rabbitmq.com) as events broker. 7 | - [PostgreSQL](https://www.postgresql.org/) as services data store. 8 | 9 | The implementation consists of an _order service_ acting as the events producer. It stores events in an outbox table and performs regular business operations as part of the same database transaction. Debezium monitors the outbox table while streaming new entries to RabbitMQ. The _shipment service_ acts as a message consumer reading events from the broker. 10 | 11 | High level architecture mirrors the schema below. 12 | 13 | ![Architecture Overview](resources/images/architecture-overview.png) 14 | 15 | ## Run locally 16 | The only requirement to run the example locally is [Docker](https://www.docker.com/). Once you have Docker installed on your local machine, all services can be started by running 17 | 18 | ```console 19 | $ docker compose up --build 20 | ``` 21 | 22 | The repository also includes a [Taskfile](Taskfile.yml) that aliases some commands. In case you'd like to bootstrap all services by using [Task](https://taskfile.dev/) tool you can run 23 | 24 | ```console 25 | $ task bootstrap 26 | ``` 27 | 28 | ## Call REST APIs 29 | 30 | [Data folder](resources/data) includes some static data that can be used as body of http requests. To create a new order you can perform the following http request against _order service_ APIs 31 | 32 | ```console 33 | // Using cURL. 34 | $ curl -X POST -H "Content-Type: application/json" --data @./resources/data/create-order-request.json http://localhost:3000/orders 35 | 36 | // Using Task runner. 37 | $ task create-order 38 | ``` 39 | 40 | To cancel an order line that was previously created you can run 41 | 42 | ```console 43 | // Using cURL. 44 | // Be sure to replace `:orderID` and `:orderLineId` with the actual values. 45 | $ curl -X PUT -H "Content-Type: application/json" --data @./resources/data/cancel-order-line-request.json http://localhost:3000/orders/:orderID/lines/:orderLineId 46 | 47 | // Using Task runner. 48 | // Be sure to provide the actual values to `ORDER_ID` and `ORDER_LINE_ID` env vars. 49 | $ task cancel-order-line ORDER_ID="replace-me" ORDER_LINE_ID="replace-me" 50 | ``` 51 | 52 | ## Insights 53 | Following services logs you should be able to get some insights on what's happening under the hood while interacting with public APIs. Since both `order_service` and `shipment_service` run in debug mode, all SQL queries performed against the data store will be part of stdout stream. 54 | 55 | ```console 56 | // order-service logs. 57 | $ docker compose logs --follow order-service 58 | 59 | // order-service logs with Task runner. 60 | $ task order-service-logs 61 | 62 | // shipment-service logs. 63 | $ docker compose logs --follow shipment-service 64 | 65 | // shipment-service logs with Task runner. 66 | $ task shipment-service-logs 67 | ``` 68 | 69 | To start an interactive session against order database and run queries you can do the following: 70 | 71 | ```console 72 | // Interactive session using docker compose. 73 | $ docker compose run --rm order-db psql -d postgres://postgres:postgres@order-db/orderdb 74 | 75 | // Interactive session using Task runner. 76 | $ task order-db-cli 77 | 78 | // Run queries. 79 | $ select * from orders.purchase_order; 80 | ``` 81 | 82 | What's depicted right above closely applies to shipment database: 83 | 84 | ```console 85 | // Interactive session using docker compose. 86 | $ docker compose run --rm shipment-db psql -d postgres://postgres:postgres@shipment-db/shipmentdb 87 | 88 | // Interactive session using Task runner. 89 | $ task shipment-db-cli 90 | 91 | // Run queries. 92 | $ select * from shipments.shipment; 93 | ``` 94 | 95 | ## Credits 96 | - The implementation is heavily inspired by the official Debezium outbox [example](https://github.com/debezium/debezium-examples/tree/main/outbox). 97 | - Related [post](https://debezium.io/blog/2019/02/19/reliable-microservices-data-exchange-with-the-outbox-pattern/) on Debezium blog. -------------------------------------------------------------------------------- /apps/shipment-service/src/event/rabbitmq-consumer.service.ts: -------------------------------------------------------------------------------- 1 | import { EventType } from '@libs/events'; 2 | import { 3 | Inject, 4 | Injectable, 5 | Logger, 6 | OnModuleDestroy, 7 | OnModuleInit, 8 | } from '@nestjs/common'; 9 | import { ChannelWrapper, connect } from 'amqp-connection-manager'; 10 | import { 11 | ConfirmChannel, 12 | ConsumeMessage, 13 | MessagePropertyHeaders, 14 | credentials, 15 | } from 'amqplib'; 16 | import { CreateRequestContext } from '@mikro-orm/core'; 17 | import { EntityManager } from '@mikro-orm/postgresql'; 18 | import { MessageLogService } from '../service/message-log.service'; 19 | import { ConfigService } from '../config/config.service'; 20 | import { UUID } from 'node:crypto'; 21 | import { Result, isFailure, tryCatch } from '@libs/monads'; 22 | import { isNone } from '@libs/monads'; 23 | import { 24 | ExportedEventHandlerResolver, 25 | ExportedEventHandlerResolverToken, 26 | } from './providers'; 27 | import { TypeGuardService } from '../service/type-guard.service'; 28 | 29 | @Injectable() 30 | export class RabbitMqConsumerService implements OnModuleInit, OnModuleDestroy { 31 | private readonly channel: ChannelWrapper; 32 | private readonly logger = new Logger('RabbitMqConsumerService'); 33 | 34 | constructor( 35 | private readonly messageLog: MessageLogService, 36 | private readonly em: EntityManager, 37 | private readonly config: ConfigService, 38 | private typeGuard: TypeGuardService, 39 | @Inject(ExportedEventHandlerResolverToken) 40 | private readonly exportedEventHandlerResolver: ExportedEventHandlerResolver, 41 | ) { 42 | const connectionManager = connect(`amqp://${this.config.rabbit.host}`, { 43 | connectionOptions: { 44 | port: this.config.rabbit.port, 45 | credentials: credentials.plain( 46 | this.config.rabbit.user, 47 | this.config.rabbit.password, 48 | ), 49 | }, 50 | }); 51 | this.channel = connectionManager.createChannel(); 52 | } 53 | 54 | async onModuleInit() { 55 | try { 56 | await this.channel.addSetup(async (channel: ConfirmChannel) => { 57 | await channel.assertQueue(this.config.rabbit.orderQueue, { 58 | durable: true, 59 | }); 60 | 61 | await channel.consume( 62 | this.config.rabbit.orderQueue, 63 | this.handleMessage.bind(this), 64 | ); 65 | 66 | this.logger.log('Consumer setup completed.'); 67 | }); 68 | this.logger.log('Consumer service started and listening for messages.'); 69 | } catch (err) { 70 | this.logger.error('Error starting the consumer:', err); 71 | } 72 | } 73 | 74 | async onModuleDestroy() { 75 | return this.channel.close(); 76 | } 77 | 78 | @CreateRequestContext() 79 | async handleMessage(message: ConsumeMessage) { 80 | const messageHeaders = message.properties.headers; 81 | const eventMetaResult = this.getEventMeta(messageHeaders); 82 | 83 | if (isFailure(eventMetaResult)) { 84 | this.logger.error(eventMetaResult.failure, { messageHeaders }); 85 | return; 86 | } 87 | 88 | const { eventId, eventType } = eventMetaResult.value; 89 | 90 | const wasEventAlreadyProcessed = 91 | await this.messageLog.wasAlreadyProcessed(eventId); 92 | 93 | if (wasEventAlreadyProcessed) { 94 | this.logger.warn( 95 | `Event with id "${eventId}" was already processed, skipping it`, 96 | ); 97 | this.channel.ack(message); 98 | return; 99 | } 100 | 101 | const messageContent = message.content.toString(); 102 | const payloadResult = this.getPayload( 103 | messageContent, 104 | this.typeGuard.isRecord, 105 | ); 106 | 107 | if (isFailure(payloadResult)) { 108 | this.logger.error(payloadResult.failure, { messageContent }); 109 | return; 110 | } 111 | 112 | const handler = this.exportedEventHandlerResolver.resolve(eventType); 113 | if (isNone(handler)) { 114 | this.logger.error(new Error(`Missing handler for event ${eventType}`)); 115 | return; 116 | } 117 | 118 | const result = handler.value.handlePlain(payloadResult.value); 119 | if (isFailure(result)) { 120 | this.logger.error(result.failure); 121 | return; 122 | } 123 | 124 | this.messageLog.markAsProcessed(eventId); 125 | await this.em.flush(); 126 | 127 | this.channel.ack(message); 128 | } 129 | 130 | private getEventMeta( 131 | headers: MessagePropertyHeaders | undefined, 132 | ): Result<{ eventType: EventType; eventId: UUID }, Error> { 133 | const eventTypeResult = this.getPayload( 134 | headers?.eventType, 135 | this.typeGuard.isEnum(EventType), 136 | ); 137 | 138 | const eventIdResult = this.getPayload(headers?.id, this.typeGuard.isUUID); 139 | 140 | const eventMetaResult = eventTypeResult.chain((eventType) => 141 | eventIdResult.map((eventId) => ({ 142 | eventId, 143 | eventType, 144 | })), 145 | ); 146 | 147 | return eventMetaResult; 148 | } 149 | 150 | private getPayload( 151 | envelop: string, 152 | guard: (value: unknown) => value is T, 153 | key = 'payload', 154 | ): Result { 155 | return tryCatch( 156 | () => JSON.parse(envelop), 157 | () => new Error(`Failed to parse envelop to plain object`), 158 | ) 159 | .map((plainEnvelop) => plainEnvelop[key]) 160 | .filterOrElse(guard, () => new Error('Payload does not match guard')); 161 | } 162 | } 163 | --------------------------------------------------------------------------------