VIP+W Clean Architecture
A scalable, testable architecture pattern for iOS apps with strict separation of concerns and unidirectional data flow.
Components Overview
View (ViewController)
- •Responsibility: Display logic ONLY
- •Protocol:
DisplayLogic - •Does: Renders UI, forwards user actions to Interactor
- •Does NOT: Contain business logic, format data, make API calls
Interactor
- •Responsibility: Orchestrates business logic
- •Protocol:
BusinessLogic - •Does: Receives requests from View, coordinates Workers, sends responses to Presenter
- •Does NOT: Format data for display, handle UI, make direct API calls
Presenter
- •Responsibility: Formats data for display
- •Protocol:
PresentationLogic - •Does: Transforms business data into ViewModels, calls View methods
- •Does NOT: Contain business logic, hold state, access network
Worker
- •Responsibility: Handles async operations
- •Protocol:
WorkerLogic - •Does: API calls, database operations, heavy computations
- •Does NOT: Know about View or Presenter, contain business decisions
Router/Wireframe
- •Responsibility: Navigation between scenes
- •Protocol:
RoutingLogic,DataPassingProtocol - •Does: Push/present view controllers, pass data between scenes
- •Does NOT: Contain business logic
Models
- •Request: Data sent from View to Interactor
- •Response: Data sent from Interactor to Presenter
- •ViewModel: Data sent from Presenter to View
Data Flow (Unidirectional)
code
┌───────────────────────────────────────────────────────────────┐ │ │ │ View ──request──► Interactor ──delegate──► Worker │ │ ▲ │ │ │ │ │ │◄─────result──────────┘ │ │ │ │ │ │ │ ▼ │ │ └──viewModel──── Presenter │ │ │ │ Router: Navigation between scenes (accessed via Interactor) │ │ │ └───────────────────────────────────────────────────────────────┘
File Structure Template
code
SceneName/ ├── SceneNameViewController.swift # View (DisplayLogic) ├── SceneNameInteractor.swift # Business Logic (BusinessLogic) ├── SceneNamePresenter.swift # Format for Display (PresentationLogic) ├── SceneNameWorker.swift # Async Operations (WorkerLogic) ├── SceneNameRouter.swift # Navigation (RoutingLogic) ├── SceneNameModels.swift # Request/Response/ViewModel └── SceneNameConfigurator.swift # DI Assembly
Protocol Definitions
DisplayLogic (View conforms)
swift
protocol LoginDisplayLogic: AnyObject {
func displayLoginResult(viewModel: Login.Authenticate.ViewModel)
func displayValidationError(viewModel: Login.Validate.ViewModel)
func displayLoading(isLoading: Bool)
}
BusinessLogic (Interactor conforms)
swift
protocol LoginBusinessLogic {
func authenticate(request: Login.Authenticate.Request)
func validateInput(request: Login.Validate.Request)
}
PresentationLogic (Presenter conforms)
swift
protocol LoginPresentationLogic {
func presentLoginResult(response: Login.Authenticate.Response)
func presentValidationError(response: Login.Validate.Response)
func presentLoading(isLoading: Bool)
}
WorkerLogic (Worker conforms)
swift
protocol LoginWorkerLogic {
func login(email: String, password: String) async throws -> User
func validateCredentials(email: String, password: String) -> ValidationResult
}
RoutingLogic (Router conforms)
swift
protocol LoginRoutingLogic {
func routeToHome()
func routeToForgotPassword()
func routeToSignup()
}
protocol LoginDataPassing {
var dataStore: LoginDataStore? { get }
}
DataStore (Shared state between scenes)
swift
protocol LoginDataStore {
var authenticatedUser: User? { get set }
}
Complete Code Templates
Models (SceneNameModels.swift)
swift
import Foundation
enum Login {
// MARK: - Use Cases
enum Authenticate {
struct Request {
let email: String
let password: String
}
struct Response {
let result: Result<User, AuthError>
}
struct ViewModel {
let isSuccess: Bool
let errorMessage: String?
let welcomeMessage: String?
}
}
enum Validate {
struct Request {
let email: String
let password: String
}
struct Response {
let emailError: String?
let passwordError: String?
}
struct ViewModel {
let emailErrorText: String?
let passwordErrorText: String?
let isValid: Bool
}
}
}
Worker (SceneNameWorker.swift)
swift
import Foundation
protocol LoginWorkerLogic {
func login(email: String, password: String) async throws -> User
func validateCredentials(email: String, password: String) -> ValidationResult
}
final class LoginWorker: LoginWorkerLogic {
// MARK: - Dependencies
private let apiClient: APIClientProtocol
private let validator: CredentialValidatorProtocol
// MARK: - Init
init(
apiClient: APIClientProtocol = APIClient.shared,
validator: CredentialValidatorProtocol = CredentialValidator()
) {
self.apiClient = apiClient
self.validator = validator
}
// MARK: - WorkerLogic
func login(email: String, password: String) async throws -> User {
let request = LoginRequest(email: email, password: password)
let response: LoginResponse = try await apiClient.perform(request)
return response.user
}
func validateCredentials(email: String, password: String) -> ValidationResult {
validator.validate(email: email, password: password)
}
}
struct ValidationResult {
let emailError: String?
let passwordError: String?
var isValid: Bool {
emailError == nil && passwordError == nil
}
}
Interactor (SceneNameInteractor.swift)
swift
import Foundation
protocol LoginBusinessLogic {
func authenticate(request: Login.Authenticate.Request)
func validateInput(request: Login.Validate.Request)
}
protocol LoginDataStore {
var authenticatedUser: User? { get set }
}
final class LoginInteractor: LoginBusinessLogic, LoginDataStore {
// MARK: - Dependencies
var presenter: LoginPresentationLogic?
var worker: LoginWorkerLogic
// MARK: - DataStore
var authenticatedUser: User?
// MARK: - Init
init(worker: LoginWorkerLogic = LoginWorker()) {
self.worker = worker
}
// MARK: - BusinessLogic
func authenticate(request: Login.Authenticate.Request) {
presenter?.presentLoading(isLoading: true)
Task { @MainActor in
defer { presenter?.presentLoading(isLoading: false) }
do {
let user = try await worker.login(
email: request.email,
password: request.password
)
authenticatedUser = user
let response = Login.Authenticate.Response(result: .success(user))
presenter?.presentLoginResult(response: response)
} catch let error as AuthError {
let response = Login.Authenticate.Response(result: .failure(error))
presenter?.presentLoginResult(response: response)
} catch {
let response = Login.Authenticate.Response(result: .failure(.unknown))
presenter?.presentLoginResult(response: response)
}
}
}
func validateInput(request: Login.Validate.Request) {
let result = worker.validateCredentials(
email: request.email,
password: request.password
)
let response = Login.Validate.Response(
emailError: result.emailError,
passwordError: result.passwordError
)
presenter?.presentValidationError(response: response)
}
}
Presenter (SceneNamePresenter.swift)
swift
import Foundation
protocol LoginPresentationLogic {
func presentLoginResult(response: Login.Authenticate.Response)
func presentValidationError(response: Login.Validate.Response)
func presentLoading(isLoading: Bool)
}
final class LoginPresenter: LoginPresentationLogic {
// MARK: - Dependencies
weak var viewController: LoginDisplayLogic?
// MARK: - PresentationLogic
func presentLoginResult(response: Login.Authenticate.Response) {
let viewModel: Login.Authenticate.ViewModel
switch response.result {
case .success(let user):
viewModel = Login.Authenticate.ViewModel(
isSuccess: true,
errorMessage: nil,
welcomeMessage: "Welcome, \(user.name)!"
)
case .failure(let error):
viewModel = Login.Authenticate.ViewModel(
isSuccess: false,
errorMessage: error.localizedDescription,
welcomeMessage: nil
)
}
viewController?.displayLoginResult(viewModel: viewModel)
}
func presentValidationError(response: Login.Validate.Response) {
let viewModel = Login.Validate.ViewModel(
emailErrorText: response.emailError,
passwordErrorText: response.passwordError,
isValid: response.emailError == nil && response.passwordError == nil
)
viewController?.displayValidationError(viewModel: viewModel)
}
func presentLoading(isLoading: Bool) {
viewController?.displayLoading(isLoading: isLoading)
}
}
ViewController (SceneNameViewController.swift)
swift
import UIKit
protocol LoginDisplayLogic: AnyObject {
func displayLoginResult(viewModel: Login.Authenticate.ViewModel)
func displayValidationError(viewModel: Login.Validate.ViewModel)
func displayLoading(isLoading: Bool)
}
final class LoginViewController: UIViewController {
// MARK: - VIP References
var interactor: LoginBusinessLogic?
var router: (LoginRoutingLogic & LoginDataPassing)?
// MARK: - UI Elements
private let emailTextField = UITextField()
private let passwordTextField = UITextField()
private let loginButton = UIButton(type: .system)
private let activityIndicator = UIActivityIndicatorView(style: .large)
private let emailErrorLabel = UILabel()
private let passwordErrorLabel = UILabel()
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupActions()
}
// MARK: - Setup
private func setupUI() {
view.backgroundColor = .systemBackground
// Layout code here (see uikit-layouting skill)
}
private func setupActions() {
loginButton.addTarget(self, action: #selector(loginTapped), for: .touchUpInside)
emailTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
passwordTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
}
// MARK: - Actions
@objc private func loginTapped() {
let request = Login.Authenticate.Request(
email: emailTextField.text ?? "",
password: passwordTextField.text ?? ""
)
interactor?.authenticate(request: request)
}
@objc private func textFieldDidChange() {
let request = Login.Validate.Request(
email: emailTextField.text ?? "",
password: passwordTextField.text ?? ""
)
interactor?.validateInput(request: request)
}
}
// MARK: - DisplayLogic
extension LoginViewController: LoginDisplayLogic {
func displayLoginResult(viewModel: Login.Authenticate.ViewModel) {
if viewModel.isSuccess {
router?.routeToHome()
} else if let errorMessage = viewModel.errorMessage {
showAlert(title: "Error", message: errorMessage)
}
}
func displayValidationError(viewModel: Login.Validate.ViewModel) {
emailErrorLabel.text = viewModel.emailErrorText
passwordErrorLabel.text = viewModel.passwordErrorText
loginButton.isEnabled = viewModel.isValid
}
func displayLoading(isLoading: Bool) {
loginButton.isEnabled = !isLoading
if isLoading {
activityIndicator.startAnimating()
} else {
activityIndicator.stopAnimating()
}
}
private func showAlert(title: String, message: String) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
}
Router (SceneNameRouter.swift)
swift
import UIKit
protocol LoginRoutingLogic {
func routeToHome()
func routeToForgotPassword()
func routeToSignup()
}
protocol LoginDataPassing {
var dataStore: LoginDataStore? { get }
}
final class LoginRouter: LoginRoutingLogic, LoginDataPassing {
// MARK: - Dependencies
weak var viewController: LoginViewController?
var dataStore: LoginDataStore?
// MARK: - Routing
func routeToHome() {
let destinationVC = HomeConfigurator.configure()
passDataToHome(destination: destinationVC)
navigateToHome(destination: destinationVC)
}
func routeToForgotPassword() {
let destinationVC = ForgotPasswordConfigurator.configure()
viewController?.navigationController?.pushViewController(destinationVC, animated: true)
}
func routeToSignup() {
let destinationVC = SignupConfigurator.configure()
viewController?.navigationController?.pushViewController(destinationVC, animated: true)
}
// MARK: - Navigation
private func navigateToHome(destination: HomeViewController) {
viewController?.navigationController?.setViewControllers([destination], animated: true)
}
// MARK: - Data Passing
private func passDataToHome(destination: HomeViewController) {
guard let user = dataStore?.authenticatedUser else { return }
destination.router?.dataStore?.currentUser = user
}
}
Configurator (SceneNameConfigurator.swift)
swift
import UIKit
enum LoginConfigurator {
static func configure() -> LoginViewController {
let viewController = LoginViewController()
let interactor = LoginInteractor()
let presenter = LoginPresenter()
let router = LoginRouter()
let worker = LoginWorker()
// Connect VIP cycle
viewController.interactor = interactor
viewController.router = router
interactor.presenter = presenter
interactor.worker = worker
presenter.viewController = viewController
router.viewController = viewController
router.dataStore = interactor
return viewController
}
}
Worker Responsibilities
API Calls
swift
func fetchUser(id: String) async throws -> User {
try await apiClient.get("/users/\(id)")
}
Database Operations
swift
func saveUser(_ user: User) async throws {
try await coreDataManager.save(user)
}
File I/O
swift
func loadCachedData() async throws -> Data {
try await fileManager.read(from: cacheURL)
}
Heavy Computations
swift
func processImage(_ image: UIImage) async -> UIImage {
await Task.detached(priority: .userInitiated) {
image.applyFilters()
}.value
}
Testing Strategy
Test Interactor with Mock Worker
swift
final class LoginInteractorTests: XCTestCase {
var sut: LoginInteractor!
var mockPresenter: MockLoginPresenter!
var mockWorker: MockLoginWorker!
override func setUp() {
mockPresenter = MockLoginPresenter()
mockWorker = MockLoginWorker()
sut = LoginInteractor(worker: mockWorker)
sut.presenter = mockPresenter
}
func testAuthenticateSuccess() async {
mockWorker.loginResult = .success(User.mock)
sut.authenticate(request: .init(email: "test@test.com", password: "password"))
await fulfillment(of: [mockPresenter.presentLoginResultCalled])
XCTAssertTrue(mockPresenter.lastLoginResponse?.result.isSuccess == true)
}
}
Test Presenter with Mock View
swift
final class LoginPresenterTests: XCTestCase {
var sut: LoginPresenter!
var mockView: MockLoginViewController!
override func setUp() {
mockView = MockLoginViewController()
sut = LoginPresenter()
sut.viewController = mockView
}
func testPresentLoginResultSuccess() {
let response = Login.Authenticate.Response(result: .success(User.mock))
sut.presentLoginResult(response: response)
XCTAssertTrue(mockView.lastLoginViewModel?.isSuccess == true)
XCTAssertNotNil(mockView.lastLoginViewModel?.welcomeMessage)
}
}
Best Practices
- •Never skip the Worker - All async operations go through Worker
- •Keep Presenter pure - No side effects, only data transformation
- •View is dumb - Only calls
interactor?.someMethod(request:) - •Use the Configurator - Single place for dependency assembly
- •Protocol-first - Define protocols before implementations
- •Test each layer - Mock dependencies for isolation
Pre-Creation Interview
Before creating a new VIP+W scene, gather requirements using AskUserQuestion:
Scene Creation Questions
Question 1: Scene Purpose
- •Header: "Purpose"
- •Question: "What is the main purpose of this scene?"
- •Options:
- •Display data - Show information (list, detail, profile)
- •Form input - Collect user input (login, registration, settings)
- •Navigation hub - Route to other scenes (home, dashboard)
- •Action flow - Multi-step process (checkout, onboarding)
Question 2: Data Requirements
- •Header: "Data"
- •Question: "What data operations does this scene need?"
- •Options:
- •Fetch from API - Load data from backend
- •Local storage - Read/write to database
- •User input only - No external data
- •Multiple sources - API + local + input
Question 3: Navigation Pattern
- •Header: "Navigation"
- •Question: "How does this scene connect to others?"
- •Options:
- •Push detail - Navigate deeper (list → detail)
- •Modal presentation - Overlay screens
- •Replace root - New flow (login → home)
- •Tab child - Part of tab navigation
Interview Flow
- •Ask questions using AskUserQuestion
- •Summarize: "Creating a [purpose] scene with [data] operations using [navigation] pattern"
- •Generate file structure
- •Create protocols first
- •Implement each component
Skip Interview If:
- •User provided detailed scene requirements
- •User says "skip questions" or "just do it"
- •Scene purpose is obvious from context