25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.

44KB

FOCUS Architecture — Chapter-23: Build Rosie’s App

  • Source: /library/FOCUS Architecture/source-file.pdf
  • PDF pages: 680–729
  • Pages without text: none

Build Rosie’s App In this chapter, you’ll: clone the coffee shop app’s repository and run all four slices together, with a file database and a simulated card reader, in a single command; point, for every architecture decision in the app, to the file where it lives and the chapter that taught it; port a slice to your language and prove the port against the repository’s shared case suite. You saw the four pieces one at a time. The view in chapter 12, the orchestrator in chapter 13, the use case in chapter 14, the repository in chapter 15, each in an example the size of one page. None of those examples had a database, a real screen, or a failure that happened on its own. Now all four sit inside the same app, one that opens, saves, charges, and fails. The scaffolding ends here. In the previous chapters, every listing came with a line-by-line explanation, because the concept was new. It no longer is. From this point on, the text gives three things per piece: what the spec asked for, which decision got made and why, and the snippet where the decision lives. The rest of the file sits in the repository, and the path printed before each snippet is the real path over there.

No new term shows up from this paragraph on. Everything the app uses was already defined in an earlier chapter, and every section points to the number. If a name looks new, it isn’t: it’s a business name from the coffee shop stuck onto a shape you already know. The focus-coffee repository The app lives at github.com/JCKodel/focus-coffee , public, MIT (Massachusetts Institute of Technology) license, the permissive license named after the university that wrote it. It’s chapter 22 in code: every file path printed from here on is a path over there, at tag coffee-v2 . The tree has fewer surprises than you’d expect from an app with four features:

Every slice holds the same four files. The menu one, opened up:

The files drawn under menu/ repeat under the other three slices, with the slice’s name up front. It’s chapter 11’s structure, unadapted: the folder is the feature, and each piece’s role lives in the file name, not in a folder. Folder What it holds Chapter lib/features/menu/ the read-only slice 12, 13, 15 lib/features/tab/ the slice with a lifecycle 13 lib/features/payment/ the slice with the failures 8, 15, 16 lib/features/loyalty/ the slice with the rule 14, 18 /*_view.dart dispatches events, renders state 12

/_orchestrator.dart turns events into state 13 /_state.dart the state the view renders 13 /_.dart pure rule, no I/O 7, 14 /*_repository.dart CRUD and exception translation 15 lib/shared/discount/ the only shared/ , by the rule of three 5, 11 lib/infra/ database, fake card reader, formatting 9 lib/main.dart the composition root, and the only one 9 cases/ the shared test suite, in neutral JSON 22 ports/ ports to other languages 18, 22 docs/ each slice’s spec and plan 21

This table is the repository’s README.md , word for word, so that whoever arrives through the code and whoever arrives through the book read the same map. The README.md also carries the commands, and there are four: flutter pub get dart run build_runner build --delete-conflicting-outputs flutter test flutter run -d linux The first two are setup: the second generates the database code, and without it the project doesn’t compile. The two you care about are the last two. flutter test runs 85 tests. flutter run -d linux opens the window with Rosie’s menu loaded from the database. The platform checked step by step, from a clean clone, is Linux desktop. On macOS or Windows, swap the -d target for macos or windows . There’s no account to create, no API key to get, and no service to stand up. The database is a local file, and the card reader is a simulation whose outcome you program. Menu: the read-only slice The spec in docs/001-menu/ asks for little: the cashier opens the app and sees the menu with price and out-of-stock mark, without typing anything. An out-of-stock item shows up struck through

and can’t be added. Nothing else. A feature like this tends to turn into half a dozen lines thrown at the screen. Here it has three pieces, and the third is the one that usually disappears. First decision: this slice has no use case. The whole slice is six files, and the listing is the proof: lib/features/menu/ menu_item.dart menu_list_view.dart menu_orchestrator.dart menu_repository.dart menu_state.dart menu_view.dart There’s no rule file because there’s no rule to apply: the slice reads the menu and shows it. The flat slice makes the absence visible for free. In the tree organized by technical role it needed a note, because an empty folder doesn’t survive git and the only way to show the gap was deliberate was a file inside it saying so. Here whoever looks for the rule walks six names and sees it isn’t there. Layer for ceremony’s sake is chapter 19’s entry for exactly this: the layer that exists because the diagram has four boxes, not because anyone needed it. A FetchMenuUseCase that just forwards the call to the repository protects nothing, decides nothing, and still costs one more file on every future change. I didn’t write that file, and I wouldn’t. Second decision: the exception dies in the repository. The slice’s boundary is an interface, and the implementation with the drift package (the Dart database-access generator, unrelated to

the “drift” between code and document from chapter 23) is the only piece that knows SQLite exists. It’s in lib/features/menu/menu_repository.dart : Dart /// The slice's boundary. Whoever sits above here doesn't know drift, /// doesn't know SQLite, and doesn't know exceptions. abstract interface class MenuRepository { Future fetchMenu(); } class MenuRepositoryImpl implements MenuRepository { const MenuRepositoryImpl(this._database); final CoffeeShopDatabase _database; @override Future fetchMenu() async {

try { final rows = await _database.select(_database.menuItems).get(); final items = rows .map( (row) => MenuItem(row.name, row.priceInCents, outOfStock: row.outOfSto ck), ) .toList(); return MenuFound(items); } on Exception { return InfraFailure(Failure.databaseUnavailable); } } }

It’s chapter 15 applied at full price. One try , one catch , one return value. Whoever calls it gets back MenuFound or InfraFailure , and the compiler charges for both branches. Third decision: the price turns into text in the orchestrator, never in the view. Chapter 12 calls this renderable state, and the app takes it seriously: the view receives formattedPrice as a ready string and doesn’t know price is an integer count of cents. The conversion is in lib/features/menu/menu_orchestrator.dart : Dart Future _onLoadMenu(LoadMenu event, Emitter emit) async { emit(Loading()); final result = await _repository.fetchMenu(); switch (result) { case MenuFound(:final items): emit(Ready(MenuData(items.map(_render).toList()))); case InfraFailure(:final failure):

emit(Failed(_messageFor(failure))); } } The formatter itself lives in lib/infra/money_formatting.dart , not in lib/shared/ . The reason shows up in the loyalty section, along with the repository’s only shared/ . With state ready, the view comes out the size chapter 12 promised. This is the whole file, at lib/features/menu/menu_view.dart : Dart /// Fires the event and draws the state. Doesn't format, doesn't /// calculate, and doesn't decide anything. /// /// onTapItem arrives from outside because the menu doesn't know /// about the tab. What wires one slice to the other is the /// composition root, and that's where the tap turns into AddItem. class MenuView extends StatelessWidget { const MenuView({required this.onTapItem, super.key});

final void Function(String name) onTapItem; @override Widget build(BuildContext context) { return BlocBuilder<MenuOrchestrator, MenuState>( builder: (context, state) { return switch (state) { Loading() => const Center(child: CircularProgressIndicator()), Ready(:final data) => _MenuList(data: data, onTapItem: onTapItem), Failed(:final message) => Center(child: Text(message)), }; }, ); } }

Three states, three branches, zero if . Add a fourth state to the sealed type and this file stops compiling, which is the whole point of chapter 13. Notice onTapItem . Tapping a menu item adds it to the tab, and that’s this slice’s second decision point: the view could read the tab’s orchestrator directly, with a context.read , and settle everything in one line. It doesn’t, and the reason is chapter 11. The menu slice imports nothing from features/tab/ , and stays that way even after this connection: the tap climbs up as a function, and whoever wires it to the AddItem event is the composition root, in lib/main.dart . Two slices that talk through the place where the concrete implementations already meet, which is chapter 9’s subject. The day the menu becomes a screen on its own, with no tab beside it, nothing inside the slice changes. Tab: the slice with a lifecycle The spec in docs/002-tab/ asks the cashier to open a table’s tab, add menu items to it, remove what got added by mistake, close the bill, and mark it as paid. Three situations, in order. A closed tab still reopens, because the customer asks for one more coffee after asking for the bill; the only transition with no way back is paying.

First decision: the lifecycle is the hierarchy, not a field. The temptation is a bool paid or an enum status inside one Tab class. The app does the opposite, in lib/features/tab/tab.dart : Dart /// The tab's lifecycle is the hierarchy, not a field. /// /// An open tab still changes and has no closed total. A closed tab and /// a paid one do. With three types, the compiler stops anyone from /// reading the total of a tab that's still changing.

sealed class Tab { const Tab(this.table, this.lineItems); final int table; final List lineItems; } final class OpenTab extends Tab { const OpenTab(super.table, super.lineItems); OpenTab withItem(TabLineItem item) { return OpenTab(table, [...lineItems, item]); } OpenTab withoutItem(int index) { final remaining = [...lineItems]..removeAt(index);

return OpenTab(table, remaining); } ClosedTab close() { final total = lineItems.fold(0, (sum, item) => sum + item.priceInCents); return ClosedTab(table, lineItems, total); } } final class ClosedTab extends Tab { const ClosedTab(super.table, super.lineItems, this.totalInCents); final int totalInCents; PaidTab pay() => PaidTab(table, lineItems, totalInCents);

/// A closed tab reopens: the customer ordered one more coffee before /// paying. The only transition with no way back is pay. OpenTab reopen() => OpenTab(table, lineItems); } final class PaidTab extends Tab { const PaidTab(super.table, super.lineItems, this.totalInCents); final int totalInCents; } Look at where totalInCents shows up and where it doesn’t. OpenTab has no such field, because a tab that still receives items has no closed total. With a bool paid , that total would exist the whole time and someone would read the wrong value. With three types, reading the total of an open tab doesn’t compile. The transition is also a method, and only on the type that can make it: close exists on OpenTab , reopen and pay exist on ClosedTab , and PaidTab has no transition at all, because there’s no leaving it. Second decision: the use case receives the price, it doesn’t fetch it. Here the app diverges from chapter 14, and the divergence has a lesson. Chapter 14 prints addItemToTab(Tab tab, String item) with a price table inside the use case file itself, which is perfect for a

self-contained example and impossible in a real app, where the menu lives in the database. The real signature is in lib/features/tab/add_item_to_tab.dart : Dart /// The rule lives here, and nowhere else. /// /// A pure function at the top of the file: takes data, returns a /// result. It doesn't receive a repository, doesn't read the database, /// doesn't throw an exception. The item's price arrives as an argument /// because a pure function doesn't consult the menu; the orchestrator /// does that, before calling it. Result addItemToTab( Tab tab, String item, { required int priceInCents, required bool outOfStock,

}) { if (tab is! OpenTab) { return RuleViolated(“table ${tab.table}‘s tab isn't open”); } if (outOfStock) { return RuleViolated(“$item is out of stock”); } return Success(tab.withItem(TabLineItem(item, priceInCents))); } Duplicating the price table inside the use case would be duplicated knowledge in chapter 5’s sense, and giving it a repository would stop it from being pure. What’s left is the third way out: the data enters as a parameter. The one who queries the database is the orchestrator, before calling it. I’d rather admit the signature change out loud than pretend chapter 14’s example would survive contact with a real database untouched.

Third decision: the orchestrator converts, and only converts. It fetches the menu, calls the use case, publishes the state, and decides nothing. The Emitter is called emit , as in chapter 13. The snippet is in lib/features/tab/tab_orchestrator.dart : Dart Future _onCloseTab(CloseTab event, Emitter emit) async { final current = _tab; if (current is! OpenTab) { emit(Failed(“table $_table's tab isn't open”)); return; } _tab = current.close(); emit(Ready(_data())); final save = await _tabRepository.save(_tab);

if (save is InfraFailure) { emit(Failed(_messageFor(save.failure))); } } Every transition in the diagram is a one-line method call. No math, no try , no rule. The orchestrator is plumbing, and that’s what it should look like. Notice what this handler does not do: it doesn’t mark the tab as paid. Closing and paying are two transitions in the diagram, and two events in the code, because a card reader that can decline sits between one and the other. The payment slice does the paying, and the tab only receives ConfirmPayment after the charge comes back approved. If the card reader declines, the tab stays closed and the bill is still open, which is what happens at the counter. That separation wasn’t there either. The first version closed and paid on the same line, and the app looked like it worked: the button disabled and the tab went to “paid” without a single cent getting charged. I’ll come back to that defect in Pitfalls. Fourth decision: the state carries the reason, not just the number. I learned this one with the app on screen, after everything was green. I added a $6.00 cheese bread and the tab showed a total of $5.40, with no word about why. The math was right, it was the 10% loyalty discount from chapter 14. What was wrong was what the orchestrator was publishing.

The use case returns the sealed DiscountResult , and each of its variants carries the reason: the discount applied, how many points are missing, which out-of-stock item canceled everything. The method that built the payload received that whole type and collapsed it into a single number. The reason went to the trash before reaching the screen. The view, dumb by design since chapter 12, had no way to explain what it never received. The rule that stuck applies to any of your slices: if the view needs to explain something to the user, that explanation is part of the state, not a deduction the screen makes. TabData gained the subtotal and a ready-made sentence per variant, and the file is lib/features/tab/tab_state.dart : Dart final List items; /// The view shows the reopen button when the tab is closed and not /// yet paid. The decision belongs to the orchestrator, never to the /// screen. final bool canReopen; final String formattedSubtotal;

/// Why the total differs from the sum of the items, in a ready-made /// sentence. Empty when total and subtotal match and there's nothing /// to explain. final String discountExplanation; final String formattedTotal; final bool canPay; Notice what the view does with this: nothing. It prints the sentence if there is one. Who decided the text was the orchestrator, from a type the use case returned, and that’s why changing the discount rule doesn’t touch a single widget. Payment: the slice of errors The spec in docs/003-payment/ asks for three outcomes with three different treatments: approved, declined by the acquirer, and channel down. The same screen splits the bill among the people at the table before charging. The path a payment takes to the screen is this:

Notice the exception only exists on the first arrow. From the second arrow on, everything is a value, and every translation is one sealed union turning into another. First decision: the Result is born at the boundary, once. The payment’s sealed type and the interface are in lib/features/payment/payment_repository.dart : Dart /// The payment's three outcomes. A declined card is a domain variant, /// not a Failure: the channel worked, the acquirer is the one who /// said no. sealed class PaymentResult {}

final class PaymentApproved extends PaymentResult { PaymentApproved(this.receipt); final String receipt; } final class PaymentDeclined extends PaymentResult { PaymentDeclined(this.reason); final String reason; } final class InfraFailure extends PaymentResult { InfraFailure(this.failure); final Failure failure; }

abstract interface class PaymentRepository { Future charge(int amountInCents); } The translation happens right below, in the same lib/features/payment/payment_repository.dart , and fits in twenty-one lines: Dart /// The boundary. It's here, and only here, that the acquirer's /// exception turns into a value. class PaymentRepositoryImpl implements PaymentRepository { const PaymentRepositoryImpl(this._api); final FakePaymentApi _api; @override Future charge(int amountInCents) async { try {

final response = await _api.charge(amountInCents); if (response.authorized) { return PaymentApproved(response.receipt); } return PaymentDeclined(response.reason); } on ChannelError catch (error) { return InfraFailure(error.failure); } } } Second decision: a declined card isn’t a Failure . Insufficient funds is a business answer: the card reader worked, the network answered, the acquirer looked at it and said no. Nothing failed. Treating this as a channel failure sends the screen to ask “try again” to a customer who needs a different card. Chapter 8 already split the two in its Rust example, and chapter 15 spends pages taking the mix-up apart.

Third decision, and this is the one I got wrong first. Chapter 15 closes with enum Failure { noConnection } and proposes timedOut as an exercise. That’s what the app was born with, those two variants. Both describe a remote channel, and the app has two channels: the acquirer, which is remote, and the embedded SQLite, which runs in the same process and has no connection to drop. The menu and the tab read from SQLite. With only two variants available, their repositories returned Failure.noConnection whenever a local read failed, and the screen told the cashier there was no connection, on a machine that had never been connected to anything. The enum modeled one channel, and whoever used the other one had to lie. The lie showed up in full in the payment repository. Since ChannelError carried the API’s outcome, and the outcome has four values, the exhaustive switch demanded a branch for approved and another for declined , and both ended up at Failure.noConnection . Two lines claiming that approved means no connection. They were unreachable, because the fake API only raises ChannelError on the two channel outcomes, so nothing broke: 71 green tests and a clean flutter analyze . Neither tool catches a lying type, because the type compiles. The fix has two parts, and the first is in lib/infra/failure.dart : Dart /// Infrastructure conditions, by channel. /// /// noConnection and timedOut belong to the remote channel: they

/// apply to the acquirer, never to the embedded database, which runs /// in the same process and has no connection to drop. A local read or /// write that fails is databaseUnavailable. /// /// A declined card does NOT belong here. Decline is the acquirer's /// business answer, and lives in the payment slice's domain sealed /// type. enum Failure { noConnection, timedOut, databaseUnavailable } The second part is in lib/infra/fake_payment/fake_payment_api.dart , and it’s the one that erased the problem instead of patching it: Dart /// Channel error raised by the fake API. It's an exception on purpose: /// the repository exists to translate it into a value, once, at the /// boundary. /// /// It carries the Failure itself, not the outcome, because only two

/// of the four outcomes are channel errors. A type that only /// represents the possible cases spares the repository from deciding /// what to do with approved in a place approved never reaches. class ChannelError implements Exception { const ChannelError(this.failure); final Failure failure; @override String toString() => “ChannelError(${failure.name})"; } With ChannelError carrying Failure instead of an API outcome, the translation method that used to live in the repository disappeared whole, and with it the two lying branches. It’s chapter 8’s lesson applied one level up: when a type represents only the possible cases, the code written to satisfy impossible ones vanishes. What’s left is the spec’s third case, the split tab. It’s a pure function called before charging, in lib/features/payment/split_tab_between.dart : Dart

/// Pure function: data comes in as an argument, a result goes out as /// the return. No repository, no I/O, no exception. /// /// The remainder of the division is handed out one unit at a time to /// the FIRST people: the share at index i gets one extra unit if, /// and only if, i < remainder. The sum of the shares closes exactly /// on the total. Everything in an integer count of cents: floating /// point doesn't enter here, which is why the same case gives the /// same result in any language that ports this rule. SplitResult splitTabBetween({required Tab tab, required int people}) { if (people < 1) { return InvalidPeopleCount(people); } if (tab is PaidTab) {

return TabAlreadyPaid(tab.table); } final total = tabTotal(tab); final base = total ~/ people; final remainder = total % people; final shares = List.generate( people, (index) => index < remainder ? base + 1 : base, ); return SplitCalculated( SplitData(table: tab.table, totalInCents: total, sharesInCents: shares), );

} Splitting 6615 cents between four people gives 1654, 1654, 1654, and 1653. The first three get the remainder’s cent because their index is below 3. The sum closes on 6615. It’s this arithmetic that’s behind chapter 16’s rule for a command with a typed return: the split can be refused for two different reasons, and each refusal has its own name in the return. Loyalty: the rule, and the first shared/ The spec in docs/004-loyalty/ brings chapter 14’s rule unchanged: one hundred points earn a ten percent discount, an out-of-stock item cancels the whole discount, and the division is truncated integer division. This is the rule the book used to explain pure functions, and it’s the same one the app runs in production. First decision: this rule moved up to lib/shared/ , and it’s the only one that did. The file is lib/shared/discount/loyalty_discount.dart : Dart /// The rule lives here and nowhere else. /// /// Order: an out-of-stock item cancels first, then eligibility, then /// the discount itself. No IO, no framework, no exception. ///

/// The discount uses truncated integer division (~/), never /// rounding. Nine hundred and ninety-nine cents at ten percent is /// ninety-nine point nine, and the rule truncates to ninety-nine: the /// total lands at nine hundred, not eight hundred and ninety-nine. /// That case is what separates a correct port from one that reached /// for a floating-point shortcut. DiscountResult applyLoyaltyDiscount( Tab tab, int loyaltyPoints, ) { for (final item in tab.lineItems) { if (item.outOfStock) { return ItemOutOfStock(item.name); } }

if (loyaltyPoints < 100) { return NotEligibleForDiscount(loyaltyPoints, 100); } final total = tabTotal(tab); final discounted = total - total * 10 ~/ 100; return DiscountApplied(ClosedTab(tab.table, tab.lineItems, discounted)); } Chapters 5 and 11 fix the rule of 3: promote when the third case shows up, not when the second one scares you. The three callers exist, and they have names. The first is the tab, which shows the total already discounted, in lib/features/tab/tab_orchestrator.dart : Dart /// The displayed total already carries the loyalty discount. It's /// the first of the three callers of the rule that lives in /// lib/shared/discount/.

TabData _data() { final gross = tabTotal(_tab); final discount = applyLoyaltyDiscount(_tab, _loyaltyPoints); The second is the payment, which needs to charge the right amount, not the storefront value, in lib/features/payment/payment_orchestrator.dart : Dart /// The amount charged is the discounted amount. It's the second of /// the three callers of the rule that lives in /// lib/shared/discount/. int _amountToCharge(Tab toCharge) { final discount = applyLoyaltyDiscount(toCharge, _loyaltyPoints); The third is the loyalty program screen, which shows the customer how much their points are worth today, in lib/features/loyalty/loyalty_orchestrator.dart : Dart /// Third and last caller of the shared rule. This third caller is

/// what authorized the promotion to lib/shared/; with two callers, /// it would have stayed inside the tab slice. void _onOpenProgram(OpenProgram event, Emitter emit) { final gross = tabTotal(event.tab); final result = applyLoyaltyDiscount(event.tab, _loyaltyPoints); Second decision: the cost of the promotion is real, and it’s declared. Whoever touches the discount percentage touches three slices at once. They’re the total the tab shows, the amount the payment charges, and the number the loyalty screen promises. That’s shared/ ’s price, and it’s expensive. That’s why it only shows up after the third case, and never at the second one’s scare. With two callers, the rule would have stayed inside the tab slice, and the payment would call it from there. Third decision: formatCurrency didn’t move up. It has four callers, more than the discount rule, and it still lives in lib/infra/money_formatting.dart . Currency formatting is a presentation convention, not a rule. There’s no condition, no refusal, no change request Rosie could make about it. lib/shared/ , in the sense of chapters 11 and 19, is domain promoted to shared. Mixing the two things in there would weaken the one legitimate shared/ example the repository has. The rule and the tab split, added together, fit in one self- contained file, with no database and no screen, and that’s how it runs in the 10 official languages.

Try it: open https://focus.kodel.com.br/en/dart/22-01 and swap en/dart for en/go , en/rust , en/python , or any of the book’s 10 official languages, whose equivalence table sits in chapter 18. A tab worth 7350 cents with 120 loyalty points becomes 6615, split four ways as 1654, 1654, 1654, 1653 in every one of them. Now swap the integer division for regular division in the language you picked. If a decimal place shows up in the output, the port broke, and chapter 18 explains which column of the table it broke in. Porting roadmap This section reads on its own. If you skipped the rest of the chapter to port the app, start here: it says in what order, what changes in each language, and how to know you’re done. Port the four features in this order: menu, tab, payment, loyalty. The order isn’t arbitrary, and it isn’t a code dependency either: no feature imports another out of technical necessity. Each one depends on the previous ones in vocabulary. The menu introduces repository, sealed union, and renderable state with the smallest possible surface. The tab adds a lifecycle on top of that vocabulary. Payment adds error, which is a lifecycle with a bad outcome. Loyalty is the only one that requires the previous three finished, because the three callers of shared/ live in them. Chapter 18’s equivalence table is the translation map. These are the rows that decide the port the most: Language sealed/union Exhaustiveness Dart sealed class switch expression, guaranteed

Go none; (T, error) none TypeScript discriminated union assertNever , opt-in Rust algebraic enum exhaustive match , mandatory Language Result versus exceptions Dart sealed Result at the boundary; exception in infra Go error as a value, native; panic is a bug TypeScript exception by default; neverthrow /Either growing Rust Result plus ? is THE idiom Three cut-down ports live in ports/ , one per divergence. They don’t reproduce the whole app: each one brings the piece where the language forces the architecture to change shape. Go The piece that changes shape is the use case. Go has no sealed union, and the return becomes (T, error) : exhaustiveness stops being a compiler guarantee and becomes review discipline. The snippet is in ports/go/main.go : // splitTab returns (T, error) instead of a sealed union. // // The remainder goes to the FIRST shares, one unit at a time: the share

// at index i gets one extra unit if, and only if, i < remainder. func splitTab(tab Tab, people int) (SplitTabData, error) { if people < 1 { return SplitTabData{}, ErrInvalidPeopleCount } if tab.Paid { return SplitTabData{}, ErrTabAlreadyPaid } Nothing forces the caller to tell ErrInvalidPeopleCount apart from ErrTabAlreadyPaid , and nothing warns when a third error shows up. In Dart the compiler charges for the new branch. In Go the reviewer is the one who charges, and that’s the trade chapter 18 calls disciplinary compensation. TypeScript The piece that changes shape is the view. It’s the most replaceable of the four, and the port makes that explicit: the orchestrator publishes state to subscribers and doesn’t know who draws it. The snippet is in ports/typescript/menu.ts : // The orchestrator publishes state and doesn't know who draws it.

// Swapping React for Svelte, for Vue, or for nothing only swaps the // subscriber. export class MenuOrchestrator { private state: MenuState = { type: “loading” }; private readonly subscribers: ((s: MenuState) => void)[] = []; private readonly fetchMenu: () => Promise<readonly MenuItem[]>; constructor(fetch: () => Promise<readonly MenuItem[]>) { this.fetchMenu = fetch; } subscribe(subscriber: (s: MenuState) => void): void { this.subscribers.push(subscriber); subscriber(this.state); }

Swapping React for Svelte swaps the subscriber and nothing else. That’s why the repository’s CONTRIBUTING.md doesn’t require a port to reproduce the screen: demanding the same interface in ten languages would turn the acceptance criteria into a framework fight. Rust The piece that changes shape is the repository, or rather, the type it returns. Rust already ships Result and enum with data built into the language, so the manual construction from chapters 8 and 15, which in Dart costs a sealed class plus one final class per variant, disappears. The snippet is in ports/rust/src/main.rs : /// The signature is the language's own Result<T, E>. No hand-written /// sealed class: the ? operator and the exhaustive match come /// built in. fn split_tab(tab: &Tab, people: i64) -> Result<Vec, SplitRefusal> { if people < 1 { return Err(SplitRefusal::InvalidPeopleCount(people)); } if tab.paid {

return Err(SplitRefusal::TabAlreadyPaid); } The generic Result<T, E> that chapter 21 points to as a symptom of code generated with no architecture is, in Rust, the language’s own idiom. The difference is who wrote the error variants: here SplitRefusal names the two business refusals, and that’s what separates a Result with meaning from a Result<T, String> . The port’s acceptance criterion is a single one: the case suite in cases/ passes whole, value by value and in the printed order. The cases live in three JSON files, one per rule, so a partial port can run what it already implemented. JSON because all 10 languages read JSON from their standard library, and the suite exists for whoever ports, not for whoever wrote the original. Passing the suite is a necessary condition for a correct port. It isn’t sufficient, and the next section explains why.

The critique: “book examples always work” I used to be that skeptical reader, and the distrust is fair. A book example has no deadline, no customer complaining, and it usually gets born with the outcome agreed on in advance. Three answers, and none of them is “but this one is different.” The first is that the repository is open to issues, and the invitation is literal. Found a spot where the architecture gets in the way instead of helping? Open an issue with the file path and the change request that made it awkward. I won’t answer that you didn’t understand FOCUS. The CONTRIBUTING.md says how a port gets in and what the suite proves; it doesn’t say the app is good. The second is naming the weak points here, instead of waiting for you to find them. The app has a single table, hardcoded in lib/main.dart , because a table selector wouldn’t teach anything the rest doesn’t already teach. Loyalty points enter as a number in the composition root, with no customer record. The card reader is local and always answers instantly, so nothing in the app exercises a retry, a real timeout, or a pending payment. History is missing too. A paid tab goes to the database and nobody ever reads it back. Each of these absences is a new slice, and none of them would change the shape of the four that exist. The third is the one that stings. Full FOCUS doesn’t pay off in a throwaway prototype. If what you have is a weekend and the question is whether the idea interests anyone, four layers, sealed types per state, and a case suite are pure cost. Write it all in one file, show it to three people, throw it away. This book’s architecture pays off when the code is going to be changed by someone else, or by you six months from now, who is practically the same person.

And there’s an argument worth more than the three. This app didn’t work on the first try. The payment section shows the exact spot where the model was wrong, an enum with a single channel, and the damage it caused in two slices that weren’t even its own. The defect got past 71 green tests and a clean analyzer. It’s printed in the chapter with the fix right beside it, because an example that never fails teaches less than one that fails and shows where. Pitfalls Running the app before reading the specs. The repository’s four specs in docs/ say what each slice is supposed to do, and they were committed before its code. Whoever reads only the code discovers the “how” and misses the “what,” which is exactly what chapter 21 uses to restrain the code generator. Read docs/001- menu/spec.md before opening lib/features/menu/ . Testing the pieces and never assembling the toy. This is the most expensive defect this app had, and the most embarrassing to admit in a chapter that promises four slices working together. All four were written. All four had use case, orchestrator, and repository tests, all green. Two of them, payment and loyalty, weren’t on the screen: the composition root only assembled menu and tab. The pay button wrote “paid” straight to the database, without ever triggering the card reader, so the app charged nothing, and the exhaustive Result the payment section describes never ran once while the app was running. No test could catch this, because testing a piece is exactly testing a piece. flutter test claimed every slice worked on its own, and every slice really did work. Nothing claimed the app assembles the four. The fix was a new file, test/app_test.dart , whose first test is thirteen lines long: it boots the app and looks for the four views

in the widget tree. At tag coffee-v2 the file has already grown, because a second test walks the whole counter, but what closed the hole were the first thirteen lines. It also caught, as a bonus, a width overflow on the payment buttons, which nobody would have seen without opening the window. If your architecture separates the pieces well, and FOCUS does, you get a blind spot exactly where they meet. Write the test that boots the whole system and checks the parts are there. Just one, the dumbest possible one. It doesn’t replace the piece tests: it covers the one spot the piece tests, by definition, don’t look at. Trusting a green suite to say the screen opens. When I finished the tab slice, the 71 tests passed and the analyzer didn’t complain about anything. I opened the app and the menu showed up on the left, with the tab spinning a progress indicator forever on the right. The orchestrator was born in Loading and only registered handlers for the interaction events, so the load event and the line that fires it in lib/main.dart were both missing. The menu slice had both, and that’s why it worked. No test caught it, and the reason matters: every blocTest in that file starts by firing an interaction, and nobody had written the case where the orchestrator just got born and the user hasn’t touched anything yet, which is exactly the state it finds the app in when it opens. The rule that stuck: an orchestrator whose initial state is a loading state needs a load event, the matching ..add() in the composition root, and a test that fires only that event. Without that test, your suite can’t tell whether the app opens. Porting syntax instead of architecture. Translating line by line produces Dart written with Go’s words, and the result has the shape of the original with none of its guarantees. Chapter 18 closes with the tip to translate anchor by anchor, and there are three anchors. The signature that returns a Result, the single spot where the exception dies, and the place where the concretes

get built. Reproduce the three in your language, in whatever order your language allows. If your port has try in three different files, you ported syntax. Copying the whole structure into a weekend prototype. Already in the previous section, and I repeat it here because it’s the most common mistake for someone who just finished an architecture book. Four layers for a screen you’re going to throw away is the same waste chapter 4 calls speculative functionality, just applied to the design. A port that passes the suite with the layers scrambled. The suite proves behavior, not design. You can pass every case with business rules inside the view and try / catch scattered around, and the CONTRIBUTING.md says so out loud. Passing isn’t the same as getting it right. The port’s architecture review is chapter 21’s checklist: the same questions you’d ask of AI-generated code apply, without adding or removing a single one, to code ported by hand. Q&A Why Flutter, if the book is multi-language? Because the app needed to run on a single machine, with an embedded database and no server, and because chapter 12 already fixed Flutter as Dart’s canonical framework. The choice shows up in the *_view.dart files and nowhere else: the orchestrator knows package:bloc , the use case knows nothing, and the repository knows drift. Swapping Flutter for another screen touches the screen files, a handful per slice. Isn’t the app too small to prove architecture? It’s small, and it proves little on its own. What it proves is what a small app can prove: that the four pieces fit together with no ceremony, that the slice with no rule ends up with no use

case, and that a modeling mistake in lib/infra/failure.dart leaks into two slices. Architecture proves itself in change, not in size, and that’s what the exercises ask for. Can I use a different database? You can, and the swap stays local. Only lib/infra/database/ and the files in /data/ import drift. Rewrite the implementations of MenuRepository and TabRepository with whatever you want, keep the interfaces, and nothing above data/ finds out. If something above breaks, chapter 15’s boundary was leaking before the swap. Does the port need to pass every case, or just the ones for the feature I ported? Just its own. The cases live in three separate files exactly for that reason, and a partial port is welcome in ports/ . What the CONTRIBUTING.md demands is that the file you declared passes whole, value by value and in order. Quick tip Before porting, run flutter test and read the test names with flutter test --reporter expanded . The list of names is the app’s executable specification, feature by feature, and it’s shorter than the four specs put together. Porting with that list open beside you turns the port into an exercise of making names turn green. Quick reference Feature Concepts it uses Menu dumb view, exception-

translating repository, slice with no use case Tab sealed lifecycle, pure use case, rule of 3 on consumption Payment Result at the boundary, decline versus channel failure, pure split Loyalty pure function, shared/ by the rule of 3, equivalences Feature Chapters behind it Menu 12, 13, 15, 19 Tab 8, 13, 14 Payment 7, 8, 15, 16 Loyalty 5, 7, 11, 14, 18 Exercises

  1. Add “out of stock” to the menu through the full path: write the change to the docs/001-menu/ spec first, then the test, then the code. The result precedent already exists: ItemOutOfStock has been a DiscountResult variant since chapter 14, and the column already sits in the database. Finish with flutter test green and the screen showing the item struck through and unclickable.
  2. Port the menu slice to your language and make cases/loyalty- discount.json pass on it. No view required, if you’d rather skip it: a function that receives the state and returns lines of text is

enough, the way the TypeScript port does it. Open a pull request in ports/ once it passes. Tip 22: architecture proves itself on the second change request, not on the first commit. The first commit is easy with any design. That’s why it’s no evidence. The second request is the one that reveals where the decisions live: if the change fits inside one slice, the design held; if it opens seven files across four folders, it didn’t, and now you know which boundary leaked. You have a project of your own waiting for a request like that. Pick the feature that hurts the most to change, sketch the four pieces for it on paper, and see how many files the next request opens. Next chapter: the app exists, and so do the specs, and that’s where the problem begins. Who guarantees the two still say the same thing six months from now?

Powered by TurnKey Linux.