feat(guardian): add guardian package - code quality analyzer

Add @puaros/guardian package v0.1.0 - code quality guardian for vibe coders and enterprise teams.

Features:
- Hardcode detection (magic numbers, magic strings)
- Circular dependency detection
- Naming convention enforcement (Clean Architecture)
- Architecture violation detection
- CLI tool with comprehensive reporting
- 159 tests with 80%+ coverage
- Smart suggestions for fixes
- Built for AI-assisted development

Built with Clean Architecture and DDD principles.
Works with Claude, GPT, Copilot, Cursor, and any AI coding assistant.
This commit is contained in:
imfozilbek
2025-11-24 02:54:39 +05:00
parent 9f97509b06
commit 03705b5264
96 changed files with 9520 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
/**
* Create User Request DTO
*
* Application Layer: Input DTO
* - Validation at system boundary
* - No domain logic
* - API contract
*/
export interface CreateUserRequest {
readonly email: string
readonly firstName: string
readonly lastName: string
}

View File

@@ -0,0 +1,24 @@
/**
* Order Response DTO
*/
export interface OrderItemDto {
readonly id: string
readonly productId: string
readonly productName: string
readonly price: number
readonly currency: string
readonly quantity: number
readonly total: number
}
export interface OrderResponseDto {
readonly id: string
readonly userId: string
readonly items: OrderItemDto[]
readonly status: string
readonly subtotal: number
readonly currency: string
readonly createdAt: string
readonly confirmedAt?: string
readonly deliveredAt?: string
}

View File

@@ -0,0 +1,33 @@
/**
* User Response DTO
*
* DDD Pattern: Data Transfer Object
* - No business logic
* - Presentation layer data structure
* - Protects domain from external changes
*
* SOLID Principles:
* - SRP: only data transfer
* - ISP: client-specific interface
*
* Clean Architecture:
* - Application layer DTO
* - Maps to/from domain
* - API contracts
*
* Benefits:
* - Domain entity isolation
* - API versioning
* - Client-specific data
*/
export interface UserResponseDto {
readonly id: string
readonly email: string
readonly firstName: string
readonly lastName: string
readonly fullName: string
readonly isActive: boolean
readonly isBlocked: boolean
readonly registeredAt: string
readonly lastLoginAt?: string
}

View File

@@ -0,0 +1,41 @@
import { Order } from "../../domain/aggregates/Order"
import { OrderItemDto, OrderResponseDto } from "../dtos/OrderResponseDto"
/**
* Order Mapper
*/
export class OrderMapper {
public static toDto(order: Order): OrderResponseDto {
const total = order.calculateTotal()
return {
id: order.orderId.value,
userId: order.userId.value,
items: order.items.map((item) => OrderMapper.toItemDto(item)),
status: order.status.value,
subtotal: total.amount,
currency: total.currency,
createdAt: order.createdAt.toISOString(),
confirmedAt: order.confirmedAt?.toISOString(),
deliveredAt: order.deliveredAt?.toISOString(),
}
}
private static toItemDto(item: any): OrderItemDto {
const total = item.calculateTotal()
return {
id: item.id,
productId: item.productId,
productName: item.productName,
price: item.price.amount,
currency: item.price.currency,
quantity: item.quantity,
total: total.amount,
}
}
public static toDtoList(orders: Order[]): OrderResponseDto[] {
return orders.map((order) => OrderMapper.toDto(order))
}
}

View File

@@ -0,0 +1,44 @@
import { User } from "../../domain/aggregates/User"
import { UserResponseDto } from "../dtos/UserResponseDto"
/**
* User Mapper
*
* DDD Pattern: Mapper
* - Converts between domain and DTOs
* - Isolates domain from presentation
* - No business logic
*
* SOLID Principles:
* - SRP: only mapping
* - OCP: extend for new DTOs
*
* Clean Architecture:
* - Application layer
* - Protects domain integrity
*/
export class UserMapper {
/**
* Map domain entity to response DTO
*/
public static toDto(user: User): UserResponseDto {
return {
id: user.userId.value,
email: user.email.value,
firstName: user.firstName,
lastName: user.lastName,
fullName: user.fullName,
isActive: user.isActive,
isBlocked: user.isBlocked,
registeredAt: user.registeredAt.toISOString(),
lastLoginAt: user.lastLoginAt?.toISOString(),
}
}
/**
* Map array of entities to DTOs
*/
public static toDtoList(users: User[]): UserResponseDto[] {
return users.map((user) => UserMapper.toDto(user))
}
}

View File

@@ -0,0 +1,60 @@
import { Email } from "../../domain/value-objects/Email"
import { UserRegistrationService } from "../../domain/services/UserRegistrationService"
import { UserMapper } from "../mappers/UserMapper"
import { CreateUserRequest } from "../dtos/CreateUserRequest"
import { UserResponseDto } from "../dtos/UserResponseDto"
/**
* Use Case: CreateUser
*
* DDD Pattern: Application Service / Use Case
* - Orchestrates domain operations
* - Transaction boundary
* - Converts DTOs to domain
*
* SOLID Principles:
* - SRP: handles user creation workflow
* - DIP: depends on abstractions (UserRegistrationService)
* - OCP: can extend without modifying
*
* Clean Architecture:
* - Application layer
* - Uses domain services
* - Returns DTOs (not domain entities)
*
* Clean Code:
* - Verb+Noun naming: CreateUser
* - Single purpose
* - No business logic (delegated to domain)
*/
export class CreateUser {
constructor(private readonly userRegistrationService: UserRegistrationService) {}
public async execute(request: CreateUserRequest): Promise<UserResponseDto> {
this.validateRequest(request)
const email = Email.create(request.email)
const user = await this.userRegistrationService.registerUser(
email,
request.firstName,
request.lastName,
)
return UserMapper.toDto(user)
}
private validateRequest(request: CreateUserRequest): void {
if (!request.email?.trim()) {
throw new Error("Email is required")
}
if (!request.firstName?.trim()) {
throw new Error("First name is required")
}
if (!request.lastName?.trim()) {
throw new Error("Last name is required")
}
}
}

View File

