feat: add repository pattern validation (v0.5.0)

Add comprehensive Repository Pattern validation to detect violations
and ensure proper domain-infrastructure separation.

Features:
- ORM type detection in repository interfaces (25+ patterns)
- Concrete repository usage detection in use cases
- Repository instantiation detection (new Repository())
- Domain language validation for repository methods
- Smart violation reporting with fix suggestions

Tests:
- 31 new tests for repository pattern detection
- 292 total tests passing (100% pass rate)
- 96.77% statement coverage, 83.82% branch coverage

Examples:
- 8 example files (4 bad patterns, 4 good patterns)
- Demonstrates Clean Architecture and SOLID principles
This commit is contained in:
imfozilbek
2025-11-24 20:11:33 +05:00
parent 3fecc98676
commit 0534fdf1bd
23 changed files with 2149 additions and 2 deletions

View File

@@ -0,0 +1,220 @@
# Repository Pattern Examples
This directory contains examples demonstrating proper and improper implementations of the Repository Pattern.
## Overview
The Repository Pattern provides an abstraction layer between domain logic and data access. A well-implemented repository:
1. Uses domain types, not ORM-specific types
2. Depends on interfaces, not concrete implementations
3. Uses dependency injection, not direct instantiation
4. Uses domain language, not technical database terms
## Examples
### ❌ Bad Examples
#### 1. ORM Types in Interface
**File:** `bad-orm-types-in-interface.ts`
**Problem:** Repository interface exposes Prisma-specific types (`Prisma.UserWhereInput`, `Prisma.UserCreateInput`). This couples the domain layer to infrastructure concerns.
**Violations:**
- Domain depends on ORM library
- Cannot swap ORM without changing domain
- Breaks Clean Architecture principles
#### 2. Concrete Repository in Use Case
**File:** `bad-concrete-repository-in-use-case.ts`
**Problem:** Use case depends on `PrismaUserRepository` instead of `IUserRepository` interface.
**Violations:**
- Violates Dependency Inversion Principle
- Cannot easily mock for testing
- Tightly coupled to specific implementation
#### 3. Creating Repository with 'new'
**File:** `bad-new-repository.ts`
**Problem:** Use case instantiates repositories with `new UserRepository()` instead of receiving them through constructor.
**Violations:**
- Violates Dependency Injection principle
- Hard to test (cannot mock dependencies)
- Hidden dependencies
- Creates tight coupling
#### 4. Technical Method Names
**File:** `bad-technical-method-names.ts`
**Problem:** Repository methods use database/SQL terminology (`findOne`, `insert`, `query`, `execute`).
**Violations:**
- Uses technical terms instead of domain language
- Exposes implementation details
- Not aligned with ubiquitous language
### ✅ Good Examples
#### 1. Clean Interface
**File:** `good-clean-interface.ts`
**Benefits:**
- Uses only domain types (UserId, Email, User)
- ORM-agnostic interface
- Easy to understand and maintain
- Follows Clean Architecture
```typescript
interface IUserRepository {
findById(id: UserId): Promise<User | null>
findByEmail(email: Email): Promise<User | null>
save(user: User): Promise<void>
delete(id: UserId): Promise<void>
}
```
#### 2. Interface in Use Case
**File:** `good-interface-in-use-case.ts`
**Benefits:**
- Depends on interface, not concrete class
- Easy to test with mocks
- Can swap implementations
- Follows Dependency Inversion Principle
```typescript
class CreateUser {
constructor(private readonly userRepo: IUserRepository) {}
async execute(data: CreateUserRequest): Promise<UserResponseDto> {
// Uses interface, not concrete implementation
}
}
```
#### 3. Dependency Injection
**File:** `good-dependency-injection.ts`
**Benefits:**
- All dependencies injected through constructor
- Explicit dependencies (no hidden coupling)
- Easy to test with mocks
- Follows SOLID principles
```typescript
class CreateUser {
constructor(
private readonly userRepo: IUserRepository,
private readonly emailService: IEmailService
) {}
}
```
#### 4. Domain Language
**File:** `good-domain-language.ts`
**Benefits:**
- Methods use business-oriented names
- Self-documenting interface
- Aligns with ubiquitous language
- Hides implementation details
```typescript
interface IUserRepository {
findById(id: UserId): Promise<User | null>
findByEmail(email: Email): Promise<User | null>
findActiveUsers(): Promise<User[]>
save(user: User): Promise<void>
search(criteria: UserSearchCriteria): Promise<User[]>
}
```
## Key Principles
### 1. Persistence Ignorance
Domain entities and repositories should not know about how data is persisted.
```typescript
// ❌ Bad: Domain knows about Prisma
interface IUserRepository {
find(query: Prisma.UserWhereInput): Promise<User>
}
// ✅ Good: Domain uses own types
interface IUserRepository {
findById(id: UserId): Promise<User | null>
}
```
### 2. Dependency Inversion
High-level modules (use cases) should not depend on low-level modules (repositories). Both should depend on abstractions (interfaces).
```typescript
// ❌ Bad: Use case depends on concrete repository
class CreateUser {
constructor(private repo: PrismaUserRepository) {}
}
// ✅ Good: Use case depends on interface
class CreateUser {
constructor(private repo: IUserRepository) {}
}
```
### 3. Dependency Injection
Don't create dependencies inside classes. Inject them through constructor.
```typescript
// ❌ Bad: Creates dependency
class CreateUser {
execute() {
const repo = new UserRepository()
}
}
// ✅ Good: Injects dependency
class CreateUser {
constructor(private readonly repo: IUserRepository) {}
}
```
### 4. Ubiquitous Language
Use domain language everywhere, including repository methods.
```typescript
// ❌ Bad: Technical terminology
interface IUserRepository {
findOne(id: string): Promise<User>
insert(user: User): Promise<void>
}
// ✅ Good: Domain language
interface IUserRepository {
findById(id: UserId): Promise<User | null>
save(user: User): Promise<void>
}
```
## Testing with Guardian
Run Guardian to detect Repository Pattern violations:
```bash
guardian check --root ./examples/repository-pattern
```
Guardian will detect:
- ORM types in repository interfaces
- Concrete repository usage in use cases
- Repository instantiation with 'new'
- Technical method names in repositories
## Further Reading
- [Clean Architecture by Robert C. Martin](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
- [Domain-Driven Design by Eric Evans](https://www.domainlanguage.com/ddd/)
- [Repository Pattern - Martin Fowler](https://martinfowler.com/eaaCatalog/repository.html)
- [SOLID Principles](https://en.wikipedia.org/wiki/SOLID)

View File

@@ -0,0 +1,67 @@
/**
* ❌ BAD EXAMPLE: Concrete repository in use case
*
* Use case depends on concrete repository implementation instead of interface.
* This violates Dependency Inversion Principle.
*/
class CreateUser {
constructor(private userRepo: PrismaUserRepository) {}
async execute(data: CreateUserRequest): Promise<UserResponseDto> {
const user = User.create(data.email, data.name)
await this.userRepo.save(user)
return UserMapper.toDto(user)
}
}
class PrismaUserRepository {
constructor(private prisma: any) {}
async save(user: User): Promise<void> {
await this.prisma.user.create({
data: {
email: user.getEmail(),
name: user.getName(),
},
})
}
}
class User {
static create(email: string, name: string): User {
return new User(email, name)
}
constructor(
private email: string,
private name: string,
) {}
getEmail(): string {
return this.email
}
getName(): string {
return this.name
}
}
interface CreateUserRequest {
email: string
name: string
}
interface UserResponseDto {
email: string
name: string
}
class UserMapper {
static toDto(user: User): UserResponseDto {
return {
email: user.getEmail(),
name: user.getName(),
}
}
}

View File

@@ -0,0 +1,69 @@
/**
* ❌ BAD EXAMPLE: Creating repository with 'new' in use case
*
* Use case creates repository instances directly.
* This violates Dependency Injection principle and makes testing difficult.
*/
class CreateUser {
async execute(data: CreateUserRequest): Promise<UserResponseDto> {
const userRepo = new UserRepository()
const emailService = new EmailService()
const user = User.create(data.email, data.name)
await userRepo.save(user)
await emailService.sendWelcomeEmail(user.getEmail())
return UserMapper.toDto(user)
}
}
class UserRepository {
async save(user: User): Promise<void> {
console.warn("Saving user to database")
}
}
class EmailService {
async sendWelcomeEmail(email: string): Promise<void> {
console.warn(`Sending welcome email to ${email}`)
}
}
class User {
static create(email: string, name: string): User {
return new User(email, name)
}
constructor(
private email: string,
private name: string,
) {}
getEmail(): string {
return this.email
}
getName(): string {
return this.name
}
}
interface CreateUserRequest {
email: string
name: string
}
interface UserResponseDto {
email: string
name: string
}
class UserMapper {
static toDto(user: User): UserResponseDto {
return {
email: user.getEmail(),
name: user.getName(),
}
}
}

View File

@@ -0,0 +1,26 @@
/**
* ❌ BAD EXAMPLE: ORM-specific types in repository interface
*
* This violates Repository Pattern by coupling domain layer to infrastructure (ORM).
* Domain should remain persistence-agnostic.
*/
import { Prisma, PrismaClient } from "@prisma/client"
interface IUserRepository {
findOne(query: Prisma.UserWhereInput): Promise<User | null>
findMany(query: Prisma.UserFindManyArgs): Promise<User[]>
create(data: Prisma.UserCreateInput): Promise<User>
update(id: string, data: Prisma.UserUpdateInput): Promise<User>
}
class User {
constructor(
public id: string,
public email: string,
public name: string,
) {}
}

View File

@@ -0,0 +1,32 @@
/**
* ❌ BAD EXAMPLE: Technical method names
*
* Repository interface uses database/ORM terminology instead of domain language.
* Methods should reflect business operations, not technical implementation.
*/
interface IUserRepository {
findOne(id: string): Promise<User | null>
findMany(filter: any): Promise<User[]>
insert(user: User): Promise<void>
updateOne(id: string, data: any): Promise<void>
deleteOne(id: string): Promise<void>
query(sql: string): Promise<any>
execute(command: string): Promise<void>
select(fields: string[]): Promise<User[]>
}
class User {
constructor(
public id: string,
public email: string,
public name: string,
) {}
}

View File

@@ -0,0 +1,61 @@
/**
* ✅ GOOD EXAMPLE: Clean repository interface
*
* Repository interface uses only domain types, keeping it persistence-agnostic.
* ORM implementation details stay in infrastructure layer.
*/
interface IUserRepository {
findById(id: UserId): Promise<User | null>
findByEmail(email: Email): Promise<User | null>
save(user: User): Promise<void>
delete(id: UserId): Promise<void>
findAll(criteria: UserSearchCriteria): Promise<User[]>
}
class UserId {
constructor(private readonly value: string) {}
getValue(): string {
return this.value
}
}
class Email {
constructor(private readonly value: string) {}
getValue(): string {
return this.value
}
}
class UserSearchCriteria {
constructor(
public readonly isActive?: boolean,
public readonly registeredAfter?: Date,
) {}
}
class User {
constructor(
private readonly id: UserId,
private email: Email,
private name: string,
) {}
getId(): UserId {
return this.id
}
getEmail(): Email {
return this.email
}
getName(): string {
return this.name
}
}

View File

@@ -0,0 +1,98 @@
/**
* ✅ GOOD EXAMPLE: Dependency Injection
*
* Use case receives dependencies through constructor.
* This makes code testable and follows SOLID principles.
*/
class CreateUser {
constructor(
private readonly userRepo: IUserRepository,
private readonly emailService: IEmailService,
) {}
async execute(data: CreateUserRequest): Promise<UserResponseDto> {
const user = User.create(Email.from(data.email), data.name)
await this.userRepo.save(user)
await this.emailService.sendWelcomeEmail(user.getEmail())
return UserMapper.toDto(user)
}
}
interface IUserRepository {
save(user: User): Promise<void>
findByEmail(email: Email): Promise<User | null>
}
interface IEmailService {
sendWelcomeEmail(email: Email): Promise<void>
}
class Email {
private constructor(private readonly value: string) {}
static from(value: string): Email {
if (!value.includes("@")) {
throw new Error("Invalid email")
}
return new Email(value)
}
getValue(): string {
return this.value
}
}
class User {
static create(email: Email, name: string): User {
return new User(email, name)
}
private constructor(
private readonly email: Email,
private readonly name: string,
) {}
getEmail(): Email {
return this.email
}
getName(): string {
return this.name
}
}
interface CreateUserRequest {
email: string
name: string
}
interface UserResponseDto {
email: string
name: string
}
class UserMapper {
static toDto(user: User): UserResponseDto {
return {
email: user.getEmail().getValue(),
name: user.getName(),
}
}
}
class UserRepository implements IUserRepository {
async save(_user: User): Promise<void> {
console.warn("Saving user to database")
}
async findByEmail(_email: Email): Promise<User | null> {
return null
}
}
class EmailService implements IEmailService {
async sendWelcomeEmail(email: Email): Promise<void> {
console.warn(`Sending welcome email to ${email.getValue()}`)
}
}

View File

@@ -0,0 +1,81 @@
/**
* ✅ GOOD EXAMPLE: Domain language in repository
*
* Repository interface uses domain-driven method names that reflect business operations.
* Method names are self-documenting and align with ubiquitous language.
*/
interface IUserRepository {
findById(id: UserId): Promise<User | null>
findByEmail(email: Email): Promise<User | null>
findActiveUsers(): Promise<User[]>
save(user: User): Promise<void>
delete(id: UserId): Promise<void>
search(criteria: UserSearchCriteria): Promise<User[]>
countActiveUsers(): Promise<number>
existsByEmail(email: Email): Promise<boolean>
}
class UserId {
constructor(private readonly value: string) {}
getValue(): string {
return this.value
}
}
class Email {
constructor(private readonly value: string) {}
getValue(): string {
return this.value
}
}
class UserSearchCriteria {
constructor(
public readonly isActive?: boolean,
public readonly registeredAfter?: Date,
public readonly department?: string,
) {}
}
class User {
constructor(
private readonly id: UserId,
private email: Email,
private name: string,
private isActive: boolean,
) {}
getId(): UserId {
return this.id
}
getEmail(): Email {
return this.email
}
getName(): string {
return this.name
}
isUserActive(): boolean {
return this.isActive
}
activate(): void {
this.isActive = true
}
deactivate(): void {
this.isActive = false
}
}

View File

@@ -0,0 +1,74 @@
/**
* ✅ GOOD EXAMPLE: Repository interface in use case
*
* Use case depends on repository interface, not concrete implementation.
* This follows Dependency Inversion Principle.
*/
class CreateUser {
constructor(private readonly userRepo: IUserRepository) {}
async execute(data: CreateUserRequest): Promise<UserResponseDto> {
const user = User.create(Email.from(data.email), data.name)
await this.userRepo.save(user)
return UserMapper.toDto(user)
}
}
interface IUserRepository {
save(user: User): Promise<void>
findByEmail(email: Email): Promise<User | null>
}
class Email {
private constructor(private readonly value: string) {}
static from(value: string): Email {
if (!value.includes("@")) {
throw new Error("Invalid email")
}
return new Email(value)
}
getValue(): string {
return this.value
}
}
class User {
static create(email: Email, name: string): User {
return new User(email, name)
}
private constructor(
private readonly email: Email,
private readonly name: string,
) {}
getEmail(): Email {
return this.email
}
getName(): string {
return this.name
}
}
interface CreateUserRequest {
email: string
name: string
}
interface UserResponseDto {
email: string
name: string
}
class UserMapper {
static toDto(user: User): UserResponseDto {
return {
email: user.getEmail().getValue(),
name: user.getName(),
}
}
}