feat(shared): add shared module with domain primitives, infrastructure services, and utils

Domain primitives: BaseEntity, AggregateRoot, ValueObject, DomainEvent, Result<T,E>
Infrastructure: PrismaService, RedisService, LoggerService (pino), EventBusService
Utils: Vietnam phone validator/normalizer, VND currency formatter, Vietnamese slug generator
Includes 45 unit tests covering all domain primitives, validators, and formatters.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 00:07:27 +07:00
parent 83d55de65b
commit 1fb7bb39d2
30 changed files with 282 additions and 92 deletions

View File

@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { AggregateRoot } from '../aggregate-root';
import { type DomainEvent } from '../domain-event';
class TestAggregate extends AggregateRoot {
doSomething(): void {
this.addDomainEvent({
eventName: 'TestHappened',
occurredAt: new Date(),
aggregateId: this.id,
});
}
}
describe('AggregateRoot', () => {
it('should collect domain events', () => {
const agg = new TestAggregate('agg-1');
agg.doSomething();
agg.doSomething();
expect(agg.domainEvents).toHaveLength(2);
expect(agg.domainEvents[0]!.eventName).toBe('TestHappened');
});
it('should clear domain events and return them', () => {
const agg = new TestAggregate('agg-1');
agg.doSomething();
const events = agg.clearDomainEvents();
expect(events).toHaveLength(1);
expect(agg.domainEvents).toHaveLength(0);
});
it('should return a copy of domain events (not mutable reference)', () => {
const agg = new TestAggregate('agg-1');
agg.doSomething();
const events = agg.domainEvents;
(events as DomainEvent[]).push({
eventName: 'Fake',
occurredAt: new Date(),
aggregateId: 'x',
});
expect(agg.domainEvents).toHaveLength(1);
});
});

View File

@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
import { Result } from '../result';
describe('Result', () => {
describe('ok', () => {
it('should create a successful result', () => {
const result = Result.ok(42);
expect(result.isOk).toBe(true);
expect(result.isErr).toBe(false);
expect(result.unwrap()).toBe(42);
});
});
describe('err', () => {
it('should create an error result', () => {
const error = new Error('fail');
const result = Result.err(error);
expect(result.isOk).toBe(false);
expect(result.isErr).toBe(true);
expect(result.unwrapErr()).toBe(error);
});
});
describe('unwrap', () => {
it('should throw on error result', () => {
const result = Result.err(new Error('boom'));
expect(() => result.unwrap()).toThrow('boom');
});
});
describe('unwrapOr', () => {
it('should return value on ok', () => {
expect(Result.ok(10).unwrapOr(0)).toBe(10);
});
it('should return default on err', () => {
expect(Result.err<number>(new Error('x')).unwrapOr(0)).toBe(0);
});
});
describe('map', () => {
it('should transform ok value', () => {
const result = Result.ok(5).map((v) => v * 2);
expect(result.unwrap()).toBe(10);
});
it('should pass through error', () => {
const error = new Error('oops');
const result = Result.err<number>(error).map((v) => v * 2);
expect(result.unwrapErr()).toBe(error);
});
});
describe('andThen', () => {
it('should chain ok results', () => {
const result = Result.ok(5).andThen((v) =>
v > 0 ? Result.ok(v * 2) : Result.err(new Error('negative')),
);
expect(result.unwrap()).toBe(10);
});
it('should short-circuit on error', () => {
const result = Result.err<number>(new Error('first')).andThen((v) => Result.ok(v * 2));
expect(result.unwrapErr().message).toBe('first');
});
});
describe('match', () => {
it('should call ok handler on success', () => {
const output = Result.ok(42).match({
ok: (v) => `value: ${v}`,
err: (e) => `error: ${e.message}`,
});
expect(output).toBe('value: 42');
});
it('should call err handler on failure', () => {
const output = Result.err(new Error('bad')).match({
ok: () => 'ok',
err: (e) => `error: ${e.message}`,
});
expect(output).toBe('error: bad');
});
});
});

View File

