# Entrevista iOS Senior 2026: Preguntas de Arquitectura y Patrones de Diseño > Prepárate para entrevistas iOS senior con preguntas clave sobre MVVM, VIPER, Clean Architecture y patrones de diseño. Guía completa con ejemplos de código Swift. - Published: 2026-03-02 - Updated: 2026-04-29 - Author: SharpSkill - Tags: ios, swift, architecture, mvvm, viper, interview, design-patterns - Reading time: 12 min --- Las entrevistas iOS senior ponen un fuerte énfasis en la arquitectura y los patrones de diseño. Más allá de la sintaxis Swift, los entrevistadores evalúan la capacidad de diseñar aplicaciones mantenibles, testeables y escalables. Esta guía cubre las preguntas más frecuentes sobre MVVM, VIPER, Clean Architecture y patrones esenciales, con respuestas detalladas y ejemplos de código listos para entrevistas. > **Lo que realmente evalúan los entrevistadores** > > En las entrevistas senior, la respuesta técnica importa menos que el razonamiento. Siempre hay que explicar **por qué** una arquitectura encaja en un contexto dado, no solo **cómo** implementarla. ## Comprender las arquitecturas iOS: una visión general Antes de profundizar en preguntas específicas, es esencial entender el panorama arquitectónico de iOS. Cada patrón resuelve diferentes problemas y se adapta a distintos contextos. ### MVC: el patrón histórico de Apple MVC (Model-View-Controller) sigue siendo el patrón por defecto de Apple, pero sufre el problema de los "Massive View Controllers" en aplicaciones complejas. ```swift // UserViewController.swift // 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() } } ``` Este patrón se vuelve problemático cuando los ViewControllers superan las 500 líneas, haciendo que las pruebas unitarias sean prácticamente imposibles. ## Pregunta 1: Explica MVVM y su implementación en Swift MVVM (Model-View-ViewModel) separa la lógica de presentación en un ViewModel, facilitando las pruebas y reduciendo el tamaño del ViewController. > **Punto clave en entrevistas** > > MVVM brilla con SwiftUI gracias a `@Observable` y al data binding nativo. Con UIKit, se requiere un mecanismo de binding (Combine, closures). ### Implementación de MVVM con Combine ```swift // UserViewModel.swift // 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() 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 con binding Combine ```swift // UserListView.swift // 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() } } } ``` El ViewModel no conoce ni UIKit ni SwiftUI, lo que lo hace completamente testeable de forma unitaria. ## Pregunta 2: ¿Cuándo elegir VIPER en lugar de MVVM? VIPER (View-Interactor-Presenter-Entity-Router) se adapta a aplicaciones complejas que requieren una separación estricta de responsabilidades y una navegación avanzada. ### Estructura completa de VIPER ```swift // UserListProtocols.swift // 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) } ``` ### El Presenter orquesta la lógica ```swift // UserListPresenter.swift // 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) } } ``` > **Advertencia en entrevistas** > > VIPER introduce un boilerplate considerable. Su uso debe justificarse por el tamaño del equipo (varios desarrolladores en un mismo módulo) o por la complejidad del dominio de negocio. ## Pregunta 3: ¿Cómo se implementa Clean Architecture en iOS? Clean Architecture organiza el código en círculos concéntricos, con las reglas de negocio en el centro, independientes de los frameworks. ### Estructura por capas ```swift // Domain/Entities/User.swift // 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 } } } ``` ### La capa de datos con el patrón Repository ```swift // Data/Repositories/UserRepository.swift // 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() } } } ``` Esta organización permite testear cada capa de forma independiente y cambiar implementaciones (por ejemplo, migrar de CoreData a SwiftData) sin tocar el dominio. ## Pregunta 4: ¿Qué patrones de diseño usas a diario? Los entrevistadores esperan dominio práctico de los patrones, no recitación teórica. Estos son los más frecuentes en iOS. ### Inyección de dependencias con Property Wrappers ```swift // DependencyInjection/Container.swift // Simple and effective injection container final class DIContainer { static let shared = DIContainer() private var factories: [String: () -> Any] = [:] func register(_ type: T.Type, factory: @escaping () -> T) { let key = String(describing: type) factories[key] = factory } func resolve(_ 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 { 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) } } ``` ### Patrón Coordinator para la navegación ```swift // Coordinators/AppCoordinator.swift // 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() } } ``` Este patrón saca la lógica de navegación de los ViewControllers, haciéndolos más ligeros y reutilizables. ## Pregunta 5: ¿Cómo se maneja la comunicación entre módulos? La comunicación entre módulos es crucial en aplicaciones grandes. Existen varios enfoques según el acoplamiento deseado. ### Comunicación basada en protocolos ```swift // Modules/Shared/ModuleProtocols.swift // Public contracts exposed by each module protocol PaymentModuleProtocol { func startPaymentFlow(for productId: String, completion: @escaping (Result) -> 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) -> Void) { // Module-internal logic paymentService.process(productId: productId, completion: completion) } } ``` ### Comunicación orientada a eventos con Combine ```swift // EventBus/AppEventBus.swift // 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() var events: AnyPublisher { eventSubject.eraseToAnyPublisher() } func send(_ event: AppEvent) { eventSubject.send(event) } } // Listening in any module final class AnalyticsModule { private var cancellables = Set() 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 } } } ``` > **Consejo para entrevistas** > > Menciona que la elección entre acoplamiento fuerte (protocolos) y acoplamiento débil (eventos) depende del contexto: los eventos se adaptan a notificaciones globales, los protocolos a interacciones directas. ## Pregunta 6: ¿Cómo estructuras los tests en una arquitectura modular? La testabilidad es un criterio clave para puestos senior. Una buena arquitectura facilita las pruebas en todos los niveles. ### Test unitario del ViewModel ```swift // Tests/UserViewModelTests.swift // 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() } } ``` ### Test de integración del Use Case ```swift // Tests/GetUsersUseCaseTests.swift // 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) } } ``` ## Pregunta 7: ¿Cómo manejar estados de UI complejos? La gestión de estados es crítica en aplicaciones senior. Un enfoque estructurado evita bugs y simplifica la depuración. ### Máquina de estados con Enum ```swift // ViewModels/CheckoutViewModel.swift // 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) } } } ``` Este enfoque hace imposibles los estados inconsistentes (por ejemplo, `isLoading = true` con un error mostrado). ## Conclusión Las entrevistas iOS senior evalúan la capacidad de elegir y justificar una arquitectura adecuada al contexto. Conclusiones clave: **Checklist de arquitectura iOS Senior:** ✅ MVVM para apps de tamaño medio con SwiftUI o UIKit + Combine ✅ VIPER para equipos grandes y dominios de negocio complejos ✅ Clean Architecture para independencia del framework ✅ Inyección de dependencias sistemática para la testabilidad ✅ Patrón Coordinator para desacoplar la navegación ✅ Máquinas de estado para flujos complejos ✅ Tests en cada nivel: unitarios, integración, UI **En entrevistas, se debe demostrar:** - Comprensión de los trade-offs (MVVM simple vs VIPER estructurado) - Experiencia práctica con ejemplos de proyectos reales - Capacidad de adaptar la arquitectura al contexto (tamaño del equipo, complejidad) - Dominio del testing como criterio de calidad arquitectónica --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/es/blog/ios/ios-senior-interview-architecture-design-patterns