WIP - first shot of massive ddd-hexagon refactor

This commit is contained in:
sbriat
2023-06-20 16:50:55 +02:00
parent cd84567107
commit e1989c0a52
124 changed files with 3651 additions and 2460 deletions

View File

@@ -0,0 +1,19 @@
export class ApiErrorResponse {
readonly statusCode: number;
readonly message: string;
readonly error: string;
readonly correlationId: string;
readonly subErrors?: string[];
constructor(body: ApiErrorResponse) {
this.statusCode = body.statusCode;
this.message = body.message;
this.error = body.error;
this.correlationId = body.correlationId;
this.subErrors = body.subErrors;
}
}

View File

@@ -0,0 +1,7 @@
export class IdResponse {
constructor(id: string) {
this.id = id;
}
readonly id: string;
}

View File

@@ -0,0 +1,8 @@
import { Paginated } from '../ddd';
export abstract class PaginatedResponseDto<T> extends Paginated<T> {
readonly count: number;
readonly limit: number;
readonly page: number;
abstract readonly data: readonly T[];
}

View File

@@ -0,0 +1,23 @@
import { IdResponse } from './id.response.dto';
export interface BaseResponseProps {
id: string;
createdAt: Date;
updatedAt: Date;
}
/**
* Most of our response objects will have properties like
* id, createdAt and updatedAt so we can move them to a
* separate class and extend it to avoid duplication.
*/
export class ResponseBase extends IdResponse {
constructor(props: BaseResponseProps) {
super(props.id);
this.createdAt = new Date(props.createdAt).toISOString();
this.updatedAt = new Date(props.updatedAt).toISOString();
}
readonly createdAt: string;
readonly updatedAt: string;
}

View File

@@ -0,0 +1,46 @@
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AggregateRoot, Mapper, RepositoryPort } from '../ddd';
import { ObjectLiteral } from '../types';
import { LoggerPort } from '../ports/logger.port';
import { None, Option, Some } from 'oxide.ts';
import { PrismaRepositoryPort } from '../ports/prisma-repository.port';
export abstract class PrismaRepositoryBase<
Aggregate extends AggregateRoot<any>,
DbModel extends ObjectLiteral,
> implements RepositoryPort<Aggregate>
{
protected constructor(
protected readonly prisma: PrismaRepositoryPort<Aggregate> | any,
protected readonly mapper: Mapper<Aggregate, DbModel>,
protected readonly eventEmitter: EventEmitter2,
protected readonly logger: LoggerPort,
) {}
async findOneById(uuid: string): Promise<Option<Aggregate>> {
try {
const entity = await this.prisma.findUnique({
where: { uuid },
});
return entity ? Some(this.mapper.toDomain(entity)) : None;
} catch (e) {
console.log('ouch on findOneById');
}
}
async insert(entity: Aggregate): Promise<void> {
try {
await this.prisma.create({
data: this.mapper.toPersistence(entity),
});
} catch (e) {
console.log(e);
console.log('ouch on insert');
}
}
async healthCheck(): Promise<boolean> {
return true;
}
}

View File

@@ -0,0 +1,15 @@
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
async enableShutdownHooks(app: INestApplication) {
this.$on('beforeExit', async () => {
await app.close();
});
}
}

View File

@@ -0,0 +1,35 @@
import { Entity } from './entity.base';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { LoggerPort } from '@libs/ports/logger.port';
import { DomainEvent } from './domain-event.base';
export abstract class AggregateRoot<EntityProps> extends Entity<EntityProps> {
private _domainEvents: DomainEvent[] = [];
get domainEvents(): DomainEvent[] {
return this._domainEvents;
}
protected addEvent(domainEvent: DomainEvent): void {
this._domainEvents.push(domainEvent);
}
public clearEvents(): void {
this._domainEvents = [];
}
public async publishEvents(
logger: LoggerPort,
eventEmitter: EventEmitter2,
): Promise<void> {
await Promise.all(
this.domainEvents.map(async (event) => {
logger.debug(
`"${event.constructor.name}" event published for aggregate ${this.constructor.name} : ${this.id}`,
);
return eventEmitter.emitAsync(event.constructor.name, event);
}),
);
this.clearEvents();
}
}

View File

@@ -0,0 +1,52 @@
import { v4 } from 'uuid';
import { ArgumentNotProvidedException } from '../exceptions';
import { Guard } from '../guard';
export type CommandProps<T> = Omit<T, 'id' | 'metadata'> & Partial<Command>;
type CommandMetadata = {
/** ID for correlation purposes (for commands that
* arrive from other microservices,logs correlation, etc). */
readonly correlationId: string;
/**
* Causation id to reconstruct execution order if needed
*/
readonly causationId?: string;
/**
* ID of a user who invoker the command. Can be useful for
* logging and tracking execution of commands and events
*/
readonly userId?: string;
/**
* Time when the command occurred. Mostly for tracing purposes
*/
readonly timestamp: number;
};
export class Command {
/**
* Command id, in case if we want to save it
* for auditing purposes and create a correlation/causation chain
*/
readonly id: string;
readonly metadata: CommandMetadata;
constructor(props: CommandProps<unknown>) {
if (Guard.isEmpty(props)) {
throw new ArgumentNotProvidedException(
'Command props should not be empty',
);
}
this.id = props.id || v4();
this.metadata = {
correlationId: props?.metadata?.correlationId,
causationId: props?.metadata?.causationId,
timestamp: props?.metadata?.timestamp || Date.now(),
userId: props?.metadata?.userId,
};
}
}

View File

@@ -0,0 +1,52 @@
import { ArgumentNotProvidedException } from '../exceptions';
import { Guard } from '../guard';
import { v4 } from 'uuid';
type DomainEventMetadata = {
/** Timestamp when this domain event occurred */
readonly timestamp: number;
/** ID for correlation purposes (for Integration Events,logs correlation, etc).
*/
readonly correlationId: string;
/**
* Causation id used to reconstruct execution order if needed
*/
readonly causationId?: string;
/**
* User ID for debugging and logging purposes
*/
readonly userId?: string;
};
export type DomainEventProps<T> = Omit<T, 'id' | 'metadata'> & {
aggregateId: string;
metadata?: DomainEventMetadata;
};
export abstract class DomainEvent {
public readonly id: string;
/** Aggregate ID where domain event occurred */
public readonly aggregateId: string;
public readonly metadata: DomainEventMetadata;
constructor(props: DomainEventProps<unknown>) {
if (Guard.isEmpty(props)) {
throw new ArgumentNotProvidedException(
'DomainEvent props should not be empty',
);
}
this.id = v4();
this.aggregateId = props.aggregateId;
this.metadata = {
correlationId: props?.metadata?.correlationId,
causationId: props?.metadata?.causationId,
timestamp: props?.metadata?.timestamp || Date.now(),
userId: props?.metadata?.userId,
};
}
}

150
src/libs/ddd/entity.base.ts Normal file
View File

@@ -0,0 +1,150 @@
import {
ArgumentNotProvidedException,
ArgumentInvalidException,
ArgumentOutOfRangeException,
} from '../exceptions';
import { Guard } from '../guard';
import { convertPropsToObject } from '../utils';
export type AggregateID = string;
export interface BaseEntityProps {
id: AggregateID;
createdAt: Date;
updatedAt: Date;
}
export interface CreateEntityProps<T> {
id: AggregateID;
props: T;
createdAt?: Date;
updatedAt?: Date;
}
export abstract class Entity<EntityProps> {
constructor({
id,
createdAt,
updatedAt,
props,
}: CreateEntityProps<EntityProps>) {
this.setId(id);
this.validateProps(props);
const now = new Date();
this._createdAt = createdAt || now;
this._updatedAt = updatedAt || now;
this.props = props;
this.validate();
}
protected readonly props: EntityProps;
/**
* ID is set in the concrete entity implementation to support
* different ID types depending on your needs.
* For example it could be a UUID for aggregate root,
* and shortid / nanoid for child entities.
*/
protected abstract _id: AggregateID;
private readonly _createdAt: Date;
private _updatedAt: Date;
get id(): AggregateID {
return this._id;
}
private setId(id: AggregateID): void {
this._id = id;
}
get createdAt(): Date {
return this._createdAt;
}
get updatedAt(): Date {
return this._updatedAt;
}
static isEntity(entity: unknown): entity is Entity<unknown> {
return entity instanceof Entity;
}
/**
* Checks if two entities are the same Entity by comparing ID field.
* @param object Entity
*/
public equals(object?: Entity<EntityProps>): boolean {
if (object === null || object === undefined) {
return false;
}
if (this === object) {
return true;
}
if (!Entity.isEntity(object)) {
return false;
}
return this.id ? this.id === object.id : false;
}
/**
* Returns entity properties.
* @return {*} {Props & EntityProps}
* @memberof Entity
*/
public getProps(): EntityProps & BaseEntityProps {
const propsCopy = {
id: this._id,
createdAt: this._createdAt,
updatedAt: this._updatedAt,
...this.props,
};
return Object.freeze(propsCopy);
}
/**
* Convert an Entity and all sub-entities/Value Objects it
* contains to a plain object with primitive types. Can be
* useful when logging an entity during testing/debugging
*/
public toObject(): unknown {
const plainProps = convertPropsToObject(this.props);
const result = {
id: this._id,
createdAt: this._createdAt,
updatedAt: this._updatedAt,
...plainProps,
};
return Object.freeze(result);
}
/**
* There are certain rules that always have to be true (invariants)
* for each entity. Validate method is called every time before
* saving an entity to the database to make sure those rules are respected.
*/
public abstract validate(): void;
private validateProps(props: EntityProps): void {
const MAX_PROPS = 50;
if (Guard.isEmpty(props)) {
throw new ArgumentNotProvidedException(
'Entity props should not be empty',
);
}
if (typeof props !== 'object') {
throw new ArgumentInvalidException('Entity props should be an object');
}
if (Object.keys(props as any).length > MAX_PROPS) {
throw new ArgumentOutOfRangeException(
`Entity props should not have more than ${MAX_PROPS} properties`,
);
}
}
}

