Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

44KB

FOCUS Architecture — Chapter-15: Repositories: The Exception Boundary

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

Repositories: The Exception Boundary In this chapter, you’ll: write the tab repository that fetches and writes data, translating a network drop into Failure.noConnection in one single place; return one typed Result from the read and another from the write, with no infrastructure try/catch leaking into the orchestrator or the screen; spot, in someone else’s repository, the business rule that hid inside a “clever” query where only a connection to the world should be. Chapter 14’s use case receives a ready tab and decides the rule on it. But someone has to go fetch that tab from the real world, where Rosie’s Coffee Shop’s Wi-Fi drops in the middle of the lunch rush and the card processor takes five seconds to answer. This chapter builds the piece that faces that dirty world and hands the rest of the app nothing but clean values: the repository, the last box chapters 13 and 14 left sealed. Chapter 13 closed the orchestrator’s cycle with two boxes still sealed: the use case, which chapter 14 opened, and the repository, which opens now. The contract’s line has been signed since chapter 10, in the canonical table’s fourth row:

Layer Does Forbids Repository CRUD (fetch and save) business rules the only place an infra exception becomes a Result It’s worth reading slowly, because every piece of that row got written after a bug. CRUD (Create, Read, Update, Delete) is the four data operations any app runs against storage. The one asking is chapter 13’s orchestrator: it wants table 4’s tab, and the repository hands back table 4’s tab, whether it comes from an API, a local database, or a file. “The only place an infra exception becomes a Result” is the hard thesis, and it comes from chapter 8. The infrastructure boundary is the layer that talks to the world outside your process: network, database, disk, card processor. It’s the only territory where someone else’s exceptions are unavoidable, because the libraries living there are the ones throwing them. FOCUS’s rule fits in one line: an infrastructure exception gets caught once, right there, and translated into a typed Failure ; from there inward, only Results circulate through the app. Chapter 8 showed the gesture in five lines and promised that “chapter 15 builds this boundary in detail.” That promise comes due now. The ban closes the back door. “Business rules” are forbidden in the repository because a rule has its own home, chapter 14’s use case, and a scattered rule turns into a copy, the way you already saw in chapter 12 with the three screens and in chapter 13 with the discount stuck in the middle of the switch. The repository knows where the data lives and how to talk to it. It never knows whether the customer earned the discount.

The piece’s name is old. Repository is the pattern Martin Fowler cataloged in Patterns of Enterprise Application Architecture (2002) as the layer that “mediates between the domain and data mapping layers, acting like an in-memory collection of domain objects.” Eric Evans, in Domain-Driven Design (2003), backs the same design: one repository per domain aggregate, with the interface speaking the business’s vocabulary, not the table’s. Notice that both define a repository by domain feature, never as a generic per-table CRUD. That distinction comes back in the critique section, and it’s the difference between a pattern and a decoration. One common misunderstanding needs clearing up before a single line gets written: the screen doesn’t subscribe to the repository. The one publishing state is chapter 13’s orchestrator, the owner of the channel that carries state down to the View. When the data changes in another waiter’s hands and the screen needs to know, it’s the orchestrator that listens to the source and translates the change into state. The repository hands over the data and gets out of the way. The anti-solution: the copied catch in every screen Like every chapter in this part, the right path starts with the wrong one. Rosie hired a developer who solved the network outage the most direct way possible: wherever the code needed the data, it fetched straight from the server and wrapped the call in a try/catch . It worked on the first screen. Dart // Menu screen: fetches straight from the server and handles the

// failure by hand. String menuScreen(int table) { try { final tab = queryServer(table); return “table ${tab.table}‘s tab”; } on SocketException { return “no connection”; } } It worked on the second screen too, and the third, and the fourth. The problem isn’t any one of them alone; it’s that there are now four. The menu screen, the tab screen, the history screen, and the register report each carry a copy of the same try/catch , and each developer who wrote theirs invented a different message: “no connection,” “network failed, try again,” “couldn’t load,” “communication error.” Three pains come out of that, and they’re worth naming. The first is duplication: the same SocketException translation in four places. The day the network failure needs to become a metric, or a single message, or a retry, you edit four files and

hope you didn’t miss one. The second is invisible forgetting, and it’s worse, because it doesn’t show up in a single-file code review. Look at the fifth screen, the payment one, written last week by someone in a hurry: Dart // Payment screen, just created. It forgot the try/catch. With the // network down, the SocketException rises bare and the app crashes on // the most expensive gesture: getting paid. void paymentScreen(int table) { final tab = queryServer(table); saveToServer(tab); } There’s no catch here. While the network stays up, nobody notices. The day the Wi-Fi drops in the middle of the lunch rush, this screen crashes in the customer’s hand, card reader out, right at the moment of payment. The crash isn’t a logic bug; it’s the absence of a line the other four screens had and this one doesn’t. Nothing in the compiler charged for the gap, because SocketException is an ordinary exception, and an ordinary exception doesn’t show up in anyone’s signature.

The third pain is the blind caller. Even in the four screens that remembered the catch , whoever calls menuScreen receives a String , and that string can just as easily be a tab as it can be “no connection.” The type doesn’t tell success from failure apart. Whoever consumes it has to compare text, or worse, trust that the string looks like a tab. The failure turned into a second-class value, disguised as a success. All three pains share one root: the infrastructure exception is being handled everywhere, when it should be translated in one place. Take SocketException back to its own territory, and all three disappear together. The repository’s contract comes before its body Write the interface first. The contract is what chapter 13’s orchestrator sees; the body, nobody outside gets to see. At Rosie’s Coffee Shop, the tab repository does three things: finds a tab, saves a tab, and marks a tab as paid. The lookup already existed as a signature in chapter 13; the two writes come in now, with the payment feature. Dart // features/tab/tab_repository.dart // The contract: fetch and write on demand, request/response. No // generic method, no watch/Stream (reactivity lives in the // orchestrator, chapter 13).

abstract interface class TabRepository { LookupResult findTab(int table); SaveResult save(Tab tab); SaveResult markAsPaid(Tab tab); } Read it method by method. findTab(int table) arrives exactly as chapter 13 declared it, with the LookupResult return that chapter anticipated; now you see where that type comes from, because the lookup can fail over the network and needs to say so in its type. save and markAsPaid are new, and they return SaveResult , a Result of the write’s own. markAsPaid is the gesture where the Wi- Fi drops in the anti-solution; it’s the one driving the rest of the chapter. Kotlin writes the same contract with interface and plain functions, one line per method, the same names: fun findTab(table: Int): LookupResult . Same shape, half the ceremony. Notice what the interface doesn’t have: no generic save(T entity) , no getAll() . Just the three verbs the tab feature asks for. That restriction is a design decision, and the critique section explains why it’s the pattern’s whole point. Now the return types. The read and the write have distinct Results, and that’s on purpose. Dart // features/tab/failure.dart

// The sealed family of INFRASTRUCTURE failures, with the same // identifiers chapter 8 printed. It only grows with infra variants. enum Failure { noConnection } // features/tab/lookup_result.dart // Result of the READ (chapter 8) and Result of the WRITE (new). Two // distinct sealed types plant chapter 16's command/query distinction. sealed class LookupResult {} sealed class SaveResult {} final class TabFound extends LookupResult { TabFound(this.tab); final Tab tab; }

final class TabSaved extends SaveResult { TabSaved(this.tab); final Tab tab; } // A class can't extend two sealed types; it implements both instead. // The infra failure is the same one, whether you're reading or // writing. final class InfraFailure implements LookupResult, SaveResult { InfraFailure(this.failure); final Failure failure; } TabFound and InfraFailure are spelled exactly as chapter 8 spelled them, character for character; chapter 8 showed the usage, this chapter gives the full definition. Failure is the sealed family of infrastructure failures, with the same identifier chapter 8 already

printed ( noConnection ). Only an infra variant belongs there: the repository doesn’t produce a domain failure, because a domain failure is a rule’s verdict, and rules don’t live here. Two things deserve attention. The first: LookupResult and SaveResult are two distinct sealed types, with TabFound on one side and TabSaved on the other. Asking and changing are gestures of a different nature, and naming them with different types plants a distinction chapter 16 will harvest. The second: InfraFailure belongs to both. The network failure is the same one whether you’re reading or writing, so it implements both sealed types instead of duplicating the variant. In Dart, a class extends at most one sealed type and implements as many as it needs; that language restriction is the technical reason InfraFailure uses implements while TabFound uses extends . Kotlin solves the same restriction with sealed interface , which a class can implement more than once. The single translation: one catch, and only one With the contract signed, the body fits in a small class, and it’s the opposite of the anti-solution. Where there were five scattered try/catch blocks (four copied and one forgotten), now there’s one. Dart // features/tab/tab_repository_impl.dart // The single translation: the SocketException try/catch lives HERE, // in one place, on both the read AND the write. From there inward, // only Results circulate.

class TabRepositoryImpl implements TabRepository { TabRepositoryImpl(this._driver); final ServerDriver _driver; @override LookupResult findTab(int table) { try { return TabFound(_driver.query(table)); } on SocketException { return InfraFailure(Failure.noConnection); } } @override SaveResult save(Tab tab) {

try { _driver.write(tab); return TabSaved(tab); } on SocketException { return InfraFailure(Failure.noConnection); } } @override SaveResult markAsPaid(Tab tab) => save(tab); } This is where the definition that titles the chapter is born. Single exception translation is catching the infrastructure exception at the boundary, exactly once, with immediate conversion into a typed Failure ; no other layer ever touches that exception again. _driver is an opaque box that talks to the dirty world (the database, the HTTP server) and can throw SocketException ; no line here writes SQL, because the driver is a detail behind the contract. What the repository does with the exception is exactly

one thing: turn it into InfraFailure(Failure.noConnection) and hand that back as a value. From the return on, SocketException has stopped existing for the rest of the app. Notice the translation happens on both the read and the write, with the same on SocketException . It’s the two-handed funnel: every piece of data going in and every piece coming out passes through this one point, and it’s only here that a network exception becomes a value. The anti-solution’s four screens now call findTab and get back a LookupResult ; the fifth, the payment one, calls markAsPaid and gets back a SaveResult , and the compiler no longer lets it ignore the failure, because the failure is now a case of the type it has to handle. The exception’s path fits in one diagram, and it’s the map for the whole chapter:

The exception is born red in the driver, dies green in the repository, and what climbs through every layer after that is a typed value, never an exception. Compare that with the anti- solution: there, SocketException could climb out of any of the five screens, and one of them let it climb all the way to a crash. Here there’s one point, and only one, where an infra failure has permission to exist. A main proves the two-handed funnel, with the scenario that runs the same way across the book’s ten languages: Dart // Canonical scenario: two verbs. A live lookup returns the tab; a

// write with the network down returns a typed noConnection, no crash // and no try/catch in the consumer. void main() { final online = TabRepositoryImpl(ServerDriver()); final lookup = online.findTab(4); switch (lookup) { case TabFound(:final tab): print( “findTab(4) success → " “TabFound (table ${tab.table}, " “${tab.totalInCents} cents)", ); case InfraFailure(:final failure): print(“findTab(4) → InfraFailure($failure)");

} final offline = TabRepositoryImpl(ServerDriver(networkDown: true)); final saved = offline.markAsPaid(Tab(4, const [], 1895, true)); switch (saved) { case TabSaved(:final tab): print(“markAsPaid(t) → TabSaved(${tab.table})"); case InfraFailure(:final failure): print( “markAsPaid(t) network down → " “InfraFailure(Failure.${failure.name})", ); } }

Run it and you get the two lines every version of this chapter prints: findTab(4) success → TabFound (table 4, 1895 cents) markAsPaid(t) network down → InfraFailure(Failure.noConnection) The consumer handles both Results with an exhaustive switch , the same one from chapter 8, and nowhere in it does a try/catch exist. The network drop arrived as a value, typed, and the compiler guaranteed the failure case got handled. Try it: open https://focus.kodel.com.br/en/dart/15-01 and delete the case InfraFailure(...) arm from one of main ’s switches. Prediction: the code doesn’t even run; the compiler flags the switch as non-exhaustive and lists the missing variant. That’s the warning the anti-solution never had: there, the payment screen forgot the catch and nobody warned it. The other nine languages are at /en/kotlin/15-01, /en/ts/15-01, /en/java/15-01, /en/csharp/15-01, /en/go/15- 01, /en/php/15-01, /en/python/15-01, /en/swift/15-01, and /en/rust/15-01. The lookup feeding the orchestrator The repository’s contract closes the loop with chapter 13. The orchestrator used to fetch the tab and hand it to the use case; now the lookup can return InfraFailure , and it’s the orchestrator that translates that into state, because it, not the repository, is the one publishing state to the screen.

Dart // In the orchestrator (chapter 13): the lookup can now fail over the // network. final lookup = _repository.findTab(event.table); switch (lookup) { case TabFound(:final tab): emit(Ready(toData(tab))); case InfraFailure(): emit(Failed(“no connection”)); // the failure state, not the exception } The failure arm doesn’t even open the variant: the orchestrator just needs to know it failed, and the unbound case InfraFailure() says exactly that. The publishing mechanism is chapter 13’s (the bloc’s stream, StateFlow , the Redux store), and nothing about it changes here; what changes is that the failure state can now be born from a translated network drop, on top of the use case’s rule violation. The repository doesn’t know a screen is waiting; it returned a value, and the orchestrator decided which state that value becomes.

The same boundary in ten languages The single translation isn’t a Dart trick. The nine listings below are the same TabRepositoryImpl you just read, each one in its own language’s idiom. In every one, look for the same three things: the point where the driver’s exception gets caught, the line that converts it into InfraFailure , and the absence of any business rule in between. What changes from one to the next is only how the language writes “one of two outcomes.” TypeScript has no sealed class and solves the Result with a discriminated union, a type whose kind field tells the cases apart. The parameterless catch catches any error the driver throws, and the return comes back already labeled: TypeScript type LookupResult = | { readonly kind: “tabFound”; readonly tab: Tab } | { readonly kind: “infraFailure”; readonly failure: Failure }; type SaveResult = | { readonly kind: “tabSaved”; readonly tab: Tab } | { readonly kind: “infraFailure”; readonly failure: Failure };

class TabRepositoryImpl implements TabRepository { constructor(private readonly driver: ServerDriver) {} findTab(table: number): LookupResult { try { return { kind: “tabFound”, tab: this.driver.query(table) }; } catch { return { kind: “infraFailure”, failure: “noConnection” }; } } save(tab: Tab): SaveResult { try { this.driver.write(tab); return { kind: “tabSaved”, tab };

} catch { return { kind: “infraFailure”, failure: “noConnection” }; } } markAsPaid(tab: Tab): SaveResult { return this.save(tab); } } Exhaustiveness doesn’t come free: the consumer switches on result.kind with a default that calls assertNever . Add a third case to the union and forget to handle it, and never complains at compile time. It’s chapter 8’s protection, opt-in. Kotlin is Dart’s next-door neighbor, with one advantage: sealed interface accepts multiple implementation, so InfraFailure belongs to both Results without the extends / implements pair Dart needed. And since try is an expression in Kotlin, each method fits in a single = , with no return : Kotlin class TabRepositoryImpl(

private val driver: ServerDriver, ) : TabRepository { override fun findTab(table: Int): LookupResult = try { TabFound(driver.query(table)) } catch (e: IOException) { InfraFailure(Failure.noConnection) } override fun save(tab: Tab): SaveResult = try { driver.write(tab) TabSaved(tab) } catch (e: IOException) { InfraFailure(Failure.noConnection) }

override fun markAsPaid(tab: Tab): SaveResult = save(tab) } Swift models both Results as an enum with associated values, and the translation turns into do/catch . Notice the loose dot in .infraFailure and .noConnection : Swift infers the type from the declared return, so the enum’s name disappears from the line. Whoever consumes it gets an exhaustive switch by the language’s own requirement. Swift struct TabRepositoryImpl: TabRepository { let driver: ServerDriver func findTab(_ table: Int) -> LookupResult { do { return .tabFound(try driver.query(table)) } catch { return .infraFailure(.noConnection)

} } func save(_ tab: Tab) -> SaveResult { do { try driver.write(tab) return .tabSaved(tab) } catch { return .infraFailure(.noConnection) } } func markAsPaid(_ tab: Tab) -> SaveResult { return save(tab) }

} C# carries chapter 8’s caveat: a record hierarchy doesn’t guarantee exhaustiveness, and the compiler only emits a warning. There’s a second consequence here, and it’s about modeling: a record in C# is a class, and a class inherits from only one, so LookupResult and SaveResult need to be interfaces for InfraFailure to belong to both. C# class TabRepositoryImpl : ITabRepository { private readonly ServerDriver _driver; public TabRepositoryImpl(ServerDriver driver) => _driver = driver; public LookupResult FindTab(int table) { try {

return new TabFound(_driver.Query(table)); } catch (System.IO.IOException) { return new InfraFailure(Failure.NoConnection); } } public SaveResult Save(Tab tab) { try { _driver.Write(tab); return new TabSaved(tab); }

catch (System.IO.IOException) { return new InfraFailure(Failure.NoConnection); } } public SaveResult MarkAsPaid(Tab tab) => Save(tab); } Java 21 writes the same design with sealed interface and record , and the consumer’s pattern switch is exhaustive just like Dart’s. The difference that jumps out is the throws IOException on the driver’s signature: in Java, an IO exception is checked, and the compiler demands that someone handle it. That someone is the repository. The language pushes the catch exactly where FOCUS wants it. Java static final class TabRepositoryImpl implements TabRepository { private final ServerDriver driver;

TabRepositoryImpl(ServerDriver driver) { this.driver = driver; } @Override public LookupResult findTab(int table) { try { return new TabFound(driver.query(table)); } catch (IOException e) { return new InfraFailure(Failure.noConnection); } } @Override public SaveResult save(Tab tab) { try {

driver.write(tab); return new TabSaved(tab); } catch (IOException e) { return new InfraFailure(Failure.noConnection); } } @Override public SaveResult markAsPaid(Tab tab) { return save(tab); } } PHP has no real sealed types, and the snippet solves it with marker interfaces: LookupResult and SaveResult are empty interfaces, and the variants are final readonly classes that implement them. Sealing turns into convention, enforced in review, and the

consumer’s match fails at runtime if a case is missing. Notice the catch (RuntimeException) with no variable: PHP 8 lets you omit $e when nobody’s going to use it. PHP final class TabRepositoryImpl implements TabRepository { public function __construct(private readonly ServerDriver $driver) { } public function findTab(int $table): LookupResult { try { return new TabFound($this->driver->query($table)); } catch (RuntimeException) { return new InfraFailure(Failure::NoConnection); }

} public function save(Tab $tab): SaveResult { try { $this->driver->write($tab); return new TabSaved($tab); } catch (RuntimeException) { return new InfraFailure(Failure::NoConnection); } } public function markAsPaid(Tab $tab): SaveResult { return $this->save($tab);

} } Python builds the Results with a frozen dataclass and the | union operator, and catches ConnectionError , the standard library’s network exception. Exhaustiveness is left to the type checker: in the consumer, the match ends in a case _: assert_never(...) , which mypy enforces during static analysis and the interpreter ignores. Python class TabRepositoryImpl: def init(self, driver: ServerDriver) -> None: self._driver = driver def find_tab(self, table: int) -> LookupResult: try: return TabFound(self._driver.query(table)) except ConnectionError: return InfraFailure(Failure.NO_CONNECTION)

def save(self, tab: Tab) -> SaveResult: try: self._driver.write(tab) return TabSaved(tab) except ConnectionError: return InfraFailure(Failure.NO_CONNECTION) def mark_as_paid(self, tab: Tab) -> SaveResult: return self.save(tab) Go pulls in the opposite direction, and it’s the chapter’s counterpoint. The language has no union and no sealed type; its idiom for “success or failure” is the native (T, error) pair, which chapter 8 already introduced. Here’s the point: translating an infrastructure error into a domain error inside the repository isn’t an imported pattern in Go, it’s the idiomatic way to do it. The translation even earns a method with its own name, translate , and errors.As is what feeds it: Go func (r TabRepositoryImpl) translate(err error) error {

var opErr *net.OpError if errors.As(err, &opErr) { return ErrNoConnection } return err } func (r TabRepositoryImpl) FindTab(table int) (Tab, error) { tab, err := r.Driver.Query(table) if err != nil { return Tab{}, r.translate(err) } return tab, nil

} func (r TabRepositoryImpl) Save(tab Tab) error { return r.translate(r.Driver.Write(tab)) } func (r TabRepositoryImpl) MarkAsPaid(tab Tab) error { return r.Save(tab) } Without TabFound and InfraFailure types, Go carries the same information in the (Tab, error) pair, and the caller compares err against ErrNoConnection through errors.Is . What Go doesn’t do is force you to handle the error: a _ swallows err and the compiler stays quiet. Exhaustiveness left the compiler and turned into review discipline, exactly as in chapter 13. The single translation, though, still holds: the boundary’s ErrNoConnection is the other languages’ InfraFailure wearing different clothes. Rust closes the loop at Go’s opposite extreme: here there’s no exception at all to catch. The driver already returns Result<Tab, NetworkError> , so the single translation turns into a match that swaps the infra error for the domain Failure . It’s the same gesture as Dart’s, with the compiler billing you for every arm.

Rust impl TabRepository for TabRepositoryImpl { fn find_tab(&self, table: i64) -> LookupResult { match self.driver.query(table) { Ok(tab) => LookupResult::TabFound(tab), Err(NetworkError) => { LookupResult::InfraFailure(Failure::NoConnection) } } } fn save(&self, tab: Tab) -> SaveResult { match self.driver.write(&tab) { Ok(()) => SaveResult::TabSaved(tab), Err(NetworkError) => {

SaveResult::InfraFailure(Failure::NoConnection) } } } fn mark_as_paid(&self, tab: Tab) -> SaveResult { self.save(tab) } } Ten languages, ten syntaxes, one structure. Sealed types in three of them, a marker interface in two, a type union in two, an algebraic enum in two, and the (T, error) pair in one; try/catch in six, do/catch in one, try/except in one, match over a Result in one, and errors.As in one. And in every one of them, without exception, the same single point: the driver’s exception goes in, the typed Failure comes out, and nothing infrastructural crosses the door. Each version runs on its own short route and prints the same two lines. Where reading can stop

Go back to the three-method contract and ask a reader’s question rather than an author’s: to change the payment rule, how much of the repository do you have to read? The answer is the interface, and nothing below it. The three signatures say what goes in, what comes out, and how failure shows up; the driver, the SQL, the retry, and the table’s schema stay on the other side. It isn’t that they’re irrelevant: it’s that they’re irrelevant to that change. A point in the system where reading can stop without owing anything has a name in the architecture literature for language models: context boundaries, the limits that say how far context has to be loaded to understand a decision. Chapter 11 dealt with the slice that fits whole in the window; here comes what the slice can legitimately leave out. The two hold each other up: the slice only fits because a boundary says the driver doesn’t have to come along. The proof is the reverse exercise. In the anti-solution that opened the chapter, the SocketException catch sat inside every screen, and every screen was itself a piece of driver. Reading couldn’t stop anywhere there: to know why the menu screen returned “no connection” and the history screen returned “couldn’t load,” you had to read both screens and the network library both of them used. The boundary isn’t a folder or a package; it’s the point where the infrastructure type stops existing. Contracts that age slowly The boundary in the section above holds for a snapshot of the code. The other half is missing, which is what happens to it over time.

Swap TabRepositoryImpl ’s ServerDriver for a client of a different database. Then add an in-memory cache for the lookup. Then a retry with backoff for the write. All three touch the body, and none touches the three signatures: findTab still takes a table and returns a LookupResult , markAsPaid still returns a SaveResult . Whoever read the tab feature before the three changes reads the same thing after them. That property is what the same literature calls stable contracts: interfaces that survive swapping out what sits behind them. It isn’t automatic, and the ruler for checking is a harsh one. If swapping the implementation forces a change to the signature, the contract was leaking an implementation detail and you’ve just found out where. A findBySql(String sql) fails on sight. Returning the driver’s exception inside the Result fails too, and that’s why Failure has noConnection , which is a fact about the tab that never arrived, and not SocketException , which is a fact about the library of the month. The promise is worth measuring. A stable contract doesn’t mean a frozen contract: when the coffee shop starts accepting partial payment, a new method comes in, and it should. What the stable contract promises is something else: that the signature changes when the rule changes, not when the database changes. The critique: the generic repository and “the ORM already does this” A pattern with a design-pattern name draws two strong critiques, and both deserve a sourced answer. The first attacks the generic form. There’s a temptation, popular in enterprise code, to write an IRepository with Add(T) , GetById(id) , GetAll() , and Delete(T) , and derive every repository

from it. Ben Morris takes that apart in the article “Why the generic repository is just a lazy anti-pattern” (ben-morris.com), and the argument is direct: the generic repository isolates nothing. To serve any real query it ends up exposing an IQueryable , and the underlying technology (Entity Framework, SQL) leaks through the interface that was supposed to hide it. You gained a layer of indirection and didn’t gain the isolation it promised. It’s the same reading Fowler and Evans gave, defining the repository by domain aggregate, with methods the feature asks for, not as a per-table CRUD. Here’s my position, no middle ground: I wouldn’t write a generic save(T entity) under torture, and the tab’s case says why. A generic save doesn’t know how to translate SocketException into the tab’s vocabulary; it would hand back the raw exception or a bool , and the typed InfraFailure would die at the door. A generic getAll() invites fetching the whole tab table when the screen only needs table 4’s total. FOCUS’s repository contract is the opposite of generic: the three methods the tab feature asks for, named in the business’s own words, and nothing more. That restriction is the pattern’s main feature. The second critique comes from ORM users: “Entity Framework, Prisma, Eloquent are already repositories; FOCUS’s layer is redundant.” The ORM (Object-Relational Mapping) is the library that translates table rows into objects and back. It solves the mapping, and solves it well. What it doesn’t do are the two roles the canonical table’s row demands. The first is the per-feature contract: EF’s DbContext exposes the whole database, every table, every possible query; the tab repository exposes three verbs. The second is the exception funnel: the ORM throws its own connection exception ( DbUpdateException , SocketException underneath) and doesn’t translate it into your vocabulary. The repository is

the house where that translation happens exactly once. The ORM lives inside the repository, as one more driver: it’s one of the things the boundary wraps.

Preview of the fake: the interface you’ll thank in chapter 17 All this interface discipline pays off somewhere that only shows up in testing. Since the repository is the one piece that touches network, database, and disk, it’s also the one piece you don’t want to call for real in a unit test: a test that opens a socket is slow, flaky, and depends on the server being up. The way out is swapping the real implementation for a double, and the interface from the start of the chapter is what makes the swap trivial. A repository fake is the same interface implemented in memory, one that hands back canned data or a simulated failure, without touching any IO at all. Dart // THE FAKE: same interface, in memory. The network outage is a flag, // not a socket. The test controls the outcome without touching any IO // at all. class TabRepositoryFake implements TabRepository { TabRepositoryFake({this.simulateNetworkOutage = false}); final bool simulateNetworkOutage;

@override LookupResult findTab(int table) { if (simulateNetworkOutage) { return InfraFailure(Failure.noConnection); } return TabFound( Tab( table, const [ (name: “Espresso”, priceInCents: 700), (name: “Cappuccino”, priceInCents: 1195), ], 1895, true, ),

); } @override SaveResult save(Tab tab) { if (simulateNetworkOutage) { return InfraFailure(Failure.noConnection); } return TabSaved(tab); } @override SaveResult markAsPaid(Tab tab) => save(tab); }

A single bool in the constructor plays the role of all the network’s instability. The test flips the flag, calls markAsPaid , and checks that it gets back a typed InfraFailure(Failure.noConnection) , with no server and no framework double. The confirm in the snippet is the snippet’s own assertion, which prints “ok” when the condition holds and blows up when it doesn’t; in your project, swap it for expect from package:test : Dart // Simulated network outage: the write returns a typed noConnection. final TabRepository offline = TabRepositoryFake(simulateNetworkOutage: true); final saved = offline.markAsPaid(Tab(4, const [], 1895, true)); switch (saved) { case TabSaved(): confirm( “with the network down, the write should not go through”, false,

); case InfraFailure(:final failure): confirm( “markAsPaid with the network down returns a typed InfraFailure”, failure == Failure.noConnection, ); } The fake steps into the real one’s place through dependency injection (chapter 9), the same technique already holding up the rest of the app: whoever builds the orchestrator receives a TabRepository , and in the test that parameter is the fake. Notice where injection pays off and where it doesn’t. In chapter 14’s use case, which is a pure function, you inject nothing; you call the function with data and compare the Result, no double needed. It’s at the IO boundary that injection earns its keep, because that’s where an external-world dependency exists to swap out. Injecting an interface into a pure use case would be ceremony with no payoff; injecting one into the repository is what makes the test possible at all. Chapter 17 is entirely about this, and it reuses exactly this fake. Try it: open https://focus.kodel.com.br/en/dart/15-02 and flip the simulateNetworkOutage flag in the happy-path case. Prediction: the lookup’s assertion fails on the spot, because

the fake started returning InfraFailure where the test expected the tab. That’s the test proving the network drop arrives typed, with no socket ever opened. The other nine languages are at /en/kotlin/15-02, /en/ts/15-02, /en/java/15- 02, /en/csharp/15-02, /en/go/15-02, /en/php/15-02, /en/python/15-02, /en/swift/15-02, and /en/rust/15-02. Pitfalls The first pitfall is a business rule hiding inside a “clever” query. The repository fetches the tab, and someone figures it’s elegant to filter right there: fetch only the tabs belonging to customers with more than 100 loyalty points. It looks like optimization, saves a round trip. What goes wrong: the “earned the discount” criterion just gained a second home, inside a WHERE , far from the use case that decides it, and the day the rule changes, the repository falls behind. How to get out: the repository fetches the whole tab, and the use case decides who earns what. The query brings data; it doesn’t judge. Watch out for the inverted reading of that pitfall: it doesn’t ban WHERE , ORDER BY , or pagination from the repository. Filtering is the query’s job, and the database does it better than any layer of yours: fetching a thousand tabs to throw away nine hundred and ninety-eight in the application trades an index for wasted network traffic and memory. I call that waste the findAll trap, because it’s always the same gesture: pull the whole table to filter it in memory. “Table 4’s open tabs, newest first” is a legitimate method on the contract, with the filter and the ordering inside the query. The line separating the two cases is the meaning of the criterion: table, status, date, and page size describe what data you

want, and they go into the query; “earned the discount” is a rule’s verdict, and verdicts live in the use case. The repository selects; it doesn’t decide. The second pitfall is an IO catch leaking into another layer. The orchestrator or the use case receives the repository’s Result and, “just to be safe,” wraps the call in a try/catch too. What goes wrong: you just recreated the anti-solution’s duplication, one layer up, and now there are two places translating the exception, with the risk that they disagree. The numbered tip at the end of the chapter is the test: if an infra exception showed up outside the repository, you have a leak, and the fix is removing the catch above, not adding one more. Q&A The repository is asynchronous in real life (the network takes time). Why is this chapter’s code synchronous? So the canonical scenario runs the same, short way across all ten languages, without dragging each one’s concurrency model into the example. The mechanism (async/await in Dart and C#, suspend in Kotlin, a goroutine in Go) is orthogonal to the thesis: the single translation happens in the same place, synchronous or not. Swap the return for a Future / Promise and the catch stays exactly where it is. Where does caching fit in, then? Shouldn’t the lookup keep the last result around? It can, and the place for it is inside the repository, invisible to the caller. Caching is a detail of how the repository fulfills the contract, not an item on the interface. What chapter 13’s third pitfall bans is caching in the orchestrator, which creates a second owner of the data; inside the repository, in one single place, it’s legitimate.

So are WHERE , ORDER BY , and pagination banned from the repository? No; they’re its job. Filtering, ordering, and paginating at the source is what keeps a query cheap, and pulling everything to sift through it in the application is the earlier section’s findAll trap. What the first pitfall bans is something else: a criterion carrying a business decision (“who earned the discount”) hidden inside the query. Selecting which data to bring back is the repository’s job; deciding what the data means is the use case’s. Why two Results, LookupResult and SaveResult , if the failure is the same? Because the success isn’t the same: reading returns a found tab, writing confirms a saved tab, and those are gestures of a different nature. The infra failure, that one really is shared, which is why InfraFailure implements both. That asymmetry (different success, same failure) is the seed of chapter 16’s distinction. Quick tip Open your project’s fattest repository and search it for if and for where / filter . Every condition whose operand is a business value (loyalty points, a price range, “earned it”) is a rule that leaked into the query, and its destination is a use case. The selection conditions (table 4’s tab, today’s open ones, ordered by date, twenty per page) stay right where they are: that WHERE is the repository doing its job, in the cheapest place to do it. The repository filters by selection; never by policy. Quick reference

The question “does this belong in the repository?” answers itself by nature: Situation Fix Infra exception (network, database, disk) repository: translates it into a Failure IO try/catch in another layer leaked: remove it, the repository already translated Deciding who earned the discount leaked: the decision goes to the use case (chapter 14) Fetching table 4’s tab repository: it owns access to the data Screen updates itself when the data changes orchestrator (chapter 13) Generic method save or getAll() out: only the feature’s own verbs WHERE / ORDER BY /page by table, date repository: selection is the query’s job WHERE that decides discount policy leaked: the rule goes to the use case Caching the lookup’s last result repository, in one place, invisible Exercises

  1. Add the variant Failure.timedOut , translated from TimeoutException , to snippet 15-01 in your language. Start with the repository: add a second catch (or error arm) that turns TimeoutException into InfraFailure(Failure.timedOut) , next to the noConnection that’s already there. Now follow the compiler’s errors: in Dart, Kotlin, Swift, and Rust, every consumer switch / match that doesn’t handle timedOut becomes non-exhaustive and the compiler points at the exact spot; in C#, PHP, and Python, it’s the _ arm or the type checker that charges you. Finish with the base rule intact: noConnection keeps working, and the output gains a third line with the new timed-out case, without you touching the repository in more than one spot.
  2. Rosie wants the app to work offline: if the network drops during a lookup, the screen should show the last known tab instead of an error. Could you add a cache inside the repository, invisible to the orchestrator, that returns TabFound with the stale data when the network drops, without moving a single line of code outside the boundary? Think about where the SocketException catch decides between the cache and the Failure . Tip 15 Every infrastructure exception dies in the repository; if it showed up in another layer, you have a leak. Next chapter: the repository has two verbs of a different nature, finding and saving, and this chapter treated them almost the same, with the same InfraFailure on both sides. Do asking and changing really deserve the same treatment, or is there an asymmetry this chapter hasn’t charged for yet?

Powered by TurnKey Linux.