@@ -0,0 +1,88 @@
import { OrderFactory } from "../../domain/factories/OrderFactory"
import { IOrderRepository } from "../../domain/repositories/IOrderRepository"
import { UserId } from "../../domain/value-objects/UserId"
import { Money } from "../../domain/value-objects/Money"
import { OrderMapper } from "../mappers/OrderMapper"
import { OrderResponseDto } from "../dtos/OrderResponseDto"
/**
* Place Order Request
*/
export interface PlaceOrderRequest {
readonly userId: string
readonly items: Array<{
readonly productId: string
readonly productName: string
readonly price: number
readonly currency: string
readonly quantity: number
}>
}
/**
* Use Case: PlaceOrder
*
* Application Service:
* - Orchestrates order placement
* - Transaction boundary
* - Validation at system boundary
*
* Business Flow:
* 1. Validate request
* 2. Create order with items
* 3. Confirm order
* 4. Persist order
* 5. Return DTO
*/
export class PlaceOrder {
constructor(private readonly orderRepository: IOrderRepository) {}
public async execute(request: PlaceOrderRequest): Promise<OrderResponseDto> {
this.validateRequest(request)
const userId = UserId.create(request.userId)
const items = request.items.map((item) => ({
productId: item.productId,
productName: item.productName,
price: Money.create(item.price, item.currency),
quantity: item.quantity,
}))
const order = OrderFactory.createWithItems(userId, items)
order.confirm()
await this.orderRepository.save(order)
return OrderMapper.toDto(order)
}
private validateRequest(request: PlaceOrderRequest): void {
if (!request.userId?.trim()) {
throw new Error("User ID is required")
}
if (!request.items || request.items.length === 0) {
throw new Error("Order must have at least one item")
}
for (const item of request.items) {
if (!item.productId?.trim()) {
throw new Error("Product ID is required")
}
if (!item.productName?.trim()) {
throw new Error("Product name is required")
}
if (item.price <= 0) {
throw new Error("Price must be positive")
}
if (item.quantity <= 0) {
throw new Error("Quantity must be positive")
}
}
}
}

View File

@@ -0,0 +1,263 @@
import { BaseEntity } from "../../../../src/domain/entities/BaseEntity"
import { OrderId } from "../value-objects/OrderId"
import { UserId } from "../value-objects/UserId"
import { OrderStatus } from "../value-objects/OrderStatus"
import { Money } from "../value-objects/Money"
import { OrderItem } from "../entities/OrderItem"
/**
* Order Aggregate Root
*
* DDD Patterns:
* - Aggregate Root: controls access to OrderItems
* - Consistency Boundary: all changes through Order
* - Rich Domain Model: contains business logic
*
* SOLID Principles:
* - SRP: manages order lifecycle
* - OCP: extensible through status transitions
* - ISP: focused interface for order operations
*
* Business Rules (Invariants):
* - Order must have at least one item
* - Cannot modify confirmed/paid/shipped orders
* - Status transitions must be valid
* - Total = sum of all items
* - Cannot cancel delivered orders
*
* Clean Code:
* - No magic numbers: MIN_ITEMS constant
* - Meaningful names: addItem, removeItem, confirm
* - Small methods: each does one thing
* - No hardcoded strings: OrderStatus enum
*/
export class Order extends BaseEntity {
private static readonly MIN_ITEMS = 1
private readonly _orderId: OrderId
private readonly _userId: UserId
private readonly _items: Map<string, OrderItem>
private _status: OrderStatus
private readonly _createdAt: Date
private _confirmedAt?: Date
private _deliveredAt?: Date
private constructor(
orderId: OrderId,
userId: UserId,
items: OrderItem[],
status: OrderStatus,
createdAt: Date,
confirmedAt?: Date,
deliveredAt?: Date,
) {
super(orderId.value)
this._orderId = orderId
this._userId = userId
this._items = new Map(items.map((item) => [item.id, item]))
this._status = status
this._createdAt = createdAt
this._confirmedAt = confirmedAt
this._deliveredAt = deliveredAt
this.validateInvariants()
}
/**
* Factory: Create new order
*/
public static create(userId: UserId): Order {
const orderId = OrderId.create()
const now = new Date()
return new Order(orderId, userId, [], OrderStatus.PENDING, now)
}
/**
* Factory: Reconstitute from persistence
*/
public static reconstitute(
orderId: OrderId,
userId: UserId,
items: OrderItem[],
status: OrderStatus,
createdAt: Date,
confirmedAt?: Date,
deliveredAt?: Date,
): Order {
return new Order(orderId, userId, items, status, createdAt, confirmedAt, deliveredAt)
}
/**
* Business Operation: Add item to order
*
* DDD: Only Aggregate Root can modify its entities
*/
public addItem(productId: string, productName: string, price: Money, quantity: number): void {
this.ensureCanModify()
const existingItem = Array.from(this._items.values()).find(
(item) => item.productId === productId,
)
if (existingItem) {
existingItem.updateQuantity(existingItem.quantity + quantity)
} else {
const newItem = OrderItem.create(productId, productName, price, quantity)
this._items.set(newItem.id, newItem)
}
this.touch()
}
/**
* Business Operation: Remove item from order
*/
public removeItem(itemId: string): void {
this.ensureCanModify()
if (!this._items.has(itemId)) {
throw new Error(`Item not found: ${itemId}`)
}
this._items.delete(itemId)
this.touch()
}
/**
* Business Operation: Update item quantity
*/
public updateItemQuantity(itemId: string, newQuantity: number): void {
this.ensureCanModify()
const item = this._items.get(itemId)
if (!item) {
throw new Error(`Item not found: ${itemId}`)
}
item.updateQuantity(newQuantity)
this.touch()
}
/**
* Business Operation: Confirm order
*/
public confirm(): void {
this.transitionTo(OrderStatus.CONFIRMED)
this._confirmedAt = new Date()
}
/**
* Business Operation: Mark as paid
*/
public markAsPaid(): void {
this.transitionTo(OrderStatus.PAID)
}
/**
* Business Operation: Ship order
*/
public ship(): void {
this.transitionTo(OrderStatus.SHIPPED)
}
/**
* Business Operation: Deliver order
*/
public deliver(): void {
this.transitionTo(OrderStatus.DELIVERED)
this._deliveredAt = new Date()
}
/**
* Business Operation: Cancel order
*/
public cancel(): void {
if (this._status.isDelivered()) {
throw new Error("Cannot cancel delivered order")
}
this.transitionTo(OrderStatus.CANCELLED)
}
/**
* Business Query: Calculate total
*/
public calculateTotal(): Money {
const items = Array.from(this._items.values())
if (items.length === 0) {
return Money.zero("USD")
}
return items.reduce((total, item) => total.add(item.calculateTotal()), Money.zero("USD"))
}
/**
* Business Query: Check if order can be modified
*/
public canModify(): boolean {
return this._status.isPending()
}
/**
* Getters
*/
public get orderId(): OrderId {
return this._orderId
}
public get userId(): UserId {
return this._userId
}
public get items(): readonly OrderItem[] {
return Array.from(this._items.values())
}
public get status(): OrderStatus {
return this._status
}
public get createdAt(): Date {
return this._createdAt
}
public get confirmedAt(): Date | undefined {
return this._confirmedAt
}
public get deliveredAt(): Date | undefined {
return this._deliveredAt
}
/**
* Private helpers
*/
private ensureCanModify(): void {
if (!this.canModify()) {
throw new Error(`Cannot modify order in ${this._status.value} status`)
}
}
private transitionTo(newStatus: OrderStatus): void {
if (!this._status.canTransitionTo(newStatus)) {
throw new Error(
`Invalid status transition: ${this._status.value} -> ${newStatus.value}`,
)
}
this._status = newStatus
this.touch()
}
/**
* Invariant validation
*/
private validateInvariants(): void {
if (!this._status.isPending() && this._items.size < Order.MIN_ITEMS) {
throw new Error(`Order must have at least ${Order.MIN_ITEMS} item(s)`)
}
}
}

