How To Build “Change-Friendly” iOS Application

Photo of Paweł Kozielecki

Paweł Kozielecki

Feb 14, 2023 • 21 min read
mobile phone

In IT projects, there seems to be only one constant: change. Sooner or later, business and technical requirements evolve, necessitating smaller or bigger adjustments in the application code.

For almost 2 years, I was engaged in developing a mobile payment assistant for one of the payment card providers. As you can imagine, the project had numerous business and technical stakeholders that could have produced (and did produce) new requirements at any point in time.

In addition, the application had to conform to the Client’s strict internal security standards. As a result, to put it mildly, it changed a lot. Suffice it to say, every major component, such as networking or device authorization, was thoroughly rebuilt at least once.

However, even in such extreme conditions, the critical project deadlines were always kept, and the application Unit Tests coverage never dropped below 85%.

So, what was our secret? In this article, I’ll try to highlight general concepts, architectural decisions, and common-sense rules that laid the foundation for delivering a complete, timely, and high-quality mobile project.

In short: How did we make our application “change-friendly”?

Types of change in mobile app development projects

Let’s begin with different types of changes that we may be forced to deal with:

a) Changes to the product core

This is the most profound type of a change that turns around the entire business model for the application. Unfortunately, it’s also the only type of change you cannot really prepare for as a developer.

To put it simply, if your Product Owner asks you to turn a banking application into a 3D game, all you can do is close the project and start implementing the new application from scratch.

b) Changes in project business requirements

Such a change comes from Stakeholders – people or organizations that have the power to add and alter the project requirements. More often than not, this change is necessitated by new business developments, such as a killer feature added by the competition.

c) Security requirements updates

It could be as simple as slightly bumping the iOS SDK (to fulfill App Store submission requirements) or as complex as implementing a new algorithm to identify users’ devices. In brief, for certain application types (e.g. fintech, banking, or medical) security is one of the most important drivers of change in the development process.

d) Technical changes

Although not originating from the business side of the project, technical changes may have an impact on the application’s stability, performance, and the overall user experience. Application dependencies which we need to periodically review and upgrade might serve as a good example of that.

e) Third-party-driven changes

This change is rather rare but the project sometimes must be adjusted to regulations set by e.g. a third-party service used in the project.

So as not to look too far, iOS developers were obligated by Apple to introduce features like adding Account Deletion, allowing to use Sign-in with Apple when the sign-up was possible with social services, adding App Tracking Transparency, or leveraging users' privacy by filling App Privacy Matrix – all of this under the threat of removing the application from the App Store or rejecting a new release.

As you can see, unlike people, different types of change are not equal. The ones necessitated by the business or security requirements will always take precedence over purely technical ones. Knowing this, requests for changes can be prioritized and arranged in time to minimize their negative impact on the delivery process.

For example, bumping the version of Swift in the sprint preceding an important release might not be the safest idea. After all, we cannot guarantee that our software (let alone its dependencies) will work the same way when compiled with another version of Swift. However, the same change performed when the application is in a maintenance period, following the same release, is more likely to succeed.

Moreover, we are usually informed about planned security requirements bumps ahead of time. The same can be said about App Store submission requirements. Apple accepts applications built with the previous SDK for a prolonged period of time, usually over six months, which gives us enough time to plan the transition.

All things considered, you can do a lot of things to ensure the necessary changes are made during times when they have the least negative impact on the project. Ultimately, however, how difficult it is to implement these changes depends mostly on the quality of the codebase. Let’s discuss it next.

How to make your code easier to change?

Arguably, the best definition of well-written code is that it is easier to change than the badly-written one.

Gathered below are the design patterns, architectural decisions, and simple, common-sense rules we applied throughout the application development. Please bear in mind that although these techniques may seem simple, they are not easy to implement in an application that already has significant technical debt. The sooner you start introducing them, the easier it will be!

Remove business logic from Views

The first thing we should consider doing is a strong separation of application layers, especially purging business logic from the code responsible for generating the UI.

Let’s take a look at how we can provide a view with the information it needs:


