> Как устроены слои в чистой архитектуре? (iOS, Swift)

Уровень: senior · Роль: mobile · Категория: Технические вопросы

Компании: Travelata

Стек: iOS, Swift

> Пример ответа

Короткий ответ

Чистая архитектура делит приложение на слои с направлением зависимостей строго внутрь: domain (entities и use cases), data (repositories и их реализации) и presentation (UI и view models). Внутренние слои не знают о внешних - только через протоколы (интерфейсы). Для iOS это означает, что domain не импортирует UIKit, а presentation зависит от абстракций use cases, а не от конкретных сервисов. Главная цель - изолировать бизнес-логику от фреймворков и упростить тестирование.

Подробное объяснение

Слои в чистой архитектуре (по Роберту Мартину) - это концентрические круги: entities, use cases, interface adapters, frameworks and drivers. На iOS обычно выделяют три основных слоя:

  • Domain - самый внутренний. Содержит entities (модели бизнес-логики) и use cases (интеракторы). Не зависит ни от чего, кроме стандартной библиотеки Swift. Не импортирует UIKit, Foundation (по возможности), сторонние библиотеки.
  • Data - реализация репозиториев, маппинг DTO в domain-модели, работа с сетью, БД, UserDefaults. Зависит от domain (реализует протоколы репозиториев), но не от presentation.
  • Presentation - View, ViewModel (или Presenter), координация навигации. Зависит от domain через протоколы use cases. Может использовать фреймворки (UIKit, SwiftUI), но не содержит бизнес-правил.

Ключевой принцип - dependency rule: исходный код зависит только внутрь круга. Внешние слои могут использовать внутренние, но не наоборот. Это достигается через инверсию зависимостей: domain объявляет протоколы (например, UserRepository), а data предоставляет конкретную реализацию (UserRepositoryImpl). Presentation получает эту реализацию через DI-контейнер или инициализатор.

На iOS это часто выглядит так: ViewModel держит ссылку на протокол FetchUsersUseCase, а не на конкретный класс. В рантайме туда подставляется реализация, которая использует URLSession или CoreData - но ViewModel об этом не знает.

На практике

В реальном iOS-проекте слои обычно организуют в виде отдельных папок или Swift packages (например, Domain, Data, Presentation). Навигация между слоями происходит через координаторы или роутеры, которые живут в presentation. DI-контейнер (Swinject, Resolver или ручная сборка) связывает протоколы с реализациями на старте приложения.

Типичный поток: View вызывает метод ViewModel → ViewModel вызывает use case (через протокол) → use case обращается к репозиторию (через протокол) → репозиторий (реализация в data) делает сетевой запрос → возвращает DTO → маппится в domain-модель → use case возвращает результат → ViewModel обновляет state → View рендерит.

На практике часто добавляют слой Common или Core для утилит, но он не должен содержать бизнес-логику. Также важно не путать слои с модулями: слои - это логическое разделение, а модули (packages) - физическое. Можно иметь один модуль с чёткими папками, но лучше - отдельные таргеты для domain и data, чтобы enforce dependency rule на уровне компилятора.

Пример кода

SWIFT
// Domain
protocol UserRepository {
func fetchUser(id: String) async throws -> User
}
struct User: Equatable {
let id: String
let name: String
}
protocol FetchUserUseCase {
func execute(id: String) async throws -> User
}
final class FetchUserUseCaseImpl: FetchUserUseCase {
private let repository: UserRepository
init(repository: UserRepository) {
self.repository = repository
}
func execute(id: String) async throws -> User {
try await repository.fetchUser(id: id)
}
}
// Data
struct UserDTO: Decodable {
let id: String
let name: String
}
final class UserRepositoryImpl: UserRepository {
private let apiClient: APIClient
init(apiClient: APIClient) {
self.apiClient = apiClient
}
func fetchUser(id: String) async throws -> User {
let dto: UserDTO = try await apiClient.get("/users/\(id)")
return User(id: dto.id, name: dto.name)
}
}
// Presentation
final class UserViewModel: ObservableObject {
@Published var user: User?
private let useCase: FetchUserUseCase
init(useCase: FetchUserUseCase) {
self.useCase = useCase
}
func load(id: String) async {
user = try? await useCase.execute(id: id)
}
}

Как отвечать на собеседовании

Начни с определения слоёв и dependency rule. Приведи конкретный пример из iOS: domain не знает про UIKit, data реализует протоколы, presentation зависит от абстракций. Обязательно упомяни инверсию зависимостей и DI. Если спросят про trade-off - скажи, что чистая архитектура добавляет boilerplate и сложность для маленьких проектов, но окупается в больших, где важна тестируемость и поддерживаемость. Покажи, как тестировать use case с мок-репозиторием. Хорошо, если упомянешь, что на практике не всегда нужны все слои - для простого экрана можно обойтись без отдельного use case.

Что проверяет интервьюер

Интервьюер оценивает: понимание направления зависимостей (внутрь), умение объяснить, зачем нужны протоколы между слоями, знание, как это применяется в iOS (UIKit/SwiftUI, DI, тестирование). Важно показать, что ты понимаешь разницу между domain-моделью и DTO, и почему нельзя тянуть CoreData-объекты в presentation. Также проверяется способность рассуждать о границах: что класть в domain, а что в data. Если кандидат путает слои с VIPER или MVC - это красный флаг.

Типичные ошибки

  • Путаница между слоями и архитектурными паттернами (VIPER, MVVM) - это не одно и то же.
  • Утверждение, что domain может импортировать UIKit - нарушение dependency rule.
  • Хранение бизнес-логики в ViewModel или View - это нарушение изоляции.
  • Отсутствие протоколов: presentation напрямую зависит от конкретной реализации репозитория.
  • Передача DTO или CoreData-объектов в presentation вместо domain-моделей.
  • Слишком фанатичное следование: создание use case для каждого экрана даже там, где это не нужно - интервьюер ценит прагматизм.

> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?

Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью