Files
2025-11-29 18:17:22 +08:00

14 KiB

Clean Architecture Examples

Complete examples of proper layer separation, MVVM patterns, and dependency injection.

Complete Architecture Example

Proper 3-Layer Implementation

Presentation Layer - ViewModel

class PaymentViewModel: BaseViewModel<PaymentState> {
    // ✅ Depends only on UseCase (Domain layer)
    private let paymentUC: PaymentUseCase
    private let disposeBag = DisposeBag()

    // UI State
    let paymentAmount = BehaviorRelay<String>(value: "")
    let isProcessing = BehaviorRelay<Bool>(value: false)

    // ✅ Constructor injection
    init(paymentUC: PaymentUseCase) {
        self.paymentUC = paymentUC
        super.init()
    }

    func processPayment() {
        isProcessing.accept(true)

        // ✅ Delegates to UseCase, no business logic here
        paymentUC.execute(amount: paymentAmount.value)
            .subscribeOn(ConcurrentScheduler.background)
            .observeOn(MainScheduler.instance)
            .subscribe(
                onNext: { [weak self] result in
                    self?.setState(.success(result))
                },
                onError: { [weak self] error in
                    self?.setState(.error(error))
                },
                onCompleted: { [weak self] in
                    self?.isProcessing.accept(false)
                }
            )
            .disposed(by: disposeBag)
    }
}

Domain Layer - UseCase

// ✅ Protocol in Domain layer
protocol PaymentUseCase {
    func execute(amount: String) -> Single<PaymentResult>
}

// ✅ Implementation in Domain layer
class PaymentUseCaseImpl: PaymentUseCase {
    // ✅ Depends on Repository protocol (Domain layer)
    private let paymentRepository: PaymentRepository
    private let validationService: ValidationService

    init(paymentRepository: PaymentRepository,
         validationService: ValidationService) {
        self.paymentRepository = paymentRepository
        self.validationService = validationService
    }

    func execute(amount: String) -> Single<PaymentResult> {
        // ✅ Business logic in UseCase
        return validationService.validateAmount(amount)
            .flatMap { validatedAmount in
                // ✅ Calls Repository, not API directly
                return self.paymentRepository.processPayment(amount: validatedAmount)
            }
            .map { response in
                // ✅ Business rules applied here
                return self.applyBusinessRules(response)
            }
    }

    private func applyBusinessRules(_ response: PaymentResponse) -> PaymentResult {
        // Business logic here
        return PaymentResult(from: response)
    }
}

Data Layer - Repository

// ✅ Protocol in Domain layer
protocol PaymentRepository {
    func processPayment(amount: Double) -> Single<PaymentResponse>
}

// ✅ Implementation in Data layer
class PaymentRepositoryImpl: PaymentRepository {
    private let apiService: PaymentApiService
    private let localStorage: PaymentLocalStorage

    init(apiService: PaymentApiService, localStorage: PaymentLocalStorage) {
        self.apiService = apiService
        self.localStorage = localStorage
    }

    func processPayment(amount: Double) -> Single<PaymentResponse> {
        // ✅ Data access only, no business logic
        return apiService.processPayment(amount: amount)
            .do(onSuccess: { [weak self] response in
                // Save to local storage
                self?.localStorage.savePaymentRecord(response)
            })
    }
}

DI Setup - Swinject

extension Container {
    func registerPaymentModule() {
        // Register Use Cases
        register(PaymentUseCase.self) { resolver in
            PaymentUseCaseImpl(
                paymentRepository: resolver.resolve(PaymentRepository.self)!,
                validationService: resolver.resolve(ValidationService.self)!
            )
        }

        // Register Repositories
        register(PaymentRepository.self) { resolver in
            PaymentRepositoryImpl(
                apiService: resolver.resolve(PaymentApiService.self)!,
                localStorage: resolver.resolve(PaymentLocalStorage.self)!
            )
        }

        // Register ViewModels
        register(PaymentViewModel.self) { resolver in
            PaymentViewModel(
                paymentUC: resolver.resolve(PaymentUseCase.self)!
            )
        }
    }
}

Common Anti-Patterns

Anti-Pattern 1: ViewModel Calling API Directly

class PaymentViewModel: BaseViewModel<PaymentState> {
    private let apiService: PaymentApiService  // ❌ Wrong layer!

    func processPayment(amount: Double) {
        // ❌ Direct API call bypasses business logic
        apiService.processPayment(amount: amount)
            .subscribe(onNext: { result in
                // Handle result
            })
            .disposed(by: disposeBag)
    }
}