extension CodeVerificationView {

    
    struct Configuration: Equatable {

        /// A title text.
        let title: String

        /// A subtitle-tagged text.
        let subtitle: String

        /// A description text, if any.
        let description: String?

        /// A top image, if any.
        let topImage: UIImage?

        …

        /// A title for the primary button.
        let primaryButtonTitle: String

        /// A title for the secondary button.
        let secondaryButtonTitle: String

        …
    }
}

As you can see, there are no complex types here – just a couple of strings and images. There are no references to the business logic at all – only a bunch of simple texts with a clear indication of where each of them should be displayed: title, description, main button, etc.

And there is a good reason for that. As developers, we want our views to be:

1. Small, clear, and simple

Knowing nothing of the business logic, our views are responsible only for rendering the UI and receiving user feedback. This allows such views to be written using a relatively small amount of code (50-200 lines, excluding comments). Such code is clear and readable, easier to maintain.

2. Reusable

Furthermore, such small views can be easily made reusable. Take the configuration of the CodeVerificationView presented above. It says nothing of what kind of code the view verifies. Depending on the business context, we can use the view in multiple application flows, eg:
a) account registration – to confirm an email address belongs to the user
b) mobile number registration – to confirm a phone number belongs to the user
c) payment card registration – to bind a card with a provided phone number

If another business case requiring code verification ever arises, we can reuse the view yet again. All we need to do is prepare another configuration for the view!

3. Testable


A simple and configurable view is easily testable. To cover user interaction, we can use Unit Tests, whereas Snapshot Tests would be ideal to test the UI, etc.
Having an exhaustive automated tests suite in place, we can freely update and refactor such views without worrying about possible regressions.

4. Faster to develop


Decoupling from business logic types allows us to leverage the Preview feature in Xcode to quickly iterate on view implementation. Additionally, having multiple previews allows us to verify how the view looks in different states. All that without the necessity to recompile the application and test in the simulator!

In short: We want to make sure our views have one thing to “worry” about showing the data (passed in a configuration) and gathering user feedback (e.g. tapping on a button). As such, the views should be oblivious to any governing business logic. If that logic is updated, it should not require changes to the view implementation details.

Let’s take a password creation screen as an example. If we’ve done our job correctly, a new password validation rule would not affect the view at all. The view would still have to present an error to the user, just using a different copy (provided through a configuration).

Are there any negatives associated with decoupling business logic from the UI? Sure! For instance, we need to create (and maintain) a logic converting “business” data models into view configurations. Given the benefits it brings, though, it seems to be a reasonable price to pay.

Ultimately, our goal as developers is to ensure the continuously delivering business value to our clients. Being able to do so faster (without regressions) and in excellent quality won’t go unnoticed.

Extract application navigation to a dedicated component

Robust and scalable navigation in mobile applications is tricky to implement. The first step on the road to achieving that is extracting the navigation code to a dedicated component and making sure it is responsible only for that task!

In the aforementioned application, we decided to implement navigation based on a series of Flow Coordinators. The purpose of such coordinators is very simple – to lay out specific views in a specific order. Let’s take a look at what the public API of a coordinator looks like:


protocol FlowCoordinator: AnyObject {

    /// A flow coordinator delegate.
    var flowCoordinatorDelegate: FlowCoordinatorDelegate? { get set }

    /// A navigation controller presenting the flow.
    var presentingNavigationController: UINavigationController? { get }

    /// Flow distinct name.
    var name: String { get }

    /// Starts the flow.
    ///
    /// - Parameter animated: perform animation when showing flow initial screen.
    func start(animated: Bool)
}

where:


protocol FlowCoordinatorDelegate: AnyObject {

    /// Executed when flow is finished.
    ///
    /// - Parameter flowCoordinator: flow coordinator.
    func flowCoordinatorDidFinish(_ flowCoordinator: FlowCoordinator)
}

A coordinator must have access to a navigation controller in order to lay out its views.
It also should have a way to notify its owner when it completed all the work – hence the delegate. Simple enough, right?

So let’s take a look at the actual implementation:


