Questions entretien technique iOS senior France 2026 : architecture et design patterns

Préparez votre entretien iOS senior avec les questions clés sur MVVM, VIPER, Clean Architecture et les design patterns. Guide complet avec exemples Swift.

Diagramme d'architecture iOS avec MVVM et VIPER pour entretien technique senior

Les entretiens techniques iOS senior en France mettent fortement l'accent sur l'architecture et les design patterns. Au-delà de la syntaxe Swift, les recruteurs évaluent la capacité à concevoir des applications maintenables, testables et scalables.

Ce guide couvre les questions les plus fréquentes sur MVVM, VIPER, Clean Architecture et les patterns essentiels, avec des réponses détaillées et des exemples de code prêts pour l'entretien.

Ce que les recruteurs évaluent vraiment

En entretien senior, la réponse technique compte moins que le raisonnement. Expliquez toujours pourquoi une architecture convient à un contexte donné, pas seulement comment l'implémenter.

Comprendre les architectures iOS : vue d'ensemble

Avant d'aborder les questions spécifiques, il est essentiel de comprendre le paysage architectural iOS. Chaque pattern résout des problèmes différents et convient à des contextes variés.

MVC : le pattern historique d'Apple

MVC (Model-View-Controller) reste le pattern par défaut d'Apple, mais souffre du problème des "Massive View Controllers" dans les applications complexes.

UserViewController.swiftswift
// Exemple typique de MVC avec ses limitations
class UserViewController: UIViewController {
    // Le ViewController accumule trop de responsabilités
    private var users: [User] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        // Logique de vue
        setupUI()
        // Logique métier
        fetchUsers()
        // Logique de navigation
        setupNavigationBar()
    }

    private func fetchUsers() {
        // Le networking dans le VC : anti-pattern
        URLSession.shared.dataTask(with: URL(string: "api/users")!) { data, _, _ in
            // Parsing JSON ici aussi...
        }.resume()
    }
}

Ce pattern devient problématique quand le ViewController dépasse 500 lignes, rendant les tests unitaires quasi impossibles.

Question 1 : expliquez MVVM et son implémentation en Swift

MVVM (Model-View-ViewModel) sépare la logique de présentation dans un ViewModel, facilitant les tests et réduisant la taille des ViewControllers.

Point clé entretien

MVVM brille avec SwiftUI grâce à @Observable et le data binding natif. Avec UIKit, un mécanisme de binding (Combine, closures) est nécessaire.

Implémentation MVVM avec Combine

UserViewModel.swiftswift
// ViewModel séparé de toute dépendance UIKit
import Combine

@MainActor
final class UserViewModel: ObservableObject {
    // États publiés pour le binding
    @Published private(set) var users: [User] = []
    @Published private(set) var isLoading = false
    @Published private(set) var errorMessage: String?

    // Dépendance injectée pour la testabilité
    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)
    }
}

La View avec binding Combine

UserListView.swiftswift
// SwiftUI View consommant le ViewModel
import SwiftUI

struct UserListView: View {
    // StateObject pour le cycle de vie
    @StateObject private var viewModel = UserViewModel()

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

Le ViewModel ne connaît ni UIKit ni SwiftUI, ce qui le rend entièrement testable unitairement.

Question 2 : quand choisir VIPER plutôt que MVVM ?

VIPER (View-Interactor-Presenter-Entity-Router) convient aux applications complexes nécessitant une séparation stricte des responsabilités et une navigation avancée.

Structure VIPER complète

UserListProtocols.swiftswift
// Définition des contrats entre composants VIPER
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)
}

Le Presenter orchestre la logique

UserListPresenter.swiftswift
// Le Presenter fait le pont entre View et 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 pour les callbacks de l'Interactor
extension UserListPresenter: UserListInteractorOutputProtocol {
    func didFetchUsers(_ users: [User]) {
        // Transformation Model -> ViewModel
        let viewModels = users.map { UserViewModel(from: $0) }
        view?.showUsers(viewModels)
    }

    func didFailWithError(_ error: Error) {
        view?.showError(error.localizedDescription)
    }
}
Attention en entretien

VIPER introduit beaucoup de boilerplate. Justifiez son usage par la taille de l'équipe (plusieurs devs sur un module) ou la complexité du domaine métier.

Question 3 : comment implémentez-vous Clean Architecture sur iOS ?

Clean Architecture organise le code en cercles concentriques, avec les règles métier au centre, indépendantes des frameworks.

Structure des couches

Domain/Entities/User.swiftswift
// Entité métier pure, aucune dépendance framework
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 encapsulant une règle métier
protocol GetUsersUseCaseProtocol {
    func execute() async throws -> [User]
}

final class GetUsersUseCase: GetUsersUseCaseProtocol {
    // Dépendance vers l'abstraction, pas l'implémentation
    private let repository: UserRepositoryProtocol

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

    func execute() async throws -> [User] {
        let users = try await repository.fetchAll()
        // Règle métier : trier par niveau d'abonnement
        return users.sorted { $0.subscriptionLevel.rawValue > $1.subscriptionLevel.rawValue }
    }
}

La couche Data avec Repository Pattern

Data/Repositories/UserRepository.swiftswift
// Implémentation concrète du repository
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 {
            // Stratégie cache-first avec fallback
            let remoteUsers = try await remoteDataSource.fetchUsers()
            await localDataSource.save(remoteUsers)
            return remoteUsers
        } catch {
            // Fallback sur le cache local
            return try await localDataSource.fetchUsers()
        }
    }
}

Cette organisation permet de tester chaque couche indépendamment et de changer l'implémentation (ex: migrer de CoreData vers SwiftData) sans toucher au domaine.

Prêt à réussir tes entretiens iOS ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Question 4 : quels design patterns utilisez-vous quotidiennement ?

Les recruteurs attendent une maîtrise pratique des patterns, pas une récitation théorique. Voici les plus fréquents en iOS.

Dependency Injection avec Property Wrappers

DependencyInjection/Container.swiftswift
// Container d'injection simple et efficace
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 pour injection élégante
@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 }
    }
}

// Utilisation dans un 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 Pattern pour la navigation

Coordinators/AppCoordinator.swiftswift
// Coordinator gérant le flux de navigation
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()

        // Décision de flux basée sur l'état
        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()
    }
}

Ce pattern déplace la logique de navigation hors des ViewControllers, les rendant plus légers et réutilisables.

Question 5 : comment gérez-vous la communication entre modules ?

La communication inter-modules est cruciale dans les grandes applications. Plusieurs approches existent selon le couplage souhaité.

Protocol-based communication

Modules/Shared/ModuleProtocols.swiftswift
// Contrats publics exposés par chaque 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
// Implémentation interne du module
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) {
        // Logique interne au module
        paymentService.process(productId: productId, completion: completion)
    }
}

Event-driven avec Combine

EventBus/AppEventBus.swiftswift
// Bus d'événements découplé pour communication asynchrone
enum AppEvent {
    case userDidLogin(User)
    case userDidLogout
    case purchaseCompleted(Receipt)
    case subscriptionChanged(SubscriptionLevel)
}

final class AppEventBus {
    static let shared = AppEventBus()

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

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

// Écoute dans n'importe quel 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
        }
    }
}
Conseil entretien

Mentionnez que le choix entre couplage fort (protocols) et faible (events) dépend du contexte : les événements conviennent aux notifications globales, les protocols aux interactions directes.

Question 6 : comment structurez-vous les tests dans une architecture modulaire ?

La testabilité est un critère majeur pour les postes seniors. Une bonne architecture facilite les tests à tous les niveaux.

Tests unitaires du ViewModel

Tests/UserViewModelTests.swiftswift
// Tests unitaires avec mocks injectés
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()
    }
}

Tests d'intégration du Use Case

Tests/GetUsersUseCaseTests.swiftswift
// Test d'intégration vérifiant la logique métier
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 doit être en premier
        XCTAssertEqual(result.first?.subscriptionLevel, .premium)
        XCTAssertEqual(result.last?.subscriptionLevel, .free)
    }
}

Question 7 : comment gérez-vous les états complexes dans l'UI ?

La gestion d'états est critique pour les applications seniors. Une approche structurée évite les bugs et simplifie le debugging.

State Machine avec enum

ViewModels/CheckoutViewModel.swiftswift
// Machine à états pour le flux de paiement
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)
        }
    }
}

Cette approche rend impossible les états incohérents (ex: isLoading = true avec une erreur affichée).

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Conclusion

Les entretiens iOS senior en France évaluent la capacité à choisir et justifier une architecture adaptée au contexte. Les points clés à retenir :

Checklist architecture iOS senior :

✅ MVVM pour les applications moyennes avec SwiftUI ou UIKit + Combine ✅ VIPER pour les grandes équipes et domaines métier complexes ✅ Clean Architecture pour l'indépendance aux frameworks ✅ Dependency Injection systématique pour la testabilité ✅ Coordinator Pattern pour découpler la navigation ✅ State Machines pour les flux complexes ✅ Tests à chaque niveau : unitaires, intégration, UI

En entretien, démontrez :

  • La compréhension des trade-offs (MVVM simple vs VIPER structuré)
  • L'expérience pratique avec des exemples de projets réels
  • La capacité à adapter l'architecture au contexte (taille équipe, complexité)
  • La maîtrise des tests comme critère de qualité architecturale

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tags

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

Partager

Articles similaires