7
src/libs/ddd/index.ts Normal file
View File

@@ -0,0 +1,7 @@
export * from './aggregate-root.base';
export * from './command.base';
export * from './domain-event.base';
export * from './entity.base';
export * from './mapper.interface';
export * from './repository.port';
export * from './value-object.base';

View File

@@ -0,0 +1,11 @@
import { Entity } from './entity.base';
export interface Mapper<
DomainEntity extends Entity<any>,
DbRecord,
Response = any,
> {
toPersistence(entity: DomainEntity): DbRecord;
toDomain(record: any): DomainEntity;
toResponse(entity: DomainEntity): Response;
}

View File

@@ -0,0 +1,42 @@
import { Option } from 'oxide.ts';
/* Most of repositories will probably need generic
save/find/delete operations, so it's easier
to have some shared interfaces.
More specific queries should be defined
in a respective repository.
*/
export class Paginated<T> {
readonly count: number;
readonly limit: number;
readonly page: number;
readonly data: readonly T[];
constructor(props: Paginated<T>) {
this.count = props.count;
this.limit = props.limit;
this.page = props.page;
this.data = props.data;
}
}
export type OrderBy = { field: string | true; param: 'asc' | 'desc' };
export type PaginatedQueryParams = {
limit: number;
page: number;
offset: number;
orderBy: OrderBy;
};
export interface RepositoryPort<Entity> {
insert(entity: Entity | Entity[]): Promise<void>;
findOneById(id: string): Promise<Option<Entity>>;
healthCheck(): Promise<boolean>;
// findAll(): Promise<Entity[]>;
// findAllPaginated(params: PaginatedQueryParams): Promise<Paginated<Entity>>;
// delete(entity: Entity): Promise<boolean>;
// transaction<T>(handler: () => Promise<T>): Promise<T>;
}

View File

