Sitemap
Tripadvisor Tech

Stories from the Product and Engineering Team at Tripadvisor

The evolution of native engineering at Tripadvisor: Part 1

--

This is the first of a 3 part series on how we’ve evolved our iOS architecture to ensure scalability, testability and added flexibility in delivering features to our customers.

By

In the ever-evolving landscape of iOS development, architectural decisions can make or break a team’s productivity and code quality. For our large team of iOS developers working on a Server Driven UI (SDUI) platform, the Model-View-ViewModel- (MVVM-C) architecture that once served us well began to show significant cracks as our application scaled.

In December 2024, we made the pivotal decision to migrate to The Composable Architecture (TCA), developed by Point-Free. This wasn’t just a technical refactoring — it represented a fundamental shift in how we approach iOS development, particularly for complex applications with sophisticated navigation requirements.

So far, with only a portion of the migration complete, the results have been transformative: our codebase is already more testable, our navigation is predictable, and we have a plan to escape the UIKit dependency that our coordinator pattern enforces. We’ve reduced code volume while increasing robustness, and our SwiftUI transition is now proceeding smoothly without the Combine-based glue code that once dominated our development.

In this post I’ll walk you through our iOS architectural journey thus far: why we made the switch to TCA, how we’re approaching our migration, and the tangible benefits we’re realizing as we progress towards the completion of our migration. I’ll also candidly discuss the challenges we’ve faced — from mindset shifts required for proper view/reducer hierarchy modeling, to performance optimizations needed along the way.

Stick around for part 2 where , Engineering Manager, discusses the evolution of our Server Driven UI platform. In part 3, Andrew Frederick, Principal iOS Engineer, will discuss the technical intricacies of developing a fully flexible UI using TCA.

Why we made the switch from MVVM-C to TCA

When we initially adopted the MVVM-C architecture, it seemed like a logical choice to manage our complex app’s navigation and business logic. However, as our SDUI platform grew and our development team expanded, several critical issues became increasingly apparent.

Navigation entropy

Perhaps the most painful aspect of our MVVM-C implementation is the navigation structure — or more accurately, the lack of one. Our coordinators can launch any other coordinator, creating a web of navigation possibilities that is nearly impossible to document or reason about.

The following diagram is an attempt at mapping the navigation possibilities using our coordinator pattern:

As you can see, it’s really difficult to follow.

The line between where one coordinator ends and another begins is blurry at best. Even our most experienced engineers struggle to trace the execution path when debugging navigation issues. As an example of how complex this process becomes, let’s examine a typical series of events:

  1. Coordinator A creates Coordinator B
  2. Coordinator B creates Coordinator C
  3. Coordinator C creates Coordinator D
  4. Coordinator D pushes a new view onto the screen
  5. Coordinator D maps a view model event and emits a result event
  6. Coordinator C catches the result event, maps, and re-emits a duplicated event
  7. Coordinator B catches the result event, maps, and re-emits another duplicated event
  8. Coordinator A finally catches the final event and performs the navigation

This results in the same event being duplicated over and over across the chain. Worse yet, we are forced to model invalid states into the app, as coordinator event mappers need to map unhandled events by a particular parent coordinator.

At a more granular level, consider an example where an anonymous user is looking at a hotel or restaurant. The user taps on a button to write a review but must be authenticated before doing so. The red arrow describes the flow of execution between coordinators, view models, and event mappers. Can you follow the red arrow?

UIKit dependency

Let me preface this by saying that UIKit isn’t bad by any means. We all agreed that we want to commit long term to SwiftUI as much as possible, however, our coordinator architecture locks us firmly into UIKit controllers. The entire premise of our flows was built on having a reference to a controller that could be passed around to the next coordinator. In practice, this meant passing a UINavigationController subclass to each coordinator’s constructor, allowing it to push new views onto the screen.

This approach is fundamentally incompatible with SwiftUI’s paradigm, where UI is a function of state. Despite talking about migrating to SwiftUI navigation for months, we haven’t been able to make meaningful progress without a significant architectural shift. We need to move from reference-based navigation to state-based navigation.

