feat: add aggregate boundary validation (v0.7.0)

Implement DDD aggregate boundary validation to detect and prevent direct
entity references across aggregate boundaries.

Features:
- Detect direct entity imports between aggregates
- Allow only ID or Value Object references
- Support multiple folder structures (domain/aggregates/*, domain/*, domain/entities/*)
- Filter allowed imports (value-objects, events, repositories, services)
- Critical severity level for violations
- 41 comprehensive tests with 92.55% coverage
- CLI output with detailed suggestions
- Examples of good and bad patterns

Breaking changes: None
Backwards compatible: Yes
This commit is contained in:
imfozilbek
2025-11-24 23:54:16 +05:00
parent 83b5dccee4
commit c75738ba51
16 changed files with 1297 additions and 12 deletions

View File

@@ -0,0 +1,538 @@
import { describe, it, expect } from "vitest"
import { AggregateBoundaryDetector } from "../src/infrastructure/analyzers/AggregateBoundaryDetector"
import { LAYERS } from "../src/shared/constants/rules"
describe("AggregateBoundaryDetector", () => {
const detector = new AggregateBoundaryDetector()
describe("extractAggregateFromPath", () => {
it("should extract aggregate from domain/aggregates/name path", () => {
expect(detector.extractAggregateFromPath("src/domain/aggregates/order/Order.ts")).toBe(
"order",
)
expect(detector.extractAggregateFromPath("src/domain/aggregates/user/User.ts")).toBe(
"user",
)
expect(
detector.extractAggregateFromPath("src/domain/aggregates/product/Product.ts"),
).toBe("product")
})
it("should extract aggregate from domain/name path", () => {
expect(detector.extractAggregateFromPath("src/domain/order/Order.ts")).toBe("order")
expect(detector.extractAggregateFromPath("src/domain/user/User.ts")).toBe("user")
expect(detector.extractAggregateFromPath("src/domain/cart/ShoppingCart.ts")).toBe(
"cart",
)
})
it("should extract aggregate from domain/entities/name path", () => {
expect(detector.extractAggregateFromPath("src/domain/entities/order/Order.ts")).toBe(
"order",
)
expect(detector.extractAggregateFromPath("src/domain/entities/user/User.ts")).toBe(
"user",
)
})
it("should return undefined for non-domain paths", () => {
expect(
detector.extractAggregateFromPath("src/application/use-cases/CreateUser.ts"),
).toBeUndefined()
expect(
detector.extractAggregateFromPath(
"src/infrastructure/repositories/UserRepository.ts",
),
).toBeUndefined()
expect(detector.extractAggregateFromPath("src/shared/types/Result.ts")).toBeUndefined()
})
it("should return undefined for paths without aggregate structure", () => {
expect(detector.extractAggregateFromPath("src/domain/User.ts")).toBeUndefined()
expect(detector.extractAggregateFromPath("src/User.ts")).toBeUndefined()
})
it("should handle Windows-style paths", () => {
expect(
detector.extractAggregateFromPath("src\\domain\\aggregates\\order\\Order.ts"),
).toBe("order")
expect(detector.extractAggregateFromPath("src\\domain\\user\\User.ts")).toBe("user")
})
})
describe("isAggregateBoundaryViolation", () => {
it("should detect direct entity import from another aggregate", () => {
expect(detector.isAggregateBoundaryViolation("../user/User", "order")).toBe(true)
expect(detector.isAggregateBoundaryViolation("../../user/User", "order")).toBe(true)
expect(
detector.isAggregateBoundaryViolation("../../../domain/user/User", "order"),
).toBe(true)
})
it("should NOT detect import from same aggregate", () => {
expect(detector.isAggregateBoundaryViolation("../order/Order", "order")).toBe(false)
expect(detector.isAggregateBoundaryViolation("./OrderItem", "order")).toBe(false)
})
it("should NOT detect value object imports", () => {
expect(
detector.isAggregateBoundaryViolation("../user/value-objects/UserId", "order"),
).toBe(false)
expect(detector.isAggregateBoundaryViolation("../user/vo/Email", "order")).toBe(false)
})
it("should NOT detect event imports", () => {
expect(
detector.isAggregateBoundaryViolation("../user/events/UserCreatedEvent", "order"),
).toBe(false)
expect(
detector.isAggregateBoundaryViolation(
"../user/domain-events/UserRegisteredEvent",
"order",
),
).toBe(false)
})
it("should NOT detect repository interface imports", () => {
expect(
detector.isAggregateBoundaryViolation(
"../user/repositories/IUserRepository",
"order",
),
).toBe(false)
})
it("should NOT detect service imports", () => {
expect(
detector.isAggregateBoundaryViolation("../user/services/UserService", "order"),
).toBe(false)
})
it("should NOT detect external package imports", () => {
expect(detector.isAggregateBoundaryViolation("express", "order")).toBe(false)
expect(detector.isAggregateBoundaryViolation("@nestjs/common", "order")).toBe(false)
})
it("should NOT detect imports without path separator", () => {
expect(detector.isAggregateBoundaryViolation("User", "order")).toBe(false)
})
})
describe("detectViolations", () => {
describe("Domain layer aggregate boundary violations", () => {
it("should detect direct entity import from another aggregate", () => {
const code = `
import { User } from '../user/User'
export class Order {
constructor(private user: User) {}
}`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/order/Order.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(1)
expect(violations[0].fromAggregate).toBe("order")
expect(violations[0].toAggregate).toBe("user")
expect(violations[0].entityName).toBe("User")
expect(violations[0].importPath).toBe("../user/User")
expect(violations[0].line).toBe(2)
})
it("should detect multiple entity imports from different aggregates", () => {
const code = `
import { User } from '../user/User'
import { Product } from '../product/Product'
import { Category } from '../catalog/Category'
export class Order {
constructor(
private user: User,
private product: Product,
private category: Category
) {}
}`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/order/Order.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(3)
expect(violations[0].entityName).toBe("User")
expect(violations[1].entityName).toBe("Product")
expect(violations[2].entityName).toBe("Category")
})
it("should NOT detect value object imports", () => {
const code = `
import { UserId } from '../user/value-objects/UserId'
import { ProductId } from '../product/value-objects/ProductId'
export class Order {
constructor(
private userId: UserId,
private productId: ProductId
) {}
}`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/order/Order.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(0)
})
it("should NOT detect event imports", () => {
const code = `
import { UserCreatedEvent } from '../user/events/UserCreatedEvent'
import { ProductAddedEvent } from '../product/domain-events/ProductAddedEvent'
export class Order {
handle(event: UserCreatedEvent): void {}
}`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/order/Order.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(0)
})
it("should NOT detect repository interface imports", () => {
const code = `
import { IUserRepository } from '../user/repositories/IUserRepository'
export class OrderService {
constructor(private userRepo: IUserRepository) {}
}`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/order/OrderService.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(0)
})
it("should NOT detect imports from same aggregate", () => {
const code = `
import { OrderItem } from './OrderItem'
import { OrderStatus } from './value-objects/OrderStatus'
export class Order {
constructor(
private items: OrderItem[],
private status: OrderStatus
) {}
}`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/order/Order.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(0)
})
})
describe("Non-domain layers", () => {
it("should return empty array for application layer", () => {
const code = `
import { User } from '../../domain/aggregates/user/User'
import { Order } from '../../domain/aggregates/order/Order'
export class CreateOrder {
constructor() {}
}`
const violations = detector.detectViolations(
code,
"src/application/use-cases/CreateOrder.ts",
LAYERS.APPLICATION,
)
expect(violations).toHaveLength(0)
})
it("should return empty array for infrastructure layer", () => {
const code = `
import { User } from '../../domain/aggregates/user/User'
export class UserController {
constructor() {}
}`
const violations = detector.detectViolations(
code,
"src/infrastructure/controllers/UserController.ts",
LAYERS.INFRASTRUCTURE,
)
expect(violations).toHaveLength(0)
})
it("should return empty array for undefined layer", () => {
const code = `import { User } from '../user/User'`
const violations = detector.detectViolations(code, "src/utils/helper.ts", undefined)
expect(violations).toHaveLength(0)
})
})
describe("Import statement formats", () => {
it("should detect violations in named imports", () => {
const code = `import { User, UserProfile } from '../user/User'`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/order/Order.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(1)
})
it("should detect violations in default imports", () => {
const code = `import User from '../user/User'`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/order/Order.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(1)
})
it("should detect violations in namespace imports", () => {
const code = `import * as UserAggregate from '../user/User'`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/order/Order.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(1)
})
it("should detect violations in require statements", () => {
const code = `const User = require('../user/User')`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/order/Order.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(1)
})
})
describe("Different path structures", () => {
it("should detect violations in domain/aggregates/name structure", () => {
const code = `import { User } from '../user/User'`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/order/Order.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(1)
expect(violations[0].fromAggregate).toBe("order")
expect(violations[0].toAggregate).toBe("user")
})
it("should detect violations in domain/name structure", () => {
const code = `import { User } from '../user/User'`
const violations = detector.detectViolations(
code,
"src/domain/order/Order.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(1)
expect(violations[0].fromAggregate).toBe("order")
expect(violations[0].toAggregate).toBe("user")
})
it("should detect violations in domain/entities/name structure", () => {
const code = `import { User } from '../../user/User'`
const violations = detector.detectViolations(
code,
"src/domain/entities/order/Order.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(1)
expect(violations[0].fromAggregate).toBe("order")
expect(violations[0].toAggregate).toBe("user")
})
})
describe("Edge cases", () => {
it("should handle empty code", () => {
const violations = detector.detectViolations(
"",
"src/domain/aggregates/order/Order.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(0)
})
it("should handle code with no imports", () => {
const code = `
export class Order {
constructor(private id: string) {}
}`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/order/Order.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(0)
})
it("should handle file without aggregate in path", () => {
const code = `import { User } from '../user/User'`
const violations = detector.detectViolations(
code,
"src/domain/Order.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(0)
})
it("should handle comments in imports", () => {
const code = `
// This is a comment
import { User } from '../user/User' // Bad import
`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/order/Order.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(1)
})
})
describe("getMessage", () => {
it("should return correct violation message", () => {
const code = `import { User } from '../user/User'`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/order/Order.ts",
LAYERS.DOMAIN,
)
expect(violations[0].getMessage()).toBe(
"Order aggregate should not directly reference User entity from User aggregate",
)
})
it("should capitalize aggregate names in message", () => {
const code = `import { Product } from '../product/Product'`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/cart/ShoppingCart.ts",
LAYERS.DOMAIN,
)
expect(violations[0].getMessage()).toContain("Cart aggregate")
expect(violations[0].getMessage()).toContain("Product aggregate")
})
})
describe("getSuggestion", () => {
it("should return suggestions for fixing aggregate boundary violations", () => {
const code = `import { User } from '../user/User'`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/order/Order.ts",
LAYERS.DOMAIN,
)
const suggestion = violations[0].getSuggestion()
expect(suggestion).toContain("Reference other aggregates by ID")
expect(suggestion).toContain("Use Value Objects")
expect(suggestion).toContain("Avoid direct entity references")
expect(suggestion).toContain("independently modifiable")
})
})
describe("getExampleFix", () => {
it("should return example fix for aggregate boundary violation", () => {
const code = `import { User } from '../user/User'`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/order/Order.ts",
LAYERS.DOMAIN,
)
const example = violations[0].getExampleFix()
expect(example).toContain("// ❌ Bad")
expect(example).toContain("// ✅ Good")
expect(example).toContain("UserId")
expect(example).toContain("CustomerInfo")
})
})
})
describe("Complex scenarios", () => {
it("should detect mixed valid and invalid imports", () => {
const code = `
import { User } from '../user/User' // VIOLATION
import { UserId } from '../user/value-objects/UserId' // OK
import { Product } from '../product/Product' // VIOLATION
import { ProductId } from '../product/value-objects/ProductId' // OK
import { OrderItem } from './OrderItem' // OK - same aggregate
export class Order {
constructor(
private user: User,
private userId: UserId,
private product: Product,
private productId: ProductId,
private items: OrderItem[]
) {}
}`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/order/Order.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(2)
expect(violations[0].entityName).toBe("User")
expect(violations[1].entityName).toBe("Product")
})
it("should handle deeply nested import paths", () => {
const code = `import { User } from '../../../domain/aggregates/user/entities/User'`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/order/Order.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(1)
expect(violations[0].entityName).toBe("User")
})
it("should detect violations with .ts extension in import", () => {
const code = `import { User } from '../user/User.ts'`
const violations = detector.detectViolations(
code,
"src/domain/aggregates/order/Order.ts",
LAYERS.DOMAIN,
)
expect(violations).toHaveLength(1)
expect(violations[0].entityName).toBe("User")
})
})
})