Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

27KB

FOCUS Architecture — Chapter-16: Commands and Queries: CQS Without Ceremony

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

Commands and Queries: CQS Without Ceremony In this chapter, you’ll: classify any operation in Rosie’s Coffee Shop app as a command or a query using Meyer’s rule, without hesitating over the eight real operations; refactor the hybrid method payAndGetTab() into a command that returns a Result and a query the orchestrator publishes as state; explain why FOCUS stops at CQRS-lite, owning Fowler’s criticism instead of arguing it away. You’ve been separating writes from reads since chapter 13 without knowing the name for it. The orchestrator that fetches and publishes, the use case that returns a Result, the two distinct sealed types from chapter 15: all of it is already a discipline with a name, a surname, and a birth year. This chapter gives it the name, shows the rule behind it, and draws the exact line where FOCUS stops following it. Chapter 15 closed with a question: do asking and changing deserve the same treatment? The answer was already planted in that chapter. Notice that the repository returns two distinct sealed types, LookupResult for reads and SaveResult for writes, with TabFound on one side and TabSaved on the other. That split wasn’t a typing whim. It’s half of a rule from 1988 that this chapter

presents in full, and you built the other half back in chapter 13, when the orchestrator fetched the tab from the repository and published the state to the screen. One thing is still missing: the code that breaks the rule, so the pain shows up before the fix, the way every chapter in this part does it. The anti-solution: paying and asking in the same gesture Rosie’s Coffee Shop’s payment feature needs two things: charge the customer and show the updated tab on screen. A developer in a hurry solves both at once, in a single method, and the signature even looks convenient: you call it, the customer gets charged, and the tab comes back ready to render. Dart // The hybrid: looks up, charges, and returns the tab, all in one gesture. Tab payAndGetTab(int table) { final lookup = repository.findTab(table); final tab = switch (lookup) { TabFound(:final tab) => tab, InfraFailure() => const Tab(0, [], 0, false),

}; // Charges the customer. The write's outcome gets thrown away: if // the network drops right here, nobody finds out. repository.markAsPaid(tab); // And it returns the “updated” tab, as if the charge had gone // through. The signature promises data; the mutation shipped with // no receipt. return tab; } Read the signature before the body. Tab payAndGetTab(int table) promises read data, and that’s all the caller sees. The body, though, does a second thing: it calls repository.markAsPaid(tab) , a write from chapter 15, and throws away the SaveResult it returns. The type that carried the charge’s outcome died on a line with no assignment. While Rosie’s Coffee Shop’s network stays up, this method works, and that’s what makes it dangerous. The bug doesn’t show up the day you write it; it shows up on a busy Saturday

night, when the Wi-Fi drops between the lookup and the write. The lookup went fine. The write returned InfraFailure(Failure.noConnection) , and nobody read it. The method returns the tab as usual, the screen shows “paid,” and now nobody can answer the question that matters: did the customer get charged? The server never recorded the payment, the screen says it did, and Rosie finds the gap when she closes the register. A returned value is an answer; this method answered the wrong question. Try it: run https://focus.kodel.com.br/en/dart/16-01. The output shows table 4’s tab coming back whole, 1895 cents, with the network down partway through. Find the line in the code that discards the SaveResult : it’s a call with no final in front, and the compiler doesn’t complain about a thing. The same hybrid runs in the other nine languages, at https://focus.kodel.com.br/en/ts/16-01, https://focus.kodel.com.br/en/kotlin/16-01, https://focus.kodel.com.br/en/java/16-01, https://focus.kodel.com.br/en/csharp/16-01, https://focus.kodel.com.br/en/go/16-01, https://focus.kodel.com.br/en/php/16-01, https://focus.kodel.com.br/en/python/16-01, https://focus.kodel.com.br/en/swift/16-01, and https://focus.kodel.com.br/en/rust/16-01. CQS: Meyer’s rule The pain has had a name and a diagnosis since 1988. CQS (Command-Query Separation) is the rule Bertrand Meyer wrote down in Object-Oriented Software Construction (1988): a method changes state or returns data, never both. The etymology helps it

stick. A command is an order: “pay the tab” changes the world and earns a receipt saying whether the order went through. A query is a question: “what’s table 4’s total?” changes nothing and earns an answer. The hybrid from the last section gave an order and returned the answer to a different question; the order’s receipt went in the trash. The refactor splits the two gestures, and its best part is what you won’t write: no new type. The command returns the SaveResult chapter 15 already defined; the repository does the writing, and the orchestrator (chapter 13) tells it to, because a use case doesn’t do IO. This particular command has no rule to decide, so it doesn’t even need a use case; the day it does, the rule decides first, in a pure function that returns a Result, the shape chapter 14 built. The query is findTab , spelled exactly as chapter 15 spelled it; it returns the read Result chapter 8 introduced. Dart // features/tab/pay_tab.dart // THE COMMAND: changes state and returns the order's outcome, nothing // else. The repository from chapter 15 does the writing, on the // orchestrator's orders; a business rule, once one exists, decides // first in the use case (chapter 14). SaveResult payTab(Tab tab) => repository.markAsPaid(tab);

// THE QUERY: answers the question, exactly as chapter 15 published it. // The orchestrator (chapter 13) publishes this result as state. LookupResult findTab(int table) => repository.findTab(table); Two lines of body, and the whole pain is gone. payTab takes the tab and returns SaveResult : either TabSaved or InfraFailure , and the exhaustive switch from chapter 8 forces the caller to face both cases. There’s no more way for the charge to fail in silence, because the outcome now IS the return value, and a sealed type’s return doesn’t get discarded without the compiler flagging the untreated variant in whoever consumes it. findTab stayed untouched: it’s the same signature the orchestrator from chapter 13 already called to publish state. The screen that used to show “the tab the payment returned” now shows “the state the orchestrator published after looking up again,” and those two sentences describe different worlds: in the second one, the screen never lies. The split, in all ten languages Two functions and no new type: that’s the shape of the refactor, and it crosses all ten languages in the book without losing anything along the way. In the nine listings below, look for the same pair every time: one function whose return is the order’s receipt, and one function whose return is the question’s answer. What changes from language to language is where the pair lives and how it reaches the repository.

TypeScript writes the same split with the discriminated unions from chapter 15, plus one extra detail: both functions return a Promise , because in the JS ecosystem the real repository is always asynchronous. The async wrapper doesn’t change the rule; it only changes how the same Result gets delivered. TypeScript // THE COMMAND: changes state and returns the order's outcome, nothing else. async function payTab(tab: Tab): Promise { return repository.markAsPaid(tab); } // THE QUERY: answers the question, exactly as chapter 15 published it. async function findTab(table: number): Promise { return repository.findTab(table); } Kotlin, C#, and Java form the next family: in all three, the pair lives inside a class that takes the repository through its constructor, PaymentService . It’s not a new layer; it’s the same pair

with an address, and chapter 9 already justified the injection. Kotlin opens the family, and its single-expression = fits each operation into one line: Kotlin // features/tab/PaymentService.kt (CQS version) // Command and query, split apart: whoever calls payTab RECEIVES the // write's outcome and the exhaustive when forces them to handle it. class PaymentService(private val repository: TabRepository) { // Command: changes the world and returns the outcome, no read data. fun payTab(tab: Tab): SaveResult = repository.markAsPaid(tab) // Query: only reads, no hidden side effect. fun findTab(table: Int): LookupResult = repository.findTab(table) }

C# writes the same class with expression-bodied members, the => that’s Kotlin’s = cousin. The difference that matters shows up in the consumer: as chapter 8 warned, C#’s exhaustiveness is weak, so the switch over SaveResult needs a _ arm that throws at runtime. C# class PaymentService { private readonly ITabRepository _repository; public PaymentService(ITabRepository repository) => _repository = repository; // Command: changes the world and returns the outcome, no read data. public SaveResult PayTab(Tab tab) => _repository.MarkAsPaid(tab); // Query: only reads, no hidden side effect.

public LookupResult FindTab(int table) => _repository.FindTab(table); } Java closes the family with more ceremony and the same anatomy: a final field, an explicit constructor, two one-line methods. In exchange, Java 21’s pattern switch over the sealed interface from chapter 15 is genuinely exhaustive, and the compiler bills you for the failure case the anti-solution used to swallow. Java static final class PaymentService { private final TabRepository repository; PaymentService(TabRepository repository) { this.repository = repository; } // Command: changes the world and returns the outcome, no read data. SaveResult payTab(Tab tab) {

return repository.markAsPaid(tab); } // Query: only reads, no hidden side effect. LookupResult findTab(int table) { return repository.findTab(table); } } Swift, PHP, Python, and Rust take a different road, and it’s just as valid: free functions that take the repository as their first parameter. No class, no field, no constructor. It’s the usual trade between constructor injection and parameter injection, and CQS doesn’t care which one you pick, because its rule lives in each function’s signature. Swift shows the shape: Swift // Command: changes the world and returns the change's outcome, nothing more. func payTab( _ repository: TabRepository, _ tab: Tab

) -> SaveResult { return repository.markAsPaid(tab) } // Query: answers a question without changing anything. func findTab( _ repository: TabRepository, _ table: Int ) -> LookupResult { return repository.findTab(table) } PHP writes the same two functions with declared return types, and those types carry the contract: SaveResult on the order, LookupResult on the question. Without those two types in the signature, PHP would let the hybrid through without a complaint. PHP // Command: changes the world and returns the change's outcome, nothing more. function payTab(

TabRepository $repository, Tab $tab, ): SaveResult { return $repository->markAsPaid($tab); } // Query: answers a question without changing anything. function findTab( TabRepository $repository, int $table, ): LookupResult { return $repository->findTab($table); } Python uses type annotations for the same reason, with one difference that matters: they’re worth nothing at runtime. What enforces the split is the type checker, and it’s the type checker that flags a command returning LookupResult . Without mypy in your pipeline, CQS in Python turns into a code-review discipline.

Python

Command: changes the world and returns the change's outcome, nothing more.

def pay_tab( repository: FakeTabRepository, tab: Tab ) -> SaveResult: return repository.mark_as_paid(tab)

Query: answers a question without changing anything.

def find_tab( repository: FakeTabRepository, table: int ) -> LookupResult: return repository.find_tab(table) Go tells the whole chapter’s story on its own, in the signature, and that’s why it’s worth reading slowly. Look at the pair of return types: the command returns error and nothing else; the query returns (Tab, error) .

Go // THE COMMAND: changes state and returns only the order's outcome. A // clean signature: error, and nothing else. func payTab(tab Tab) error { return repository.MarkAsPaid(tab) } // THE QUERY: answers the question, exactly as chapter 15 published it. func findTab(table int) (Tab, error) { return repository.FindTab(table) } Now compare that to the hybrid from the first section, whose Go signature reads func payAndGetTab(table int) (Tab, error) . In Go there’s no hiding a dual nature: the pair (T, error) is the only way to return both data and an outcome, so the hybrid confesses in its signature that it does both, and it reads ugly. That ugliness is a feature. In single-return languages, Tab payAndGetTab(...) looked innocent, because the discarded Result happened out of sight, in the body; in Go, the (Tab, error) signature on a method named

“pay” shouts that there’s too much going on there. If your Go method’s signature mixes both without being a query, CQS got violated, and you didn’t even have to open the body to know it. Rust closes the loop. &dyn TabRepository is the repository arriving as a reference to a trait object, Rust’s way of accepting any implementation of chapter 15’s contract, real or fake. The rest is the same pair: Rust // Command: changes the world and returns the change's outcome, nothing more. fn pay_tab( repository: &dyn TabRepository, tab: Tab, ) -> SaveResult { repository.mark_as_paid(tab) } // Query: answers a question without changing anything. fn find_tab( repository: &dyn TabRepository,

table: i64, ) -> LookupResult { repository.find_tab(table) } Ten languages, three ways to host the pair: a class method in Kotlin, C#, and Java; a free function with the repository as a parameter in Swift, PHP, Python, and Rust; a top-level function with the repository injected through a variable in Dart, TypeScript, and Go. None of them needed a new type, a library, an annotation, or a framework. CQS costs one signature. Try it: run https://focus.kodel.com.br/en/dart/16-02 and play out the scenario: paying with the network up prints TabSaved(4) , paying with the network down prints InfraFailure(Failure.noConnection) , and looking up prints table 4’s TabFound . Try discarding payTab ’s return the way the hybrid did: the code still compiles, but the outcome is now a value in your hands, and ignoring it becomes a visible decision in the diff, not an accident. The other nine languages are at https://focus.kodel.com.br/en/ts/16-02, https://focus.kodel.com.br/en/kotlin/16-02, https://focus.kodel.com.br/en/java/16-02, https://focus.kodel.com.br/en/csharp/16-02, https://focus.kodel.com.br/en/go/16-02, https://focus.kodel.com.br/en/php/16-02, https://focus.kodel.com.br/en/python/16-02,

https://focus.kodel.com.br/en/swift/16-02, and https://focus.kodel.com.br/en/rust/16-02, with the same three-line output. The canonical table’s two tracks The refactor you just did uncovered an architecture that was already there. Look at the whole flow in a single diagram, with the command going down one track and the query coming back on the other: The command track goes down in two steps, and each step has an owner. When the order involves a rule, the orchestrator hands the data to the use case, and that’s the Use Case row from chapter 10’s canonical table: “the only place for business rules, a pure

function, takes data and returns a Result,” with the ban on “IO, framework, and domain exception.” That ban is the detail the diagram has to respect: a use case decides and returns the decision’s Result, but it never writes. The write is the second step: the orchestrator tells the repository to write and gets back the SaveResult . Today’s payTab is only that second step, because it has no rule to decide yet. There’s a subtlety worth facing head-on here, because it comes back in the table of eight operations. Chapter 14’s applyLoyaltyDiscount is the first step in action, and on its own, by Meyer’s ruler, it’s a query: a pure function, takes data, returns a verdict, and changes nothing in the world. What changes the world is the second step. A whole business gesture (“apply the discount and charge”) is usually a query followed by a command, and Meyer’s ruler applies to each method, never to the whole gesture. Mixing up the two levels is the most common mistake anyone classifying for the first time makes. The query track comes back: the orchestrator fetches, the repository answers, the state reaches the screen. It’s the Orchestrator row from the same table: “converts event to state, fetches data from the repository, calls use cases, publishes state,” with the ban on “deciding rules and persisting.” The pair “fetches data from the repository” and “publishes state” is FOCUS’s definition of a query, and chapter 13 built it before you knew its name. Chapter 15’s repository answers on demand; the one who turns that answer into state for the View is the orchestrator, never the repository. Two rows of the table, two tracks, two kinds of operation. The canonical table was already Meyer’s CQS, written in the vocabulary of layers.

From CQS to CQRS, and where FOCUS stops Twenty years after Meyer, the method-level rule leveled up. CQRS (Command Query Responsibility Segregation) is the pattern Greg Young named in 2010: instead of separating methods, separate the models themselves, one object for writes and one for reads, each free to evolve on its own. The difference in level matters more than the similar-sounding name. CQS is a method rule: it fits in a signature and costs nothing. CQRS is an architecture decision: it splits the system into two paths and charges maintenance on both. A mythology grew up around CQRS that Young himself spent years dismantling, in the article “CQRS, Task Based UIs, Event Sourcing agh!” (2010), and that Oskar Dudycz revisits on event- driven.io. Three myths fall at once. CQRS doesn’t require Event Sourcing, the technique of storing the sequence of events that happened instead of the final state: Young presented the two together and the market married them, but a CQRS system can write ordinary state just fine. CQRS also doesn’t need two databases, because the segregation is of the model, and both models can live in the same database. And CQRS doesn’t need eventual consistency: stale reads are an implementation choice, outside the definition. If you’ve ever turned down CQRS “because I don’t want two databases,” you turned down a myth. Once the myths clear out, the real criticism remains, and it comes from Martin Fowler, in the “CQRS” bliki entry: “for most systems CQRS adds risky complexity.” Fowler is right, and FOCUS isn’t going to pretend otherwise. Two models is twice the code for the same feature, and the sync between them is a problem most apps never needed to have.

I carry a scar from that complexity. I watched a team adopt full CQRS, two databases and projections, for a 12-screen CRUD registration flow. Syncing the write database with the read database burned more development hours than all 12 screens combined, and the first question in every bug report became “are the databases in agreement?” I wouldn’t do it again even on a system ten times bigger; the pain bought no benefit, because no read in that system ever needed to diverge from the write. FOCUS’s position distills that experience into one term: CQRS- lite is the logical split between commands and queries, with none of the distributed cost. Commands change state and return a Result, with the rule decided in the use case and the write done in the repository; queries are on-demand reads from the repository, published as state by the orchestrator. One database, no events, no eventual consistency. It’s everything chapters 13, 14, and 15 already built, plus the discipline of never mixing the tracks, and the extra price is zero, because the structure was already standing. CQS’s clarity, without CQRS’s bill. Classify the eight operations Meyer’s rule is only worth what you can apply to a menu of real operations. Take Rosie’s Coffee Shop app’s eight and ask, for each one: does it change state, or answer a question? Operation Classification Why Pay the tab command changes state; returns a Result Show the tab total query answers a question; becomes state

Apply the loyalty discount query a pure function decides (chapter 14) List the menu query on-demand read Mark an item out of stock command changes state Split the tab command changes state Look up loyalty points query answers a question Register an order command changes state Four commands, four queries, no operation on both teams. The discount row is usually the most contested, and it’s the one that teaches the most: chapter 14’s use case takes the tab and the points, returns a Result with the amount discounted, and writes nowhere. Question asked, answer given. Whoever saves the discount afterward is the payment command, in a second method. The one that tricks people most is “register the order and show the total,” which sounds like it wants a hybrid just like the anti-solution’s. It’s two operations: the command “register order” returns the write’s Result, and the query “show total” fetches and publishes the new total. The screen’s flow chains the two; the code doesn’t fuse them. Every time a feature “needs” a method that changes and returns, redo this split; it’s the same exercise as this chapter’s refactor, with different names. Pitfalls

The classic CQS pitfall is the query that “takes advantage” of the trip to update something. Rosie asks: “I want to know how many times the menu got viewed.” The developer figures it’s efficient to bump a counter inside listMenu() , since “the query’s already right there.” What goes wrong: the read turned into a write in disguise, and now showing the menu twice counts two visits, the screen’s automatic retry inflates the metric, the test that calls the query to set up a scenario changes the database, and the cache chapter 15 allowed inside the repository starts hiding writes. A side effect in a read is Meyer’s rule violation number one. How to get out: the counter is state, so changing it is an order. Create the command recordMenuView() and let the query only ask; the orchestrator decides when to fire the command, on the screen- opening event, once. The second pitfall is the command that returns read data “for convenience”: payTab handing back the whole tab so the screen can skip a fetch. It’s the anti-solution’s hybrid coming back thin. A command’s Result carries the order’s outcome, and an outcome is a different thing than screen data; the day the screen needs more fields, the command swells right along with it, and the two tracks tangle up again. The third is concluding that adopting the split forces you to adopt the infrastructure: “if it’s CQRS, I need two databases.” Reread Young’s and Dudycz’s myths from the earlier section. FOCUS stays at the lite version exactly so you collect the logical split while paying zero extra infrastructure. Q&A What about a command that needs to return the generated id, like “register order” creating a new tab? A command’s Result carries the order’s outcome, and the outcome can name the thing it created: TabSaved already carries the tab

inside, the way chapter 15 defined it. What a command doesn’t return is read data for the screen to lay out; that’s a question, and a question is a query. A receipt with a confirmation number, yes; a receipt with the full statement, no. Can a query never have any effect at all? Not even a log? Meyer’s criterion is observable domain state. A log, an infrastructure metric, and the repository’s internal cache (chapter 15) don’t change the answer to any business question, so they don’t violate the rule. The view counter from the pitfall above does: it’s data Rosie wants to read, so it’s domain state, so only a command may touch it. payTab just delegates to markAsPaid . Why the layer, for one line? Today it’s one line; the track is what matters. The day the rule “a split tab can’t be closed out by a single waiter” shows up, it goes into the use case, the only place for business rules (chapter 14), and no caller changes. Without the track, the rule would be born in the orchestrator or the repository, the two homes the canonical table bans it from. Quick tip Open any repository or service file in your current project and search its returns: a method with a change verb in its name ( pay , save , apply , register ) returning the whole object is a hybrid candidate. In five minutes you’ll have your codebase’s list of payAndGetTab s; this chapter’s refactor works the same way on every one of them. Quick reference

Situation Fix Changes state (pay, register, mark) command: returns a Result Rule to decide before writing pure use case (chapter 14), then the command Answers a question (total, menu, points) query: the repository fetches Query answered for the screen the orchestrator publishes it as state (chapter 13) Method changes state AND returns data split it: payTab() and findTab() Command “needs” to return screen data the Result is the outcome; use the query Query “takes advantage” to save a counter the counter is state: create the command “Do I need two databases?” no: CQRS-lite is logical, one database, zero events Exercises

  1. Classify the eight operations from this chapter’s table without looking at the answer column: pay the tab, show the tab total, apply the loyalty discount, list the menu, mark an item out of stock, split the tab, look up loyalty points, register an order. For each one, also write down what the return type would be

in your language: a save Result for the commands, data (or a lookup Result) for the queries. Check yourself against the rule: changes state, command; answers a question, query. 2. Open https://focus.kodel.com.br/en/dart/16-01 (or your language’s route) and refactor the hybrid yourself: delete payAndGetTab() and write payTab() and findTab() as two separate functions, reusing the types from chapter 15 that are already in the snippet. Can you get the output to tell the truth by printing the InfraFailure(Failure.noConnection) the hybrid used to swallow, without creating a single new type? Tip 16 A method that changes state returns a Result; a method that answers a question returns the data. If it returns both, that’s two methods. Next chapter: the two tracks you just split ask for different kinds of proof, and that’s exactly what chapter 17 builds: each track calls for a different kind of test.

Powered by TurnKey Linux.