Development friction and onboarding challenges

The complexity of our architecture creates an immense barrier to entry. I’ve experienced this first hand. New developers take much longer than expected to become productive, and even seasoned team members encounter challenges when working with unfamiliar parts of the codebase.

Finding where a coordinator is launched from is difficult, as it could be launched from anywhere. Understanding the coordinator hierarchy is nearly impossible because it’s truly never consistent. Simple tasks become complex endeavors: hiding tabs from a child view, presenting a web view modally, deep linking to specific content…

The necessity to duplicate code multiple times (for events, mappers, etc.) not only increases development time but also extends build times and makes keeping documentation and diagrams updated an impossible task.

Combine complexity

Our MVVM-C implementation heavily leverages Apple’s Combine framework, which, while powerful, adds another layer of complexity. Debugging Combine-based event chains proves exceptionally difficult, especially when they traverse multiple coordinators and are composed of several layers of publishers and operators.

The asynchronous nature of Combine, mixed with our coordinator chains, creates a perfect storm for bugs that are difficult to reproduce and fix.

What we needed in a solution

As we evaluated alternatives, we identified several key requirements for our new architecture:

  • Simple, predictable navigation that could be centrally controlled and easily traced
  • SwiftUI compatibility to break free from our UIKit dependency
  • Elimination of duplicated events
  • Responder chain-like capabilities
  • Lower barrier to entry for new and existing team members
  • Well-documented, supported architecture with active community and resources
  • Prevention of invalid states to reduce bugs and improve reliability

After evaluating several options, including MVVM-R (Responder), we determined that The Composable Architecture (TCA) best met our requirements.

Why TCA stands out

TCA offers several compelling advantages that directly addresses our pain points:

  • State-based navigation that aligns perfectly with SwiftUI’s paradigm
  • Exhaustive testing capabilities that make it easy to verify complex flows
  • Exhaustive testability of asynchronous side effects
  • Natural “responder-chain like” behaviors through state scoping
  • Built-in async/await support for modern Swift concurrency while fully supporting Combine dependencies
  • Robust documentation and community support for easier onboarding
  • Value types for reducers that eliminate concerns about retain cycles
  • The ability to opt-in at any level of code (or opting out should we decide to move towards another solution)

Perhaps most importantly, TCA’s approach to composition aligns with our need to manage a complex, feature-rich application with many interconnected components. The ability to compose smaller features into larger ones promises a more maintainable codebase with clearer boundaries and responsibilities.

Another compelling factor in our decision is TCA’s evolution and maturity. Having followed TCA since its early iterations, we recognize that its iOS 17+ compatibility perfectly aligns with our forward-looking development roadmap. This means that we can leverage vanilla SwiftUI alongside the new Observation framework — a significant advantage for performance and developer experience.

The introduction of the macro is particularly valuable, as it enables granular state property observation. This feature delivers a natural performance boost by ensuring views only re-render when observed properties change, rather than on every state mutation.

Beyond the technical merits, the quality of support behind TCA is decisive. The Point-Free team maintains exceptional documentation and provides regular updates that incorporate community feedback and Apple’s latest platform advancements. The vibrant community surrounding TCA includes not only passionate developers but also several Apple engineers. Their dedicated Slack workspace offers direct access to both framework authors and experienced practitioners, making troubleshooting and knowledge-sharing remarkably efficient. New team members can ramp up significantly faster thanks to the increased breadth of tutorials and documentation from both Point-Free and the larger community.

TCA’s consistent patterns and explicit state management make it easier to understand how the application works. What previously took weeks now often takes just days.

Let’s take a look at our previous example where an anonymous user wants to write a review for a hotel or restaurant from the perspective of TCA:

Through scoping, we get a natural responder chain-like behaviour. The shared navigation feature can handle any actions sent by any child. There is no need for re-emitting duplicated events. The high-level workflow looks like this:

  1. Review Card Feature sends a button tap action
  2. Shared Navigation Feature handles the action and sets the state for the Authentication Feature
  3. Authentication Feature sends a success action
  4. Shared Navigation Feature handles the action and sets the state for the Review Feature

This is not only fewer steps in code execution but it’s also fully testable.

How we implemented the migration from MVVM-C to TCA

With our decision made, we began planning our transition to TCA.

Transitioning a large-scale application with a large team of iOS developers from an established architecture to a new one is no small feat. Our migration strategy needed to be thoughtful, incremental, and it needed to minimize disruption to any ongoing feature development.

Rather than attempting a risky “big bang” rewrite, we developed a phased migration plan that allowed us to progressively introduce TCA while maintaining our delivery commitments.

Phase 1: Foundation and knowledge building

Before writing a single line of TCA code in our production app, we invested in team-wide education:

  • We organized iOS Guild talks around Point-Free’s TCA tutorials and documentation
  • We built a smaller version of our production app using TCA to serve as reference of how everything would come together
  • We created internal documentation mapping our MVVM-C concepts to TCA equivalents
  • We established coding standards and architectural guidelines specific to our TCA implementation

This foundation-setting was crucial for ensuring consistency as we began the actual migration work. This work was documented and serves as onboarding material for new engineers joining our team.

Phase 2: Leaf views migration

Recognizing the structural parallels between TCA stores and MVVM view models, we adopted a “leaf-to-root” migration strategy:

  1. Start with leaf nodes
    We first identified and migrated view models that had no child view models. These represented the simplest conversion cases, as they required no composition. For each leaf view model, we created an equivalent TCA store with initial state and a corresponding reducer.
  2. Move up the hierarchy
    Once the leaf components were converted, we progressed to their parent view models. These “branch” components now needed to compose with the already-migrated TCA leaf components instead of instantiating child view models.
  3. Repeat the process
    We continued this pattern methodically, moving upward through the hierarchy layer by layer. Each migration reinforced our patterns and accelerated subsequent conversions as the team gained expertise.

This bottom-up approach enables us to maintain a functioning application throughout the migration process. It also provides natural breakpoints where we can pause, validate our work, and ensure that we are maintaining feature parity before proceeding to more complex components.

This disciplined approach gives us the ability to tweak our guidelines and onboarding material while progressively improving code quality.

Phase 3: Core navigation

While the view model layer is still being migrated, we have a plan for migrating our coordinator layer. We’ll be applying a similar “leaf-to-root” strategy but with a crucial difference: instead of replacing each coordinator with a TCA equivalent, we’ll be working towards consolidation.

The high-level strategy looks like this:

  1. Migrate leaf coordinators first
    We begin by identifying leaf coordinators — those that don’t create other coordinators. For each one, we move its navigation responsibilities upstream to its parent coordinator while implementing the equivalent navigation logic in TCA.
  2. Progressively consolidate navigation logic
    As we migrate each coordinator, we systematically consolidate navigation logic in fewer and fewer places. This gradually flattens our previously complex hierarchy.
  3. Create a centralized navigation system
    Eventually, this consolidation process will lead us to a single navigation source of truth — a global router implemented as a TCA reducer. This centralized approach provides several significant advantages:
    a. A unified state representation of the entire navigation structure
    b. Consistent handling of deep links and back navigation
    c. Simplified testing of complex navigation flows
    d. Complete visibility into the application’s navigation state at any moment

This navigation consolidation represents perhaps the most transformative aspect of our migration. Where we currently have dozens of coordinators with overlapping responsibilities and complex interactions, we’ll eventually have a clean, state-based navigation system that’s both more powerful and significantly easier to understand.

Learnings: What we discovered during our migration

Transitioning from MVVM-C to TCA has been a journey filled with valuable insights. Although the migration is still in progress, we’ve already seen many benefits. This work has revealed complexity and performance issues that were previously concealed behind multiple series of Combine publishers.

Mindset shifts required

