From 0e6c0c3655d1ec2f2eea209925345373061647ae Mon Sep 17 00:00:00 2001 From: debian Date: Wed, 4 Mar 2026 16:22:42 -0500 Subject: [PATCH] fase(1): shared domain building blocks --- .ralph/fix_plan.md | 30 +++---- dist/shared/application/EventBus.js | 2 + dist/shared/application/EventHandler.js | 2 + dist/shared/application/UseCase.js | 2 + dist/shared/application/index.js | 19 +++++ dist/shared/domain/AggregateRoot.js | 22 +++++ dist/shared/domain/DomainEvent.js | 2 + dist/shared/domain/Entity.js | 17 ++++ dist/shared/domain/Result.js | 11 +++ dist/shared/domain/UniqueId.js | 18 ++++ dist/shared/domain/ValueObject.js | 14 ++++ dist/shared/domain/index.js | 22 +++++ frontend/package-lock.json | 24 +++++- frontend/package.json | 4 +- package-lock.json | 23 +++++- package.json | 4 +- src/shared/application/EventBus.ts | 7 ++ src/shared/application/EventHandler.ts | 5 ++ src/shared/application/UseCase.ts | 5 ++ src/shared/application/index.ts | 3 + src/shared/domain/AggregateRoot.ts | 25 ++++++ src/shared/domain/DomainEvent.ts | 7 ++ src/shared/domain/Entity.ts | 18 ++++ src/shared/domain/Result.ts | 8 ++ src/shared/domain/UniqueId.ts | 15 ++++ src/shared/domain/ValueObject.ts | 12 +++ src/shared/domain/index.ts | 6 ++ tests/shared/domain.test.ts | 105 ++++++++++++++++++++++++ 28 files changed, 413 insertions(+), 19 deletions(-) create mode 100644 dist/shared/application/EventBus.js create mode 100644 dist/shared/application/EventHandler.js create mode 100644 dist/shared/application/UseCase.js create mode 100644 dist/shared/application/index.js create mode 100644 dist/shared/domain/AggregateRoot.js create mode 100644 dist/shared/domain/DomainEvent.js create mode 100644 dist/shared/domain/Entity.js create mode 100644 dist/shared/domain/Result.js create mode 100644 dist/shared/domain/UniqueId.js create mode 100644 dist/shared/domain/ValueObject.js create mode 100644 dist/shared/domain/index.js create mode 100644 src/shared/application/EventBus.ts create mode 100644 src/shared/application/EventHandler.ts create mode 100644 src/shared/application/UseCase.ts create mode 100644 src/shared/application/index.ts create mode 100644 src/shared/domain/AggregateRoot.ts create mode 100644 src/shared/domain/DomainEvent.ts create mode 100644 src/shared/domain/Entity.ts create mode 100644 src/shared/domain/Result.ts create mode 100644 src/shared/domain/UniqueId.ts create mode 100644 src/shared/domain/ValueObject.ts create mode 100644 src/shared/domain/index.ts create mode 100644 tests/shared/domain.test.ts diff --git a/.ralph/fix_plan.md b/.ralph/fix_plan.md index e9290aa..10cf402 100644 --- a/.ralph/fix_plan.md +++ b/.ralph/fix_plan.md @@ -19,23 +19,23 @@ --- -## Phase 1: Shared Domain — Building Blocks [PENDIENTE] +## Phase 1: Shared Domain — Building Blocks [COMPLETO] Spec: `.ralph/specs/phase-01-shared-domain.md` -- [ ] 1.1: Crear directorio `src/shared/domain/` -- [ ] 1.2: Crear `src/shared/domain/Result.ts` — Result con Ok(), Err(), isOk(), isErr() -- [ ] 1.3: Crear `src/shared/domain/UniqueId.ts` — UUID v4 wrapper con create(), toString(), equals() -- [ ] 1.4: Crear `src/shared/domain/Entity.ts` — base class con _id: UniqueId, equals() -- [ ] 1.5: Crear `src/shared/domain/AggregateRoot.ts` — extends Entity + domainEvents[], addDomainEvent(), clearEvents() -- [ ] 1.6: Crear `src/shared/domain/ValueObject.ts` — base class inmutable con props frozen, equals() -- [ ] 1.7: Crear `src/shared/domain/DomainEvent.ts` — interface: eventId, eventName, aggregateId, occurredOn, payload -- [ ] 1.8: Crear `src/shared/application/UseCase.ts` — interface: execute(req) → Promise> -- [ ] 1.9: Crear `src/shared/application/EventBus.ts` — interface: publish(event), subscribe(name, handler) -- [ ] 1.10: Crear `src/shared/application/EventHandler.ts` — interface: handle(event) → Promise -- [ ] 1.11: Crear `src/shared/domain/index.ts` — barrel export de todo shared/domain -- [ ] 1.12: Crear `src/shared/application/index.ts` — barrel export de todo shared/application -- [ ] 1.13: Tests unitarios: Result (Ok/Err/isOk/isErr), Entity (equals by id), ValueObject (equals by props), UniqueId (create/equals) -- [ ] 1.14: Verificar build completo + commit: `fase(1): shared domain building blocks` +- [x] 1.1: Crear directorio `src/shared/domain/` +- [x] 1.2: Crear `src/shared/domain/Result.ts` — Result con Ok(), Err(), isOk(), isErr() +- [x] 1.3: Crear `src/shared/domain/UniqueId.ts` — UUID v4 wrapper con create(), toString(), equals() +- [x] 1.4: Crear `src/shared/domain/Entity.ts` — base class con _id: UniqueId, equals() +- [x] 1.5: Crear `src/shared/domain/AggregateRoot.ts` — extends Entity + domainEvents[], addDomainEvent(), clearEvents() +- [x] 1.6: Crear `src/shared/domain/ValueObject.ts` — base class inmutable con props frozen, equals() +- [x] 1.7: Crear `src/shared/domain/DomainEvent.ts` — interface: eventId, eventName, aggregateId, occurredOn, payload +- [x] 1.8: Crear `src/shared/application/UseCase.ts` — interface: execute(req) → Promise> +- [x] 1.9: Crear `src/shared/application/EventBus.ts` — interface: publish(event), subscribe(name, handler) +- [x] 1.10: Crear `src/shared/application/EventHandler.ts` — interface: handle(event) → Promise +- [x] 1.11: Crear `src/shared/domain/index.ts` — barrel export de todo shared/domain +- [x] 1.12: Crear `src/shared/application/index.ts` — barrel export de todo shared/application +- [x] 1.13: Tests unitarios: Result (Ok/Err/isOk/isErr), Entity (equals by id), ValueObject (equals by props), UniqueId (create/equals) +- [x] 1.14: Verificar build completo + commit: `fase(1): shared domain building blocks` --- diff --git a/dist/shared/application/EventBus.js b/dist/shared/application/EventBus.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/shared/application/EventBus.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/shared/application/EventHandler.js b/dist/shared/application/EventHandler.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/shared/application/EventHandler.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/shared/application/UseCase.js b/dist/shared/application/UseCase.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/shared/application/UseCase.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/shared/application/index.js b/dist/shared/application/index.js new file mode 100644 index 0000000..f1ea61a --- /dev/null +++ b/dist/shared/application/index.js @@ -0,0 +1,19 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +__exportStar(require("./UseCase"), exports); +__exportStar(require("./EventBus"), exports); +__exportStar(require("./EventHandler"), exports); diff --git a/dist/shared/domain/AggregateRoot.js b/dist/shared/domain/AggregateRoot.js new file mode 100644 index 0000000..4071e19 --- /dev/null +++ b/dist/shared/domain/AggregateRoot.js @@ -0,0 +1,22 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AggregateRoot = void 0; +const Entity_1 = require("./Entity"); +class AggregateRoot extends Entity_1.Entity { + constructor(props, id) { + super(props, id); + this._domainEvents = []; + } + get domainEvents() { + return this._domainEvents; + } + addDomainEvent(event) { + this._domainEvents.push(event); + } + clearEvents() { + const events = [...this._domainEvents]; + this._domainEvents = []; + return events; + } +} +exports.AggregateRoot = AggregateRoot; diff --git a/dist/shared/domain/DomainEvent.js b/dist/shared/domain/DomainEvent.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/shared/domain/DomainEvent.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/shared/domain/Entity.js b/dist/shared/domain/Entity.js new file mode 100644 index 0000000..604b7d3 --- /dev/null +++ b/dist/shared/domain/Entity.js @@ -0,0 +1,17 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Entity = void 0; +const UniqueId_1 = require("./UniqueId"); +class Entity { + constructor(props, id) { + this._id = id ?? UniqueId_1.UniqueId.create(); + this.props = props; + } + get id() { return this._id; } + equals(other) { + if (!other) + return false; + return this._id.equals(other._id); + } +} +exports.Entity = Entity; diff --git a/dist/shared/domain/Result.js b/dist/shared/domain/Result.js new file mode 100644 index 0000000..3c20134 --- /dev/null +++ b/dist/shared/domain/Result.js @@ -0,0 +1,11 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Err = exports.Ok = void 0; +exports.isOk = isOk; +exports.isErr = isErr; +const Ok = (value) => ({ ok: true, value }); +exports.Ok = Ok; +const Err = (error) => ({ ok: false, error }); +exports.Err = Err; +function isOk(r) { return r.ok; } +function isErr(r) { return !r.ok; } diff --git a/dist/shared/domain/UniqueId.js b/dist/shared/domain/UniqueId.js new file mode 100644 index 0000000..d0eadcf --- /dev/null +++ b/dist/shared/domain/UniqueId.js @@ -0,0 +1,18 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.UniqueId = void 0; +const uuid_1 = require("uuid"); +class UniqueId { + constructor(value) { + this.value = value; + } + static create() { return new UniqueId((0, uuid_1.v4)()); } + static from(value) { return new UniqueId(value); } + toString() { return this.value; } + equals(other) { + if (!other) + return false; + return this.value === other.value; + } +} +exports.UniqueId = UniqueId; diff --git a/dist/shared/domain/ValueObject.js b/dist/shared/domain/ValueObject.js new file mode 100644 index 0000000..15c660a --- /dev/null +++ b/dist/shared/domain/ValueObject.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ValueObject = void 0; +class ValueObject { + constructor(props) { + this.props = Object.freeze(props); + } + equals(other) { + if (!other) + return false; + return JSON.stringify(this.props) === JSON.stringify(other.props); + } +} +exports.ValueObject = ValueObject; diff --git a/dist/shared/domain/index.js b/dist/shared/domain/index.js new file mode 100644 index 0000000..2e4e30d --- /dev/null +++ b/dist/shared/domain/index.js @@ -0,0 +1,22 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +__exportStar(require("./Result"), exports); +__exportStar(require("./UniqueId"), exports); +__exportStar(require("./Entity"), exports); +__exportStar(require("./AggregateRoot"), exports); +__exportStar(require("./ValueObject"), exports); +__exportStar(require("./DomainEvent"), exports); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9603e59..7866268 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,7 +13,8 @@ "react-dom": "^19.2.0", "react-router-dom": "^7.13.1", "socket.io-client": "^4.8.3", - "tailwindcss": "^4.2.1" + "tailwindcss": "^4.2.1", + "uuid": "^13.0.0" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -22,6 +23,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^5.1.1", "@vitest/browser": "^4.0.18", "concurrently": "^9.2.1", @@ -2063,6 +2065,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", @@ -5011,6 +5020,19 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 08dd720..495eed9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,8 @@ "react-dom": "^19.2.0", "react-router-dom": "^7.13.1", "socket.io-client": "^4.8.3", - "tailwindcss": "^4.2.1" + "tailwindcss": "^4.2.1", + "uuid": "^13.0.0" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -25,6 +26,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^5.1.1", "@vitest/browser": "^4.0.18", "concurrently": "^9.2.1", diff --git a/package-lock.json b/package-lock.json index 3b31e87..868e739 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@types/express-rate-limit": "^5.1.3", "@types/node-cron": "^3.0.11", "@types/pino": "^7.0.4", + "@types/uuid": "^10.0.0", "better-sqlite3": "^12.6.2", "commander": "^14.0.3", "cors": "^2.8.6", @@ -27,7 +28,8 @@ "pixelmatch": "^7.1.0", "playwright": "^1.40.0", "sharp": "^0.34.5", - "socket.io": "^4.8.3" + "socket.io": "^4.8.3", + "uuid": "^13.0.0" }, "devDependencies": { "@types/jest": "^29.5.0", @@ -1837,6 +1839,12 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -6480,6 +6488,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index 3b49aad..f315f5f 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@types/express-rate-limit": "^5.1.3", "@types/node-cron": "^3.0.11", "@types/pino": "^7.0.4", + "@types/uuid": "^10.0.0", "better-sqlite3": "^12.6.2", "commander": "^14.0.3", "cors": "^2.8.6", @@ -50,6 +51,7 @@ "pixelmatch": "^7.1.0", "playwright": "^1.40.0", "sharp": "^0.34.5", - "socket.io": "^4.8.3" + "socket.io": "^4.8.3", + "uuid": "^13.0.0" } } diff --git a/src/shared/application/EventBus.ts b/src/shared/application/EventBus.ts new file mode 100644 index 0000000..2612d93 --- /dev/null +++ b/src/shared/application/EventBus.ts @@ -0,0 +1,7 @@ +import { DomainEvent } from '../domain/DomainEvent'; +import { EventHandler } from './EventHandler'; + +export interface EventBus { + publish(event: DomainEvent): Promise; + subscribe(eventName: string, handler: EventHandler): void; +} diff --git a/src/shared/application/EventHandler.ts b/src/shared/application/EventHandler.ts new file mode 100644 index 0000000..5908b62 --- /dev/null +++ b/src/shared/application/EventHandler.ts @@ -0,0 +1,5 @@ +import { DomainEvent } from '../domain/DomainEvent'; + +export interface EventHandler { + handle(event: DomainEvent): Promise; +} diff --git a/src/shared/application/UseCase.ts b/src/shared/application/UseCase.ts new file mode 100644 index 0000000..ed46ab5 --- /dev/null +++ b/src/shared/application/UseCase.ts @@ -0,0 +1,5 @@ +import { Result } from '../domain/Result'; + +export interface UseCase { + execute(request: TRequest): Promise>; +} diff --git a/src/shared/application/index.ts b/src/shared/application/index.ts new file mode 100644 index 0000000..c7df0c8 --- /dev/null +++ b/src/shared/application/index.ts @@ -0,0 +1,3 @@ +export * from './UseCase'; +export * from './EventBus'; +export * from './EventHandler'; diff --git a/src/shared/domain/AggregateRoot.ts b/src/shared/domain/AggregateRoot.ts new file mode 100644 index 0000000..26dd6c0 --- /dev/null +++ b/src/shared/domain/AggregateRoot.ts @@ -0,0 +1,25 @@ +import { Entity } from './Entity'; +import { DomainEvent } from './DomainEvent'; +import { UniqueId } from './UniqueId'; + +export abstract class AggregateRoot extends Entity { + private _domainEvents: DomainEvent[] = []; + + constructor(props: T, id?: UniqueId) { + super(props, id); + } + + get domainEvents(): ReadonlyArray { + return this._domainEvents; + } + + protected addDomainEvent(event: DomainEvent): void { + this._domainEvents.push(event); + } + + clearEvents(): DomainEvent[] { + const events = [...this._domainEvents]; + this._domainEvents = []; + return events; + } +} diff --git a/src/shared/domain/DomainEvent.ts b/src/shared/domain/DomainEvent.ts new file mode 100644 index 0000000..512cbbc --- /dev/null +++ b/src/shared/domain/DomainEvent.ts @@ -0,0 +1,7 @@ +export interface DomainEvent { + readonly eventId: string; + readonly eventName: string; + readonly aggregateId: string; + readonly occurredOn: Date; + readonly payload: Record; +} diff --git a/src/shared/domain/Entity.ts b/src/shared/domain/Entity.ts new file mode 100644 index 0000000..c82b7e9 --- /dev/null +++ b/src/shared/domain/Entity.ts @@ -0,0 +1,18 @@ +import { UniqueId } from './UniqueId'; + +export abstract class Entity { + protected readonly _id: UniqueId; + protected props: T; + + constructor(props: T, id?: UniqueId) { + this._id = id ?? UniqueId.create(); + this.props = props; + } + + get id(): UniqueId { return this._id; } + + equals(other?: Entity): boolean { + if (!other) return false; + return this._id.equals(other._id); + } +} diff --git a/src/shared/domain/Result.ts b/src/shared/domain/Result.ts new file mode 100644 index 0000000..0a4dfcc --- /dev/null +++ b/src/shared/domain/Result.ts @@ -0,0 +1,8 @@ +type ResultOk = { readonly ok: true; readonly value: T }; +type ResultErr = { readonly ok: false; readonly error: E }; +export type Result = ResultOk | ResultErr; + +export const Ok = (value: T): Result => ({ ok: true, value }); +export const Err = (error: E): Result => ({ ok: false, error }); +export function isOk(r: Result): r is ResultOk { return r.ok; } +export function isErr(r: Result): r is ResultErr { return !r.ok; } diff --git a/src/shared/domain/UniqueId.ts b/src/shared/domain/UniqueId.ts new file mode 100644 index 0000000..62534c7 --- /dev/null +++ b/src/shared/domain/UniqueId.ts @@ -0,0 +1,15 @@ +import { randomUUID } from 'crypto'; + +export class UniqueId { + private constructor(private readonly value: string) {} + + static create(): UniqueId { return new UniqueId(randomUUID()); } + static from(value: string): UniqueId { return new UniqueId(value); } + + toString(): string { return this.value; } + + equals(other?: UniqueId): boolean { + if (!other) return false; + return this.value === other.value; + } +} diff --git a/src/shared/domain/ValueObject.ts b/src/shared/domain/ValueObject.ts new file mode 100644 index 0000000..51e3272 --- /dev/null +++ b/src/shared/domain/ValueObject.ts @@ -0,0 +1,12 @@ +export abstract class ValueObject { + protected readonly props: T; + + constructor(props: T) { + this.props = Object.freeze(props) as T; + } + + equals(other?: ValueObject): boolean { + if (!other) return false; + return JSON.stringify(this.props) === JSON.stringify(other.props); + } +} diff --git a/src/shared/domain/index.ts b/src/shared/domain/index.ts new file mode 100644 index 0000000..a4dd9c2 --- /dev/null +++ b/src/shared/domain/index.ts @@ -0,0 +1,6 @@ +export * from './Result'; +export * from './UniqueId'; +export * from './Entity'; +export * from './AggregateRoot'; +export * from './ValueObject'; +export * from './DomainEvent'; diff --git a/tests/shared/domain.test.ts b/tests/shared/domain.test.ts new file mode 100644 index 0000000..d01b295 --- /dev/null +++ b/tests/shared/domain.test.ts @@ -0,0 +1,105 @@ +import { Ok, Err, isOk, isErr } from '../../src/shared/domain/Result'; +import { UniqueId } from '../../src/shared/domain/UniqueId'; +import { Entity } from '../../src/shared/domain/Entity'; +import { ValueObject } from '../../src/shared/domain/ValueObject'; + +// --- Result --- +describe('Result', () => { + it('Ok creates accessible value', () => { + const r = Ok(42); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(42); + }); + + it('Err creates accessible error', () => { + const e = new Error('fail'); + const r = Err(e); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe(e); + }); + + it('isOk discriminates correctly', () => { + expect(isOk(Ok('x'))).toBe(true); + expect(isOk(Err('e'))).toBe(false); + }); + + it('isErr discriminates correctly', () => { + expect(isErr(Err('e'))).toBe(true); + expect(isErr(Ok('x'))).toBe(false); + }); +}); + +// --- UniqueId --- +describe('UniqueId', () => { + it('create generates a valid UUID string', () => { + const id = UniqueId.create(); + expect(id.toString()).toMatch(/^[0-9a-f-]{36}$/); + }); + + it('from preserves the value', () => { + const id = UniqueId.from('abc-123'); + expect(id.toString()).toBe('abc-123'); + }); + + it('equals returns true for same value', () => { + const a = UniqueId.from('same'); + const b = UniqueId.from('same'); + expect(a.equals(b)).toBe(true); + }); + + it('equals returns false for different value', () => { + const a = UniqueId.create(); + const b = UniqueId.create(); + expect(a.equals(b)).toBe(false); + }); + + it('equals returns false for undefined', () => { + const a = UniqueId.create(); + expect(a.equals(undefined)).toBe(false); + }); +}); + +// --- Entity --- +class TestEntity extends Entity<{ name: string }> {} + +describe('Entity', () => { + it('equals compares by id, not by props', () => { + const id = UniqueId.from('id-1'); + const a = new TestEntity({ name: 'Alice' }, id); + const b = new TestEntity({ name: 'Bob' }, id); + expect(a.equals(b)).toBe(true); + }); + + it('equals returns false for different ids', () => { + const a = new TestEntity({ name: 'Alice' }); + const b = new TestEntity({ name: 'Alice' }); + expect(a.equals(b)).toBe(false); + }); + + it('equals returns false for undefined', () => { + const a = new TestEntity({ name: 'Alice' }); + expect(a.equals(undefined)).toBe(false); + }); +}); + +// --- ValueObject --- +class TestVO extends ValueObject<{ amount: number; currency: string }> {} + +describe('ValueObject', () => { + it('equals compares by props', () => { + const a = new TestVO({ amount: 100, currency: 'USD' }); + const b = new TestVO({ amount: 100, currency: 'USD' }); + expect(a.equals(b)).toBe(true); + }); + + it('equals returns false when props differ', () => { + const a = new TestVO({ amount: 100, currency: 'USD' }); + const b = new TestVO({ amount: 200, currency: 'USD' }); + expect(a.equals(b)).toBe(false); + }); + + it('props are frozen (immutable)', () => { + const vo = new TestVO({ amount: 100, currency: 'USD' }); + expect(Object.isFrozen((vo as unknown as { props: unknown }).props)).toBe(true); + }); +});