# FOCUS Architecture — Chapter-13: The Orchestrator: Event In, State Out - **Source**: /library/FOCUS Architecture/source-file.pdf - **PDF pages**: 331–385 - **Pages without text**: none --- The Orchestrator: Event In, State Out In this chapter, you’ll: write the tab’s orchestrator that receives AddItem and publishes the right sequence of states, with no business rule tucked inside; test the event-to-states flow without booting a single screen, with a test that breaks the moment the publish order flips; point, inside someone else’s orchestrator, at the exact line where a rule snuck in where only a connection belonged. You’ve chased a total that flickered wrong on one screen and right on another, unable to say who changed the value. The problem wasn’t the value: it was the number of paths leading to it. This chapter builds the piece that narrows those paths down to one: the Orchestrator, the box chapter 12 left closed. An event goes in at the top, a state comes out at the bottom, and nothing else happens. Chapter 12 ended with the dumb screen firing TabEvent into a dashed box and getting TabData back. That box’s contract has been signed since chapter 10, on the Orchestrator’s line of the canonical table: Orchestrator Contract Does converts event to state Does fetches data from the repository Does calls use cases Does publishes state Forbids deciding rules Forbids persisting Four verbs and two bans. Each one deserves its own paragraph, because every word in that line was paid for with somebody’s bug. “Converts event to state” is the job description in one line. The Orchestrator is a translation function with effects in the middle: a gesture named after the business goes in ( AddItem ), and something the screen draws without thinking comes out. It never returns raw data, never returns an exception, never returns “void and good luck.” “Fetches data from the repository” says where the raw material comes from. In this chapter’s tab, the conversation is a question and an answer: the Orchestrator asks for table 4’s tab, the repository hands back table 4’s tab, done. That’s not a ban on streams. When the data changes by someone else’s hand (another waiter writing to the same tab on a local database, a GraphQL subscription), the repository is free to expose a stream, and the Orchestrator is the one who subscribes to it and translates every emission into state. What decides the contract is the source: a local database and GraphQL know how to announce that something changed; a REST or RPC API has no way to, and faking reactivity on top of either turns into polling wearing a disguise. Chapter 15 shows when each contract fits. “Calls use cases” points at whoever knows the rules. The Orchestrator hands over the tab it fetched and the event’s item to the use case and waits for a verdict. It never second-guesses the verdict. If the loyalty discount changed, if the item dropped off the menu, if the tab hit some cap: none of that is the Orchestrator’s problem. “Publishes state” closes the cycle, and hides the line’s most important word: publishes. The screen doesn’t ask; it subscribes. Wherever reactivity exists in the system, this is where it lives: the Orchestrator is the sole owner of the channel that carries state down to the View, and that’s exactly why chapter 12’s screen could stay so dumb. The bans close the back doors. “Deciding rules” is forbidden because a rule already has its own house (the use case, chapter 14), and a rule outside that house turns into a copy, the same way chapter 12’s three screens grew three totals. “Persisting” is forbidden because saving data is the repository’s trade (chapter 15), and an orchestrator that saves on its own opens a second write path nobody audits. The Orchestrator knows when and where to. It never knows what. Where the one-way flow came from This discipline has a birth certificate, and it helps to remember why each ban exists. In 2014, Facebook lived with a bug every user of the era saw: the notification counter at the top of the page announced a new message, you opened the chat, there was no message at all, and a few minutes later the counter accused a phantom again. Engineering fixed it, and the bug came back in the next release. The cause, told by Facebook’s own team at the Flux talk (F8 conference, 2014) and retold on the “Prior Art” page of the Redux documentation: models and views updating each other, every screen holding write permission on the state the others read. Nobody could answer “who changed this value?”, because the answer was “anyone.” Facebook’s fix was radical: ban scattered writes and force every state change through a single path, always running the same direction. That design has a name: unidirectional data flow is the architecture where state changes through only one path, always in the same direction: the interface fires an intent, a single responsible party processes it and publishes the new state, the interface renders whatever arrived. No shortcuts, no side writes, no screen talking to screen. The idea wasn’t born at Facebook, and it didn’t die there either. It got rediscovered so many times, by people solving the same problem, that each generation gave it a new name. The five stops are worth knowing, because you’ll meet all five names in job listings, and they’re all the same design. Elm came first. Around 2013, Evan Czaplicki structured the Elm Architecture: every program is a Model , an update function that takes a message and returns the new model, and a view that draws the model. The official Elm guide (guide.elm- lang.org/architecture) still presents it today as the pattern the others derived from. The problem it solved: in Elm there’s no mutation, so “who changes the state?” has to have a single answer by construction. Flux came in 2014, out of the counter bug: Facebook needed an answer to “who changed this value?” across a giant JavaScript codebase. Actions, a dispatcher, and stores that are the sole owners of state. Flux was the one that popularized the slogan this chapter keeps repeating: data flows in one direction. Redux, in 2015, is Dan Abramov and Andrew Clark simplifying Flux “following Elm’s lead,” in the documentation’s own words (redux.js.org, “Prior Art”): one store, pure reducers, immutable state. The problem it solved was ergonomics: the original Flux had too many moving parts, and Redux proved three were enough. MVI (Model-View-Intent) formalized the same cycle as an observable flow. André Staltz described the whole family in “Unidirectional User Interface Architectures” (2015, staltz.com), showing that Elm, Flux, Redux, and his own Cycle.js were variations on one cycle; the Android community adopted the name MVI for that variation. The problem it solved: giving the cycle a reactive shape, where even the user’s intent is a stream. BLoC closes the lineage in 2018. Paolo Soares and Cong Hui, from Google, presented the Business Logic Component at DartConf 2018, in a talk about sharing code between Flutter and AngularDart: events go in through one stream, states come out through another, and nothing else crosses the boundary. The problem it solved: the same logic serving two different interfaces without a rewrite. Five names, one design: event goes in, state comes out, and state changes through one path only. FOCUS’s Orchestrator is that design’s seat in the canonical table, with one detail the rest of this chapter explores: in FOCUS, the “single responsible party” connects, but the use case decides. The anti-solution: the orchestrator that decides Like every chapter in this part, the right path starts with the wrong one. Two pains, in the table line’s two bans. The first pain is a rule sneaking in. The orchestrator below receives the event, fetches, calls the use case, and publishes: all correct, until the middle of the switch: Dart // The anti-solution: the right cycle, with a rule smuggled into the // middle. class TabOrchestrator extends Bloc { TabOrchestrator(this._repository, this._loyaltyPoints) : super(Loading()) { on(_onAddItem); } final TabRepository _repository; final int _loyaltyPoints; Future _onAddItem( AddItem event, Emitter emit, ) async { emit(Loading()); final lookup = _repository.findTab(event.table); switch (lookup) { case InfraFailure(): emit(Failed("no connection")); case TabFound(:final tab): final result = addItemToTab(tab, event.item); switch (result) { case Success(:final value): // The smuggled rule: the loyalty discount computed right // here, comparing business data in the middle of a wire. var total = value.totalInCents; if (_loyaltyPoints >= 100) { total = total * 90 ~/ 100; } emit( Ready( toData( Tab(value.table, value.items, total, value.canPay), ), ), ); case RuleViolated(:final reason): emit(Failed(reason)); } } } } The if works, and that’s the danger. The 100-point cutoff and the 10% markdown now live in two places: in the use case chapter 14 is going to write, and in this switch. The day Rosie changes the discount, one of the two falls behind, and the app starts showing one total at the register and another in the kitchen. You already watched this movie in chapter 12; the difference is that the copy isn’t sitting on a screen this time, it’s sitting in the middle of the wire, which is harder to catch in code review. The second pain is the local edition of Facebook’s bug. Two screens show the total of the same tab, and someone solved the sharing problem the fast way: a global mutable object. Dart // Pain (b): the mutable state two screens share. Each screen writes // straight into the total; neither knows what the other did. class SharedState { int totalInCents = 0; int loyaltyPoints = 0; } final globalState = SharedState(); // The waiter's screen adds the item straight into the global state... void waiterScreenAddsItem(int priceInCents) { globalState.totalInCents += priceInCents; } // ...and the cashier's screen applies the discount on its own. Run it // twice and the discount lands twice: the total flickers wrong on the // other screen. void cashierScreenAppliesDiscount() { if (globalState.loyaltyPoints >= 100) { globalState.totalInCents = globalState.totalInCents * 90 ~/ 100; } } Trace the flow slowly. The waiter adds an item: their screen writes to the total. The cashier opens the payment screen: it applies the discount and writes to the same total. The waiter adds another item: their screen sums on top of a value that’s already discounted. The cashier refreshes: discount again, now doubled. The total flickers wrong, right, wrong, and neither snippet has an isolated bug; the bug is the scattered write permission. It’s Facebook’s notification counter at coffee-shop scale, and the fix is the same one from 2014: cut every write path but one. Both pains share a diagnosis, and it deserves a name. Connect vs. decide is the test that separates what the Orchestrator does from what it hands off: connecting means knowing when to act and where to send it ( AddItem arrived, fetch the tab, call the use case, publish the result); deciding means knowing what the rule says (100 points, 10%, an item off the menu). Every line inside an orchestrator answers one of those two questions. If it answers “what,” it’s in the wrong place. Events and states as sealed classes The refactor starts with the types, and the types are the half you already have. TabEvent came ready-made from chapter 12, and its spelling doesn’t change again. Chapter 13’s new piece is the type the Orchestrator publishes: a sealed hierarchy of states. Dart · Kotlin // The View's only output: business-named events, spelled the way // chapter 12 fixed them. sealed class TabEvent {} final class AddItem extends TabEvent { AddItem(this.table, this.item); final int table; final String item; } final class RemoveItem extends TabEvent { RemoveItem(this.table, this.item); final int table; final String item; } final class PayTab extends TabEvent { PayTab(this.table); final int table; } // What the Orchestrator publishes: the cycle's state, new in chapter 13. sealed class TabState {} final class Loading extends TabState {} final class Ready extends TabState { Ready(this.data); final TabData data; } final class Failed extends TabState { Failed(this.message); final String message; } Kotlin writes the same contract with sealed interface and data class , one line per type: data class Ready(val data: TabData) : TabState . Same names, same shape, half the lines. Notice that TabState doesn’t replace TabData ; it wraps it. Chapter 12’s renderable data survives intact as Ready ’s payload, and the other two states exist because the real cycle has time and failure: between the waiter’s tap and the finished data there’s Loading , and when the use case says no there’s Failed with a message ready to show. The screen renders the state with a three-armed switch, and the compiler guarantees no arm is missing. This type already showed up once, under another name and a smaller frame. Chapter 10 called the value the Orchestrator published TabResult , and reused that same type in two other spots: the use case’s return and the repository’s return. That fit on one page because it was a sketch, and from here on the three things split apart, each one wearing its own trade’s name. What the screen subscribes to is this TabState . The use case’s verdict is a Result (chapter 14). The repository’s answer is a LookupResult (chapter 15). The sketch also had no Loading , because it had no time: nobody was waiting on anything, and now somebody is. Keep this listing’s size in mind, because it’s an answer. The most repeated criticism against Redux, MVI, and their relatives is boilerplate: “a class for every event, one for every state, too much ceremony.” The entire tab’s set of events and states just fit on half a page, and every one of those classes buys something no annotation can: the compiler now knows every possible case. The criticism section returns to this point with sources; for now, the Try It below is worth more than the argument. Try it: open https://focus.kodel.com.br/en/dart/13-01 (or https://focus.kodel.com.br/en/kotlin/13-01) and add a new state to the hierarchy: final class Cancelled extends TabState {} . Prediction: the code doesn’t even run; the compiler flags the switch in renderState as non-exhaustive, naming the missing variant. In Kotlin, do the same with a new event in the orchestrator’s when : the error is identical. That free warning, at every point in the system that consumes the type, is what the “extra” classes buy. The other eight languages live at /en/ts/13-01 , /en/java/13-01 , /en/csharp/13-01 , /en/go/13-01 , /en/php/13-01 , /en/python/13-01 , /en/swift/13-01 , and /en/rust/13-01 . The complete orchestrator With the types in place, the orchestrator that only connects fits in one class. In Dart, the implementation uses package:bloc , the pattern’s dominant library in the Flutter ecosystem; the book’s prose keeps saying “orchestrator,” because chapter 10 already showed that BLoC, ViewModel, and Store are the same job under different badges. Dart // The orchestrator only connects: receives the event, fetches, calls // the use case, publishes the state. No rule, no persistence. class TabOrchestrator extends Bloc { TabOrchestrator(this._repository) : super(Loading()) { on(_onAddItem); on(_onRemoveItem); } final TabRepository _repository; Future _onAddItem( AddItem event, Emitter emit, ) async { // Publishes the waiting state: the screen reacts before the data // arrives. emit(Loading()); // Fetches the tab: the read can now fail over the network, so the // repository returns a LookupResult (chapter 15), with two // variants. final lookup = _repository.findTab(event.table); switch (lookup) { case TabFound(:final tab): // Calls whoever knows the rule: the use case (chapter 14). final result = addItemToTab(tab, event.item); // Translates the Result into state: exhaustive switch, as in // chapter 8. switch (result) { case Success(:final value): emit(Ready(toData(value))); case RuleViolated(:final reason): emit(Failed(reason)); } case InfraFailure(): emit(Failed("no connection")); } } Future _onRemoveItem( RemoveItem event, Emitter emit, ) async { // Same cycle, only the use case changes; the fetch can fail over // the network here too. emit(Loading()); final lookup = _repository.findTab(event.table); switch (lookup) { case TabFound(:final tab): final result = removeItemFromTab(tab, event.item); switch (result) { case Success(:final value): emit(Ready(toData(value))); case RuleViolated(:final reason): emit(Failed(reason)); } case InfraFailure(): emit(Failed("no connection")); } } } Read _onAddItem as a checklist against the table’s line. It publishes Loading : the screen shows a waiting indicator without knowing what’s being waited on. It fetches the tab: one question to the repository, one answer, the contract this stub offers. It calls addItemToTab : the use case receives the tab and the item, and whatever it decides comes back as a Result . It runs chapter 8’s exhaustive switch: Success becomes Ready , RuleViolated becomes Failed , and the compiler confirms no variant went untranslated. Four steps, zero decisions. The whole method survives any change to the coffee shop’s rules without a single edited line. The two functions the Orchestrator calls are closed boxes, and they stay closed until the chapters ahead. From the use case, chapter 13 knows only the signature, and that signature is a contract: chapter 14 implements exactly this function, with the loyalty discount living inside it. Dart Result addItemToTab(Tab tab, String item) { From the repository, the same story, with one difference chapter 15’s revision brought: findTab(int table) no longer returns the raw tab. Since the read can now fail over the network, the return grew from Future into LookupResult , a synchronous Result with two variants, TabFound and InfraFailure . The closed box’s contract now declares those types: Dart enum Failure { noConnection } sealed class LookupResult {} final class TabFound extends LookupResult { TabFound(this.tab); final Tab tab; } final class InfraFailure extends LookupResult { InfraFailure(this.failure); final Failure failure; } abstract interface class TabRepository { LookupResult findTab(int table); } The full definition of these types, along with the single exception translation that produces them, is chapter 15’s business. In this chapter’s code, both have provisional bodies (the use case adds up prices from a fixed table; the in-memory repository returns TabFound with a seed tab), enough for the whole cycle to compile and run today. The complete cycle, with the closed boxes marked: Six numbered arrows, and every state change in the system travels all six, in order, every time. When table 4’s total shows up wrong, you know where to look, because only one path could have carried it there. Compare that with pain (b): there, “who changed this value?” answered “any screen”; here, the answer is a four-step method. The design is the same across the book’s ten languages; what changes is the mechanism that publishes the state, and each platform has its own established one. Kotlin, in the Android world, publishes with StateFlow , the mechanism the official documentation recommends for UI state; the orchestrator turns into a plain class with an exhaustive when over the event: Kotlin class TabOrchestrator( private val repository: TabRepository, ) { private val mutableState = MutableStateFlow(Loading) val state: StateFlow = mutableState suspend fun onReceive(event: TabEvent) { when (event) { is AddItem -> onAddItem(event) is RemoveItem -> onRemoveItem(event) is PayTab -> Unit } } private suspend fun onAddItem(event: AddItem) { // Publishes the waiting state: the screen reacts before the // data arrives. mutableState.value = Loading // Fetches the tab: the read can now fail over the network, // and the repository returns LookupResult (chapter 15). when (val lookup = repository.findTab(event.table)) { is TabFound -> { // Calls whoever knows the rule: the use case (ch. 14). val result = addItemToTab(lookup.tab, event.item) // Translates the Result into state: exhaustive when, // chapter 8. mutableState.value = when (result) { is Success -> Ready(toData(result.value)) is RuleViolated -> Failed(result.reason) } } is InfraFailure -> mutableState.value = Failed("no connection") } } } The visible difference from Dart is where the dispatch lives: package:bloc registers one handler per event with on , while Kotlin receives everything in onReceive and routes it through a when . The when has a teaching advantage: when you create a new event, it’s the one the compiler flags as incomplete. TypeScript has the most famous mechanism of all: Redux Toolkit, the official successor of the original Redux. The state is the store’s state, createSlice generates the publishing actions, and the Orchestrator is the async function that connects: TypeScript // The state is the slice's state; the reducer just swaps the // published state. const stateSlice = createSlice({ name: "state", initialState: { type: "loading" } as TabState, reducers: { loading(): TabState { return { type: "loading" }; }, ready(_, action: PayloadAction): TabState { return { type: "ready", data: action.payload }; }, failed(_, action: PayloadAction): TabState { return { type: "failed", message: action.payload }; }, }, }); Notice what the reducers do: they swap the state, and nothing else. The fetch, the use case call, and the switch on the Result live in an orchestrate function outside the slice, which ends with store.dispatch(ready(...)) or store.dispatch(failed(...)) . That split is a stance on where rules belong, and the criticism section returns to it, because the Redux documentation recommends the opposite. Java doesn’t have a dominant reactive mechanism outside front- end frameworks, so the port uses the design the market calls MVVM (Model-View-ViewModel) with the standard library’s own property notification, PropertyChangeSupport from java.beans : the orchestrator keeps the state and fires the listeners on every publish: Java // Publishes the state: the "state" property notifies listeners. // The previous value goes in as null: equal states published back // to back still notify, because firePropertyChange normally // silences equal values, and null defeats that check on purpose. private void publish(TabState next) { state = next; support.firePropertyChange("state", null, next); } The rest of the class is the same four-step cycle, with pattern matching over sealed records (Java 21), exhaustive just like Dart’s. C# is MVVM’s home turf: the notification interface ships with the platform ( INotifyPropertyChanged , from System.ComponentModel ), and the orchestrator is a ViewModel whose State property notifies from its setter: C# public TabState State { get => _state; private set { _state = value; PropertyChanged?.Invoke( this, new PropertyChangedEventArgs(nameof(State))); } } C#’s caveat is the same one from chapter 8: record hierarchies carry no guaranteed exhaustiveness, so the switch over the Result carries a _ arm that throws. The compiler doesn’t bill you for the missing case; the extra arm bills you at runtime. PHP and Python don’t have an established BLoC-style mechanism, and the book makes the same choice for both: a hand-rolled ViewModel, with registered observers and notification at a single publish point. In PHP: PHP private function publish(TabState $state): void { $this->state = $state; foreach ($this->observers as $observer) { $observer($this->state); } } In Python, the same design with callbacks, plus 3.10’s structural match playing the switch’s part: Python # Translates the Result into state: structural match, chapter 8. match result: case Success(value): self._publish(Ready(to_data(value))) case RuleViolated(reason): self._publish(Failed(reason)) case _: impossible_variant(result) In both, exhaustiveness is the type checker’s job (Psalm or PHPStan on one side, mypy or pyright on the other), not the interpreter’s; the case _ that throws exists for the day someone runs the code without checking. Swift publishes with SwiftUI’s standard mechanism: ObservableObject and @Published , from Combine. The whole orchestrator ends up looking like an iOS ViewModel, and the enums with associated values give the family’s cleanest exhaustive switch: Swift final class TabOrchestrator: ObservableObject { @Published var state: TabState = .loading private let repository: TabRepository init(repository: TabRepository) { self.repository = repository } func send(_ event: TabEvent) { switch event { case .addItem(let table, let item): runCycle(table: table, item: item, useCase: addItemToTab) case .removeItem(let table, let item): runCycle(table: table, item: item, useCase: removeItemFromTab) case .payTab: state = .failed("paying the tab arrives in chapter 14") } } Rust skips the framework, just like chapter 12: the state goes out through a standard-library channel ( std::sync::mpsc ), and main is a render loop consuming that channel. The port’s nicest detail is that chapter 8’s Result never needed a declaration, because in Rust it’s the language’s own Result type: Ok plays Success ’s part and Err carries RuleViolated ’s reason: Rust // Translates the Result into state: exhaustive match, chapter 8. match result { Ok(tab) => { publisher .send(TabState::Ready(to_data(&tab))) .unwrap(); } Err(reason) => { publisher.send(TabState::Failed(reason)).unwrap(); } } Nine languages so far, and the same inventory as always: the publishing mechanism changes name (bloc’s stream, StateFlow , Redux’s store, PropertyChangeSupport , INotifyPropertyChanged , a hand- rolled observer, @Published , a channel), and the cycle’s four steps never change. The tenth language earned its own section, because it’s missing a piece. Try it: open https://focus.kodel.com.br/en/dart/13-02 (or https://focus.kodel.com.br/en/kotlin/13-02) and run it. Prediction: the console prints “loading…” and then the tab with Espresso, Cappuccino, and the $18.95 total, without a single line of View code adding anything up; after that, a second event with an item that’s not on the menu prints the failure state with the ready-made message. Swap “Cappuccino” for “cheese bread” and predict the total before you run it. The other eight languages live at /en/ts/13-02 , /en/java/13-02 , /en/csharp/13-02 , /en/go/13-02 , /en/php/13-02 , /en/python/13-02 , /en/swift/13-02 , and /en/rust/13-02 . Go’s counterpoint: no unions, no billing Go can write the same orchestrator, and the listing proves it; what Go can’t do is force you to keep it complete. The language has no union types and no sealed classes: the idiom for “one of N types” is a marker interface with a private method, which keeps outside packages from implementing it but never tells the compiler how many implementations exist. Go // Events: no sealed class, so the idiom is a marker interface with a // private method. Only types in this package can be an Event. type Event interface { event() } type AddItem struct { Table int Item string } type RemoveItem struct { Table int Item string } type PayTab struct { Table int } func (AddItem) event() {} func (RemoveItem) event() {} func (PayTab) event() {} The cycle is the usual one, and publishing goes out through a channel, Go’s native reactive mechanism: Go func Orchestrate(event Event, states chan<- State) { switch e := event.(type) { case AddItem: states <- Loading{} // The read can now fail over the network: FindTab returns // LookupResult (chapter 15), with two variants. switch lookup := FindTab(e.Table).(type) { case TabFound: result, err := AddItemToTab(lookup.Tab, e.Item) if err != nil { states <- Failed{err.Error()} return } states <- Ready{ToData(result)} case InfraFailure: states <- Failed{"no connection"} } } } Now repeat the Try It from the sealed-classes section, here. In Dart and Kotlin, the new variant broke the build at every incomplete switch. In Go, look at the type switch above: RemoveItem and PayTab exist in the package and have no arm, and go vet on this exact file comes back clean. The event goes in, lands in no arm, and the function returns in silence: no state published, no error, a screen waiting forever for a state that never comes. The reason for the difference is a language design choice. Go treats “one of N outcomes” with the pair (T, error) , which this port’s own use case relies on and which chapter 8 introduced as Go’s native Result; for “one of N types,” the official answer is the type switch, and exhaustiveness never made it into the contract. What the Go team does about it is what the community recommends: convention (every type switch over Event covers every event, and code review enforces it) and testing (one flow test per event; the next section’s test is the template). The pattern’s cost didn’t drop in Go; it just left the compiler and landed on your process. That’s going to be a central argument two sections from now. The flow test: event on top, states below The whole chapter’s promise fits in one test. If every state change travels a single path, then declaring “this event goes in, this sequence of states comes out” has to be enough to test the Orchestrator, with no screen involved. In Dart, bloc_test (the testing companion of package:bloc ) writes exactly that sentence: Dart blocTest( "AddItem publishes Loading then Ready, in that order", build: () => TabOrchestrator(InMemoryTabRepository()), act: (orchestrator) => orchestrator.add(AddItem(4, "Cappuccino")), expect: () => [ isA(), isA().having( (state) => state.data.formattedTotal, "formattedTotal", "\$18.95", ), ], ); Read the three parts. build sets up the orchestrator with the in- memory stub: no database, no framework mock. act delivers the event, the same gesture the screen would make. expect declares the sequence: Loading first, then Ready with the formatted total the screen is going to show. No pumpWidget , no render tree, no animation wait; the whole test file doesn’t import a single UI concern. This test is only possible because the Orchestrator is dumb. An orchestrator with a rule inside would force you to test the rule right here, one case per discount tier; an orchestrator with shared state would force you to set up both screens to reproduce the flicker. The orchestrator that only connects narrows the test down to the contract: event goes in, states come out, in this order. And the order carries weight: flip the publishes inside _onAddItem (emit the ready state before Loading ) and this test fails on the spot: it points at the sequence it actually received. The Pitfalls section shows how this exact bug is born on its own inside async code, with nobody flipping anything on purpose. The flow test above has a deliberate limit: it exercises the happy path, the run where everything works, the item exists, the repository answers, the total comes out. A test that stops there is half a test. Code spends its life on the other paths: the invalid input, the rule that says no, the value sitting right on the limit; a good test set builds every possible scenario, failures included, because the scenario nobody visits is where a bug ages in peace. Writing a whole test per scenario gets tiring, and test frameworks fix that with parametrization: you declare a list of cases, each with its input and expected verdict, and the framework turns every row into a test. JUnit does it with @ParameterizedTest , pytest with parametrize ; Dart’s package:test needs no annotation at all, because test is an ordinary function and a for over the list is enough. The tab’s use case, tested this way: Dart typedef Case = ({ String description, List items, String item, int? expectedTotal, String? expectedReason, }); void main() { const cases = [ ( description: "happy path: a menu item adds to the total", items: [(name: "Espresso", priceInCents: 700)], item: "Cappuccino", expectedTotal: 1895, expectedReason: null, ), ( description: "empty tab: the first item enables paying", items: [], item: "cheese bread", expectedTotal: 600, expectedReason: null, ), ( description: "repeated item: adds again, no deduplication", items: [(name: "Cappuccino", priceInCents: 1195)], item: "Cappuccino", expectedTotal: 2390, expectedReason: null, ), ( description: "item off the menu: rule violated, with a reason", items: [(name: "Espresso", priceInCents: 700)], item: "Feijoada", expectedTotal: null, expectedReason: "Feijoada is not on the menu", ), ( description: "empty text: also an item off the menu", items: [], item: "", expectedTotal: null, expectedReason: " is not on the menu", ), ]; for (final testCase in cases) { test(testCase.description, () { final total = testCase.items.fold(0, (s, i) => s + i.priceInCents); final tab = Tab(4, testCase.items, total, testCase.items.isNotEmpty); final result = addItemToTab(tab, testCase.item); switch (result) { case Success(:final value): expect(value.totalInCents, testCase.expectedTotal); expect(value.canPay, isTrue); case RuleViolated(:final reason): expect(reason, testCase.expectedReason); } }); } } Five rows in the table, five generated tests, and only the first one is the happy path. The other four visit the empty tab, the repeated item, the rejected rule, and the degenerate input (the empty string), and each one costs a single row of data. Notice too the division of labor between this chapter’s two test files: the flow test asks the Orchestrator “do the states come out in the right order?”, and the parametrized test asks the use case “is the verdict right for every input?” Each one interrogates its own layer, neither boots a UI, and both run in milliseconds. When chapter 14 writes the loyalty discount inside this same use case, this table is going to gain the boundary rows every discount deserves: 99 points with no discount, 100 points with one, and the order that only goes through when no item on the tab is out of stock. Transient context and persistent context This chapter’s flow moves two kinds of information that are easy to confuse, and the confusion is expensive in both directions. Transient context is what holds while someone is working the screen: Loading , the item that was just refused, the tab with the seven items being assembled right now. It is born when the screen opens and dies when the app closes, and that is exactly what the orchestrator publishes. Persistent context is what has to still be true tomorrow morning: the saved tab, the customer’s point balance, the confirmed payment. That one isn’t published, it’s stored, and what stores it is the repository. Swapping one for the other produces bugs from two different families. Treating the persistent as transient is the item Rosie added at three in the afternoon that lived only in the orchestrator’s state: the phone ran out of battery and the order vanished, with no error on screen. Treating the transient as persistent is writing on every keystroke, with the database receiving half a tab and the screen waiting on the confirmation of a write nobody asked for. The ruler is the previous paragraph’s question, and it fits on one line: does this have to survive the app closing? If it does, it crosses the use case and goes to the repository before it becomes state. If it doesn’t, it’s state and that’s it, and the orchestrator owns it. Whoever reads the orchestrator’s file finds the answer without opening anything else, because the list of states is the list of what’s ephemeral in that slice. The criticism: boilerplate and rules in the reducer A pattern with this many names collects criticism, and the two strongest ones deserve a sourced answer. The first is boilerplate. Redux’s own documentation keeps an FAQ page about code structure (redux.js.org/faq/code-structure) that exists because the question “why does one interaction need so many files?” never stopped arriving; back in classic Redux, every interaction demanded an action constant, an action creator, a reducer, and a selector, scattered across four folders. The criticism was fair, and its answer sits in the sealed-classes section: the tab’s complete set of events and states fit on half a page of sealed classes, and every one of those classes buys exhaustiveness checking at every switch in the system. The 2016 Redux boilerplate came from the language: JavaScript with no unions, no pattern matching, and no compiler to bill you for missed cases. The pattern was never at fault. The proof is the language that fixed the problem: Redux Toolkit, from the same maintainers, eliminated the four folders, and what’s left in modern TypeScript is close to the Dart listing wearing different syntax. Here’s my scar, so you know where I’m speaking from: I wrote Redux in 2016, with actions named by string, a 400-line switch in a reducer three teams edited, and a typo in "ADD_ITEM_SUCESS" that spent six hours pretending to be a network bug, because a misnamed action breaks nothing, it just never fires. I’ll take three extra sealed classes over that any day of the week. The compiler that flags my incomplete switch works for free; the intern hunting a string typo doesn’t. The second criticism attacks from the other direction: “if the Orchestrator decides nothing, it’s a bureaucratic layer; the logic could just live there.” That’s not a strawman, it’s Redux’s official recommendation: the documentation’s style guide (redux.js.org/style- guide) carries the rule “Put as Much Logic as Possible in Reducers.” FOCUS disagrees on purpose, and the reason starts with the Orchestrator’s job description: BLoC, ViewModel, and Store are support code for the View, born tied to the screen they serve. A business rule written there inherits that leash, and the leash charges you twice. When a second screen needs the same rule, either the rule gets copied into the second orchestrator (chapter 12’s three screens with three totals, one layer down), or one orchestrator starts calling the other, and then a piece that holds a rule depends on another piece that holds a rule: coupling between siblings in the same layer, which the bloc library’s own documentation says to avoid at any cost (“no bloc should know about any other bloc,” bloclibrary.dev, architecture page). The way out of both dead ends is the same one, and it isn’t exclusive to FOCUS: Flutter’s official architecture guide (docs.flutter.dev/app-architecture) recommends extracting use cases exactly when logic “will be reused by different view models” or needs to combine data from more than one repository. That’s the table’s line said with different words: the rule leaves the Orchestrator and becomes a reusable verb, a pure function, testable with no framework, that any orchestrator can call without knowing about the others (the waiter’s screen and the cashier’s report both call the same addItemToTab ). The smell that gives away the divergence in practice, you already saw in the anti-solution: an if comparing a business value inside the Orchestrator. The fix is always the same change of address, and chapter 14 is entirely about the right address. Pitfalls The first pitfall is a rule disguised as “just one if.” It never introduces itself as a business rule; it arrives as a “tiny validation”: if (event.item.isEmpty) return; at the top of the handler, “not even worth calling the use case for.” What goes wrong: the criterion for a valid item just gained a second home, and when the rule grows (a duplicate item? an item from another shift?) the if grows with it, in the wrong place. How to get out: run the connect vs. decide test on the operand. An if that compares a business value (item, price, quantity) is a decision, and decisions belong in the use case, which returns RuleViolated with a ready message. The second pitfall is persistence disguised as “just a cache.” The Orchestrator just received the updated tab from the use case, and stashing it in a map “so the next fetch is instant” looks like harmless optimization. What goes wrong: a second place where the tab lives was just born, and it doesn’t talk to the repository; the next screen that fetches straight from the repository gets the stale version, and the total flickers again. Why: a cache is persistence with an expiration date, and the table’s line forbids persisting, deadline or not. How to get out: the tab’s keeper is chapter 15’s repository, which is free to keep its own internal cache, in one place, invisible to whoever fetches. The third pitfall is state emitted out of order inside async code, and this one shows up with nobody writing wrong code on purpose. The waiter taps twice: two events go in, two async cycles start, and if the first cycle is waiting on a slow repository while the second one hits a cached value, the second one’s state can land before the first one’s. What you see on screen: the right total shows up, and half a second later a late Loading runs it over. How to get out: first, have the flow test, because it’s the one that turns “the total sometimes flickers” into a declared sequence that fails in CI; second, process events serially inside the Orchestrator ( package:bloc does this by default inside every on , and it’s one of the reasons it exists; inside a hand-rolled ViewModel, a plain queue solves it). Sophisticated event concurrency is a subject the book returns to later; this chapter’s rule is simpler: never emit state from a cycle that’s already been overtaken. Q&A The Orchestrator builds TabData , formats currency, and picks the error message. Isn’t that deciding? It’s translating, and the distinction sits in the operand: formatting 1895 as “$18.95” doesn’t compare a business value against anything, it just dresses up a value someone already decided. It turns into a decision the moment a business if shows up in the middle: showing red when the total crosses X is a rule, and it goes down to the use case to hand back already decided. When building the state grows, extract pure presentation functions (a formatCurrency ), called by the Orchestrator. The use case keeps working on the other side of that line, in whole cents, never reading or writing text. One orchestrator per screen, or per feature? Per feature, the way chapter 11’s tree suggests: one tab_orchestrator in the slice serves the waiter’s screen and the cashier’s, and that exact sharing is what kills pain (b) without any global state: both screens subscribe to the same source, and writing only happens through an event. PayTab has existed since chapter 12 and the Orchestrator still doesn’t handle it. Shouldn’t the compiler complain? In Kotlin’s when it does, and the is PayTab -> Unit arm sits there, explicit, waiting for chapter 14. Unit is Kotlin’s “nothing”: the counterpart to void , except it’s an actual value, the object that means “there’s nothing useful to return.” A -> Unit arm is therefore a written-down “do nothing”: the compiler demands the variant get handled, and you record that you handled it by choosing to ignore it, which is a very different thing from forgetting it. In Dart with package:bloc there’s no switch over events: an event with no registered handler blows up at runtime. It’s a real trade-off between the two APIs, and exercise 1 puts it in your hands. Do I need package:bloc ? Can’t I write the Orchestrator by hand? You can, and this chapter’s Java, PHP, and Python ports are exactly that: a class with observers and a single publish point. The library buys you serial event processing, bloc_test , and the conventions another developer in the ecosystem already recognizes. Quick tip Open the fattest orchestrator in your current project and run the search three times: if ( , >= , and [ . Every if whose operand is a business value, every quantity comparison, and every write into a collection that survives past the method is a candidate to change address: the first two go to a use case, the third to the repository. Same exercise as chapter 12’s Quick tip, one layer down. Quick reference Situation Fix Receive event, choose which use case to call orchestrator: the “when” Fetch data from the repository before the rule orchestrator: the “where to” Compare total, quantity, or item in an if leaked: the decision belongs in the use case Compute a discount, fee, or price leaked: the rule belongs in the use case Stash a result in a “cache” map leaked: persisting belongs to the repository Translate Result into state in a switch orchestrator: translates, doesn’t decide Format an already-decided value for display orchestrator (or a pure function) Publish the state for the screen orchestrator: owns the channel Emit state from a cycle already overtaken bug: reread the third Pitfall Exercises 1. Add the event ClearTab(int table) to the TabEvent hierarchy from snippet 13-02 in your language and follow the compiler errors all the way through the cycle: in Kotlin and Swift, the orchestrator’s when and switch break right away and name the missing arm; in Dart, the compiler stays quiet ( bloc registers handlers, it doesn’t switch) and it’s the flow test that catches it; in Go, nothing complains, exactly as the counterpoint section predicted. Finish by publishing the sequence Loading and then Ready with an empty tab. 2. The orchestrator below connects and it runs, and exactly two business rules snuck into it. Find both before reading the answer key in exercise 3; apply the Quick reference’s question to every line: Dart Future _onAddItem( AddItem event, Emitter emit, ) async { emit(Loading()); final lookup = _repository.findTab(event.table); if (lookup is! TabFound) { emit(Failed("no connection")); return; } final tab = lookup.tab; // "Just a safety cap, not really a rule": the line compares a // business quantity and decides right here. if (tab.items.length >= 20) { emit(Failed("table ${event.table} passed the item limit")); return; } final result = addItemToTab(tab, event.item); switch (result) { case Success(:final value): // "Just a cache for the next fetch": the tab saved right // here, persistence wearing another name. _cacheByTable[event.table] = value; emit(Ready(toData(value))); case RuleViolated(:final reason): emit(Failed(reason)); } } 3. Answer key for exercise 2. First rule: the if (tab.items.length >= 20) right after the fetch; the operand is a business quantity (the table’s item limit is Rosie’s policy), so the line violates the table’s “forbids deciding rules,” and the cap moves address into addItemToTab , which already knows how to return RuleViolated with a ready message. Second rule: the _cacheByTable[event.table] = value inside the Success case; it’s persistence disguised as a cache (second Pitfall), it violates “forbids persisting,” and its destination is chapter 15’s repository, the only place a tab gets saved. Open challenge: run the Quick tip on the biggest orchestrator in one of your own projects and count how many lines answer “what” instead of “when” or “where to.” Tip 13 The Orchestrator knows when and where to; never what. The moment it knows what, you just found a rule hiding in plain sight. Next chapter: addItemToTab stops being a signature: chapter 14 opens the use case and writes, as a pure function, the discount rule that spent this whole chapter locked in the box.