instance is a getter with an arrow body, so every read runs Session._() and returns a new object: the token went into an object nobody kept. Store the instance once: static final Session instance = Session._();.
**get runs on every access: a getter is a method without parentheses, not a cached value
static final is initialized once: lazily on first read, then it is the same object for the life of the isolate
Same trap with factory Session() => Session._();: a factory constructor returns one instance only if it returns a stored one
??= also works**: static Session? _i; static Session get instance => _i ??= Session._();, worth it only when tests need to reset _i
FlutterSenior
Design Patterns
Singleton, Factory, Observer, Builder, Strategy, Command in Flutter
Questions closed
0 / 22
- 0Review
- 4Asked often
Questions
Flutter is built from them: the widget tree is Composite, ChangeNotifier and Stream are Observer, ScrollPhysics is Strategy, and Actions/Intent is Command. App code adds Repository, Adapter, Decorator and Factory around data and SDKs.
Composite: every widget is a node whose children are widgets too, so Column and Text are handled through the one Widget type
Observer: Listenable, ChangeNotifier, ValueNotifier, Stream; most state-management libraries build on one of them
Strategy: interchangeable behaviour behind one interface: ScrollPhysics, Curve, TextInputFormatter
Factory: a factory constructor such as fromJson that picks a subtype, or a function that returns a platform-specific implementation
Adapter and Decorator: wrap a vendor SDK in your own interface; wrap a repository with caching or logging behind the same interface
Singleton: one instance for the app's lifetime, and the one to be careful with: a global accessor hides dependencies and leaks state between tests, so register one instance in DI instead
A private named constructor plus a static final field holding the instance; Dart creates it lazily on first access, and a factory constructor can return it if callers should write Analytics(). The drawbacks come from the global access point, not from having one instance.
Hidden dependency: Analytics.instance inside a method shows up in no signature, so neither a reader nor a test can see or replace it
State leaks between tests: tests in one file share an isolate, so what test 3 writes, test 4 reads: they pass alone and fail as a suite
Wrong lifetime: it lives as long as the isolate, so a client holding the previous user's token survives sign-out
No async setup: a constructor cannot await, so anything that needs SharedPreferences.getInstance() ends up with late fields and an init() everyone must remember to call
The fix: keep one instance but create it in the composition root and inject it (registerLazySingleton in get_it, a Riverpod provider); if a global must stay, add a @visibleForTesting reset and call it in setUp
A repository hides where data comes from behind an interface: the app asks for a User and never learns whether it came from the network, a database or a cache. Switching or combining sources then changes one class, not every screen.
The domain layer declares the abstract class; the data layer implements it with a remote and a local data source, and dependency injection wires them at startup.
Swap the source freely: moving from REST to GraphQL, or adding an offline cache, changes one class
Tests get easy: a fake repository returning fixed objects replaces the whole network in a widget test
One place for the policy: network-first with a cache fallback, retries and deduplication live here, not in five view models
Return domain models: map DTOs inside the repository so JSON field names never reach the UI
Common mistake: a repository per screen, or one that returns HTTP responses; that is Dio with extra steps
The items getter returns the repository's own list, not a copy, so removeWhere on the screen deletes items from the cart itself: the filter lowers totalCents, and the hidden items are gone for good.
Dart lists are references: a getter hands out the same object; unlike Swift's value-type Array, nothing is copied on assignment
The fix: the repository returns List.unmodifiable(_items) and the screen builds its own list with where(...).toList(), so the cart changes only through the repository's methods
Copy or view: List.unmodifiable copies on every read; UnmodifiableListView(_items) from dart:collection is a cheap read-only window that still reflects later changes
Catch it: with a read-only list the old removeWhere fails loudly with Unsupported operation: Cannot remove from an unmodifiable list instead of corrupting state; a unit test that filters and then checks totalCents covers it
Dependency injection means a class receives its dependencies from outside instead of creating them, so a test can hand it a fake. The real choice is how you build the graph: constructor injection, a service locator (get_it) or Riverpod providers.
Constructor injection: dependencies sit in the signature, the compiler checks the wiring, tests pass fakes directly. The cost is threading them through, which is why something else usually builds the graph
**get_it**: a service locator: sl<UserRepository>() from anywhere, no code generation. The dependency is invisible in the signature, a missing registration crashes only at runtime, and tests must reset() a global
**injectable**: code generation on top of get_it: @injectable, @lazySingleton and @Environment('dev') generate the registrations, so they cannot drift from the code
Riverpod: providers are DI and state at once: dependencies come through ref, are created lazily, disposed with autoDispose, and a test swaps any of them with overrides
Rule of thumb: whatever the container, keep constructor injection inside your classes and touch the container only in the composition root and in widgets; then the container stays replaceable
LoginBloc is a lazy singleton, yet BlocProvider(create:) owns what it creates and closes it when the page is popped. The next visit gets the same closed bloc, so add throws Bad state: Cannot add new events after calling close.
Two owners, two lifetimes: get_it keeps the instance for the whole app, BlocProvider disposes it with the route; the shorter lifetime wins, and get_it keeps handing out a dead object
The fix: registerFactory for anything scoped to a screen (blocs, cubits, view models): every create gets a fresh instance, so closing it on pop is correct
Singletons are for app-wide services: repositories, Dio, analytics: objects no screen disposes
If sharing was the intent: keep the singleton, pass it with BlocProvider.value, which never closes it, and close it yourself, for example on logout
Catch it: a widget test that pumps the page, pops it, pushes it again and taps the button; the same mix-up with ChangeNotifierProvider(create: (_) => sl<CartModel>()) fails with "A CartModel was used after being disposed"
A facade is one entry point shaped like a task, sitting over several subsystems: it holds no data and adds no rules, it only knows how the pieces fit. A repository hides where one kind of data comes from; a facade hides how several things are combined for one user action.
Where it pays: a checkout that touches payment, inventory and notifications: CheckoutFacade.placeOrder(cart) hides the sequence and the rollback order
Repeated flows: pick an image, ask permission, compress, upload, report progress; every caller wants one method, not five
Shape it after the task: payWithCard(order) is a facade, getStripeClient() is a leaky abstraction
Delete it if it forwards one call: that is indirection with no isolation, and a facade that keeps collecting unrelated methods is several facades under one name
An adapter wraps a class that speaks one interface and re-exposes it through the interface your app wants. You reach for it when a third-party SDK's shape does not match your code and you control neither side.
In practice it implements your interface, holds the foreign object in a field, and translates each call.
Two SDKs, one contract: both analytics vendors behind a single Analytics interface, picked at startup
Ugly callbacks in, clean API out: a payment SDK's typed callbacks become one Future<Result> charge(...)
Foreign types stay outside: the adapter maps the SDK's data classes into your own domain models
It is how you build an anti-corruption layer: when the vendor ships a breaking v2, only the adapter changes
Common mistake: letting the SDK's types show through your interface; then you have a wrapper, not an adapter
A decorator wraps an object to add behaviour without changing its class: the wrapper implements the same interface, forwards every call to the inner object, and puts extra logic around it.
What it is for: logging, caching, retries or authorization checks around a repository or an HTTP client
Why not inheritance: decorators stack (auth, then cache, then logging) and you can choose them at runtime, per environment or per feature flag
Mixin: folds the behaviour into the class itself; cleaner when the behaviour truly belongs there, but you cannot add or remove it at runtime
Extension: adds methods without changing the type and cannot override an interface method, so it is for helpers, not for cross-cutting concerns
Use a decorator when: the behaviour is optional, composable, and must stay invisible to callers behind the same interface
Two. Both calls check the cache before either response has arrived, so both miss; the map fills only after the first await completes. Cache the Future instead of the value, so the second caller joins the request already in flight.
Why: the check and the write are split by an await, and other code runs in that gap even on a single isolate
The fix: a Map<String, Future<User>> filled with putIfAbsent: the first caller starts the request, every later one gets the same pending future
Evict failures: a cached failed future replays its error forever, so remove the entry when it fails and the next call retries
The general name: request deduplication; add a TTL when the data can go stale
A callback already is a Strategy when the behaviour is one operation with no state and no identity. Write an interface once the behaviour has several operations, carries state or needs a name.
More than one operation: encode and decode, canHandle and handle; three closures in a record is a class with worse ergonomics
It carries state: a retry policy counting attempts, a formatter holding a locale, a cache with its own eviction
It needs identity: a name for logs, a key to select it from a registry, equality so a test can assert which one ran
Readability: DiscountRule in a signature says something, bool Function(Order, User, DateTime) does not
Middle ground: a typedef names a function type without a class, and a class with a call method is both, so you can grow from one into the other without touching call sites
Flutter shows both: ScrollPhysics is an interface strategy, Comparator is a plain function.
A command turns an operation into an object with an execute() method and, when you need history, an undo(). Storing those objects instead of just calling functions is what makes undo possible.
Two stacks: push each executed command onto the undo stack; undo pops it, calls undo() and pushes it onto the redo stack, which any new command clears
Capture the before-state: a command stores what it needs to restore at the moment it runs, and never recomputes it inside undo()
The sender stays dumb: a button knows it fires a DeleteUser command, not what deleting does
Replay comes free: the command log can be replayed in tests or kept as an audit trail
When not to: wrapping every tap in a command is ceremony; you need it only for undo, replay or composition
Almost never for plain construction. Named arguments with defaults and copyWith already give you the readable, order-free, partly specified construction that Builder was invented for.
It still pays for accumulation: StringBuffer, BytesBuilder, a query builder collecting clauses before it runs
Or for a staged API: when the compiler must stop you calling build() before the required steps happened
Or for whole-object validation: checks that only make sense once, over the complete set of inputs
Cascades are not a substitute: .. configures a mutable object fluently but hands callers a half-built instance
**Flutter's builder: is a different pattern**: LayoutBuilder and ListView.builder defer construction: to get a BuildContext below the current widget, to receive constraints known only at layout time, or to build children lazily as they scroll in
A base class fixes the order of an algorithm and leaves named steps for subclasses to fill in. State works exactly this way: the framework decides when initState, build and dispose run, and you only override the hooks.
You never call the hooks: the framework drives them; calling initState() yourself is always a bug
**@mustCallSuper guards the base steps**: the analyzer warns when an override skips super; for dispose a debug-only assert fires too, which release builds compile out, so a missing super.dispose() can ship silently
**Where super goes**: super.initState() first so the base is ready, super.dispose() last so your resources go before the base tears down
Other examples: CustomPainter with paint and shouldRepaint, performLayout on a RenderBox, the onChange and onError hooks of a Bloc
Template Method or Strategy: inheritance fixes the steps at compile time, Strategy passes a step as an object you can swap. Prefer Strategy when one step varies and is chosen at runtime
ChangeNotifier, ValueNotifier and Stream are ready-made Observers: a subject keeps a list of listeners and calls them on change. BLoC is Observer with rules on top: named events in, immutable states out, and one class mapping one into the other.
**ChangeNotifier: synchronous and payload-free: it only says "something changed", and listeners read the new values themselves
Stream: each event carries a value or an error, delivery is asynchronous, and every subscription must be cancelled
What BLoC adds: a fixed input and output shape, so a screen's logic is one events-in, states-out class that you test by adding events and asserting states, with nothing rendered
Cancellation**: a new event can supersede work in flight, for example the restartable() transformer on a search field
When Observer is enough: a checkbox or a tab index; ValueNotifier is plenty and a bloc is ceremony
Each screen subscribed with addListener and never called removeListener in dispose, so the long-lived ChangeNotifier keeps every old State alive through its listener list; a closure that captured this drags in the whole subtree.
Lifetimes are asymmetric: the subject lives for the app, the observer for one route, and only the observer knows when it dies
**removeListener compares with ==**: an inline closure is a new object every time, so removing () => _sync() removes nothing; a method tear-off works
Release builds stay quiet: a retained, disposed State still gets its listener called: debug throws 'setState() called after dispose()', release strips that assert and the leak goes unnoticed
The fix: prefer ListenableBuilder, ValueListenableBuilder or context.watch, which subscribe and unsubscribe with the element
By hand: subscribe in initState, keep the reference in a field, undo it in dispose, and dispose only what you created
Both model an object that behaves differently in each of several modes. The State pattern puts the behaviour inside the state objects; Dart 3 sealed classes make states plain data and let the compiler force you to handle every one.
State pattern: the object delegates to a State field implementing an interface, and a state can swap itself for the next one, so transitions live inside the states
Sealed classes: a sealed parent with concrete subtypes; the surrounding code switches on which one is current, and a switch without default stops compiling when you add a case
Where the logic goes: with sealed classes the transitions live in the notifier, cubit or bloc, not in the state itself
Pick State when: each mode has rich behaviour of its own and there are many transitions between them
Pick sealed when: the state is mostly data driving the UI, which in Flutter app code is most of the time
Dispatch on the type field in exactly one factory, and make the unknown branch a real model instead of a throw: an app that crashes on a type it has never seen can only be fixed by shipping a release.
Closed set, one codebase: a sealed base class and a switch inside one fromJson; the compiler then points at every place to update when you add a case
Open set: a map from the type string to a parser function, filled explicitly in the composition root. Worth it only when separate modules contribute their own item types
Self-registration silently does nothing: Dart initializes statics lazily, so static final _registered = ... inside a class nobody touches never runs, and the type is simply missing at runtime. Register from a list you can read
Degrade, never throw: parse an unknown type into UnknownItem(type), log it once, skip the row
Catch per item: one malformed row should cost you that row, not the whole feed
**freezed writes it for you**: @Freezed(unionKey: 'type', fallbackUnion: 'unknown') gives the sealed switch and the safe default in one line
This is Chain of Responsibility: model each stage as a function that takes the request and the next stage, fold the list into one pipeline, and prefer it to ifs when order, per-environment sets or testing each stage matter.
Wrapping middleware: every stage runs code before and after the rest of the chain: logging, timing, auth headers, caching, error mapping. Dio interceptors are the same idea
First match wins: each handler either handles the request or passes it on; deep-link routing and notification payloads work this way
Better than a block of ifs when: the order matters and you want it visible as one list, the set differs per environment, or each stage must be testable with a stubbed next
The cost: control flow stops being obvious, so name every stage and log the chain in debug
Common mistake: a stage that forgets to call next silently drops everything after it; cover it with a test asserting the tail ran
Where retry sits: a retry stage calls next again, so it replays only the stages after it (logging every attempt, re-reading the token) and never the ones before it; put it before auth if each attempt needs a fresh token, after logging if the log should show one line per request
With three stages that never change, a plain function with three ifs is the better answer.
Put the implementations behind one abstract interface and choose between them with a conditional export. A kIsWeb check is only a runtime branch: it cannot remove an import, and a web-only import breaks the mobile compile.
The mechanism: one public library writes export 'src/create_store_stub.dart' if (dart.library.io) 'src/create_store_io.dart' if (dart.library.js_interop) 'src/create_store_web.dart';
The condition asks what exists: which library this compilation target has, not which platform you run on; the unconditional first path is the fallback and must compile on its own
Nothing checks that the branches match: same names, same signatures, or you get a build failure on the one platform you did not build
It cannot tell Android from iOS: dart.library.io is true for both, plus desktop and the test VM; that stays a runtime question
The two directions fail differently: dart:io compiles into a web build and throws Unsupported operation on first use; dart:js_interop and package:web do not compile on mobile at all, whatever guards the call
A runtime guard fixes neither: if (!kIsWeb) leaves the import in place: a shared file importing package:web still breaks the Android and iOS build, and one importing dart:io still throws as soon as code outside the guard touches it
Shape it as an Abstract Factory: one abstract interface class SecureStore and a top-level createSecureStore() in each platform file.
In Dart 3 the sealed switch is the default answer: it gives the same compiler-checked completeness as a Visitor, without accept/visit boilerplate or double dispatch. Visitor stays for open hierarchies you cannot seal.
The trade-off: a Visitor makes a new operation cheap (one new class) and a new node type expensive: visitX goes into the interface and every visitor must implement it. The compiler does flag that, so the real cost is the accept/visit boilerplate and the double dispatch
Sealed plus switch: a new operation is a plain function, and a new node type turns every switch without default into a compile error, the same safety with no ceremony
Patterns destructure as they dispatch: case Heading(:final level, :final text) reads as a table, and the nodes stay dumb data with no accept boilerplate
Visitor still earns its place: when the hierarchy is open to third parties who implement accept themselves, as the analyzer's AstNode and AstVisitor are, or when the traversal itself is the hard part: Element.visitChildren takes a callback because only the parent knows how to walk its children
The trap: subtypes of a sealed class must live in its own library (one file, or part files); anywhere else is a compile error. The silent failure is a default or _ branch on a switch over it: it swallows the next node type you add, so leave it out