@@ -0,0 +1,71 @@
import { ArgumentNotProvidedException } from '../exceptions';
import { Guard } from '../guard';
import { convertPropsToObject } from '../utils';
/**
* Domain Primitive is an object that contains only a single value
*/
export type Primitives = string | number | boolean;
export interface DomainPrimitive<T extends Primitives | Date> {
value: T;
}
type ValueObjectProps<T> = T extends Primitives | Date ? DomainPrimitive<T> : T;
export abstract class ValueObject<T> {
protected readonly props: ValueObjectProps<T>;
constructor(props: ValueObjectProps<T>) {
this.checkIfEmpty(props);
this.validate(props);
this.props = props;
}
protected abstract validate(props: ValueObjectProps<T>): void;
static isValueObject(obj: unknown): obj is ValueObject<unknown> {
return obj instanceof ValueObject;
}
/**
* Check if two Value Objects are equal. Checks structural equality.
* @param vo ValueObject
*/
public equals(vo?: ValueObject<T>): boolean {
if (vo === null || vo === undefined) {
return false;
}
return JSON.stringify(this) === JSON.stringify(vo);
}
/**
* Unpack a value object to get its raw properties
*/
public unpack(): T {
if (this.isDomainPrimitive(this.props)) {
return this.props.value;
}
const propsCopy = convertPropsToObject(this.props);
return Object.freeze(propsCopy);
}
private checkIfEmpty(props: ValueObjectProps<T>): void {
if (
Guard.isEmpty(props) ||
(this.isDomainPrimitive(props) && Guard.isEmpty(props.value))
) {
throw new ArgumentNotProvidedException('Property cannot be empty');
}
}
private isDomainPrimitive(
obj: unknown,
): obj is DomainPrimitive<T & (Primitives | Date)> {
if (Object.prototype.hasOwnProperty.call(obj, 'value')) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,63 @@
export interface SerializedException {
message: string;
code: string;
correlationId: string;
stack?: string;
cause?: string;
metadata?: unknown;
/**
* ^ Consider adding optional `metadata` object to
* exceptions (if language doesn't support anything
* similar by default) and pass some useful technical
* information about the exception when throwing.
* This will make debugging easier.
*/
}
/**
* Base class for custom exceptions.
*
* @abstract
* @class ExceptionBase
* @extends {Error}
*/
export abstract class ExceptionBase extends Error {
abstract code: string;
public readonly correlationId: string;
/**
* @param {string} message
* @param {ObjectLiteral} [metadata={}]
* **BE CAREFUL** not to include sensitive info in 'metadata'
* to prevent leaks since all exception's data will end up
* in application's log files. Only include non-sensitive
* info that may help with debugging.
*/
constructor(
readonly message: string,
readonly cause?: Error,
readonly metadata?: unknown,
) {
super(message);
Error.captureStackTrace(this, this.constructor);
}
/**
* By default in NodeJS Error objects are not
* serialized properly when sending plain objects
* to external processes. This method is a workaround.
* Keep in mind not to return a stack trace to user when in production.
* https://iaincollins.medium.com/error-handling-in-javascript-a6172ccdf9af
*/
toJSON(): SerializedException {
return {
message: this.message,
code: this.code,
stack: this.stack,
correlationId: this.correlationId,
cause: JSON.stringify(this.cause),
metadata: this.metadata,
};
}
}

View File

@@ -0,0 +1,15 @@
/**
* Adding a `code` string with a custom status code for every
* exception is a good practice, since when that exception
* is transferred to another process `instanceof` check
* cannot be performed anymore so a `code` string is used instead.
* code constants can be stored in a separate file so they
* can be shared and reused on a receiving side (code sharing is
* useful when developing fullstack apps or microservices)
*/
export const ARGUMENT_INVALID = 'GENERIC.ARGUMENT_INVALID';
export const ARGUMENT_OUT_OF_RANGE = 'GENERIC.ARGUMENT_OUT_OF_RANGE';
export const ARGUMENT_NOT_PROVIDED = 'GENERIC.ARGUMENT_NOT_PROVIDED';
export const NOT_FOUND = 'GENERIC.NOT_FOUND';
export const CONFLICT = 'GENERIC.CONFLICT';
export const INTERNAL_SERVER_ERROR = 'GENERIC.INTERNAL_SERVER_ERROR';

View File

@@ -0,0 +1,82 @@
import {
ARGUMENT_INVALID,
ARGUMENT_NOT_PROVIDED,
ARGUMENT_OUT_OF_RANGE,
CONFLICT,
INTERNAL_SERVER_ERROR,
NOT_FOUND,
} from '.';
import { ExceptionBase } from './exception.base';
/**
* Used to indicate that an incorrect argument was provided to a method/function/class constructor
*
* @class ArgumentInvalidException
* @extends {ExceptionBase}
*/
export class ArgumentInvalidException extends ExceptionBase {
readonly code = ARGUMENT_INVALID;
}
/**
* Used to indicate that an argument was not provided (is empty object/array, null of undefined).
*
* @class ArgumentNotProvidedException
* @extends {ExceptionBase}
*/
export class ArgumentNotProvidedException extends ExceptionBase {
readonly code = ARGUMENT_NOT_PROVIDED;
}
/**
* Used to indicate that an argument is out of allowed range
* (for example: incorrect string/array length, number not in allowed min/max range etc)
*
* @class ArgumentOutOfRangeException
* @extends {ExceptionBase}
*/
export class ArgumentOutOfRangeException extends ExceptionBase {
readonly code = ARGUMENT_OUT_OF_RANGE;
}
/**
* Used to indicate conflicting entities (usually in the database)
*
* @class ConflictException
* @extends {ExceptionBase}
*/
export class ConflictException extends ExceptionBase {
readonly code = CONFLICT;
}
/**
* Used to indicate that entity is not found
*
* @class NotFoundException
* @extends {ExceptionBase}
*/
export class NotFoundException extends ExceptionBase {
static readonly message = 'Not found';
constructor(message = NotFoundException.message) {
super(message);
}
readonly code = NOT_FOUND;
}
/**
* Used to indicate an internal server error that does not fall under all other errors
*
* @class InternalServerErrorException
* @extends {ExceptionBase}
*/
export class InternalServerErrorException extends ExceptionBase {
static readonly message = 'Internal server error';
constructor(message = InternalServerErrorException.message) {
super(message);
}
readonly code = INTERNAL_SERVER_ERROR;
}

View File

@@ -0,0 +1,3 @@
export * from './exception.base';
export * from './exception.codes';
export * from './exceptions';

55
src/libs/guard.ts Normal file
View File

@@ -0,0 +1,55 @@
export class Guard {
/**
* Checks if value is empty. Accepts strings, numbers, booleans, objects and arrays.
*/
static isEmpty(value: unknown): boolean {
if (typeof value === 'number' || typeof value === 'boolean') {
return false;
}
if (typeof value === 'undefined' || value === null) {
return true;
}
if (value instanceof Date) {
return false;
}
if (value instanceof Object && !Object.keys(value).length) {
return true;
}
if (Array.isArray(value)) {
if (value.length === 0) {
return true;
}
if (value.every((item) => Guard.isEmpty(item))) {
return true;
}
}
if (value === '') {
return true;
}
return false;
}
/**
* Checks length range of a provided number/string/array
*/
static lengthIsBetween(
value: number | string | Array<unknown>,
min: number,
max: number,
): boolean {
if (Guard.isEmpty(value)) {
throw new Error(
'Cannot check length of a value. Provided value is empty',
);
}
const valueLength =
typeof value === 'number'
? Number(value).toString().length
: value.length;
if (valueLength >= min && valueLength <= max) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,6 @@
export interface LoggerPort {
log(message: string, ...meta: unknown[]): void;
error(message: string, trace?: unknown, ...meta: unknown[]): void;
warn(message: string, ...meta: unknown[]): void;
debug(message: string, ...meta: unknown[]): void;
}

View File

@@ -0,0 +1,4 @@
export interface PrismaRepositoryPort<Entity> {
findUnique(options: any): Promise<Entity>;
create(entity: any): Promise<Entity>;
}

6
src/libs/types/index.ts Normal file
View File

@@ -0,0 +1,6 @@
/** Consider creating a bunch of shared custom utility
* types for different situations.
* Alternatively you can use a library like
* https://github.com/andnp/SimplyTyped
*/
export * from './object-literal.type';

View File

@@ -0,0 +1,6 @@
/**
* Interface of the simple literal object with any string keys.
*/
export interface ObjectLiteral {
[key: string]: unknown;
}

View File

@@ -0,0 +1,47 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types */
import { Entity } from '../ddd/entity.base';
import { ValueObject } from '../ddd/value-object.base';
function isEntity(obj: unknown): obj is Entity<unknown> {
/**
* 'instanceof Entity' causes error here for some reason.
* Probably creates some circular dependency. This is a workaround
* until I find a solution :)
*/
return (
Object.prototype.hasOwnProperty.call(obj, 'toObject') &&
Object.prototype.hasOwnProperty.call(obj, 'id') &&
ValueObject.isValueObject((obj as Entity<unknown>).id)
);
}
function convertToPlainObject(item: any): any {
if (ValueObject.isValueObject(item)) {
return item.unpack();
}
if (isEntity(item)) {
return item.toObject();
}
return item;
}
/**
* Converts Entity/Value Objects props to a plain object.
* Useful for testing and debugging.
* @param props
*/
export function convertPropsToObject(props: any): any {
const propsCopy = structuredClone(props);
// eslint-disable-next-line guard-for-in
for (const prop in propsCopy) {
if (Array.isArray(propsCopy[prop])) {
propsCopy[prop] = (propsCopy[prop] as Array<unknown>).map((item) => {
return convertToPlainObject(item);
});
}
propsCopy[prop] = convertToPlainObject(propsCopy[prop]);
}
return propsCopy;
}

1
src/libs/utils/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './convert-props-to-object.util';