View File

@@ -0,0 +1,251 @@
import { BaseEntity } from "../../../../src/domain/entities/BaseEntity"
import { Email } from "../value-objects/Email"
import { UserId } from "../value-objects/UserId"
import { UserCreatedEvent } from "../events/UserCreatedEvent"
/**
* User Aggregate Root
*
* DDD Patterns:
* - Aggregate Root: consistency boundary
* - Rich Domain Model: contains business logic
* - Domain Events: publishes UserCreatedEvent
*
* SOLID Principles:
* - SRP: manages user identity and state
* - OCP: extensible through events
* - DIP: depends on abstractions (Email, UserId)
*
* Business Rules (Invariants):
* - Email must be unique (enforced by repository)
* - User must have valid email
* - Blocked users cannot be activated directly
* - Only active users can be blocked
*/
export class User extends BaseEntity {
private readonly _userId: UserId
private readonly _email: Email
private readonly _firstName: string
private readonly _lastName: string
private _isActive: boolean
private _isBlocked: boolean
private readonly _registeredAt: Date
private _lastLoginAt?: Date
private constructor(
userId: UserId,
email: Email,
firstName: string,
lastName: string,
isActive: boolean,
isBlocked: boolean,
registeredAt: Date,
lastLoginAt?: Date,
) {
super(userId.value)
this._userId = userId
this._email = email
this._firstName = firstName
this._lastName = lastName
this._isActive = isActive
this._isBlocked = isBlocked
this._registeredAt = registeredAt
this._lastLoginAt = lastLoginAt
this.validateInvariants()
}
/**
* Factory method: Create new user (business operation)
*
* DDD: Named constructor that represents business intent
* Clean Code: Intention-revealing method name
*/
public static create(email: Email, firstName: string, lastName: string): User {
const userId = UserId.create()
const now = new Date()
const user = new User(userId, email, firstName, lastName, true, false, now)
user.addDomainEvent(
new UserCreatedEvent({
userId: userId.value,
email: email.value,
registeredAt: now,
}),
)
return user
}
/**
* Factory method: Reconstitute from persistence
*
* DDD: Separate creation from reconstitution
* No events raised - already happened
*/
public static reconstitute(
userId: UserId,
email: Email,
firstName: string,
lastName: string,
isActive: boolean,
isBlocked: boolean,
registeredAt: Date,
lastLoginAt?: Date,
): User {
return new User(
userId,
email,
firstName,
lastName,
isActive,
isBlocked,
registeredAt,
lastLoginAt,
)
}
/**
* Business Operation: Activate user
*
* DDD: Business logic in domain
* SOLID SRP: User manages its own state
*/
public activate(): void {
if (this._isBlocked) {
throw new Error("Cannot activate blocked user. Unblock first.")
}
if (this._isActive) {
return
}
this._isActive = true
this.touch()
}
/**
* Business Operation: Deactivate user
*/
public deactivate(): void {
if (!this._isActive) {
return
}
this._isActive = false
this.touch()
}
/**
* Business Operation: Block user
*
* Business Rule: Only active users can be blocked
*/
public block(reason: string): void {
if (!this._isActive) {
throw new Error("Cannot block inactive user")
}
if (this._isBlocked) {
return
}
this._isBlocked = true
this._isActive = false
this.touch()
}
/**
* Business Operation: Unblock user
*/
public unblock(): void {
if (!this._isBlocked) {
return
}
this._isBlocked = false
this.touch()
}
/**
* Business Operation: Record login
*/
public recordLogin(): void {
if (!this._isActive) {
throw new Error("Inactive user cannot login")
}
if (this._isBlocked) {
throw new Error("Blocked user cannot login")
}
this._lastLoginAt = new Date()
this.touch()
}
/**
* Business Query: Check if user can login
*/
public canLogin(): boolean {
return this._isActive && !this._isBlocked
}
/**
* Getters: Read-only access to state
*/
public get userId(): UserId {
return this._userId
}
public get email(): Email {
return this._email
}
public get firstName(): string {
return this._firstName
}
public get lastName(): string {
return this._lastName
}
public get fullName(): string {
return `${this._firstName} ${this._lastName}`
}
public get isActive(): boolean {
return this._isActive
}
public get isBlocked(): boolean {
return this._isBlocked
}
public get registeredAt(): Date {
return this._registeredAt
}
public get lastLoginAt(): Date | undefined {
return this._lastLoginAt
}
/**
* Invariant validation
*
* DDD: Enforce business rules
*/
private validateInvariants(): void {
if (!this._firstName?.trim()) {
throw new Error("First name is required")
}
if (!this._lastName?.trim()) {
throw new Error("Last name is required")
}
if (this._isBlocked && this._isActive) {
throw new Error("Blocked user cannot be active")
}
}
}

View File

@@ -0,0 +1,101 @@
import { BaseEntity } from "../../../../src/domain/entities/BaseEntity"
import { Money } from "../value-objects/Money"
/**
* OrderItem Entity
*
* DDD Pattern: Entity (not Aggregate Root)
* - Has identity
* - Part of Order aggregate
* - Cannot exist without Order
* - Accessed only through Order
*
* Business Rules:
* - Quantity must be positive
* - Price must be positive
* - Total = price * quantity
*/
export class OrderItem extends BaseEntity {
private readonly _productId: string
private readonly _productName: string
private readonly _price: Money
private _quantity: number
private constructor(
productId: string,
productName: string,
price: Money,
quantity: number,
id?: string,
) {
super(id)
this._productId = productId
this._productName = productName
this._price = price
this._quantity = quantity
this.validateInvariants()
}
public static create(
productId: string,
productName: string,
price: Money,
quantity: number,
): OrderItem {
return new OrderItem(productId, productName, price, quantity)
}
public static reconstitute(
productId: string,
productName: string,
price: Money,
quantity: number,
id: string,
): OrderItem {
return new OrderItem(productId, productName, price, quantity, id)
}
public updateQuantity(newQuantity: number): void {
if (newQuantity <= 0) {
throw new Error("Quantity must be positive")
}
this._quantity = newQuantity
this.touch()
}
public calculateTotal(): Money {
return this._price.multiply(this._quantity)
}
public get productId(): string {
return this._productId
}
public get productName(): string {
return this._productName
}
public get price(): Money {
return this._price
}
public get quantity(): number {
return this._quantity
}
private validateInvariants(): void {
if (!this._productId?.trim()) {
throw new Error("Product ID is required")
}
if (!this._productName?.trim()) {
throw new Error("Product name is required")
}
if (this._quantity <= 0) {
throw new Error("Quantity must be positive")
}
}
}

View File

@@ -0,0 +1,27 @@
import { DomainEvent } from "../../../../src/domain/events/DomainEvent"
/**
* Domain Event: UserCreatedEvent
*
* DDD Pattern: Domain Events
* - Represents something that happened in the domain
* - Immutable
* - Past tense naming
*
* Use cases:
* - Send welcome email (async)
* - Create user profile
* - Log user registration
* - Analytics tracking
*/
export interface UserCreatedEventPayload {
readonly userId: string
readonly email: string
readonly registeredAt: Date
}
export class UserCreatedEvent extends DomainEvent<UserCreatedEventPayload> {
constructor(payload: UserCreatedEventPayload) {
super("user.created", payload)
}
}

View File

@@ -0,0 +1,102 @@
import { Order } from "../aggregates/Order"
import { OrderId } from "../value-objects/OrderId"
import { UserId } from "../value-objects/UserId"
import { OrderStatus } from "../value-objects/OrderStatus"
import { OrderItem } from "../entities/OrderItem"
import { Money } from "../value-objects/Money"
/**
* Factory: OrderFactory
*
* DDD Pattern: Factory
* - Handles complex Order creation
* - Different creation scenarios
* - Validation and defaults
*
* Clean Code:
* - Each method has clear purpose
* - No magic values
* - Meaningful names
*/
export class OrderFactory {
/**
* Create empty order for user
*/
public static createEmptyOrder(userId: UserId): Order {
return Order.create(userId)
}
/**
* Create order with initial items
*/
public static createWithItems(
userId: UserId,
items: Array<{ productId: string; productName: string; price: Money; quantity: number }>,
): Order {
const order = Order.create(userId)
for (const item of items) {
order.addItem(item.productId, item.productName, item.price, item.quantity)
}
return order
}
/**
* Reconstitute order from persistence
*/
public static reconstitute(data: {
orderId: string
userId: string
items: Array<{
id: string
productId: string
productName: string
price: number
currency: string
quantity: number
}>
status: string
createdAt: Date
confirmedAt?: Date
deliveredAt?: Date
}): Order {
const orderId = OrderId.create(data.orderId)
const userId = UserId.create(data.userId)
const status = OrderStatus.create(data.status)
const items = data.items.map((item) =>
OrderItem.reconstitute(
item.productId,
item.productName,
Money.create(item.price, item.currency),
item.quantity,
item.id,
),
)
return Order.reconstitute(
orderId,
userId,
items,
status,
data.createdAt,
data.confirmedAt,
data.deliveredAt,
)
}
/**
* Create test order
*/
public static createTestOrder(userId?: UserId): Order {
const testUserId = userId ?? UserId.create()
const order = Order.create(testUserId)
order.addItem("test-product-1", "Test Product 1", Money.create(10, "USD"), 2)
order.addItem("test-product-2", "Test Product 2", Money.create(20, "USD"), 1)
return order
}
}

View File

@@ -0,0 +1,77 @@
import { User } from "../aggregates/User"
import { Email } from "../value-objects/Email"
import { UserId } from "../value-objects/UserId"
/**
* Factory: UserFactory
*
* DDD Pattern: Factory
* - Encapsulates complex object creation
* - Hides construction details
* - Can create from different sources
*
* SOLID Principles:
* - SRP: responsible only for creating Users
* - OCP: can add new creation methods
* - DIP: returns domain object, not DTO
*
* Use cases:
* - Create from external auth provider (OAuth, SAML)
* - Create from legacy data
* - Create with default values
* - Create test users
*/
export class UserFactory {
/**
* Create user from OAuth provider data
*/
public static createFromOAuth(
oauthEmail: string,
oauthFirstName: string,
oauthLastName: string,
): User {
const email = Email.create(oauthEmail)
const firstName = oauthFirstName.trim() || "Unknown"
const lastName = oauthLastName.trim() || "User"
return User.create(email, firstName, lastName)
}
/**
* Create user from legacy database format
*/
public static createFromLegacy(legacyData: {
id: string
email: string
full_name: string
active: number
created_timestamp: number
}): User {
const [firstName = "Unknown", lastName = "User"] = legacyData.full_name.split(" ")
const userId = UserId.create(legacyData.id)
const email = Email.create(legacyData.email)
const isActive = legacyData.active === 1
const registeredAt = new Date(legacyData.created_timestamp * 1000)
return User.reconstitute(userId, email, firstName, lastName, isActive, false, registeredAt)
}
/**
* Create test user with defaults
*/
public static createTestUser(emailSuffix: string = "test"): User {
const email = Email.create(`test-${Date.now()}@${emailSuffix}.com`)
return User.create(email, "Test", "User")
}
/**
* Create admin user
*/
public static createAdmin(email: Email, firstName: string, lastName: string): User {
const user = User.create(email, firstName, lastName)
user.activate()
return user
}
}

