iOSシニア面接 2026:アーキテクチャとデザインパターンの質問集

MVVM、VIPER、Clean Architecture、デザインパターンに関する重要な質問でiOSシニア面接に備えます。Swiftコード例付きの完全ガイドです。

技術シニア面接向けのMVVMおよびVIPERパターンを示すiOSアーキテクチャ図

iOSシニア面接ではアーキテクチャとデザインパターンが大きな比重を占めます。Swift構文の知識を超えて、面接官は保守性・テスト容易性・スケーラビリティを備えたアプリケーションを設計する力を評価します。

本ガイドでは、MVVM、VIPER、Clean Architecture、そして必須となるパターンに関する頻出質問を、詳細な解説と面接で使えるコード例とともに取り上げます。

面接官が本当に評価していること

シニア面接では、技術的な答えそのものよりも、その理由付けが重視されます。なぜそのアーキテクチャが特定の文脈に適しているのかを必ず説明することが重要であり、どう実装するかの説明だけでは不十分です。

iOSアーキテクチャの全体像を理解する

個別の質問に踏み込む前に、iOSアーキテクチャの全体像を理解しておくことが欠かせません。それぞれのパターンは異なる課題を解決し、適する文脈も異なります。

MVC:Appleの伝統的なパターン

MVC(Model-View-Controller)は依然としてAppleのデフォルトパターンですが、複雑なアプリでは「Massive View Controllers」問題に苦しみます。

UserViewController.swiftswift
// Typical MVC example showing its limitations
class UserViewController: UIViewController {
    // The ViewController accumulates too many responsibilities
    private var users: [User] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        // View logic
        setupUI()
        // Business logic
        fetchUsers()
        // Navigation logic
        setupNavigationBar()
    }

    private func fetchUsers() {
        // Networking in the VC: anti-pattern
        URLSession.shared.dataTask(with: URL(string: "api/users")!) { data, _, _ in
            // JSON parsing here too...
        }.resume()
    }
}

このパターンはViewControllerが500行を超えると問題化し、ユニットテストはほぼ不可能になります。

質問1:MVVMとSwiftでの実装を説明してください

MVVM(Model-View-ViewModel)は表示ロジックをViewModelに分離し、テストを容易にしつつViewControllerのサイズを縮小します。

面接の重要ポイント

MVVMは@Observableとネイティブのデータバインディングのおかげで、SwiftUIと組み合わせると真価を発揮します。UIKitではバインディング機構(Combine、クロージャ)が必要です。

CombineによるMVVMの実装

UserViewModel.swiftswift
// ViewModel separated from any UIKit dependency
import Combine

@MainActor
final class UserViewModel: ObservableObject {
    // Published states for binding
    @Published private(set) var users: [User] = []
    @Published private(set) var isLoading = false
    @Published private(set) var errorMessage: String?

    // Injected dependency for testability
    private let userRepository: UserRepositoryProtocol
    private var cancellables = Set<AnyCancellable>()

    init(userRepository: UserRepositoryProtocol = UserRepository()) {
        self.userRepository = userRepository
    }

    func loadUsers() {
        isLoading = true
        errorMessage = nil

        userRepository.fetchUsers()
            .receive(on: DispatchQueue.main)
            .sink { [weak self] completion in
                self?.isLoading = false
                if case .failure(let error) = completion {
                    self?.errorMessage = error.localizedDescription
                }
            } receiveValue: { [weak self] users in
                self?.users = users
            }
            .store(in: &cancellables)
    }
}

Combineバインディングを用いるView

UserListView.swiftswift
// SwiftUI View consuming the ViewModel
import SwiftUI

struct UserListView: View {
    // StateObject for lifecycle management
    @StateObject private var viewModel = UserViewModel()

    var body: some View {
        Group {
            if viewModel.isLoading {
                ProgressView("Loading...")
            } else if let error = viewModel.errorMessage {
                ErrorView(message: error, retry: viewModel.loadUsers)
            } else {
                List(viewModel.users) { user in
                    UserRowView(user: user)
                }
            }
        }
        .onAppear { viewModel.loadUsers() }
    }
}