final class OnboardingFlowCoordinator: FlowCoordinator {

    /// A flow coordinator name.
    let name = RegistrationFlowCoordinatorName.onboarding.rawValue

    /// A flow coordinator delegate.
    weak var flowCoordinatorDelegate: FlowCoordinatorDelegate?

    /// A navigation controller to present the flow on.
    private(set) var presentingNavigationController: UINavigationController?

    private let dependencyProvider: DependencyProvider
    private let shouldStartWithWelcomeScreen: Bool

    /// A default initializer for OnboardingFlowCoordinator.
    ///
    /// - Parameters:
    ///   - presentingNavigationController: a navigation controller to present the flow on.
    ///   - dependencyProvider: a reference to the application Dependency Provider.
    ///   - shouldStartWithWelcomeScreen: a flag determining if the flow should start from Welcome Screen or not.
    init(
        presentingNavigationController: UINavigationController,
        dependencyProvider: DependencyProvider,
        shouldStartWithWelcomeScreen: Bool = true
    ) {
        self.presentingNavigationController = presentingNavigationController
        self.dependencyProvider = dependencyProvider
        self.shouldStartWithWelcomeScreen = shouldStartWithWelcomeScreen
    }

    /// SeeAlso: FlowCoordinator.start()
    func start(animated: Bool) {
        showInitialViewController(animated: animated)
    }
}

…

// MARK: WelcomeViewControllerDelegate

extension OnboardingFlowCoordinator: WelcomeViewControllerDelegate {
    func welcomeViewControllerDidTriggerLogIn(_ welcomeViewController: WelcomeViewController) {
        /// (4) Notify the flow's delegate when the last view in the flow finished all its tasks (or the user decided to exit the flow early):
        flowCoordinatorDelegate?.flowCoordinatorDidFinish(self)
    }

    func welcomeViewControllerDidTriggerSignUp(_ welcomeViewController: WelcomeViewController) {
        /// (2 & 3) When the initial view controller finished all its tasks, we determine which view to show next:
        showOnboardingViewController(ofType: .first)
    }
}

…

private extension OnboardingFlowCoordinator {

    /// (1) We create and push the initial view controller:
    func showInitialViewController(animated: Bool = true) {
        if shouldStartWithWelcomeScreen {
            let welcomeViewController = WelcomeViewController()
            welcomeViewController.setBackButtonStyle()
            welcomeViewController.delegate = self
            presentingNavigationController?.pushViewController(welcomeViewController, animated: animated)
        } else {
            showOnboardingViewController(ofType: .first)
        }
    }

    func showOnboardingViewController(ofType type: OnboardingViewType) {
        let viewController = OnboardingPresentationViewController()
        viewController.delegate = self
        viewController.setBackButtonStyle()
        presentingNavigationController?.setViewControllers([viewController], animated: true)
    }
}

As you can see, the flow works in a very straightforward way:

  1. An initial view is created and pushed to the navigation flow.
    The flow also is assigned as that view controller’s delegate.
  2. When that initial view completes all its tasks, it notifies the delegate, forwarding the user’s intention. This intention helps the coordinator to determine which screen to show next.
    For example, after completing the onboarding view, the user might choose to either sign up or log in.
  3. The coordinator creates the next view in the flow and puts it on the navigation stack, becoming its delegate.
  4. When the final view in the flow finishes its tasks, the coordinator notifies its owner of the completion.
    The owner is responsible for removing the flow from the navigation stack and starting a new one.

This approach has multiple advantages:

  • It’s very simple to implement.
    The solution relies on one of the oldest and simplest design patterns known to iOS developers – the delegation.
  • It’s readable.
    Implementing a view delegate as a separate extension to the coordinator nicely wraps up the code responsible for “cleaning up” after that view and showing the next one.
  • It’s scalable.
    Let’s assume a very realistic possibility that we are asked by the product team to show another onboarding screen to the user. Nothing can be simpler! Just introduce the view into an existing flow. There’s no need to change any of the existing views, nor their communication with the flow coordinator.
  • It’s testable.
    We can record how the coordinator interacts with the navigation stack and verify if the views were presented in a correct order.
  • It’s reusable.
    If the flow is short and configurable, it can be reused across the application.
    For instance, the email/password authentication flow can be presented at the start of the application or when the user wants to change the PIN.

