Architecture With Nico

Practical software architecture and design for iOS engineers.

How to Refactor Toward Better Architecture Without Rewriting the App

The most useful architecture work rarely starts with a blank project.

Most of the time, you inherit a feature that already exists. It has users. It has bugs. It has deadlines attached to it. It has code that is not terrible enough to rewrite and not clear enough to comfortably change.

That is where architecture matters.

Better architecture is not only about designing clean systems from scratch. It is about moving messy systems toward better boundaries one small decision at a time.

#Start With a Change You Need to Make

Do not refactor in the abstract.

"This code could be cleaner" is true almost everywhere and useful almost nowhere. A better starting point is a concrete change:

  • Add analytics when checkout succeeds.
  • Show cached products when the network fails.
  • Reuse the login screen from onboarding.
  • Add a second payment provider.
  • Make a feature testable.

The change gives the refactor direction. You are not trying to make the whole feature beautiful. You are trying to make this change cheaper and safer.

#Find the Pain

Before moving code, ask where the change hurts.

If adding analytics requires editing five screens, the behavior is probably scattered.

If showing cached data requires changing a ViewModel, repository, API client, and view, the data boundary is probably unclear and presentation may be doing too much.

If reusing a screen requires copying the whole screen, navigation may be too tightly coupled.

Pain is information. It tells you where the current architecture is resisting change.

#Put a Boundary Around the Behavior

Suppose a ViewModel talks directly to an API client.

final class ProductsViewModel: ObservableObject {
    private let apiClient = APIClient()

    func load() async {
        let response = try? await apiClient.getProducts()
        // map response and update state
    }
}

The first improvement is not a full Clean Architecture rewrite. It is separating presentation mapping from product loading.

final class ProductsViewModel: ObservableObject, ProductsOutput {
    @Published private(set) var state: ProductsState = .loading
    
    func didLoadProducts(_ products: [Product]) {
        state = .loaded(products.map(ProductRowViewState.init))
    }
}

struct ProductsFeatureAdapter {
    private let productsRepository: ProductsRepository
    private let output: ProductsOutput

    init(productsRepository: ProductsRepository, output: ProductsOutput) {
        self.productsRepository = productsRepository
        self.output = output
    }

    func load() async {
        do {
            output.didLoadProducts(try await productsRepository.products())
        } catch {
            output.didFailLoadingProducts()
        }
    }
}

Now the ViewModel no longer creates the API client or owns the loading operation. That one move gives you a seam for tests, previews, and future data policies.

You can wire it from the composition root:

let viewModel = ProductsViewModel()
let adapter = ProductsFeatureAdapter(
    productsRepository: RemoteProductsRepository(
        client: URLSessionHTTPClient()
    ),
    output: viewModel
)

ProductsView(
    viewModel: viewModel,
    onAppear: {
        await adapter.load()
    }
)

This is the same direction described in the composition root articles: concrete dependencies are created near the app edge, while features receive what they need.

#Extract Policy Before Extracting Layers

A common mistake is extracting files before extracting decisions.

If the feature adapter contains a retry policy, moving that code to RetryManager may not improve the architecture. It may only move the mess.

First, name the policy:

struct RetryPolicy {
    func shouldRetry(after error: Error, attempt: Int) -> Bool {
        attempt < 3 && error.isNetworkError
    }
}

Now the decision has a name. It can be tested. It can be injected. It can change without digging through presentation code.

The layer is less important than the boundary.

#Use Decorators for Additive Behavior

Some changes should not require editing the core object.

For example, adding analytics to product loading can be a decorator:

struct AnalyticsProductsRepository: ProductsRepository {
    let decoratee: ProductsRepository
    let analytics: AnalyticsTracking

    func products() async throws -> [Product] {
        do {
            let products = try await decoratee.products()
            analytics.track("products_loaded")
            return products
        } catch {
            analytics.track("products_failed")
            throw error
        }
    }
}

The original repository still fetches products. The decorator adds analytics. The composition root decides whether analytics is included.

This is a practical application of the Open-Closed Principle.

#Move Navigation Out Last

Navigation can be tricky to refactor because it touches user flow.

If a screen currently navigates directly, do not immediately invent a large navigation framework. Start by emitting an output.

enum LoginOutput {
    case didLogin
    case didRequestSignup
}

Then the presentation layer can report intent:

final class LoginViewModel: ObservableObject {
    var onOutput: (LoginOutput) -> Void = { _ in }

    func loginSucceeded() {
        onOutput(.didLogin)
    }
}

The parent, flow, coordinator, or composition root can decide what happens next.

This small output boundary is often enough to make a screen reusable.

#Add Tests Around the New Boundary

Do not try to backfill tests for every old behavior at once. Add tests around the boundary you are changing.

If you extracted a repository, test the feature adapter with a fake repository and test the ViewModel by sending it output events.

If you extracted a retry policy, test the policy directly.

If you added a decorator, test the behavior the decorator adds.

Small tests around new boundaries give you confidence without turning the refactor into a separate project.

#Keep the App Working at Every Step

A rewrite makes progress invisible until the end. A refactor should keep the app working after every small move.

A safe sequence might look like this:

  1. Inject the dependency that was created internally.
  2. Add a test double.
  3. Extract one policy or operation.
  4. Add tests for that extracted behavior.
  5. Move construction into a factory or composition root.
  6. Repeat for the next painful change.

Each step is small enough to review. Each step makes the next step easier.

#What You Should Remember

Better architecture is not a destination you reach by rewriting everything. It is a direction.

Start from a real change. Find the pain. Make one dependency visible. Extract one decision. Add one boundary. Test that boundary. Keep the app working.

That is how architecture improves in the codebases we actually have.