ViewModelはUIKitもSwiftUIも知らないため、ユニットテストで完全に検証可能です。

質問2:MVVMではなくVIPERを選ぶべき場面は?

VIPER(View-Interactor-Presenter-Entity-Router)は、責務の厳密な分離と高度なナビゲーションを必要とする複雑なアプリに適しています。

VIPERの完全な構造

UserListProtocols.swiftswift
// Contract definitions between VIPER components
protocol UserListViewProtocol: AnyObject {
    var presenter: UserListPresenterProtocol? { get set }
    func showUsers(_ users: [UserViewModel])
    func showError(_ message: String)
    func showLoading()
}

protocol UserListPresenterProtocol: AnyObject {
    var view: UserListViewProtocol? { get set }
    var interactor: UserListInteractorInputProtocol? { get set }
    var router: UserListRouterProtocol? { get set }
    func viewDidLoad()
    func didSelectUser(_ user: UserViewModel)
}

protocol UserListInteractorInputProtocol: AnyObject {
    var presenter: UserListInteractorOutputProtocol? { get set }
    func fetchUsers()
}

protocol UserListInteractorOutputProtocol: AnyObject {
    func didFetchUsers(_ users: [User])
    func didFailWithError(_ error: Error)
}

protocol UserListRouterProtocol: AnyObject {
    func navigateToUserDetail(with userId: String)
}

Presenterがロジックを統括する

UserListPresenter.swiftswift
// The Presenter bridges View and Interactor
final class UserListPresenter: UserListPresenterProtocol {
    weak var view: UserListViewProtocol?
    var interactor: UserListInteractorInputProtocol?
    var router: UserListRouterProtocol?

    func viewDidLoad() {
        view?.showLoading()
        interactor?.fetchUsers()
    }

    func didSelectUser(_ user: UserViewModel) {
        router?.navigateToUserDetail(with: user.id)
    }
}

// Extension for Interactor callbacks
extension UserListPresenter: UserListInteractorOutputProtocol {
    func didFetchUsers(_ users: [User]) {
        // Model -> ViewModel transformation
        let viewModels = users.map { UserViewModel(from: $0) }
        view?.showUsers(viewModels)
    }

    func didFailWithError(_ error: Error) {
        view?.showError(error.localizedDescription)
    }
}
面接時の注意点

VIPERは大量のボイラープレートをもたらします。導入の妥当性は、チーム規模(同一モジュールに複数の開発者がいる)や業務ドメインの複雑さで説明する必要があります。

質問3:iOSでClean Architectureをどう実装するか?

Clean Architectureはコードを同心円状に整理し、業務ルールを中心に据えてフレームワークから切り離します。

レイヤー構造

Domain/Entities/User.swiftswift
// Pure business entity, no framework dependencies
struct User: Identifiable, Equatable {
    let id: String
    let email: String
    let fullName: String
    let subscriptionLevel: SubscriptionLevel

    enum SubscriptionLevel: String {
        case free, premium, enterprise
    }
}

// Domain/UseCases/GetUsersUseCase.swift
// Use Case encapsulating a business rule
protocol GetUsersUseCaseProtocol {
    func execute() async throws -> [User]
}

final class GetUsersUseCase: GetUsersUseCaseProtocol {
    // Dependency on abstraction, not implementation
    private let repository: UserRepositoryProtocol

    init(repository: UserRepositoryProtocol) {
        self.repository = repository
    }

    func execute() async throws -> [User] {
        let users = try await repository.fetchAll()
        // Business rule: sort by subscription level
        return users.sorted { $0.subscriptionLevel.rawValue > $1.subscriptionLevel.rawValue }
    }
}

Repositoryパターンを用いるデータレイヤー

