DRY: every piece of knowledge has one home. KISS: pick the simplest design that works. YAGNI: do not build for a need you do not have yet. Removing duplication hurts when two pieces of code only look alike but change for different reasons.
DRY is about knowledge, not text: a tax rate or a password rule copied into two files will drift apart; two Padding widgets with the same inset are not a violation
Coincidental duplication: a promo code and a referral code are both 8 characters today; merge them into one isValidCode and the next rule change for one breaks the other
Rule of three: tolerate a second copy and extract on the third, when you can see what actually varies
KISS in Flutter: setState for a toggle on one screen, a ValueNotifier before a Bloc, a plain class before a generic one
YAGNI: no Repository<T> base for one entity, no plugin system for one payment method; code added "for later" must be read, tested and migrated now
They pull against each other: DRY pushes towards abstraction, KISS and YAGNI push back; decide by what a later change would cost
FlutterMiddle
SOLID & Design Principles
The SOLID principles in Dart and Flutter, DRY, KISS and YAGNI, coupling and cohesion, the Law of Demeter, dependency injection vs service locator, code smells and refactoring
Questions closed
0 / 18
- 0Review
- 4Asked often
Questions
Coupling is how much one module depends on another's details; cohesion is how closely the things inside one module belong together. Low coupling lets you change one part on its own; high cohesion puts each change in one place.
Tight coupling: a widget that builds its own Dio() and parses JSON, or a Cubit that reads another Cubit's fields; a change to the API or to that Cubit breaks the screen
Loose coupling: the widget receives state and callbacks, the Cubit receives a repository interface through its constructor; each side can change behind that boundary
High cohesion: lib/features/cart/ holds the cart's model, repository, Cubit and screens, so a cart change stays in that folder
Low cohesion: a CartService that also formats dates and sends analytics, or a helpers file every feature imports
Quick check: count the files a typical change touches (many means low cohesion) and the imports a file needs (many means tight coupling)
Coupling never reaches zero: something has to know every concrete class; keep that in the composition root (main.dart)
A code smell is a surface sign that the design may be wrong: the code works, but it will be hard to change. It is a reason to look closer, not a bug and not an automatic refactor.
Large class: a 600-line State that validates, calls the network and lays out the screen; usually several responsibilities
Feature envy: a method that mostly reads another object's fields (order.items, order.discount, order.shipping) to compute a total; move it onto Order
Shotgun surgery: one new order status edits the enum, three switches, two mappers and a colour map; the knowledge is scattered
Divergent change: the opposite case, one class edited for unrelated reasons (the API format, the UI copy, the pricing rules)
Primitive obsession: ids, money and emails as bare String, int and double, so a userId fits where an orderId belongs
Speculative generality: base classes, type parameters and hooks with exactly one user
A comment that explains what: // can the user pay? above a ten-line condition is a getter named canPay waiting to be extracted
Refactoring changes the structure of code without changing what it does. You keep it safe with tests that pin the current behaviour, small steps that each leave the app working, and refactor commits kept apart from feature changes.
Not a rewrite and not a fix: if what the user sees changes, it is a feature or a bug fix; mixed into a refactor, it makes the diff unreviewable
Tests first: run them before you start; where there are none, write a few that pin what the code does now, odd parts included
Small steps: rename, extract, move, inline, and run the tests after each; a step that goes red is reverted, not debugged
Let the tools edit: the Dart analysis server's Rename, Extract Method, Extract Local Variable and Extract Widget, in VS Code and Android Studio alike, update every call site; hand edits miss some
One kind of change per commit: move files, then extract, then the behaviour change in its own PR
Boy Scout rule: leave the code a little cleaner than you found it, but only around what you are already changing
Split it by reason to change: the validation rules, the API call, the price format and the layout change at different times for different people. Give each its own home (a state holder, a repository, a formatter, small widgets) so the State only connects them to the screen.
One reason to change, not one method: ask who would request the edit; product changes the rules, the backend team the endpoint, design the layout
Rules and the submit flow go into a ChangeNotifier, Cubit or Notifier, which a unit test drives without pumping a widget
The API call goes behind a repository injected into that state holder; the screen never sees Dio or JSON
Formatting goes into a small pure class or an extension, so the cart and the receipt print prices the same way
The layout splits into widget classes per section; private _buildX() methods shorten build but leave every responsibility in one class
A quick test: group the fields by the methods that use them; groups that never touch each other are classes waiting to be extracted
Size alone is not the signal: a long widget that only lays out one complex form still has one responsibility
Open for extension, closed for modification: a new case arrives as new code plugged into an existing extension point, not as another edit to code that already works. In widgets that means slots and builders instead of flags; in services, an interface you add implementations to.
Flutter does it everywhere: ListTile takes any widget as leading and trailing, AppBar takes actions, ListView.builder takes an itemBuilder; nobody edits ListTile to add a badge
The widget smell: a shared ProductCard that gains showBadge, isCompact and showFavorite, one per screen; each request edits, and risks, the widget every screen uses
Services: checkout takes a List<PaymentMethod>; adding Apple Pay is a new class plus one line in the composition root, and the checkout code stays untouched
More extension points to name: ThemeExtension, ScrollPhysics, TextInputFormatter, Dio Interceptors
The service smell: the same switch (method.type) copied into five files, so a new type means five edits and one of them forgotten
Sealed classes are closed on purpose: when the set of cases is yours and every switch should be revisited, the exhaustiveness error is the feature; pick an interface when others add the cases
Liskov Substitution. LimitedCart is accepted wherever a Cart is, but its add quietly does nothing once the cart is full, so buyAgain, written against Cart, counts items it never added: it returns 5 while count is 3.
LSP is about behaviour, not signatures: the override has the same type, so the analyzer is satisfied; what changed is the promise every caller of Cart relies on, that add makes count one higher
The rule of thumb: a subtype may accept more and promise more, never demand more or deliver less; a silent no-op or a new throw UnsupportedError delivers less, and an extra call required before add demands more
The symptom: callers start writing if (cart is LimitedCart) to protect themselves, which breaks Open/Closed as well
Fix by putting the variation into the contract: every Cart may have a limit and tryAdd returns whether it worked, so each caller handles a full cart; or drop the subclass and pass the limit in as a policy
The SDK bends it too, on the record: List.unmodifiable returns a List whose add throws UnsupportedError; that is documented, and it is why code should not mutate a list it did not create
Catch it: run the base class's tests against every subtype; this one fails the first that adds past the limit
Treat the fake as one more implementation that must honour the same contract, and prove it: write the contract once as a shared test suite and run it against both the fake and the real repository.
This is Liskov applied to tests: a widget test is only as true as its fake; a fake that never fails, never returns an empty page or sorts differently tests a different app
A contract test: a function such as ordersRepositoryContract(create) holds the tests, and the fake's test file and the real one's both call it
What goes into the contract: ordering, paging edges, what "not found" is (null or an exception, and which type), whether a write shows up in the next read, the error types
The real side runs against something local: a MockClient replaying recorded responses, the Firebase emulator, an in-memory Drift database
One owned fake beats fifty stubs: an InMemoryOrdersRepository checked by the contract replaces scattered when(...) calls, each with its own idea of the backend
When the contract changes: write the new test first; both implementations go red until they agree
An object cannot change its class, so an upgrade means replacing the User, and every screen, cache and provider holding the old one keeps showing ads. The plan is changing state, not a kind of user: make it a field (user.plan) and ask about capabilities, not types.
Stale copies: whoever kept a reference to the old User object stays wrong until it reloads; nothing tells it the type changed
**is checks spread**: if (user is PremiumUser) in twenty widgets is a type switch without exhaustiveness; a new Family tier means finding every one of them (Open/Closed)
Combinations explode: premium and admin, trial and premium; single inheritance cannot express both, and a PremiumAdminUser class is a warning sign
Model it as data: final Plan plan, a sealed class whose variants carry their own fields (Trial(endsAt)); an upgrade is copyWith(plan: ...), the state holder emits it, and every listener rebuilds
Ask for capabilities: user.can(Feature.noAds), so a pricing change edits one table instead of every screen
When a subtype is right: the kind never changes during the object's life and every subtype keeps the base contract, like PostItem and AdItem in a feed
Clients should not depend on methods they do not call. In Dart, implements SomeClass takes that class's whole public surface, so a fake for one screen ends up with twenty methods; give each client a small role interface, and let one class implement several.
Flutter is built this way: ListenableBuilder and AnimatedBuilder accept any Listenable (two methods), not a ChangeNotifier; vsync: takes a TickerProvider with one method, createTicker, which is why a State with SingleTickerProviderStateMixin can be passed
What a fat interface costs: with history, placement, refunds and tracking in one OrdersService, every fake implements all of it, and each new method touches every implementer
Split by client, not by entity: OrderHistory for the list screen, OrderPlacement for checkout; the real repository implements both, and each view model asks for the one it uses
The trap in implementing a concrete class: you cannot implement its private members from another library, so if its own library calls one on your object, Dart throws NoSuchMethodError at run time; declare an abstract interface class instead
Do not atomise: one interface per method is as hard to read as one fat one; split where clients actually differ
Dependency Inversion is a design rule: high-level code owns an abstraction and low-level code implements it, so the dependency points at the policy. Dependency injection and a service locator are two ways to hand over the implementation: pushed in from outside, or pulled by the class itself.
Inversion is about ownership: OrdersRepository is declared next to the code that uses it (the domain or the feature), so the data layer imports the domain, never the other way round
Injection without inversion is common: a view model that receives DioOrdersApi is injected and testable, yet still shaped by Dio's API
No container needed: constructor parameters wired in main.dart give you both; get_it, injectable and Riverpod only automate the wiring
A service locator keeps the abstraction but hides it: GetIt.I<OrdersRepository>() inside a class is in no signature, and a missing registration fails only at run time; call the locator at the edges (the composition root, a widget) and inject from there
Inversion of Control is the umbrella: the framework calls your code; Flutter calls your build and initState, and DI is the same idea applied to creating objects
Write the interface where it marks a boundary that has to stay clean or is likely to change: a vendor SDK, a backend, a platform split, or a domain that must not import the data layer. Inside a feature a concrete class is enough, because a test can already implement or mock it.
Worth it for volatile dependencies: payments, analytics, maps, a backend you do not control; the interface is the seam where the second implementation will appear
Worth it at layer and package boundaries: the domain declares OrdersRepository and the data package implements it; without the interface the dependency points the wrong way
Worth it for real variants: mobile and web, a demo build, card and Apple Pay
Ceremony: ProfileService plus ProfileServiceImpl with the same members and no second implementation: two files per change, and go-to-definition lands on the interface
Tests do not need it: class FakeClock implements Clock works on a concrete Clock, and so does mocktail's class MockClock extends Mock implements Clock
Also ceremony: one use case per repository method that only forwards the call
At a package boundary, decide up front: adding a member to a class others implement breaks them, so either mark the class final (nobody can implement it) or publish an abstract interface class and treat each new member as a breaking change
Talk only to your direct collaborators, and tell them what to do instead of pulling out their data and deciding for them. That chain reaches through three objects into a raw list, so the button depends on how the session is built and skips every rule the cart should enforce.
What it costs: the button now depends on Session, User, Cart and List; moving the cart out of User breaks every screen that copied the chain
Rules leak into callers: the item limit, the total and the analytics event are re-implemented in each button, and one of them forgets
Tell, don't ask: context.read<CartController>().add(item) enforces the rules and reports what happened; the screen only reacts
A sign in the code: a ! in the middle of a chain, or a null check on each link
Not every dot is a violation: Theme.of(context).textTheme.bodyLarge walks through immutable data, which is fine; the law is about commanding objects that have behaviour and invariants
Cascades are fine too: Paint()..color = c..strokeWidth = 2 keeps talking to the same object
Prefs works only after init(), and only HomePage calls it. A notification opens the settings screen directly, HomePage is never built, and the first read of the late field throws. Make a half-built Prefs impossible: load it in main and pass the ready object down.
Temporal coupling: correctness depends on calls happening in an order nothing enforces, and every new entry point (a deep link, a widget test, a background isolate) is another chance to skip init()
Not awaited either: even through HomePage, prefs.init() is fire-and-forget, so a read in the first frame can still come before the storage is ready
The fix: a private constructor that takes a ready SharedPreferences and a static Future<Prefs> load(); main awaits it before runApp, so no code can hold a Prefs that is not ready
**late is a promise the compiler cannot check**: here it turns a missing dependency into a crash on one path; a final field set in the constructor makes it a compile-time requirement
Drop the global too: provide the instance through Provider or your DI, so a test builds its own after SharedPreferences.setMockInitialValues({})
Catch it next time: a widget test that pumps SettingsPage on its own, with no HomePage in front of it
A long parameter list, a data clump, primitive obsession and flag arguments. Give ids their own types, group the address and the price into value objects, replace the two positional bools with an enum and a named flag, and pass one OrderRequest.
Long parameter list: ten positional arguments; swapping user.id and product.id compiles, and true, false says nothing at the call site. Introduce a parameter object: OrderRequest
Data clump: street, city and zip always travel together; they are an Address, which can then validate itself
Primitive obsession: money as double drifts (0.1 + 0.2 is not 0.3), and price and currency can be passed apart; a Money(cents, currency) value object fixes both
**Ids as String**: extension types (extension type const UserId(String value) {}) cost nothing at run time, and passing a ProductId where a UserId belongs stops compiling
Flag arguments: isGift and express switch behaviour inside the function; an enum (Shipping.express) or named parameters make the call read, and the avoid_positional_boolean_parameters lint flags the positional ones
Named parameters alone are not the fix: they make the call readable but leave ten loose values and no type that groups them
It has no cohesion: dates, prices, validators and dialog helpers share a file only because none of them had a home, and every feature now depends on all of them. Move each function next to the code that owns its knowledge, often as an extension or a method on its type.
Hidden coupling: a change to formatPrice for the cart is a change to a file forty features import, so reviews, tests and merge conflicts fan out
Nothing is findable: there is no name to search for, so teammates write formatPrice2 instead of finding the first one
Functions about one type go onto it: formatDuration(d) becomes an extension on Duration, isValidEmail(s) becomes Email.tryParse(s); behaviour sits next to its data and shows up in autocomplete
Feature-only helpers move into the feature: if only checkout uses it, it lives in features/checkout/
Truly shared code gets a subject: core/money/, core/time/relative_time.dart; small files you can name
Move in small steps: one group of helpers per PR, using the IDE's move so imports update, and delete utils.dart once it is empty
The abstraction was built from what the first repositories looked like, not from what their callers need, and every entity that did not fit was forced into a CRUD contract it could not keep. Inline it back into concrete repositories, then extract only what they really share.
Why it went wrong
Coincidental similarity: the first three entities were CRUD over REST, so a base class looked like DRY; then came read-only countries and a feed paged by cursor
Each misfit leaks: an override that throws breaks Liskov (any caller holding a BaseRepository<T> can crash), a method nobody calls breaks Interface Segregation, and a parameter added "for one case" makes the base everyone's problem
Sunk cost keeps it alive: people bend the shared class because it exists; in Sandi Metz's words, "duplication is far cheaper than the wrong abstraction"
Getting out
Inline first: copy what each repository uses into it and delete the rest; callers that depended on the base type get a feature interface instead
Re-extract by composition: what is truly shared is infrastructure (an API client that maps errors, a paging helper, a cache), held as a field rather than inherited
Contracts shaped by callers: CountriesRepository.all(), OrdersRepository.recent() and place(), named after intents
Next time
Rule of three, and the same reason to change: wait for the third copy, and check that the copies would change together
A helper is easier to leave than a base class: one caller can stop using a function without touching the others
Expose the smallest surface that covers the apps' use cases and hide every decision likely to change: the vendor SDK, the HTTP client, the storage. One library file exports the public types, everything else lives in lib/src, and no third-party type appears in a public signature.
What to expose
Use cases, not mechanics: Payments.charge(amount, idempotencyKey: ...) returning a sealed ChargeResult, not a vendor client or a Dio Response
Your own types only: a third-party type in a public signature makes its major release your major release, and the apps start depending on it directly
**An explicit export ... show list: a new type in an exported file stays private until someone adds it to the list
What to lock down
Class modifiers**: final class for value types such as Money, abstract interface class for what apps implement in tests; a class with no modifier can be both extended and implemented, so even adding a method to it can break an app
**@internal and @visibleForTesting** from package:meta: @internal marks members the package's own files share, @visibleForTesting those opened up only for tests; an app that uses either gets an analyzer warning, not a compile error
Sealed results are a promise: each new subtype breaks the apps' exhaustive switches, which makes it a major release; ship the cases you know plus a generic failure for the rest
Keeping it changeable
Apps import only the barrel: the implementation_imports lint flags package:payments/src/...
Deprecate before removing: keep a forwarding member marked @Deprecated for one major version
Test like a consumer: write most of the package's tests against package:payments/payments.dart alone, so refactoring src/ does not turn them red