mirror of
https://gitlab.com/mobicoop/v3/service/ad.git
synced 2026-03-28 11:15:49 +00:00
WIP - first shot of massive ddd-hexagon refactor
This commit is contained in:
19
src/libs/api/api-error.response.ts
Normal file
19
src/libs/api/api-error.response.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
7
src/libs/api/id.response.dto.ts
Normal file
7
src/libs/api/id.response.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export class IdResponse {
|
||||
constructor(id: string) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
readonly id: string;
|
||||
}
|
||||
8
src/libs/api/paginated.response.base.ts
Normal file
8
src/libs/api/paginated.response.base.ts
Normal 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[];
|
||||
}
|
||||
23
src/libs/api/response.base.ts
Normal file
23
src/libs/api/response.base.ts
Normal 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;
|
||||
}
|
||||
46
src/libs/db/prisma-repository.base.ts
Normal file
46
src/libs/db/prisma-repository.base.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
15
src/libs/db/prisma-service.ts
Normal file
15
src/libs/db/prisma-service.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
35
src/libs/ddd/aggregate-root.base.ts
Normal file
35
src/libs/ddd/aggregate-root.base.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
52
src/libs/ddd/command.base.ts
Normal file
52
src/libs/ddd/command.base.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
52
src/libs/ddd/domain-event.base.ts
Normal file
52
src/libs/ddd/domain-event.base.ts
Normal 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
150
src/libs/ddd/entity.base.ts
Normal 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
7
src/libs/ddd/index.ts
Normal 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';
|
||||
11
src/libs/ddd/mapper.interface.ts
Normal file
11
src/libs/ddd/mapper.interface.ts
Normal 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;
|
||||
}
|
||||
42
src/libs/ddd/repository.port.ts
Normal file
42
src/libs/ddd/repository.port.ts
Normal 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>;
|
||||
}
|
||||
71
src/libs/ddd/value-object.base.ts
Normal file
71
src/libs/ddd/value-object.base.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
63
src/libs/exceptions/exception.base.ts
Normal file
63
src/libs/exceptions/exception.base.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
15
src/libs/exceptions/exception.codes.ts
Normal file
15
src/libs/exceptions/exception.codes.ts
Normal 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';
|
||||
82
src/libs/exceptions/exceptions.ts
Normal file
82
src/libs/exceptions/exceptions.ts
Normal 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;
|
||||
}
|
||||
3
src/libs/exceptions/index.ts
Normal file
3
src/libs/exceptions/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './exception.base';
|
||||
export * from './exception.codes';
|
||||
export * from './exceptions';
|
||||
55
src/libs/guard.ts
Normal file
55
src/libs/guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
6
src/libs/ports/logger.port.ts
Normal file
6
src/libs/ports/logger.port.ts
Normal 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;
|
||||
}
|
||||
4
src/libs/ports/prisma-repository.port.ts
Normal file
4
src/libs/ports/prisma-repository.port.ts
Normal 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
6
src/libs/types/index.ts
Normal 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';
|
||||
6
src/libs/types/object-literal.type.ts
Normal file
6
src/libs/types/object-literal.type.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Interface of the simple literal object with any string keys.
|
||||
*/
|
||||
export interface ObjectLiteral {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
47
src/libs/utils/convert-props-to-object.util.ts
Normal file
47
src/libs/utils/convert-props-to-object.util.ts
Normal 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
1
src/libs/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './convert-props-to-object.util';
|
||||
Reference in New Issue
Block a user