Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

42KB

FOCUS Architecture — Chapter-18: What to Do When the Language Doesn’t Help

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

What to Do When the Language Doesn’t Help In this chapter, you’ll: locate FOCUS’s three anchors (business rule, exception that becomes a Result, repository injection) in any of the ten implementations of chapter 10’s slice; port the slice to an 11th language using the five-column equivalence table, without rereading chapters 10 through 17; say what YOUR language doesn’t enforce for the team and which discipline foots that bill, with the cost measured in lines, not in opinion. You finished Part III with the whole coffee shop slice working in one language. Now comes the question that decides whether FOCUS is an architecture or an accent: what survives when you switch languages? This chapter shows that the pieces and the arrows survive intact, that three language features change names without changing jobs, and that where a feature doesn’t exist, a discipline with a known price exists instead. The discount use case has existed since chapter 14: a tab with items, a customer with points, 10% off above 100 points, an out- of-stock item cancels the order. The full slice around that use case (View, orchestrator, repository, and composition root) was published in chapter 10, in the book’s ten official languages, and

it’s still available at https://focus.kodel.com.br/en/dart/10-01 (swap dart for your language: ts , java , csharp , go , php , python , kotlin , swift , rust ). This chapter doesn’t reprint any of that. It does the work that’s missing: hand you the map that turns ten similar- looking files into ONE drawing with ten spellings. The slice on the board Before the spellings, the drawing. Here it is, and it’s the same across all ten of chapter 10’s implementations, comment bytes aside: The event enters through the orchestrator (chapter 13). The orchestrator fetches data from the repository (chapter 15), hands everything ready-made to the use case (chapter 14), saves whatever held up, and publishes the new state. The View draws the state and nothing else (chapter 12). Open any file from the /en//10-01 route, and that’s the flow sitting there, in the same order, with the same business names. What changes from language to language fits in three spots, and this chapter calls those spots anchors: where the business rule lives, where the exception becomes a Result, and where the repository gets injected. Find those three, and you’ve found the architecture; the rest is syntax around it.

Types first: four families and one warning The piece most sensitive to language choice isn’t the orchestrator or the repository: it’s the pair of types chapter 8 called errors as values. The use case’s Result ( DiscountApplied | NotEligibleForDiscount | ItemOutOfStock ) and the tab’s state ( Loading | Ready | Failed , from chapter 13) need two guarantees: the list of variants is closed, and whoever consumes the list is forced to handle all of them. Seven of the ten languages write that closed list, and they group into four spelling families. In five of them (Kotlin, Dart, Swift, Rust, and Java) both guarantees come from the compiler, and forgetting a variant is a build error. TypeScript and Python close the list the same way, but coverage is only checked if an external type checker runs, and that difference decides who pays the bill when someone forgets a case. The other three, Go, C#, and PHP, don’t close the list with any proof at all. PHP gets its own section right below; Go and C# wait for “When the language doesn’t help,” alongside Python, because in those three the guarantee turns into team discipline. Kotlin // The use case's Result: a sealed class, three variants, each // refusal with its reason typed. sealed class DiscountResult data class DiscountApplied(val tab: Tab) : DiscountResult()

data class NotEligibleForDiscount( val points: Int, val pointsNeeded: Int, ) : DiscountResult() data class ItemOutOfStock(val name: String) : DiscountResult() // The exhaustive when: no else branch, and if a new variant is // born, this file STOPS compiling until you decide what it shows. fun describe(result: DiscountResult): String = when (result) { is DiscountApplied -> “Total with discount: ${result.tab.totalInCents} cents” is NotEligibleForDiscount -> “Missing ${result.pointsNeeded - result.points} points”

is ItemOutOfStock -> “${result.name} is out of stock” } The sealed class family: the word sealed closes the list of subclasses in the same file (Kotlin) or the same library (Dart), and that closure is what lets the compiler prove the when above covered everything. Notice what’s NOT there: no else . The else is exactly the hole a new variant would slip through unnoticed. Dart writes the same drawing with sealed class and a switch expression with destructuring patterns: DiscountApplied(:final tab) => ... . The spelling gap has a historical reason: Kotlin has had sealed since 2016 and when was always an expression; Dart only got sealed and switch as an expression in Dart 3 (2023), and used the chance to bring object patterns along, so the Dart version destructures right in the arm instead of doing a smart cast. Try it: open https://focus.kodel.com.br/en/kotlin/18-01 (or https://focus.kodel.com.br/en/dart/18-01) and delete the ItemOutOfStock arm from the when . The compiler points at the missing arm before you even run it. Now add an OnTheHouse variant to the sealed class: EVERY switch in the program breaks at once, and that cascading break is what you want. Swift // The use case's Result: an enum, each variant carries ITS OWN data. enum DiscountResult { case discountApplied(Tab)

case notEligibleForDiscount(points: Int, pointsNeeded: Int) case itemOutOfStock(name: String) } // The exhaustive switch: no default, and if a new variant is born, // this file STOPS compiling until you decide what it shows. func describe(_ result: DiscountResult) -> String { switch result { case .discountApplied(let tab): return “Total with discount: (tab.totalInCents) cents” case .notEligibleForDiscount(let points, let pointsNeeded): return “Missing (pointsNeeded - points) points” case .itemOutOfStock(let name): return “(name) is out of stock” } }

The algebraic enum family: instead of a base class with subclasses, a single enum whose variants carry associated values. It’s the same concept with a different genealogy: Swift and Rust inherited the design of algebraic data types from ML and Haskell, where the sum of variants is a type in itself, not a hierarchy. In practice the difference shows up in weight: the sealed-class version asks for one declaration per variant, the enum declares all three in three lines. Rust writes enum DiscountResult with variants DiscountApplied(Tab) and NotEligibleForDiscount { points: u32, points_needed: u32 } , and match plays the role of switch with the same proof of coverage, no _ arm. Rust’s _ and Swift’s default are the same hole else was in the previous family. It exists, sure. The team’s agreement is to use neither of them on top of a Result or a state. Try it: open https://focus.kodel.com.br/en/swift/18-02 (or https://focus.kodel.com.br/en/rust/18-02) and swap the order of the case s. Nothing changes: exhaustiveness isn’t order, it’s coverage. Then delete case .itemOutOfStock and watch the error point at the variant by name. TypeScript // The use case's Result: a union discriminated by the kind field. type DiscountResult = | { readonly kind: “discountApplied”; readonly tab: Tab } | {

readonly kind: “notEligibleForDiscount”; readonly points: number; readonly pointsNeeded: number; } | { readonly kind: “itemOutOfStock”; readonly name: string }; // The exhaustiveness bodyguard, and it's optional: it only exists if // you write it. The never is only checked with the type checker on. function assertNever(value: never): never { throw new Error(Unhandled case: ${JSON.stringify(value)}); } // The switch with assertNever in the default: forget a case and the // TYPE CHECKER complains; without it, the miss only blows up at // runtime. function describeResult(result: DiscountResult): string {

switch (result.kind) { case “discountApplied”: return Total with discount: ${result.tab.totalInCents} cents; case “notEligibleForDiscount”: return Missing ${result.pointsNeeded - result.points} points; case “itemOutOfStock”: return ${result.name} is out of stock; default: return assertNever(result); } } The discriminated union family: the closed type is a union of object shapes, and the kind field is the tag TypeScript’s narrowing uses to know, inside each case , which shape is in hand. Here sits a distinction the two previous families don’t ask for. TypeScript ALLOWS exhaustiveness, but doesn’t REQUIRE it. A switch without the default: assertNever(...) compiles just the same, and the forgotten case quietly returns undefined . assertNever is opt- in: you write the function, you remember to use it in every

switch, and tsc only complains if it’s running. It’s real protection, but protection the team installs, not one the language ships out of the box. Python belongs in this family for the same reason: a frozen dataclass per variant, a union DiscountApplied | NotEligibleForDiscount | ItemOutOfStock , and typing.assert_never in the match ’s case _: . Python’s protection depends on mypy or pyright running in CI, and the Python workaround section, further down, shows what happens without them. Try it: open https://focus.kodel.com.br/en/ts/18-03 (or https://focus.kodel.com.br/en/python/18-03), delete a case , and run the type checker. Then delete the assertNever from the default too and run it again: the error is gone, and the forgotten case turned into undefined on the screen. Java // The use case's Result: a sealed interface plus one record per // variant. The permits is the closed list the compiler now watches. sealed interface DiscountResult permits DiscountApplied, NotEligibleForDiscount, ItemOutOfStock {} record DiscountApplied(Tab tab) implements DiscountResult {}

record NotEligibleForDiscount(int points, int pointsNeeded) implements DiscountResult {} record ItemOutOfStock(String name) implements DiscountResult {} // The switch with patterns (Java 21): no default, and if a new // record enters permits, this file STOPS compiling. static String describe(DiscountResult result) { return switch (result) { case DiscountApplied(Tab tab) -> “Total with discount: " + tab.totalInCents() + " cents”; case NotEligibleForDiscount(int points, int pointsNeeded) -> “Missing " + (pointsNeeded - points) + " points”; case ItemOutOfStock(String name) -> name + " is out of stock”; };

} Java earns its own label because it’s the rare case of a language that arrived LATE to the party and brought the whole package: sealed interface (Java 17, 2021), record (Java 16), and switch with record patterns and proof of exhaustiveness (Java 21, 2023). The explicit permits is the house style: where Kotlin infers the closed list from the file, Java asks for the list in writing, in the style of someone who’s already been burned by 25 years of open hierarchies. If your production Java is still 11 or 17 without preview features, you don’t have the exhaustive switch, and in that case the C# recipe coming up (a hand-rolled Match method) works exactly the same way in Java. Try it: open https://focus.kodel.com.br/en/java/18-04, add a record OnTheHouse to permits , and recompile without touching the switch. The error that shows up names the missing pattern by name. PHP // PHP's match: it looks like Kotlin's when, but NOTHING here is // checked at compile time. A forgotten instanceof only blows up at // runtime. function describeResult(DiscountResult $result): string {

return match (true) { $result instanceof DiscountApplied => “Total with discount: {$result->tab->totalInCents} cents”, $result instanceof NotEligibleForDiscount => ‘Missing ' . ($result->pointsNeeded - $result->points) . ' points’, $result instanceof ItemOutOfStock => “{$result->name} is out of stock”, }; } PHP falls outside the four families for a reason its own match documentation states plainly: when no arm matches, match throws \UnhandledMatchError at RUNTIME (php.net, “match”). The spelling lies to you. PHP 8’s match looks like Kotlin’s when , returns a value, and compares with === , but the guarantee didn’t come along for the ride. PHP 8.1’s enum and 8.2’s readonly classes give you immutability and a list of cases, and even then no native tool proves coverage before deploy. Don’t treat this as a detail. It’s the difference between the customer at table 13 seeing “connection error” and seeing a fatal error’s blank white screen. The discipline that compensates is the same as Python’s: PHPStan or

Psalm in CI, at the level that flags a non-exhaustive match , and no merge with the analysis turned off. The full PHP slice lives at https://focus.kodel.com.br/en/php/10-01, and it carries this fragility in its body: it’s the largest of the ten implementations, and the numbers section further down shows what that costs. The three anchors, family by family With the types in place, the rest of the translation is finding three lines. It doesn’t matter which language: chapter 10’s slice has ONE use case signature that takes ready data and returns a Result (anchor 1, chapter 14), ONE point where an infrastructure failure becomes a value (anchor 2, chapter 15), and ONE spot where the concrete repository gets built and injected (anchor 3, chapter 9). Below, the minimal excerpt from each family with all three anchors together, pulled literally from the files published at the /en//10-01 route. The type names are chapter 10’s own ( TabResult , with variants TabUpdated , InvalidItem , and InfraFailure ): that chapter’s gesture was adding an item, and each operation’s Result belongs to that operation, as chapter 14 established. Kotlin · Dart // Anchor 1: the use case's signature, data goes in, Result comes out. typealias AddItemToTab = (tab: Tab, item: Item, loyaltyPoints: Int) -> TabResult

// Anchor 2: the repository's boundary, exceptions die here. return try { val saved = saveToServer(tab) tabs[saved.table] = saved TabUpdated(saved) } catch (error: IllegalStateException) { InfraFailure(error.message ?: “unknown failure”) } // Anchor 3: the composition root, the concretes are born here, and // only here. val repository = FakeTabRepository() val orchestrator = TabOrchestrator( repository,

::addItemToTab, ) In the sealed family, the use case is a function typealias : the rule is a pure function and the orchestrator receives the function, not a class. Anchor 2’s catch is the ONLY try-catch in the entire file, and it returns a Result variant instead of rethrowing. Anchor 3 lives in main : no other line in the file writes FakeTabRepository() . Rust // Anchor 1: the use case's signature, data goes in, Result comes out. type AddItemToTab = fn(&Tab, &Item, u32) -> TabResult; // Anchor 2: the repository's boundary. Rust has no exceptions; the // failure is BORN a value, and Err plays the role the catch did. fn save(&mut self, tab: Tab) -> Result<Tab, String> { if tab.table == 13 { return Err(“no connection to the server”.to_string()); }

// Anchor 3: the composition root, the concretes are born here, and // only here. let mut orchestrator = TabOrchestrator { repository: FakeTabRepository { tabs }, add_item: add_item_to_tab, state: Box::new(|state| println!("{}", tab_screen(&state))), }; In the enum family, anchor 2 changes face without changing job: Rust has no exception to translate, so the boundary returns Result<Tab, String> directly, and Err is the same line catch was in Kotlin. Swift sits halfway: it has throws in the infrastructure, and the boundary uses do/catch to return .infraFailure , as you can check at https://focus.kodel.com.br/en/swift/10-01. Rust’s anchor 3 builds the orchestrator’s struct with named fields; dependency injection without a framework is just that, a struct literal in main . TypeScript // Anchor 1: the use case's signature, data goes in, Result comes out. type AddItemToTab = ( tab: Tab,

item: Item, loyaltyPoints: number, ) => TabResult; // Anchor 2: the repository's boundary, exceptions die here. try { const saved = await this.saveToServer(tab); this.tabs.set(saved.table, saved); return { type: “tabUpdated”, tab: saved }; } catch (error) { return { type: “infraFailure”, reason: ${error} }; } // Anchor 3: the composition root, the concretes are born here, and

// only here. const repository = new FakeTabRepository(); const orchestrator = new TabOrchestrator( repository, addItemToTab, ); In the union family, the Result that comes out of catch is an object with a type field, because the discriminated union is the local shape of a sealed class. The await in anchor 2 recalls a point the other families hide: in TypeScript the repository is asynchronous by nature, and the boundary translates both the exception and the rejected promise. On the same route’s Python version, anchor 2 is an except that returns the failure dataclass, and anchor 3 builds the orchestrator in main with the fake repository passed positionally. Java // Anchor 1: the use case's signature, data goes in, Result comes out. @FunctionalInterface interface AddItemToTab { TabResult apply(

Tab tab, Item item, int loyaltyPoints); } // Anchor 2: the repository's boundary, exceptions die here. try { Tab saved = saveToServer(tab); tabs.put(saved.table(), saved); return new TabUpdated(saved); } catch (Exception error) { return new InfraFailure(error.getMessage()); } // Anchor 3: the composition root, the concretes are born here, and // only here.

TabRepository repository = new FakeTabRepository(); TabOrchestrator orchestrator = new TabOrchestrator(repository, UseCases::addItemToTab); Java swaps the typealias for a @FunctionalInterface , which is the platform’s way of naming a function type, and injects the rule with a method reference ( UseCases::addItemToTab ). Other than that, the three anchors are the same, in the same spots. That’s this whole chapter’s practical test: open the /en//10-01 route of a language you DON’T know and look for the three anchors. If you found the signature that returns a Result, the only try-catch (or the only Err ), and the single spot that builds the concrete repository, you just read a FOCUS implementation in a language you never studied. Try it: randomly pick one of chapter 10’s ten routes, in a language you don’t use day to day, and time how long it takes you to mark the three anchors. My guess: less time than it took you to find the discount rule in chapter 2’s monolith. When the language doesn’t help Five languages deliver the proof of coverage in the compiler. The other five don’t, and they’re among the most used in the world. PHP already got its section, alongside the types; that leaves Go, C#, and Python. Pretending that gap doesn’t exist would treat those languages’ readers as second-class citizens, so the three

sections below do the opposite: they show the naive code the gap allows, the concrete pain it causes, and the workaround with the price on the label. Go: no union, no exhaustiveness, and a native Result Go rejected unions and exhaustiveness by design decision, not out of lag: the language prefers a single mechanism (values) over a type system that proves coverage. The anti-solution shows up when you try to write the tab’s state as if Go had a real enum: Go // ANTI-SOLUTION: a string “enum” and a switch the compiler doesn't // watch. Forget the “failed” case and this file compiles without a // peep. func describeStateNaively(state string, tab Tab) string { switch state { case “loading”: return “Loading...” case “ready”: return fmt.Sprintf(“Table %d ready”, tab.Table)

} // The “failed” case doesn't exist and nobody warned you: whoever // lands here takes an empty string to the screen. return "” } The pain: the “failed” case has no arm, the compiler says nothing, and Rosie’s screen shows an empty string at the exact moment it should show “no connection to the server.” No happy-path test catches this. The workaround has two parts. For the use case’s Result, Go already has the right idiom out of the box, and Rob Pike named it on the official blog: “errors are values” (go.dev/blog/errors-are-values, 2015). Chapter 14’s rule returns (Tab, error) with errors named per variant: Go // WORKAROUND, part 1: the use case's refusals become named errors, // values the caller is forced to look at (or ignore, in writing). var ( ErrNotEligibleForDiscount = errors.New(“fewer than 100 points”) ErrItemOutOfStock = errors.New(“item out of stock”)

) // WORKAROUND, part 2: chapter 14's rule returns (T, error), Go's // native Result. Above 100 points, 10% off; an out-of-stock item // cancels the order. func applyLoyaltyDiscount( tab Tab, loyaltyPoints int, ) (Tab, error) { for _, item := range tab.Items { if item.OutOfStock { return Tab{}, fmt.Errorf( “%w: %s”, ErrItemOutOfStock, item.Name, ) } }

if loyaltyPoints < 100 { return Tab{}, fmt.Errorf( “%w: customer has %d”, ErrNotEligibleForDiscount, loyaltyPoints, ) } total := tab.TotalInCents discounted := total - total*10/100 return Tab{ Table: tab.Table, Items: tab.Items, TotalInCents: discounted, }, nil }

For the tab’s state, which needs variants with different data, the recipe is a struct closed BY CONVENTION: private fields, one named constructor per case ( Loading() , Ready(tab) , Failed(message) ), and the team-agreed rule that nobody builds the struct by hand. The price sits in that word, agreed. Go’s compiler doesn’t stop a zeroed TabState{} , and it doesn’t charge for a new case in renderState ; whoever charges for it is code review, every time, forever. I’d rather pay that price than fake a hierarchy with empty interfaces and type switches scattered around, because the cost stays concentrated in two auditable spots: the state file and the team’s review checklist. Try it: open https://focus.kodel.com.br/en/go/18-05 and run it. The output’s first line is the anti-solution returning "” for the forgotten state. Then add a Cancelled() constructor to the state and notice that NOTHING breaks: that’s the difference from the four families, and the reminder to update renderState is yours, not the compiler’s. C#: exhaustiveness that’s just a warning C# has records, pattern matching, and a switch expression, and it still falls outside the four families over a detail many teams only find out about in production: switch coverage isn’t an error, it’s warning CS8509. The anti-solution compiles: C# // ANTI-SOLUTION: switch expression missing the Failed arm. The // compiler emits warning CS8509 and moves on; the build passes.

static string DescribeNaively(TabState state) => state switch { Loading => “Loading...", Ready ready => $"Table {ready.Tab.Table} ready”, }; The pain arrives in two stages. At build time, Microsoft politely warns: “the switch expression does not handle all possible values of its input type” (warning CS8509, documented at learn.microsoft.com). Polite warnings drown among 200 others in a large project. Then, the day a Failed reaches that switch, the runtime throws SwitchExpressionException , a forgotten-case exception exploding in front of the customer, exactly what chapter 8 told you to prevent. The workaround turns the omission into a COMPILE error using what C# has that’s strongest, the method signature: C# // WORKAROUND: the sealed hierarchy. abstract record plus a private // constructor close the list of variants in the same file. abstract record DiscountResult

{ private DiscountResult() { } public sealed record DiscountApplied(Tab Tab) : DiscountResult; public sealed record NotEligibleForDiscount( int Points, int PointsNeeded ) : DiscountResult; public sealed record ItemOutOfStock(string Name) : DiscountResult; // Match is the exhaustiveness the language didn't give you: one // delegate per variant, all of them mandatory. New variant = new // parameter = every call site breaks at COMPILE time.

public T Match( Func<DiscountApplied, T> onApplied, Func<NotEligibleForDiscount, T> onNotEligible, Func<ItemOutOfStock, T> onOutOfStock ) => this switch { DiscountApplied applied => onApplied(applied), NotEligibleForDiscount notEligible => onNotEligible(notEligible), ItemOutOfStock outOfStock => onOutOfStock(outOfStock), _ => throw new InvalidOperationException(), }; } The private constructor keeps heirs out of other files, so the list is truly closed. Match takes a delegate per variant, and delegates are parameters: miss one, and the code doesn’t even compile. When someone adds the OnTheHouse variant, Match gains a fourth

