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

@@ -72,6 +72,15 @@ Code quality guardian for vibe coders and enterprise teams - because AI writes f
- Prevents "new Repository()" anti-pattern
- 📚 *Based on: Martin Fowler's Repository Pattern, DDD (Evans 2003)* → [Why?](./docs/WHY.md#repository-pattern)
🔒 **Aggregate Boundary Validation** ✨ NEW
- Detects direct entity references across DDD aggregates
- Enforces reference-by-ID or Value Object pattern
- Prevents tight coupling between aggregates
- Supports multiple folder structures (domain/aggregates/*, domain/*, domain/entities/*)
- Filters allowed imports (value-objects, events, repositories, services)
- Critical severity for maintaining aggregate independence
- 📚 *Based on: Domain-Driven Design (Evans 2003), Implementing DDD (Vernon 2013)* → [Why?](./docs/WHY.md#aggregate-boundaries)
🏗️ **Clean Architecture Enforcement**
- Built with DDD principles
- Layered architecture (Domain, Application, Infrastructure)

View File

@@ -256,11 +256,10 @@ Internal refactoring to eliminate hardcoded values and improve maintainability:
---
## Future Roadmap
## Version 0.7.0 - Aggregate Boundary Validation 🔒 ✅ RELEASED
### Version 0.6.0 - Aggregate Boundary Validation 🔒
**Target:** Q1 2026
**Priority:** MEDIUM
**Released:** 2025-11-24
**Priority:** CRITICAL
Validate aggregate boundaries in DDD:
@@ -286,12 +285,19 @@ class Order {
}
```
**Planned Features:**
- Detect entity references across aggregates
- Allow only ID or Value Object references
- Detect circular dependencies between aggregates
- Validate aggregate root access patterns
- Support for aggregate folder structure
**Implemented Features:**
- Detect entity references across aggregates
- Allow only ID or Value Object references from other aggregates
- ✅ Filter allowed imports (value-objects, events, repositories, services)
- ✅ Support for multiple aggregate folder structures (domain/aggregates/name, domain/name, domain/entities/name)
- ✅ 41 comprehensive tests with 100% pass rate
- ✅ Examples of good and bad patterns
- ✅ CLI output with 🔒 icon and detailed violation info
- ✅ Critical severity level for aggregate boundary violations
---
## Future Roadmap
---

View File

@@ -0,0 +1,40 @@
/**
* ❌ BAD EXAMPLE: Direct Entity Reference Across Aggregates
*
* Violation: Order aggregate directly imports and uses User entity from User aggregate
*
* Problems:
* 1. Creates tight coupling between aggregates
* 2. Changes to User entity affect Order aggregate
* 3. Violates aggregate boundary principles in DDD
* 4. Makes aggregates not independently modifiable
*/
import { User } from "../user/User"
import { Product } from "../product/Product"
export class Order {
private id: string
private user: User
private product: Product
private quantity: number
constructor(id: string, user: User, product: Product, quantity: number) {
this.id = id
this.user = user
this.product = product
this.quantity = quantity
}
getUserEmail(): string {
return this.user.email
}
getProductPrice(): number {
return this.product.price
}
calculateTotal(): number {
return this.product.price * this.quantity
}
}

View File

@@ -0,0 +1,40 @@
/**
* ✅ GOOD EXAMPLE: Reference by ID
*
* Best Practice: Order aggregate references other aggregates only by their IDs
*
* Benefits:
* 1. Loose coupling between aggregates
* 2. Each aggregate can be modified independently
* 3. Follows DDD aggregate boundary principles
* 4. Clear separation of concerns
*/
import { UserId } from "../user/value-objects/UserId"
import { ProductId } from "../product/value-objects/ProductId"
export class Order {
private id: string
private userId: UserId
private productId: ProductId
private quantity: number
constructor(id: string, userId: UserId, productId: ProductId, quantity: number) {
this.id = id
this.userId = userId
this.productId = productId
this.quantity = quantity
}
getUserId(): UserId {
return this.userId
}
getProductId(): ProductId {
return this.productId
}
getQuantity(): number {
return this.quantity
}
}

View File

@@ -0,0 +1,61 @@
/**
* ✅ GOOD EXAMPLE: Using Value Objects for Needed Data
*
* Best Practice: When Order needs specific data from other aggregates,
* use Value Objects to store that data (denormalization)
*
* Benefits:
* 1. Order aggregate has all data it needs
* 2. No runtime dependency on other aggregates
* 3. Better performance (no joins needed)
* 4. Clear contract through Value Objects
*/
import { UserId } from "../user/value-objects/UserId"
import { ProductId } from "../product/value-objects/ProductId"
export class CustomerInfo {
constructor(
readonly customerId: UserId,
readonly customerName: string,
readonly customerEmail: string,
) {}
}
export class ProductInfo {
constructor(
readonly productId: ProductId,
readonly productName: string,
readonly productPrice: number,
) {}
}
export class Order {
private id: string
private customer: CustomerInfo
private product: ProductInfo
private quantity: number
constructor(id: string, customer: CustomerInfo, product: ProductInfo, quantity: number) {
this.id = id
this.customer = customer
this.product = product
this.quantity = quantity
}
getCustomerEmail(): string {
return this.customer.customerEmail
}
calculateTotal(): number {
return this.product.productPrice * this.quantity
}
getCustomerInfo(): CustomerInfo {
return this.customer
}
getProductInfo(): ProductInfo {
return this.product
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@samiyev/guardian",
"version": "0.6.4",
"version": "0.7.0",
"description": "Research-backed code quality guardian for AI-assisted development. Detects hardcodes, circular deps, framework leaks, entity exposure, and 8 architecture violations. Enforces Clean Architecture/DDD principles. Works with GitHub Copilot, Cursor, Windsurf, Claude, ChatGPT, Cline, and any AI coding tool.",
"keywords": [
"puaros",

View File

@@ -11,6 +11,7 @@ import { IFrameworkLeakDetector } from "./domain/services/IFrameworkLeakDetector
import { IEntityExposureDetector } from "./domain/services/IEntityExposureDetector"
import { IDependencyDirectionDetector } from "./domain/services/IDependencyDirectionDetector"
import { IRepositoryPatternDetector } from "./domain/services/RepositoryPatternDetectorService"
import { IAggregateBoundaryDetector } from "./domain/services/IAggregateBoundaryDetector"
import { FileScanner } from "./infrastructure/scanners/FileScanner"
import { CodeParser } from "./infrastructure/parsers/CodeParser"
import { HardcodeDetector } from "./infrastructure/analyzers/HardcodeDetector"
@@ -19,6 +20,7 @@ import { FrameworkLeakDetector } from "./infrastructure/analyzers/FrameworkLeakD
import { EntityExposureDetector } from "./infrastructure/analyzers/EntityExposureDetector"
import { DependencyDirectionDetector } from "./infrastructure/analyzers/DependencyDirectionDetector"
import { RepositoryPatternDetector } from "./infrastructure/analyzers/RepositoryPatternDetector"
import { AggregateBoundaryDetector } from "./infrastructure/analyzers/AggregateBoundaryDetector"
import { ERROR_MESSAGES } from "./shared/constants"
/**
@@ -76,6 +78,7 @@ export async function analyzeProject(
const dependencyDirectionDetector: IDependencyDirectionDetector =
new DependencyDirectionDetector()
const repositoryPatternDetector: IRepositoryPatternDetector = new RepositoryPatternDetector()
const aggregateBoundaryDetector: IAggregateBoundaryDetector = new AggregateBoundaryDetector()
const useCase = new AnalyzeProject(
fileScanner,
codeParser,
@@ -85,6 +88,7 @@ export async function analyzeProject(
entityExposureDetector,
dependencyDirectionDetector,
repositoryPatternDetector,
aggregateBoundaryDetector,
)
const result = await useCase.execute(options)
@@ -107,5 +111,6 @@ export type {
EntityExposureViolation,
DependencyDirectionViolation,
RepositoryPatternViolation,
AggregateBoundaryViolation,
ProjectMetrics,
} from "./application/use-cases/AnalyzeProject"

View File

@@ -8,6 +8,7 @@ import { IFrameworkLeakDetector } from "../../domain/services/IFrameworkLeakDete
import { IEntityExposureDetector } from "../../domain/services/IEntityExposureDetector"
import { IDependencyDirectionDetector } from "../../domain/services/IDependencyDirectionDetector"
import { IRepositoryPatternDetector } from "../../domain/services/RepositoryPatternDetectorService"
import { IAggregateBoundaryDetector } from "../../domain/services/IAggregateBoundaryDetector"
import { SourceFile } from "../../domain/entities/SourceFile"
import { DependencyGraph } from "../../domain/entities/DependencyGraph"
import { ProjectPath } from "../../domain/value-objects/ProjectPath"
@@ -41,6 +42,7 @@ export interface AnalyzeProjectResponse {
entityExposureViolations: EntityExposureViolation[]
dependencyDirectionViolations: DependencyDirectionViolation[]
repositoryPatternViolations: RepositoryPatternViolation[]
aggregateBoundaryViolations: AggregateBoundaryViolation[]
metrics: ProjectMetrics
}
@@ -149,6 +151,19 @@ export interface RepositoryPatternViolation {
severity: SeverityLevel
}
export interface AggregateBoundaryViolation {
rule: typeof RULES.AGGREGATE_BOUNDARY
fromAggregate: string
toAggregate: string
entityName: string
importPath: string
file: string
line?: number
message: string
suggestion: string
severity: SeverityLevel
}
export interface ProjectMetrics {
totalFiles: number
totalFunctions: number
@@ -172,6 +187,7 @@ export class AnalyzeProject extends UseCase<
private readonly entityExposureDetector: IEntityExposureDetector,
private readonly dependencyDirectionDetector: IDependencyDirectionDetector,
private readonly repositoryPatternDetector: IRepositoryPatternDetector,
private readonly aggregateBoundaryDetector: IAggregateBoundaryDetector,
) {
super()
}
@@ -234,6 +250,9 @@ export class AnalyzeProject extends UseCase<
const repositoryPatternViolations = this.sortBySeverity(
this.detectRepositoryPatternViolations(sourceFiles),
)
const aggregateBoundaryViolations = this.sortBySeverity(
this.detectAggregateBoundaryViolations(sourceFiles),
)
const metrics = this.calculateMetrics(sourceFiles, totalFunctions, dependencyGraph)
return ResponseDto.ok({
@@ -247,6 +266,7 @@ export class AnalyzeProject extends UseCase<
entityExposureViolations,
dependencyDirectionViolations,
repositoryPatternViolations,
aggregateBoundaryViolations,
metrics,
})
} catch (error) {
@@ -532,6 +552,37 @@ export class AnalyzeProject extends UseCase<
return violations
}
private detectAggregateBoundaryViolations(
sourceFiles: SourceFile[],
): AggregateBoundaryViolation[] {
const violations: AggregateBoundaryViolation[] = []
for (const file of sourceFiles) {
const boundaryViolations = this.aggregateBoundaryDetector.detectViolations(
file.content,
file.path.relative,
file.layer,
)
for (const violation of boundaryViolations) {
violations.push({
rule: RULES.AGGREGATE_BOUNDARY,
fromAggregate: violation.fromAggregate,
toAggregate: violation.toAggregate,
entityName: violation.entityName,
importPath: violation.importPath,
file: file.path.relative,
line: violation.line,
message: violation.getMessage(),
suggestion: violation.getSuggestion(),
severity: VIOLATION_SEVERITY_MAP.AGGREGATE_BOUNDARY,
})
}
}
return violations
}
private calculateMetrics(
sourceFiles: SourceFile[],
totalFunctions: number,

View File

@@ -155,6 +155,7 @@ program
entityExposureViolations,
dependencyDirectionViolations,
repositoryPatternViolations,
aggregateBoundaryViolations,
} = result
const minSeverity: SeverityLevel | undefined = options.onlyCritical
@@ -185,6 +186,10 @@ program
repositoryPatternViolations,
minSeverity,
)
aggregateBoundaryViolations = filterBySeverity(
aggregateBoundaryViolations,
minSeverity,
)
if (options.onlyCritical) {
console.log("\n🔴 Filtering: Showing only CRITICAL severity issues\n")
@@ -374,6 +379,35 @@ program
)
}
// Aggregate boundary violations
if (options.architecture && aggregateBoundaryViolations.length > 0) {
console.log(
`\n🔒 Found ${String(aggregateBoundaryViolations.length)} aggregate boundary violation(s)`,
)
displayGroupedViolations(
aggregateBoundaryViolations,
(ab, index) => {
const location = ab.line ? `${ab.file}:${String(ab.line)}` : ab.file
console.log(`${String(index + 1)}. ${location}`)
console.log(` Severity: ${SEVERITY_LABELS[ab.severity]}`)
console.log(` From Aggregate: ${ab.fromAggregate}`)
console.log(` To Aggregate: ${ab.toAggregate}`)
console.log(` Entity: ${ab.entityName}`)
console.log(` Import: ${ab.importPath}`)
console.log(` ${ab.message}`)
console.log(" 💡 Suggestion:")
ab.suggestion.split("\n").forEach((line) => {
if (line.trim()) {
console.log(` ${line}`)
}
})
console.log("")
},
limit,
)
}
// Hardcode violations
if (options.hardcode && hardcodeViolations.length > 0) {
console.log(
@@ -407,7 +441,8 @@ program
frameworkLeakViolations.length +
entityExposureViolations.length +
dependencyDirectionViolations.length +
repositoryPatternViolations.length
repositoryPatternViolations.length +
aggregateBoundaryViolations.length
if (totalIssues === 0) {
console.log(CLI_MESSAGES.NO_ISSUES)

View File

@@ -48,3 +48,11 @@ export const REPOSITORY_PATTERN_MESSAGES = {
SUGGESTION_DELETE: "remove or delete",
SUGGESTION_QUERY: "find or search",
}
export const AGGREGATE_VIOLATION_MESSAGES = {
USE_ID_REFERENCE: "1. Reference other aggregates by ID (UserId, OrderId) instead of entity",
USE_VALUE_OBJECT:
"2. Use Value Objects to store needed data from other aggregates (CustomerInfo, ProductSummary)",
AVOID_DIRECT_REFERENCE: "3. Avoid direct entity references to maintain aggregate independence",
MAINTAIN_INDEPENDENCE: "4. Each aggregate should be independently modifiable and deployable",
}

View File

@@ -0,0 +1,45 @@
import { AggregateBoundaryViolation } from "../value-objects/AggregateBoundaryViolation"
/**
* Interface for detecting aggregate boundary violations in DDD
*
* Aggregate boundary violations occur when an entity from one aggregate
* directly references an entity from another aggregate. In DDD, aggregates
* should reference each other only by ID or Value Objects to maintain
* loose coupling and independence.
*/
export interface IAggregateBoundaryDetector {
/**
* Detects aggregate boundary violations in the given code
*
* Analyzes import statements to identify direct entity references
* across aggregate boundaries.
*
* @param code - Source code to analyze
* @param filePath - Path to the file being analyzed
* @param layer - The architectural layer of the file (should be 'domain')
* @returns Array of detected aggregate boundary violations
*/
detectViolations(
code: string,
filePath: string,
layer: string | undefined,
): AggregateBoundaryViolation[]
/**
* Checks if a file path belongs to an aggregate
*
* @param filePath - The file path to check
* @returns The aggregate name if found, undefined otherwise
*/
extractAggregateFromPath(filePath: string): string | undefined
/**
* Checks if an import path references an entity from another aggregate
*
* @param importPath - The import path to analyze
* @param currentAggregate - The aggregate of the current file
* @returns True if the import crosses aggregate boundaries inappropriately
*/
isAggregateBoundaryViolation(importPath: string, currentAggregate: string): boolean
}

View File

@@ -0,0 +1,137 @@
import { ValueObject } from "./ValueObject"
import { AGGREGATE_VIOLATION_MESSAGES } from "../constants/Messages"
interface AggregateBoundaryViolationProps {
readonly fromAggregate: string
readonly toAggregate: string
readonly entityName: string
readonly importPath: string
readonly filePath: string
readonly line?: number
}
/**
* Represents an aggregate boundary violation in the codebase
*
* Aggregate boundary violations occur when an entity from one aggregate
* directly references an entity from another aggregate, violating DDD principles:
* - Aggregates should reference each other only by ID or Value Objects
* - Direct entity references create tight coupling between aggregates
* - Changes to one aggregate should not require changes to another
*
* @example
* ```typescript
* // Bad: Direct entity reference across aggregates
* const violation = AggregateBoundaryViolation.create(
* 'order',
* 'user',
* 'User',
* '../user/User',
* 'src/domain/aggregates/order/Order.ts',
* 5
* )
*
* console.log(violation.getMessage())
* // "Order aggregate should not directly reference User entity from User aggregate"
* ```
*/
export class AggregateBoundaryViolation extends ValueObject<AggregateBoundaryViolationProps> {
private constructor(props: AggregateBoundaryViolationProps) {
super(props)
}
public static create(
fromAggregate: string,
toAggregate: string,
entityName: string,
importPath: string,
filePath: string,
line?: number,
): AggregateBoundaryViolation {
return new AggregateBoundaryViolation({
fromAggregate,
toAggregate,
entityName,
importPath,
filePath,
line,
})
}
public get fromAggregate(): string {
return this.props.fromAggregate
}
public get toAggregate(): string {
return this.props.toAggregate
}
public get entityName(): string {
return this.props.entityName
}
public get importPath(): string {
return this.props.importPath
}
public get filePath(): string {
return this.props.filePath
}
public get line(): number | undefined {
return this.props.line
}
public getMessage(): string {
return `${this.capitalizeFirst(this.props.fromAggregate)} aggregate should not directly reference ${this.props.entityName} entity from ${this.capitalizeFirst(this.props.toAggregate)} aggregate`
}
public getSuggestion(): string {
const suggestions: string[] = [
AGGREGATE_VIOLATION_MESSAGES.USE_ID_REFERENCE,
AGGREGATE_VIOLATION_MESSAGES.USE_VALUE_OBJECT,
AGGREGATE_VIOLATION_MESSAGES.AVOID_DIRECT_REFERENCE,
AGGREGATE_VIOLATION_MESSAGES.MAINTAIN_INDEPENDENCE,
]
return suggestions.join("\n")
}
public getExampleFix(): string {
return `
// ❌ Bad: Direct entity reference across aggregates
// domain/aggregates/order/Order.ts
import { User } from '../user/User'
class Order {
constructor(private user: User) {}
}
// ✅ Good: Reference by ID
// domain/aggregates/order/Order.ts
import { UserId } from '../user/value-objects/UserId'
class Order {
constructor(private userId: UserId) {}
}
// ✅ Good: Use Value Object for needed data
// domain/aggregates/order/value-objects/CustomerInfo.ts
class CustomerInfo {
constructor(
readonly customerId: string,
readonly customerName: string,
readonly customerEmail: string
) {}
}
// domain/aggregates/order/Order.ts
class Order {
constructor(private customerInfo: CustomerInfo) {}
}`
}
private capitalizeFirst(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1)
}
}

View File

@@ -0,0 +1,308 @@
import { IAggregateBoundaryDetector } from "../../domain/services/IAggregateBoundaryDetector"
import { AggregateBoundaryViolation } from "../../domain/value-objects/AggregateBoundaryViolation"
import { LAYERS } from "../../shared/constants/rules"
import { IMPORT_PATTERNS } from "../constants/paths"
/**
* Detects aggregate boundary violations in Domain-Driven Design
*
* This detector enforces DDD aggregate rules:
* - Aggregates should reference each other only by ID or Value Objects
* - Direct entity references across aggregates create tight coupling
* - Each aggregate should be independently modifiable
*
* Folder structure patterns detected:
* - domain/aggregates/order/Order.ts
* - domain/order/Order.ts (aggregate name from parent folder)
* - domain/entities/order/Order.ts
*
* @example
* ```typescript
* const detector = new AggregateBoundaryDetector()
*
* // Detect violations in order aggregate
* const code = `
* import { User } from '../user/User'
* import { UserId } from '../user/value-objects/UserId'
* `
* const violations = detector.detectViolations(
* code,
* 'src/domain/aggregates/order/Order.ts',
* 'domain'
* )
*
* // violations will contain 1 violation for direct User entity import
* // but not for UserId (value object is allowed)
* console.log(violations.length) // 1
* ```
*/
export class AggregateBoundaryDetector implements IAggregateBoundaryDetector {
private readonly entityFolderNames = new Set(["entities", "aggregates"])
private readonly valueObjectFolderNames = new Set(["value-objects", "vo"])
private readonly allowedFolderNames = new Set([
"value-objects",
"vo",
"events",
"domain-events",
"repositories",
"services",
"specifications",
])
/**
* Detects aggregate boundary violations in the given code
*
* Analyzes import statements to identify direct entity references
* across aggregate boundaries in the domain layer.
*
* @param code - Source code to analyze
* @param filePath - Path to the file being analyzed
* @param layer - The architectural layer of the file (should be 'domain')
* @returns Array of detected aggregate boundary violations
*/
public detectViolations(
code: string,
filePath: string,
layer: string | undefined,
): AggregateBoundaryViolation[] {
if (layer !== LAYERS.DOMAIN) {
return []
}
const currentAggregate = this.extractAggregateFromPath(filePath)
if (!currentAggregate) {
return []
}
const violations: AggregateBoundaryViolation[] = []
const lines = code.split("\n")
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const lineNumber = i + 1
const imports = this.extractImports(line)
for (const importPath of imports) {
if (this.isAggregateBoundaryViolation(importPath, currentAggregate)) {
const targetAggregate = this.extractAggregateFromImport(importPath)
const entityName = this.extractEntityName(importPath)
if (targetAggregate && entityName) {
violations.push(
AggregateBoundaryViolation.create(
currentAggregate,
targetAggregate,
entityName,
importPath,
filePath,
lineNumber,
),
)
}
}
}
}
return violations
}
/**
* Checks if a file path belongs to an aggregate
*
* Extracts aggregate name from paths like:
* - domain/aggregates/order/Order.ts → 'order'
* - domain/order/Order.ts → 'order'
* - domain/entities/order/Order.ts → 'order'
*
* @param filePath - The file path to check
* @returns The aggregate name if found, undefined otherwise
*/
public extractAggregateFromPath(filePath: string): string | undefined {
const normalizedPath = filePath.toLowerCase().replace(/\\/g, "/")
const domainMatch = /\/domain\//.exec(normalizedPath)
if (!domainMatch) {
return undefined
}
const pathAfterDomain = normalizedPath.substring(domainMatch.index + domainMatch[0].length)
const segments = pathAfterDomain.split("/").filter(Boolean)
if (segments.length < 2) {
return undefined
}
if (this.entityFolderNames.has(segments[0])) {
if (segments.length < 3) {
return undefined
}
return segments[1]
}
return segments[0]
}
/**
* Checks if an import path references an entity from another aggregate
*
* @param importPath - The import path to analyze
* @param currentAggregate - The aggregate of the current file
* @returns True if the import crosses aggregate boundaries inappropriately
*/
public isAggregateBoundaryViolation(importPath: string, currentAggregate: string): boolean {
const normalizedPath = importPath.replace(IMPORT_PATTERNS.QUOTE, "").toLowerCase()
if (!normalizedPath.includes("/")) {
return false
}
if (!normalizedPath.startsWith(".") && !normalizedPath.startsWith("/")) {
return false
}
const targetAggregate = this.extractAggregateFromImport(normalizedPath)
if (!targetAggregate || targetAggregate === currentAggregate) {
return false
}
if (this.isAllowedImport(normalizedPath)) {
return false
}
return this.seemsLikeEntityImport(normalizedPath)
}
/**
* Checks if the import path is from an allowed folder (value-objects, events, etc.)
*/
private isAllowedImport(normalizedPath: string): boolean {
for (const folderName of this.allowedFolderNames) {
if (normalizedPath.includes(`/${folderName}/`)) {
return true
}
}
return false
}
/**
* Checks if the import seems to be an entity (not a value object, event, etc.)
*
* Note: normalizedPath is already lowercased, so we check if the first character
* is a letter (indicating it was likely PascalCase originally)
*/
private seemsLikeEntityImport(normalizedPath: string): boolean {
const pathParts = normalizedPath.split("/")
const lastPart = pathParts[pathParts.length - 1]
if (!lastPart) {
return false
}
const filename = lastPart.replace(/\.(ts|js)$/, "")
if (filename.length > 0 && /^[a-z][a-z]/.exec(filename)) {
return true
}
return false
}
/**
* Extracts the aggregate name from an import path
*
* Handles both absolute and relative paths:
* - ../user/User → user
* - ../../domain/user/User → user
* - ../user/value-objects/UserId → user (but filtered as value object)
*/
private extractAggregateFromImport(importPath: string): string | undefined {
const normalizedPath = importPath.replace(IMPORT_PATTERNS.QUOTE, "").toLowerCase()
const segments = normalizedPath.split("/").filter((seg) => seg !== ".." && seg !== ".")
if (segments.length === 0) {
return undefined
}
for (let i = 0; i < segments.length; i++) {
if (segments[i] === "domain" || segments[i] === "aggregates") {
if (i + 1 < segments.length) {
if (
this.entityFolderNames.has(segments[i + 1]) ||
segments[i + 1] === "aggregates"
) {
if (i + 2 < segments.length) {
return segments[i + 2]
}
} else {
return segments[i + 1]
}
}
}
}
if (segments.length >= 2) {
const secondLastSegment = segments[segments.length - 2]
if (
!this.entityFolderNames.has(secondLastSegment) &&
!this.valueObjectFolderNames.has(secondLastSegment) &&
!this.allowedFolderNames.has(secondLastSegment) &&
secondLastSegment !== "domain"
) {
return secondLastSegment
}
}
if (segments.length === 1) {
return undefined
}
return undefined
}
/**
* Extracts the entity name from an import path
*/
private extractEntityName(importPath: string): string | undefined {
const normalizedPath = importPath.replace(IMPORT_PATTERNS.QUOTE, "")
const segments = normalizedPath.split("/")
const lastSegment = segments[segments.length - 1]
if (lastSegment) {
return lastSegment.replace(/\.(ts|js)$/, "")
}
return undefined
}
/**
* Extracts import paths from a line of code
*
* Handles various import statement formats:
* - import { X } from 'path'
* - import X from 'path'
* - import * as X from 'path'
* - const X = require('path')
*
* @param line - A line of code to analyze
* @returns Array of import paths found in the line
*/
private extractImports(line: string): string[] {
const imports: string[] = []
let match = IMPORT_PATTERNS.ES_IMPORT.exec(line)
while (match) {
imports.push(match[1])
match = IMPORT_PATTERNS.ES_IMPORT.exec(line)
}
match = IMPORT_PATTERNS.REQUIRE.exec(line)
while (match) {
imports.push(match[1])
match = IMPORT_PATTERNS.REQUIRE.exec(line)
}
return imports
}
}

View File

@@ -88,6 +88,7 @@ export const SEVERITY_ORDER: Record<SeverityLevel, number> = {
export const VIOLATION_SEVERITY_MAP = {
CIRCULAR_DEPENDENCY: SEVERITY_LEVELS.CRITICAL,
REPOSITORY_PATTERN: SEVERITY_LEVELS.CRITICAL,
AGGREGATE_BOUNDARY: SEVERITY_LEVELS.CRITICAL,
DEPENDENCY_DIRECTION: SEVERITY_LEVELS.HIGH,
FRAMEWORK_LEAK: SEVERITY_LEVELS.HIGH,
ENTITY_EXPOSURE: SEVERITY_LEVELS.HIGH,

View File

@@ -10,6 +10,7 @@ export const RULES = {
ENTITY_EXPOSURE: "entity-exposure",
DEPENDENCY_DIRECTION: "dependency-direction",
REPOSITORY_PATTERN: "repository-pattern",
AGGREGATE_BOUNDARY: "aggregate-boundary",
} as const
/**

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")
})
})
})