@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { ValueObject } from '../value-object';
class TestVO extends ValueObject<{ value: string }> {
get value(): string {
return this.props.value;
}
}
describe('ValueObject', () => {
it('should be immutable', () => {
const vo = new TestVO({ value: 'hello' });
expect(() => {
(vo as any).props.value = 'changed';
}).toThrow();
});
it('should be equal when props match', () => {
const a = new TestVO({ value: 'same' });
const b = new TestVO({ value: 'same' });
expect(a.equals(b)).toBe(true);
});
it('should not be equal when props differ', () => {
const a = new TestVO({ value: 'one' });
const b = new TestVO({ value: 'two' });
expect(a.equals(b)).toBe(false);
});
it('should not be equal to null/undefined', () => {
const vo = new TestVO({ value: 'x' });
expect(vo.equals(null as any)).toBe(false);
expect(vo.equals(undefined as any)).toBe(false);
});
});

View File

@@ -0,0 +1,20 @@
import { BaseEntity } from './base-entity';
import { type DomainEvent } from './domain-event';
export abstract class AggregateRoot<TId = string> extends BaseEntity<TId> {
private _domainEvents: DomainEvent[] = [];
get domainEvents(): ReadonlyArray<DomainEvent> {
return [...this._domainEvents];
}
protected addDomainEvent(event: DomainEvent): void {
this._domainEvents.push(event);
}
clearDomainEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents = [];
return events;
}
}

View File

@@ -0,0 +1,13 @@
export abstract class BaseEntity<TId = string> {
constructor(
public readonly id: TId,
public readonly createdAt: Date = new Date(),
public updatedAt: Date = new Date(),
) {}
equals(other: BaseEntity<TId>): boolean {
if (other === null || other === undefined) return false;
if (this === other) return true;
return this.id === other.id;
}
}

View File

@@ -0,0 +1,5 @@
export interface DomainEvent {
readonly eventName: string;
readonly occurredAt: Date;
readonly aggregateId: string;
}

View File

@@ -0,0 +1,5 @@
export { BaseEntity } from './base-entity';
export { AggregateRoot } from './aggregate-root';
export { ValueObject } from './value-object';
export type { DomainEvent } from './domain-event';
export { Result } from './result';

View File

@@ -0,0 +1,56 @@
export class Result<T, E = Error> {
private constructor(
private readonly _isOk: boolean,
private readonly _value?: T,
private readonly _error?: E,
) {}
get isOk(): boolean {
return this._isOk;
}
get isErr(): boolean {
return !this._isOk;
}
static ok<T, E = Error>(value: T): Result<T, E> {
return new Result<T, E>(true, value);
}
static err<T, E = Error>(error: E): Result<T, E> {
return new Result<T, E>(false, undefined, error);
}
unwrap(): T {
if (this._isOk) return this._value as T;
throw this._error;
}
unwrapErr(): E {
if (!this._isOk) return this._error as E;
throw new Error('Called unwrapErr on an Ok result');
}
map<U>(fn: (value: T) => U): Result<U, E> {
if (this._isOk) return Result.ok(fn(this._value as T));
return Result.err(this._error as E);
}
mapErr<F>(fn: (error: E) => F): Result<T, F> {
if (!this._isOk) return Result.err(fn(this._error as E));
return Result.ok(this._value as T);
}
andThen<U>(fn: (value: T) => Result<U, E>): Result<U, E> {
if (this._isOk) return fn(this._value as T);
return Result.err(this._error as E);
}
unwrapOr(defaultValue: T): T {
return this._isOk ? (this._value as T) : defaultValue;
}
match<U>(handlers: { ok: (value: T) => U; err: (error: E) => U }): U {
return this._isOk ? handlers.ok(this._value as T) : handlers.err(this._error as E);
}
}

View File

@@ -0,0 +1,12 @@
export abstract class ValueObject<TProps> {
protected readonly props: Readonly<TProps>;
constructor(props: TProps) {
this.props = Object.freeze({ ...props });
}
equals(other: ValueObject<TProps>): boolean {
if (other === null || other === undefined) return false;
return JSON.stringify(this.props) === JSON.stringify(other.props);
}
}