And how about the negatives:

  • Some boilerplate code.
    The delegation pattern is generally not known for brevity…
  • Might be difficult to read.
    As a flow gets more complex, so does the code of the coordinator governing it. Whenever having difficulties reading a flow, it’s often a good indication to split it into smaller ones.

In short: We’re making sure that the rigid app navigation won’t hold us back from implementing the cool new feature!

Introduce a layer of abstraction

One of the most important traits of well-developed app architecture is that it elevates the behavior and properties of its components, hiding their implementation details. And, as we all know, the latter can change a lot…

To illustrate this, let’s entertain a simple application environment data provider. As the name suggests, it provides information about the environment our app runs on the base URL, request timeouts, etc.


protocol AppEnvironmentProvider {

    /// A current application running environment.
    var environment: AppEnvironment { get }
}

Initially, the implementation would be very simple:


struct SimpleAppEnvironmentProvider: AppEnvironmentProvider {

    /// SeeAlso: AppEnvironmentProvider.environment
    var environment: AppEnvironment {
        .development
    }
}

As our environments stack grow, it’ll evolve towards the implementation below:


struct DefaultAppEnvironmentProvider: AppEnvironmentProvider {

    /// SeeAlso: AppEnvironmentProvider.environment
    var environment: AppEnvironment {
        #if ENV_DEVELOPMENT
            return .development
        #elseif ENV_STAGING
            return .staging
        #elseif ENV_APPIUM
            return .appiumTests
        #elseif ENV_PEN_TEST
            return .penTests
        #elseif ENV_WIREMOCK
            return .wiremock
        #elseif ENV_PRODUCTION_LOCAL
            return .productionLocal
        #else
            return .production
        #endif
    }
}

In addition, when executing the Unit Tests suite, we’d like to mock the application environment:


struct FakeAppEnvironmentProvider: AppEnvironmentProvider {
    var simulatedAppEnvironment: AppEnvironment?

    var environment: AppEnvironment {
        simulatedAppEnvironment ?? .development
    }
}

Whenever we wish to swap app environments (e.g. for testing purposes), we simply switch a few lines in the relevant implementation. If the API and the behavior of the Provider remains unchanged, the rest of the application would not even notice!

Let’s take a look at a more complex example: a local storage component.
Regardless of how such a component is implemented, it should behave in a specific way: store, retrieve, and clear values of specific (preferably basic) types.
A protocol describing such a component can look like this:


protocol LocalStorage {

    /// Retrieves a string value stored under a provided key.
    ///
    /// - Parameter defaultName: key.
    /// - Returns: a string stored under a provided key, otherwise nil.
    func string(forKey defaultName: String) -> String?

    /// Retrieves a data value stored under a provided key.
    ///
    /// - Parameter defaultName: key.
    /// - Returns: data stored under a provided key, otherwise nil.
    func data(forKey defaultName: String) -> Data?

    …

    /// Sets a value under a provided key.
    ///
    /// - Parameters:
    ///   - value: value to store.
    ///   - defaultName: key.
    func set(_ value: Any?, forKey defaultName: String)

    /// Removes an object stored under a provided key.
    ///
    /// - Parameter defaultName: key.
    func removeObject(forKey defaultName: String)
}

Does it look familiar to you? Of course! It’s almost an exact copy of a public API of the UserDefaults. If so, we can use the power of Swift extensions to make UserDefaults conform to the protocol:


extension UserDefaults: LocalStorage {}

In a component that requires such local storage, we can inject it as a dependency:


private let localStorage: LocalStorage
…

init(localStorage: LocalStorage = UserDefaults.standard) {
    self.localStorage = localStorage
}

And this is where the magic happens. From this moment, the component does not rely on UserDefaults to store data. It relies on SOME storage, bound by the contract described in the LocalStorage protocol.