View File

@@ -0,0 +1,48 @@
import { Order } from "../aggregates/Order"
import { OrderId } from "../value-objects/OrderId"
import { UserId } from "../value-objects/UserId"
/**
* Order Repository Interface
*
* DDD Pattern: Repository
* - Aggregate-oriented persistence
* - Collection metaphor
* - No business logic (that's in Order aggregate)
*/
export interface IOrderRepository {
/**
* Save order (create or update)
*/
save(order: Order): Promise<void>
/**
* Find order by ID
*/
findById(id: OrderId): Promise<Order | null>
/**
* Find orders by user
*/
findByUserId(userId: UserId): Promise<Order[]>
/**
* Find orders by status
*/
findByStatus(status: string): Promise<Order[]>
/**
* Find all orders
*/
findAll(): Promise<Order[]>
/**
* Delete order
*/
delete(id: OrderId): Promise<void>
/**
* Check if order exists
*/
exists(id: OrderId): Promise<boolean>
}

View File

@@ -0,0 +1,57 @@
import { User } from "../aggregates/User"
import { UserId } from "../value-objects/UserId"
import { Email } from "../value-objects/Email"
/**
* User Repository Interface
*
* DDD Pattern: Repository
* - Interface in domain layer
* - Implementation in infrastructure layer
* - Collection-like API for aggregates
*
* SOLID Principles:
* - DIP: domain depends on abstraction
* - ISP: focused interface
* - SRP: manages User persistence
*
* Clean Architecture:
* - Domain doesn't know about DB
* - Infrastructure implements this
*/
export interface IUserRepository {
/**
* Save user (create or update)
*/
save(user: User): Promise<void>
/**
* Find user by ID
*/
findById(id: UserId): Promise<User | null>
/**
* Find user by email
*/
findByEmail(email: Email): Promise<User | null>
/**
* Find all users
*/
findAll(): Promise<User[]>
/**
* Find active users
*/
findActive(): Promise<User[]>
/**
* Delete user
*/
delete(id: UserId): Promise<void>
/**
* Check if user exists
*/
exists(id: UserId): Promise<boolean>
}

View File

@@ -0,0 +1,80 @@
import { Order } from "../aggregates/Order"
import { Money } from "../value-objects/Money"
/**
* Domain Service: PricingService
*
* DDD Pattern: Domain Service
* - Encapsulates pricing business logic
* - Pure business logic (no infrastructure)
* - Can be used by multiple aggregates
*
* Business Rules:
* - Discounts based on order total
* - Free shipping threshold
* - Tax calculation
*
* Clean Code:
* - No magic numbers: constants for thresholds
* - Clear method names
* - Single Responsibility
*/
export class PricingService {
private static readonly DISCOUNT_THRESHOLD = Money.create(100, "USD")
private static readonly DISCOUNT_PERCENTAGE = 0.1
private static readonly FREE_SHIPPING_THRESHOLD = Money.create(50, "USD")
private static readonly SHIPPING_COST = Money.create(10, "USD")
private static readonly TAX_RATE = 0.2
/**
* Calculate discount for order
*
* Business Rule: 10% discount for orders over $100
*/
public calculateDiscount(order: Order): Money {
const total = order.calculateTotal()
if (total.isGreaterThan(PricingService.DISCOUNT_THRESHOLD)) {
return total.multiply(PricingService.DISCOUNT_PERCENTAGE)
}
return Money.zero(total.currency)
}
/**
* Calculate shipping cost
*
* Business Rule: Free shipping for orders over $50
*/
public calculateShippingCost(order: Order): Money {
const total = order.calculateTotal()
if (total.isGreaterThan(PricingService.FREE_SHIPPING_THRESHOLD)) {
return Money.zero(total.currency)
}
return PricingService.SHIPPING_COST
}
/**
* Calculate tax
*
* Business Rule: 20% tax on order total
*/
public calculateTax(order: Order): Money {
const total = order.calculateTotal()
return total.multiply(PricingService.TAX_RATE)
}
/**
* Calculate final total with all costs
*/
public calculateFinalTotal(order: Order): Money {
const subtotal = order.calculateTotal()
const discount = this.calculateDiscount(order)
const shipping = this.calculateShippingCost(order)
const tax = this.calculateTax(order)
return subtotal.subtract(discount).add(shipping).add(tax)
}
}

View File

@@ -0,0 +1,63 @@
import { User } from "../aggregates/User"
import { Email } from "../value-objects/Email"
import { IUserRepository } from "../repositories/IUserRepository"
/**
* Domain Service: UserRegistrationService
*
* DDD Pattern: Domain Service
* - Encapsulates business logic that doesn't belong to a single entity
* - Coordinates multiple aggregates
* - Stateless
*
* When to use Domain Service:
* - Business logic spans multiple aggregates
* - Operation doesn't naturally fit in any entity
* - Need to check uniqueness (requires repository)
*
* SOLID Principles:
* - SRP: handles user registration logic
* - DIP: depends on IUserRepository abstraction
* - ISP: focused interface
*
* Clean Code:
* - Meaningful name: clearly registration logic
* - Small method: does one thing
* - No magic strings: clear error messages
*/
export class UserRegistrationService {
constructor(private readonly userRepository: IUserRepository) {}
/**
* Business Operation: Register new user
*
* Business Rules:
* - Email must be unique
* - User must have valid data
* - Registration creates active user
*
* @throws Error if email already exists
* @throws Error if user data is invalid
*/
public async registerUser(email: Email, firstName: string, lastName: string): Promise<User> {
const existingUser = await this.userRepository.findByEmail(email)
if (existingUser) {
throw new Error(`User with email ${email.value} already exists`)
}
const user = User.create(email, firstName, lastName)
await this.userRepository.save(user)
return user
}
/**
* Business Query: Check if email is available
*/
public async isEmailAvailable(email: Email): boolean {
const existingUser = await this.userRepository.findByEmail(email)
return !existingUser
}
}

