Weekly Swift articles, podcasts and tips by John Sundell.

Rule-based logic in Swift

Published on 28 Jul 2019

When thinking about code architecture or system design, it’s quite easy to only think about the big-picture, overarching concepts and abstractions that a code base is using — such as whether we use view models, or logic controllers, or presenters, and how we communicate between our core logic and our UI.

However, while those larger concepts are incredibly important, we can also often make a big impact on the overall structure and quality of our code base by improving some of the more local and minor details. How are individual functions structured, how do we make local decisions within a type or a feature, and how coupled or decoupled are our various pieces of logic?

This week, let’s take a look at one technique for doing such local improvements, by refactoring large functions into dedicated, rule-based systems — and how doing so could also lead to an increase in both clarity and quality on a grander scale as well.

Handling events

One thing that almost all modern apps have in common, especially those running on increasingly sophisticated operating systems — like iOS and macOS — is that they have to handle many kinds of different events. Not only do we have to handle user input and changes in data or state, we also need to respond to various system events — ranging from device rotation, to push notifications, deep linking and beyond.

Let’s take a look at an example of such event handling, in which we’re responding to the system asking our app to open a given URL, using a URLHandler type. As an example, let’s say that we’re building a music app that deals with artists, albums, as well as user profiles — so our URL handling code might look something like this:

struct URLHandler {
    let navigationController: UINavigationController

    func handle(_ url: URL) {
        // Verify that the passed URL is something we can handle:
        guard url.scheme == "music-app",
              !url.pathComponents.isEmpty,
              let host = url.host else {
                return
        }

        // Route to the appropriate view controller, depending on
        // the initial component (or host) of the URL:
        switch host {
        case "profile":
            let username = url.pathComponents[0]
            let vc = ProfileViewController(username: username)
            navigationController.pushViewController(vc, animated: true)
        case "album":
            let id = Album.ID(url.pathComponents[0])
            let vc = AlbumViewController(albumID: id)
            navigationController.pushViewController(vc, animated: true)
        
            ...
        }
    }
}

The above code works, but from an architectural perspective, it does have some problems. First of all, since our URLHandler is currently directly responsible for creating view controllers for all of our app’s features, it needs to know a lot about all sorts of details that extend far beyond the realm of URL handling. For example, it needs to know how to parse identifiers, what kind of arguments that our various view controllers accept, and so on.

Not only does that make our code strongly coupled — which will most likely make tasks like refactoring much harder than they need to be — it also requires the above handle method to be quite massive, and it’s almost guaranteed to continue to grow as we keep adding new features or deep linking capabilities to our app.

Following the rules

Instead of putting all of our URL handling code in a single method or type, let’s see if we can refactor it to become a bit more decoupled. If we think about it, when it comes to tasks like URL handling (or any other sort of event handling code), we’re most often evaluating a set of pre-defined rules in order to match the event that occurred with a set of logic to execute.

Let’s embrace that, to see what would happen if we were to actually model our code as explicit rules. We’ll start with a rule type — let’s call it URLRule — and add properties for the information that we’ll require in order to evaluate a rule in the context of URL handling:

struct URLRule {
    /// The host name that the rule requires in order to be evaluated.
    var requiredHost: String
    /// Whether the URL's path components array needs to be non-empty.
    var requiresPathComponents: Bool
    /// The body of the rule, which takes a set of input, and either
    /// produces a view controller, or throws an error.
    var evaluate: (Input) throws -> UIViewController
}

While it also would’ve been nice to decouple the concept of view controllers from our URL handling code, we won’t do that as part of this refactor, in order to keep things simple. For some ideas on how to write decoupled navigation code, see “Navigation in Swift”.

Next, let’s define the Input type that our rules will be passed when they’re being evaluated, as well as a convenience Error type that a rule will be able to easily throw in case the given input didn’t match its requirements:

extension URLRule {
    struct Input {
        var url: URL
        var pathComponents: [String]
        var queryItems: [URLQueryItem]
    }

    struct MismatchError: Error {}
}

Above we’ve broken out the parts of a URL that most of our rules will be interested in, which reduces the amount of processing and analysis that each rule has to do — allowing them to just focus on their own internal logic. Along those lines, we can also extend our Input type with convenience APIs for common rule operations, for example to easily be able to access a given query item’s value:

extension URLRule.Input {
    func valueForQueryItem(named name: String) -> String? {
        let item = queryItems.first { $0.name == name }
        return item?.value
    }
}

With the above in place, we can start defining some rules! Let’s start with one of the simpler ones from before, which matches the profile URL host to a ProfileViewController — like this:

extension URLRule {
    static var profile: URLRule {
        return URLRule(
            requiredHost: "profile",
            requiresPathComponents: true,
            evaluate: { input in
                ProfileViewController(
                    username: input.pathComponents[0]
                )
            }
        )
    }
}

Note that since we declared that our above rule requires each URL’s path components to be non-empty, we can safely access the first component without having to deal with optionals or write any local validation code.

Pretty cool! But that was a very simple example, so let’s move on to something a bit more complex. Here’s a rule that matches the artist host, and also performs inline validation of the requested artist’s ID, as well as parsing an optional query parameter:

extension URLRule {
    static var artist: URLRule {
        return URLRule(
            requiredHost: "artist",
            requiresPathComponents: true,
            evaluate: { input in
                let rawID = input.pathComponents[0]

                guard let id = Artist.ID(rawID) else {
                    throw MismatchError()
                }

                let songID = input.valueForQueryItem(named: "song")
                                  .flatMap(Song.ID.init)

                return ArtistViewController(
                    artistID: id,
                    highlightedSongID: songID
                )
            }
        )
    }
}

To learn more about the above way of using flatMap, see the “Map, FlatMap and CompactMap” Basics article.

Above we can see that the convenience APIs that we added, such as the MismatchError type and the method for retrieving query item values, are already coming very much in handy — as they let us implement completely decoupled logic with a minimum amount of boilerplate involved.

Finally, let’s take a look at how we can even enable specific dependencies to be injected into each individual rule, without having to pass them through any other type. All we have to do is to simply pass any dependencies that a rule needs as arguments to the static factory method that creates that rule — like this:

extension URLRule {
    static func search(using loader: SearchResultsLoader) -> URLRule {
        return URLRule(
            requiredHost: "search",
            requiresPathComponents: false,
            evaluate: { input in
                SearchViewController(
                    loader: loader,
                    query: input.valueForQueryItem(named: "q")
                )
            }
        )
    }
}

The beauty of the above approach is that by splitting up our handling of events into clearly separated rules we’ve both ended up with code that’s fully decoupled (which enables us to work on, test, and modify each rule in complete isolation), and we’ve also made our logic a lot more readable — with bite-sized functions rather than one massive switch statement.

Creating and evaluating rules

Now let’s actually start using our new system, and the first thing we’ll do is to assemble all of our various rules (which can be defined in as many different places as we want) into a single ruleset. Thankfully, that’s quite easily done, since we implemented our rules using simple static factory methods — which enables us to construct a ruleset using this very nice dot syntax:

let rules: [URLRule] = [
    .profile,
    .artist,
    .album,
    .playlist,
    .search(using: searchResultsLoader)
]

Next, we need a way to convert any URL that we’ll be evaluating into the input type that our rules expect, so let’s extend URLRule.Input with a convenience initializer that does just that:

extension URLRule.Input {
    init(url: URL) {
        // A URL's path components include slashes, which we're
        // not interested in, so we'll simply filter them out:
        let pathComponents = url.pathComponents.filter {
            $0 != "/"
        }

        let queryItems = URLComponents(
            url: url,
            resolvingAgainstBaseURL: false
        ).flatMap { $0.queryItems }

        self.init(
            url: url,
            pathComponents: pathComponents,
            queryItems: queryItems ?? []
        )
    }
}

Finally, it’s time to refactor URLHandler by replacing its previous inline logic with our new ruleset. While we’ve defined our rules as an array of URLRule values, to avoid having to iterate through that array each time we’ll handle a URL (which would be an O(N) operation in terms of complexity), we’ll start by grouping our rules based on their required host name — like this:

struct URLHandler {
    private let navigationController: UINavigationController
    private let rules: [String : [URLRule]]

    init(navigationController: UINavigationController,
         rules: [URLRule]) {
        self.navigationController = navigationController
        self.rules = Dictionary(grouping: rules) { $0.requiredHost }
    }

    ...
}

The last piece of the puzzle is to update our handle method, by removing its massive switch statement, and instead iterate through the rules for the given url’s host — and once we’ve found a match we’ll push that rule’s produced view controller onto our navigation stack:

struct URLHandler {
    ...
    
    func handle(_ url: URL) {
        guard url.scheme == "music-app",
              let host = url.host,
              let rules = rules[host] else {
            return
        }

        let input = URLRule.Input(url: url)

        for rule in rules {
            if rule.requiresPathComponents {
                guard !input.pathComponents.isEmpty else {
                    continue
                }
            }
        
            guard let vc = try? rule.evaluate(input) else {
                continue
            }

            // As soon as we've encountered a rule that successfully
            // matches the given URL, we'll stop our iteration:
            navigationController.pushViewController(vc, animated: true)
            return
        }
    }
}

One thing to note about the above implementation is that we’re deliberately discarding any errors that were thrown by our rules, since we’re only using errors as control flow in this case. While we could’ve also made our rules return nil in case of a mismatch, by using errors to indicate failure we both enable our rules to easily call throwing functions themselves, and we also make it crystal clear what the failure outcome of a rule should be.

Conclusion

Whenever a function’s logic is all about evaluating a somewhat large set of rules, extracting the various parts of that logic into actual rule values can both make our code easier to read and maintain, and also enables us to iterate on each rule’s individual logic in complete isolation. While introducing an additional abstraction, like we did above, does come with a complexity cost — that cost is most often worth it when we’re dealing with code that ideally should never have been coupled together in the first place.

Structuring code as a set of rules is of course not something that’s only applicable to URL handling — this very same technique could be used for rule-based error handling, deciding how to present a given view or view controller, or any other sort of logic that follows the same pattern of linearly evaluated rules. To see a complete example of this pattern in action — check out Splash, the syntax highlighter that powers this very website, which uses a rule-based system to decide how to highlight each code token.

What do you think? Have you ever structured some of your logic as an explicit set of rules, or is it something you’ll try out? Let me know, either via Twitter or email. I really love getting feedback from readers — and if you enjoyed this article, feel free to either share it, or to check out the above sponsor — both of which really helps support me and my work.

Thanks for reading! 🚀