Data/Repositories/UserRepository.swiftswift
// Concrete repository implementation
final class UserRepository: UserRepositoryProtocol {
    private let remoteDataSource: UserRemoteDataSourceProtocol
    private let localDataSource: UserLocalDataSourceProtocol

    init(
        remoteDataSource: UserRemoteDataSourceProtocol = UserRemoteDataSource(),
        localDataSource: UserLocalDataSourceProtocol = UserLocalDataSource()
    ) {
        self.remoteDataSource = remoteDataSource
        self.localDataSource = localDataSource
    }

    func fetchAll() async throws -> [User] {
        do {
            // Cache-first strategy with fallback
            let remoteUsers = try await remoteDataSource.fetchUsers()
            await localDataSource.save(remoteUsers)
            return remoteUsers
        } catch {
            // Fallback to local cache
            return try await localDataSource.fetchUsers()
        }
    }
}

この構成により、各レイヤーを個別にテスト可能となり、実装(例:CoreDataからSwiftDataへの移行)をドメインに触れずに差し替えられます。

iOSの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

質問4:日常的にどのデザインパターンを使うか?

面接官は理論の暗唱ではなく、パターンの実用的な習熟度を期待します。iOSで頻出するものを以下に挙げます。

Property Wrapperを使うDependency Injection

DependencyInjection/Container.swiftswift
// Simple and effective injection container
final class DIContainer {
    static let shared = DIContainer()

    private var factories: [String: () -> Any] = [:]

    func register<T>(_ type: T.Type, factory: @escaping () -> T) {
        let key = String(describing: type)
        factories[key] = factory
    }

    func resolve<T>(_ type: T.Type) -> T {
        let key = String(describing: type)
        guard let factory = factories[key], let instance = factory() as? T else {
            fatalError("No registration for \(key)")
        }
        return instance
    }
}

// Property Wrapper for elegant injection
@propertyWrapper
struct Injected<T> {
    private var value: T

    init() {
        self.value = DIContainer.shared.resolve(T.self)
    }

    var wrappedValue: T {
        get { value }
        mutating set { value = newValue }
    }
}

// Usage in a ViewModel
final class PaymentViewModel {
    @Injected private var paymentService: PaymentServiceProtocol
    @Injected private var analyticsService: AnalyticsServiceProtocol

    func processPayment(_ amount: Decimal) async throws {
        analyticsService.track(.paymentInitiated(amount: amount))
        try await paymentService.charge(amount)
    }
}

ナビゲーションのためのCoordinatorパターン

Coordinators/AppCoordinator.swiftswift
// Coordinator managing navigation flow
protocol Coordinator: AnyObject {
    var childCoordinators: [Coordinator] { get set }
    var navigationController: UINavigationController { get }
    func start()
}

final class AppCoordinator: Coordinator {
    var childCoordinators: [Coordinator] = []
    let navigationController: UINavigationController

    private let window: UIWindow

    init(window: UIWindow) {
        self.window = window
        self.navigationController = UINavigationController()
    }

    func start() {
        window.rootViewController = navigationController
        window.makeKeyAndVisible()

        // Flow decision based on state
        if AuthManager.shared.isAuthenticated {
            showMainFlow()
        } else {
            showAuthFlow()
        }
    }

    private func showAuthFlow() {
        let authCoordinator = AuthCoordinator(navigationController: navigationController)
        authCoordinator.delegate = self
        childCoordinators.append(authCoordinator)
        authCoordinator.start()
    }

    private func showMainFlow() {
        let mainCoordinator = MainCoordinator(navigationController: navigationController)
        childCoordinators.append(mainCoordinator)
        mainCoordinator.start()
    }
}

このパターンによりナビゲーションロジックがViewControllerの外に切り出され、ViewControllerは軽量で再利用しやすくなります。

質問5:モジュール間通信をどう扱うか?

モジュール間通信は大規模アプリで非常に重要です。求める結合度に応じて複数のアプローチがあります。

プロトコルベースの通信

