feat: add dependency direction enforcement (v0.4.0)

Implement dependency direction detection to enforce Clean Architecture rules:
- Domain layer can only import from Domain and Shared
- Application layer can only import from Domain, Application, and Shared
- Infrastructure layer can import from all layers
- Shared layer can be imported by all layers

Added:
- IDependencyDirectionDetector interface in domain layer
- DependencyViolation value object with detailed suggestions and examples
- DependencyDirectionDetector implementation in infrastructure
- Integration with AnalyzeProject use case
- New DEPENDENCY_DIRECTION rule in constants
- 43 comprehensive tests covering all scenarios (100% passing)
- Good and bad examples in examples directory

Improvements:
- Optimized extractLayerFromImport method to reduce complexity
- Fixed indentation in DependencyGraph.ts
- Updated getExampleFix to avoid false positives in old detector

Test Results:
- All 261 tests passing
- Build successful
- Self-check: 0 architecture violations in src code
This commit is contained in:
imfozilbek
2025-11-24 18:31:41 +05:00
parent f46048172f
commit 3fecc98676
15 changed files with 1452 additions and 14 deletions

View File

@@ -0,0 +1,91 @@
/**
* ✅ GOOD: Application layer with correct dependencies
*
* Application should only import from:
* - Domain layer
* - Other application files
* - Shared utilities
*
* Application should NOT import from:
* - Infrastructure layer
*/
import { User } from "../domain/entities/User"
import { Email } from "../domain/value-objects/Email"
import { UserId } from "../domain/value-objects/UserId"
import { IUserRepository } from "../domain/repositories/IUserRepository"
import { Result } from "../../../src/shared/types/Result"
/**
* ✅ Use case depends on domain interfaces (IUserRepository)
* NOT on infrastructure implementations
*/
export class CreateUser {
constructor(private readonly userRepo: IUserRepository) {}
async execute(request: CreateUserRequest): Promise<Result<UserResponseDto>> {
const emailResult = Email.create(request.email)
if (emailResult.isFailure) {
return Result.fail(emailResult.error)
}
const userId = UserId.generate()
const user = new User(userId, emailResult.value)
await this.userRepo.save(user)
return Result.ok(UserMapper.toDto(user))
}
}
/**
* ✅ DTO in application layer
*/
export interface CreateUserRequest {
email: string
name: string
}
export interface UserResponseDto {
id: string
email: string
createdAt: string
}
/**
* ✅ Mapper in application layer converting domain to DTO
*/
export class UserMapper {
static toDto(user: User): UserResponseDto {
return {
id: user.getId().getValue(),
email: user.getEmail().getValue(),
createdAt: user.getCreatedAt().toISOString(),
}
}
}
/**
* ✅ Application defines Port (interface) for email service
* Infrastructure will provide the Adapter (implementation)
*/
export interface IEmailService {
sendWelcomeEmail(email: string): Promise<void>
}
export class SendWelcomeEmail {
constructor(
private readonly userRepo: IUserRepository,
private readonly emailService: IEmailService,
) {}
async execute(userId: string): Promise<Result<void>> {
const user = await this.userRepo.findById(UserId.from(userId))
if (!user) {
return Result.fail("User not found")
}
await this.emailService.sendWelcomeEmail(user.getEmail().getValue())
return Result.ok()
}
}

View File

@@ -0,0 +1,56 @@
/**
* ✅ GOOD: Domain layer with correct dependencies
*
* Domain should only import from:
* - Other domain files
* - Shared utilities
*
* Domain should NOT import from:
* - Application layer
* - Infrastructure layer
*/
import { Email } from "../domain/value-objects/Email"
import { UserId } from "../domain/value-objects/UserId"
import { Result } from "../../../src/shared/types/Result"
/**
* ✅ Domain entity using only domain value objects and shared types
*/
export class User {
private readonly id: UserId
private email: Email
private readonly createdAt: Date
constructor(id: UserId, email: Email, createdAt: Date = new Date()) {
this.id = id
this.email = email
this.createdAt = createdAt
}
public getId(): UserId {
return this.id
}
public getEmail(): Email {
return this.email
}
public changeEmail(newEmail: Email): Result<void> {
if (this.email.equals(newEmail)) {
return Result.fail("Email is the same")
}
this.email = newEmail
return Result.ok()
}
}
/**
* ✅ Domain repository interface (not importing from infrastructure)
*/
export interface IUserRepository {
findById(id: UserId): Promise<User | null>
save(user: User): Promise<void>
delete(id: UserId): Promise<void>
}

View File

@@ -0,0 +1,128 @@
/**
* ✅ GOOD: Infrastructure layer with correct dependencies
*
* Infrastructure CAN import from:
* - Domain layer
* - Application layer
* - Other infrastructure files
* - Shared utilities
* - External libraries (ORM, frameworks, etc.)
*/
import { User } from "../domain/entities/User"
import { UserId } from "../domain/value-objects/UserId"
import { Email } from "../domain/value-objects/Email"
import { IUserRepository } from "../domain/repositories/IUserRepository"
import { CreateUser } from "../application/use-cases/CreateUser"
import { UserResponseDto } from "../application/dtos/UserResponseDto"
import { IEmailService } from "../application/ports/IEmailService"
/**
* ✅ Infrastructure implements domain interface
*/
export class InMemoryUserRepository implements IUserRepository {
private users: Map<string, User> = new Map()
async findById(id: UserId): Promise<User | null> {
return this.users.get(id.getValue()) ?? null
}
async save(user: User): Promise<void> {
this.users.set(user.getId().getValue(), user)
}
async delete(id: UserId): Promise<void> {
this.users.delete(id.getValue())
}
}
/**
* ✅ Infrastructure provides Adapter implementing application Port
*/
export class SmtpEmailService implements IEmailService {
constructor(
private readonly host: string,
private readonly port: number,
) {}
async sendWelcomeEmail(email: string): Promise<void> {
console.log(`Sending welcome email to ${email} via SMTP`)
}
}
/**
* ✅ Controller uses application use cases and DTOs
*/
export class UserController {
constructor(private readonly createUser: CreateUser) {}
async create(request: { email: string; name: string }): Promise<UserResponseDto> {
const result = await this.createUser.execute(request)
if (result.isFailure) {
throw new Error(result.error)
}
return result.value
}
}
/**
* ✅ Infrastructure can use external frameworks
*/
import express from "express"
export class ExpressServer {
private app = express()
constructor(private readonly userController: UserController) {
this.setupRoutes()
}
private setupRoutes(): void {
this.app.post("/users", async (req, res) => {
const user = await this.userController.create(req.body)
res.json(user)
})
}
}
/**
* ✅ Infrastructure can use ORM
*/
import { PrismaClient } from "@prisma/client"
export class PrismaUserRepository implements IUserRepository {
constructor(private readonly prisma: PrismaClient) {}
async findById(id: UserId): Promise<User | null> {
const userData = await this.prisma.user.findUnique({
where: { id: id.getValue() },
})
if (!userData) {
return null
}
return new User(UserId.from(userData.id), Email.create(userData.email).value)
}
async save(user: User): Promise<void> {
await this.prisma.user.upsert({
where: { id: user.getId().getValue() },
create: {
id: user.getId().getValue(),
email: user.getEmail().getValue(),
},
update: {
email: user.getEmail().getValue(),
},
})
}
async delete(id: UserId): Promise<void> {
await this.prisma.user.delete({
where: { id: id.getValue() },
})
}
}