One of the most significant challenges isn’t technical but conceptual. TCA requires a different mental model, particularly around the following key areas.

State-driven vs. action-driven thinking

In our MVVM-C architecture, we primarily thought in terms of actions: “When this button is tapped, navigate to that screen.” TCA inverts this paradigm — we now think in terms of state: “Setting this property to true presents that screen.” This mental shift was challenging but ultimately led to more predictable code.

Hierarchical modeling considerations

We discovered that modeling feature hierarchies in TCA requires careful consideration. Early in our migration, we made the mistake of mirroring our previous component hierarchy too closely, which exposed some performance issues with our old implementation that were hidden away by Combine pipelines.

A common issue is creating reducer hierarchies that require shared logic where parent reducers are sending child actions into the store, causing too many bidirectional actions sent through long reducer chains. We learned the following lessons:

  • Leveraging child state mutation from parents (e.g. mutate child state from parent)
  • Rearchitecting hierarchies where appropriate to be more judicious about state composition (e.g. share behaviour is often easier when done in a parent)

This also led us to the introduction of state mutators. We decided to lean into the inout state mutation practice of reducers:


struct BaseMutator<State, Output>: Sendable {
var mutate: @Sendable (inout State) -> Output
}

public typealias Mutator<R: Reducer> = BaseMutator<R.State, Void>
public typealias EffectfulMutator<R: Reducer> = BaseMutator<R.State, Effect<R.Action>>

By using mutators, we were able to share mutation logic with multiple reducers, for example updating the count from the child and parent reducer:

extension Mutator<ChildExampleReducer> {
static let increment = Self { state in
state.bool = true
state.count += 1
state.string = "\(state.count)"
}
}


@Reducer
struct ParentExampleReducer {

// rest of code

let incrementMutator: Mutator<ChildExampleReducer> = .increment

var body: some ReducerOf<Self> {
Scope(state: \.child, action: \.child) { ChildExampleReducer() }
Reduce<State, Action> { state, action in
switch action {
case .child:
return .none

case .incrementChild:
incrementMutator.mutate(&state.child)
return .none
}
}
}
}

@Reducer
struct ChildExampleReducer {

// rest of code

let incrementMutator: Mutator<Self> = .increment

var body: some ReducerOf<Self> {
Reduce<State, Action> { state, action in
switch action {
case .increment:
incrementMutator.mutate(&state)
return .none
}
}
}
}

Performance lessons

While TCA brought many performance improvements, it also introduced some new reflections.

Action volume and performance

We encountered performance issues in areas where we were sending too many actions too quickly. For example, in UI elements that needed to respond to rapid user input (e.g. scrolling offsets), naive implementations could send hundreds of actions per second which often caused UI lag.

We developed some best practices to address this:

  • Using debouncing for high-frequency user inputs
  • Being more selective about what constitutes an “action” versus a simple state update through private functions on reducers
  • Making use of mutators when possible

State observation granularity

TCA’s ObservableState macro was a game-changer for performance, but it required us to be more thoughtful about state organization. We are learning to structure our state to take full advantage of granular updates.

This includes:

  • Grouping related properties that typically change together
  • Separating frequently-changing properties from stable ones
  • Using dedicated subtypes such as ViewState (e.g. loading, success, error) for complex state areas

These optimizations dramatically improved our UI responsiveness, particularly in list-heavy screens where performance had previously been a concern.

Testing transformations

Perhaps the most positive surprise came in the area of testing.

From brittle to robust tests

Our MVVM-C tests were often brittle, with complicated mocking hierarchies and unclear assertions. TCA’s testing approach transformed our test suite.

We learned the following from our testing:

  • Tests became more readable and predictable
  • The TestStore made assertions about state changes straightforward and exposed unexpected changes
  • Side effects were explicitly tracked and verified
  • Navigation testing became possible where it was previously impractical and in some cases, simply impossible

We found that tests written with TCA’s TestStore provided much stronger guarantees about application behavior. A test that passes gives us high confidence that the feature works as expected, which wasn’t always true with our previous testing approach, especially with the heavy dependency on Combine and schedulers.

