iOS Development

Jak przeprowadzić refaktor starej aplikacji iOS bez blokowania rozwoju produktu

14 min readRafał Dubiel
#iOS#Swift#Architecture#SwiftUI#Refactoring#Clean Architecture

Jeśli kiedykolwiek słyszałeś (lub sam wypowiedziałeś) zdanie "przepiszemy to od nowa, będzie szybciej" jednocześnie czując presję biznesu na dowożenie nowych funkcjonalności, to prawdopodobnie pamiętasz też, jak ta historia się skończyła. Spoiler: zazwyczaj nie za dobrze.

Powiedzmy, że mamy przed sobą monolityczną aplikację: MVP+Coordinator, UIKit i XcodeGen. Cel? Przepisanie apki na Clean Architecture, SwiftUI, MVVM oraz Tuist. W teorii brzmi to jak przepis na wielomiesięczny feature freeze i frustrację zespołu. Ale czy musi tak być? Okazuje się, że niekoniecznie!


Dlaczego nie przepisujemy aplikacji od zera?

Martin Fowler, obserwując w 2001 roku drzewa figowe w Queenslandzie, zauważył fascynujący wzorzec: figowiec-dusiciel kiełkuje w koronie drzewa-gospodarza i stopniowo obrasta je korzeniami, aż w końcu całkowicie je zastępuje. To doskonała metafora dla migracji starego kodu i fundament naszej strategii.

Dlaczego big bang rewrite to zły pomysł? Badania i doświadczenia branżowe są jednoznaczne:

  • Paraliż rozwoju - przez miesiące (lata?) zespół przepisuje istniejące funkcje zamiast dostarczać nowe wartości dla użytkowników,
  • Gonienie króliczka - biznes nie stoi w miejscu, nowe wymagania pojawiają się szybciej niż postępuje przepisywanie,
  • Utrata wiedzy domenowej - stary kod, mimo że brzydki, zawiera lata rozwiązań edge-case'ów, które często nie są udokumentowane,
  • Ryzyko katastrofy - jeśli coś pójdzie nie tak (a pójdzie), rollback jest niemożliwy,
  • Niedoszacowanie x3-4 - programiści notoryczne niedoszacowują złożoności przepisywania, typowo o 300-400%!

Chris Richardson, autor "Microservices Patterns", ujmuje to ostro: "Any time you engage in a big bang modernization effort, you are taking a huge risk, and the odds are not in your favor." Link do artykułu


1. Diagnoza: Zrozum co masz, zanim zaczniesz zmieniać

Mapowanie stanu obecnego

Zanim napiszesz pierwszą linię nowego kodu, musisz precyzyjnie zrozumieć co masz. Jak pisze Ignas Pileckas w swoim case study refaktoryzacji monolitu UIKit+Storyboards: "Before tackling a project like this, you need to evaluate where are the biggest pain points." Link do artykułu

Kluczowe pytania diagnostyczne:

  1. Jak wygląda graf zależności? Które moduły/klasy są najbardziej powiązane?
  2. Gdzie jest logika biznesowa? W Presenterach? ViewControllerach? Rozrzucona wszędzie?
  3. Jak działa nawigacja? Czy Koordynatory są prawdziwie niezależne, czy tworzą spaghetti?
  4. Jaki jest coverage testów? Które obszary są testowalne, a które nie?
  5. Gdzie są największe bolączki zespołu? Build time? Merge conflicts? Trudność w onboardingu?

Typowe problemy monolitu MVP+Coordinator

ProblemManifestacja
Massive PresentersPresentery z 1000+ liniami kodu, mieszające logikę biznesową, formatowanie danych i nawigację
XcodeGen limitationsYAML nie skaluje się - brak reużywalności, templaty są workaroundem, nie rozwiązaniem
UIKit boilerplateSetki linii kodu na prostą tabelkę, manualne zarządzanie cyklem życia, memory leaks przez retain cycles
Coordinator couplingCoordinatory wiedzą za dużo o sobie nawzajem, child coordinators tworzą głębokie hierarchie
Build timeClean build 10+ minut, incremental build 2-3 minuty nawet przy małych zmianach
Callback hellZagnieżdżone completion handlery zamiast async/await, trudne do testowania i debugowania

2. Strategia: Strangler Fig w praktyce mobilnej

Czym jest Strangler Fig dla aplikacji mobilnej?

Thoughtworks, w artykule "Using the Strangler Fig with Mobile Apps", opisuje zastosowanie tego wzorca w kontekście mobilnym jako "incremental replacement of a legacy mobile application". Kluczowa różnica względem backendu: w mobile nie mamy proxy/gateway, ale mamy coś równie potężnego - warstwa kompozycji w AppDelegate/SceneDelegate.

Fundamentalne zasady:

  • Containment, not conversion - nie konwertujesz aplikacji, wymieniasz jej brzegi, jeden feature naraz,
  • UIKit pozostaje hostem (na początku) - SwiftUI zaczyna jako "gość" wewnątrz UIHostingController, ale można też wcześniej przejść na hybrydowy NavigationStack,
  • ViewModele są agnostyczne - ten sam ViewModel może obsługiwać zarówno UIKit jak i SwiftUI,
  • Nigdy nie migruj nawigacji jako pierwszej - to najczęstszy błąd, nawigacja to ostatni element do migracji!

Kolejność kroków - roadmapa

Bazując na aktualnych wzorcach branżowych i doświadczeniach zespołów:

  1. Faza -1 (Tydzień 0-2): Infrastruktura bezpieczeństwa - metryki, feature flags, testy E2E pozytywnej scieżki (XCUITest/Maestro), zanim ktokolwiek dotknie architektury,
  2. Faza 0 (Miesiące 0-2): Domain layer - wydzielenie Use Cases, Repository protocols, migracja najgorszych callback→async/await,
  3. Faza 1 (Miesiące 2-4): Quick wins - 1-3 małe, ale biznesowo ważne i wizualnie proste ekrany w SwiftUI + @Observable. Szybki feedback, morale zespołu rosną,
  4. Faza 2 (Miesiące 4-6): Core modules - wydzielenie warstwy infrastrukturalnej (networking, storage, DI container),
  5. Faza 3 (Miesiące 6-10): Tuist + modular monolith - migracja z XcodeGen, włączenie cachingu (ogromny boost DX),
  6. Faza 4 (Miesiące 10-14): Więcej funkcjonalności w SwiftUI - stopniowe wygaszanie starych flow,
  7. Faza 5 (Miesiące 14+): Nawigacja - najpierw hybryda NavigationStack + stare Coordinatory, potem full SwiftUI.

Dlaczego quick wins przed core? W poprzednich latach zalecano odwrotną kolejność, ale doświadczenie pokazuje, że szybkie, widoczne rezultaty są kluczowe dla morali zespołu i zaufania biznesu. Jeden zmigrowany ekran to konkretny dowód, że strategia działa.


3. Faza -1: Infrastruktura bezpieczeństwa

Zanim dotkniesz kodu

Ten krok jest często pomijany, ale jest krytyczny. Bez niego nie masz jak mierzyć sukcesu ani bezpiecznie wycofać zmian.

Metryki baseline:

  • Build time (clean i incremental),
  • Test coverage,
  • Crash rate,
  • App startup time,
  • Rozmiar binarki.

Feature flags:

Każda migrowana funkcjonalność powinna być za feature flag. Jeśli coś pójdzie nie tak w produkcji, wyłączasz flagę i wracasz do starej implementacji.

// FeatureFlags.swift
enum FeatureFlag: String {
    case newProfileScreen
    case swiftUIPaymentFlow
    case asyncNetworking

    var isEnabled: Bool {
        RemoteConfig.shared.isEnabled(rawValue)
    }
}

// Użycie
if FeatureFlag.newProfileScreen.isEnabled {
    coordinator.showNewProfileScreen() // SwiftUI
} else {
    coordinator.showLegacyProfileScreen() // UIKit
}