parameter and EVERY call site breaks together, which is the four families’ behavior, rebuilt by hand. If you’d rather not write that boilerplate, the OneOf library (github.com/mcintyre321/OneOf) delivers the same Match through metaprogramming; the choice between the two is taste, the design is the same. The workaround’s cost: one Match method per closed type, maintained by whoever maintains the type, and a team rule that a direct switch on top of these hierarchies either handles ALL the cases or doesn’t pass review. Promoting CS8509 to an error in the .csproj ( WarningsAsErrors ) closes the loop from above. I go further than that: I turn on every warning as error in every project of mine, from the first commit. A warning nobody reads isn’t protection. Try it: open https://focus.kodel.com.br/en/csharp/18-06, compile it, and watch warning CS8509 point at DescribeNaively . Then delete the onOutOfStock: argument from any Match call and compare: the warning turned into an error, and that promotion is what you bought. Python: the habit the Result needs to unlearn Python has everything the union family uses: frozen dataclass , union, match , and typing.assert_never (documented in the official typing module docs, Python 3.11+). Python’s problem isn’t a missing feature, it’s a habit with its own name: EAFP (Easier to Ask Forgiveness than Permission), the style enshrined in the language’s own official glossary, which says to try first and catch the exception after. For IO, EAFP works well. For business rules, it produces this: Python

ANTI-SOLUTION: a business refusal as an exception, the EAFP way. The

signature promises a Tab and hides the two refusals; the distracted

caller only discovers ItemOutOfStockError in production.

class NotEligibleForDiscountError(Exception): pass class ItemOutOfStockError(Exception): pass def apply_discount_naively( tab: Tab, loyalty_points: int, ) -> Tab: for item in tab.items: if item.out_of_stock:

raise ItemOutOfStockError(item.name) if loyalty_points < 100: raise NotEligibleForDiscountError(loyalty_points) total = tab.total_in_cents return replace(tab, total_in_cents=total - total * 10 // 100) The pain: the signature says -> Tab and it’s lying, because the function has three exits and two of them travel over a channel no type checker tracks. The caller who forgets the try gets no tooling warning at all; ItemOutOfStockError crosses the orchestrator, crosses the View, and blows up in the production log, far from the rule that raised it. The workaround is the union family’s spelling, which you already saw in the types section: a frozen dataclass per variant, a union as the Result, and assert_never closing the match : Python

WORKAROUND, part 3: match + assert_never. Delete an arm and mypy

flags it; WITHOUT a type checker, Python runs this file the same

way, and the missing arm only blows up when that case reaches

production.

def describe(result: DiscountResult) -> str: match result: case DiscountApplied(tab): return f"Total with discount: {tab.total_in_cents} cents” case NotEligibleForDiscount(points, points_needed): return f"Missing {points_needed - points} points” case ItemOutOfStock(name): return f”{name} is out of stock” case _: assert_never(result) Before you test this and think the recipe is broken: running this file with one arm short does NOT throw anything in the interpreter, as long as the forgotten case never shows up in the data. assert_never isn’t runtime magic; it’s a function that only makes sense to the type checker. All the protection lives in mypy -- strict (or pyright) running in CI as a merge gate, on the same step as the tests. Without that step, the workaround is decorative. The cost, then, is infrastructure and culture: the team accepts that domain Python code without a type checker is code with no

coverage check, and that a business refusal comes back as a value, while EAFP stays where it belongs, the IO boundary from chapter 15. Try it: open https://focus.kodel.com.br/en/python/18-07 and run it twice: python3 18-07.py exits clean even if you delete the ItemOutOfStock arm; mypy --strict 18-07.py flags the deleted arm on the assert_never line. The gap between the two runs is the exact size of your dependency on CI. The critiques, with a ruler instead of rhetoric Two objections come up every time FOCUS meets a language with no syntactic sugar, and both deserve a numbered answer. The measurement criterion, fixed before looking at any result: non-empty lines across the ten published files of chapter 10’s slice, comments included, counted with grep -cve ‘^\s*$’ . The first objection is “Result hell”: returning a Result instead of throwing an exception would drown the code in unwrapping. The criticism has pedigree; Scott Wlaschin himself, author of Railway-Oriented Programming (fsharpforfunandprofit.com/rop/, 2013), warns on that same page not to take the pattern to extremes (“don’t take it to extremes,” in his words). The ruler says this about the coffee shop’s slice: the shortest implementation has 151 non-empty lines (Kotlin and Python tied) and the longest has 237 (PHP). The 86-line gap between the extremes, 57% more in PHP than in Kotlin, has a cause you can name line by line. Kotlin declares a Result variant in one line ( data class InvalidItem(...) : TabResult() ). PHP pays for constructors, assignments, and braces for every readonly class, plus convenience getters that data class throws in

for free. The Result isn’t the villain here. All ten versions use Result the same number of times, and the gap between 151 and 237 comes from how much boilerplate each language charges for DECLARING types, not for using them. Rob Pike defends the same design with no sugar at all: “errors are values” (go.dev/blog/errors-are-values, 2015), and Go’s slice lands at 162 lines, below Java, C#, and TypeScript. The second objection says the unidirectional flow is bureaucracy: event, orchestrator, state, all that just to call a function. This criticism also has a respectable source. Redux’s documentation lists boilerplate as the community’s number-one complaint (redux.js.org, “Prior Art”), and the Elm guide, which inspired Redux according to that same page, answers that the repetitive structure is what makes the program predictable (guide.elm- lang.org/architecture/). The coffee shop’s ruler gives the bureaucracy’s size: the WHOLE slice, View, orchestrator, use case, repository, fake, and composition root, fits in 151 to 237 non- empty lines depending on the language. The unidirectional flow itself costs almost the same in all of them: what separates the extremes is the type-declaration section, as the previous paragraph measured. Real bureaucracy is what chapter 2 showed: one discount line scattered across three screens. One measurement in this section surprised me, and it’s worth recording against myself. I expected Rust, with ? , match , and derives, to produce a visibly smaller slice than Java’s. The grep says otherwise: Rust has 172 non-empty lines, Java has 165. The sugar exists where the rhetoric promises it (the use case’s match is shorter than Java’s switch), but structs, impl blocks, and closures with Box give the difference back in the rest of the file. The lesson is this chapter’s own thesis, applied to me: per-piece expressiveness doesn’t add up linearly across a file, and anyone who wants to compare languages has to run the grep, not quote reputation. Measured numbers age better than adjectives.

The equivalence table: porting to the 11th language Your language isn’t among the ten? The equivalence table is the whole chapter in five questions, split into three pieces to fit the page. Answer the five columns for your target language and you’ll know what it gives you for free, what needs a library, and what needs discipline; the three anchors and the drawing on the board do the rest. Language sealed/union Exhaustiveness Dart sealed class switch expression, guaranteed TypeScript discriminated unions never /assertNever (opt-in) Java sealed interface + record switch patterns (21), guaranteed C# abstract record / OneOf warning only Go doesn’t have one; (T, error) doesn’t have it PHP enum + unions in signature match fails at runtime Python Union + frozen dataclasses assert_never (type checker) Kotlin sealed class / interface when exhaustive, mandatory

Swift enum with associated values switch exhaustive, mandatory Rust algebraic enum match exhaustive, mandatory Language Immutability Idiomatic DI Dart final / const , copyWith constructor; get_it /riverpod TypeScript readonly , as const closures; NestJS DI in the enterprise Java record , List.of() Spring/CDI/Guice C# record + with , init MS.Extensions.DI, standard Go by value/discipline explicit constructor PHP readonly (8.2) PSR-11 container (Laravel/Symfony) Python frozen dataclass constructor; FastAPI Depends Kotlin val , data class.copy() constructor; Koin/Hilt Swift let + value semantics initializer Rust immutable by default generics/traits in the constructor

Language Result vs exceptions Dart sealed Result at the boundary; exceptions in infra TypeScript exceptions by default; neverthrow /Either growing Java exceptions dominate; Result via sealed is growing C# exceptions; OneOf / ErrorOr in the domain Go native errors-as-values; panic = bug PHP exceptions dominate Python exceptions (EAFP); Result is a niche Kotlin kotlin.Result /Arrow; exceptions at the boundary Swift Result stdlib + typed throws Rust Result + ? is THE idiom The porting roadmap uses the columns in order. First, the closed sum: if the target language has a closed sum of types (Elixir with structs and pattern matching, Scala 3 with enum , F# with discriminated unions), you’re in one of the four families and the port is spelling. Second, exhaustiveness: if the answer is “opt-in” or “doesn’t have it,” pick NOW which workaround section is yours, because that decision becomes a CI and review rule, not an invitation. Third, immutability: look for the equivalent of copy / with / replace , because chapter 14’s use case returns a new tab, never edits the one it received. Fourth, DI: anchor 3 only needs a constructor; a DI framework is optional in EVERY language on the table, as chapter 9 established. Fifth, Result vs exceptions: decide where the exception dies, and FOCUS’s answer

is always the same, at the repository’s boundary, chapter 15. Two hours with this table and the /en//10-01 route open on the side beat any tutorial for the new language; that’s how the slice reached all ten. Pitfalls The first pitfall is porting syntax instead of architecture: taking the Kotlin version and looking up “how do you write a sealed class in Go.” You don’t. The right question is different: “where does Go guarantee that the use case’s three outcomes are visible to the caller?” The answer (named errors in (T, error) ) doesn’t look anything like a sealed class, and it still plays exactly the same role: it keeps the rule’s three outcomes in plain sight for whoever calls it. Translate word for word and you get a Frankenstein with an empty interface and a type switch; translate anchor by anchor and you get Go that looks like Go. The second is trusting opt-in protection as if it were a guarantee. TypeScript’s assertNever , mypy’s assert_never , and CS8509 promoted to an error share the same fine print: someone can turn it off. If the merge passes with the type checker red, or the .csproj doesn’t promote the warning, the coverage you think you have is the coverage some future intern removes on a Saturday. Opt-in protection only counts when CI makes it mandatory, which is why it’s a question of process before it’s a question of language. Process matters as much as product and people. A team with no defined process is a team where everyone does whatever they want. The third is PHP’s false friend: match returns a value and compares with === , so it looks like Kotlin’s when , and it isn’t. The difference only shows up with the arm missing and the rare case arriving, the worst possible moment. If your team is PHP, treat

\UnhandledMatchError in monitoring as a symptom of a forgotten arm and PHPStan in CI as the vaccine, and reread this chapter’s types section before you promise exhaustiveness in code review. The fourth is using this chapter’s line count as a language ranking. The numbers measure ONE slice, in ONE formatting style, and prove only what I claimed: the flow costs about the same and the type declaration is what varies. If someone quotes “Rust is smaller than Java” or the opposite in an architecture discussion, you already know what to ask for: the grep, the criterion, and the corpus. The ruler exists to name the cause of a difference, never to crown a language. Q&A My language has guaranteed exhaustiveness. Can I skip the workaround sections? You can, until the day a Go backend joins the project or the Python data script turns into a service. The workaround sections are the map of your neighbors’ languages, and the equivalence table works in both directions. Why doesn’t the chapter use OneOf, Arrow, neverthrow, and the like in the listings? Because the slice needs to show each language’s REAL cost with no middleman. A library hides the price exactly where this chapter wants you to see it. In your own project, use them: OneOf in C# and Arrow in Kotlin are exactly this page’s workaround, packaged and tested. Is it worth adopting Result in Python code that’s all EAFP? At the IO boundary, no; EAFP is the right idiom there, as chapter 15 showed with the repository’s except . In business rules, yes, and the test is the signature: if the function has

more than one business outcome, the outcomes show up in the return type. Migrate function by function, starting with the use cases. What if my 11th language has NONE of the five columns? Then it’s Go without the (T, error) , and the recipe is the oldest one in the book: documented convention, code review, and chapter 10’s table taped to the wall. FOCUS degrades to pure discipline; the design stays the same. Quick tip Save the command grep -cve ‘^\s*$’ file (non-empty lines) in your shell. It’s this chapter’s ruler, and it works for any verbosity argument: instead of “I think it got big,” you say “the new version has 23 more lines, and 19 are the type declarations.” An argument about taste turns into an argument about cause. Quick reference Situation Recipe Reading FOCUS in a new language find the three anchors: Result, boundary, root Guaranteed sealed/enum/union use the family from the types section Any switch over a Result ban else , default , and _ Go named (T, error) ; struct closed by convention

C# sealed records with Match , or OneOf; CS8509 as an error Python dataclass + union + assert_never ; mypy on merge PHP enum + match with PHPStan in CI; monitor the error Porting to the 11th answer the five columns; translate anchor by anchor Verbosity argument measure with grep -cve ‘^\s*$’ and name the cause Exercises

  1. Open https://focus.kodel.com.br/en/csharp/18-06 and add the variant OnTheHouse(string name) to the sealed DiscountResult . The completion criterion is the trail of errors: note how many compilation errors Match ’s fourth parameter generated, and confirm each one was a spot that HAD to decide what to do with the freebie. Then repeat the gesture on your family’s language route and compare the trails.
  2. Pick a language outside the ten (Elixir, Scala, F#, or whatever your team is dating) and fill in the equivalence table’s five columns for it, with one official-documentation source per cell. Can you port chapter 14’s discount use case using only your table and the three anchors, without opening any of the ten implementations? Tip 18

Translate the architecture, not the syntax. Pieces and arrows travel between languages; spelling stays home. Next chapter: with FOCUS standing in any language, it’s time to catalog the ways to knock it down: the anti-patterns that stalk every piece, and how to spot them before the damage is done.

Powered by TurnKey Linux.