Testing as design feedback

We also discovered that testing in TCA serves as excellent feedback about design quality. When tests become complicated or hard to write, it usually indicates that the feature’s design should be reconsidered. For example, sending an action into a test store resulting in dozens of side effects and dozens of other actions made evident that some design decisions should be revisited. This “testing as design feedback” loop has improved our overall architecture.

Integration challenges

Not everything was smooth sailing. We faced some specific challenges along the way.

Server driven UI complexity

Our SDUI architecture presented unique challenges for TCA integration. The dynamic nature of server-driven components initially seemed at odds with TCA’s more static type-safe approach. We solved this by creating a dedicated subsystem within TCA that could handle dynamic component creation while maintaining type safety. This required some additional abstraction layers but ultimately proved very successful.

We’ll talk more about this in part 2 and part 3 of this blog.

Hybrid architecture growing pains

During the transition period, we’ve had to maintain a hybrid architecture where TCA and MVVM-C components coexist and communicate.

This introduced some friction points, including the following:

  • Stores with inline reducers to communicate with parent view models
  • Two navigation paradigms (state-based vs event-base)
  • Inconsistent patterns across the codebase

While necessary, this hybrid state reinforces our commitment to completing the migration. Each new feature converted to TCA reduces this architectural friction.

Unexpected benefits

Beyond the improvements that we had initially anticipated, we discovered several unexpected benefits.

Reduced code volume

TCA implementations consistently required less code than their MVVM-C counterparts — often 15–30% less.

This reduction came primarily from:

  • Elimination of boilerplate coordinator code
  • Removal of duplicated event handling
  • Simplified navigation logic using SwiftUI navigation when appropriate
  • Simpler state management without Combine’s complexity

As a point of comparison (using our previous example), all the faded elements in the following graph are entirely eliminated:

This code reduction has accelerated our development pace and lowered our maintenance burden.

Embracing our future with TCA

Our journey from MVVM-C to TCA represents more than just a technical migration — it’s a fundamental shift in how we approach iOS development. As we near the completion of our transition, we can already see the transformative impact this architectural change has had on our team, our codebase, and our product.

The road so far

Looking back at where we started, the contrast is striking. What is currently a tangled web of coordinators, with unpredictable navigation paths and duplicated event handling, can now evolve into a clean, predictable, and testable architecture. The pain points that drove us to change — navigation entropy, UIKit dependency, development friction, and Combine complexity — are being systematically addressed through TCA’s structured approach.

The decision to migrate wasn’t taken lightly, but the results have validated our choice.

The migration has benefitted our codebase in the following ways:

  • More robust code with fewer bugs and edge cases
  • Improved test coverage with meaningful guarantees about behavior
  • Faster development cycles despite the learning curve; an investment into our future feature development
  • Better SwiftUI integration breaking our UIKit dependency
  • Clearer architecture that new team members can quickly grasp

Beyond the migration

The architectural clarity that TCA provides will enable us to implement new features more quickly, with higher quality, and with a shortened feedback loop. We expect this to translate directly to improved user experiences and faster delivery of value to our customers.

Would we do it again?

Absolutely. Despite the challenges and the substantial investment of time and resources, the benefits of TCA have far outweighed the costs. If we were starting fresh today, we would choose TCA from the beginning. The initial learning curve may seem steeper, but the long-term benefits to code quality, developer productivity, and product performance make it worthwhile.

The software architecture we choose shapes not just our code but our team’s experience, our product’s quality, and ultimately our users’ satisfaction. In choosing TCA, we’ve invested in a more sustainable foundation for our iOS development — one that will serve us well as we continue to evolve and grow our application.

Our journey continues, but the path forward is clearer than ever before.

In part 2, Mason Pomeroy will dive deeper into our Server-Drive UI platform.

In part 3, will describe how we achieved ultimate flexibility while ensuring type safety in a meticulous breakdown of our FlexibleSection patterns.

Responses (2)