Golden path E2E tests:

Minimum 5-10 testów E2E pokrywających krytyczne ścieżki użytkownika. Te testy muszą przechodzić przed i po każdej migracji.


4. Faza 0: Domain Layer + async/await

Wydzielenie Domain Layer

Nawet w ramach monolitu, stwórz folder/group Domain zawierający czyste modele domenowe i Use Cases. Te klasy nie powinny importować UIKit ani żadnych frameworków UI.

Migracja callback → async/await

W 2026 async/await to standard. Stare completion handlery i Combine dla prostych operacji są postrzegane jako przestarzałe. Migracja to idealny moment na przesiadkę.

// PRZED - callback hell
class ProfilePresenter {
    func loadProfile() {
        networkService.fetchProfile { [weak self] result in
            switch result {
            case .success(let profile):
                self?.storageService.cacheProfile(profile) { error in
                    if let error = error {
                        self?.view?.showError(error)
                    } else {
                        self?.analyticsService.track(.profileLoaded) { _ in
                            self?.view?.display(profile)
                        }
                    }
                }
            case .failure(let error):
                self?.view?.showError(error)
            }
        }
    }
}

// PO - async/await
final class FetchProfileUseCase {
    private let profileRepository: ProfileRepository
    private let analytics: AnalyticsService

    init(profileRepository: ProfileRepository, analytics: AnalyticsService) {
        self.profileRepository = profileRepository
        self.analytics = analytics
    }

    func execute() async throws -> Profile {
        let profile = try await profileRepository.fetchProfile()
        try await profileRepository.cacheProfile(profile)
        await analytics.track(.profileLoaded)
        return profile
    }
}

Repository protocols

// Domain/Repositories/ProfileRepository.swift
protocol ProfileRepository: Sendable {
    func fetchProfile() async throws -> Profile
    func cacheProfile(_ profile: Profile) async throws
    func getCachedProfile() async -> Profile?
}

// Data/Repositories/ProfileRepositoryImpl.swift
final class ProfileRepositoryImpl: ProfileRepository {
    private let apiClient: APIClient
    private let storage: LocalStorage

    func fetchProfile() async throws -> Profile {
        try await apiClient.request(ProfileEndpoint.get)
    }

    func cacheProfile(_ profile: Profile) async throws {
        try await storage.save(profile, forKey: .profile)
    }

    func getCachedProfile() async -> Profile? {
        await storage.load(forKey: .profile)
    }
}

5. Architektura docelowa: Clean Architecture + MVVM + @Observable

Dlaczego MVVM a nie pozostać przy MVP?

Decyzja o przejściu z MVP na MVVM przy migracji do SwiftUI nie jest przypadkowa:

AspektMVPMVVM + @Observable
Binding z ViewManualny przez protokół ViewAutomatyczny przez @Observable
SwiftUI fitWymaga adaptacjiNatywny - makro @Observable
Relacja1:1 (View:Presenter)1:N (ViewModel może obsługiwać wiele Views)
TestowanieWymaga mockowania View protocolViewModel testowany w izolacji
BoilerplateDużo (protokoły, delegaty)Minimum

@Observable vs ObservableObject

W 2026 praktycznie nikt już nie używa ObservableObject + @Published w nowym kodzie. Makro @Observable z iOS 17+ jest wydajniejsze (granularne śledzenie zmian) i prostsze:

// STARE (ObservableObject) - unikaj w nowym kodzie
class ProfileViewModel: ObservableObject {
    @Published var profile: Profile?
    @Published var isLoading = false
    @Published var error: Error?
}

// NOWE (@Observable) - standard 2026
@Observable
final class ProfileViewModel {
    var profile: Profile?
    var isLoading = false
    var error: Error?

    private let fetchProfileUseCase: FetchProfileUseCase

    init(fetchProfileUseCase: FetchProfileUseCase) {
        self.fetchProfileUseCase = fetchProfileUseCase
    }

