| name | clean-archi-swiftui |
| description | Clean Architecture patterns for Swift/SwiftUI iOS projects. Use when working on Swift/SwiftUI projects that follow Clean Architecture with bounded contexts, ports/adapters pattern, and layered separation (domain/UI). Triggers for creating features, use cases, refactoring to bounded contexts, reviewing architecture compliance, or understanding project structure. |
Clean Architecture for Swift/SwiftUI
Project Structure
root/
├── domain/ # Pure Swift, no UI dependencies
│ ├── {BoundedContext}/ # e.g., Session/, Group/, Auth/
│ │ ├── Entities/
│ │ ├── UseCases/
│ │ ├── Ports/ # Shared abstractions
│ │ └── Errors/
│ ├── Adapters/ # Shared implementations
│ ├── DI/
│ ├── Constants/
│ └── Utilities/
├── {app}/ # e.g., retime/, widget/
│ ├── Views/
│ ├── ViewModels/
│ ├── Ports/ # UI-specific abstractions
│ ├── Adapters/ # UI-specific implementations (RevenueCat, etc.)
│ └── {App}App.swift
├── {extension}/ # Same structure as app
└── Secrets/
Bounded Context Structure
Each bounded context is self-contained:
domain/Session/
├── Entities/
│ └── Session.swift
├── UseCases/
│ ├── StartSessionUseCase.swift
│ └── StopSessionUseCase.swift
├── Ports/
│ └── SessionRepositoryProtocol.swift
└── Errors/
└── SessionError.swift
Layer Rules
Domain Layer (Pure Swift)
- NO SwiftUI, UIKit, or any UI framework imports
- NO concrete infrastructure (UserDefaults, Network, etc.)
- ONLY Foundation and pure Swift
- Contains: Entities, UseCases, Ports (protocols), Errors
App/Extension Layer (SwiftUI)
- Imports domain module
- Contains: Views, ViewModels, UI-specific Ports/Adapters
- Adapters implement domain Ports
Dependency Direction
Views → ViewModels → UseCases → Ports ← Adapters
↑
(protocols)
Ports & Adapters Pattern
Port (Protocol in domain/)
// domain/Session/Ports/SessionRepositoryProtocol.swift
protocol SessionRepositoryProtocol {
func save(_ session: Session) async throws
func getCurrent() async throws -> Session?
func delete(_ id: String) async throws
}
Adapter (Implementation)
// domain/Adapters/UserDefaultsSessionRepository.swift (shared)
// OR
// retime/Adapters/RevenueCatPaymentAdapter.swift (UI-specific)
class UserDefaultsSessionRepository: SessionRepositoryProtocol {
private let monitor: MonitorProtocol
init(monitor: MonitorProtocol) {
self.monitor = monitor
}
func save(_ session: Session) async throws {
// Implementation
}
}
Placement Rules
| Adapter Type | Location | Example |
|---|---|---|
| Shared across apps/extensions | domain/Adapters/ |
UserDefaultsRepository, DeviceActivityManager |
| UI-specific | {app}/Adapters/ |
RevenueCatAdapter, StoreKitAdapter |
Error Handling
When to Use throws
- Single operation with clear success/failure
- Errors propagate up naturally
- Caller handles error immediately
func startSession(config: SessionConfig) async throws -> Session {
guard config.isValid else {
throw SessionError.invalidConfiguration
}
// ...
}
When to Use Result<T, E>
- Multiple possible outcomes beyond success/failure
- Caller needs to pattern match on specific cases
- Chaining operations with different error types
func validateAndStart(config: SessionConfig) -> Result<Session, SessionValidationError> {
switch validate(config) {
case .valid:
return .success(createSession(config))
case .invalidDuration(let reason):
return .failure(.invalidDuration(reason))
case .conflictingSession(let existing):
return .failure(.conflict(existing))
}
}
Error Definition
// domain/Session/Errors/SessionError.swift
enum SessionError: Error, LocalizedError {
case notFound
case alreadyActive
case invalidConfiguration
case storageFailed(underlying: Error)
var errorDescription: String? {
switch self {
case .notFound: return "Session not found"
case .alreadyActive: return "A session is already active"
case .invalidConfiguration: return "Invalid session configuration"
case .storageFailed(let error): return "Storage failed: \(error.localizedDescription)"
}
}
}
Dependency Injection
Container Registration
// domain/DI/ServiceRegistration.swift
extension DIContainer {
public func configureServices() {
// Infrastructure
register(MonitorProtocol.self, implementation: AppleLogMonitor())
register(TrackerProtocol.self, implementation: AppleLogTracker())
// Repositories (resolve dependencies)
let monitor = try! resolve(MonitorProtocol.self)
register(
SessionRepositoryProtocol.self,
implementation: UserDefaultsSessionRepository(monitor: monitor)
)
}
}
Two Composition Roots
1. App Composition Root ({App}App.swift)
@main
struct retimeApp: App {
@StateObject var sessionViewModel: SessionViewModel
init() {
// Configure DI
let container = DIContainer.shared
container.configureServices()
// Resolve Ports
let sessionRepository = try! container.resolve(SessionRepositoryProtocol.self)
let shieldManager = try! container.resolve(ShieldManagerProtocol.self)
let monitor = try! container.resolve(MonitorProtocol.self)
// Inject Ports into ViewModels
_sessionViewModel = StateObject(
wrappedValue: SessionViewModel(
sessionRepository: sessionRepository,
shieldManager: shieldManager,
monitor: monitor
)
)
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(sessionViewModel)
}
}
}
2. Extension Composition Root ({Extension}.swift)
Extensions cannot use DIContainer (different process). Instantiate dependencies manually:
class DeviceActivityMonitorExtension: DeviceActivityMonitor {
private let startSession: StartSessionUseCase
private let stopSession: StopSessionUseCase
override init() {
// Manual instantiation (no DIContainer in extensions)
let monitor = AppleLogMonitor()
let tracker = AppleLogTracker()
let sessionRepository = UserDefaultsSessionRepository(monitor: monitor, tracker: tracker)
let shieldManager = ShieldManager(monitor: monitor, tracker: tracker)
// Create UseCases
self.startSession = StartSessionUseCase(
sessionRepository: sessionRepository,
shieldManager: shieldManager,
monitor: monitor
)
self.stopSession = StopSessionUseCase(
sessionRepository: sessionRepository,
shieldManager: shieldManager,
monitor: monitor
)
super.init()
}
override func intervalDidStart(for activity: DeviceActivityName) {
super.intervalDidStart(for: activity)
Task {
_ = await startSession.execute(sessionId: extractSessionId(from: activity))
}
}
}
ViewModel Pattern
When to Use UseCase vs Port Direct
| Situation | Use | Example |
|---|---|---|
| Business logic, orchestration, validation | UseCase | startSession.execute() |
| Simple read/write, no transformation | Port direct | repository.getAll() |
Standard Pattern
ViewModels receive Ports via init, create UseCases internally when needed:
class SessionViewModel: ObservableObject {
// State
@Published var session: Session?
@Published var isLoading = false
@Published var error: SessionError?
// Ports (injected)
private let sessionRepository: SessionRepositoryProtocol
private let shieldManager: ShieldManagerProtocol
private let monitor: MonitorProtocol
// UseCases (created internally)
private let startSession: StartSessionUseCase
private let stopSession: StopSessionUseCase
init(
sessionRepository: SessionRepositoryProtocol,
shieldManager: ShieldManagerProtocol,
monitor: MonitorProtocol
) {
self.sessionRepository = sessionRepository
self.shieldManager = shieldManager
self.monitor = monitor
// Create UseCases with injected Ports
self.startSession = StartSessionUseCase(
sessionRepository: sessionRepository,
shieldManager: shieldManager,
monitor: monitor
)
self.stopSession = StopSessionUseCase(
sessionRepository: sessionRepository,
shieldManager: shieldManager,
monitor: monitor
)
}
// Action using UseCase (business logic)
func start(config: SessionConfig) {
isLoading = true
Task { @MainActor in
do {
session = try await startSession.execute(config: config)
} catch let err as SessionError {
error = err
}
isLoading = false
}
}
// Action using Port direct (simple read)
func loadCurrent() {
Task { @MainActor in
session = try? await sessionRepository.getCurrent()
}
}
}
Complex State Pattern (Forms)
class SessionFormViewModel: ObservableObject {
struct State {
var duration: TimeInterval = 0
var selectedApps: Set<AppIdentifier> = []
var breakInterval: TimeInterval?
var isValid: Bool { duration > 0 && !selectedApps.isEmpty }
}
@Published var state = State()
}
Workflows
Create Feature
See references/file-templates.md for all templates.
- Create Entity in
domain/{Context}/Entities/ - Create Error in
domain/{Context}/Errors/ - Create Port (if external dependency) in
domain/{Context}/Ports/ - Create Adapter in
domain/Adapters/or{app}/Adapters/ - Register in DI in
domain/DI/ServiceRegistration.swift - Create UseCase in
domain/{Context}/UseCases/ - Create ViewModel in
{app}/ViewModels/(receives Ports, creates UseCases) - Wire ViewModel in
{App}App.swiftcomposition root - Create View in
{app}/Views/
Create UseCase
- Identify bounded context
- Define input/output types
- Identify required Ports
- Implement UseCase class
- Wire in ViewModel
// domain/Session/UseCases/PauseSessionUseCase.swift
class PauseSessionUseCase {
private let repository: SessionRepositoryProtocol
private let shieldManager: ShieldManagerProtocol
init(repository: SessionRepositoryProtocol, shieldManager: ShieldManagerProtocol) {
self.repository = repository
self.shieldManager = shieldManager
}
func execute(sessionId: String) async throws {
guard var session = try await repository.getCurrent() else {
throw SessionError.notFound
}
session.pause()
try await repository.save(session)
try await shieldManager.temporarilyDisable()
}
}
Refactor to Bounded Context
- Identify context boundaries - Group related entities, use cases, errors
- Create context directory structure under
domain/ - Move entities first (no dependencies)
- Move errors associated with entities
- Move ports that serve this context
- Move use cases last
- Update imports across the codebase
- Verify compilation after each move
Before
domain/
├── Entities/
│ ├── Session.swift
│ ├── Group.swift
│ └── User.swift
├── UseCases/
│ ├── StartSessionUseCase.swift
│ └── CreateGroupUseCase.swift
After
domain/
├── Session/
│ ├── Entities/Session.swift
│ ├── UseCases/StartSessionUseCase.swift
│ ├── Ports/SessionRepositoryProtocol.swift
│ └── Errors/SessionError.swift
├── Group/
│ ├── Entities/Group.swift
│ ├── UseCases/CreateGroupUseCase.swift
│ └── Errors/GroupError.swift
Code Review
See references/code-review-checklist.md for complete checklist.
File Templates
See references/file-templates.md for copy-paste templates.