Modules/Shared/ModuleProtocols.swiftswift
// Public contracts exposed by each module
protocol PaymentModuleProtocol {
    func startPaymentFlow(for productId: String, completion: @escaping (Result<Receipt, PaymentError>) -> Void)
}

protocol UserModuleProtocol {
    func getCurrentUser() -> User?
    func updateProfile(_ profile: ProfileUpdate) async throws
}

// Modules/Payment/PaymentModule.swift
// Internal module implementation
final class PaymentModule: PaymentModuleProtocol {
    static let shared: PaymentModuleProtocol = PaymentModule()

    private let paymentService: PaymentService

    private init() {
        self.paymentService = PaymentService()
    }

    func startPaymentFlow(for productId: String, completion: @escaping (Result<Receipt, PaymentError>) -> Void) {
        // Module-internal logic
        paymentService.process(productId: productId, completion: completion)
    }
}

Combineによるイベント駆動の通信

EventBus/AppEventBus.swiftswift
// Decoupled event bus for async communication
enum AppEvent {
    case userDidLogin(User)
    case userDidLogout
    case purchaseCompleted(Receipt)
    case subscriptionChanged(SubscriptionLevel)
}

final class AppEventBus {
    static let shared = AppEventBus()

    // Private Subject, public Publisher
    private let eventSubject = PassthroughSubject<AppEvent, Never>()
    var events: AnyPublisher<AppEvent, Never> {
        eventSubject.eraseToAnyPublisher()
    }

    func send(_ event: AppEvent) {
        eventSubject.send(event)
    }
}

// Listening in any module
final class AnalyticsModule {
    private var cancellables = Set<AnyCancellable>()

    init() {
        AppEventBus.shared.events
            .sink { [weak self] event in
                self?.handleEvent(event)
            }
            .store(in: &cancellables)
    }

    private func handleEvent(_ event: AppEvent) {
        switch event {
        case .purchaseCompleted(let receipt):
            trackPurchase(receipt)
        case .userDidLogin(let user):
            identifyUser(user)
        default:
            break
        }
    }
}
面接時のヒント

強結合(プロトコル)と疎結合(イベント)の選択は文脈に依存することを伝えてください。イベントはグローバルな通知に、プロトコルは直接的な相互作用に向いています。

質問6:モジュール構造のテストをどう構成するか?

テスト容易性はシニアの重要な評価基準です。良いアーキテクチャはあらゆるレベルのテストを容易にします。

ViewModelのユニットテスト

Tests/UserViewModelTests.swiftswift
// Unit tests with injected mocks
import XCTest
@testable import MyApp

final class UserViewModelTests: XCTestCase {
    private var sut: UserViewModel!
    private var mockRepository: MockUserRepository!

    override func setUp() {
        super.setUp()
        mockRepository = MockUserRepository()
        sut = UserViewModel(userRepository: mockRepository)
    }

    override func tearDown() {
        sut = nil
        mockRepository = nil
        super.tearDown()
    }

    func test_loadUsers_success_updatesUsersArray() async {
        // Given
        let expectedUsers = [User.mock(), User.mock()]
        mockRepository.stubbedUsers = expectedUsers

        // When
        await sut.loadUsers()

        // Then
        XCTAssertEqual(sut.users.count, 2)
        XCTAssertFalse(sut.isLoading)
        XCTAssertNil(sut.errorMessage)
    }

    func test_loadUsers_failure_setsErrorMessage() async {
        // Given
        mockRepository.stubbedError = NetworkError.noConnection

        // When
        await sut.loadUsers()

        // Then
        XCTAssertTrue(sut.users.isEmpty)
        XCTAssertNotNil(sut.errorMessage)
    }
}

// Mocks/MockUserRepository.swift
final class MockUserRepository: UserRepositoryProtocol {
    var stubbedUsers: [User] = []
    var stubbedError: Error?
    var fetchUsersCalled = false

    func fetchUsers() -> AnyPublisher<[User], Error> {
        fetchUsersCalled = true

        if let error = stubbedError {
            return Fail(error: error).eraseToAnyPublisher()
        }
        return Just(stubbedUsers)
            .setFailureType(to: Error.self)
            .eraseToAnyPublisher()
    }
}

Use Caseの統合テスト

Tests/GetUsersUseCaseTests.swiftswift
// Integration test verifying business logic
final class GetUsersUseCaseTests: XCTestCase {
    func test_execute_sortsUsersBySubscriptionLevel() async throws {
        // Given
        let freeUser = User(id: "1", email: "free@test.com", fullName: "Free", subscriptionLevel: .free)
        let premiumUser = User(id: "2", email: "premium@test.com", fullName: "Premium", subscriptionLevel: .premium)

        let mockRepo = MockUserRepository()
        mockRepo.stubbedUsers = [freeUser, premiumUser]

        let sut = GetUsersUseCase(repository: mockRepo)

        // When
        let result = try await sut.execute()

        // Then - Premium should be first
        XCTAssertEqual(result.first?.subscriptionLevel, .premium)
        XCTAssertEqual(result.last?.subscriptionLevel, .free)
    }
}

質問7:複雑なUI状態をどう扱うか?

状態管理はシニアアプリで極めて重要です。構造化されたアプローチによりバグを防ぎ、デバッグも単純化されます。

Enumによるステートマシン

ViewModels/CheckoutViewModel.swiftswift
// State machine for payment flow
enum CheckoutState: Equatable {
    case idle
    case loadingCart
    case cartLoaded(CartSummary)
    case processingPayment
    case paymentSucceeded(Receipt)
    case paymentFailed(PaymentError)

    var isLoading: Bool {
        switch self {
        case .loadingCart, .processingPayment: return true
        default: return false
        }
    }
}

@MainActor
final class CheckoutViewModel: ObservableObject {
    @Published private(set) var state: CheckoutState = .idle

    private let cartService: CartServiceProtocol
    private let paymentService: PaymentServiceProtocol

    init(cartService: CartServiceProtocol, paymentService: PaymentServiceProtocol) {
        self.cartService = cartService
        self.paymentService = paymentService
    }

    func loadCart() async {
        state = .loadingCart

        do {
            let summary = try await cartService.getSummary()
            state = .cartLoaded(summary)
        } catch {
            state = .paymentFailed(.cartLoadFailed)
        }
    }

    func confirmPayment() async {
        guard case .cartLoaded(let summary) = state else { return }

        state = .processingPayment

        do {
            let receipt = try await paymentService.charge(summary.total)
            state = .paymentSucceeded(receipt)
        } catch let error as PaymentError {
            state = .paymentFailed(error)
        } catch {
            state = .paymentFailed(.unknown)
        }
    }
}

このアプローチによって矛盾した状態(例:エラー表示中にisLoading = true)を排除できます。

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

まとめ

iOSシニア面接では、文脈に応じたアーキテクチャを選定し、その理由を説明できる力が評価されます。要点を以下にまとめます。

iOSシニア・アーキテクチャのチェックリスト:

✅ SwiftUIまたはUIKit + Combineを用いる中規模アプリにはMVVM ✅ 大規模チームと複雑な業務ドメインにはVIPER ✅ フレームワーク非依存にはClean Architecture ✅ テスト容易性のための体系的なDependency Injection ✅ ナビゲーションを切り離すためのCoordinatorパターン ✅ 複雑なフローのためのステートマシン ✅ ユニット・統合・UIといった全レベルでのテスト

面接で示すべき点:

  • トレードオフへの理解(シンプルなMVVM vs 構造化されたVIPER)
  • 実プロジェクトの例を伴う実践経験
  • 文脈(チーム規模、複雑さ)に応じてアーキテクチャを調整する能力
  • アーキテクチャ品質の基準としてのテストへの精通

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

タグ

#ios
#swift
#architecture
#mvvm
#viper
#interview
#design-patterns

共有

関連記事