How does the app read and write data?
Through one object: dw.repo. There are no repositories to write, no client calls to make, no
cache to keep in sync. A DartWay app declares a model, gives it a DwCrudConfig on the server, and
reads it in a widget.
The split inside dw.repo is deliberate and worth learning first:
- reads are Riverpod providers — you consume them with the native
ref.watch/ref.read/ref.refresh; - writes are plain methods —
saveModel,deleteModel, awaited like anyFuture.
Nothing in the API asks you for a ref, and no provider type name ever appears in app code: the
entire Riverpod surface an app touches is ref.watch(dw.repo.<x>(...)). The reads are kept in their
own methods because they are the only state-management-coupled part — they can move to a
dartway_riverpod package later without the imperative half following.
Reading
Three providers, one per shape of answer:
// AsyncValue<List<T>> — reactive list
ref.watch(dw.repo.modelList<ClubService>())
// AsyncValue<T?> — reactive single model, absence is a legal answer
ref.watch(dw.repo.maybeModel<SessionReview>(filter: reviewOfBooking(id)))
// AsyncValue<T> — reactive single model that must exist
ref.watch(dw.repo.model<ClubSession>(id: sessionId))
model and maybeModel take either id or filter — passing neither trips an assert in
DwSingleModelStateConfig. An id is sugar: it is turned into an equals filter on the id
field.
model vs maybeModel
They run the same fetch and receive the same live updates. The only difference is what happens when the row is not there:
maybeModelresolves tonull— the data branch of theAsyncValue;modelresolves to aStateError— the error branch, messagedw.repo.model<ClubSession>: model not found (...).
Pick by whether absence is a normal state of your screen. A profile screen opened by id: model,
because a missing profile is a bug you want reported. "Has this booking been reviewed yet?":
maybeModel, because null is the answer, not a failure.
null is an answer about now, not a promise that the row will never exist. A maybeModel that
resolved to null takes the first row that later arrives and passes its filter (or has its id) —
so "save, then read the provider" works for a model you just created, exactly as it does for a
list. Until #242 it did not: an empty single-model read stopped listening the moment it answered,
the screen stayed on a legitimate-looking empty state, and a if (model == null) return; behind it
skipped its work with nothing anywhere saying so.
One consequence catches people out. model is a derived provider — a throwing view over
maybeModel, not its own fetch. So ref.refresh(dw.repo.model<T>(...).future) only recomputes the
wrapper and returns the same cached value; to force a fresh fetch, refresh the provider that
actually fetches: ref.refresh(dw.repo.maybeModel<ClubSession>(id: id).future).
Which ref verb
ref.watch(provider) subscribes reactively; ref.read(provider.future) reads once inside a
callback or an action; ref.refresh(provider.future) discards and refetches. There is also
dwGlobalRefreshStateProvider — every read watches it, so
ref.read(dwGlobalRefreshStateProvider.notifier).refresh() rebuilds all of them at once. That is a
last resort, not an everyday tool.
The signed-in profile is not one of these reads
The current user is a special source, not a row you fetch by id. It arrives with the session and is
kept up to date by it, so it has its own pair of providers on dw — never
dw.repo.model<UserProfile>(...):
// UserProfile? — signed out is a legal answer: splash, router guards, the auth zone
ref.watch(dw.userProfileProvider)
// UserProfile — non-nullable, for anything drawn under DwUserAsyncScope
ref.watch(dw.requireUserProfileProvider)
ref.watch(dw.requireUserProfileProvider.select((p) => p.firstName))
The split is the same one as maybeModel vs model, for the same reason: require throws a
StateError when nobody is signed in, because on an authenticated screen that is a wiring mistake
and not a state to render.
Both are typed by the profile model you gave DwCore<Client, UserProfile>, so your own model comes
out of them without a provider of your own. dartway create still scaffolds
lib/core/user_profile_provider.dart on top — ref.watchUserProfile / ref.readUserProfile, two
getters over requireUserProfileProvider, because Dart has no generic getters and the framework
cannot name your model in an extension on Ref. They are shorthand, not the source: delete the file
and the providers still work.
When the id is all you need — a filter, a channel key, an ownership check — there is a third provider
beside them, and no reason to pull the whole profile out to reach .id:
// int? — null while signed out, and null in an app running without a DartWay session
ref.watch(dw.signedInUserIdProvider)
Rendering a list
An AsyncValue has three branches, and writing when(loading:, error:, data:) in every feature is
how a codebase ends up with twelve different spinners. dwBuildListAsync renders all three:
ref
.watch(dw.repo.modelList<ChatMessage>(
backendFilter: AppBackendFilters.channelMessages(channel.id!),
))
.dwBuildListAsync(
loadingItemsCount: 5,
childBuilder: (messages) => ListView.builder(...),
);
The loading branch is not a shimmer rectangle. It calls your own childBuilder with
loadingItemsCount placeholder models and wraps the result in a Skeletonizer — so the skeleton
has the shape of the content that is coming. dwBuildAsync (the single-value form) does the same
with one placeholder, and switches to SliverSkeletonizer when your builder returned a sliver,
because a box skeleton is an invalid child of a CustomScrollView.
The error branch is the caller's decision
Errors go into the framework error pipeline (DwErrorSource.asyncBuild) and are replaced on screen
by errorWidget, which defaults to SizedBox.shrink(). A failing list is therefore silent on
screen and loud in your alerts.
That default stays, and it is not an oversight: the builders are extensions on AsyncValue, which
knows it holds a List<ChatMessage> and nothing about whether those messages are the screen or a
badge in its corner. A section that is the reason its screen exists renders its failure; a
decoration need not. Only the caller can tell which one it is looking at, so the caller passes
errorBuilder — and blank is not a neutral choice, because an empty list already has copy of its
own ("No news yet") and empty space reads as a third thing again.
The failure state also needs a way out of it, and that is the one place ref.invalidate is right:
the user asked for the state to be thrown away and fetched again.
// the provider is named once and used twice — watched, and thrown away by the retry
final chatMessages = dw.repo.modelList<ChatMessage>(
backendFilter: AppBackendFilters.channelMessages(channel.id!),
);
ref
.watch(chatMessages)
.dwBuildListAsync(
loadingItemsCount: 5,
childBuilder: (messages) => ListView.builder(...),
errorBuilder: (_, _) => LoadFailedMessage(
onRetry: dw.action((_) => ref.invalidate(chatMessages)),
),
);
LoadFailedMessage is the app's widget, not the framework's: the copy a person reads comes from
context.l10n, so a package cannot own it. example/ and template/ each carry one in
lib/shared/widgets/load_failed_message.dart — a message and a retry button — and it takes the
retry as a DwUiAction, because only the caller knows which read failed.
Combining several AsyncValues has one trap and it is worth naming. asData?.value answers
null while loading and on failure, so the shortest thing that compiles —
final profile = ref.watch(profileProvider).asData?.value;
if (profile == null) return const Spinner(); // a 500 is now a spinner that never stops
— shows a wait for a request that answered long ago. .value ?? const [] is the same trap wearing a
fallback. Nest the builders instead, so each read answers for its own failure.
Placeholder models must be registered
The placeholder comes from a per-type registry the app fills once, at startup:
dw.repo.setupRepository(
defaultModel: ClubSession(
id: dw.repo.mockModelId,
serviceId: dw.repo.mockModelId,
startsAt: DateTime.now(),
capacity: 10,
),
);
This is the one place an app builds a model field by field with an id in hand, and it is not a
rebuild: mockModelId is a sentinel, the instance is invented from nothing, and there is no stored
row to copy from. model_rebuild_by_constructor reads the sentinel and stays silent, so
core/default_models.dart needs no // ignore_for_file: — see
models.
Skip it for a model and the failure is immediate and total, because this registration is not only
about skeletons: setupRepository also maps the Dart type to the class name the CRUD endpoints
speak. So:
- the read itself fails with
Exception: Dw Repository was not initialized for type X, thrown by the provider before the request is even built; - the loading branch throws
UnimplementedError: Default Objects Repository doesn't contain a model of type Xwhile building the skeleton — duringbuild, so it is a red screen, not an error state.
dwBuildListAsync asserts that a placeholder is obtainable, but the assert only checks that
DwConfig.defaultModelGetter is wired at all (the app passes dwGetDefault), not that your
particular model is registered. A new model means a new setupRepository call, always.
Both the assert and the placeholder itself belong to the loading branch and run nowhere else — a
widget test that hands the builder a ready AsyncData never touches the registry, and so does not
have to stand it up. A list test that does fail on a missing placeholder is a test of the loading
state, whether or not it was written as one.
Narrowing the list: backendFilter
backendFilter becomes part of the query the server runs. Filters are declared once, as an enum
carrying the field name and the value type:
enum AppBackendFilters<T> with DwBackendFiltersMixin<T> {
clientProfileId<int>(),
startsAt<DateTime>();
static DwBackendFilter clientBookings(int userProfileId) =>
AppBackendFilters.clientProfileId.equals(userProfileId);
static DwBackendFilter upcomingSessions() =>
AppBackendFilters.startsAt.greaterThan(DateTime.now().dayStart);
}
The mixin gives equals, greaterThan(OrEquals), lessThan(OrEquals), like, ilike, each with
a negate flag. The comparison operators throw UnsupportedError at runtime for any T other than
int, double or DateTime — the type parameter on the enum value is what makes that a mistake
you make once.
The filter also applies to live updates: a model arriving over the socket is only inserted into
a list whose backendFilter accepts it. Narrowing is a property of the list, not of one fetch.
A backendFilter is not security. It is what this screen wants to see. What a user is
allowed to see is the accessFilter on the server's CRUD config, and it applies whether the
client narrows or not.
Filtering locally
The framework deliberately gives you nothing for this. Filtering an already-loaded list is a
.where (or a collection-if) inside your childBuilder, and a wrapper around .where would only
be a second thing to learn. Keep the search string in an ordinary provider and read it with
ref.watch. The moment you find yourself hunting for "the DartWay way" here, you have found a place
where there is none, on purpose.
Providers live at the root
Your app writes no ProviderScope. DwAppRunner creates the only one, around everything; tests
build their own and are welcome to.
The temptation it forbids is a nested scope that overrides a provider for one subtree — a screen
showing the same widgets "as an admin", a route fixing a mode, a panel with its own copy of some
state. It reads as a clean way to inject a value without threading it through the tree, and for
widgets it genuinely works: a WidgetRef resolves from the nearest scope above its widget and sees
the override.
A provider does not. Reading through its own Ref, it resolves from the container hosting it —
the root, for anything that declares no dependencies — and gets the base value. Nothing throws,
nothing warns, and the screen shows something other than what you overrode. The failure appears
later, when someone adds a provider that reads a value two other widgets were reading happily.
So a value that differs per subtree is passed, not scoped: a family key, or a constructor argument to a notifier.
// ❌ the override is invisible to any provider that reads workspaceModeProvider
ProviderScope(
overrides: [workspaceModeProvider.overrideWith(() => IssuesMode())],
child: const WorkspacePage(),
)
// ✅ the mode is an argument — visible in the call, and the same value for everyone
final workspaceStateProvider =
NotifierProvider.family<WorkspaceState, WorkspaceData, WorkspaceMode>(
WorkspaceState.new,
);
dartway_lints enforces this as forbidden_provider_scope. Riverpod ships a rule for the same trap
(scoped_providers_should_specify_dependencies) which cannot help you here: it only reasons about
providers written with code generation, and DartWay writes them by hand.
State that is not server data
"How this list is sorted", "is this panel collapsed", "which tab was open" is state too, and it never
goes near dw.repo. Two questions place it, in this order.
Does it survive a restart? No — an ordinary Notifier, in memory like anything else. Yes — the
dartway_shared_preferences plugin, dw.plugins.prefs. It hands back a riverpod
provider, so persisting a value costs no reactivity: the same ref.watch, and every reader on the
screen sees one value.
Does it belong to an entity? No, it is one setting for the whole app — a constant key:
final darkModeProvider =
dw.plugins.prefs.provider<bool>(key: 'darkMode', defaultValue: false);
Yes, there is one per project, per chat, per section — the family form, where keyFor builds the key
from the argument:
final projectSortProvider = dw.plugins.prefs.providerFamily<String, int>(
keyFor: (projectId) => 'project.$projectId.sort',
defaultValue: 'name',
);
final sort = ref.watch(projectSortProvider(project.id));
ref.read(projectSortProvider(project.id).notifier).update('createdAt');
The family is what makes this safe, not just short. provider takes a constant key and must be
declared once, like any riverpod provider: call it per id and each call builds a new provider, so
two of them over one key never see each other's writes. A family is declared once and riverpod keeps
one provider per argument value, which is exactly the guarantee the loop cannot give.
mappedProvider/mappedProviderFamily are the same pair for enums and custom types, stored as a
String. dw.plugins.prefs.raw is the underlying store, for a genuine one-off imperative read — a
store class of your own built on it has no subscribers, and the state ends up trapped in one widget's
State.
Writing
await dw.repo.saveModel(booking.copyWith(status: BookingStatus.cancelled));
await dw.repo.deleteModel(post);
saveModel is create and update in one call — a model with no id is inserted, one with an id is
updated, and the server's saveConfig is the single place both are configured. It returns the
persisted model, so post-processing (computed fields, timestamps) comes back to you. deleteModel
returns true when nothing is left on the server; a model that was never persisted returns true
without a round trip.
The copyWith above is not a style choice. Rebuilding an existing row by naming its fields —
SessionBooking(id: booking.id, …) — is how a field added later gets silently reset to its default
on every save, and model_rebuild_by_constructor (dartway_lints) warns about it. The reasoning,
and why clearing a nullable field is copyWith's job too, is in
models.
Both dispatch the server's updatedModels into every open watcher of that type, which is why a
booking cancelled from a card updates the list behind it with no refresh code anywhere. That
reactivity covers your own writes. Another user's write reaching your screen is a server-side
decision — see realtime.
When the network is gone
Everything above assumes a connection: dw.repo is network-only, a read that cannot reach the
backend fails, and so does a write. An app that needs otherwise declares a local store on the
core, and then a read may be served from the device and a write may be queued and replayed.
Feature code does not change — the same ref.watch(dw.repo.modelList<X>()), the same
dw.repo.saveModel(...). What changes is one line of bootstrap and, per query, one flag:
ref.watch(
dw.repo.modelList<Lesson>(
customConfig: DwModelListStateConfig<Lesson>(
readStrategy: DwRepoReadStrategy.networkFirstWithSnapshot,
),
),
);
The default stays DwRepoReadStrategy.networkOnly, so declaring a store does not quietly turn the
app into a cache. Writes work the other way round — which ones are queued is decided inside the
store, not at the call site. See offline.
Pagination
By default a list is DwNoPagination: one request, everything. For long lists pass a strategy
through customConfig:
dw.repo.modelList<ChatMessage>(
customConfig: DwModelListStateConfig<ChatMessage>(
backendFilter: AppBackendFilters.channelMessages(channelId),
paginationStrategy: DwCursorPagination(limit: 30),
),
)
DwOffsetPagination(pageSize) walks by offset; DwCursorPagination(limit:) walks backwards by id,
which is what a chat wants — an offset shifts under you when rows are inserted while you scroll.
InfiniteListView(listViewConfig: config, listTileBuilder: ...) drives loadNextPage from the
scroll position and renders the same skeletons.
DwModelListStateConfig is also the Riverpod family key, so it defines ==. Two watches with an
equal config share one state; a config rebuilt with a fresh closure (customUpdatesListener,
updatesSortingMethod) does not, and silently gets its own. Keep such configs out of build.
Testing a feature that reads and writes for itself
A feature built the way this page describes hands nothing out: it watches its own provider and calls
dw.repo.saveModel in its own action. There is no callback for a test to assert on, and you should
not keep one alive as a test seam — that buys a weaker screen for a weaker test.
The seam is one level down, and it is the same one the framework uses on itself: the transport the
core sends everything through. A test hands DwCore its own instead of a Serverpod client:
import 'package:dartway_serverpod_core_flutter/testing.dart';
// Your own generated Protocol — the same object the real client carries, and
// the only thing that names your models correctly. A generated model is an
// abstract class with a private implementation, so its `runtimeType` reads
// `_LessonImpl`. Import it prefixed: the DartWay core declares a `Protocol` too.
final transport = DwRecordingServerTransport(
serializationManager: app.Protocol(),
);
// Booted through the app's own initializer — the real bootstrap with one thing
// swapped — so a test never stands a second, subtly different core beside the
// one the app ships. Give the initializer an optional `transport` and build no
// Serverpod client when it is set: a client brings a connectivity monitor and an
// auth key manager, both of which reach for platform channels.
setUpAll(() => initAppDwCore(transport: transport));
testWidgets('the button saves the lesson', (tester) async {
transport.answerGetAll = (_) async => lessonsResponse([draft]);
await tester.pumpWidget(const App());
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.check));
await tester.pumpAndSettle();
expect(transport.saves, hasLength(1));
expect((transport.saves.single.model as Lesson).isPublished, isTrue);
});
The worked version of all of this is
example/dartway_example_flutter/test/ —
the news feature, its read states and its write, with the shared harness in test/support/.
Reads must be prepared; writes need not be. A read nobody answered throws DwUnpreparedServerCall
naming the call and the field that would answer it — a test that does not know what its subject
fetches is the thing being caught. A save answers by echoing the model back, because the assertion
is about what left; set answerSave when the response itself matters, such as the id assigned on
insert.
Two things to know before writing the test at all:
- The core has to be up for the widget to render, not only for the tap.
dw.action(...)is constructed inbuild, so a feature reachesdwwhile building — without a core the subtree does not build and the test fails later at a finder ("found 0 widgets"), with the real cause in a separate exception block above. Boot it fromsetUpAllthrough the app's own initializer, and keep that initializer idempotent so no test file has to know whether another one got there first. - The offline store is not this seam, and is documented not to be. A write always leaves by the
transport first;
dw.repo.localWritesis reached only after the connection refuses it. Reaching for it to watch a save would force every save to declare itself queued, which is a lie about intent.
And two things that surprise every test that meets them, both of which are about time rather than about the transport:
pumpAndSettledoes not drain a notification. A successfuldw.action(onSuccessNotification: ...)inserts a toast that removes itself on aFuture.delayed, and settling waits for frames, not for timers. The test then fails on "A Timer is still pending even after the widget tree was disposed" — an error about the toast, in a test about a save. PumpDwUiNotification.defaultDurationafter the tap, once, in a shared helper.- A failed read is retried. Riverpod retries a failed provider on its own with a growing
backoff, so a read you made fail is attempted many times while the test settles. Assert the shape
of what was asked (
transport.reads.map((r) => r.operation)), not the count — a count pins somebody else's default.
What this covers is "the button reached saveModel with this model". What covers the rule —
who may save what — is an integration test over the CRUD config on the server, where the rule lives.
Escape hatches
Reach for these only when a provider genuinely does not fit — an imperative flow that owns its own state, or a bespoke endpoint of your own:
dw.repo.fetchList<T>(filter:, orderByList:, limit:, offset:)— one-shotList<T>;dw.repo.count<T>(filter:)— server-side count;dw.repo.processApiResponse(response)— unwraps aDwApiResponsefrom a custom endpoint and dispatches itsupdatedModelsso open watchers stay in sync;dw.repo.addUpdatesListener<T>/removeUpdatesListener<T>— raw live updates of a type.