    func loadProfile() async {
        isLoading = true
        defer { isLoading = false }

        do {
            profile = try await fetchProfileUseCase.execute()
        } catch {
            self.error = error
        }
    }
}

6. Migracja XcodeGen → Tuist

Tuist wygrał tę wojnę

W 2025-2026 Tuist stał się de facto standardem. XcodeGen jest już postrzegany jako staroć (choć wciąż działa). Warto jednak zaznaczyć jedną rzecz: XcodeGen nie jest narzędziem „złym” ani bezużytecznym. W wielu dojrzałych aplikacjach nadal pełni rolę akceptowalnego etapu przejściowego, szczególnie gdy migracja architektury, SwiftUI i async/await już sama w sobie jest dużym przedsięwzięciem.

Główne powody:

  • Swift zamiast YAML - autocomplete, type-checking, kompilacja w Xcode,
  • Caching - w połączeniu z Xcode 26 compilation cache daje ogromny boost,
  • ProjectDescriptionHelpers - natywna reużywalność przez Swift modules,
  • Focused projects - generuj projekt tylko z modułami, nad którymi pracujesz.

Strategia migracji

// Tuist/ProjectDescriptionHelpers/Module.swift
import ProjectDescription

public enum Module: String, CaseIterable {
    case core
    case networking
    case profile
    case payment

    public var target: Target {
        .target(
            name: name,
            destinations: .iOS,
            product: .framework,
            bundleId: "com.app.\(rawValue)",
            sources: ["Sources/\(name)/**"],
            dependencies: dependencies,
            settings: .settings(
                base: [
                    "SWIFT_STRICT_CONCURRENCY": "complete"
                ]
            )
        )
    }

    public var testTarget: Target {
        .target(
            name: "\(name)Tests",
            destinations: .iOS,
            product: .unitTests,
            bundleId: "com.app.\(rawValue).tests",
            sources: ["Tests/\(name)Tests/**"],
            dependencies: [.target(name: name)]
        )
    }

    private var name: String { rawValue.capitalized }

    private var dependencies: [TargetDependency] {
        switch self {
        case .core: return []
        case .networking: return [.target(name: Module.core.name)]
        case .profile: return [.target(name: Module.networking.name)]
        case .payment: return [.target(name: Module.networking.name)]
        }
    }
}

7. Swift Testing zamiast XCTest

Migracja to idealny moment na przesiadkę z XCTest na Swift Testing. W 2026 to już de facto standard w nowych modułach.

// STARE (XCTest)
import XCTest
@testable import Profile

final class ProfileViewModelTests: XCTestCase {
    func testLoadProfileSuccess() async throws {
        let mockRepository = MockProfileRepository()
        mockRepository.fetchProfileResult = .success(.mock)

        let sut = ProfileViewModel(
            fetchProfileUseCase: FetchProfileUseCase(
                profileRepository: mockRepository,
                analytics: MockAnalytics()
            )
        )

        await sut.loadProfile()

        XCTAssertEqual(sut.profile, .mock)
        XCTAssertFalse(sut.isLoading)
        XCTAssertNil(sut.error)
    }
}

// NOWE (Swift Testing) - standard 2026
import Testing
@testable import Profile

@Suite("ProfileViewModel")
struct ProfileViewModelTests {

    @Test("loads profile successfully")
    func loadProfileSuccess() async throws {
        let mockRepository = MockProfileRepository()
        mockRepository.fetchProfileResult = .success(.mock)

        let sut = ProfileViewModel(
            fetchProfileUseCase: FetchProfileUseCase(
                profileRepository: mockRepository,
                analytics: MockAnalytics()
            )
        )

        await sut.loadProfile()

        #expect(sut.profile == .mock)
        #expect(!sut.isLoading)
        #expect(sut.error == nil)
    }

    @Test("shows error on failure", arguments: [
        NetworkError.noConnection,
        NetworkError.timeout,
        NetworkError.serverError(500)
    ])
    func loadProfileFailure(error: NetworkError) async {
        let mockRepository = MockProfileRepository()
        mockRepository.fetchProfileResult = .failure(error)

        let sut = ProfileViewModel(/* ... */)

        await sut.loadProfile()

        #expect(sut.profile == nil)
        #expect(sut.error != nil)
    }
}

Przewagi Swift Testing:

  • Czytelniejsza składnia (#expect zamiast XCTAssert*),
  • Parametryzowane testy (arguments:),
  • Lepsze grupowanie (@Suite),
  • Szybsze wykonanie (równoległość),
  • Integracja z Swift Concurrency.

8. Migracja nawigacji: Koordynator na SwiftUI NavigationStack

Dlaczego na końcu?

Nawigacja to klej łączący wszystkie ekrany. Migracja nawigacji przed ekranami oznacza utrzymywanie dwóch systemów nawigacji równolegle przez cały proces. Naprawdę nie chcesz tego robić.

Podejście hybrydowe

Obecnie coraz wcześniej można przejść na hybrydowy NavigationStack + UIKit child controllers:

// Hybryda: SwiftUI NavigationStack z UIKit child
struct MainTabView: View {
    @State private var profilePath = NavigationPath()

    var body: some View {
        TabView {
            NavigationStack(path: $profilePath) {
                ProfileView()
                    .navigationDestination(for: ProfileRoute.self) { route in
                        switch route {
                        case .edit:
                            EditProfileView()
                        case .settings:
                            // UIKit screen wrapped in UIViewControllerRepresentable
                            LegacySettingsViewController.asSwiftUIView()
                        }
                    }
            }
            .tabItem { Label("Profile", systemImage: "person") }
        }
    }
}

// Helper do wrappowania UIKit
extension UIViewController {
    static func asSwiftUIView() -> some View {
        ViewControllerRepresentable(makeViewController: { Self() })
    }
}

struct ViewControllerRepresentable<VC: UIViewController>: UIViewControllerRepresentable {
    let makeViewController: () -> VC

    func makeUIViewController(context: Context) -> VC {
        makeViewController()
    }

    func updateUIViewController(_ uiViewController: VC, context: Context) {}
}

Docelowy Router Pattern

Obecnie coraz częściej widzi się też Router object zamiast klasycznego koordynatora:

@Observable
final class AppRouter {
    var path = NavigationPath()
    var sheet: Sheet?
    var fullScreenCover: FullScreenCover?

    enum Route: Hashable {
        case profile
        case editProfile
        case settings
        case paymentFlow(PaymentContext)
    }

    enum Sheet: Identifiable {
        case share(URL)
        case filter(FilterOptions)

        var id: String { String(describing: self) }
    }

    enum FullScreenCover: Identifiable {
        case onboarding
        case camera

        var id: String { String(describing: self) }
    }

    func push(_ route: Route) {
        path.append(route)
    }

    func pop() {
        guard !path.isEmpty else { return }
        path.removeLast()
    }

    func popToRoot() {
        path.removeLast(path.count)
    }

    func present(_ sheet: Sheet) {
        self.sheet = sheet
    }

    func presentFullScreen(_ cover: FullScreenCover) {
        self.fullScreenCover = cover
    }
}

// Użycie
struct ContentView: View {
    @State private var router = AppRouter()
    @Environment(\.diContainer) private var container

    var body: some View {
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: AppRouter.Route.self) { route in
                    container.view(for: route)
                }
        }
        .sheet(item: $router.sheet) { sheet in
            container.sheet(for: sheet)
        }
        .fullScreenCover(item: $router.fullScreenCover) { cover in
            container.fullScreenCover(for: cover)
        }
        .environment(router)
    }
}

9. Czego unikać: Anty-wzorce i pułapki

Techniczne pułapki

  • Over-modularization - nie twórz modułu dla każdej małej funkcji. Moduł powinien reprezentować dany kontekst,
  • Distributed monolith - wiele modułów komunikujących się synchronicznie to gorszy monolit, nie lepszy,
  • Premature SwiftUI adoption - jeśli targetujesz iOS 15/16, niektóre API są niestabilne. iOS 17+ to najlepszy wybór,
  • Circular dependencies - użyj narzędzi jak swift-dependencies lub grafów zależności do wczesnego wykrywania,
  • Ignorowanie Strict Concurrency - włącz SWIFT_STRICT_CONCURRENCY=complete w nowych modułach od początku.

Pułapki procesowe

  • Feature freeze - nigdy nie blokuj developmentu nowych funkcji. Refaktor idzie równolegle,
  • Big PRs - PR migrujący 50 plików jest koszmarem podczas code review. Stosuj małe, inkrementalne zmiany,
  • Brak metryk - mierz build time, coverage, crash rate przed i po. Bez metryk nie wiesz czy rzeczywiście jest lepiej,
  • Hero culture - "Janek to zrobi, on zna ten stary kod" - wiedza musi być rozproszona, a nie skoncentrowana,
  • Pomijanie Fazy -1 - bez feature flags i E2E testów nie masz siatki bezpieczeństwa.

10. Checklista dla zespołu

Przed rozpoczęciem (Faza -1)

  • Zmierzone metryki baseline (build time, coverage, crash rate, startup time),
  • Upewnij się że system Feature flags jest gotowy,
  • Testy E2E pozytywnej ścieżki zostały napisane i przechodzą na zielono,
  • Zgoda biznesu na długoterminowy (12+ miesięcy) proces,
  • Zespół ma doświadczenie ze SwiftUI i async/await (lub zaplanowane szkolenie),
  • Zidentyfikowane 2-3 pilotażowe ekrany do Fazy 1,

W trakcie migracji

  • Każda zmiana jest deployowalna i produkcyjna,
  • Nowe funkcje są pisane w nowej architekturze,
  • Feature flagi aktywne dla zmigrowanych ekranów,
  • Regularnie mierzone metryki i korygowany kurs,
  • Code review rozproszone (nie tylko dla "eksperta od legacy"),

Sygnały ostrzegawcze 🚨

  • Build time rośnie zamiast maleć,
  • Crash rate wzrasta po kolejnych migracjach,
  • Zespół spędza więcej czasu na "łataniu" niż migracji,
  • Nowe funkcje są blokowane przez refaktor,
  • Tylko jedna osoba rozumie nową architekturę.

Podsumowanie: Wybieramy maraton, a nie sprint

Migracja przestarzałej aplikacji to maraton trwający miesiące, często lata. To jest maraton, a nie sprint, który przyniesie swoje benefity dopiero po pewnym czasie.

Kluczowe zasady:

  1. Strangler Fig > Big Bang - stopniowe zastępowanie zawsze wygrywa z przepisywaniem od zera,
  2. Quick wins early - 1-3 zmigrowane ekrany w pierwszych miesiącach budują morale i dowodzą, że strategia działa,
  3. @Observable + async/await - to standard, nie opcja,
  4. Swift Testing - migracja to idealny moment na przesiadkę z XCTest,
  5. Tuist + caching - XcodeGen to już trochę staroć,
  6. Feature flags everywhere - każda migracja za flagą, możesz zrobić rollback jednym kliknięciem,
  7. Measure, don't assume - metryki przed i po każdej fazie.

Pamiętaj: najgorszy kod to kod który nie działa. Twój przestarzały monolit, mimo wszystkich wad, działa w produkcji i przynosi wartość użytkownikom. Każda zmiana musi utrzymywać ten stan.

Powodzenia w migracji!

Rafał Dubiel

Rafał Dubiel

Senior iOS Developer z ponad 8-letnim doświadczeniem w tworzeniu aplikacji mobilnych. Pasjonat czystej architektury, Swift i dzielenia się wiedzą ze społecznością.

Udostępnij artykuł:

Jak przeprowadzić refaktor starej aplikacji iOS bez blokowania rozwoju produktu | FractalDev Blog