iOS Senior Sollicitatiegesprek 2026: Vragen over Architectuur en Design Patterns
Voorbereiding op iOS senior sollicitatiegesprekken met kernvragen over MVVM, VIPER, Clean Architecture en design patterns. Volledige gids met Swift-codevoorbeelden.

iOS senior sollicitatiegesprekken leggen sterk de nadruk op architectuur en design patterns. Naast Swift-syntaxis beoordelen interviewers het vermogen om onderhoudbare, testbare en schaalbare applicaties te ontwerpen.
Deze gids behandelt de meest voorkomende vragen over MVVM, VIPER, Clean Architecture en essentiële patterns, met gedetailleerde antwoorden en codevoorbeelden klaar voor het gesprek.
In senior gesprekken telt het technische antwoord minder dan de redenering. Leg altijd uit waarom een architectuur past bij een gegeven context, niet alleen hoe ze te implementeren.
iOS-architecturen begrijpen: een overzicht
Voordat specifieke vragen behandeld worden, is inzicht in het iOS-architectuurlandschap essentieel. Elk pattern lost andere problemen op en past bij verschillende contexten.
MVC: Apple's historische pattern
MVC (Model-View-Controller) blijft Apple's standaard pattern, maar lijdt onder het probleem van "Massive View Controllers" in complexe applicaties.
// 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()
}
}Dit pattern wordt problematisch zodra ViewControllers de 500 regels overschrijden, waardoor unit tests bijna onmogelijk worden.
Vraag 1: Leg MVVM en de Swift-implementatie uit
MVVM (Model-View-ViewModel) scheidt de presentatielogica in een ViewModel, wat tests vergemakkelijkt en de omvang van de ViewController vermindert.
MVVM komt tot zijn recht met SwiftUI dankzij @Observable en native data binding. Met UIKit is een binding-mechanisme (Combine, closures) vereist.
MVVM-implementatie met Combine
// 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)
}
}De View met Combine-binding
// 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() }
}
}Het ViewModel kent noch UIKit noch SwiftUI, waardoor het volledig per unit test te toetsen is.
Vraag 2: Wanneer kiezen voor VIPER in plaats van MVVM?
VIPER (View-Interactor-Presenter-Entity-Router) past bij complexe applicaties die strikte scheiding van verantwoordelijkheden en geavanceerde navigatie vereisen.
Volledige VIPER-structuur
// 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)
}De Presenter orkestreert de logica
// 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 introduceert aanzienlijke boilerplate. Het gebruik moet gerechtvaardigd worden door de teamgrootte (meerdere developers op één module) of de complexiteit van het bedrijfsdomein.
Vraag 3: Hoe implementeer je Clean Architecture op iOS?
Clean Architecture organiseert code in concentrische cirkels, met de bedrijfsregels in het midden, onafhankelijk van frameworks.
Laagstructuur
// 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 }
}
}De datalaag met het Repository-pattern
// 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()
}
}
}Deze opzet maakt het mogelijk om elke laag onafhankelijk te testen en implementaties uit te wisselen (bijvoorbeeld migreren van CoreData naar SwiftData) zonder het domein te raken.
Klaar om je iOS gesprekken te halen?
Oefen met onze interactieve simulatoren, flashcards en technische tests.
Vraag 4: Welke design patterns gebruik je dagelijks?
Interviewers verwachten praktische beheersing van patterns, geen theoretische opsomming. Dit zijn de meest voorkomende op iOS.
Dependency Injection met Property Wrappers
// 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-pattern voor navigatie
// 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()
}
}Dit pattern haalt de navigatielogica weg uit ViewControllers, waardoor ze lichter en beter herbruikbaar worden.
Vraag 5: Hoe wordt communicatie tussen modules afgehandeld?
Communicatie tussen modules is cruciaal in grote applicaties. Er bestaan verschillende benaderingen, afhankelijk van de gewenste koppeling.
Protocol-gebaseerde communicatie
// 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)
}
}Event-gedreven communicatie met Combine
// 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
}
}
}Vermeld dat de keuze tussen sterke koppeling (protocollen) en zwakke koppeling (events) afhangt van de context: events passen bij globale notificaties, protocollen bij directe interacties.
Vraag 6: Hoe structureer je tests in een modulaire architectuur?
Testbaarheid is een belangrijk criterium voor senior posities. Een goede architectuur faciliteert tests op alle niveaus.
Unit test van het ViewModel
// 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()
}
}Integratietest van de Use Case
// 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)
}
}Vraag 7: Hoe ga je om met complexe UI-states?
State management is cruciaal in senior applicaties. Een gestructureerde aanpak voorkomt bugs en vereenvoudigt het debuggen.
State machine met Enum
// 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)
}
}
}Deze aanpak maakt inconsistente states onmogelijk (bijvoorbeeld isLoading = true met een getoonde fout).
Begin met oefenen!
Test je kennis met onze gespreksimulatoren en technische tests.
Conclusie
iOS senior gesprekken beoordelen het vermogen om een architectuur te kiezen en te onderbouwen die past bij de context. Belangrijkste punten:
iOS Senior architectuur-checklist:
✅ MVVM voor middelgrote apps met SwiftUI of UIKit + Combine ✅ VIPER voor grote teams en complexe bedrijfsdomeinen ✅ Clean Architecture voor framework-onafhankelijkheid ✅ Systematische Dependency Injection voor testbaarheid ✅ Coordinator-pattern om navigatie te ontkoppelen ✅ State machines voor complexe flows ✅ Tests op elk niveau: unit, integratie, UI
In gesprekken moet aangetoond worden:
- Begrip van trade-offs (eenvoudig MVVM versus gestructureerd VIPER)
- Praktische ervaring met voorbeelden uit echte projecten
- Vermogen om de architectuur aan te passen aan de context (teamgrootte, complexiteit)
- Beheersing van testen als architecturaal kwaliteitscriterium
Begin met oefenen!
Test je kennis met onze gespreksimulatoren en technische tests.
Tags
Delen
Gerelateerde artikelen

StoreKit 2 Sollicitatiegesprek: Abonnementenbeheer en Bonvalidatie
Beheers iOS sollicitatievragen over StoreKit 2, abonnementenbeheer, bonvalidatie en de implementatie van in-app aankopen met praktische Swift-codevoorbeelden.

Swift Testing Framework Sollicitatiegesprek 2026: Macro's #expect en #require vs XCTest
Beheers het nieuwe Swift Testing Framework voor iOS-sollicitaties: macro's #expect en #require, migratie vanuit XCTest, geavanceerde patronen en veelgemaakte fouten.

iOS Push Notifications-sollicitatiegesprek 2026: APNs, tokens en troubleshooting
Complete gids om je voor te bereiden op iOS-sollicitatiegesprekken over Push Notifications, APNs, tokenbeheer en troubleshooting. Veelgestelde vragen met uitgebreide antwoorden.