View File

@@ -0,0 +1,52 @@
import { Specification } from "./Specification"
import { Email } from "../value-objects/Email"
/**
* Email Domain Specification
*
* Business Rule: Check if email is from corporate domain
*/
export class CorporateEmailSpecification extends Specification<Email> {
private static readonly CORPORATE_DOMAINS = ["company.com", "corp.company.com"]
public isSatisfiedBy(email: Email): boolean {
const domain = email.getDomain()
return CorporateEmailSpecification.CORPORATE_DOMAINS.includes(domain)
}
}
/**
* Email Blacklist Specification
*
* Business Rule: Check if email domain is blacklisted
*/
export class BlacklistedEmailSpecification extends Specification<Email> {
private static readonly BLACKLISTED_DOMAINS = [
"tempmail.com",
"throwaway.email",
"guerrillamail.com",
]
public isSatisfiedBy(email: Email): boolean {
const domain = email.getDomain()
return BlacklistedEmailSpecification.BLACKLISTED_DOMAINS.includes(domain)
}
}
/**
* Valid Email for Registration
*
* Composed specification: not blacklisted
*/
export class ValidEmailForRegistrationSpecification extends Specification<Email> {
private readonly notBlacklisted: Specification<Email>
constructor() {
super()
this.notBlacklisted = new BlacklistedEmailSpecification().not()
}
public isSatisfiedBy(email: Email): boolean {
return this.notBlacklisted.isSatisfiedBy(email)
}
}

View File

@@ -0,0 +1,79 @@
import { Specification } from "./Specification"
import { Order } from "../aggregates/Order"
import { Money } from "../value-objects/Money"
/**
* Order Can Be Cancelled Specification
*
* Business Rule: Order can be cancelled if not delivered
*/
export class OrderCanBeCancelledSpecification extends Specification<Order> {
public isSatisfiedBy(order: Order): boolean {
return !order.status.isDelivered()
}
}
/**
* Order Eligible For Discount Specification
*
* Business Rule: Orders over $100 get discount
*/
export class OrderEligibleForDiscountSpecification extends Specification<Order> {
private static readonly DISCOUNT_THRESHOLD = Money.create(100, "USD")
public isSatisfiedBy(order: Order): boolean {
const total = order.calculateTotal()
return total.isGreaterThan(OrderEligibleForDiscountSpecification.DISCOUNT_THRESHOLD)
}
}
/**
* Order Eligible For Free Shipping Specification
*
* Business Rule: Orders over $50 get free shipping
*/
export class OrderEligibleForFreeShippingSpecification extends Specification<Order> {
private static readonly FREE_SHIPPING_THRESHOLD = Money.create(50, "USD")
public isSatisfiedBy(order: Order): boolean {
const total = order.calculateTotal()
return total.isGreaterThan(
OrderEligibleForFreeShippingSpecification.FREE_SHIPPING_THRESHOLD,
)
}
}
/**
* High Value Order Specification
*
* Business Rule: Orders over $500 are high value
* (might need special handling, insurance, etc.)
*/
export class HighValueOrderSpecification extends Specification<Order> {
private static readonly HIGH_VALUE_THRESHOLD = Money.create(500, "USD")
public isSatisfiedBy(order: Order): boolean {
const total = order.calculateTotal()
return total.isGreaterThan(HighValueOrderSpecification.HIGH_VALUE_THRESHOLD)
}
}
/**
* Composed Specification: Premium Order
*
* Premium = High Value AND Eligible for Discount
*/
export class PremiumOrderSpecification extends Specification<Order> {
private readonly spec: Specification<Order>
constructor() {
super()
this.spec = new HighValueOrderSpecification().and(
new OrderEligibleForDiscountSpecification(),
)
}
public isSatisfiedBy(order: Order): boolean {
return this.spec.isSatisfiedBy(order)
}
}

View File

@@ -0,0 +1,92 @@
/**
* Specification Pattern (base class)
*
* DDD Pattern: Specification
* - Encapsulates business rules
* - Reusable predicates
* - Combinable (AND, OR, NOT)
* - Testable in isolation
*
* SOLID Principles:
* - SRP: each specification has one rule
* - OCP: extend by creating new specifications
* - LSP: all specifications are substitutable
*
* Benefits:
* - Business rules as first-class citizens
* - Reusable across use cases
* - Easy to test
* - Can be combined
*/
export abstract class Specification<T> {
/**
* Check if entity satisfies specification
*/
public abstract isSatisfiedBy(entity: T): boolean
/**
* Combine specifications with AND
*/
public and(other: Specification<T>): Specification<T> {
return new AndSpecification(this, other)
}
/**
* Combine specifications with OR
*/
public or(other: Specification<T>): Specification<T> {
return new OrSpecification(this, other)
}
/**
* Negate specification
*/
public not(): Specification<T> {
return new NotSpecification(this)
}
}
/**
* AND Specification
*/
class AndSpecification<T> extends Specification<T> {
constructor(
private readonly left: Specification<T>,
private readonly right: Specification<T>,
) {
super()
}
public isSatisfiedBy(entity: T): boolean {
return this.left.isSatisfiedBy(entity) && this.right.isSatisfiedBy(entity)
}
}
/**
* OR Specification
*/
class OrSpecification<T> extends Specification<T> {
constructor(
private readonly left: Specification<T>,
private readonly right: Specification<T>,
) {
super()
}
public isSatisfiedBy(entity: T): boolean {
return this.left.isSatisfiedBy(entity) || this.right.isSatisfiedBy(entity)
}
}
/**
* NOT Specification
*/
class NotSpecification<T> extends Specification<T> {
constructor(private readonly spec: Specification<T>) {
super()
}
public isSatisfiedBy(entity: T): boolean {
return !this.spec.isSatisfiedBy(entity)
}
}

View File

@@ -0,0 +1,62 @@
import { ValueObject } from "../../../../src/domain/value-objects/ValueObject"
interface EmailProps {
readonly value: string
}
/**
* Email Value Object
*
* DDD Pattern: Value Object
* - Immutable
* - Self-validating
* - No identity
* - Equality by value
*
* Clean Code:
* - Single Responsibility: represents email
* - Meaningful name: clearly email
* - No magic values: validation rules as constants
*/
export class Email extends ValueObject<EmailProps> {
private static readonly EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
private static readonly MAX_LENGTH = 255
private constructor(props: EmailProps) {
super(props)
}
public static create(email: string): Email {
const trimmed = email.trim().toLowerCase()
if (!trimmed) {
throw new Error("Email cannot be empty")
}
if (trimmed.length > Email.MAX_LENGTH) {
throw new Error(`Email must be less than ${Email.MAX_LENGTH} characters`)
}
if (!Email.EMAIL_REGEX.test(trimmed)) {
throw new Error(`Invalid email format: ${email}`)
}
return new Email({ value: trimmed })
}
public get value(): string {
return this.props.value
}
public getDomain(): string {
return this.props.value.split("@")[1]
}
public isFromDomain(domain: string): boolean {
return this.getDomain() === domain.toLowerCase()
}
public toString(): string {
return this.props.value
}
}

View File

@@ -0,0 +1,107 @@
import { ValueObject } from "../../../../src/domain/value-objects/ValueObject"
interface MoneyProps {
readonly amount: number
readonly currency: string
}
/**
* Money Value Object
*
* DDD Pattern: Value Object
* - Encapsulates amount + currency
* - Immutable
* - Rich behavior (add, subtract, compare)
*
* Prevents common bugs:
* - Adding different currencies
* - Negative amounts (when not allowed)
* - Floating point precision issues
*/
export class Money extends ValueObject<MoneyProps> {
private static readonly SUPPORTED_CURRENCIES = ["USD", "EUR", "GBP", "RUB"]
private static readonly DECIMAL_PLACES = 2
private constructor(props: MoneyProps) {
super(props)
}
public static create(amount: number, currency: string): Money {
const upperCurrency = currency.toUpperCase()
if (!Money.SUPPORTED_CURRENCIES.includes(upperCurrency)) {
throw new Error(
`Unsupported currency: ${currency}. Supported: ${Money.SUPPORTED_CURRENCIES.join(", ")}`,
)
}
if (amount < 0) {
throw new Error("Money amount cannot be negative")
}
const rounded = Math.round(amount * 100) / 100
return new Money({ amount: rounded, currency: upperCurrency })
}
public static zero(currency: string): Money {
return Money.create(0, currency)
}
public get amount(): number {
return this.props.amount
}
public get currency(): string {
return this.props.currency
}
public add(other: Money): Money {
this.ensureSameCurrency(other)
return Money.create(this.amount + other.amount, this.currency)
}
public subtract(other: Money): Money {
this.ensureSameCurrency(other)
const result = this.amount - other.amount
if (result < 0) {
throw new Error("Cannot subtract: result would be negative")
}
return Money.create(result, this.currency)
}
public multiply(multiplier: number): Money {
if (multiplier < 0) {
throw new Error("Multiplier cannot be negative")
}
return Money.create(this.amount * multiplier, this.currency)
}
public isGreaterThan(other: Money): boolean {
this.ensureSameCurrency(other)
return this.amount > other.amount
}
public isLessThan(other: Money): boolean {
this.ensureSameCurrency(other)
return this.amount < other.amount
}
public isZero(): boolean {
return this.amount === 0
}
private ensureSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new Error(
`Cannot operate on different currencies: ${this.currency} vs ${other.currency}`,
)
}
}
public toString(): string {
return `${this.amount.toFixed(Money.DECIMAL_PLACES)} ${this.currency}`
}
}

View File

@@ -0,0 +1,35 @@
import { ValueObject } from "../../../../src/domain/value-objects/ValueObject"
import { v4 as uuidv4, validate as uuidValidate } from "uuid"
interface OrderIdProps {
readonly value: string
}
/**
* OrderId Value Object
*
* Type safety: cannot mix with UserId
*/
export class OrderId extends ValueObject<OrderIdProps> {
private constructor(props: OrderIdProps) {
super(props)
}
public static create(id?: string): OrderId {
const value = id ?? uuidv4()
if (!uuidValidate(value)) {
throw new Error(`Invalid OrderId format: ${value}`)
}
return new OrderId({ value })
}
public get value(): string {
return this.props.value
}
public toString(): string {
return this.props.value
}
}

View File

@@ -0,0 +1,92 @@
import { ValueObject } from "../../../../src/domain/value-objects/ValueObject"
interface OrderStatusProps {
readonly value: string
}
/**
* OrderStatus Value Object
*
* DDD Pattern: Enum as Value Object
* - Type-safe status
* - Business logic: valid transitions
* - Self-validating
*/
export class OrderStatus extends ValueObject<OrderStatusProps> {
public static readonly PENDING = new OrderStatus({ value: "pending" })
public static readonly CONFIRMED = new OrderStatus({ value: "confirmed" })
public static readonly PAID = new OrderStatus({ value: "paid" })
public static readonly SHIPPED = new OrderStatus({ value: "shipped" })
public static readonly DELIVERED = new OrderStatus({ value: "delivered" })
public static readonly CANCELLED = new OrderStatus({ value: "cancelled" })
private static readonly VALID_STATUSES = [
"pending",
"confirmed",
"paid",
"shipped",
"delivered",
"cancelled",
]
private constructor(props: OrderStatusProps) {
super(props)
}
public static create(status: string): OrderStatus {
const lower = status.toLowerCase()
if (!OrderStatus.VALID_STATUSES.includes(lower)) {
throw new Error(
`Invalid order status: ${status}. Valid: ${OrderStatus.VALID_STATUSES.join(", ")}`,
)
}
return new OrderStatus({ value: lower })
}
public get value(): string {
return this.props.value
}
/**
* Business Rule: Valid status transitions
*/
public canTransitionTo(newStatus: OrderStatus): boolean {
const transitions: Record<string, string[]> = {
pending: ["confirmed", "cancelled"],
confirmed: ["paid", "cancelled"],
paid: ["shipped", "cancelled"],
shipped: ["delivered"],
delivered: [],
cancelled: [],
}
const allowedTransitions = transitions[this.value] ?? []
return allowedTransitions.includes(newStatus.value)
}
public isPending(): boolean {
return this.value === "pending"
}
public isConfirmed(): boolean {
return this.value === "confirmed"
}
public isCancelled(): boolean {
return this.value === "cancelled"
}
public isDelivered(): boolean {
return this.value === "delivered"
}
public isFinal(): boolean {
return this.isDelivered() || this.isCancelled()
}
public toString(): string {
return this.props.value
}
}

View File

@@ -0,0 +1,43 @@
import { ValueObject } from "../../../../src/domain/value-objects/ValueObject"
import { v4 as uuidv4, validate as uuidValidate } from "uuid"
interface UserIdProps {
readonly value: string
}
/**
* UserId Value Object
*
* DDD Pattern: Identity Value Object
* - Strongly typed ID (not just string)
* - Self-validating
* - Type safety: can't mix with OrderId
*
* Benefits:
* - No accidental ID mixing: `findUser(orderId)` won't compile
* - Clear intent in code
* - Encapsulated validation
*/
export class UserId extends ValueObject<UserIdProps> {
private constructor(props: UserIdProps) {
super(props)
}
public static create(id?: string): UserId {
const value = id ?? uuidv4()
if (!uuidValidate(value)) {
throw new Error(`Invalid UserId format: ${value}`)
}
return new UserId({ value })
}
public get value(): string {
return this.props.value
}
public toString(): string {
return this.props.value
}
}

View File

@@ -0,0 +1,32 @@
import { PlaceOrder, PlaceOrderRequest } from "../../application/use-cases/PlaceOrder"
import { OrderResponseDto } from "../../application/dtos/OrderResponseDto"
/**
* Order Controller
*
* Infrastructure Layer: HTTP Controller
* - No business logic
* - Returns DTOs (not domain entities!)
* - Delegates to use cases
*/
export class OrderController {
constructor(private readonly placeOrder: PlaceOrder) {}
/**
* POST /orders
*
* ✅ Good: Returns DTO
* ✅ Good: Delegates to use case
* ✅ Good: No business logic
*/
public async placeOrder(request: PlaceOrderRequest): Promise<OrderResponseDto> {
try {
return await this.placeOrder.execute(request)
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to place order: ${error.message}`)
}
throw error
}
}
}

View File

@@ -0,0 +1,46 @@
import { CreateUser } from "../../application/use-cases/CreateUser"
import { CreateUserRequest } from "../../application/dtos/CreateUserRequest"
import { UserResponseDto } from "../../application/dtos/UserResponseDto"
/**
* User Controller
*
* Clean Architecture: Infrastructure / Presentation Layer
* - HTTP concerns (not in use case)
* - Request/Response handling
* - Error handling
* - Delegates to use cases
*
* SOLID Principles:
* - SRP: HTTP handling only
* - DIP: depends on use case abstraction
* - OCP: can add new endpoints
*
* Important:
* - NO business logic here
* - NO domain entities exposed
* - Returns DTOs only
* - Use cases do the work
*/
export class UserController {
constructor(private readonly createUser: CreateUser) {}
/**
* POST /users
*
* Clean Code:
* - Returns DTO, not domain entity
* - Delegates to use case
* - Focused method
*/
public async createUser(request: CreateUserRequest): Promise<UserResponseDto> {
try {
return await this.createUser.execute(request)
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to create user: ${error.message}`)
}
throw error
}
}
}

View File

@@ -0,0 +1,45 @@
import { IOrderRepository } from "../../domain/repositories/IOrderRepository"
import { Order } from "../../domain/aggregates/Order"
import { OrderId } from "../../domain/value-objects/OrderId"
import { UserId } from "../../domain/value-objects/UserId"
/**
* In-Memory Order Repository
*/
export class InMemoryOrderRepository implements IOrderRepository {
private readonly orders: Map<string, Order> = new Map()
public async save(order: Order): Promise<void> {
this.orders.set(order.orderId.value, order)
}
public async findById(id: OrderId): Promise<Order | null> {
return this.orders.get(id.value) ?? null
}
public async findByUserId(userId: UserId): Promise<Order[]> {
return Array.from(this.orders.values()).filter(
(order) => order.userId.value === userId.value,
)
}
public async findByStatus(status: string): Promise<Order[]> {
return Array.from(this.orders.values()).filter((order) => order.status.value === status)
}
public async findAll(): Promise<Order[]> {
return Array.from(this.orders.values())
}
public async delete(id: OrderId): Promise<void> {
this.orders.delete(id.value)
}
public async exists(id: OrderId): Promise<boolean> {
return this.orders.has(id.value)
}
public clear(): void {
this.orders.clear()
}
}

View File

@@ -0,0 +1,63 @@
import { IUserRepository } from "../../domain/repositories/IUserRepository"
import { User } from "../../domain/aggregates/User"
import { UserId } from "../../domain/value-objects/UserId"
import { Email } from "../../domain/value-objects/Email"
/**
* In-Memory User Repository
*
* DDD Pattern: Repository Implementation
* - Implements domain interface
* - Infrastructure concern
* - Can be replaced with real DB
*
* SOLID Principles:
* - DIP: implements abstraction from domain
* - SRP: manages User persistence
* - LSP: substitutable with other implementations
*
* Clean Architecture:
* - Infrastructure layer
* - Depends on domain
* - Can be swapped (in-memory, Postgres, MongoDB)
*
* Use cases:
* - Testing
* - Development
* - Prototyping
*/
export class InMemoryUserRepository implements IUserRepository {
private readonly users: Map<string, User> = new Map()
public async save(user: User): Promise<void> {
this.users.set(user.userId.value, user)
}
public async findById(id: UserId): Promise<User | null> {
return this.users.get(id.value) ?? null
}
public async findByEmail(email: Email): Promise<User | null> {
return Array.from(this.users.values()).find((user) => user.email.equals(email)) ?? null
}
public async findAll(): Promise<User[]> {
return Array.from(this.users.values())
}
public async findActive(): Promise<User[]> {
return Array.from(this.users.values()).filter((user) => user.isActive)
}
public async delete(id: UserId): Promise<void> {
this.users.delete(id.value)
}
public async exists(id: UserId): Promise<boolean> {
return this.users.has(id.value)
}
public clear(): void {
this.users.clear()
}
}