Problems:

  • Business logic scattered or missing
  • Hard to test
  • Violates layer separation
  • Cannot reuse logic elsewhere

Fix: Add UseCase layer

// 1. Create UseCase
protocol PaymentUseCase {
    func execute(amount: Double) -> Single<PaymentResult>
}

// 2. Update ViewModel
class PaymentViewModel: BaseViewModel<PaymentState> {
    private let paymentUC: PaymentUseCase  // ✅ Correct!

    func processPayment(amount: Double) {
        paymentUC.execute(amount: amount)  // ✅ Through UseCase
            .subscribe(/* ... */)
            .disposed(by: disposeBag)
    }
}

Anti-Pattern 2: Business Logic in ViewModel

class PaymentViewModel: BaseViewModel<PaymentState> {
    func processPayment(amount: String) {
        // ❌ Validation logic in ViewModel
        guard let amt = Double(amount), amt > 1000 else {
            setState(.showError("Invalid amount"))
            return
        }

        // ❌ Business rules in ViewModel
        let fee = amt * 0.01
        let total = amt + fee

        if total > 50_000_000 {
            setState(.showError("Amount too high"))
            return
        }

        // Process...
    }
}

Problems:

  • Cannot reuse validation/business logic
  • Hard to test logic independently
  • ViewModel becomes complex
  • Violates Single Responsibility

Fix: Move to UseCase

// ✅ Business logic in UseCase
class PaymentUseCaseImpl: PaymentUseCase {
    func execute(amount: String) -> Single<PaymentResult> {
        return validateAmount(amount)
            .flatMap { validatedAmount in
                return self.calculateFeesAndProcess(validatedAmount)
            }
    }

    private func validateAmount(_ amount: String) -> Single<Double> {
        guard let amt = Double(amount), amt > 1000 else {
            return .error(PaymentError.invalidAmount)
        }

        let fee = amt * 0.01
        let total = amt + fee

        guard total <= 50_000_000 else {
            return .error(PaymentError.amountTooHigh)
        }

        return .just(amt)
    }
}

// ✅ ViewModel simplified
class PaymentViewModel: BaseViewModel<PaymentState> {
    func processPayment(amount: String) {
        paymentUC.execute(amount: amount)
            .subscribe(
                onNext: { [weak self] result in
                    self?.setState(.success(result))
                },
                onError: { [weak self] error in
                    self?.setState(.error(error))
                }
            )
            .disposed(by: disposeBag)
    }
}

Anti-Pattern 3: UseCase Calling API Directly

class PaymentUseCaseImpl: PaymentUseCase {
    private let apiService: PaymentApiService  // ❌ Bypasses Repository!

    func execute(amount: Double) -> Single<PaymentResult> {
        // ❌ Direct API call
        return apiService.processPayment(amount: amount)
            .map { response in
                return PaymentResult(from: response)
            }
    }
}

Problems:

  • Cannot swap data sources (API/local/mock)
  • Hard to test independently
  • Violates Dependency Inversion

Fix: Add Repository layer

// ✅ Repository protocol in Domain
protocol PaymentRepository {
    func processPayment(amount: Double) -> Single<PaymentResponse>
}

// ✅ UseCase depends on protocol
class PaymentUseCaseImpl: PaymentUseCase {
    private let paymentRepository: PaymentRepository  // ✅ Correct!

    func execute(amount: Double) -> Single<PaymentResult> {
        return paymentRepository.processPayment(amount: amount)
            .map { response in
                return PaymentResult(from: response)
            }
    }
}

// ✅ Implementation in Data layer
class PaymentRepositoryImpl: PaymentRepository {
    private let apiService: PaymentApiService

    func processPayment(amount: Double) -> Single<PaymentResponse> {
        return apiService.processPayment(amount: amount)
    }
}

Refactoring Examples

Example: Refactoring ViewModel with Business Logic

Before (Violates Clean Architecture)

class TransactionViewModel: BaseViewModel<TransactionState> {
    private let apiService: TransactionApiService

    func loadTransactions(from startDate: Date, to endDate: Date) {
        // ❌ Date validation in ViewModel
        guard startDate <= endDate else {
            setState(.showError("Invalid date range"))
            return
        }

        // ❌ Business rule in ViewModel
        let daysDiff = Calendar.current.dateComponents([.day], from: startDate, to: endDate).day!
        guard daysDiff <= 90 else {
            setState(.showError("Date range too large"))
            return
        }

        // ❌ Direct API call
        apiService.getTransactions(from: startDate, to: endDate)
            .subscribe(onNext: { [weak self] transactions in
                // ❌ Business logic: filtering
                let filtered = transactions.filter { $0.amount > 0 }
                self?.transactions.accept(filtered)
            })
            .disposed(by: disposeBag)
    }
}

After (Clean Architecture)

// DOMAIN LAYER - UseCase
protocol TransactionUseCase {
    func getTransactions(from: Date, to: Date) -> Single<[Transaction]>
}

class TransactionUseCaseImpl: TransactionUseCase {
    private let repository: TransactionRepository

    func getTransactions(from startDate: Date, to endDate: Date) -> Single<[Transaction]> {
        // ✅ Validation in UseCase
        return validateDateRange(startDate, endDate)
            .flatMap { _ in
                return self.repository.getTransactions(from: startDate, to: endDate)
            }
            .map { transactions in
                // ✅ Business logic in UseCase
                return self.filterValidTransactions(transactions)
            }
    }

    private func validateDateRange(_ start: Date, _ end: Date) -> Single<Void> {
        guard start <= end else {
            return .error(TransactionError.invalidDateRange)
        }

        let daysDiff = Calendar.current.dateComponents([.day], from: start, to: end).day!
        guard daysDiff <= 90 else {
            return .error(TransactionError.dateRangeTooLarge)
        }

        return .just(())
    }

    private func filterValidTransactions(_ transactions: [Transaction]) -> [Transaction] {
        return transactions.filter { $0.amount > 0 }
    }
}

// PRESENTATION LAYER - ViewModel
class TransactionViewModel: BaseViewModel<TransactionState> {
    private let transactionUC: TransactionUseCase  // ✅ Depends on UseCase

    func loadTransactions(from startDate: Date, to endDate: Date) {
        // ✅ Simply delegates to UseCase
        transactionUC.getTransactions(from: startDate, to: endDate)
            .subscribe(
                onNext: { [weak self] transactions in
                    self?.transactions.accept(transactions)
                    self?.setState(.loaded)
                },
                onError: { [weak self] error in
                    self?.setState(.error(error))
                }
            )
            .disposed(by: disposeBag)
    }
}

Benefits:

  • Clear separation of concerns
  • Business logic testable in isolation
  • ViewModel is simple coordinator
  • Can reuse UseCase elsewhere

Testing Benefits

With Clean Architecture

class PaymentUseCaseTests: XCTestCase {
    func testExecute_WithValidAmount_ProcessesPayment() {
        // ✅ Can test UseCase in isolation
        let mockRepository = MockPaymentRepository()
        mockRepository.processPaymentResult = .just(PaymentResponse.success)

        let useCase = PaymentUseCaseImpl(paymentRepository: mockRepository)

        // Test business logic directly
        let result = try! useCase.execute(amount: "10000").toBlocking().first()!
        XCTAssertTrue(result.isSuccess)
    }

    func testExecute_WithInvalidAmount_ReturnsError() {
        let mockRepository = MockPaymentRepository()
        let useCase = PaymentUseCaseImpl(paymentRepository: mockRepository)

        // Test validation logic
        XCTAssertThrowsError(
            try useCase.execute(amount: "500").toBlocking().first()
        ) { error in
            XCTAssertEqual(error as? PaymentError, .invalidAmount)
        }
    }
}

Without Clean Architecture

// ❌ Hard to test business logic without UI/Network
class PaymentViewModelTests: XCTestCase {
    func testProcessPayment() {
        // Need to mock UI, network, and test everything together
        // Business logic mixed with presentation logic
        // Hard to isolate what's being tested
    }
}

Checklist for Reviews

## Clean Architecture Checklist

### Layer Separation
- [ ] ViewModel only depends on UseCase (not API/Repository)
- [ ] UseCase contains all business logic
- [ ] UseCase only depends on Repository protocol
- [ ] Repository implementation is in Data layer
- [ ] No business logic in ViewModel
- [ ] No business logic in Repository
- [ ] No UI code in Domain/Data layers

### Patterns
- [ ] ViewModel extends BaseViewModel<State>
- [ ] UseCase follows protocol + implementation pattern
- [ ] Repository follows protocol + implementation pattern
- [ ] State management uses setState()

### Dependency Injection
- [ ] All dependencies injected via constructor
- [ ] No direct instantiation of dependencies
- [ ] Swinject container properly configured
- [ ] Dependencies are protocol-based (not concrete types)

### Data Flow
- [ ] UI → ViewController → ViewModel → UseCase → Repository → API/DB
- [ ] Never skips layers
- [ ] Observables/Singles for async operations
- [ ] Errors properly propagated through layers