14 KiB
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