506 lines
14 KiB
Markdown
506 lines
14 KiB
Markdown
# Clean Architecture Examples
|
|
|
|
Complete examples of proper layer separation, MVVM patterns, and dependency injection.
|
|
|
|
## Complete Architecture Example
|
|
|
|
### Proper 3-Layer Implementation
|
|
|
|
#### Presentation Layer - ViewModel
|
|
```swift
|
|
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
|
|
```swift
|
|
// ✅ 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
|
|
```swift
|
|
// ✅ 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
|
|
```swift
|
|
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
|
|
|
|
```swift
|
|
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
|
|
```swift
|
|
// 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
|
|
|
|
```swift
|
|
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
|
|
```swift
|
|
// ✅ 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
|
|
|
|
```swift
|
|
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
|
|
```swift
|
|
// ✅ 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)
|
|
```swift
|
|
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)
|
|
```swift
|
|
// 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
|
|
|
|
```swift
|
|
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
|
|
|
|
```swift
|
|
// ❌ 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
|
|
|
|
```markdown
|
|
## 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
|
|
```
|