No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.

34KB

FOCUS Architecture — Chapter-14: Use Cases: Where the Rules Live

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

Use Cases: Where the Rules Live In this chapter, you’ll: write Rosie’s Coffee Shop’s loyalty discount as a pure function, applyLoyaltyDiscount , that takes data and returns a Result with the typed refusal, without touching a database, a screen, or a framework; port that same rule to the book’s ten languages and see what each one gains or loses expressing it; test that rule without a single test double, and answer why the single-implementation interface so many people ask for here is dead weight. In chapter 13 the orchestrator fetched the tab, called a closed box named addItemToTab , and published whatever came back, without deciding a thing. This chapter opens that box. Inside it lives the rule the whole book promised would have a single address: the discount Rosie gives customers who rack up points. You’re going to write it in a way that can’t go wrong in two different places, because it only exists in one. Picture the gesture that opens the box: table 4’s tab is ready, the customer holds out her loyalty card, and someone asks “how much with the discount?”. In chapter 13 that question reached the orchestrator, which passed it along without an opinion. Now it reaches its destination. That destination’s contract has been signed since chapter 10, on the third line of the canonical responsibility table:

Use Case Contract Does the only place for business rules Does is a pure function Does takes data, returns a Result Forbids IO Forbids framework Forbids domain exception Each piece of that line deserves unpacking, because the chapter implements it to the letter. Start with the word that names the piece. A use case is a business rule isolated as a named operation: a domain verb that takes the data it needs, applies the house policy, and returns the verdict. “Apply the loyalty discount” is a use case; “add two integers” isn’t, because it carries no business policy at all. “The only place for business rules” is the central promise, and it points back to chapter 5: knowledge that repeats itself diverges. If the discount lives in one place, changing it means editing a function; if it lives in three, changing it means a manhunt. “Pure function” you already mastered in chapter 7: same input, same output, no side effect. The use case doesn’t read a clock, doesn’t roll dice, doesn’t write to disk; it calculates. “Takes data” closes the loop with chapter 9: everything the rule needs arrives ready, as an argument, materialized by whoever called it. And “returns a Result” is the idiom from chapter 8: the refusal doesn’t fly off as an exception, it comes back as a typed value the caller is forced to handle.

The prohibitions are the negative of the same photo. “IO, framework, and domain exception” are out: no SELECT , no widget, no throw to signal that the customer has no points. Keep that list in mind, because the chapter’s first pitfall is violating the first one of them with the best of intentions. The anti-solution: the same rule in three places Like every chapter in this part, the right path starts at the wrong one. Rosie’s loyalty rule is simple: a hundred points or more earn a 10% discount on the total. The problem isn’t the rule, it’s where it ended up. In a codebase that grew without a use case, it leaks into every spot that needs the total already discounted. The first leak is in the View, to “show the right total on screen right away”. During a promotion, someone bumped the discount to 15% here and forgot the rest: Dart // Copy 1, in the View: to show the total right away, the rule got // written here. During the promotion, it became 15%, and only here. String totalOnScreen(int totalInCents, int points) { final percentage = points >= 100 ? 15 : 0; final discounted = totalInCents - totalInCents * percentage ~/ 100; final dollars = (discounted / 100).toStringAsFixed(2);

return “Total with discount: $$dollars”; } The second leak is in the HTTP handler, which revalidates “for safety” with the rule handwritten again, still at the old 10%. The third is a database trigger, a third truth about the same discount, now in SQL, far from the eyes of whoever reads the Dart. All three calculate the same discount, and nothing guarantees they agree: Dart int totalInHandler(int totalInCents, int points) { final percentage = points >= 100 ? 10 : 0; return totalInCents - totalInCents * percentage ~/ 100; } Run a $40.00 tab for a customer with 120 points through all three paths. The screen promises $34.00, the handler charges $36.00, the database records $36.00. The customer sees one number and pays another, and none of the three snippets has an isolated bug: each one is internally correct. The bug is the existence of three copies with the right to disagree. You already watched this movie in chapter 12 with three screens and three totals; here it’s the same disease one layer down, and the cure is the same too: one truth, and only one.

The rule as a pure function: the signature first Before writing a single line of the body, write the signature, because the signature is what defines the piece. Signature as contract is the idea that a function’s input and output types say everything it can and can’t do, before any body exists at all: Dart DiscountResult applyLoyaltyDiscount( Tab tab, int loyaltyPoints, ); Read what that line already promises. It takes a Tab and an int , and nothing else: no repository, no HTTP client, no clock along the way, so there’s no way to query the database in here, not even when temptation strikes. It returns DiscountResult , a type of its own, not an int or a bool : the refusal is going to have a name. The piece’s entire architecture is already in those three elements; the body just fills in the promise. One detail of the input deserves a name, because it’s the chapter’s thesis in miniature. Notice that the inventory information (whether “Cheese bread” is out of stock today) isn’t a separate parameter or a query: it arrives inside the Tab itself, in an outOfStock field on each item. That’s materialized data: everything the rule needs gets assembled BEFORE the call, by whoever called it, and handed over ready. Missing a piece of data? The input grows to carry it. The use case never goes looking for it.

With the signature standing, the body is almost a deduction. First, the Result: three result variants, each refusal carrying its typed reason: Dart · Kotlin · Swift sealed class DiscountResult {} final class DiscountApplied extends DiscountResult { DiscountApplied(this.tab); final Tab tab; } final class NotEligibleForDiscount extends DiscountResult { NotEligibleForDiscount(this.points, this.pointsNeeded); final int points; final int pointsNeeded; }

final class ItemOutOfStock extends DiscountResult { ItemOutOfStock(this.name); final String name; } DiscountApplied carries the tab with the total already reduced; NotEligibleForDiscount carries the points the customer has and the points they were missing, so the screen can explain the refusal without consulting any rule; ItemOutOfStock carries the name of the item that canceled the order. None of this is a loose string: every refusal is a type, and chapter 8 already showed why that matters. Kotlin writes the same contract with sealed class and data class , one line per variant: data class DiscountApplied(val tab: Tab) : DiscountResult() . Swift uses an enum with associated values, the leanest spelling in the family: case discountApplied(Tab) . Three spellings for the same design, and the rule’s body is trivially the same across all three. Here it is, and the order of the checks is the rule: Dart · Kotlin · Swift DiscountResult applyLoyaltyDiscount( Tab tab, int loyaltyPoints,

) { // First subgoal: an out-of-stock item cancels the whole order. for (final item in tab.items) { if (item.outOfStock) { return ItemOutOfStock(item.name); } } // Second: fewer than a hundred points, no discount. if (loyaltyPoints < 100) { return NotEligibleForDiscount(loyaltyPoints, 100); } // Third: ten percent off the total, truncated integer division. final total = tab.totalInCents; final discounted = total - total * 10 ~/ 100;

return DiscountApplied(Tab(tab.table, tab.items, discounted)); } Three subgoals, three return s, no side effect. The ~/ is Dart’s integer division, the same choice snippet 10-01 made: working in cents and truncating, the total is an exact int , and the chapter’s ten versions all land on the same number with no floating-point argument. A customer with 120 points on a $40.00 tab gets back DiscountApplied with 3600 cents, $36.00. The same customer, if the tab has an out-of-stock item, gets back ItemOutOfStock(“Cheese bread”) before any math runs. Notice what’s NOT here. No try , no throw , no await , no database import . The domain’s most dramatic refusal (the item ran out) is a one-line return . That’s the difference between a rule that lives in its own house and a rule squatting in the middle of a handler. A quick aside about addItemToTab , which chapter 13 left as a closed signature: it lives in this same layer, and now its body can be opened too. The signature doesn’t change a comma, Result addItemToTab( Tab tab, String item) , and the body is another pure function: it looks the item up in the menu, returns RuleViolated if it can’t find it, Success with the grown tab if it can. Same layer, same rules, each operation with its own Result. One last note on semantics, so it doesn’t get confused with chapter 10. The rule is exactly the one from snippet 10-01: threshold 100, 10%, an out-of-stock item cancels. What changes is the gesture. In 10-01, the discount showed up embedded in addItemPure , and a customer under 100 points still succeeded with a 0% reduction, because adding an item always works. Here the gesture is different: the customer ASKS for the discount when

closing the tab, and asking without having earned it is a real refusal, NotEligibleForDiscount , not a silent success. Same rule, different gesture, different Result. The same rule, ten languages Now the piece travels. The first three languages already came stacked together because the code was the same; the next seven each get their own listing. The rule is identical across all ten. What changes is how each language says “one of three results”, and that’s what’s worth comparing. In none of them is the rule rewritten: it’s the same function, ported. Java 21 has the right tool: sealed interface plus record plus pattern- matching switch . The sealed interface lists who’s allowed to implement it, the record gives you a data carrier with no ceremony, and the expression switch demands exhaustiveness just like Dart: Java sealed interface DiscountResult {} record DiscountApplied(Tab tab) implements DiscountResult {} record NotEligibleForDiscount(int points, int pointsNeeded) implements DiscountResult {}

record ItemOutOfStock(String name) implements DiscountResult {} static DiscountResult applyLoyaltyDiscount( Tab tab, int loyaltyPoints ) { for (Item item : tab.items()) { if (item.outOfStock()) return new ItemOutOfStock(item.name()); } if (loyaltyPoints < 100) { return new NotEligibleForDiscount(loyaltyPoints, 100); } int total = tab.totalInCents(); int discounted = total - total * 10 / 100;

return new DiscountApplied( new Tab(tab.table(), tab.items(), discounted)); } TypeScript has no sealed class, and the most didactic entry point is the discriminated union: a type that’s the sum of several objects, each with a literal field ( kind ) that says which one it is. The compiler narrows the type by that field’s value, and an assertNever in the final branch guarantees that if you add a fourth case and forget to handle it, tsc --strict complains: TypeScript type DiscountResult = | { readonly kind: “discountApplied”; readonly tab: Tab } | { readonly kind: “notEligibleForDiscount”; readonly points: number; readonly pointsNeeded: number } | { readonly kind: “itemOutOfStock”; readonly name: string }; function applyLoyaltyDiscount( tab: Tab,

loyaltyPoints: number, ): DiscountResult { for (const it of tab.items) { if (it.outOfStock) return { kind: “itemOutOfStock”, name: it.name }; } if (loyaltyPoints < 100) { return { kind: “notEligibleForDiscount”, points: loyaltyPoints, pointsNeeded: 100 }; } const total = tab.totalInCents; const discounted = total - Math.trunc((total * 10) / 100); return { kind: “discountApplied”, tab: { ...tab, totalInCents: discounted } };

} Rust is the conceptual north of the whole thing: the algebraic enum is made exactly for this, and the exhaustive match is the law of the language, no way around it. The NotEligibleForDiscount variant uses named fields, and DiscountApplied wraps the tab; no case can go unhandled and still compile: Rust enum DiscountResult { DiscountApplied(Tab), NotEligibleForDiscount { points: i64, points_needed: i64 }, ItemOutOfStock(String), } fn apply_loyalty_discount( tab: Tab, loyalty_points: i64, ) -> DiscountResult { for item in &tab.items {

if item.out_of_stock { return DiscountResult::ItemOutOfStock(item.name.clone()); } } if loyalty_points < 100 { return DiscountResult::NotEligibleForDiscount { points: loyalty_points, points_needed: 100, }; } let total = tab.total_in_cents; let discounted = total - total * 10 / 100; DiscountResult::DiscountApplied(Tab {

total_in_cents: discounted, ..tab }) } C# has record and switch , but with a caveat chapter 8 already flagged: exhaustiveness over a record hierarchy is only a warning, not an error. That’s why this port uses the OneOf library, which swaps the hierarchy for a closed sum type and forces a Match with one arm per case. That arm-per-case demand is exactly the exhaustiveness the language doesn’t enforce on its own: C# public static OneOf<DiscountApplied, NotEligibleForDiscount, ItemOutOfStock> ApplyLoyaltyDiscount(Tab tab, int loyaltyPoints) { foreach (var item in tab.Items) { if (item.OutOfStock) return new ItemOutOfStock(item.Name); }

if (loyaltyPoints < 100) { return new NotEligibleForDiscount(loyaltyPoints, 100); } int total = tab.TotalInCents; int discounted = total - total * 10 / 100; return new DiscountApplied(tab with { TotalInCents = discounted }); } PHP 8.2 has no real sealed union, and this port’s idiom is an abstract class with final subclasses (sealed by convention) plus a match over instanceof . The important difference lives in the match : without a default arm, an unforeseen case becomes an UnhandledMatchError at runtime, not at compile time. The enforcement exists, but it arrives late. Here’s the port: PHP function applyLoyaltyDiscount( Tab $tab,

int $loyaltyPoints, ): DiscountResult { foreach ($tab->items as $item) { if ($item->outOfStock) { return new ItemOutOfStock($item->name); } } if ($loyaltyPoints < 100) { return new NotEligibleForDiscount($loyaltyPoints, 100); } $total = $tab->totalInCents; $discounted = $total - intdiv($total * 10, 100); return new DiscountApplied(

new Tab($tab->table, $tab->items, $discounted)); } Go is the pedagogical counterpoint, and it’s worth reading closely, because it shows what gets lost without unions. Go has no sealed class and no algebraic enum; its idiom for “one of N results” is the (T, error) pair. Both refusals become typed errors ( NotEligibleForDiscount and ItemOutOfStock implement error ), and success comes back as the tab plus a nil . It works, and the listing proves it; what’s lost is compiler enforcement, because nothing forces the caller to tell the two errors apart. The idiomatic mitigation is errors.As in the caller plus a test per flow: Go type NotEligibleForDiscount struct { Points int PointsNeeded int } func (e NotEligibleForDiscount) Error() string { return fmt.Sprintf(“not eligible: %d of %d points”, e.Points, e.PointsNeeded)

} type ItemOutOfStock struct{ Name string } func (e ItemOutOfStock) Error() string { return fmt.Sprintf("%s is out of stock”, e.Name) } func applyLoyaltyDiscount( tab Tab, loyaltyPoints int, ) (Tab, error) { for _, item := range tab.Items { if item.OutOfStock { return Tab{}, ItemOutOfStock{item.Name} }

} if loyaltyPoints < 100 { return Tab{}, NotEligibleForDiscount{loyaltyPoints, 100} } total := tab.TotalInCents discounted := total - total*10/100 return Tab{tab.Table, tab.Items, discounted}, nil } Python closes the list with a frozen dataclass (the immutability from chapter 7), a | union, structural match from 3.10, and assert_never for the type checker to close the exhaustiveness. The rule is the same, with integer division as // : Python @dataclass(frozen=True) class DiscountApplied:

tab: Tab @dataclass(frozen=True) class NotEligibleForDiscount: points: int points_needed: int @dataclass(frozen=True) class ItemOutOfStock: name: str DiscountResult = DiscountApplied | NotEligibleForDiscount | ItemOutOfStock def apply_loyalty_discount(

tab: Tab, loyalty_points: int, ) -> DiscountResult: for item in tab.items: if item.out_of_stock: return ItemOutOfStock(item.name) if loyalty_points < 100: return NotEligibleForDiscount(loyalty_points, 100) total = tab.total_in_cents discounted = total - total * 10 // 100 return DiscountApplied(replace(tab, total_in_cents=discounted)) Ten languages, one rule, and the same inventory as always: what changes is how each one says “one of three results” and how early it enforces the cases you’re missing (Dart, Rust, Swift, and Kotlin at compile time; Java and TypeScript too, opt-in; C# with a

library; PHP and Python only at runtime or in the type checker; Go doesn’t enforce it at all). What doesn’t change is the rule, or the total: 3600 cents, across all ten. The ten complete versions, with a main running both canonical cases, are at the short routes in the Try it box at the end of the next section. Orchestrator fetches, use case decides Now it’s possible to name the division of labor the whole chapter has been building. Orchestrator fetches, use case decides is the pair of responsibilities that makes the discount work without leaking: the orchestrator from chapter 13 knows WHEN to act and WHERE to find the data, materializes the tab with inventory and points, and hands it over ready; this chapter’s use case takes that data and says WHAT the rule decided. One knows the address, the other knows the policy, and they never swap roles. The full cycle, with the repository still the closed box chapter 15 is going to open:

Follow the arrows. The View asks; the orchestrator fetches from the repository, which returns the tab already materialized, with each item’s outOfStock filled in from inventory; the orchestrator calls the use case with the tab and the points; the use case decides and returns the Result; the orchestrator translates it into state and publishes it. The use case shows up at the end of the chain: it takes data and hands back a verdict, with no idea the repository even exists. It’s the table’s row turning into a sequence. Notice the repository as a closed box: the use case never talks to it. When “Cheese bread” is out of stock, the inventory is what knows that, and that information enters the tab BEFORE the use case gets called. The use case just finds an item.outOfStock == true already sitting in the data it received. That’s why the signature has two parameters and not a database client: a piece of data is missing, the input grows, never a query.

Try it: open https://focus.kodel.com.br/en/dart/14-01 (or https://focus.kodel.com.br/en/kotlin/14-01) and run it. Prediction: the console prints “Table 4: $36.00” and, on the next line, “No go: Cheese bread is out of stock”, with no screen and no database. Now swap the 120 points for 90 and predict the output before running it. The other eight languages are at /en/ts/14-01, /en/java/14-01, /en/csharp/14-01, /en/go/14-01, /en/php/14-01, /en/python/14-01, /en/swift/14-01, and /en/rust/14-01. The use case as a retrieval unit It pays to look at this division of labor through the question asked by whoever shows up later. “What’s the loyalty discount rule?” has, in this design, a one-file answer. That’s no accident: the signature declares everything the rule consumes, the body queries nothing on the outside, and the outcome is a value. Whoever opens applyLoyaltyDiscount finishes the reading knowing the entire policy, including what it refuses and why. Compare that with the version this chapter opened with, where the 10% lived in the View, in the HTTP handler, and in a SQL trigger. There the same question forces you to find three files in three languages, confirm those are all three, and decide which one wins when they disagree. The cost isn’t in reading the rule, which is short in both versions; it’s in building the certainty that no fourth copy is left over. The property the pure version has is the one the architecture literature calls retrieval-oriented architecture: a design judged by what has to be retrieved to answer a question about the system. FOCUS’s retrieval unit is the use case, and it works because of chapter 7’s purity. A hidden dependency is precisely

what ruins retrieval: if the body went looking for inventory, the complete answer would start demanding the repository, its configuration, and the decision about which environment was running. Testing without a single test double Here purity delivers on its promise. A function that only takes data and returns a value tests in the simplest way there is: you arrange the data, call it, and compare the Result. There’s no repository to simulate, no clock to freeze, no screen to assemble, so there’s not a single test double (the mocks, stubs, and fakes from chapter 9). There’s nothing to fake, because there’s no dependency at all: Dart test(“120 points earn 10%: 4000 becomes 3600”, () { final tab = Tab(4, items, 4000); final result = applyLoyaltyDiscount(tab, 120); check(result).isA() .has((r) => r.tab.totalInCents, “total”) .equals(3600); });

test(“an out-of-stock item cancels the order”, () { final withOutOfStock = [ const Item(“Espresso”, 700), const Item(“Cheese bread”, 600, outOfStock: true), ]; final tab = Tab(4, withOutOfStock, 1300); final result = applyLoyaltyDiscount(tab, 120); check(result).isA() .has((r) => r.name, “name”) .equals(“Cheese bread”); }); Read the arrangement, the call, and the comparison. Each test builds whichever Tab it wants, calls applyLoyaltyDiscount with that case’s points, and checks the Result’s variant and what it carries, with package:checks from chapter 9 giving the expressive assertion.

The happy path (120 points, $36.00) and the refusal (out-of- stock item) cost a handful of lines each, and the whole file imports nothing from UI or from a database. A test that stops at the happy path is half a test, so the real file still covers the boundary every threshold demands: 100 points win, 99 don’t. It’s the kind of case snippet 10-01 had no way to isolate (the rule was embedded) and that here costs one line, because the rule has an entry point of its own. And there’s the edge case that fools a lot of people: an empty tab, with a zero total, is DiscountApplied with a zero total, not a refusal, because 10% of zero is zero and success doesn’t depend on there being anything to discount. Keep one last proof for the next section: change the < 100 to < 90 in the use case, or the 10 to 15 , and run the test. It breaks right away and points at the wrong number. The rule has exactly one place where sabotage lands, and the test stands guard over it. Try it: open https://focus.kodel.com.br/en/dart/14-02 and run the test. Prediction: four cases pass, including the 100/99 threshold and the empty tab. Now do the sabotage from the paragraph above ( < 100 becomes < 90 ) and run it again: the case “100 points win; 99 don’t” breaks, because with the threshold at 90 a 99-point customer starts earning the discount. Revert it and watch everything go green again. The other nine languages are at /en/kotlin/14-02, /en/ts/14- 02, /en/java/14-02, /en/csharp/14-02, /en/go/14-02, /en/php/14-02, /en/python/14-02, /en/swift/14-02, and /en/rust/14-02. The critique: “where’s the use case’s interface?”

Anyone coming from an enterprise codebase is going to miss one thing in this chapter, and the omission is deliberate. Where’s the IDiscountUseCase , the interface the use case implements “so it can be mocked in the test”? The question is legitimate and has serious defenders, so it deserves an answer backed by a source, not by taste. The direct critique comes from Dan North, the same creator of BDD (Behavior Driven Development), in the essay “CUPID, for joyful coding” (2022, dannorth.net): interfaces with a single implementation, created only to satisfy a mocking tool, are ceremony that gets in the way of reading, not abstraction that helps the project. And the positive formulation comes from Mark Seemann, whom nobody accuses of going easy on dependency injection: in the post “Dependency rejection” (2017, blog.ploeh.dk) and in the book Dependency Injection Principles, Practices, and Patterns (Manning, 2019, with Steven van Deursen), he argues that a pure function that takes data has no dependency to inject, and therefore nothing to abstract behind an interface. That’s the core of FOCUS’s answer, and it’s structural, not a matter of preference. A test interface exists so you can swap an implementation for a double. But applyLoyaltyDiscount receives no collaborator to swap: it takes a Tab and an int , inert data. There’s no hidden database, no service to replace. The interface isn’t skipped to save effort; it simply has nothing to abstract. Real dependency injection still exists in the project, but at the address from chapter 9: it lives in the repositories, where there’s an actual IO boundary to swap between production and test. The use case stays pure, and pure doesn’t need a double. Here’s my position, so you know where I’m speaking from. I’ve inherited more than one project with a single-method IDiscountUseCase , its single-method DiscountUseCaseImpl , a registration in the injection container, and a factory, all of it to wrap a

function that adds up a discount. Five files of scaffolding around ten lines of rule, and not one of them ever got a second implementation. It looked like abstraction and worked like dead weight: the cost of an indirection that never bought a single ounce of flexibility. I prefer the pure function, called by name, tested with data. If a real second implementation ever shows up, that’s when the interface earns its keep, and extracting an interface out of a pure function is a two-minute refactor. Before that, it’s just one more file for the next developer to open expecting logic and finding a return impl.apply(x) . Pitfalls The first pitfall is the most tempting one: the “just one quick SELECT ” inside the use case. The rule needs to know whether the item is out of stock, and inventory is one query away, so why not query it right here? Because the instant the use case talks to the database, it stops being a pure function, goes back to depending on IO, and the test from the previous section needs a repository double to run. What goes wrong is exactly what the whole chapter avoided. How to get out of it: the missing data enters through the signature, materialized by whoever calls it. Missing the inventory? The Tab grows an outOfStock field, and the orchestrator fills it in before calling. The input grows; the use case never leaves home. The second pitfall is returning the rule as an exception. It’s tempting to write throw NoPointsException() when the customer isn’t eligible, because “it’s an exceptional case”. It isn’t: a customer without points is routine, not exceptional, and the table’s row forbids domain exceptions in the use case. What goes wrong: the refusal turns into invisible control flow the caller can forget to

catch, and chapter 8 showed the damage that does. How to get out of it: a refusal is a return , not a throw ; a typed Result variant that the caller’s switch is forced to handle. The third pitfall is solving a missing piece of data with a query instead of letting the signature grow. A new requirement lands (the discount now depends on the time of day, or on the customer’s spending that month), and the reflex is to inject a clock or a repository into the use case. What goes wrong: every injected dependency is one less unit of purity and one more double the test needs. How to get out of it: if the rule needs a new piece of data, it becomes a parameter, and whoever calls the function materializes it. A good use case’s signature tells the whole story of what the rule consumes; a use case that hides queries lies about what it needs. Q&A What if the rule needs a piece of data that didn’t come with the tab? The signature grows. Needed the month’s total spend for a progressive discount? It becomes a parameter, int monthlySpendInCents , and the orchestrator fetches it from the repository and hands it over ready. The wrong reflex is injecting a repository into the use case so it can query on its own; that makes it impure and brings back the problem the chapter solved. The question that separates the two paths: “is this data an input to the rule, or is the rule going out looking for it?” Input grows the signature; going looking breaks purity. Isn’t it wasteful to materialize everything up front, even when the rule refuses right away over an out-of-stock item? In practice, the orchestrator was already going to fetch the tab anyway to show it on screen, so the data is already materialized by the time the use case gets called;

there’s no extra fetch. If some day there’s an expensive piece of data that only a fraction of calls actually use, the answer still isn’t to query inside the use case: it’s for the orchestrator to decide whether to materialize it. The rule stays pure. One use case per operation, or one service with several methods? One per operation, one domain verb per function, like in this chapter. A “service” with ten methods turns into the junk drawer where ownerless rules pile up, and you’re back to the cohesion problem from chapter 6. Pure functions named inside the feature folder (chapter 11) age better than a Swiss-army-knife class. Quick tip Open any use case or “service” in your project and read only the signature, no body. If it takes a repository, an HTTP client, a clock, or a logger, the body has hidden IO and the test is going to ask for a double. Write down those parameters: each one is a candidate to become an input materialized by whoever calls it. The impurity moves to the boundary, and the rule stays testable with plain values. Quick reference Situation Fix It’s a business rule (discount, limit, policy)? use case: pure function The rule needs a piece of data that didn’t come? the signature grows

I want to refuse the customer’s request return a Result variant I need to know if the item is out of stock it comes ready in the input data Fetch the tab before deciding orchestrator (chapter 13), not the use case Save the tab already discounted repository (chapter 15), not the use case Test the rule arrange data, call it, compare the Result; no double I created a single-method interface to mock dead weight; the pure function is enough Exercises

  1. On birthdays, Rosie gives an extra 5% discount. Extend applyLoyaltyDiscount so the birthday customer earns 15% instead of 10%. Treat the birthday as DATA, not as a hidden rule: let the signature grow with what it needs to know. Start by breaking it on purpose and follow the errors from the exhaustive switch of whoever consumes the Result. Prove the original cases still hold ($40.00 with 120 points still gives $36.00 for a non-birthday customer) and that the birthday customer with 120 points and a $40.00 tab gets back $34.00.
  2. Could you write splitTabBetween(Tab tab, int people) as a use case from the same family? Think about what it takes (just data?), what it returns (which typed refusals: an empty table? a split that doesn’t land on round cents?), and how you’d test it

without booting up anything. You don’t have to get the rounding rule right on the first try; you have to keep the piece pure and the refusal a value. Tip 14 If a rule needs a mock to be tested, it’s in the wrong layer. A pure rule tests with data; the mock is the smell of a dependency that should be at the boundary, not inside the rule. Next chapter: the use case received the materialized tab and never asked where it came from. So where did it come from? Who filled in the outOfStock field, who turned the server’s “no connection” into a value, who keeps the tab between one tap and the next? Chapter 15 opens the last closed box, the repository’s, and answers where the data comes from.

Powered by TurnKey Linux.