Improving Composable Architecture Debugging

I've been a fan of Point Free Composable architecture for a while now.

I've worked on TCA projects for more than a year on projects of all sizes: from smaller projects like my indie Sourcery Pro, through the New York Times project, to a truly large codebase, a completely new browser for The Browser Company.

TCA has been working great in all of them, but one thing that I was missing was an easy way to debug state changes, as the official debug higher-order reducer doesn't play well with large app states.

So I thought: let's create an interface that will not only deal with larger states, but also offer us a way to filter actions with ease:

.debugDiffing(allowedActions: .allExcept(.hoverActions, .windowVisibility))

Problems to solve

Large diffs

The first problem is the amount of data TCA will print out whenever an action changes its state. ? In case of bigger apps it's simply won't be readable to humans because it prints the whole state and just marks the change.

Sure, you can attach the debug in a specific module and that should make it more manageable - but when you're trying to understand all interactions between different behaviors in-app, that's not a real solution.

Solution

Fortunately, there is one ready-to-use solution. I've built it for TDD workflows a few years ago. It's called
Difference and it generates exact property level difference between 2 objects.

Leveraging difference will let you get debug logs like this one:

🎬 Received action:
  AppAction.windows(
    WindowsAction.window(
      windowID: WindowID(
        rawValue: "131DDF0A-DDE2-4757-B1AC-4DAB60269E8D"
      ),
      action: WindowAction.toggleListCollapsedState(
        listID: RootOrListIdentifier.list(
          Identifier<SidebarItem>(
            rawValue: "F686B64A-E221-49FB-9504-FE003FC6493F"
          )
        )
      )
    )
  )
🖨️ State:
persistedWindows:
| windows:
| | Key 131DDF0A-DDE2-4757-B1AC-4DAB60269E8D:
| | | collapsedListIDs:
| | | | Different count:
| | | | | Current: (1)
| | | | | Previous: (0)

Action filtering

When dealing with state debugging at the app level we often want to suppress messages from a particular set of actions, e.g. at Browser Company we get actions for things like windows visibility or mouse hover state. Those things can easily get noisy, so it's good to have a way to suppress it.

To do that, you could simply add closure for filtering actions, such as:

.debugDiffing(allowedActions: { action in 
  switch action {
    case .windows(_, action: .visibility):
      return false

    default: 
      return true
  }
})

That could definitely work, but it would also mean that all the filtering code gets deleted the moment you are done with debugging.

So, the next person that tries to debug the app will have to repeat the same kind of work. Not very efficient or convenient, right?

Action repository

Instead, let's build a solution that:

  • enables you to accumulate an action filter repository that can be used by the next person debugging the codebase,
  • has got a nice composable DSL for readability and leveraging IntelliSense.

To achieve the above we can introduce a generic wrapper over Action check called ActionFilter:

public struct ActionFilter<Action: Equatable> {
    let isIncluded: (Action) -> Bool

    func callAsFunction(_ action: Action) -> Bool {
        isIncluded(action)
    }

    /// Include all actions
    public static var all: Self {
        .init(isIncluded: { _ in true })
    }

    /// negates the filter
    public static func not(_ filter: Self) -> Self {
        .init(isIncluded: { !filter($0) })
    }

    /// Allows all actions except those specified
    public static func allExcept(_ actions: [Self]) -> Self {
        .init(isIncluded: { action in
            !actions.contains(where: { $0(action) })
        })
    }

    /// Allows any of the specified actions
    public static func anyOf(_ actions: [Self]) -> Self {
        .init(isIncluded: { action in
            actions.contains(where: { $0(action) })
        })
    }

By leveraging variadic argument support in Swift you can enable an even nicer API when dealing with multiple filters and, therefore, simply forward the call to the array variant you already created:

/// Allows all actions except those specified
public static func allExcept(_ actions: Self...) -> Self {
    allExcept(actions)
}

By leveraging callAsFunction feature you can call the filters as one would do with standard closures. Using generic means that you can write specialized extensions for different Action types in our projects:

extension ActionFilter where Action == AppAction {
    static var sidebarActions: Self {
        Self(isIncluded: {
            switch $0 {
            case .appSidebar, .sidebarSync, .sidebarSyncCloudKit:
                return true
            default:
                return false
            }
        })
    }

This means that now you can have a central repository of filters related to AppAction and you can leverage IntelliSense when debugging AppAction.

Connecting it all together

Now you just want to take TCA original debug reducer implementation interface and modify it to fit your needs:

extension Reducer where State: Equatable, Action: Equatable {
    public func debugDiffing(
        actionFormat: ActionFormat = .prettyPrint,
        allowedActions: ActionFilter<Action> = .all
    ) -> Self {
        .init { state, action, env in
            let oldState = state
            let effects = self.run(&state, action, env)
            let newState = state

            /// Leverage callAsFunction
            guard allowedActions(action) else {
                return effects
            }

            return .merge(
                .fireAndForget {
                    debugEnvironment.queue.async {
                        let actionOutput =
                            actionFormat == .prettyPrint
                                ? debugOutput(action).indent(by: 2)
                                : debugCaseOutput(action).indent(by: 2)

                        var stateOutput: String = "🖨️ (No state changes)"
                        if oldState != newState {
                           /// Leverage Difference
                            stateOutput = diff(oldState, newState, indentationType: .pipe, skipPrintingOnDiffCount: true, nameLabels: .comparing).joined(separator: ", ")
                        }

                        debugEnvironment.printer(
                            """
                            🎬 Received action:
                            \(actionOutput)
                            🖨️ State:
                            \(stateOutput)
                            """
                        )
                    }
                },
                effects
            )
        }
    }

This allows you to append debugDiffing(...) call to any reducer that has got equatable state/action pairs.

Conclusion

I believe this reducer to be a significant improvement for a majority of TCA-based applications.

It leverages smaller diffs and adds the ability to easily filter out actions that you are not interested in.

This interface not only leverages a couple of language features which enable convenient DSL and IntelliSense, but also improves the future workflows since the ActionFilter repository grows with each developer.

Full gist is available on my github


Do you like working with TCA-based architecture? The Browser Company is hiring and they are one of the largest projects leveraging this architecture that I've seen.

You've successfully subscribed to Krzysztof Zabłocki
Great! Next, complete checkout to get full access to all premium content.
Error! Could not sign up. invalid link.
Welcome back! You've successfully signed in.
Error! Could not sign in. Please try again.
Success! Your account is fully activated, you now have access to all content.
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.