📌 For AI/Cursor Context:
- Project Type: Swift iOS app using Clean Architecture
- Package Manager: Swift Package Manager (SPM) - MANDATORY for all features
- Required External Dependencies: AugmentedSUICoordinator and Resolver - MUST be added as SPM dependencies
- Critical Rule: Every component (Screen, Repository, UseCase) MUST be an SPM package with
Package.swift- Dependency Rule: Dependencies point inward only (Presentation → UseCase → Repository → Data)
- Quick Start: See 🚀 Quick Start section below
- SPM Templates: See 📦 Package Templates section
- 📦 Required External Dependencies
- 🚀 Quick Start: Implementing a Feature
⚠️ Critical Requirements- 📦 Package Templates
- Overview
- Project Structure: SPM Feature Modules
- Clean Architecture Dependency Rule
- Architectural Rules (Non-Negotiable)
- 1. Frameworks & Drivers Layer (Data Sources)
- 2. Repository Pattern (Interface Adapters)
- 3. Use Cases Layer (THE CENTER)
- 4. Interface Adapters Layer (Presentation)
- 5. Dependency Injection
- 6. Data Flow
- 7. Boundary Interfaces
- 8. Composition Root
- 9. Testing Strategy
- 10. Anti-Patterns
- 11. Best Practices
- 12. Example: Complete Feature Implementation
For AI/Cursor implementing features:
- SPM Packages are MANDATORY - Every component (Screen, Repository, UseCase, Coordinator) must be an SPM package with
Package.swift - API/Impl Pattern - Each component has two packages:
{Component}API(protocols) and{Component}Impl(implementation) - Dependency Direction - Always points inward: Presentation → UseCase → Repository → Data
- Use Cases are the Center - They define repository interfaces; repositories implement them
- Implementation Order - Bottom-up: UseCaseAPI → UseCaseImpl → RepositoryAPI → RepositoryImpl → ScreenAPI → ScreenImpl
- Protocol-Based - All inter-layer communication uses protocols, never concrete types
- ViewModels are Thin - They delegate to UseCases and format for UI only
- Coordinator Pattern - Navigation is handled by coordinators using SUICoordinator library
- Resolver for DI - All Impl packages use Resolver for dependency injection (not API packages)
- SUICoordinator for Navigation - All Coordinator packages and ScreenAPI packages use SUICoordinator
Quick Links:
This project follows Clean Architecture as defined by Robert C. Martin (Uncle Bob). Clean Architecture is not about "layers" or "MVVM best practices" — it's about dependency rules and boundaries.
Source code dependencies must point inward. Nothing in an inner circle can know anything at all about something in an outer circle.
-
Frameworks & Drivers (Outermost)
- UI frameworks (SwiftUI, UIKit)
- Web frameworks (HTTP clients, networking)
- Database frameworks (CoreData, SQLite)
- Device services (Keychain, UserDefaults)
- Image caching services
-
Interface Adapters
- ViewModels (convert UseCase outputs to UI format)
- Presenters (format data for display)
- Controllers (handle user input)
- Gateways (convert data formats)
-
Use Cases (Interactors) (Application Business Rules)
- This is the center of the architecture
- Encapsulate all business rules
- Define repository interfaces (input boundaries)
- Define output boundaries (presenters/result models)
- Must not know about UI, frameworks, or data sources
-
Entities (Enterprise Business Rules)
- Pure business objects
- No framework dependencies
- No persistence concerns
- No UI concerns
- Use Cases are the center — everything else exists to serve them
- Frameworks are details — the architecture doesn't know about SwiftUI, Combine, or URLSession
- Dependencies point inward — inner layers define interfaces, outer layers implement them
- Business rules live in Use Cases — nowhere else
This project requires two external Swift Package Manager (SPM) dependencies that must be fetched and added to the project before implementing any features:
Repository: https://github.com/kiarashvosough1999/AugmentedSUICoordinator
Purpose: Navigation coordinator library for SwiftUI. Provides the coordinator pattern implementation used throughout the project for managing navigation flows.
Required For:
- All Coordinator packages (both API and Impl)
- ScreenAPI packages that define coordinator protocols
How to Add:
- In Xcode:
File→Add Package Dependencies... - Enter the repository URL:
https://github.com/kiarashvosough1999/AugmentedSUICoordinator.git - Select the version/branch as needed
- Add to your target
Usage in Package.swift:
dependencies: [
.package(url: "https://github.com/kiarashvosough1999/AugmentedSUICoordinator.git", from: "1.0.0")
]See Section 4.6.2: Adding SUICoordinator to SPM Packages for detailed integration instructions.
Repository: https://github.com/hmlongco/Resolver
Purpose: Dependency injection framework. Used for managing and resolving dependencies throughout the application.
Required For:
- All Impl packages (UseCaseImpl, RepositoryImpl, ScreenImpl, CoordinatorImpl)
- NOT required for API packages
How to Add:
- In Xcode:
File→Add Package Dependencies... - Enter the repository URL:
https://github.com/hmlongco/Resolver.git - Select version
1.5.1or later - Add to your target
Usage in Package.swift:
dependencies: [
.package(url: "https://github.com/hmlongco/Resolver.git", from: "1.5.1")
]See Section 5.1: Adding Resolver to SPM Packages for detailed integration instructions.
⚠️ These dependencies are mandatory and must be added to the project before implementing features⚠️ Each SPM package in the project will reference these dependencies in theirPackage.swiftfiles⚠️ The main Xcode project must have these packages added as dependencies first- ✅ Once added to the main project, individual SPM packages can reference them in their
Package.swiftfiles
When implementing ANY feature, follow these steps in order:
Every component MUST be an SPM package with Package.swift. See CRITICAL REQUIREMENT section for detailed steps.
Minimum structure for a feature:
Features/{Feature}/
├── Presentation/{Screen}/{Screen}API/ (with Package.swift)
├── Presentation/{Screen}/{Screen}Impl/ (with Package.swift)
├── Repositories/{Repository}/{Repository}API/ (with Package.swift)
├── Repositories/{Repository}/{Repository}Impl/ (with Package.swift)
├── UseCases/{UseCase}/{UseCase}API/ (with Package.swift)
└── UseCases/{UseCase}/{UseCase}Impl/ (with Package.swift)
- UseCaseAPI - Define protocol and repository protocol
- UseCaseImpl - Implement use case
- RepositoryAPI - Define data protocols
- RepositoryImpl - Implement repository
- ScreenAPI - Define screen protocol
- ScreenImpl - Implement view and view model
- ✅ Use Cases define Repository interfaces (not vice versa)
- ✅ Dependencies point inward only
- ✅ Every component is an SPM package
- ✅ API packages contain only protocols, no implementation
- ❌ Never put implementation in API packages
- ❌ Never make Impl packages depend on other Impl packages
Add Resolver dependency to all Impl packages (UseCaseImpl, RepositoryImpl, ScreenImpl) in their Package.swift files. Do NOT add Resolver to API packages.
See Section 5.1: Adding Resolver to SPM Packages for detailed instructions.
Add SUICoordinator dependency to:
- All Coordinator packages (both API and Impl)
- ScreenAPI packages that define coordinator protocols
See Section 4.6.2: Adding SUICoordinator to SPM Packages for detailed instructions.
If your feature requires navigation, create coordinator packages following the coordinator pattern. Each coordinator consists of:
{Feature}CoordinatorAPI- Defines coordinator protocol{Feature}CoordinatorImpl- Implements coordinator using SUICoordinator
See Section 4.6: Coordinator Pattern for detailed implementation guide.
Add all implementations to the dependency injection container in PayRed/DI/DIRegisterar.swift using Resolver's registration API.
See Section 5.2: Registration Pattern for detailed examples.
This project uses Swift Package Manager (SPM) to organize code into feature modules. Each feature is a self-contained module with clear boundaries, enabling:
- Modularity: Features can be developed, tested, and maintained independently
- Dependency Management: Clear compile-time dependencies between modules
- Encapsulation: Internal implementation details are hidden behind API packages
- Reusability: Features can be reused across different projects
- Testability: Each module can be tested in isolation
SPM is essential for this architecture because:
- Feature Isolation: Each feature is a separate SPM package, preventing accidental dependencies between features
- API/Impl Separation: SPM packages enforce the separation between public APIs (protocols) and private implementations
- Dependency Graph: SPM's dependency resolution ensures the dependency rule is enforced at compile time
- Incremental Compilation: Only changed modules need to be recompiled
- Module Boundaries: SPM packages create explicit boundaries that prevent architectural violations
When implementing ANY feature in this project, you MUST create SPM packages. This is NOT optional.
Every single component in a feature (Screen, Repository, UseCase) MUST be organized as separate SPM packages with:
- ✅ A
Package.swiftfile in each API and Impl directory - ✅ Proper
Sources/directory structure - ✅ Correct package dependencies configured
DO NOT:
- ❌ Create Swift files without a
Package.swiftfile - ❌ Put multiple components in a single package
- ❌ Skip creating API/Impl package separation
- ❌ Create folders without SPM package structure
When implementing a new feature (e.g., PaymentFeature), follow these steps:
Features/PaymentFeature/
├── Presentation/
│ └── PaymentScreen/
│ ├── PaymentScreenAPI/ # Create this directory
│ └── PaymentScreenImpl/ # Create this directory
├── Repositories/
│ └── PaymentRepository/
│ ├── PaymentRepositoryAPI/ # Create this directory
│ └── PaymentRepositoryImpl/ # Create this directory
└── UseCases/
└── PaymentUseCase/
├── PaymentUseCaseAPI/ # Create this directory
└── PaymentUseCaseImpl/ # Create this directoryFor each API package (e.g., PaymentUseCaseAPI):
-
Create the directory structure:
mkdir -p Features/PaymentFeature/UseCases/PaymentUseCase/PaymentUseCaseAPI/Sources/PaymentUseCaseAPI
-
Create
Package.swiftfile inPaymentUseCaseAPI/:// swift-tools-version: 5.9 import PackageDescription let package = Package( name: "PaymentUseCaseAPI", platforms: [ .iOS(.v16) ], products: [ .library( name: "PaymentUseCaseAPI", targets: ["PaymentUseCaseAPI"] ), ], dependencies: [ // Only API packages, no Impl packages ], targets: [ .target( name: "PaymentUseCaseAPI", dependencies: [] ), ] )
-
Create protocol files in
Sources/PaymentUseCaseAPI/:PaymentUseCaseProtocol.swiftPaymentUseCaseRepositoryProtocol.swiftPaymentUseCaseNameSpace.swift
For each Impl package (e.g., PaymentUseCaseImpl):
-
Create the directory structure:
mkdir -p Features/PaymentFeature/UseCases/PaymentUseCase/PaymentUseCaseImpl/Sources/PaymentUseCaseImpl
-
Create
Package.swiftfile inPaymentUseCaseImpl/:// swift-tools-version: 5.9 import PackageDescription let package = Package( name: "PaymentUseCaseImpl", platforms: [ .iOS(.v16) ], products: [ .library( name: "PaymentUseCaseImpl", targets: ["PaymentUseCaseImpl"] ), ], dependencies: [ .package(path: "../PaymentUseCaseAPI"), // Depends on API package ], targets: [ .target( name: "PaymentUseCaseImpl", dependencies: [ .product(name: "PaymentUseCaseAPI", package: "PaymentUseCaseAPI") ] ), ] )
-
Create implementation files in
Sources/PaymentUseCaseImpl/:PaymentUseCaseImpl.swift
You must create SPM packages for:
- ✅ Every Screen (ScreenAPI + ScreenImpl)
- ✅ Every Repository (RepositoryAPI + RepositoryImpl)
- ✅ Every UseCase (UseCaseAPI + UseCaseImpl)
Total packages per feature: 6 SPM packages minimum (3 API + 3 Impl)
After creating all SPM packages:
- Open Xcode project
- File → Add Package Dependencies...
- Add Local packages for each created package
- Or add them via project settings → Package Dependencies
API Package Template:
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "{Component}API",
platforms: [.iOS(.v16)],
products: [
.library(name: "{Component}API", targets: ["{Component}API"])
],
dependencies: [
// Only other API packages
],
targets: [
.target(name: "{Component}API", dependencies: [])
]
)Impl Package Template:
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "{Component}Impl",
platforms: [.iOS(.v17)],
products: [
.library(name: "{Component}Impl", targets: ["{Component}Impl"])
],
dependencies: [
.package(path: "../{Component}API"),
.package(
url: "https://github.com/hmlongco/Resolver.git",
from: "1.5.1"
),
],
targets: [
.target(
name: "{Component}Impl",
dependencies: [
.product(name: "{Component}API", package: "{Component}API"),
.product(name: "Resolver", package: "Resolver")
]
)
]
)- Resolver is REQUIRED for all Impl packages
- Resolver is NOT added to API packages
- Use version
1.5.1or later for Resolver - Always add Resolver to both
dependenciesand targetdependenciesarrays
Before considering a feature complete, verify:
- Every API directory has a
Package.swiftfile - Every Impl directory has a
Package.swiftfile - All
Package.swiftfiles have correct dependencies - Resolver is NOT added to API packages
- Resolver IS added to all Impl packages
- SUICoordinator IS added to all Coordinator packages (API and Impl)
- SUICoordinator IS added to ScreenAPI packages that define coordinator protocols
- All packages are added to Xcode project
- Project builds successfully with all SPM packages
- No Swift files exist outside of SPM package structure
- All implementations are registered in
DIRegisterar.swift - Coordinators are registered in
DIRegisterar.swift(if applicable)
Each feature follows the API/Impl pattern with separate SPM packages:
Features/{Feature}/
├── Presentation/
│ └── {Screen}/
│ ├── {Screen}API/ # SPM Package: Public protocols & dependencies
│ │ ├── Package.swift
│ │ └── Sources/
│ │ └── {Screen}API/
│ │ ├── {Screen}Protocol.swift
│ │ └── {Screen}Dependencies.swift
│ └── {Screen}Impl/ # SPM Package: Implementation
│ ├── Package.swift
│ └── Sources/
│ └── {Screen}Impl/
│ ├── {Screen}View.swift
│ └── {Screen}ViewModel.swift
│
├── Repositories/
│ └── {Repository}/
│ ├── {Repository}API/ # SPM Package: Data protocols & interfaces
│ │ ├── Package.swift
│ │ └── Sources/
│ │ └── {Repository}API/
│ │ ├── {Repository}RemoteDataProtocol.swift
│ │ ├── {Repository}DataBaseDataProtocol.swift
│ │ └── {Repository}KeyChainDataProtocol.swift
│ └── {Repository}Impl/ # SPM Package: Repository implementation
│ ├── Package.swift
│ └── Sources/
│ └── {Repository}Impl/
│ └── {Repository}Impl.swift
│
└── UseCases/
└── {UseCase}/
├── {UseCase}API/ # SPM Package: UseCase protocol & repository protocol
│ ├── Package.swift
│ └── Sources/
│ └── {UseCase}API/
│ ├── {UseCase}Protocol.swift
│ ├── {UseCase}RepositoryProtocol.swift
│ └── {UseCase}NameSpace.swift
└── {UseCase}Impl/ # SPM Package: UseCase implementation
├── Package.swift
└── Sources/
└── {UseCase}Impl/
└── {UseCase}Impl.swift
API Packages ({Feature}API):
- Purpose: Define public interfaces (protocols) and data models
- Visibility: Public - can be imported by other modules
- Contains:
- Protocol definitions
- Domain models (namespaces)
- Dependency interfaces
- No implementation code
- Dependencies: Only other API packages (if needed)
Impl Packages ({Feature}Impl):
- Purpose: Implement the protocols defined in API packages
- Visibility: Internal - implementation details are hidden
- Contains:
- Concrete implementations
- Business logic
- Framework-specific code
- Dependencies:
- Corresponding API package (to implement protocols)
- Other API packages (to use their interfaces)
- Never depends on other Impl packages directly
Features/Login/
├── Presentation/
│ └── LoginScreen/
│ ├── LoginScreenAPI/
│ │ ├── Package.swift
│ │ └── Sources/LoginScreenAPI/
│ │ ├── LoginScreenProtocol.swift
│ │ └── LoginScreenDependencies.swift
│ └── LoginScreenImpl/
│ ├── Package.swift
│ └── Sources/LoginScreenImpl/
│ ├── LoginScreenView.swift
│ └── LoginScreenViewModel.swift
│
├── Repositories/
│ └── LoginRepository/
│ ├── LoginRepositoryAPI/
│ │ ├── Package.swift
│ │ └── Sources/LoginRepositoryAPI/
│ │ ├── LoginRepositoryRemoteDataProtocol.swift
│ │ ├── LoginRepositoryDataBaseDataProtocol.swift
│ │ └── LoginRepositoryNameSpace.swift
│ └── LoginRepositoryImpl/
│ ├── Package.swift
│ └── Sources/LoginRepositoryImpl/
│ └── LoginRepositoryImpl.swift
│
└── UseCases/
└── LoginUseCase/
├── LoginUseCaseAPI/
│ ├── Package.swift
│ └── Sources/LoginUseCaseAPI/
│ ├── LoginUseCaseProtocol.swift
│ ├── LoginUseCaseRepositoryProtocol.swift
│ └── LoginUseCaseNameSpace.swift
└── LoginUseCaseImpl/
├── Package.swift
└── Sources/LoginUseCaseImpl/
└── LoginUseCaseImpl.swift
LoginUseCaseAPI/Package.swift:
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "LoginUseCaseAPI",
platforms: [
.iOS(.v16)
],
products: [
.library(
name: "LoginUseCaseAPI",
targets: ["LoginUseCaseAPI"]
),
],
dependencies: [
// Only API packages, no Impl packages
],
targets: [
.target(
name: "LoginUseCaseAPI",
dependencies: []
),
]
)LoginUseCaseImpl/Package.swift:
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "LoginUseCaseImpl",
platforms: [
.iOS(.v16)
],
products: [
.library(
name: "LoginUseCaseImpl",
targets: ["LoginUseCaseImpl"]
),
],
dependencies: [
.package(path: "../LoginUseCaseAPI"), // Depends on API package
],
targets: [
.target(
name: "LoginUseCaseImpl",
dependencies: [
.product(name: "LoginUseCaseAPI", package: "LoginUseCaseAPI")
]
),
]
)Data layer implementations are organized as SPM packages that implement protocols from RepositoryAPI packages:
Data/
├── MobileGatewayService/
│ └── MobileGatewayServiceImpl/
│ ├── Package.swift
│ └── Sources/
│ └── MobileGatewayServiceImpl/
│ ├── NetworkService.swift
│ ├── NetworkService+LoginRepository.swift
│ └── NetworkService+CardListRepository.swift
│
├── DataBase/
│ └── DataBase/
│ ├── Package.swift
│ └── Sources/
│ └── DataBase/
│ ├── Database.swift
│ ├── Database+LoginRepository.swift
│ └── Database+CardListRepository.swift
│
├── Keychain/
│ └── KeyChainDataImpl/
│ ├── Package.swift
│ └── Sources/
│ └── KeyChainDataImpl/
│ └── KeychainService.swift
│
└── ImageCache/
└── ImageCacheImpl/
├── Package.swift
└── Sources/
└── ImageCacheImpl/
└── ImageCacheImpl.swift
SPM enforces the dependency rule at compile time:
- API packages can only depend on other API packages
- Impl packages depend on their corresponding API package and other API packages
- Impl packages never depend on other Impl packages directly
- Data layer packages implement protocols from RepositoryAPI packages
✅ Compile-time Safety: SPM prevents circular dependencies and architectural violations
✅ Clear Boundaries: Package boundaries make layer separation explicit
✅ Incremental Builds: Only changed packages are recompiled
✅ Module Isolation: Features can be developed independently
✅ Dependency Graph: SPM shows the dependency structure clearly
✅ Reusability: Features can be extracted and reused in other projects
✅ Testing: Each package can have its own test target
The Dependency Rule (Non-Negotiable): Source code dependencies can only point inward. Nothing in an inner circle can know anything about something in an outer circle.
graph TB
subgraph Frameworks["Frameworks & Drivers<br/>(Outermost Layer)"]
SwiftUI["SwiftUI Views"]
ImageCache["Image Cache"]
URLSession["URLSession/HTTP"]
CoreData["CoreData"]
Keychain["Keychain"]
end
subgraph Adapters["Interface Adapters"]
ViewModel["ViewModels<br/>(State Mapping)"]
Presenter["Presenters<br/>(Data Formatting)"]
Controller["Controllers<br/>(Input Handling)"]
Gateway["Gateways<br/>(Data Conversion)"]
end
subgraph UseCases["Use Cases<br/>(Application Business Rules)<br/>THE CENTER"]
UseCaseImpl["UseCase Implementation"]
UseCaseProtocol["UseCase Protocol<br/>(Input Boundary)"]
OutputBoundary["Output Boundary<br/>(Result Models)"]
RepoInterface["Repository Interface<br/>(Defined by UseCase)"]
end
subgraph Entities["Entities<br/>(Enterprise Business Rules)"]
DomainEntity["Domain Entities<br/>(Pure Business Objects)"]
end
Frameworks -->|depends on| Adapters
Adapters -->|depends on| UseCases
UseCases -->|depends on| Entities
style Frameworks fill:#ffcdd2
style Adapters fill:#fff9c4
style UseCases fill:#c8e6c9
style Entities fill:#bbdefb
Note: The diagram below shows how Clean Architecture is implemented in this project. The diagram is also available as a Mermaid file:
docs/diagrams/architecture-diagram.mmd
graph TB
subgraph Presentation["Presentation Layer"]
View["View<br/>(SwiftUI)"]
ViewModel["ViewModel<br/>(Observable)"]
ScreenAPI["ScreenAPI<br/>(Protocol)"]
ImageCache["Image Cache<br/>(Caching)"]
View --> ViewModel
ViewModel --> ScreenAPI
ViewModel -.-> ImageCache
end
subgraph UseCase["UseCase Layer"]
UseCaseImpl["UseCaseImpl<br/>(Business Logic)"]
UseCaseAPI["UseCaseAPI<br/>(Protocol)"]
RepoProtocol["RepositoryProtocol<br/>(from UseCaseAPI)"]
UseCaseImpl -.implements.-> UseCaseAPI
UseCaseImpl -->|uses| RepoProtocol
end
subgraph Repository["Repository Layer"]
RepoImpl["RepositoryImpl<br/>(Orchestrates Data Sources)"]
DataProtocols["DataProtocols<br/>(from RepositoryAPI)"]
RepoImpl -.implements.-> RepoProtocol
RepoImpl -->|uses| DataProtocols
end
subgraph Data["Data Layer"]
RemoteData["Remote Data<br/>(API Calls)"]
Database["Database<br/>(CoreData)"]
Keychain["Keychain<br/>(Secure)"]
RemoteData -.implements.-> DataProtocols
Database -.implements.-> DataProtocols
Keychain -.implements.-> DataProtocols
end
Presentation -->|uses| UseCase
UseCase -->|implemented by| Repository
Repository -->|uses| Data
style Presentation fill:#e1f5ff
style UseCase fill:#fff4e1
style Repository fill:#e8f5e9
style Data fill:#fce4ec
These rules are hard constraints that must never be violated. They are the foundation of Clean Architecture.
✅ Allowed: Outer layers depend on inner layers
❌ Forbidden: Inner layers depend on outer layers
Examples of violations:
- UseCase importing SwiftUI
- UseCase importing URLSession
- Domain entity conforming to Codable
- UseCase knowing about ViewModels
✅ Allowed: Use Cases contain all business rules
❌ Forbidden: Business rules in ViewModels, Repositories, or Data layers
What this means:
- Validation logic → Use Case
- Business decisions → Use Case
- State machine logic → Use Case
- ViewModels only map/format data
✅ Allowed: Frameworks in outermost layer only
❌ Forbidden: Framework types crossing boundaries
Forbidden imports in Use Cases:
import SwiftUIimport Combine(unless for pure reactive interfaces)import Foundationnetworking typesimport CoreData
✅ Allowed: Use Cases define repository interfaces
❌ Forbidden: Data layer defining what Use Cases need
Correct pattern:
// UseCaseAPI defines the interface
protocol LoginUseCaseRepositoryProtocol {
func login(email: String) async throws -> LoginResponse
}
// RepositoryImpl implements it
// Data implementations provide the data✅ Allowed: Pure Swift types with business logic
❌ Forbidden: Framework pollution in domain models
Forbidden in domain entities:
Codableconformance- Database IDs
- API field names
- Framework-specific types
✅ Allowed: State mapping, event forwarding, UI formatting
❌ Forbidden: Business logic, validation, decision-making
ViewModel responsibilities:
- Convert UseCase outputs to UI format
- Forward user events to UseCases
- Format data for display
- Manage UI-specific state (loading indicators, etc.)
Not ViewModel responsibilities:
- Validating business rules
- Making business decisions
- Fetching data directly
- Containing business logic
Purpose: Framework details and device services
Contains:
- SwiftUI Views (UI framework)
- Image Cache (caching service)
- Network clients (URLSession, Alamofire)
- Database frameworks (CoreData)
- Keychain services
- UserDefaults
Rules:
- Can depend on Interface Adapters
- Must not be referenced by inner layers
- Framework-specific implementations only
Purpose: Convert data between Use Cases and Frameworks
Contains:
- ViewModels (convert UseCase outputs to UI)
- Presenters (format data for display)
- Controllers (handle user input)
- Gateways (convert between data formats)
Rules:
- Depends on Use Cases (inner)
- Depends on Frameworks (outer)
- No business logic
- Only data transformation
Purpose: Application business rules
Contains:
- UseCase implementations
- UseCase protocols (input boundaries)
- Repository interfaces (defined here)
- Output boundaries (result models)
- Business validation
- State machines
- Business decisions
Rules:
- This is the center — everything serves Use Cases
- Depends only on Entities (inner)
- Defines interfaces for outer layers
- Contains ALL business rules
- Framework-independent
Purpose: Enterprise business rules
Contains:
- Pure domain models
- Business value objects
- Domain logic
Rules:
- No dependencies on other layers
- Pure Swift types
- No framework types
- No persistence concerns
Clean Architecture Position: Outermost layer (Frameworks & Drivers)
Purpose: Framework-specific implementations for data persistence and retrieval. These are details that the architecture doesn't care about.
Important: This layer implements interfaces defined by Use Cases (through Repository interfaces). The Use Cases don't know these implementations exist.
Location: Data/{ServiceName}/{ServiceName}Impl/
Purpose: Handles all network communication with the backend API.
Key Components:
- Service Implementation: Main service class that performs HTTP requests (e.g., using Alamofire, URLSession)
- Protocol Conformance: Implements
*RepositoryRemoteDataProtocolinterfaces from RepositoryAPI package - Features:
- Request/response handling
- Authentication interceptors
- SSL certificate pinning configuration (optional)
- Error handling and logging
Example Implementation:
// NetworkService+LoginRepository.swift
import LoginRepositoryAPI // Contains LoginRepositoryRemoteDataProtocol
// Data implementation IMPLEMENTS DataProtocol from RepositoryAPI
extension NetworkService: LoginRepositoryRemoteDataProtocol { // From LoginRepositoryAPI
public func login(email: String) async throws -> LoginRepositoryNameSpace.LoginOrRegisterRemoteResult {
let request = RequestBuilder(
endpoint: "/login",
method: .post,
body: LoginRequest(email: email)
)
let result: ResponseModel<LoginResponse> =
try await performRequest(request: request)
return mapToLoginResult(result.payload)
}
}Characteristics:
- Each feature has its own extension file (e.g.,
NetworkService+LoginRepository.swift) - Implements
DataProtocolsfrom RepositoryAPI package - Uses request builders for constructing API calls
- Handles request/response transformation
- Returns domain-specific entities defined in Repository namespace
Location: Data/Database/{DatabaseImpl}/
Purpose: Provides local persistence (e.g., CoreData, SQLite, Realm).
Key Components:
- Database Manager: Main database stack manager
- Entities: Database managed objects (e.g.,
LoginStateEntity,ProfileEntity) - Protocol Conformance: Implements
*RepositoryDataBaseDataProtocolinterfaces from RepositoryAPI package
Example Implementation (CoreData):
// Database+LoginRepository.swift
import LoginRepositoryAPI // Contains LoginRepositoryDataBaseDataProtocol
// Data implementation IMPLEMENTS DataProtocol from RepositoryAPI
extension Database: LoginRepositoryDataBaseDataProtocol { // From LoginRepositoryAPI
public func createUserState(with email: String, temporaryToken: String?) async throws {
try await context.perform(schedule: .enqueued) { [context] in
let entity = LoginStateEntity(context: context)
entity.email = email
entity.state = .idle
entity.createdAt = Date()
entity.temporaryToken = temporaryToken
try context.save()
}
}
public func doesUserStateExist(email: String) async throws -> Bool {
try await context.perform(schedule: .enqueued) { [context] in
let request: NSFetchRequest<LoginStateEntity> = LoginStateEntity.fetchRequest()
request.predicate = NSPredicate(format: "email ==[c] %@", email)
request.fetchLimit = 1
let count = try context.count(for: request)
return count > 0
}
}
}Characteristics:
- All database operations are async/await
- Uses thread-safe database access patterns
- Each feature has its own extension file
- Returns domain-specific entities
Location: Data/Keychain/{KeychainImpl}/
Purpose: Stores sensitive data securely (tokens, PINs, biometric data).
Protocol Conformance: Implements *RepositoryKeyChainDataProtocol interfaces from RepositoryAPI package
Example Usage:
- Storing app PIN
- Storing authentication tokens
- Storing biometric state
Location: Data/UserDefaults/{UserDefaultsImpl}/
Purpose: Stores simple key-value preferences.
Protocol Conformance: Implements *RepositoryUserDefaultsDataProtocol interfaces from RepositoryAPI package
Clean Architecture Position: Interface Adapters layer
Critical Understanding:
- Repository interfaces are defined in Use Cases (inner layer)
- Repository implementations live in Interface Adapters (outer layer)
- Data sources are in Frameworks & Drivers (outermost layer)
Key Principle:
- Use Cases define repository interfaces (input boundaries)
- RepositoryImpl implements
RepositoryProtocolfrom UseCaseAPI (defined by Use Case) - RepositoryImpl uses (depends on)
DataProtocolsfrom RepositoryAPI - Data implementations implement
DataProtocolsfrom RepositoryAPI
Important: Repositories do NOT "fetch data" — they abstract how data is obtained. The Use Case requests data, the Repository provides it through the abstraction.
📋 IMPLEMENTATION INSTRUCTIONS FOR AI/CURSOR:
- Create TWO SPM packages:
{Repository}APIand{Repository}Impl- Each MUST have a
Package.swiftfileRepositoryAPIdefines data protocols (RemoteDataProtocol, DataBaseDataProtocol, etc.)RepositoryImplimplementsRepositoryProtocolfromUseCaseAPIpackageRepositoryImpldepends on data protocols fromRepositoryAPIpackage- See CRITICAL REQUIREMENT section for Package.swift templates
Each repository is organized as separate SPM packages following this structure:
Features/{Feature}/Repositories/{Repository}/
├── {Repository}API/ # SPM Package
│ ├── Package.swift
│ └── Sources/
│ └── {Repository}API/
│ ├── {Repository}RemoteDataProtocol.swift # Data protocol (defined here, Data Layer implements)
│ ├── {Repository}DataBaseDataProtocol.swift # Data protocol (defined here, Data Layer implements)
│ ├── {Repository}KeyChainDataProtocol.swift # Data protocol (if needed)
│ └── {Repository}NameSpace.swift # Domain models
└── {Repository}Impl/ # SPM Package
├── Package.swift
└── Sources/
└── {Repository}Impl/
└── {Repository}Impl.swift # Implementation
Features/{Feature}/UseCases/{UseCase}/
└── {UseCase}API/ # SPM Package
├── Package.swift
└── Sources/
└── {UseCase}API/
└── {UseCase}RepositoryProtocol.swift # Repository protocol (defined here, RepositoryImpl implements)
Important:
RepositoryProtocolis defined in UseCaseAPI SPM packageDataProtocolsare defined in RepositoryAPI SPM package- Each
APIandImplis a separate SPM package with its ownPackage.swift
Example: LoginRepository
// LoginRepositoryImpl.swift
import LoginRepositoryAPI // Contains DataProtocols (RemoteDataProtocol, DataBaseDataProtocol)
import LoginUseCaseAPI // Contains RepositoryProtocol (LoginUseCaseRepositoryProtocol)
public final class LoginRepositoryImpl: @unchecked Sendable {
// Dependencies: RepositoryImpl USES (depends on) Data Protocols from RepositoryAPI
private let remoteData: LoginRepositoryRemoteDataProtocol // From LoginRepositoryAPI
private let datbaseData: LoginRepositoryDataBaseDataProtocol // From LoginRepositoryAPI
public init() {
self.remoteData = DependencyContainer.resolve(LoginRepositoryRemoteDataProtocol.self)
self.datbaseData = DependencyContainer.resolve(LoginRepositoryDataBaseDataProtocol.self)
}
}
// MARK: - LoginUseCaseRepositoryProtocol
// RepositoryImpl IMPLEMENTS the protocol from UseCaseAPI (what UseCase depends on)
extension LoginRepositoryImpl: LoginUseCaseRepositoryProtocol { // From LoginUseCaseAPI
public func login(email: String) async throws -> LoginUseCaseNameSpace.LoginResponse {
// 1. Call remote API (using Data Protocol)
let remoteData = try await remoteData.login(email: email)
// 2. Transform remote data to UseCase domain model
return if let temporaryToken = remoteData.token, case .verifyEmail = remoteData.step {
.userNotExist(temporaryToken: temporaryToken)
} else {
.userExist
}
}
public func saveUserLoginState(email: String, temporaryToken: String?, userExist: Bool) async throws {
// 1. Check if state exists (using Data Protocol)
if try await datbaseData.doesUserStateExist(email: email) == false {
try await datbaseData.createUserState(with: email, temporaryToken: temporaryToken)
}
// 2. Update state based on user existence
if userExist {
try await datbaseData.updateUserLoginStateToShouldEnterPin(
email: email,
temporaryToken: temporaryToken,
userExist: userExist
)
} else {
try await datbaseData.updateUserLoginStateToShouldVerify(
email: email,
temporaryToken: temporaryToken,
userExist: userExist
)
}
}
public func doesUserExist(email: String) async throws -> Bool {
try await datbaseData.doesUserStateExist(email: email)
}
// Example: Fetching profile with image URLs (NOT downloading images)
public func fetchProfile() async throws -> ProfileEntity {
let remoteProfile = try await remoteData.fetchProfile()
// Return profile with image URLs - do NOT download images here
return ProfileEntity(
firstName: remoteProfile.firstName,
lastName: remoteProfile.lastName,
profileImageURL: remoteProfile.profileImageURL, // Pass URL only
favorites: remoteProfile.favorites.map { favorite in
FavoriteEntity(
id: favorite.id,
name: favorite.name,
imageURL: favorite.imageURL // Pass URL only
)
}
)
}
}Important Note: Repositories should never download images. They should only pass image URLs. The Presentation Layer (ViewModels) will use Image Cache to download and cache images when needed.
- Data Orchestration: Combines data from multiple sources (Remote, Database, Keychain, etc.)
- Data Transformation: Converts Data Layer models to UseCase domain models
- Caching Strategy: Decides when to use cached data vs. fetching fresh data
- Error Handling: Transforms Data Layer errors to domain errors
- Image URLs Only: Repositories should pass image URLs, never download images. Image downloading is handled by Presentation Layer.
public func fetchProfile() async throws -> ProfileEntity? {
// Try database first for offline support
if let databaseProfile = try await database.fetchProfile() {
return databaseProfile
}
// Fetch from remote
let remoteProfile = try await remote.fetchProfile()
// Cache in database
try await database.saveProfile(remoteProfile)
return remoteProfile
}public func login(email: String) async throws -> LoginResponse {
// Always fetch from remote
let remoteData = try await remoteData.login(email: email)
// Transform and return
return mapToLoginResponse(remoteData)
}public func fetchProfile() async throws -> ProfileEntity {
// Fetch from remote
let remoteProfile = try await remote.fetchProfile()
// Save to database
try await database.saveProfile(remoteProfile)
// Return data with image URLs (NOT downloading images)
// Image downloading is handled by Presentation Layer using Image Cache
return ProfileEntity(
firstName: remoteProfile.firstName,
lastName: remoteProfile.lastName,
favorites: remoteProfile.favorites.map { favorite in
FavoriteEntity(
id: favorite.id,
name: favorite.name,
imageURL: favorite.imageURL // Pass URL, not downloaded image
)
}
)
}Important: Repositories should never download images. They should only pass image URLs. Image downloading and caching is handled directly by the Presentation Layer using Image Cache.
Clean Architecture Position: Application Business Rules (THE CENTER)
Uncle Bob: "Use cases encapsulate all of the business rules of the system."
This is the most important layer. Everything else exists to serve Use Cases.
Key Principles:
- Use Cases contain ALL business rules
- Use Cases define repository interfaces (input boundaries)
- Use Cases define output boundaries (result models)
- Use Cases are framework-independent except Combine from Apple(Ask before using).
- Use Cases do NOT know about UI, ViewModels, or data sources
What belongs in Use Cases:
- Business validation
- Business decisions
- State machines
- Business workflows
- Domain logic coordination
What does NOT belong in Use Cases:
- UI concerns
- Framework types (SwiftUI)
- Data source details
- Presentation formatting
📋 IMPLEMENTATION INSTRUCTIONS FOR AI/CURSOR:
- Create TWO SPM packages:
{UseCase}APIand{UseCase}Impl- Each MUST have a
Package.swiftfileUseCaseAPIdefines: UseCase protocol AND Repository protocolUseCaseImplimplements UseCase protocol fromUseCaseAPIUseCaseImpldepends on Repository protocol fromUseCaseAPI(not RepositoryImpl!)- Use Cases are the CENTER - they define repository interfaces
- See CRITICAL REQUIREMENT section for Package.swift templates
Each UseCase is organized as separate SPM packages following this structure:
Features/{Feature}/UseCases/{UseCase}/
├── {UseCase}API/ # SPM Package
│ ├── Package.swift
│ └── Sources/
│ └── {UseCase}API/
│ ├── {UseCase}Protocol.swift # Public interface (what ViewModel depends on)
│ ├── {UseCase}RepositoryProtocol.swift # Repository protocol (defined here, RepositoryImpl implements)
│ └── {UseCase}NameSpace.swift # Domain models
└── {UseCase}Impl/ # SPM Package
├── Package.swift
└── Sources/
└── {UseCase}Impl/
└── {UseCase}Impl.swift # Implementation
Important:
RepositoryProtocolis defined in UseCaseAPI SPM packageRepositoryImplimplements this protocol from UseCaseAPI- Each
APIandImplis a separate SPM package with its ownPackage.swift
Example: LoginUseCase
// LoginUseCaseImpl.swift
import LoginUseCaseAPI // Contains UseCaseProtocol and RepositoryProtocol
public final class LoginUseCaseImpl: @unchecked Sendable {
// Dependency: UseCaseImpl USES (depends on) Repository Protocol from UseCaseAPI
private let loginRepository: LoginUseCaseRepositoryProtocol // From LoginUseCaseAPI
private let stateSubject: CurrentValueSubject<LoginUseCaseNameSpace.States, Never>
public init() {
self.loginRepository = DependencyContainer.resolve(LoginUseCaseRepositoryProtocol.self)
self.stateSubject = CurrentValueSubject<LoginUseCaseNameSpace.States, Never>(
.idle(email: "", acceptedTerms: false)
)
}
}
// MARK: - LoginUseCaseProtocol
// UseCaseImpl IMPLEMENTS the protocol from UseCaseAPI (what ViewModel depends on)
extension LoginUseCaseImpl: LoginUseCaseProtocol { // From LoginUseCaseAPI
public var statePublisher: AnyPublisher<LoginUseCaseNameSpace.States, Never> {
stateSubject.eraseToAnyPublisher()
}
public func login(email: String) async throws {
// 1. Validate input
guard isValidEmail(email) else {
stateSubject.send(.error(
email: email,
acceptedTerms: false,
error: LoginUseCaseNameSpace.Errors.invalidEmailFormat
))
throw LoginUseCaseNameSpace.Errors.invalidEmailFormat
}
// 2. Check current state
switch stateSubject.value {
case .idle(_, let acceptedTerms):
guard acceptedTerms else {
throw LoginUseCaseNameSpace.Errors.accpetTermsAndConditions
}
// 3. Update state to loading
stateSubject.send(.loggingIn(email: email, acceptedTerms: acceptedTerms))
// 4. Call repository
let loginResponse = try await loginRepository.login(email: email)
// 5. Handle response
switch loginResponse {
case .userExist:
try await loginRepository.saveUserLoginState(
email: email,
temporaryToken: nil,
userExist: true
)
stateSubject.send(.loggedIn(
email: email,
registertaion: .alreadyRegistered,
acceptedTerms: true
))
case .userNotExist(let temporaryToken):
try await loginRepository.saveUserLoginState(
email: email,
temporaryToken: temporaryToken,
userExist: false
)
stateSubject.send(.loggedIn(
email: email,
registertaion: .notRegisteredBefore,
acceptedTerms: true
))
}
// 6. Clear state after completion
clear()
case .loggingIn:
throw LoginUseCaseNameSpace.Errors.unableToLoginWhileLogginIn
case .loggedIn:
throw LoginUseCaseNameSpace.Errors.unableToLoginWhileLoggedIn
case .error:
// Handle error state
break
}
}
}- Business Logic: Implements feature-specific business rules
- State Management: Manages state using Combine publishers
- Input Validation: Validates user inputs before processing
- Error Handling: Transforms repository errors to domain errors
- State Transitions: Manages state machine transitions
UseCases use Combine for reactive state management:
// State definition
extension LoginUseCaseNameSpace {
public enum States: Equatable {
case idle(email: String, acceptedTerms: Bool)
case loggingIn(email: String, acceptedTerms: Bool)
case loggedIn(email: String, registertaion: RegistrationState, acceptedTerms: Bool)
case error(email: String, acceptedTerms: Bool, error: Errors)
}
}
// State publisher
private let stateSubject: CurrentValueSubject<LoginUseCaseNameSpace.States, Never>
public var statePublisher: AnyPublisher<LoginUseCaseNameSpace.States, Never> {
stateSubject.eraseToAnyPublisher()
}public func performAction() async {
switch stateSubject.value {
case .idle:
stateSubject.send(.loading)
do {
let result = try await repository.fetchData()
stateSubject.send(.loaded(result))
} catch {
stateSubject.send(.error(error))
}
case .loading:
// Ignore duplicate calls
break
case .loaded, .error:
// Reset and retry
stateSubject.send(.loading)
// ... retry logic
}
}public func updateForm(_ form: FormEntity) {
self.form = form
validateForm()
}
private func validateForm() {
let isValid = isValidEmail(form.email) &&
isValidAmount(form.amount) &&
form.description.isEmpty == false
isFormValidSubject.send(isValid)
}public var statePublisher: AnyPublisher<States, Never> {
stateSubject
.combineLatest(repository.cachedImageStatePublisher)
.map { [weak self] state, cacheState in
guard let self = self else { return state }
return self.mergeStateWithCache(state: state, cacheState: cacheState)
}
.eraseToAnyPublisher()
}Clean Architecture Position: Interface Adapters
Purpose: Convert data between Use Cases (inner) and Frameworks (outer). This layer adapts business data to UI format and UI events to business operations.
Critical Understanding:
- ViewModels are interface adapters, not business logic containers
- Views are framework details (SwiftUI)
- Image Cache is a framework service
- This layer depends on Use Cases (inner) and depends on Frameworks (outer)
Key Principle: ViewModels only map/format/forward. They do NOT contain business rules.
📋 IMPLEMENTATION INSTRUCTIONS FOR AI/CURSOR:
- Create TWO SPM packages:
{Screen}APIand{Screen}Impl- Each MUST have a
Package.swiftfileScreenAPIdefines screen protocol and dependenciesScreenImplcontains View and ViewModel- ViewModel depends on UseCase protocol from
UseCaseAPI(not UseCaseImpl!)- ViewModel is thin - delegates to UseCase, formats for UI
- View uses Image Cache directly for image downloading (not through Repository/UseCase)
- See CRITICAL REQUIREMENT section for Package.swift templates
Each screen is organized as separate SPM packages following this structure:
Features/{Feature}/Presentation/{Screen}/
├── {Screen}API/ # SPM Package
│ ├── Package.swift
│ └── Sources/
│ └── {Screen}API/
│ ├── {Screen}Protocol.swift
│ └── {Screen}Dependencies.swift
└── {Screen}Impl/ # SPM Package
├── Package.swift
└── Sources/
└── {Screen}Impl/
├── {Screen}View.swift
└── {Screen}ViewModel.swift
Important:
- Each
APIandImplis a separate SPM package with its ownPackage.swift ScreenAPIpackage defines the protocol that coordinators depend onScreenImplpackage implements the protocol and contains SwiftUI views
Location: Presentation/ImageCache/{ImageCacheImpl}/ or Utils/ImageCache/{ImageCacheImpl}/
Purpose: Manages image caching and downloading for UI display. This is directly used by the Presentation Layer, not through the Repository/UseCase layers.
Usage in ViewModels:
final class ProfileViewModel: ObservableObject {
@LazyInjected private var imageCache: ImageCacheProtocol
@Published var profileImage: UIImage?
@Published var favoriteImages: [Int: UIImage] = [:]
// Load profile image from URL (received from UseCase/Repository)
func loadProfileImage(url: URL) async {
if let cachedImage = await imageCache.getCachedImage(for: url) {
self.profileImage = cachedImage
} else {
let image = await imageCache.downloadAndCacheImage(from: url)
self.profileImage = image
}
}
// Load favorite images from URLs (received from UseCase/Repository)
func loadFavoriteImages(favorites: [FavoriteEntity]) async {
await withTaskGroup(of: (Int, UIImage?).self) { group in
for favorite in favorites {
guard let url = favorite.imageURL else { continue }
group.addTask { [weak self] in
guard let self = self else { return (favorite.id, nil) }
if let cached = await self.imageCache.getCachedImage(for: url) {
return (favorite.id, cached)
} else {
let image = await self.imageCache.downloadAndCacheImage(from: url)
return (favorite.id, image)
}
}
}
for await (id, image) in group {
if let image = image {
self.favoriteImages[id] = image
}
}
}
}
}Characteristics:
- Directly injected into ViewModels
- Used for UI-specific image caching needs
- Not part of the business logic flow - Repositories/UseCases only provide URLs
- Provides synchronous/async image loading APIs
- ViewModels receive URLs from UseCases and handle image downloading/caching
Example: LoginViewModel
// LoginViewModel.swift
final class LoginViewModel: ObservableObject, @unchecked Sendable {
// Published properties for SwiftUI binding
@Published var state: LoginUseCaseNameSpace.States = .idle(email: "", acceptedTerms: false)
@Published var emailText: String = ""
@Published var acceptTermsCheckbox: Bool = false
@Published var hasError: Bool = false
// Error handling
@Published var isErrorAlertPresented: Bool = false
@Published var errorTitle: String = "Error"
@Published var errorMessage: String = ""
@Published var errorRetryAction: (() -> Void)?
private var cancellables: Set<AnyCancellable> = []
private let dependencies: LoginScreenDependencies
private let loginUseCase: LoginUseCaseProtocol
// Dependency: ViewModel USES (depends on) UseCase Protocol
init(dependencies: LoginScreenDependencies, loginUseCase: LoginUseCaseProtocol) {
self.dependencies = dependencies
self.loginUseCase = loginUseCase
subscribeToPublishers()
}
private func subscribeToPublishers() {
// Subscribe to UseCase state changes
loginUseCase
.statePublisher
.map { state in
switch state {
case .idle(let email, _),
.loggingIn(let email, _),
.loggedIn(let email, _, _),
.error(let email, _, _):
email
}
}
.receive(on: DispatchQueue.main)
.assign(to: &$emailText)
loginUseCase
.statePublisher
.receive(on: DispatchQueue.main)
.assign(to: &$state)
// Handle state transitions
loginUseCase
.statePublisher
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self else { return }
switch state {
case .loggedIn(let email, let registertaion, _):
self.acceptTermsCheckbox = false
self.emailText = ""
Task { [dependencies] in
switch registertaion {
case .alreadyRegistered:
await dependencies.coordinator.pushLockScreen(email: email)
case .notRegisteredBefore:
await dependencies.coordinator.pushVerificationScreen(email: email)
}
}
case .error(_, _, let error):
self.showError(
title: "Login Failed",
message: error.localizedDescription,
retryAction: { [weak self] in
Task { @Sendable [weak self] in
guard let self else { return }
try await self.loginUseCase.login(email: self.emailText)
}
}
)
case .idle, .loggingIn:
break
}
}
.store(in: &cancellables)
}
func onTapContinue() {
Task.detached(priority: .userInitiated) { @Sendable [weak loginUseCase, emailText] in
guard let loginUseCase else { return }
do {
try await loginUseCase.login(email: emailText)
} catch {
print(error)
}
}
}
}✅ Allowed Responsibilities:
- State Mapping: Converts UseCase outputs to UI format
- Event Forwarding: Forwards user events to UseCases
- UI Formatting: Formats data for display (dates, currency, etc.)
- UI State: Manages UI-specific state (loading indicators, alerts, etc.)
- Error Presentation: Formats domain errors for UI display
❌ Forbidden Responsibilities:
- Business Validation: Validation belongs in Use Cases
- Business Decisions: Decision-making belongs in Use Cases
- Data Fetching: Data access belongs in Use Cases via Repositories
- Business Logic: All business rules belong in Use Cases
- State Machine Logic: State transitions belong in Use Cases
Example: LoginView
// LoginView.swift
struct LoginView: View {
@StateObject private var viewModel: LoginViewModel
init(dependencies: LoginScreenDependencies, loginUseCase: LoginUseCaseProtocol) {
self._viewModel = StateObject(
wrappedValue: LoginViewModel(
dependencies: dependencies,
loginUseCase: loginUseCase
)
)
}
var body: some View {
VStack(alignment: .center, spacing: .zero) {
Spacer()
topTitle
Spacer()
.frame(height: .point(.medium))
emailTextField
if viewModel.hasError {
errorTextView
}
termsAndConditionToggle
Spacer()
actionButton
}
.alert(
viewModel.errorTitle,
isPresented: $viewModel.isErrorAlertPresented
) {
if viewModel.errorRetryAction != nil {
Button("Retry") {
viewModel.onTryAgainTapped()
}
}
Button("OK") {
viewModel.dismissError()
}
} message: {
Text(viewModel.errorMessage)
}
.padding(.horizontal, .standard)
}
private var emailTextField: some View {
TextField(
"Email Address",
text: $viewModel.emailText,
prompt: nil
)
.keyboardType(.emailAddress)
.primaryTextFieldStyle()
}
private var actionButton: some View {
Button {
viewModel.onTapContinue()
} label: {
Text("Continue")
}
.buttonStyle(.appPrimary(isEnabled: viewModel.isActionButtonEnabled))
.disabled(viewModel.isActionButtonEnabled == false)
}
}- UI Rendering: Defines the visual layout and components
- Data Binding: Binds to ViewModel
@Publishedproperties - User Interaction: Calls ViewModel methods on user actions
- Styling: Applies design system styles and themes
This project uses the Coordinator Pattern for navigation management, implemented using the AugmentedSUICoordinator library. Coordinators handle all navigation logic, separating it from views and view models, which makes the codebase more maintainable and testable.
The Coordinator Pattern is a design pattern that:
- Separates navigation logic from views and view models
- Manages navigation flow and screen transitions
- Handles deep linking and navigation state
- Enables testability by abstracting navigation operations
- Supports hierarchical navigation with parent-child coordinator relationships
Key Benefits:
- Views and ViewModels don't know about navigation implementation details
- Navigation logic is centralized and reusable
- Easy to test navigation flows
- Supports complex navigation scenarios (tabs, modals, deep links)
-
Add SUICoordinator to
Package.swiftdependencies:dependencies: [ .package( url: "https://github.com/kiarashvosough1999/AugmentedSUICoordinator.git", branch: "main" ), // ... other dependencies ],
-
Add SUICoordinator to target dependencies:
targets: [ .target( name: "{Coordinator}API", // or {Coordinator}Impl dependencies: [ .product(name: "SUICoordinator", package: "AugmentedSUICoordinator"), // ... other dependencies ] ) ]
-
Import SUICoordinator in your files:
import SUICoordinator
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "LoginScreenCoordinatorAPI",
platforms: [.iOS(.v17)],
products: [
.library(
name: "LoginScreenCoordinatorAPI",
targets: ["LoginScreenCoordinatorAPI"]
),
],
dependencies: [
.package(
url: "https://github.com/kiarashvosough1999/AugmentedSUICoordinator.git",
branch: "main"
),
],
targets: [
.target(
name: "LoginScreenCoordinatorAPI",
dependencies: [
.product(
name: "SUICoordinator",
package: "AugmentedSUICoordinator"
)
]
)
]
)// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "FirstAppCoordinatorImpl",
platforms: [.iOS(.v17)],
products: [
.library(
name: "FirstAppCoordinatorImpl",
targets: ["FirstAppCoordinatorImpl"]
),
],
dependencies: [
.package(
url: "https://github.com/kiarashvosough1999/AugmentedSUICoordinator.git",
branch: "main"
),
.package(
url: "https://github.com/hmlongco/Resolver.git",
from: "1.5.1"
),
.package(path: "../FirstAppCoordinatorAPI"),
// ... other dependencies
],
targets: [
.target(
name: "FirstAppCoordinatorImpl",
dependencies: [
.product(
name: "SUICoordinator",
package: "AugmentedSUICoordinator"
),
.product(
name: "Resolver",
package: "Resolver"
),
.product(
name: "FirstAppCoordinatorAPI",
package: "FirstAppCoordinatorAPI"
),
// ... other dependencies
]
)
]
)Each coordinator consists of:
- CoordinatorAPI Package: Defines the coordinator protocol and routes
- CoordinatorImpl Package: Implements the coordinator using SUICoordinator
Coordinator/{Feature}Coordinator/
├── {Feature}CoordinatorAPI/ # SPM Package: Coordinator protocol & routes
│ ├── Package.swift # Includes SUICoordinator
│ └── Sources/
│ └── {Feature}CoordinatorAPI/
│ ├── {Feature}CoordinatorProtocol.swift
│ └── {Feature}Routes.swift # Optional: if routes are in API
│
└── {Feature}CoordinatorImpl/ # SPM Package: Coordinator implementation
├── Package.swift # Includes SUICoordinator + Resolver
└── Sources/
└── {Feature}CoordinatorImpl/
├── {Feature}CoordinatorImpl.swift
└── {Feature}Routes.swift # Routes enum (if not in API)
// Coordinator/LoginCoordinator/LoginCoordinatorAPI/Sources/LoginCoordinatorAPI/LoginCoordinatorProtocol.swift
import SUICoordinator
import SwiftUI
@MainActor
public protocol LoginCoordinatorProtocol: CoordinatorType {
func pushVerificationScreen(email: String) async
func pushLockScreen(email: String) async
func openTermsAndConditions(link: String) async
}Routes define all possible navigation destinations for a coordinator:
// Coordinator/FirstAppCoordinator/FirstAppCoordinatorImpl/Sources/FirstAppCoordinatorImpl/FirstAppRoutes.swift
import SUICoordinator
import Resolver
import SwiftUI
import LoginScreenAPI
import VerificationCodeScreenAPI
import LockScreenAPI
public enum FirstAppRoutes: RouteType {
case pushLoginScreen(
coordinator: any LoginScreenCoordinatorProtocol,
loginScreen: LoginScreenProtocol
)
case pushVerificationCodeScreen(
coordinator: any VerificationCodeScreenCoordinatorProtocol,
verificationCodeScreen: VerificationCodeScreenProtocol,
email: String
)
case pushLockScreen(
coordinator: any LockScreenCoordinatorProtocol,
screen: LockScreenProtocol,
email: String,
firstLaunch: Bool
)
// Define presentation style for each route
public var presentationStyle: TransitionPresentationStyle {
switch self {
case .pushLoginScreen,
.pushVerificationCodeScreen,
.pushLockScreen:
.push
}
}
// Define the view body for each route
public var body: some View {
switch self {
case .pushLoginScreen(let coordinator, let screen):
screen.screen(
dependencies: LoginScreenDependencies(
coordinator: coordinator
)
)
case .pushVerificationCodeScreen(let coordinator, let screen, let email):
screen.screen(
dependencies: VerificationCodeScreenDependencies(
coordinator: coordinator,
email: email
)
)
case .pushLockScreen(let coordinator, let screen, let email, let firstLaunch):
screen.screen(
dependencies: LockScreenDependencies(
coordinator: coordinator,
email: email,
firstLaunch: firstLaunch
)
)
}
}
}// Coordinator/FirstAppCoordinator/FirstAppCoordinatorImpl/Sources/FirstAppCoordinatorImpl/FirstAppCoordinatorImpl.swift
import SUICoordinator
import Resolver
import SwiftUI
import FirstAppCoordinatorAPI
import LoginScreenAPI
import AuthenticationFlowUseCaseAPI
import Combine
public final class FirstAppCoordinatorImpl: Coordinator<FirstAppRoutes> {
private var cancellables: Set<AnyCancellable> = []
@LazyInjected private var authFlowUseCase: AuthenticationFlowUseCaseProtocol
public override init() {
super.init()
}
public override func start() async {
// Initial navigation logic
@LazyInjected var loginScreen: LoginScreenProtocol
await startFlow(
route: .pushLoginScreen(
coordinator: self,
loginScreen: loginScreen
)
)
}
}
// MARK: - FirstAppCoordinatorProtocol
extension FirstAppCoordinatorImpl: FirstAppCoordinatorProtocol {
public func coordinatorRootView() -> AnyView {
getView()
.earseToAnyView()
}
}
// MARK: - LoginScreenCoordinatorProtocol
extension FirstAppCoordinatorImpl: LoginScreenCoordinatorProtocol {
public func pushVerificationScreen(email: String) async {
@Injected var verificationCodeScreen: VerificationCodeScreenProtocol
await router.navigate(
toRoute: .pushVerificationCodeScreen(
coordinator: self,
verificationCodeScreen: verificationCodeScreen,
email: email
)
)
}
public func pushLockScreen(email: String) async {
@LazyInjected var lockScreen: LockScreenProtocol
await router.navigate(
toRoute: .pushLockScreen(
coordinator: self,
screen: lockScreen,
email: email,
firstLaunch: false
)
)
}
public func openTermsAndConditions(link: String) async {
// Handle terms and conditions navigation
}
}SUICoordinator provides several navigation methods:
await startFlow(
route: .pushLoginScreen(
coordinator: self,
loginScreen: loginScreen
)
)await router.navigate(
toRoute: .pushVerificationCodeScreen(
coordinator: self,
verificationCodeScreen: verificationCodeScreen,
email: email
)
)@Injected var mainTabBarCoordinator: any MainTabBarCoordinatorProtocol
await navigate(
to: mainTabBarCoordinator,
presentationStyle: .push,
animated: true
)await router.dismiss()await router.pop()await restart()await finishFlow()SUICoordinator supports various presentation styles:
public var presentationStyle: TransitionPresentationStyle {
switch self {
case .pushLoginScreen:
.push // Push onto navigation stack
case .showModal:
.sheet // Present as sheet
case .showFullScreen:
.fullScreenCover // Present as full screen cover
case .showNavigationSheet:
.navigationSheet([.medium]) // Present as navigation sheet with detents
case .showNavigationFullScreen:
.navigationFullScreenCover // Present as navigation full screen cover
case .customDetents:
.detents([.fraction(0.5)]) // Custom detents
}
}Each screen defines a coordinator protocol that specifies navigation actions:
// Features/Login/Presentation/LoginScreen/LoginScreenAPI/Sources/LoginScreenAPI/LoginScreenCoordinatorProtocol.swift
public protocol LoginScreenCoordinatorProtocol: AnyObject, Sendable {
func pushVerificationScreen(email: String) async
func pushLockScreen(email: String) async
func openTermsAndConditions(link: String) async
}The coordinator protocol is included in screen dependencies:
// Features/Login/Presentation/LoginScreen/LoginScreenAPI/Sources/LoginScreenAPI/LoginScreenDependencies.swift
public struct LoginScreenDependencies: Sendable {
public let coordinator: any LoginScreenCoordinatorProtocol
public init(coordinator: any LoginScreenCoordinatorProtocol) {
self.coordinator = coordinator
}
}ViewModels receive the coordinator through dependencies and use it for navigation:
// Features/Login/Presentation/LoginScreen/LoginScreenImpl/Sources/LoginScreenImpl/LoginViewModel.swift
final class LoginViewModel: ObservableObject {
private let dependencies: LoginScreenDependencies
private let loginUseCase: LoginUseCaseProtocol
init(
dependencies: LoginScreenDependencies,
loginUseCase: LoginUseCaseProtocol
) {
self.dependencies = dependencies
self.loginUseCase = loginUseCase
}
func handleLoginSuccess(email: String) async {
// Navigate using coordinator
await dependencies.coordinator.pushVerificationScreen(email: email)
}
func handleTermsAndConditions() async {
await dependencies.coordinator.openTermsAndConditions(
link: "https://payred.com.tr/terms/"
)
}
}Coordinators can have parent-child relationships:
// MainAppCoordinator manages FirstAppCoordinator and MainTabBarCoordinator
public final class MainAppCoordinatorImpl: Coordinator<MainAppCoordinatorRoutes> {
public override func start() async {
// Navigate to child coordinator
@Injected var firstAppCoordinator: any FirstAppCoordinatorProtocol
await navigate(
to: firstAppCoordinator,
presentationStyle: .push,
animated: true
)
}
}Coordinators are registered in DIRegisterar.swift:
// PayRed/DI/DIRegisterar.swift
import Resolver
import MainAppCoordinatorAPI
import MainAppCoordinatorImpl
extension Resolver: @retroactive ResolverRegistering {
public static func registerAllServices() {
// Register MainAppCoordinator
Resolver.register(MainAppCoordinatorProtocol.self) { @MainActor in
MainAppCoordinatorImpl()
}
.scope(.application)
// Register FirstAppCoordinator (implements multiple protocols)
Resolver.register(FirstAppCoordinatorImpl.self) { @MainActor in
FirstAppCoordinatorImpl()
}
.implements(FirstAppCoordinatorProtocol.self)
.implements(SplashScreenCoordinatorProtocol.self)
.implements(LoginScreenCoordinatorProtocol.self)
.implements(VerificationCodeScreenCoordinatorProtocol.self)
.implements(LockScreenCoordinatorProtocol.self)
.scope(.shared)
}
}Each major feature flow should have its own coordinator:
FirstAppCoordinator- Handles authentication flowMainTabBarCoordinator- Handles main app navigationHomeTabCoordinator- Handles home tab navigationRegisterCoordinator- Handles registration flow
Each screen should define a coordinator protocol that specifies its navigation needs:
// ScreenAPI defines coordinator protocol
public protocol LoginScreenCoordinatorProtocol: AnyObject, Sendable {
func pushVerificationScreen(email: String) async
func pushLockScreen(email: String) async
}
// CoordinatorImpl implements the protocol
extension FirstAppCoordinatorImpl: LoginScreenCoordinatorProtocol {
public func pushVerificationScreen(email: String) async {
// Implementation
}
}All coordinators should be marked with @MainActor:
@MainActor
public final class FirstAppCoordinatorImpl: Coordinator<FirstAppRoutes> {
// ...
}.application: Root coordinators (e.g.,MainAppCoordinator).shared: Feature coordinators (e.g.,FirstAppCoordinator,MainTabBarCoordinator)
// ✅ Good: Inject child coordinator
@Injected var firstAppCoordinator: any FirstAppCoordinatorProtocol
await navigate(to: firstAppCoordinator, presentationStyle: .push)
// ❌ Bad: Create coordinator directly
let coordinator = FirstAppCoordinatorImpl()
await navigate(to: coordinator, presentationStyle: .push)public override func start() async {
// Initial setup
await setupInitialFlow()
// Subscribe to state changes
subscribeToStateChanges()
}
deinit {
// Cleanup
cancellables.removeAll()
}public final class FirstAppCoordinatorImpl: Coordinator<FirstAppRoutes> {
@LazyInjected private var authFlowUseCase: AuthenticationFlowUseCaseProtocol
public override func start() async {
loop: for await state in authFlowUseCase.statePublisher.values {
switch state {
case .login:
await showLoginScreen()
case .verificationCode(let email):
await showVerificationScreen(email: email)
case .pinCode(let email, _):
await showLockScreen(email: email)
case .authenticated:
break loop
}
}
}
}public final class MainTabBarCoordinatorImpl: Coordinator<MainTabBarRoutes> {
public override func start() async {
await startFlow(
route: .mainTabBar(
coordinator: self,
homeCoordinator: homeTabCoordinator,
cardsCoordinator: cardsTabCoordinator,
transactionCoordinator: transactionTabCoordinator,
transferCoordinator: transferTabCoordinator,
menuCoordinator: menuTabCoordinator
)
)
}
}extension FirstAppCoordinatorImpl: LoginScreenCoordinatorProtocol {
public func showForgotPassword() async {
@Injected var forgotPasswordScreen: ForgotPasswordScreenProtocol
await router.navigate(
toRoute: .forgotPasswordBottomSheetScreen(
coordinator: self,
forgotPasswordScreen: forgotPasswordScreen
)
)
}
}Before considering a coordinator complete, verify:
- SUICoordinator is added to CoordinatorAPI Package.swift
- SUICoordinator is added to CoordinatorImpl Package.swift
- Coordinator protocol extends
CoordinatorTypefrom SUICoordinator - Routes enum conforms to
RouteType - Routes define
presentationStylefor each case - Routes define
bodyview for each case - Coordinator implementation extends
Coordinator<Routes> - Coordinator implements
start()method - Coordinator implements all required coordinator protocols
- Coordinator is registered in
DIRegisterar.swift - Coordinator uses
@MainActor - Screen coordinator protocols are defined in ScreenAPI packages
- Screen dependencies include coordinator protocol
This project uses Resolver for dependency injection. Resolver is an ultralight Dependency Injection / Service Locator framework for Swift that provides automatic type inference, scopes, and property wrapper support.
Why Resolver?
- Ultralight implementation (~700 lines of code)
- Automatic type inference (40-60% less code)
- Thread-safe
- Property wrapper support (
@Injected,@LazyInjected) - Multiple scopes (Application, Cached, Graph, Shared, Unique)
- Protocol support
- Well-tested and production-ready
All dependencies are registered in a central location: PayRed/DI/DIRegisterar.swift.
API packages ({Component}API) should NOT include Resolver because:
- API packages contain only protocols and interfaces
- They should remain framework-agnostic
- They define contracts, not implementations
Impl packages ({Component}Impl) MUST include Resolver because:
- They contain implementations that need to resolve dependencies
- They use Resolver to inject dependencies via
Resolver.resolve()or property wrappers
When creating a new Impl package (UseCaseImpl, RepositoryImpl, ScreenImpl, etc.), follow these steps:
-
Add Resolver to
Package.swiftdependencies:dependencies: [ .package(path: "../{Component}API"), // API package dependency .package( url: "https://github.com/hmlongco/Resolver.git", from: "1.5.1" ), ],
-
Add Resolver to target dependencies:
targets: [ .target( name: "{Component}Impl", dependencies: [ .product(name: "{Component}API", package: "{Component}API"), .product(name: "Resolver", package: "Resolver") ] ) ]
-
Import Resolver in your implementation files:
import Resolver import {Component}API
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "LoginUseCaseImpl",
platforms: [.iOS(.v17)],
products: [
.library(
name: "LoginUseCaseImpl",
targets: ["LoginUseCaseImpl"]
),
],
dependencies: [
.package(
name: "LoginUseCaseAPI",
path: "../LoginUseCaseAPI"
),
.package(
url: "https://github.com/hmlongco/Resolver.git",
from: "1.5.1"
),
],
targets: [
.target(
name: "LoginUseCaseImpl",
dependencies: [
.product(
name: "LoginUseCaseAPI",
package: "LoginUseCaseAPI"
),
.product(
name: "Resolver",
package: "Resolver"
)
]
),
]
)// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "LoginRepositoryImpl",
platforms: [.iOS(.v17)],
products: [
.library(
name: "LoginRepositoryImpl",
targets: ["LoginRepositoryImpl"]
),
],
dependencies: [
.package(
name: "LoginRepositoryAPI",
path: "../LoginRepositoryAPI"
),
.package(
name: "LoginUseCaseAPI",
path: "../../UseCases/LoginUseCase/LoginUseCaseAPI"
),
.package(
url: "https://github.com/hmlongco/Resolver.git",
from: "1.5.1"
),
],
targets: [
.target(
name: "LoginRepositoryImpl",
dependencies: [
.product(
name: "LoginRepositoryAPI",
package: "LoginRepositoryAPI"
),
.product(
name: "LoginUseCaseAPI",
package: "LoginUseCaseAPI"
),
.product(
name: "Resolver",
package: "Resolver"
)
]
),
]
)Before considering a package complete, verify:
- Resolver is NOT added to API packages (
{Component}API) - Resolver IS added to all Impl packages (
{Component}Impl) - Resolver is added to
dependenciesarray inPackage.swift - Resolver is added to target
dependenciesarray -
import Resolveris added to implementation files that use it - Package builds successfully with Resolver dependency
All dependencies are registered in PayRed/DI/DIRegisterar.swift using Resolver's registration API. The registration follows the ResolverRegistering protocol pattern.
Registration File Structure:
// PayRed/DI/DIRegisterar.swift
import Resolver
// ... import all API and Impl packages
extension Resolver: @retroactive ResolverRegistering {
public static func registerAllServices() {
// All registrations go here
}
}Example: Registering Data Layer Implementations
Data implementations implement multiple Data Protocols from RepositoryAPI packages:
// Register MobileGatewayService (implements multiple RemoteDataProtocols)
register {
MobileGatewayServiceImpl()
}
.implements(LoginRepositoryRemoteDataProtocol.self) // From LoginRepositoryAPI
.implements(CardListRepositoryRemoteDataProtocol.self) // From CardListRepositoryAPI
.implements(TransferRepositoryRemoteDataProtocol.self) // From TransferRepositoryAPI
.implements(HomeTabRepositoryRemoteDataProtocol.self) // From HomeTabRepositoryAPI
// ... implements many more protocols
.scope(.application)
// Register Database (implements multiple DataBaseDataProtocols)
register {
DataBase()
}
.implements(LoginRepositoryDataBaseDataProtocol.self) // From LoginRepositoryAPI
.implements(CardListRepositoryDataBaseDataProtocol.self) // From CardListRepositoryAPI
.implements(AuthenticationFlowRepositoryDataBaseDataProtocol.self) // From AuthenticationFlowRepositoryAPI
// ... implements many more protocols
.scope(.application)
// Register Keychain (implements multiple KeyChainDataProtocols)
register {
KeyChainDataImpl()
}
.implements(LockRepositoryKeyChainDataProtocol.self) // From LockRepositoryAPI
.implements(AuthenticationFlowRepositoryKeyChainDataProtocol.self) // From AuthenticationFlowRepositoryAPI
.implements(ChangeAppPinRepositoryKeyChainDataProtocol.self) // From ChangeAppPinRepositoryAPI
// ... implements more protocols
.scope(.application)
// Register Image Cache (used directly by Presentation Layer)
register {
ImageCacheImpl()
}
.implements(UserProfileRepositoryImageCacheDataProtocol.self) // From UserProfileRepositoryAPI
.implements(EditProfilePictureRepositoryImageCacheDataProtocol.self) // From EditProfilePictureRepositoryAPI
.implements(HomeTabRepositoryImageCacheDataProtocol.self) // From HomeTabRepositoryAPI
// ... implements more protocols
.scope(.application)Example: Registering Repository Implementations
Repository implementations implement Repository Protocols from UseCaseAPI packages:
// Register LoginRepository (implements Repository Protocol from UseCaseAPI)
register {
LoginRepositoryImpl()
}
.implements(LoginUseCaseRepositoryProtocol.self) // From LoginUseCaseAPI
.scope(.shared)
// Register AuthenticationFlowRepository
register {
AuthenticationFlowRepositoryImpl()
}
.implements(AuthenticationFlowUseCaseRepositoryProtocol.self) // From AuthenticationFlowUseCaseAPI
.scope(.shared)Example: Registering UseCase Implementations
UseCase implementations implement UseCase Protocols:
// Register LoginUseCase (implements UseCase Protocol)
register(LoginUseCaseProtocol.self) {
LoginUseCaseImpl()
}
.scope(.shared)
// Register AuthenticationFlowUseCase
register {
AuthenticationFlowUseCaseImpl()
}
.implements(AuthenticationFlowUseCaseProtocol.self)
.scope(.application)Example: Registering Presentation Implementations
Screen implementations implement Screen Protocols:
// Register LoginScreen (implements Screen Protocol)
register(LoginScreenProtocol.self) {
LoginScreenImpl()
}
.scope(.unique)
// Register Coordinators (with @MainActor for UI components)
Resolver.register(MainAppCoordinatorProtocol.self) { @MainActor in
MainAppCoordinatorImpl()
}
.scope(.application)
Resolver.register(FirstAppCoordinatorImpl.self) { @MainActor in
FirstAppCoordinatorImpl()
}
.implements(FirstAppCoordinatorProtocol.self)
.implements(SplashScreenCoordinatorProtocol.self)
.implements(LoginScreenCoordinatorProtocol.self)
// ... implements multiple coordinator protocols
.scope(.shared)Resolver provides several scopes for managing object lifetimes. This project uses the following scopes:
-
.application: Singleton instance for the app lifetime- Used for: Data sources (NetworkService, Database, Keychain, ImageCache)
- Example:
MobileGatewayServiceImpl,DataBase(),KeyChainDataImpl()
-
.shared: Shared instance within a feature or module- Used for: UseCases, Repositories, Coordinators
- Example:
LoginUseCaseImpl,LoginRepositoryImpl,MainAppCoordinatorImpl
-
.unique: New instance each time it's resolved- Used for: Screens, ViewModels (when they need fresh state)
- Example:
LoginScreenImpl,LockScreenImpl
Resolver also supports:
.cached: Cached for the duration of the resolution graph.graph: New instance per resolution graph
Note: For @MainActor types (like Coordinators and Screens), use @MainActor closure annotation:
Resolver.register(MainAppCoordinatorProtocol.self) { @MainActor in
MainAppCoordinatorImpl()
}
.scope(.application)Resolver provides multiple ways to resolve dependencies. This project uses both explicit resolution (using Resolver.resolve()) and property wrappers (@Injected, @LazyInjected).
In UseCases (UseCaseImpl resolves Repository Protocol):
// Features/Login/UseCases/LoginUseCase/LoginUseCaseImpl/Sources/LoginUseCaseImpl/LoginUseCaseImpl.swift
import Resolver
import LoginUseCaseAPI
public final class LoginUseCaseImpl: @unchecked Sendable {
private let loginRepository: LoginUseCaseRepositoryProtocol
public init() {
// UseCaseImpl USES Repository Protocol - explicit resolution
self.loginRepository = Resolver.resolve(LoginUseCaseRepositoryProtocol.self)
}
}In Repositories (RepositoryImpl resolves Data Protocols):
// Features/Login/Repositories/LoginRepository/LoginRepositoryImpl/Sources/LoginRepositoryImpl/LoginRepositoryImpl.swift
import Resolver
import LoginRepositoryAPI
import LoginUseCaseAPI
public final class LoginRepositoryImpl: @unchecked Sendable {
private let remoteData: LoginRepositoryRemoteDataProtocol
private let databaseData: LoginRepositoryDataBaseDataProtocol
public init() {
// RepositoryImpl USES Data Protocols - explicit resolution
self.remoteData = Resolver.resolve(LoginRepositoryRemoteDataProtocol.self)
self.databaseData = Resolver.resolve(LoginRepositoryDataBaseDataProtocol.self)
}
}Property wrappers provide automatic dependency injection. Use @LazyInjected for dependencies that should be resolved lazily (on first access).
In ViewModels (using property wrappers):
// Features/Login/Presentation/LoginScreen/LoginScreenImpl/Sources/LoginScreenImpl/LoginViewModel.swift
import Resolver
import LoginUseCaseAPI
final class LoginViewModel: ObservableObject {
// ViewModel receives UseCase Protocol via constructor injection
private let loginUseCase: LoginUseCaseProtocol
init(dependencies: LoginScreenDependencies, loginUseCase: LoginUseCaseProtocol) {
self.dependencies = dependencies
self.loginUseCase = loginUseCase
}
}In Coordinators (using @Injected for MainActor types):
// Coordinator/AppMainCoordinator/MainAppCoordinatorImpl/Sources/MainAppCoordinatorImpl/MainAppCoordinatorImpl.swift
import Resolver
@MainActor
public final class MainAppCoordinatorImpl: MainAppCoordinatorProtocol {
// Lazy injection for UseCases
@LazyInjected private var authFlowUseCase: AuthenticationFlowUseCaseProtocol
@LazyInjected private var updateAppUseCase: UpdateAppUseCaseProtocol
@LazyInjected private var notificationManagerUseCase: NotificationManagerUseCaseProtocol
// Injected for coordinators (resolved immediately)
func showFirstApp() {
@Injected var firstAppCoordinator: any FirstAppCoordinatorProtocol
// Use coordinator
}
}In ViewModels (for Image Cache - using @LazyInjected):
// Example from ViewModels that use Image Cache
import Resolver
final class ProfileViewModel: ObservableObject {
// Lazy injection for Image Cache (used directly by Presentation Layer)
@LazyInjected private var imageCache: ImageCacheProtocol
func loadProfileImage(url: URL) async {
if let cachedImage = await imageCache.getCachedImage(for: url) {
self.profileImage = cachedImage
} else {
let image = await imageCache.downloadAndCacheImage(from: url)
self.profileImage = image
}
}
}In App Entry Point (PayRedApp.swift):
// PayRed/PayRedApp.swift
import Resolver
@main
struct PayRedApp: App {
@Injected private var mainCoordinator: (any MainAppCoordinatorProtocol)
init() {
// Register all dependencies
Resolver.registerAllServices()
// Resolve dependencies that need to be initialized at app startup
_ = Resolver.resolve(NotificationManagerUseCaseProtocol.self)
}
var body: some Scene {
WindowGroup {
mainCoordinator.start()
}
}
}Resolver.resolve(): Use in initializers when you need the dependency immediately@Injected: Use for properties that should be resolved immediately (typically for@MainActortypes)@LazyInjected: Use for properties that should be resolved on first access (saves memory, better for optional dependencies)
A single implementation can implement multiple protocols. This is commonly used for data layer implementations:
// Register MobileGatewayService implementing multiple RemoteDataProtocols
register {
MobileGatewayServiceImpl()
}
.implements(LoginRepositoryRemoteDataProtocol.self)
.implements(CardListRepositoryRemoteDataProtocol.self)
.implements(TransferRepositoryRemoteDataProtocol.self)
.implements(HomeTabRepositoryRemoteDataProtocol.self)
// ... can implement many more protocols
.scope(.application)Resolver supports named registrations for differentiating between multiple instances of the same type:
// Register with a name
register(name: "production") {
NetworkService(baseURL: "https://api.production.com")
}
register(name: "staging") {
NetworkService(baseURL: "https://api.staging.com")
}
// Resolve by name
let productionService: NetworkService = Resolver.resolve(name: "production")Resolver supports passing arguments during resolution:
// Register with factory that accepts arguments
register { (userId: String) in
UserProfileService(userId: userId)
}
// Resolve with arguments
let service = Resolver.resolve(UserProfileService.self, args: "user123")Use conditional registration for different build configurations:
#if DEBUG
register {
MockNetworkService()
}
.implements(NetworkServiceProtocol.self)
.scope(.application)
#else
register {
ProductionNetworkService()
}
.implements(NetworkServiceProtocol.self)
.scope(.application)
#endifWhile this project primarily uses .application, .shared, and .unique, Resolver also supports custom scopes:
// Create a custom scope
extension ResolverScope {
static let session = ResolverScopeCache()
}
// Register with custom scope
register {
SessionManager()
}
.scope(.session)Register dependencies in dependency order (data sources first, then repositories, then use cases, then presentation):
public static func registerAllServices() {
// 1. Register Data Layer implementations first
register { MobileGatewayServiceImpl() }
.implements(LoginRepositoryRemoteDataProtocol.self)
.scope(.application)
register { DataBase() }
.implements(LoginRepositoryDataBaseDataProtocol.self)
.scope(.application)
// 2. Register Repository implementations
register {
LoginRepositoryImpl()
}
.implements(LoginUseCaseRepositoryProtocol.self)
.scope(.shared)
// 3. Register UseCase implementations
register(LoginUseCaseProtocol.self) {
LoginUseCaseImpl()
}
.scope(.shared)
// 4. Register Presentation implementations last
register(LoginScreenProtocol.self) {
LoginScreenImpl()
}
.scope(.unique)
}-
.application: Use for singletons that live for the app lifetime- Data sources (NetworkService, Database, Keychain, ImageCache)
- Services that maintain global state
- Expensive-to-create objects
-
.shared: Use for objects that should be shared within a feature/module- UseCases (business logic)
- Repositories (data access)
- Coordinators (navigation)
-
.unique: Use for objects that need fresh state each time- Screens (UI components)
- ViewModels (when they need fresh state)
- Temporary objects
Always register implementations against their protocol interfaces, not concrete types:
// ✅ Good: Register against protocol
register(LoginUseCaseProtocol.self) {
LoginUseCaseImpl()
}
.scope(.shared)
// ❌ Bad: Register against concrete type
register {
LoginUseCaseImpl()
}
.scope(.shared)For @MainActor types (Coordinators, Screens), use @MainActor closure annotation:
register(MainAppCoordinatorProtocol.self) { @MainActor in
MainAppCoordinatorImpl()
}
.scope(.application)Resolver is thread-safe, but be aware of:
- Use
@MainActorfor UI-related types - Use
@unchecked Sendablefor types that resolve dependencies ininit() - Prefer property wrappers (
@Injected,@LazyInjected) for thread-safe access
For testing, you can register mock implementations:
// In test setup
Resolver.register(LoginUseCaseProtocol.self) {
MockLoginUseCase()
}
.scope(.unique)
// In test teardown
Resolver.root = Resolver()// RepositoryImpl resolves multiple Data Protocols
public final class LoginRepositoryImpl: LoginUseCaseRepositoryProtocol {
private let remoteData: LoginRepositoryRemoteDataProtocol
private let databaseData: LoginRepositoryDataBaseDataProtocol
private let keychainData: LoginRepositoryKeyChainDataProtocol
public init() {
self.remoteData = Resolver.resolve(LoginRepositoryRemoteDataProtocol.self)
self.databaseData = Resolver.resolve(LoginRepositoryDataBaseDataProtocol.self)
self.keychainData = Resolver.resolve(LoginRepositoryKeyChainDataProtocol.self)
}
}// UseCaseImpl resolves Repository Protocol
public final class LoginUseCaseImpl: LoginUseCaseProtocol {
private let loginRepository: LoginUseCaseRepositoryProtocol
public init() {
self.loginRepository = Resolver.resolve(LoginUseCaseRepositoryProtocol.self)
}
}// Coordinator uses @LazyInjected for UseCases
@MainActor
public final class MainAppCoordinatorImpl: MainAppCoordinatorProtocol {
@LazyInjected private var authFlowUseCase: AuthenticationFlowUseCaseProtocol
@LazyInjected private var updateAppUseCase: UpdateAppUseCaseProtocol
// Local injection for coordinators
func showFirstApp() {
@Injected var firstAppCoordinator: any FirstAppCoordinatorProtocol
// Use coordinator
}
}// Screen receives UseCase via constructor (not Resolver)
public struct LoginScreenImpl: LoginScreenProtocol {
private let dependencies: LoginScreenDependencies
private let loginUseCase: LoginUseCaseProtocol
public init(
dependencies: LoginScreenDependencies,
loginUseCase: LoginUseCaseProtocol
) {
self.dependencies = dependencies
self.loginUseCase = loginUseCase
}
}
// Coordinator resolves and passes dependencies
@MainActor
public final class FirstAppCoordinatorImpl: FirstAppCoordinatorProtocol {
func showLogin() {
let loginUseCase = Resolver.resolve(LoginUseCaseProtocol.self)
let screen = Resolver.resolve(LoginScreenProtocol.self)
// Configure and present screen
}
}Note: The diagram below is also available as a Mermaid file:
docs/diagrams/data-flow-diagram.mmd
flowchart TD
Start([User Action]) --> View[View: User taps Continue button]
View --> ViewModel[ViewModel.onTapContinue]
ViewModel --> UseCaseCall[ViewModel calls loginUseCase.login]
UseCaseCall --> UseCaseProcess[UseCase Processing]
UseCaseProcess --> Validate[Validates email format]
Validate --> UpdateState[Updates state: .loggingIn]
UpdateState --> CallRepo[Calls repository: loginRepository.login]
CallRepo --> RepoProcess[Repository Processing]
RepoProcess --> CallRemote[Calls remote: remoteData.login]
CallRemote --> HTTPRequest[NetworkService performs HTTP request]
HTTPRequest --> RemoteResult[Returns: LoginOrRegisterRemoteResult]
RemoteResult --> Transform[Transforms to: LoginResponse]
Transform --> RepoReturn[Repository → UseCase]
RepoReturn --> HandleResponse[UseCase handles response]
HandleResponse --> SaveState[Calls: loginRepository.saveUserLoginState]
SaveState --> SaveDB[Repository saves to database]
SaveDB --> UpdateLoggedIn[Updates state: .loggedIn]
UpdateLoggedIn --> StatePublisher[State publisher emits: .loggedIn]
StatePublisher --> ViewModelSub[ViewModel subscribes to state changes]
ViewModelSub --> UpdatePublished[Updates @Published properties]
UpdatePublished --> SwiftUI[SwiftUI observes @Published changes]
SwiftUI --> ReRender[View re-renders]
ReRender --> Navigation[ViewModel triggers navigation via coordinator]
Navigation --> End([End])
style Start fill:#4caf50
style End fill:#4caf50
style UseCaseProcess fill:#fff4e1
style RepoProcess fill:#e8f5e9
style HTTPRequest fill:#fce4ec
Note: The diagram below is also available as a Mermaid file:
docs/diagrams/state-flow-diagram.mmd
stateDiagram-v2
[*] --> idle: Initial State
idle --> loggingIn: User Action<br/>(ViewModel → UseCase)
loggingIn --> Repository: Calls Repository
Repository --> DataLayer: Calls Data Layer
DataLayer --> loggedIn: Success
DataLayer --> error: Failure
loggedIn --> ViewModelUpdate: State Publisher Emits
error --> ViewModelUpdate: State Publisher Emits
ViewModelUpdate --> ViewRender: Updates @Published
ViewRender --> [*]: View Re-renders
note right of idle
Initial state
Waiting for user input
end note
note right of loggingIn
UseCase validates input
Updates state to loading
end note
note right of Repository
Orchestrates data sources
Transforms data
end note
note right of DataLayer
Remote API calls
Database operations
Keychain access
end note
Clean Architecture Principle: Use Cases define boundaries (interfaces) that outer layers implement.
Defined by: Use Cases
Implemented by: Repository Implementations
// UseCaseAPI defines the interface (input boundary)
public protocol LoginUseCaseRepositoryProtocol {
func login(email: String) async throws -> LoginUseCaseNameSpace.LoginResponse
}
// RepositoryImpl implements it
extension LoginRepositoryImpl: LoginUseCaseRepositoryProtocol {
// Implementation
}Key Points:
- Use Case defines what it needs
- Repository provides what Use Case needs
- Dependency points inward (Repository depends on Use Case's interface)
Defined by: Use Cases
Used by: Interface Adapters (ViewModels)
// UseCaseAPI defines output boundary
extension LoginUseCaseNameSpace {
public enum States {
case idle(email: String, acceptedTerms: Bool)
case loggingIn(email: String, acceptedTerms: Bool)
case loggedIn(email: String, registration: RegistrationState, acceptedTerms: Bool)
case error(email: String, acceptedTerms: Bool, error: Errors)
}
}
// ViewModel consumes the output boundary
viewModel.subscribe(to: useCase.statePublisher) // Receives States enumKey Points:
- Use Case defines output format
- ViewModel consumes output format
- Use Case doesn't know about ViewModel
- ViewModel adapts Use Case output to UI format
Clean Architecture Principle: Object composition happens at the outermost layer, once, at application startup.
The Composition Root is where all dependencies are wired together. It lives in the Frameworks & Drivers layer (outermost).
Location: App/AppDelegate.swift or App/PayRedApp.swift
- Wire all dependencies - Connect interfaces to implementations
- Create object graph - Build the dependency tree
- Happens once - At application startup
- Outermost layer only - Only the app entry point knows about all implementations
In this project, the Composition Root is split into two parts:
- Registration:
PayRed/DI/DIRegisterar.swift- Contains all dependency registrations - Initialization:
PayRed/PayRedApp.swift- Calls registration at app startup
Registration File (PayRed/DI/DIRegisterar.swift):
import Resolver
// ... import all API and Impl packages
extension Resolver: @retroactive ResolverRegistering {
public static func registerAllServices() {
// Register Data Layer implementations
register {
MobileGatewayServiceImpl()
}
.implements(LoginRepositoryRemoteDataProtocol.self)
.implements(CardListRepositoryRemoteDataProtocol.self)
// ... implements many more protocols
.scope(.application)
register {
DataBase()
}
.implements(LoginRepositoryDataBaseDataProtocol.self)
.implements(CardListRepositoryDataBaseDataProtocol.self)
// ... implements many more protocols
.scope(.application)
// Register Repository implementations
register {
LoginRepositoryImpl()
}
.implements(LoginUseCaseRepositoryProtocol.self)
.scope(.shared)
// Register UseCase implementations
register(LoginUseCaseProtocol.self) {
LoginUseCaseImpl()
}
.scope(.shared)
// Register Presentation implementations
register(LoginScreenProtocol.self) {
LoginScreenImpl()
}
.scope(.unique)
// ... all other registrations
}
}App Entry Point (PayRed/PayRedApp.swift):
import Resolver
@main
struct PayRedApp: App {
@Injected private var mainCoordinator: (any MainAppCoordinatorProtocol)
init() {
// Wire all dependencies here - happens once at app startup
Resolver.registerAllServices()
// Optionally resolve dependencies that need initialization at startup
_ = Resolver.resolve(NotificationManagerUseCaseProtocol.self)
}
var body: some Scene {
WindowGroup {
mainCoordinator.start()
}
}
}Key Rules:
- ✅ Composition Root knows about all implementations (in
DIRegisterar.swift) - ✅ Inner layers don't know about Resolver (they use protocols)
- ✅ Constructors receive abstractions (protocols) via Resolver
- ✅ Wiring happens once at startup via
Resolver.registerAllServices() - ✅ Resolver uses the
ResolverRegisteringprotocol pattern for automatic registration discovery
Clean Architecture enables testability by design. Each layer has different testing requirements.
Test Type: Pure unit tests
Dependencies: None
Framework: XCTest
// Pure business logic tests
func testEmailValidation() {
let email = Email(value: "test@example.com")
XCTAssertTrue(email.isValid)
}Characteristics:
- Fast (no I/O)
- No mocks needed
- Pure business logic validation
Test Type: Business rule tests
Dependencies: Mock repositories
Framework: XCTest
// Test business rules with mocked dependencies
func testLoginWithInvalidEmail() {
let mockRepository = MockLoginRepository()
let useCase = LoginUseCaseImpl(repository: mockRepository)
XCTAssertThrowsError(try await useCase.login(email: "invalid"))
}Characteristics:
- Test business rules
- Mock repository interfaces
- No framework dependencies
- Fast execution
Test Type: Mapping/formatting tests
Dependencies: Mock Use Cases
Framework: XCTest
// Test data transformation
func testViewModelFormatsError() {
let mockUseCase = MockLoginUseCase()
let viewModel = LoginViewModel(useCase: mockUseCase)
// Test that ViewModel formats UseCase error correctly
}Characteristics:
- Test data transformation
- Test UI formatting
- Mock Use Cases
- No business logic tests here
Test Type: Integration tests (minimal)
Dependencies: Real frameworks
Framework: XCTest + Test doubles
// Minimal integration tests
func testNetworkServiceMakesRequest() {
// Test actual network call with test server
}Characteristics:
- Minimal testing (frameworks are details)
- Integration tests only
- Can be slow
- Test that implementations work
Clean Architecture Principle: Test doubles (stubs, mocks, spies) must be flexible and configurable to enable comprehensive testing without framework dependencies.
For each API package (containing protocols and data models), create a separate test target that provides test doubles.
Package Structure:
{Feature}API/
├── Package.swift
├── Sources/
│ └── {Feature}API/
│ ├── {Feature}Protocol.swift
│ ├── {Feature}RepositoryProtocol.swift
│ └── {Feature}NameSpace.swift
└── Tests/
└── {Feature}APITestDoubles/ # Separate test target
└── Sources/
└── {Feature}APITestDoubles/
├── {Feature}Stub.swift
├── {Feature}Mock.swift
└── {Feature}Spy.swift
Package.swift Configuration:
// Package.swift
let package = Package(
name: "LoginUseCaseAPI",
products: [
.library(name: "LoginUseCaseAPI", targets: ["LoginUseCaseAPI"]),
],
targets: [
.target(name: "LoginUseCaseAPI"),
.target(
name: "LoginUseCaseAPITestDoubles",
dependencies: ["LoginUseCaseAPI"], // Depends on main target
path: "Tests/LoginUseCaseAPITestDoubles"
),
]
)For each protocol in an API package, create flexible test doubles that can be configured in tests.
Pattern: Combine Stub, Mock, and Spy into a single flexible test double when possible, or separate them if needed.
// LoginUseCaseAPITestDoubles/Sources/LoginUseCaseAPITestDoubles/LoginUseCaseStub.swift
import LoginUseCaseAPI
import Foundation
/// Flexible test double that combines Stub, Mock, and Spy capabilities
public final class LoginUseCaseStub: LoginUseCaseProtocol {
// MARK: - Configurable Behavior (Stub)
/// Configurable return value for login method
public var loginResult: Result<Void, Error> = .success(())
/// Configurable state publisher
public var statePublisherValue: AnyPublisher<LoginUseCaseNameSpace.States, Never> {
stateSubject.eraseToAnyPublisher()
}
private let stateSubject = CurrentValueSubject<LoginUseCaseNameSpace.States, Never>(
.idle(email: "", acceptedTerms: false)
)
// MARK: - Call Tracking (Spy)
/// Tracks if login was called
public private(set) var loginCalled = false
/// Tracks login call count
public private(set) var loginCallCount = 0
/// Tracks last email passed to login
public private(set) var lastLoginEmail: String?
/// Tracks all emails passed to login
public private(set) var loginEmails: [String] = []
/// Tracks if acceptTerms was called
public private(set) var acceptTermsCalled = false
/// Tracks last email passed to acceptTerms
public private(set) var lastAcceptTermsEmail: String?
/// Tracks if rejectTerms was called
public private(set) var rejectTermsCalled = false
/// Tracks if clear was called
public private(set) var clearCalled = false
// MARK: - Configurable Closures (Mock)
/// Closure to execute when login is called (allows custom behavior)
public var onLogin: ((String) async throws -> Void)?
/// Closure to execute when acceptTerms is called
public var onAcceptTerms: ((String) async throws -> Void)?
/// Closure to execute when rejectTerms is called
public var onRejectTerms: ((String) async throws -> Void)?
// MARK: - Reset Method
/// Reset all tracking and configuration
public func reset() {
loginCalled = false
loginCallCount = 0
lastLoginEmail = nil
loginEmails = []
acceptTermsCalled = false
lastAcceptTermsEmail = nil
rejectTermsCalled = false
clearCalled = false
loginResult = .success(())
onLogin = nil
onAcceptTerms = nil
onRejectTerms = nil
stateSubject.send(.idle(email: "", acceptedTerms: false))
}
// MARK: - LoginUseCaseProtocol Implementation
public var statePublisher: AnyPublisher<LoginUseCaseNameSpace.States, Never> {
statePublisherValue
}
public func login(email: String) async throws {
// Spy: Track call
loginCalled = true
loginCallCount += 1
lastLoginEmail = email
loginEmails.append(email)
// Mock: Execute custom closure if provided
if let onLogin = onLogin {
try await onLogin(email)
return
}
// Stub: Return configured result
switch loginResult {
case .success:
// Update state to simulate success
stateSubject.send(.loggingIn(email: email, acceptedTerms: true))
stateSubject.send(.loggedIn(
email: email,
registertaion: .alreadyRegistered,
acceptedTerms: true
))
case .failure(let error):
throw error
}
}
public func acceptTerms(email: String) async throws {
acceptTermsCalled = true
lastAcceptTermsEmail = email
if let onAcceptTerms = onAcceptTerms {
try await onAcceptTerms(email)
return
}
stateSubject.send(.idle(email: email, acceptedTerms: true))
}
public func rejectTerms(email: String) async throws {
rejectTermsCalled = true
if let onRejectTerms = onRejectTerms {
try await onRejectTerms(email)
return
}
stateSubject.send(.idle(email: email, acceptedTerms: false))
}
public func clear() {
clearCalled = true
stateSubject.send(.idle(email: "", acceptedTerms: false))
}
// MARK: - Helper Methods for Testing
/// Manually update state (useful for testing state transitions)
public func setState(_ state: LoginUseCaseNameSpace.States) {
stateSubject.send(state)
}
/// Simulate error state
public func simulateError(_ error: LoginUseCaseNameSpace.Errors, email: String, acceptedTerms: Bool) {
stateSubject.send(.error(email: email, acceptedTerms: acceptedTerms, error: error))
}
}// LoginRepositoryAPITestDoubles/Sources/LoginRepositoryAPITestDoubles/LoginRepositoryStub.swift
import LoginUseCaseAPI
import Foundation
public final class LoginRepositoryStub: LoginUseCaseRepositoryProtocol {
// MARK: - Configurable Behavior
public var loginResult: Result<LoginUseCaseNameSpace.LoginResponse, Error> = .success(.userExist)
public var saveUserLoginStateResult: Result<Void, Error> = .success(())
public var doesUserExistResult: Result<Bool, Error> = .success(true)
// MARK: - Call Tracking
public private(set) var loginCalled = false
public private(set) var loginCallCount = 0
public private(set) var lastLoginEmail: String?
public private(set) var saveUserLoginStateCalled = false
public private(set) var lastSaveUserLoginStateEmail: String?
public private(set) var lastSaveUserLoginStateToken: String?
public private(set) var lastSaveUserLoginStateUserExist: Bool?
public private(set) var doesUserExistCalled = false
public private(set) var lastDoesUserExistEmail: String?
// MARK: - Configurable Closures
public var onLogin: ((String) async throws -> LoginUseCaseNameSpace.LoginResponse)?
public var onSaveUserLoginState: ((String, String?, Bool) async throws -> Void)?
public var onDoesUserExist: ((String) async throws -> Bool)?
// MARK: - Reset
public func reset() {
loginCalled = false
loginCallCount = 0
lastLoginEmail = nil
saveUserLoginStateCalled = false
lastSaveUserLoginStateEmail = nil
lastSaveUserLoginStateToken = nil
lastSaveUserLoginStateUserExist = nil
doesUserExistCalled = false
lastDoesUserExistEmail = nil
loginResult = .success(.userExist)
saveUserLoginStateResult = .success(())
doesUserExistResult = .success(true)
onLogin = nil
onSaveUserLoginState = nil
onDoesUserExist = nil
}
// MARK: - LoginUseCaseRepositoryProtocol Implementation
public func login(email: String) async throws -> LoginUseCaseNameSpace.LoginResponse {
loginCalled = true
loginCallCount += 1
lastLoginEmail = email
if let onLogin = onLogin {
return try await onLogin(email)
}
switch loginResult {
case .success(let response):
return response
case .failure(let error):
throw error
}
}
public func saveUserLoginState(email: String, temporaryToken: String?, userExist: Bool) async throws {
saveUserLoginStateCalled = true
lastSaveUserLoginStateEmail = email
lastSaveUserLoginStateToken = temporaryToken
lastSaveUserLoginStateUserExist = userExist
if let onSaveUserLoginState = onSaveUserLoginState {
try await onSaveUserLoginState(email, temporaryToken, userExist)
return
}
switch saveUserLoginStateResult {
case .success:
return
case .failure(let error):
throw error
}
}
public func doesUserExist(email: String) async throws -> Bool {
doesUserExistCalled = true
lastDoesUserExistEmail = email
if let onDoesUserExist = onDoesUserExist {
return try await onDoesUserExist(email)
}
switch doesUserExistResult {
case .success(let exists):
return exists
case .failure(let error):
throw error
}
}
}Implementation package test target imports both the API package and its test doubles:
// LoginUseCaseImplTests/LoginUseCaseImplTests.swift
import XCTest
import LoginUseCaseAPI
import LoginUseCaseAPITestDoubles // Import test doubles
import LoginUseCaseImpl
final class LoginUseCaseImplTests: XCTestCase {
var sut: LoginUseCaseImpl!
var repositoryStub: LoginRepositoryStub!
override func setUp() {
super.setUp()
repositoryStub = LoginRepositoryStub()
sut = LoginUseCaseImpl(repository: repositoryStub)
}
override func tearDown() {
repositoryStub.reset() // Reset between tests
repositoryStub = nil
sut = nil
super.tearDown()
}
func testLoginWithValidEmail() async throws {
// Arrange: Configure stub behavior
repositoryStub.loginResult = .success(.userExist)
// Act
try await sut.login(email: "test@example.com")
// Assert: Verify behavior and check spy data
XCTAssertTrue(repositoryStub.loginCalled)
XCTAssertEqual(repositoryStub.loginCallCount, 1)
XCTAssertEqual(repositoryStub.lastLoginEmail, "test@example.com")
XCTAssertTrue(repositoryStub.saveUserLoginStateCalled)
}
func testLoginWithCustomBehavior() async throws {
// Arrange: Use closure for custom behavior
var customBehaviorCalled = false
repositoryStub.onLogin = { email in
customBehaviorCalled = true
return .userNotExist(temporaryToken: "token123")
}
// Act
try await sut.login(email: "test@example.com")
// Assert
XCTAssertTrue(customBehaviorCalled)
XCTAssertTrue(repositoryStub.loginCalled)
}
func testLoginHandlesError() async throws {
// Arrange: Configure error
repositoryStub.loginResult = .failure(URLError(.notConnectedToInternet))
// Act & Assert
do {
try await sut.login(email: "test@example.com")
XCTFail("Should have thrown error")
} catch {
XCTAssertTrue(repositoryStub.loginCalled)
}
}
}-
Flexibility: All behavior should be configurable
- Configurable return values
- Configurable closures for custom behavior
- Ability to reset state between tests
-
Spy Capabilities: Track all interactions
- Method call tracking
- Parameter tracking
- Call count tracking
-
Stub Capabilities: Provide default behavior
- Default return values
- Configurable success/failure scenarios
-
Mock Capabilities: Allow custom behavior
- Closure-based customization
- Dynamic behavior per test
-
Separation: Test doubles live in API package test targets
- Implementation tests import API + TestDoubles
- No test doubles in implementation packages
- Reusable across all implementation tests
// LoginUseCaseImpl/Package.swift
let package = Package(
name: "LoginUseCaseImpl",
products: [
.library(name: "LoginUseCaseImpl", targets: ["LoginUseCaseImpl"]),
],
dependencies: [
.package(path: "../LoginUseCaseAPI"),
],
targets: [
.target(
name: "LoginUseCaseImpl",
dependencies: ["LoginUseCaseAPI"]
),
.testTarget(
name: "LoginUseCaseImplTests",
dependencies: [
"LoginUseCaseImpl",
"LoginUseCaseAPI",
.product(name: "LoginUseCaseAPITestDoubles", package: "LoginUseCaseAPI"), // Import test doubles
]
),
]
)✅ Reusability: Test doubles defined once, used everywhere
✅ Flexibility: Fully configurable behavior per test
✅ Maintainability: Changes to protocols automatically reflected in test doubles
✅ Type Safety: Test doubles are strongly typed
✅ Separation: Test infrastructure separate from implementation
✅ Easy Testing: Simple setup and teardown with reset methods
This section documents common violations of Clean Architecture that we explicitly avoid.
What it is: ViewModels containing business logic, validation, or decision-making.
Why it's wrong: Violates Clean Architecture - business rules belong in Use Cases.
Example (WRONG):
class LoginViewModel {
func validateEmail(_ email: String) -> Bool {
// ❌ Business validation in ViewModel
return email.contains("@")
}
}Correct approach:
// ✅ Validation in Use Case
class LoginUseCase {
func login(email: String) async throws {
guard isValidEmail(email) else {
throw LoginErrors.invalidEmail
}
// ...
}
}What it is: Repositories that contain business logic or make business decisions.
Why it's wrong: Repositories are abstractions, not business logic containers.
Example (WRONG):
class UserRepository {
func createUser(email: String) {
// ❌ Business logic in Repository
if email.contains("admin") {
// Business decision
}
}
}Correct approach:
// ✅ Business logic in Use Case
class CreateUserUseCase {
func createUser(email: String) async throws {
// Business decision here
if email.contains("admin") {
throw UserErrors.adminEmailNotAllowed
}
try await repository.saveUser(email: email)
}
}What it is: Domain entities conforming to Codable or containing API/database concerns.
Why it's wrong: Domain models must be pure - no framework or persistence pollution.
Example (WRONG):
// ❌ Domain model polluted with framework concerns
struct User: Codable {
let id: Int // Database ID
let email: String
let createdAt: Date
}Correct approach:
// ✅ Pure domain model
struct User {
let email: Email // Value object
let createdAt: Date
}
// ✅ Separate DTO for API
struct UserDTO: Codable {
let id: Int
let email: String
let createdAt: String
}What it is: Using SwiftUI types (View, @Published, etc.) in Use Cases or Domain.
Why it's wrong: Frameworks are details - inner layers must not know about them.
Example (WRONG):
// ❌ Framework type in Use Case
import SwiftUI
class LoginUseCase {
@Published var state: LoginState // ❌ SwiftUI in Use Case
}Correct approach:
// ✅ Pure reactive interface (framework-agnostic)
class LoginUseCase {
var statePublisher: AnyPublisher<LoginState, Never> {
// Pure Combine, no SwiftUI
}
}What it is: Passing framework types (URLSession, CoreData context) to inner layers.
Why it's wrong: Inner layers must not depend on framework details.
Example (WRONG):
// ❌ Framework type passed to Use Case
class LoginUseCase {
init(urlSession: URLSession) { // ❌ Framework dependency
// ...
}
}Correct approach:
// ✅ Abstract interface
protocol NetworkClient {
func request(_ endpoint: String) async throws -> Data
}
class LoginUseCase {
init(networkClient: NetworkClient) { // ✅ Abstraction
// ...
}
}What it is: Use Cases directly referencing or depending on ViewModels.
Why it's wrong: Use Cases are the center - they don't know about outer layers.
Example (WRONG):
// ❌ Use Case knows about ViewModel
class LoginUseCase {
func login(email: String, viewModel: LoginViewModel) { // ❌
// ...
}
}Correct approach:
// ✅ Use Case defines output boundary, ViewModel consumes it
class LoginUseCase {
var statePublisher: AnyPublisher<LoginState, Never> {
// ViewModel subscribes, Use Case doesn't know about it
}
}Clean Architecture Principle: Errors must be modeled as business concepts, not framework types.
Domain Errors (UseCaseNameSpace.Errors)
↓
Use Case transforms Repository errors to Domain errors
↓
ViewModel formats Domain errors for UI
↓
View displays formatted error
Defined in: Use Cases
Type: Enum in UseCaseNameSpace
extension LoginUseCaseNameSpace {
public enum Errors: LocalizedError {
case invalidEmailFormat
case termsNotAccepted
case userNotFound
case networkUnavailable
public var errorDescription: String? {
// Business-friendly error messages
}
}
}Key Points:
- Errors are business concepts, not HTTP codes
- Defined in Use Case namespace
- Framework-agnostic
- Business-meaningful
Repository Layer → Use Case Layer:
// Repository throws framework error
catch let error as URLError {
// Use Case translates to domain error
throw LoginUseCaseNameSpace.Errors.networkUnavailable
}Use Case Layer → ViewModel:
// Use Case throws domain error
catch let error as LoginUseCaseNameSpace.Errors {
// ViewModel formats for UI
self.errorMessage = error.localizedDescription
}Forbidden:
- ❌ Passing HTTP status codes to Use Cases
- ❌ Using NSError in domain layer
- ❌ Framework error types in business logic
- All layers communicate through protocols
- Enables easy testing and mocking
- Supports multiple implementations
- UseCases use Combine publishers for state
- ViewModels subscribe to UseCase publishers
- SwiftUI automatically updates on
@Publishedchanges
Clean Architecture Core Principle: The Dependency Rule
Source code dependencies can only point inward. Nothing in an inner circle can know anything about something in an outer circle.
How it works:
- Inner layers define interfaces (Use Cases define Repository interfaces)
- Outer layers implement interfaces (Repository implementations, Data sources)
- Dependencies point inward (Repository depends on Use Case's interface, not vice versa)
- DI happens at boundaries (Composition Root wires implementations to interfaces)
Example:
// ✅ CORRECT: Use Case (inner) defines interface
// UseCaseAPI
protocol LoginUseCaseRepositoryProtocol {
func login(email: String) async throws -> LoginResponse
}
// ✅ CORRECT: Repository (outer) implements interface
// RepositoryImpl
extension LoginRepositoryImpl: LoginUseCaseRepositoryProtocol {
// Implementation
}
// ✅ CORRECT: Dependency points inward
// UseCaseImpl depends on protocol (defined by itself)
class LoginUseCaseImpl {
private let repository: LoginUseCaseRepositoryProtocol // Depends on own interface
}Forbidden:
- ❌ Use Case depending on RepositoryImpl (concrete class)
- ❌ Use Case depending on Data implementations
- ❌ Inner layers knowing about outer layer implementations
Clean Architecture Separation:
- Entities (Innermost): Pure business objects, no dependencies
- Use Cases (Center): Application business rules, depends only on Entities
- Interface Adapters: Data transformation, depends on Use Cases
- Frameworks & Drivers (Outermost): Framework details, depends on Interface Adapters
Key Principle: Each layer has a single, well-defined responsibility and depends only on inner layers.
Each feature uses a namespace to avoid naming conflicts:
// UseCase namespace
extension LoginUseCaseNameSpace {
public enum States { ... }
public enum Errors { ... }
}
// Repository namespace
extension LoginRepositoryNameSpace {
public struct LoginOrRegisterRemoteResult { ... }
}When a protocol method returns nothing (void), create a Result model to avoid method signature conflicts. This ensures that methods with the same name from different protocols don't get mixed up.
Pattern: For methods that return Void, create a Result model named {MethodName}Result and return it instead.
Example:
// Protocol definition
public protocol ProfileRepositoryProtocol {
func fetchProfile() async throws -> ProfileRepositoryNameSpace.FetchProfileResult
}
// Namespace with Result model
extension ProfileRepositoryNameSpace {
public struct FetchProfileResult {
public init() {}
}
}
// Implementation
extension ProfileRepositoryImpl: ProfileRepositoryProtocol {
public func fetchProfile() async throws -> ProfileRepositoryNameSpace.FetchProfileResult {
// Perform fetch operation
// ...
return ProfileRepositoryNameSpace.FetchProfileResult()
}
}Why this pattern?
- Prevents method signature conflicts when the same method name exists in multiple protocols
- Makes method signatures unique and distinguishable
- Allows for future extension if the method needs to return data later
- Maintains type safety and clarity
Alternative for methods that actually return data:
// If the method returns actual data, use a meaningful Result model
public protocol ProfileRepositoryProtocol {
func fetchProfile() async throws -> ProfileRepositoryNameSpace.FetchProfileResult
}
extension ProfileRepositoryNameSpace {
public struct FetchProfileResult {
public let profile: ProfileEntity
public let lastUpdated: Date
public init(profile: ProfileEntity, lastUpdated: Date) {
self.profile = profile
self.lastUpdated = lastUpdated
}
}
}Note: The diagram below is also available as a Mermaid file:
docs/diagrams/error-propagation-diagram.mmd
flowchart TD
Start([Data Layer Error]) --> RepoTransform[Repository transforms<br/>to domain error]
RepoTransform --> UseCaseHandle[UseCase handles error<br/>and updates state]
UseCaseHandle --> ViewModelFormat[ViewModel formats<br/>error for UI]
ViewModelFormat --> ViewDisplay[View displays<br/>error alert]
ViewDisplay --> End([User sees error])
subgraph ErrorTypes["Error Types"]
DataError["Data Layer Errors<br/>Network, Database, etc."]
DomainError["Domain Errors<br/>UseCaseNameSpace.Errors"]
UIError["UI Errors<br/>Formatted in ViewModel"]
end
Start -.-> DataError
RepoTransform -.-> DomainError
ViewModelFormat -.-> UIError
style Start fill:#f44336,color:#fff
style End fill:#4caf50
style DataError fill:#ff9800
style DomainError fill:#ff9800
style UIError fill:#ff9800
- Data Layer Errors: Network errors, database errors, etc.
- Domain Errors: Defined in UseCase namespace (e.g.,
LoginUseCaseNameSpace.Errors) - UI Errors: Formatted in ViewModel for user display
For API Packages:
- Each API package has a separate test target:
{Feature}APITestDoubles - Contains Stubs, Mocks, and Spies for all protocols
- Flexible and fully configurable
For Implementation Packages:
- Test targets import both API package and its TestDoubles
- Use test doubles from API packages
- No test doubles defined in implementation packages
See Section 9.5 for detailed testing infrastructure documentation including:
- Package structure for test doubles
- Stub/Mock/Spy pattern implementation
- Usage examples in implementation tests
- Configuration and flexibility patterns
- Protocols enable easy mocking - All dependencies are protocols
- Test doubles are reusable - Defined once in API packages, used everywhere
- Fully configurable - All behavior can be customized per test
- Spy capabilities - Track all method calls and parameters
- Dependency Injection - Allows easy substitution of test doubles
- State publishers - Can be tested with Combine testing utilities
-
SPM Package Organization (MANDATORY):
- ✅ CRITICAL: When implementing ANY feature, you MUST create SPM packages - this is NOT optional
- ✅ Each API and Impl must be a separate SPM package with its own
Package.swiftfile - ✅ DO NOT create Swift files without creating the corresponding SPM package structure first
- ✅ Follow the feature module structure:
Features/{Feature}/{Layer}/{Component}/ - ✅ API packages define protocols; Impl packages implement them
- ❌ Never put implementation code in API packages
- 📖 See Section "CRITICAL REQUIREMENT: SPM Packages Are Mandatory" above for step-by-step instructions
-
SPM Dependency Rules (ENFORCED AT COMPILE TIME):
- ✅ API packages can only depend on other API packages
- ✅ Impl packages depend on their corresponding API package and other API packages
- ❌ Impl packages never depend on other Impl packages directly
- ✅ SPM enforces these rules at compile time
-
Dependency Direction (THE DEPENDENCY RULE):
- ✅ Dependencies always point inward (Presentation → UseCase → Repository → Data)
- ✅ Inner layers define interfaces; outer layers implement them
- ✅ Use Cases define Repository interfaces (not vice versa)
-
Protocol-Based Communication:
- ✅ Always use protocols for inter-layer communication
- ✅
ImplSPM packages implement protocols (interfaces) from API packages - ✅
ImplSPM packages use (depend on) protocols from API packages in lower layers
-
Layer Responsibilities:
- ✅ UseCases: Pure business logic, no UI dependencies, define repository interfaces
- ✅ Repositories: Orchestrate data sources, don't put business logic here
- ✅ ViewModels: Thin adapters, delegate to UseCases, format for UI only
- ✅ Views: Display data, handle user input, use Image Cache directly
-
Technical Standards:
- ✅ Use async/await for all async operations
- ✅ Publish state changes through reactive frameworks (Combine, RxSwift, etc.)
- ✅ Handle errors gracefully at each layer
- ✅ Use namespaces to avoid naming conflicts
- ✅ Register dependencies in dependency injection container
-
Naming Conventions:
- ✅ Follow pattern:
{Feature}{Layer}APIand{Feature}{Layer}Implas SPM packages - ✅ Example:
LoginUseCaseAPI,LoginUseCaseImpl,LoginRepositoryAPI,LoginRepositoryImpl
- ✅ Follow pattern:
-
Image Handling:
- ✅ Repositories/UseCases: Only pass image URLs, never download images
- ✅ Presentation Layer: Use Image Cache directly to download and cache images from URLs
- ✅ Image downloading is a UI concern, not a business logic concern
When implementing a feature, verify:
- All components are SPM packages with
Package.swiftfiles - API packages contain only protocols (no implementation)
- Impl packages implement protocols from corresponding API packages
- Dependencies point inward only
- Use Cases define repository interfaces
- ViewModels are thin (delegate to UseCases)
- All dependencies registered in DI container
- Async operations use async/await
- State changes published via Combine
- Errors handled at each layer
Each component is a separate SPM package:
Features/Login/
├── Presentation/
│ └── LoginScreen/
│ ├── LoginScreenAPI/ # SPM Package: Protocol & Dependencies
│ │ ├── Package.swift
│ │ └── Sources/LoginScreenAPI/
│ │ ├── LoginScreenProtocol.swift
│ │ └── LoginScreenDependencies.swift
│ └── LoginScreenImpl/ # SPM Package: View & ViewModel
│ ├── Package.swift
│ └── Sources/LoginScreenImpl/
│ ├── LoginScreenView.swift
│ └── LoginScreenViewModel.swift
│
├── Repositories/
│ └── LoginRepository/
│ ├── LoginRepositoryAPI/ # SPM Package: Data Protocols
│ │ ├── Package.swift
│ │ └── Sources/LoginRepositoryAPI/
│ │ ├── LoginRepositoryRemoteDataProtocol.swift
│ │ ├── LoginRepositoryDataBaseDataProtocol.swift
│ │ └── LoginRepositoryNameSpace.swift
│ └── LoginRepositoryImpl/ # SPM Package: Repository Implementation
│ ├── Package.swift
│ └── Sources/LoginRepositoryImpl/
│ └── LoginRepositoryImpl.swift
│
└── UseCases/
└── LoginUseCase/
├── LoginUseCaseAPI/ # SPM Package: UseCase Protocol & Repository Protocol
│ ├── Package.swift
│ └── Sources/LoginUseCaseAPI/
│ ├── LoginUseCaseProtocol.swift
│ ├── LoginUseCaseRepositoryProtocol.swift
│ └── LoginUseCaseNameSpace.swift
└── LoginUseCaseImpl/ # SPM Package: UseCase Implementation
├── Package.swift
└── Sources/LoginUseCaseImpl/
└── LoginUseCaseImpl.swift
Data layer implementations are SPM packages that implement protocols from RepositoryAPI packages:
Data/
├── MobileGatewayService/
│ └── MobileGatewayServiceImpl/ # SPM Package
│ ├── Package.swift
│ └── Sources/MobileGatewayServiceImpl/
│ ├── NetworkService.swift
│ ├── NetworkService+LoginRepository.swift # Implements LoginRepositoryRemoteDataProtocol
│ └── NetworkService+CardListRepository.swift
│
├── DataBase/
│ └── DataBase/ # SPM Package
│ ├── Package.swift
│ └── Sources/DataBase/
│ ├── Database.swift
│ ├── Database+LoginRepository.swift # Implements LoginRepositoryDataBaseDataProtocol
│ └── Database+CardListRepository.swift
│
├── Keychain/
│ └── KeyChainDataImpl/ # SPM Package
│ ├── Package.swift
│ └── Sources/KeyChainDataImpl/
│ └── KeychainService.swift
│
└── ImageCache/
└── ImageCacheImpl/ # SPM Package
├── Package.swift
└── Sources/ImageCacheImpl/
└── ImageCacheImpl.swift
Note: The diagram below is also available as a Mermaid file:
docs/diagrams/dependency-flow-diagram.mmd
graph TB
ViewModel["ViewModel"] -->|uses| UseCaseProtocol["UseCaseProtocol<br/>(from UseCaseAPI)"]
UseCaseImpl["UseCaseImpl"] -.implements.-> UseCaseProtocol
UseCaseImpl -->|uses| RepoProtocol["RepositoryProtocol<br/>(from UseCaseAPI)"]
RepoImpl["RepositoryImpl"] -.implements.-> RepoProtocol
RepoImpl -->|uses| DataProtocols["DataProtocols<br/>(from RepositoryAPI)"]
RemoteData["Remote Data<br/>Implementation"] -.implements.-> DataProtocols
DatabaseData["Database<br/>Implementation"] -.implements.-> DataProtocols
KeychainData["Keychain<br/>Implementation"] -.implements.-> DataProtocols
subgraph Packages["Protocol Packages"]
UseCaseAPIPkg["UseCaseAPI Package<br/>Contains: UseCaseProtocol<br/>Contains: RepositoryProtocol"]
RepoAPIPkg["RepositoryAPI Package<br/>Contains: DataProtocols"]
end
UseCaseProtocol -.defined in.-> UseCaseAPIPkg
RepoProtocol -.defined in.-> UseCaseAPIPkg
DataProtocols -.defined in.-> RepoAPIPkg
style ViewModel fill:#e1f5ff
style UseCaseImpl fill:#fff4e1
style RepoImpl fill:#e8f5e9
style RemoteData fill:#fce4ec
style DatabaseData fill:#fce4ec
style KeychainData fill:#fce4ec
style UseCaseAPIPkg fill:#fff9c4
style RepoAPIPkg fill:#fff9c4
Key Points:
- SPM Package Structure: Each API and Impl is a separate SPM package with its own
Package.swift - Protocol Locations (SPM Packages):
RepositoryProtocolis defined in UseCaseAPI SPM packageDataProtocolsare defined in RepositoryAPI SPM package
- Implements (SPM Package Dependencies):
UseCaseImplSPM package implementsUseCaseProtocol(from UseCaseAPI package)RepositoryImplSPM package implementsRepositoryProtocol(from UseCaseAPI package)- Data SPM packages implement
DataProtocols(from RepositoryAPI package)
- Uses/Depends on (SPM Package Dependencies):
UseCaseImplSPM package depends on and usesRepositoryProtocol(from UseCaseAPI package)RepositoryImplSPM package depends on and usesDataProtocols(from RepositoryAPI package)ScreenImplSPM package depends on and usesUseCaseProtocol(from UseCaseAPI package)
- Image Cache: Used directly by Presentation Layer (ScreenImpl packages), not through Repository/UseCase
- SPM Enforces Boundaries: SPM's dependency resolution ensures the dependency rule is enforced at compile time
-
Create SPM Package Structure (MANDATORY)
- Create 6 SPM packages minimum: 3 API + 3 Impl
- Each MUST have
Package.swiftfile - See CRITICAL REQUIREMENT section
-
Implementation Order (Bottom-Up)
UseCaseAPI → UseCaseImpl → RepositoryAPI → RepositoryImpl → ScreenAPI → ScreenImpl -
Key Dependencies
UseCaseImpldepends onUseCaseAPI(implements protocol)UseCaseImplusesRepositoryProtocolfromUseCaseAPI(not RepositoryImpl!)RepositoryImpldepends onRepositoryAPI(implements protocol)RepositoryImplusesDataProtocolsfromRepositoryAPIScreenImpldepends onScreenAPI(implements protocol)ScreenImplusesUseCaseProtocolfromUseCaseAPI(not UseCaseImpl!)
-
Critical Rules
- ✅ Use Cases define Repository interfaces (inner defines, outer implements)
- ✅ Dependencies point inward only
- ✅ API packages = protocols only, no implementation
- ✅ Impl packages never depend on other Impl packages
- ❌ Never put implementation in API packages
- ❌ Never make outer layers define interfaces for inner layers
API Package:
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "{Component}API",
platforms: [.iOS(.v16)],
products: [.library(name: "{Component}API", targets: ["{Component}API"])],
dependencies: [], // Only other API packages
targets: [.target(name: "{Component}API", dependencies: [])]
)Impl Package:
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "{Component}Impl",
platforms: [.iOS(.v16)],
products: [.library(name: "{Component}Impl", targets: ["{Component}Impl"])],
dependencies: [.package(path: "../{Component}API")],
targets: [
.target(
name: "{Component}Impl",
dependencies: [.product(name: "{Component}API", package: "{Component}API")]
)
]
)| Layer | Defines | Implements | Uses |
|---|---|---|---|
| UseCaseAPI | UseCase protocol Repository protocol |
- | - |
| UseCaseImpl | - | UseCase protocol | Repository protocol |
| RepositoryAPI | Data protocols | - | - |
| RepositoryImpl | - | Repository protocol | Data protocols |
| ScreenAPI | Screen protocol | - | - |
| ScreenImpl | - | Screen protocol | UseCase protocol |
UseCase Pattern:
- Define protocol in
UseCaseAPI - Define repository protocol in
UseCaseAPI - Implement in
UseCaseImpl - Use Combine publishers for state
Repository Pattern:
- Implement repository protocol from
UseCaseAPI - Use data protocols from
RepositoryAPI - Orchestrate data sources (remote, database, keychain)
ViewModel Pattern:
- Thin adapter layer
- Depends on UseCase protocol (not impl)
- Formats UseCase output for UI
- No business logic
This architecture follows Clean Architecture as defined by Robert C. Martin. It is not "MVVM with layers" — it is a strict application of the Dependency Rule.
- The Dependency Rule: Source code dependencies point inward only
- Use Cases Are The Center: All business rules live in Use Cases
- Frameworks Are Details: The architecture doesn't know about SwiftUI, URLSession, or CoreData
- Boundaries Are Explicit: Input and output boundaries are clearly defined
- Testability By Design: Each layer is independently testable
✅ Inner layers define interfaces (Use Cases define Repository interfaces)
✅ Outer layers implement interfaces (Repositories, Data sources)
✅ Business rules in Use Cases only (not in ViewModels or Repositories)
✅ Framework independence (Use Cases don't know about SwiftUI, Combine specifics)
✅ Pure domain models (no Codable, no framework types)
✅ Composition Root (dependency wiring at outermost layer)
✅ Error modeling (business concepts, not HTTP codes)
❌ Not MVVM - ViewModels are interface adapters, not business logic containers
❌ Not "Layered Architecture" - It's dependency-rule architecture
❌ Not Framework-Driven - Frameworks are details, not the architecture
❌ Not "Best Practices" - It's strict architectural rules
If you can:
- Replace SwiftUI with UIKit without changing Use Cases → ✅ Clean Architecture
- Replace URLSession with Alamofire without changing Use Cases → ✅ Clean Architecture
- Test Use Cases without any framework dependencies → ✅ Clean Architecture
- Change database without affecting business rules → ✅ Clean Architecture
Then you have Clean Architecture.
Remember: Clean Architecture is not about "what works" — it's about dependency rules and boundaries. The architecture must be independent of frameworks, UI, databases, and external agencies.