Of course, initially, the storage will likely be implemented using UserDefaults, but in the future? Not necessarily.

As we progress through the roadmap, there will probably be a requirement to make the application’s local storage more secure. All we need to do to comply is to create a component conforming to the LocalStorage protocol yet leveraging more secure forms of storing data, e.g. the Keychain.

The “new, improved” storage can be injected into all the places that require access to local data, e.g. a user authentication service. The beauty of it is that none of these components (nor the rest of the application) will even notice the change!

Naturally, you have to be careful in designing such protocols. Each time their API is updated, all the conforming types must be revised as well – don’t worry if the compiler will be our friend here! As before, however, it is a small price to pay for having such a powerful weapon!

Apply separation of concerns

Finally, we arrive at the separation of concerns – one of the most important paradigms in software development. It indicates (extending the S.O.L.I.D’s Single Responsibility Principle) that the application components should only be assigned to precisely defined, limited, and atomic responsibilities. Ideally – just one.

How does this benefit our application? Let’s take a look at the following network module:


final class DefaultNetworkModule: NetworkModule {

    /// A request builder.
    private let requestBuilder: RequestBuilder

    /// A network state provider.
    private let networkStateProvider: NetworkStateProvider

    /// An URL session.
    private let urlSession: NetworkSession

    /// Array of behaviors to be executed before and / or after network call.
    private let networkBehaviors: [NetworkModuleBehaviur]

    /// An executor on which completion callbacks are executed.
    private let completionExecutor: AsynchronousOperationsExecutor

    /// A no-network connection handler.
    private let noNetworkConnectionHandler: NoNetworkConnectionHandler

As you can imagine, the component is responsible for performing a network request, as well as interpreting and returning an answer.

Each step in that process is handled by a dedicated dependency:

  • A NetworkStateProvider ensures a stable Internet connection
  • A RequestBuilder parses a request description into a URLRequest
  • If applicable, a NetworkModuleBehaviour enriches the URLRequest with additional data (e.g. appends a request header with a field containing an access token)
  • A NetworkSession executes the request and provides a response
  • A completion executor ensures that a response is provided on a desired thread, etc.

I could go on like this forever. Ultimately, what matters is that we can precisely tell what each component is responsible for. This greatly enhances code readability and improves testability.

However, each time we have to change something, it allows us to limit the area affected by that change:

  • Requirements concerning detecting unstable internet connection changed – we modify the NetworkStateProvider.
  • We need to include a specific header field in all of the outgoing requests – we implement a dedicated NetworkModuleBehaviour to handle that.
  • We need to modify the caching policy – we change the way NetworkSession is configured.

Furthermore, specialized components are also highly reusable.

Let’s imagine a business request to display an “app is online” indicator on one of the views. Nothing can be simpler! Just take the (already implemented) NetworkStateProvider and use it as a data source for the indicator. At least 50% of the work is already done!

Is there a downside? Naturally! There’s a need to inject multiple dependencies into certain components so they can perform their functions. Look no further than the network module showcased above – we could’ve just implemented all its tasks directly in the module and saved some time.

Jokes aside, a rule of thumb is to always monitor a number of dependencies required by a component. If there are too many, maybe it’s worth splitting the component and dividing its responsibilities?

Ultimately, as Ricky from Trailer Park Boys TV show aptly put it: Having responsibilities is a vital part of life.

Dealing with change in mobile app development

To sum up, in this article we’ve:

  • discussed the benefits of removing business logic from the views
  • explained why it’s important to have a dedicated component responsible for app navigation
  • outlined the sheer power behind relying on the behavior and properties of an object instead of its implementation details
  • discussed the importance of dividing responsibilities between the application components

Is that aIl we need to do to make sure our app is “change friendly”? No! Of course, there is more! Stay tuned for part 2!

Photo of Paweł Kozielecki

More posts by this author

Paweł Kozielecki

Paweł Kozielecki works as a Software Architect iOS at Netguru.
Native mobile app development  Build an app with an exceptional design and user experience Read the customer story

We're Netguru!

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency

Let's talk business!

Trusted by: