本文へ移動
cccskills
無料GitHub で公開

VIP+W Architecture

VIP+W (View-Interactor-Presenter-Worker) Clean Architecture for iOS with unidirectional data flow and proper separation of concerns

インストール方法を見る

含まれるファイル(1)

  • SKILL.md19.4 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

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)

┌───────────────────────────────────────────────────────────────┐
│                                                               │
│   View ──request──► Interactor ──delegate──► Worker          │
│     ▲                    │                      │             │
│     │                    │◄─────result──────────┘             │
│     │                    │                                    │
│     │                    ▼                                    │
│     └──viewModel──── Presenter                                │
│                                                               │
│   Router: Navigation between scenes (accessed via Interactor) │
│                                                               │
└───────────────────────────────────────────────────────────────┘

File Structure Template

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)

protocol LoginDisplayLogic: AnyObject {
    func displayLoginResult(viewModel: Login.Authenticate.ViewModel)
    func displayValidationError(viewModel: Login.Validate.ViewModel)
    func displayLoading(isLoading: Bool)
}

BusinessLogic (Interactor conforms)

protocol LoginBusinessLogic {
    func authenticate(request: Login.Authenticate.Request)
    func validateInput(request: Login.Validate.Request)
}

PresentationLogic (Presenter conforms)

protocol LoginPresentationLogic {
    func presentLoginResult(response: Login.Authenticate.Response)
    func presentValidationError(response: Login.Validate.Response)
    func presentLoading(isLoading: Bool)
}

WorkerLogic (Worker conforms)

protocol LoginWorkerLogic {
    func login(email: String, password: String) async throws -> User
    func validateCredentials(email: String, password: String) -> ValidationResult
}

RoutingLogic (Router conforms)

protocol LoginRoutingLogic {
    func routeToHome()
    func routeToForgotPassword()
    func routeToSignup()
}

protocol LoginDataPassing {
    var dataStore: LoginDataStore? { get }
}

DataStore (Shared state between scenes)

protocol LoginDataStore {
    var authenticatedUser: User? { get set }
}

Complete Code Templates

Models (SceneNameModels.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)

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)

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)

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)

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)

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)

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

func fetchUser(id: String) async throws -> User {
    try await apiClient.get("/users/\(id)")
}

Database Operations

func saveUser(_ user: User) async throws {
    try await coreDataManager.save(user)
}

File I/O

func loadCachedData() async throws -> Data {
    try await fileManager.read(from: cacheURL)
}

Heavy Computations

func processImage(_ image: UIImage) async -> UIImage {
    await Task.detached(priority: .userInitiated) {
        image.applyFilters()
    }.value
}

Testing Strategy

Test Interactor with Mock Worker

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

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

  1. Never skip the Worker - All async operations go through Worker
  2. Keep Presenter pure - No side effects, only data transformation
  3. View is dumb - Only calls interactor?.someMethod(request:)
  4. Use the Configurator - Single place for dependency assembly
  5. Protocol-first - Define protocols before implementations
  6. 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

  1. Ask questions using AskUserQuestion
  2. Summarize: "Creating a [purpose] scene with [data] operations using [navigation] pattern"
  3. Generate file structure
  4. Create protocols first
  5. 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

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

When the user wants to A/B test App Store product page elements to improve conversion rate. Also use when the user mentions "A/B test", "product page optimization", "test my screenshots", "test my icon", "conversion rate optimization", "CPP", or "custom product pages". For screenshot design, see screenshot-optimization. For metadata optimization, see metadata-optimization.

日本語の概要は準備中です。原文の説明を表示しています。

JordanCoin/ios-skills-collection62026年9月10日 更新

Discover and configure Bluetooth and Wi-Fi accessories using AccessorySetupKit. Use when presenting a privacy-preserving accessory picker, defining discovery descriptors for BLE or Wi-Fi devices, handling accessory session events, migrating from CoreBluetooth permission-based scanning, or setting up accessories without requiring broad Bluetooth permissions.

日本語の概要は準備中です。原文の説明を表示しています。

JordanCoin/ios-skills-collection62026年9月10日 更新

Implement, review, or improve Live Activities and Dynamic Island experiences in iOS apps using ActivityKit. Use when building real-time updating widgets for the Lock Screen and Dynamic Island — delivery tracking, sports scores, ride-sharing status, workout timers, media playback, or any time-sensitive information that updates in real time. Also use when working with ActivityKit, ActivityAttributes, Activity lifecycle (request/update/end), Dynamic Island layouts (compact/minimal/expanded), push-to-update Live Activities, or Lock Screen live widgets.

日本語の概要は準備中です。原文の説明を表示しています。

JordanCoin/ios-skills-collection62026年9月10日 更新

Measure ad effectiveness with privacy-preserving attribution using AdAttributionKit. Use when registering ad impressions, handling attribution postbacks, updating conversion values, implementing re-engagement attribution, configuring publisher or advertiser apps, or replacing SKAdNetwork with AdAttributionKit for ad measurement.

日本語の概要は準備中です。原文の説明を表示しています。

JordanCoin/ios-skills-collection62026年9月10日 更新

alarmkit

無料

Implement AlarmKit alarms and countdown timers for iOS and iPadOS with Lock Screen, Dynamic Island, and Apple Watch system UI. Covers AlarmManager scheduling, AlarmAttributes and AlarmPresentation, AlarmButton stop and snooze actions, authorization, state observation, and Live Activity integration. Use when building wake-up alarms, countdown timers, or alarm-style alerts that need Apple's system alarm experience.

日本語の概要は準備中です。原文の説明を表示しています。

JordanCoin/ios-skills-collection62026年9月10日 更新

When the user wants to optimize their Google Play Store listing — title, short description, full description, keywords, ratings, or Play Store-specific features. Use when the user mentions "Google Play", "Android", "Play Store", "Play Console", "short description", "full description indexed", "Google Play ASO", or wants Google Play-specific keyword, creative, or ratings strategy. For iOS App Store optimization, see aso-audit and metadata-optimization.

日本語の概要は準備中です。原文の説明を表示しています。

JordanCoin/ios-skills-collection62026年9月10日 更新

JordanCoin のスキルをすべて見る

このスキルの問題を報告する