Mobile Tech Lead

Apply SOLID and architecture patterns with realistic Swift examples, dependency inversion, testing seams, and tradeoffs.

~12 min reading
Suggest content

Architecture and SOLID

🟪 Syntax · 🟦 Types/APIs · 🟩 Correct pattern · 🟥 Bug · 🟨 Gotcha · 🧠 Explanation

Quick Recap

  • SOLID is a set of design heuristics for cohesion, extension, substitutability, focused interfaces, and dependency direction—not a requirement to add layers everywhere.
  • High-level policy should depend on stable capabilities; construct concrete services once at a composition root and inject them.
  • Choose boundaries that enable a real change, test seam, ownership decision, or failure-isolation benefit.
  • Measure whether the architecture improves change safety, testability, and delivery; simplify abstractions that only add indirection.

SOLID in one view

PrincipleInterview definitioniOS exampleTypical violation
SRPA type should have one reason to change.A view model coordinates presentation state while a repository handles data access.A view controller performs networking, parsing, persistence, navigation, and rendering.
OCPBehavior should be extendable without repeatedly modifying stable code.Add a new PaymentMethod implementation behind a protocol.Add another if paymentType == ... throughout the application.
LSPAny implementation of an abstraction must preserve its promised behavior.Every repository implementation obeys the same success, failure, and cancellation contract.A mock, subclass, or implementation throws unexpectedly or ignores required behavior.
ISPPrefer small, client-specific protocols.Split ReadableStore and WritableStore.A read-only client must implement unused save and delete methods.
DIPHigh-level policy depends on abstractions rather than concrete details.A view model receives a repository protocol instead of constructing an API client.ViewModel directly creates URLSession, database, and analytics objects.

SOLID example: one checkout feature

This small design shows the principles working together instead of treating them as five unrelated definitions.

SWIFT
import Foundation
​
struct CheckoutRequest: Sendable {
    let totalInCents: Int
}
​
struct Receipt: Sendable {
    let id: UUID
}
​
protocol PaymentAuthorizing: Sendable {
    func authorize(_ request: CheckoutRequest) async throws -> Receipt
}
​
protocol ReceiptStoring: Sendable {
    func save(_ receipt: Receipt) async throws
}
​
@MainActor
final class CheckoutModel {
    enum State {
        case idle
        case submitting
        case completed(Receipt)
        case failed
    }
​
    private let authorizer: any PaymentAuthorizing
    private let receiptStore: any ReceiptStoring
    private(set) var state: State = .idle
​
    init(
        authorizer: any PaymentAuthorizing,
        receiptStore: any ReceiptStoring
    ) {
        self.authorizer = authorizer
        self.receiptStore = receiptStore
    }
​
    func submit(_ request: CheckoutRequest) async {
        state = .submitting
​
        do {
            let receipt = try await authorizer.authorize(request)
            try await receiptStore.save(receipt)
            state = .completed(receipt)
        } catch is CancellationError {
            state = .idle
        } catch {
            state = .failed
        }
    }
}
PrincipleWhere it appears
SRPCheckoutModel owns presentation state; payment authorization and receipt storage live elsewhere.
OCPAdd a card, Apple Pay, or preview authorizer without rewriting CheckoutModel.
LSPEvery authorizer must preserve the same success, failure, and cancellation contract.
ISPAuthorization and receipt storage are separate, focused protocols.
DIPThe high-level checkout flow depends on capabilities, not concrete SDK or database types.

🟨 SOLID does not mean “more layers.” The goal is to keep likely changes local and make contracts honest. If a feature has one simple implementation and no useful boundary, concrete code can be the clearer design.

OCP versus LSP

  • OCP asks: can new behavior be added without modifying stable consumers?
  • LSP asks: does the new implementation still honor the abstraction's behavioral contract?
  • Protocols can support both, but merely introducing a protocol does not guarantee either one.
  • The rectangle/square example is primarily an LSP example: a square cannot safely replace a mutable rectangle if callers expect width and height to change independently.

Good interview phrasing:

OCP is about the structure of extension; LSP is about the correctness of the extension. A protocol makes extension possible, but every conforming type must still preserve the protocol's semantics.

Dependency inversion in Swift

Interview question: What is dependency inversion, and how do you use dependency injection in an iOS feature?

Short answer: Dependency inversion is the design principle: high-level policy should not depend directly on low-level infrastructure details. Dependency injection is one way to supply a dependency from the outside. In Swift, I define the smallest useful capability at the consumer boundary, inject it through an initializer, and construct the live graph at the app's composition root.

These terms are related but not interchangeable. DIP is the direction of source-level dependency; DI is the object-construction technique. Neither requires a protocol in every case—a closure or concrete value can be the better seam when it keeps the design simpler.

SWIFT
protocol UserRepository {
    func user(id: User.ID) async throws -> User
}
​
final class UserDetailsViewModel {
    private let repository: any UserRepository
​
    init(repository: any UserRepository) {
        self.repository = repository
    }
}

The high-level view model knows what it needs, not how HTTP, caching, or persistence works. Its initializer makes the dependency visible and lets a test supply a deterministic implementation. Composition happens at the application boundary, coordinator, or dependency container; see the composition-root example for the complete app graph.

Benefits:

  • Test doubles can be injected.
  • Infrastructure can change independently.
  • Business and presentation code remain focused.
  • Object lifetimes are visible and controllable.

Use this when there is a real change boundary, multiple implementations, an isolation boundary, a test seam, or a meaningful ownership decision. Avoid it when an abstraction merely renames a stable implementation, when it adds indirection without a second responsibility, or when a small pure value can be passed directly.

Common mistakes are to confuse DIP with “everything must be a protocol,” construct the concrete dependency inside the consumer anyway, hide ownership in a global service locator, or make a protocol so broad that every fake must implement unrelated methods. Keep the contract client-focused and document failure, cancellation, and concurrency behavior as part of its semantics.

Senior follow-up: Which layer owns the abstraction, and how do you test the consumer?

Lead follow-up: Where is the composition root, who owns dependency lifetime, and how would the boundary evolve across feature modules?

Data and domain separation

Data model / DTO

  • Mirrors an external representation such as JSON or a database record.
  • May contain optional fields, API naming, transport metadata, and version-specific details.
  • Often conforms to Decodable or persistence framework requirements.

Domain model

  • Expresses concepts used by the application.
  • Should be stable even when the API changes.
  • Should contain valid, meaningful data where possible.
  • Should not need to know about HTTP status codes or JSON keys.

Mapping location

Mapping normally belongs in the data layer, close to the DTO and repository implementation:

TEXT
API -> DTO -> mapper/repository -> Domain Model -> Use Case/ViewModel -> View State

Avoid decoding directly inside a domain entity or formatting user-facing strings in the repository. Domain models should not depend on transport details, and presenters/view models should not repeat API mapping.

Network-driven versus state-driven design

A network-driven screen lets API responses dictate the UI directly. It is quick but tends to leak transport details into presentation code.

A state-driven screen converts events and results into explicit states:

SWIFT
enum PaymentViewState: Equatable {
    case idle
    case loading
    case ready(PaymentSummary)
    case submitting
    case completed(Receipt)
    case failed(PaymentErrorViewData)
}

The state should describe the UI. It should not itself perform network calls. A view model or reducer receives an event, invokes a use case/service, and then transitions state.

MVVM-C

Responsibilities:

  • View: renders state and forwards user actions.
  • ViewModel: presentation logic and screen state.
  • Model/Repository/Use Case: business and data operations.
  • Coordinator: navigation flow, destination construction, and dependency wiring.

Passing data:

  • Pass the smallest meaningful value: an ID, domain model, or destination input.
  • The coordinator constructs the next view model.
  • Avoid global mutable state for navigation data.
  • Avoid having views construct repositories or service clients.
SWIFT
func showDetails(for city: City) {
    let viewModel = WeatherDetailsViewModel(city: city, repository: repository)
    let view = WeatherDetailsView(viewModel: viewModel)
    navigationController.pushViewController(UIHostingController(rootView: view), animated: true)
}

VIPER

Responsibilities

  • View: displays view data and forwards actions.
  • Interactor: coordinates business rules and data operations.
  • Presenter: converts results into presentation-ready state and decides what the view should display.
  • Entity: domain/data structures used by the feature.
  • Router: navigation and module construction.

Entities are usually good struct candidates because they are data values. Use a class when identity, shared mutable state, inheritance, or Objective-C interoperability is required.

Parsing should usually happen in a service, worker, mapper, or repository. The interactor coordinates the operation. The presenter formats dates, currency, labels, and view-specific sections. The presenter should not make network calls or contain persistence logic.

VIPER pagination

  1. View reports that the user is near the end.
  2. Presenter/interactor rejects the request if one is already running or no more pages exist.
  3. Interactor requests the next cursor/page through a repository.
  4. Results are appended without duplicating existing items.
  5. Presenter updates loading, content, empty, or error state.

Important state:

  • isLoading
  • nextCursor or currentPage
  • hasMore
  • existing items
  • retry information

Reset all pagination state for a new query or filter.

Factory and Observer patterns

Factory

Centralizes construction when object creation is complex or depends on configuration.

Use it for feature modules, API clients, parsers, or environment-specific implementations. A factory should create objects; it should not become a service locator hiding all dependencies.

Observer

Provides one-to-many event delivery. iOS examples include Combine publishers, NotificationCenter, delegates, and observation APIs.

Choose deliberately:

  • Delegate: one owner/listener with a focused contract.
  • Closure: a small local callback.
  • Combine/Observation: state or event streams with composition.
  • NotificationCenter: broad decoupled notifications, used sparingly because contracts are less visible.

Protocol-oriented design

Swift protocols define capabilities and boundaries. Protocol extensions can provide shared default behavior, but avoid defaults that silently hide missing business behavior.

Good protocol design:

  • Small and cohesive
  • Named by capability where appropriate
  • Clear about threading, errors, and cancellation
  • Does not expose unnecessary implementation details
  • Easy to fake in tests

Architecture testing strategy

  • ViewModel/Presenter: state transitions, formatting, error mapping, and action handling
  • Interactor/Use case: business decisions and repository interactions
  • Repository: mapping, caching policy, and fallback behavior
  • Router/Coordinator: requested destination and passed input
  • Mapper: malformed, partial, and valid DTOs
  • UI tests: a few critical end-to-end journeys, not every branch

The test boundary should match the responsibility boundary.

Key Takeaways

  • Keep each type focused and make contracts explicit enough that implementations remain substitutable.
  • Dependency inversion is about dependency direction; dependency injection is one practical way to supply the abstraction.
  • Use a composition root for construction and keep business logic independent of framework details where it pays off.
  • Introduce protocols and modules to isolate real variability or ownership—not by default.
  • Validate architecture with behavior-focused tests and delivery outcomes.