feat: scaffold monorepo with Turborepo + NestJS + Next.js

- Turborepo monorepo with pnpm workspaces
- apps/api: NestJS 11.x with CQRS module
- apps/web: Next.js 14 App Router + TailwindCSS
- src/modules/shared: base entities, Result pattern, value objects
- TypeScript 5.7+ strict mode, shared tsconfig base
- Build pipeline: dev, build, lint, test, typecheck

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-07 23:52:33 +07:00
commit e1e5fa6252
52 changed files with 6110 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
import { BaseEntity } from './base-entity';
import { 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,22 @@
import { BaseEntity } from './base.entity';
export interface DomainEvent {
readonly occurredAt: Date;
readonly eventName: string;
}
export abstract class AggregateRoot<TProps> extends BaseEntity<TProps> {
private _domainEvents: DomainEvent[] = [];
get domainEvents(): ReadonlyArray<DomainEvent> {
return this._domainEvents;
}
protected addDomainEvent(event: DomainEvent): void {
this._domainEvents.push(event);
}
clearDomainEvents(): void {
this._domainEvents = [];
}
}

View File

@@ -0,0 +1,37 @@
import { randomUUID } from 'node:crypto';
export abstract class BaseEntity<TProps> {
private readonly _id: string;
private readonly _createdAt: Date;
private _updatedAt: Date;
protected props: TProps;
constructor(props: TProps, id?: string) {
this._id = id ?? randomUUID();
this._createdAt = new Date();
this._updatedAt = new Date();
this.props = props;
}
get id(): string {
return this._id;
}
get createdAt(): Date {
return this._createdAt;
}
get updatedAt(): Date {
return this._updatedAt;
}
protected markUpdated(): void {
this._updatedAt = new Date();
}
equals(other: BaseEntity<TProps>): 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 { 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,58 @@
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);
}
}

View File

@@ -0,0 +1,12 @@
export abstract class ValueObject<TProps> {
protected readonly props: 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);
}
}

View File

@@ -0,0 +1,4 @@
export * from './domain';
export * from './infrastructure';
export * from './utils';
export { SharedModule } from './shared.module';

View File

@@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { DomainEvent } from '../domain/domain-event';
@Injectable()
export class EventBusService {
constructor(private readonly eventEmitter: EventEmitter2) {}
publish(event: DomainEvent): void {
this.eventEmitter.emit(event.eventName, event);
}
publishAll(events: DomainEvent[]): void {
for (const event of events) {
this.publish(event);
}
}
async publishAsync(event: DomainEvent): Promise<void> {
await this.eventEmitter.emitAsync(event.eventName, event);
}
}

View File

@@ -0,0 +1,4 @@
export { PrismaService } from './prisma.service';
export { RedisService } from './redis.service';
export { LoggerService } from './logger.service';
export { EventBusService } from './event-bus.service';

View File

@@ -0,0 +1,41 @@
import { Injectable, LoggerService as NestLoggerService } from '@nestjs/common';
import pino, { Logger } from 'pino';
@Injectable()
export class LoggerService implements NestLoggerService {
private readonly logger: Logger;
constructor() {
this.logger = pino({
level: process.env['LOG_LEVEL'] ?? 'info',
transport:
process.env['NODE_ENV'] !== 'production'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
});
}
log(message: string, context?: string): void {
this.logger.info({ context }, message);
}
error(message: string, trace?: string, context?: string): void {
this.logger.error({ context, trace }, message);
}
warn(message: string, context?: string): void {
this.logger.warn({ context }, message);
}
debug(message: string, context?: string): void {
this.logger.debug({ context }, message);
}
verbose(message: string, context?: string): void {
this.logger.trace({ context }, message);
}
child(bindings: Record<string, unknown>): Logger {
return this.logger.child(bindings);
}
}

View File

@@ -0,0 +1,16 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit(): Promise<void> {
await this.$connect();
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}

View File

@@ -0,0 +1,40 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import Redis from 'ioredis';
@Injectable()
export class RedisService implements OnModuleDestroy {
private readonly client: Redis;
constructor() {
this.client = new Redis({
host: process.env['REDIS_HOST'] ?? 'localhost',
port: Number(process.env['REDIS_PORT'] ?? 6379),
password: process.env['REDIS_PASSWORD'] ?? undefined,
lazyConnect: true,
});
}
async onModuleDestroy(): Promise<void> {
await this.client.quit();
}
getClient(): Redis {
return this.client;
}
async get(key: string): Promise<string | null> {
return this.client.get(key);
}
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
if (ttlSeconds) {
await this.client.set(key, value, 'EX', ttlSeconds);
} else {
await this.client.set(key, value);
}
}
async del(key: string): Promise<void> {
await this.client.del(key);
}
}

View File

@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { PrismaService } from './infrastructure/prisma.service';
import { RedisService } from './infrastructure/redis.service';
import { LoggerService } from './infrastructure/logger.service';
import { EventBusService } from './infrastructure/event-bus.service';
@Global()
@Module({
imports: [EventEmitterModule.forRoot()],
providers: [PrismaService, RedisService, LoggerService, EventBusService],
exports: [PrismaService, RedisService, LoggerService, EventBusService],
})
export class SharedModule {}

View File

@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "."
},
"include": ["./**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,11 @@
export type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}

View File

@@ -0,0 +1,26 @@
const VND_FORMATTER = new Intl.NumberFormat('vi-VN', {
style: 'currency',
currency: 'VND',
maximumFractionDigits: 0,
});
const VND_COMPACT_FORMATTER = new Intl.NumberFormat('vi-VN', {
style: 'currency',
currency: 'VND',
notation: 'compact',
maximumFractionDigits: 1,
});
export function formatVND(amount: number): string {
return VND_FORMATTER.format(amount);
}
export function formatVNDCompact(amount: number): string {
return VND_COMPACT_FORMATTER.format(amount);
}
export function parseVND(formatted: string): number | null {
const cleaned = formatted.replace(/[^\d]/g, '');
const value = Number(cleaned);
return Number.isNaN(value) ? null : value;
}

View File

@@ -0,0 +1,3 @@
export { isValidVietnamPhone, normalizeVietnamPhone } from './vietnam-phone.validator';
export { formatVND, formatVNDCompact, parseVND } from './currency.formatter';
export { generateSlug } from './slug.generator';

View File

@@ -0,0 +1,31 @@
const VIETNAMESE_MAP: Record<string, string> = {
à: 'a', á: 'a', : 'a', ã: 'a', : 'a',
ă: 'a', : 'a', : 'a', : 'a', : 'a', : 'a',
â: 'a', : 'a', : 'a', : 'a', : 'a', : 'a',
đ: 'd',
è: 'e', é: 'e', : 'e', : 'e', : 'e',
ê: 'e', ế: 'e', : 'e', : 'e', : 'e', : 'e',
ì: 'i', í: 'i', : 'i', ĩ: 'i', : 'i',
ò: 'o', ó: 'o', : 'o', õ: 'o', : 'o',
ô: 'o', : 'o', : 'o', : 'o', : 'o', : 'o',
ơ: 'o', : 'o', : 'o', : 'o', : 'o', : 'o',
ù: 'u', ú: 'u', : 'u', ũ: 'u', : 'u',
ư: 'u', : 'u', : 'u', : 'u', : 'u', : 'u',
: 'y', ý: 'y', : 'y', : 'y', : 'y',
};
function removeVietnameseTones(str: string): string {
return str
.split('')
.map((char) => VIETNAMESE_MAP[char] ?? VIETNAMESE_MAP[char.toLowerCase()]?.toUpperCase() ?? char)
.join('');
}
export function generateSlug(text: string): string {
return removeVietnameseTones(text)
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/[\s]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}

View File

@@ -0,0 +1,16 @@
const VN_PHONE_REGEX = /^(?:\+84|84|0)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/;
export function isValidVietnamPhone(phone: string): boolean {
const cleaned = phone.replace(/[\s.-]/g, '');
return VN_PHONE_REGEX.test(cleaned);
}
export function normalizeVietnamPhone(phone: string): string | null {
const cleaned = phone.replace(/[\s.-]/g, '');
if (!VN_PHONE_REGEX.test(cleaned)) return null;
if (cleaned.startsWith('+84')) return cleaned;
if (cleaned.startsWith('84')) return `+${cleaned}`;
if (cleaned.startsWith('0')) return `+84${cleaned.slice(1)}`;
return null;
}