We looked for a navigation pattern that fit modular TCA apps. We didn’t find one, so we built our own.
In a SwiftUI app, navigation is one of the first things that breaks once the project stops being simple. NavigationStack and a few @State booleans work well enough in small projects. But when an app goes modular, features move into separate Swift packages, flows need to be reused, deep links show up, and someone eventually asks how navigation logic gets tested. That’s when the simple patterns stop being enough.
I’m Evgeny Serdyukov, iOS Tech Lead at Manyсhat, and this is the story of how we came up with a SwiftUI navigation architecture that actually works for our modular app, built around TCA.
Navigation challenges in a modular app
In a modular app, features are isolated by design. A feature doesn’t know what screen comes before it or after it — and it shouldn’t. But navigation still has to connect everything somehow. How do you do that without features depending on each other?
When we committed to SwiftUI navigation, we quickly realized the examples wouldn’t take us very far. Neither Apple’s documentation nor most community articles cover navigation in a large modular app. SwiftUI gives you all the navigation primitives you need — stacks, pushes, pops, and sheets — but offers little guidance on how those pieces should fit together in a modular architecture.
The next problem appears when a feature becomes a flow. Imagine a multi-step feature (sub-flows) such as checkout with choosing delivery address, payment method, order confirmation, and payment success screen. Internally, it wants its own navigation stack.
Unfortunately, SwiftUI doesn’t allow nesting a NavigationStack inside another NavigationStack. The usual workaround is to present such flows as sheets, giving them a separate navigation context. That works until design requirements get involved.
Our designers wanted the same flow to be reusable in different contexts: sometimes pushed directly into the current navigation stack, sometimes presented as a sheet. The implementation shouldn’t care which presentation style was chosen.
TCA adds another layer of complexity. In TCA, navigation isn’t owned by SwiftUI — it’s part of the feature’s state and managed by reducers. In a modular app, every complex feature has its own reducer, and naturally wants to own its own stack. But that runs straight into SwiftUI’s single-stack limitation.
By this point, we had a fairly concrete set of requirements.
| # | Requirement | Why |
|---|---|---|
| R1 | Inline push instead of a modal | Per-screen swipe-back, host navigation bar stays visible. |
| R2 | One Feature per sub-flow | Three-step Checkout is one reducer, not three. |
| R3 | Host doesn't know sub-flow internals | Adding a step inside Checkout mustn't touch the host. |
| R4 | Sub-flow doesn't know its host | Same Checkout binary plugs into any host. |
To explain the solution, I’ll use a simplified checkout flow: Home → Checkout → Payment → Receipt. The full example is on GitHub — a small but complete app with real features, deep links, modal presentations, and tests
Alternatives considered
-
Canonical TCA StackState
TCA has a built-in navigation system. The standard approach is to declare a StackState inside the host reducer — a typed list of everything that can be pushed onto the stack:
@Reducer struct ShopFlow {
struct State { var path = StackState<Path.State>() }
@Reducer enum Path {
case checkout(CheckoutRootFeature)
case payment(PaymentFeature)
case receipt(ReceiptFeature)
}
}
Advancing the sub-flow is straightforward:
state.path.append(.payment(.init()))
At first glance, this looks exactly like what we need.
The problem is that Path lives in the host — ShopFlow. Then the host must enumerate every screen that can ever appear on the stack, including the internal screens of nested sub-flows. Add a step inside Checkout, and you’re editing ShopFlow.
A three-step sub-flow becomes three separate reducers with delegate handoffs between them. The host knows every internal transition and every screen.
For us, that broke the purpose of modularity.
2. TCACoordinators (johnpatrickmorgan)
TCACoordinators brings the Coordinator pattern to TCA and is probably the most commonly suggested solution for complex navigation. It offers two ways to handle sub-flows
Option A: present sub-flow as sheet or full-screen covers. This keeps sub-flows isolated, but they can no longer participate in the parent’s navigation stack. Users can’t navigate through the sub-flow using the same push-based experience, and swipe-back behavior changes.
For us, that violated the requirement that a flow must be reusable both as a push and as a sheet.
Option B: flatten steps into the parent’s screen enum.
This removes the sheet limitation, but puts us back in the same situation as the canonical StackState
The modularity problem returns.
3. Global AppPath enum
Another common approach is to define a single navigation enum at the application root.
To compile, Hub module must depend on EVERY feature in the app at once:
public enum AppPath {
case checkout(CheckoutFeature)
case payment(PaymentFeature)
case receipt(ReceiptFeature)
…all other navigation path cases
}
Any host can append any case.
This feels flexible at first, but the flexibility is an illusion. The enum must import every feature, and every feature depends on the enum — the dependency arrows stop pointing one way and the module boundary collapses. Every new screen edits the same file, turning it into a chokepoint for merge conflicts and full-app rebuilds. Worse, ownership leaks upward: the knowledge that payment leads to receipt now lives at the root, not inside checkout. Different name, same shape — this is the god router we set out to avoid.
| Alternative | R1 inline push | R2 single Feature | R3 host-blind | R4 sub-flow blind |
|---|---|---|---|---|
| Canonical StackState<Path> | ✅ | ❌ | ❌ | ❌ |
| TCACoordinators (sheet) | ❌ | ✅ | ✅ | ✅ |
| TCACoordinators (flattened) | ✅ | ❌ | ❌ | ❌ |
| Global AppPath | ✅ | ❌ | ❌ | ❌ |
We explored six to eight variations in total. These three represent the main architectural archetypes. Each solved part of the problem, but every one came with a tradeoff we weren’t willing to accept. So we built our own.
The solution: the flow pattern
We didn’t invent this from scratch. The foundation is the Coordinator pattern, originally described by Soroush Khanlou in 2015: a dedicated object owns navigation logic while features emit events.
We call it a Flow rather than a Coordinator for two reasons.
First, to avoid confusion with TCACoordinators, which implements a different navigation model.
Second, a Flow is more than a classic coordinator. It’s a full TCA reducer that owns the child state and can mutate it directly. A traditional Coordinator decides which screen is shown. A Flow also decides what data that screen starts with and can update that data at any point through the normal TCA state tree.
The architecture is built on three primitives:
- Shared — a reference-semantic, lock-protected shared value provided by swift-sharing. The host flow owns one instance and passes it down to child features.
- Path enums — typed hashable values pushed into the NavigationStack path. Each feature declares its own.
- .navigationStackContext — a custom TCA reducer modifier that handles push, cleanup-on-pop, and effect cancellation in one place.
The key idea is simple: navigation is a shared state, not imperative routing. A feature navigates by mutating its own state; the shared stack reflects that change, and .navigationStackContext keeps the two in sync — in both directions — automatically. No feature ever knows the whole map; it only owns its own slice of it.
Before diving into the primitives, it helps to see where everything lives. Here’s how this maps to an actual project structure:
App/
Packages/
├── ThirdParty/ ← TCA wrapper
├── Core/ ← NavigationState, navigationStackContext DSL
├── Features/ ← Isolated feature reducers + views
└── Flows/ ← Navigation composition layer
Dependency arrows flow in one direction:
App → Flows → Features → Core → ThirdParty.
Features don’t import Flows. Flows don’t know how features are presented inside themselves.
Here is how flows (navigation layer) interact with features:
Solid arrows represent state ownership: parent reducers own child state and pass down the shared navigation stack.
Dashed arrows represent delegate actions: child features report meaningful events, and the parent decides what to do next.
Features never talk to each other directly. Everything goes through the Flow above them.
NavigationState
SwiftUI’s built-in NavigationPath is opaque. You can push and pop values, but you can’t inspect what’s currently on the stack. Real navigation logic often needs that visibility: to pop to an anchor, check whether a screen is already pushed, or validate deep-link state.
NavigationState wraps NavigationPath with a parallel [AnyHashable] array, making the stack fully inspectable:
public struct NavigationState: Equatable, @unchecked Sendable {
private var _path = NavigationPath()
public private(set) var items: [AnyHashable] = []
public var path: NavigationPath {
get { _path }
set {
// Called by SwiftUI on back-swipe — trim items to match
let diff = _path.count - newValue.count
if diff > 0 { items.removeLast(min(diff, items.count)) }
_path = newValue
}
}
public mutating func append<V: Hashable & Sendable>(_ value: V) {
_path.append(value)
items.append(AnyHashable(value))
}
/// Pop everything after the most recent occurrence of `value`; anchor stays.
public mutating func popTo<V: Hashable & Sendable>(_ value: V) { ... }
/// Pop the anchor and everything after it.
public mutating func popPast<V: Hashable & Sendable>(_ value: V) { ... }
}
The implementation maintains a single invariant:
path.count == items.count
The path setter accepts only shorter writes — that’s SwiftUI signaling a back-swipe. Everything else goes through append.
Typed path enums
Every feature that participates in navigation declares its own path enum:
public enum CheckoutPath: Hashable, Sendable {
case checkout
case payment
case receipt
}
This enum is the feature’s public navigation contract.
The host Flow knows that CheckoutPath exists, and nothing more.
Internal screens are represented as enum cases. Adding a new screen to the checkout flow means adding a new case and updating the destinations inside the same feature package. The host remains unchanged.
The navigationStackContext
This is the piece that ties everything together and makes everything declarative. You declare your navigable children once:
// Inside CheckoutFeature.body:
.navigationStackContext(nav: \.$nav, pathAction: \.pathDidChange) { context in
context.ifLet(\.payment, action: \.payment, path: CheckoutPath.payment) {
PaymentFeature()
}
context.ifLet(\.receipt, action: \.receipt, path: CheckoutPath.receipt) {
ReceiptFeature()
}
}
What this one modifier buys you:
1. Auto-push. When state.payment changes from nil to a value, the modifier automatically appends CheckoutPath.payment to the shared navigation stack — nav.items.
No manual navigation mutations:
state.$nav.withLock {
$0.append(.payment)
}
2. Cleanup on pop. When the user swipes back, SwiftUI emits .pathDidChange(newPath). The modifier checks whether CheckoutPath.payment is still present in the navigation stack. If not, it sets state.payment = nil. TCA’s ifLet then automatically cancels any in-flight payment effects.
3. Programmatic pop. Calling state.$nav.withLock { $0.popPast(.payment) } updates the navigation stack. The modifier observes the change and triggers the same cleanup flow as a user-initiated back swipe.
The result is a clear separation of responsibilities: reducers manage state, while navigationStackContext manages navigation synchronization and cleanup.
Inside navigationStackContext
The DSL is built from two lower-level primitives composed together:
- ifLetNavigated. It extends TCA’s standard .ifLet with navigation awareness:
func ifLetNavigated<ChildState, ChildAction, Path, Child>( _
child: WritableKeyPath<State, ChildState?>,
action childAction: CaseKeyPath<Action, ChildAction>,
nav: KeyPath<State, Shared<NavigationState>>,
path: Path,
syncOn syncAction: CaseKeyPath<Action, NavigationPath>,
relayTo childSyncAction: CaseKeyPath<ChildAction, NavigationPath>? = nil,
@ReducerBuilder<ChildState, ChildAction> then childReducer: () -> Child
) -> some Reducer<State, Action>
It keeps the child state and navigation state synchronized.
Specifically, it:
- Watches the syncAction (path changes). If the child’s path tag disappears from nav.items, it sets the child state back to_ nil_.
- Watches state transitions from nil to a value and automatically pushes the corresponding path tag to nav.items.
- Optionally forwards navigation changes to the child via childSyncAction. This is the relayTo mechanism for nested sub-flows.
2. observingNavChanges. Whenever the navigation stack shrinks — whether because of a back swipe or a programmatic pop — it re-dispatches pathAction:
func observingNavChanges(
nav: KeyPath<State, Shared<NavigationState>>,
pathAction: CaseKeyPath<Action, NavigationPath>
) -> some Reducer<State, Action>
This creates a single navigation event stream for all navigation mutations. Whether the user swiped back or a reducer called popPast, navigation follows the same cleanup path.
navigationStackContext — a result builder DSL — composes the primitives above.
It collects _NavChild declarations, applies ifLetNavigated to each one, and wraps the result with observingNavChanges:
func navigationStackContext(
nav: KeyPath<State, Shared<NavigationState>>,
pathAction: CaseKeyPath<Action, NavigationPath>,
@_NavigationBuilder _ build: (NavigationStackContext<State, Action>) -> [_NavChild<State, Action>]
) -> some Reducer<State, Action> {
let context = NavigationStackContext(nav: nav, pathAction: pathAction)
let children = build(context)
var result: any Reducer<State, Action> = self
for child in children {
result = child.apply(result) // chain ifLetNavigated for each child
}
return _AnyReducerBox(base: result)
.observingNavChanges(nav: nav, pathAction: pathAction)
}
Flows: the composition layer
A Flow is a reducer that owns Shared and coordinates navigation between features. Here’s ShopFlow from the diagram above:
@Reducer
public struct ShopFlow {
@ObservableState
public struct State: Equatable, Sendable {
@Shared(value: NavigationState()) public var nav
public var checkout: CheckoutFeature.State?
public var home = ShopHomeFeature.State()
}
public enum Action {
case checkout(CheckoutFeature.Action)
case home(ShopHomeFeature.Action)
case openURL(URL)
case pathDidChange(NavigationPath)
}
public var body: some ReducerOf<Self> {
Scope(state: \.home, action: \.home) { ShopHomeFeature() }
Reduce { state, action in
switch action {
case let .home(.delegate(.buyButtonTapped(productID))):
// Just assign state — navigationStackContext auto-pushes
state.checkout = CheckoutFeature.State(
nav: state.$nav,
productID: productID
)
return .none
case .checkout(.delegate(.finished)):
state.$nav.withLock { $0.popPast(CheckoutPath.checkout) }
return .none
case let .openURL(url):
guard let route = ShopDeepLinkParser.parse(url) else { return .none }
// ...
return .none
case .checkout, .home, .pathDidChange:
return .none
}
}
.navigationStackContext(nav: \.$nav, pathAction: \.pathDidChange) { context in
context.ifLet(
\.checkout,
action: \.checkout,
path: CheckoutPath.checkout,
relayTo: \.pathDidChange
) {
CheckoutFeature()
}
}
}
}
ShopFlow doesn’t know that Checkout contains a Payment screen. It doesn’t know that Checkout eventually shows a Receipt screen. It doesn’t even know how many steps the flow contains.
The only thing it knows is that CheckoutPath.checkout is the entry point.
When home signals that the user tapped Buy, ShopFlow assigns CheckoutFeature.State and passes down its shared navigation stack. navigationStackContext auto-pushes the first checkout screen.
From that point on, everything inside CheckoutFeature is opaque to the host. The host Flow sees the flow as a single destination, regardless of how many screens live behind it.
The relayTo: .pathDidChange forwards path changes from ShopFlow into CheckoutFeature’s own navigationStackContext, so that Checkout’s internal screens get proper cleanup when the user swipes all the way back.
The view layer
ShopFlowView’s job is simply to connect SwiftUI’s NavigationStack to the Flow’s shared navigation state and register feature destinations. Here’s the root view for our example flow:
public struct ShopFlowView: View {
@SwiftUI.Bindable var store: StoreOf<ShopFlow>
public var body: some View {
NavigationStack(path: $store.nav.path.sending(\.pathDidChange)) {
ShopHomeView(store: store.scope(state: \.home, action: \.home))
.checkoutDestinations(store: store.scope(state: \.checkout, action: \.checkout))
}
}
}
.sending(.pathDidChange) is the bridge between SwiftUI.NavigationStack and TCA. When the user swipes back, SwiftUI writes a shorter NavigationPath into the binding. .sending converts that into a .pathDidChange action and sends it through the reducer. That’s what triggers navigationStackContext’s cleanup.
checkoutDestinations is a View extension defined inside CheckoutFeature — the feature owns its destination registration:
public extension View {
func checkoutDestinations(store: StoreOf<CheckoutFeature>?) -> some View {
navigationDestination(for: CheckoutPath.self) { path in
if let store {
switch path {
case .checkout:
CheckoutView(store: store)
case .payment:
if let s = store.scope(state: \.payment, action: \.payment) {
PaymentView(store: s)
}
case .receipt:
if let s = store.scope(state: \.receipt, action: \.receipt) {
ReceiptView(store: s)
}
}
}
}
}
}
The host Flow attaches checkoutDestinations, but it doesn’t know how Checkout routes internally. The routing logic lives inside the feature package.
Adding a new Checkout screen means updating CheckoutPath and checkoutDestinations. Both changes happen inside the CheckoutFeature package. ShopFlowView doesn’t change.
One more thing is worth mentioning. navigationDestination(for:) must be attached to the root view managed by the NavigationStack, not to one of the pushed destination views.
SwiftUI scopes destination registrations to the subtree of the view that declares them. Registering destinations inside a pushed screen can lead to routes that are ignored or resolved inconsistently.
For that reason, all destination registration in this architecture happens at the stack root, while the destination definitions themselves remain owned by the corresponding feature packages.
Deep linking
Deep links are handled entirely in the Flow layer, where navigation state lives:
case let .openURL(url):
guard let route = ShopDeepLinkParser.parse(url) else { return .none }
switch route {
case let .checkout(productID):
state.checkout = CheckoutFeature.State(nav: state.$nav, productID: productID)
return .none
case let .checkoutPayment(productID):
state.checkout = CheckoutFeature.State(nav: state.$nav, productID: productID)
return .send(.checkout(.deepLinkToPayment))
case let .receipt(orderID):
state.checkout = CheckoutFeature.State(nav: state.$nav, productID: "unknown")
return .send(.checkout(.deepLinkToReceipt(orderID: orderID)))
}
The parser itself is a pure function and can be tested independently of the rest of the navigation system:
public enum ShopDeepLinkParser {
public static func parse(_ url: URL) -> ShopDeepLink? {
let segments = pathSegments(from: url)
// shop://checkout/{productID}
if segments.count == 2, segments[0] == "checkout" {
return .checkout(productID: segments[1])
}
// shop://checkout/{productID}/payment
if segments.count == 3, segments[0] == "checkout", segments[2] == "payment" {
return .checkoutPayment(productID: segments[1])
}
// https://example.com/receipt/{orderID}
if segments.count == 2, segments[0] == "receipt" {
return .receipt(orderID: segments[1])
}
return nil
}
}
Since navigation state is plain data, handling a deep link is no different from any other flow entry. The Flow just builds the right state and sends the actions. The only view-layer piece is forwarding the URL:
struct ContentView: View {
@State private var store = Store(initialState: ShopFlow.State()) {
ShopFlow()
}
var body: some View {
ShopFlowView(store: store)
.onOpenURL { store.send(.openURL($0)) }
}
}
Everything past that — parsing, building state, pushing the stack — is pure reducer logic. Which is exactly why deep-link tests need no simulator.
Modal presentations
Modals — sheets and alerts — use TCA’s @Presents, and a key principle holds: a presentation lives with the feature that raises it, the host never touches it.
CheckoutFeature owns a promo-code sheet:
@ObservableState
public struct State: Equatable, Sendable {
@Presents public var promoCode: PromoCodeFeature.State?
// ...
}
A sheet is presented by assigning state, and dismissed by setting it back to nil:
case .promoCodeButtonTapped:
state.promoCode = PromoCodeFeature.State()
return .none
case let .promoCode(.presented(.delegate(.applied(code)))):
state.appliedPromoCode = code
state.promoCode = nil // dismiss the sheet
return .none
PaymentFeature owns the alert. It’s the one that knows a payment failed. The host never sees the alert:
case .failedButtonTapped:
state.alert = AlertState {
TextState("Payment failed")
} actions: {
ButtonState(action: .retryPayment) { TextState("Try again") }
ButtonState(role: .cancel, action: .cancelPayment) { TextState("Cancel") }
} message: {
TextState("Your payment could not be processed.")
}
return .none
case .alert(.presented(.cancelPayment)):
return .send(.delegate(.cancelled)) // tell the host; it pops the stack
Both are wired with .ifLet, each inside its own feature’s body:
// inside CheckoutFeature
.ifLet(\.$promoCode, action: \.promoCode) { PromoCodeFeature() }
// inside PaymentFeature
.ifLet(\.$alert, action: \.alert)
Push navigation and modal presentations are symmetric: both are optional states that TCA manages via .ifLet. The only difference is whether you route through NavigationStack or a .sheet / .alert modifier. And just like push navigation, a modal stays encapsulated in the feature that owns it — PaymentFeature decides to alert; CheckoutFeature only learns the user cancelled via a delegate action, then pops the stack.
What a feature needs to be a sub-flow
A feature only needs a few things to become a reusable sub-flow that can participate in any host’s navigation stack.
- It defines a typed path enum describing the screens it can navigate to: A *Path enum containing Hashable & Sendable cases for the feature’s screens.
- It defines its own destination registration : A *Destinations(store:) view extension that registers navigationDestination(for: *Path.self) and maps path cases to views.
- It accepts a navigation stack from its host: State.init(nav: Shared), with a default Shared(value: NavigationState()) value so the feature can also run standalone in a sheet.
If the features contain nested navigation, it also:
- Declares case pathDidChange(NavigationPath) in Action
- Uses .navigationStackContext in its body.
- The parent then passes relayTo: .pathDidChange to forward path changes inward.
The feature lives in Features/, not Flows/. It doesn’t own NavigationState, it uses one passed in from outside.
Here’s what that looks like in practice:
@Reducer
public struct CheckoutFeature {
@ObservableState
public struct State: Equatable, Sendable {
@Shared public var nav: NavigationState // shared with host
public var payment: PaymentFeature.State?
public var receipt: ReceiptFeature.State?
public var productID: String
// ...
}
public enum Action {
case continueButtonTapped
case deepLinkToPayment
case pathDidChange(NavigationPath) // receives path changes from host
case payment(PaymentFeature.Action)
case receipt(ReceiptFeature.Action)
case delegate(Delegate)
// ...
}
}
The @shared reference means CheckoutFeature and its host share the same NavigationState. When Checkout pushes .payment, the host’s NavigationStack sees the change immediately. The default value in init means the same feature works as a sheet.
Testing
TCA’s TestStore makes navigation logic straightforward to test. The entire stack is inspectable via nav.items:
func test_buyButtonPushesCheckout() async {
let store = TestStore(initialState: ShopFlow.State()) { ShopFlow() }
store.exhaustivity = .off
await store.send(.home(.buyButtonTapped))
await store.skipReceivedActions()
XCTAssertEqual(store.state.checkout?.productID, "coffee-grinder")
XCTAssertTrue(store.state.nav.items.contains { ($0 as? CheckoutPath) == .checkout })
}
func test_backSwipeCleansPaymentButKeepsCheckout() async {
let store = TestStore(initialState: ShopFlow.State()) { ShopFlow() }
store.exhaustivity = .off
await store.send(.home(.buyButtonTapped))
await store.skipReceivedActions()
await store.send(.checkout(.continueButtonTapped))
// Simulate swipe-back to checkout (remove payment from path)
var path = NavigationPath()
path.append(CheckoutPath.checkout)
await store.send(.pathDidChange(path))
await store.skipReceivedActions()
XCTAssertNotNil(store.state.checkout) // checkout stays
XCTAssertNil(store.state.checkout?.payment) // payment cleaned up
XCTAssertEqual(
store.state.nav.items.compactMap { $0 as? CheckoutPath },
[.checkout]
)
}
func test_deepLinkToPaymentBuildsFeatureAndStack() async {
let store = TestStore(initialState: ShopFlow.State()) { ShopFlow() }
store.exhaustivity = .off
await store.send(.openURL(URL(string: "shop://checkout/espresso-machine/payment")!))
await store.skipReceivedActions()
XCTAssertEqual(store.state.checkout?.productID, "espresso-machine")
XCTAssertNotNil(store.state.checkout?.payment)
XCTAssertEqual(
store.state.nav.items.compactMap { $0 as? CheckoutPath },
[.checkout, .payment]
)
}
func test_receiptCloseFinishesFlow() async {
let store = TestStore(initialState: ShopFlow.State()) { ShopFlow() }
store.exhaustivity = .off
await store.send(.openURL(URL(string: "https://example.com/receipt/order-42")!))
await store.skipReceivedActions()
await store.send(.checkout(.receipt(.closeButtonTapped)))
await store.skipReceivedActions()
XCTAssertNil(store.state.checkout)
XCTAssertTrue(store.state.nav.items.isEmpty)
}
You can test deep links, back-swipes, alert interactions, and multi-screen flows without ever launching a simulator. Navigation logic is just state transformation.
Conclusion
This is a niche solution.
If you’re building a small app or a team of two, the canonical StackState
The cost is real: roughly 150 lines of custom infrastructure in Core. In return:
- Features are isolated reducers that emit delegate actions. They don’t know what comes before or after them.
- Flows own Shared, compose features, and route delegate actions between them.
- navigationStackContext handles push, cleanup, and effect cancellation automatically.
- Deep links are just state construction and actions — no different from any other flow entry.
- Navigation logic is plain state, so tests need no simulator.
Most importantly, sub-flows remain self-contained.
We pushed the same CheckoutFeature binary into three different hosts without touching it once. Our designers get their animations. A change inside any feature doesn’t touch Flows or App — the compiler sees the boundary and rebuilds only what changed.
In practice, incremental builds run in roughly 2 seconds.
Enjoy this kind of problem-solving? Come build cool things with a great team — join Manychat.


Top comments (0)