choose-data-stack
Skill0 starsChanged 49 days agoMITView on GitHub
Adds the first storage or network package to packages/data (Drift, Hive/Isar, Dio, or Firebase/Supabase) together with a FailureMapper, a guard-wrapped provider skeleton and a test harness; run this once, before writing the first data provider, when packages/data/pubspec.yaml has no persistence/network dependency yet.
In this repository35 other files it gives its agents.
- AGENTS.mdAGENTS.md
- bootstrap-projectSkill
- code-reviewSkill
- conventional-commitsSkill
- data-providerSkill
- domain-modelingSkill
- error-handlingSkill
- flavors-and-flagsSkill
- flutter-fvmSkill
- localizationSkill
- new-featureSkill
- quality-gateSkill
- routingSkill
- state-managementSkill
- testingSkill
- themingSkill
- ui-screenSkill
- bootstrap-projectSkill
- choose-data-stackSkill
- code-reviewSkill
- conventional-commitsSkill
- data-providerSkill
- domain-modelingSkill
- error-handlingSkill
- flavors-and-flagsSkill
- flutter-fvmSkill
- flutter_fvmSkill
- localizationSkill
- new-featureSkill
- quality-gateSkill
- routingSkill
- state-managementSkill
- testingSkill
- themingSkill
- ui-screenSkill
The file
We split this skill into the rules it gives an agent
6 rules. About 0 are common, 1 is distinctive, and 5 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 16
Ask the user which stack fits the app, or infer it from context (an offline-first app with relational data → Drift.
- Very specific: line 23
Every subsection below follows the same shape: (a) the pubspec dependencies to add, (b) a FailureMapper translating that package's real …
- Very specific: line 32
After adding a stack, run fvm flutter pub get from the repo root, then proceed with .agents/skills/data-provider/SKILL.md and …
- Very specific: line 169
Every FK column and every column used in a WHERE/ORDER BY needs a Drift index — see .agents/skills/data-provider/SKILL.md for the indexing …
- Very specific: line 288
Keep the NoteIsarModel (the @collection annotated class) plus its toEntity()/fromEntity() conversions in lib/src/data_sources/, private to …
- Very specific: line 619
If the stack needs code generation (Drift, Isar): fvm dart run build_runner build --delete-conflicting-outputs from packages/data/.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Choose a data stack
packages/data ships with zero storage/network dependencies — only
domain and uuid (see packages/data/pubspec.yaml). AGENTS.md section 7
and packages/data/AGENTS.md both require this skill to run before the
first data-provider implementation is written. lib/src/data_providers/,
lib/src/data_sources/ and lib/src/models/ in packages/data exist and
are ready to receive files but are currently empty.
1. Pick a stack
Ask the user which stack fits the app, or infer it from context (an offline-first app with relational data → Drift; a simple key-value cache → Hive/Isar; a thin REST client → Dio; an app that wants managed auth/sync → Firebase/Supabase). Multiple stacks can coexist later (e.g. Drift for local cache + Dio for sync) — add each one's dependencies only when a provider actually needs it.
Every subsection below follows the same shape: (a) the pubspec dependencies
to add, (b) a FailureMapper translating that package's real exception types
into the five DomainFailure subtypes (packages/domain/lib/src/failures/domain_failure.dart:
UnexpectedFailure, NotFoundFailure, ValidationFailure,
ConnectivityFailure, UnauthorizedFailure), (c) a provider skeleton
wrapping guard/guardStream, (d) a test-harness pattern. Every example
uses a generic Note entity (id, title, body, updatedAt) — swap it
for your real entity, don't copy the shape verbatim.
After adding a stack, run fvm flutter pub get from the repo root, then
proceed with .agents/skills/data-provider/SKILL.md and
.agents/skills/new-feature/SKILL.md for the full generator.
Option A — Drift (SQLite, offline-first, relational)
a. Dependencies
packages/data/pubspec.yaml:
dependencies:
domain:
path: ../domain
drift: ^2.24.0
path_provider: ^2.1.5
sqlite3_flutter_libs: ^0.5.28
uuid: ^4.5.1
dev_dependencies:
build_runner: ^2.4.15
drift_dev: ^2.24.0
flutter_test:
sdk: flutter
very_good_analysis: ^10.0.0Also add flutter_test's transitive Flutter dependency by keeping this
package's resolution: workspace — Drift's generated code needs
dart:ffi/platform bindings that only resolve inside the Flutter SDK
constraint already declared in environment: sdk:.
b. FailureMapper
// packages/data/lib/src/data_sources/drift_failure_mapper.dart
import 'package:domain/domain.dart';
import 'package:drift/drift.dart';
DomainFailure mapDriftError(Object error) => switch (error) {
SqliteException(extendedResultCode: final code) when code == 787 =>
// SQLITE_CONSTRAINT_FOREIGNKEY — a referenced row doesn't exist.
ValidationFailure(error),
SqliteException(extendedResultCode: final code) when code == 2067 =>
// SQLITE_CONSTRAINT_UNIQUE.
ValidationFailure(error),
InvalidDataException() => ValidationFailure(error),
_ => UnexpectedFailure(error),
};Drift has no built-in "not found" exception — a missing row is a null
return from getSingleOrNull(), which the provider maps to a NotFoundFailure
explicitly rather than through the mapper (see the skeleton below).
c. Provider skeleton
// packages/data/lib/src/data_sources/app_database.dart
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
part 'app_database.g.dart';
class Notes extends Table {
TextColumn get id => text()();
TextColumn get title => text()();
TextColumn get body => text()();
DateTimeColumn get updatedAt => dateTime()();
@override
Set<Column<Object>> get primaryKey => {id};
}
@DriftDatabase(tables: [Notes])
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 1;
static LazyDatabase _openConnection() => LazyDatabase(() async {
final dir = await getApplicationDocumentsDirectory();
return NativeDatabase.createInBackground(File(p.join(dir.path, 'app.sqlite')));
});
}// packages/data/lib/src/data_providers/drift_note_data_provider.dart
import 'package:domain/domain.dart';
import '../data_sources/app_database.dart';
import '../data_sources/drift_failure_mapper.dart';
import '../guards/failure_guard.dart';
final class DriftNoteDataProvider implements NoteDataProvider {
DriftNoteDataProvider(this._db);
final AppDatabase _db;
@override
Future<Note?> getNoteById(String id) => guard(() async {
final row = await (_db.select(_db.notes)..where((t) => t.id.equals(id))).getSingleOrNull();
return row == null ? null : _mapToEntity(row);
}, mapper: mapDriftError);
@override
Stream<List<Note>> watchAllNotes() =>
guardStream(() => _db.select(_db.notes).watch().map((rows) => rows.map(_mapToEntity).toList()),
mapper: mapDriftError);
@override
Future<void> saveNote(Note note) => guard(
() => _db.into(_db.notes).insertOnConflictUpdate(_mapFromEntity(note)),
mapper: mapDriftError,
);
@override
Future<void> deleteNote(String id) =>
guard(() => (_db.delete(_db.notes)..where((t) => t.id.equals(id))).go(), mapper: mapDriftError);
Note _mapToEntity(NotesData row) =>
Note(id: row.id, title: row.title, body: row.body, updatedAt: row.updatedAt);
NotesCompanion _mapFromEntity(Note note) => NotesCompanion.insert(
id: note.id,
title: note.title,
body: note.body,
updatedAt: note.updatedAt,
);
}Every FK column and every column used in a WHERE/ORDER BY needs a Drift
index — see .agents/skills/data-provider/SKILL.md for the indexing pattern
and the .watch()-joins-not-asyncMap() rule.
d. Test harness
// packages/data/test/data_providers/drift_note_data_provider_test.dart
import 'package:data/data.dart';
import 'package:domain/domain.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
late AppDatabase db;
late DriftNoteDataProvider provider;
setUp(() {
db = AppDatabase(NativeDatabase.memory());
provider = DriftNoteDataProvider(db);
});
tearDown(() => db.close());
test('saveNote then getNoteById round-trips the entity', () async {
final note = Note(id: '1', title: 'Title', body: 'Body', updatedAt: DateTime(2026));
await provider.saveNote(note);
expect(await provider.getNoteById('1'), note);
});
}NativeDatabase.memory() gives every test an isolated, ephemeral database —
prefer this over mocking Drift (packages/data/AGENTS.md → "Testing").
Option B — Hive or Isar (NoSQL local storage)
a. Dependencies (Isar shown; Hive is analogous with hive/hive_flutter)
dependencies:
domain:
path: ../domain
isar: ^3.1.8
isar_flutter_libs: ^3.1.8
path_provider: ^2.1.5
uuid: ^4.5.1
dev_dependencies:
build_runner: ^2.4.15
flutter_test:
sdk: flutter
isar_generator: ^3.1.8
very_good_analysis: ^10.0.0b. FailureMapper
// packages/data/lib/src/data_sources/isar_failure_mapper.dart
import 'package:domain/domain.dart';
import 'package:isar/isar.dart';
DomainFailure mapIsarError(Object error) => switch (error) {
IsarError(message: final message) when message.contains('unique') => ValidationFailure(error),
IsarNotReadyError() => UnexpectedFailure(error),
_ => UnexpectedFailure(error),
};Isar/Hive, like Drift, represent "not found" as a null read rather than a
thrown exception — map it explicitly in the provider, not the mapper.
c. Provider skeleton
// packages/data/lib/src/data_providers/isar_note_data_provider.dart
import 'package:domain/domain.dart';
import 'package:isar/isar.dart';
import '../data_sources/isar_failure_mapper.dart';
import '../data_sources/note_isar_model.dart';
import '../guards/failure_guard.dart';
final class IsarNoteDataProvider implements NoteDataProvider {
IsarNoteDataProvider(this._isar);
final Isar _isar;
@override
Future<Note?> getNoteById(String id) => guard(() async {
final row = await _isar.noteIsarModels.filter().idEqualTo(id).findFirst();
return row?.toEntity();
}, mapper: mapIsarError);
@override
Stream<List<Note>> watchAllNotes() => guardStream(
() => _isar.noteIsarModels.where().watch(fireImmediately: true).map(
(rows) => rows.map((row) => row.toEntity()).toList(),
),
mapper: mapIsarError,
);
@override
Future<void> saveNote(Note note) =>
guard(() => _isar.writeTxn(() => _isar.noteIsarModels.put(NoteIsarModel.fromEntity(note))),
mapper: mapIsarError);
@override
Future<void> deleteNote(String id) => guard(
() => _isar.writeTxn(() => _isar.noteIsarModels.filter().idEqualTo(id).deleteFirst()),
mapper: mapIsarError,
);
}Keep the NoteIsarModel (the @collection annotated class) plus its
toEntity()/fromEntity() conversions in lib/src/data_sources/, private
to this provider file's imports — not a shared cross-feature "mapper layer"
(packages/data/AGENTS.md → "Models/mappers").
d. Test harness
// packages/data/test/data_providers/isar_note_data_provider_test.dart
import 'package:data/data.dart';
import 'package:domain/domain.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:isar/isar.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
void main() {
late Isar isar;
late IsarNoteDataProvider provider;
setUp(() async {
isar = await Isar.open([NoteIsarModelSchema], directory: '', inspector: false);
provider = IsarNoteDataProvider(isar);
});
tearDown(() => isar.close(deleteFromDisk: true));
test('saveNote then getNoteById round-trips the entity', () async {
final note = Note(id: '1', title: 'Title', body: 'Body', updatedAt: DateTime(2026));
await provider.saveNote(note);
expect(await provider.getNoteById('1'), note);
});
}An empty directory: '' opens an in-memory-equivalent, disposable instance
per test — the same "real, ephemeral instance over mocking" preference as
Drift's NativeDatabase.memory().
Option C — Dio (REST)
a. Dependencies
dependencies:
dio: ^5.9.0
domain:
path: ../domain
uuid: ^4.5.1
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^1.0.5
very_good_analysis: ^10.0.0b. FailureMapper
// packages/data/lib/src/data_sources/dio_failure_mapper.dart
import 'package:dio/dio.dart';
import 'package:domain/domain.dart';
DomainFailure mapDioError(Object error) {
if (error is! DioException) return UnexpectedFailure(error);
return switch (error.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout ||
DioExceptionType.connectionError => ConnectivityFailure(error),
DioExceptionType.badResponse => switch (error.response?.statusCode) {
404 => NotFoundFailure(error),
401 || 403 => UnauthorizedFailure(error),
400 || 422 => ValidationFailure(error),
_ => UnexpectedFailure(error),
},
_ => UnexpectedFailure(error),
};
}c. Provider skeleton
// packages/data/lib/src/data_providers/dio_note_data_provider.dart
import 'package:dio/dio.dart';
import 'package:domain/domain.dart';
import '../data_sources/dio_failure_mapper.dart';
import '../guards/failure_guard.dart';
final class DioNoteDataProvider implements NoteDataProvider {
DioNoteDataProvider(this._client);
final Dio _client;
@override
Future<Note?> getNoteById(String id) => guard(() async {
try {
final response = await _client.get<Map<String, dynamic>>('/notes/$id');
return _mapToEntity(response.data!);
} on DioException catch (error) {
if (error.response?.statusCode == 404) return null;
rethrow;
}
}, mapper: mapDioError);
@override
Future<List<Note>> getAllNotes() => guard(() async {
final response = await _client.get<List<dynamic>>('/notes');
return response.data!.cast<Map<String, dynamic>>().map(_mapToEntity).toList();
}, mapper: mapDioError);
@override
Future<void> saveNote(Note note) =>
guard(() => _client.put<void>('/notes/${note.id}', data: _mapFromEntity(note)), mapper: mapDioError);
@override
Future<void> deleteNote(String id) =>
guard(() => _client.delete<void>('/notes/$id'), mapper: mapDioError);
Note _mapToEntity(Map<String, dynamic> json) => Note(
id: json['id'] as String,
title: json['title'] as String,
body: json['body'] as String,
updatedAt: DateTime.parse(json['updatedAt'] as String),
);
Map<String, dynamic> _mapFromEntity(Note note) => {
'id': note.id,
'title': note.title,
'body': note.body,
'updatedAt': note.updatedAt.toIso8601String(),
};
}A REST-backed provider has no native watchX() — a data-provider interface
backed by Dio typically exposes Future<List<Note>> getAllNotes() instead of
Stream<List<Note>> watchAllNotes(); don't force a stream shape onto a stack
that doesn't support one.
d. Test harness
// packages/data/test/data_providers/dio_note_data_provider_test.dart
import 'package:data/data.dart';
import 'package:dio/dio.dart';
import 'package:domain/domain.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockDio extends Mock implements Dio {}
void main() {
late MockDio client;
late DioNoteDataProvider provider;
setUp(() {
client = MockDio();
provider = DioNoteDataProvider(client);
});
test('getNoteById maps a 404 to null', () async {
when(() => client.get<Map<String, dynamic>>('/notes/1')).thenThrow(
DioException(
requestOptions: RequestOptions(path: '/notes/1'),
response: Response(requestOptions: RequestOptions(path: '/notes/1'), statusCode: 404),
type: DioExceptionType.badResponse,
),
);
expect(await provider.getNoteById('1'), isNull);
});
test('getNoteById wraps a connection error as ConnectivityFailure', () async {
when(() => client.get<Map<String, dynamic>>('/notes/1')).thenThrow(
DioException(requestOptions: RequestOptions(path: '/notes/1'), type: DioExceptionType.connectionError),
);
await expectLater(provider.getNoteById('1'), throwsA(isA<ConnectivityFailure>()));
});
}mocktail is a dev_dependency only — never add it to dependencies
(AGENTS.md section 12).
Option D — Firebase / Supabase (BaaS)
a. Dependencies (Firestore shown; Supabase follows the same shape with
supabase_flutter)
dependencies:
cloud_firestore: ^5.6.0
domain:
path: ../domain
firebase_core: ^3.10.0
uuid: ^4.5.1
dev_dependencies:
fake_cloud_firestore: ^3.1.0
flutter_test:
sdk: flutter
very_good_analysis: ^10.0.0b. FailureMapper
// packages/data/lib/src/data_sources/firestore_failure_mapper.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:domain/domain.dart';
DomainFailure mapFirestoreError(Object error) {
if (error is! FirebaseException) return UnexpectedFailure(error);
return switch (error.code) {
'not-found' => NotFoundFailure(error),
'permission-denied' || 'unauthenticated' => UnauthorizedFailure(error),
'invalid-argument' || 'already-exists' => ValidationFailure(error),
'unavailable' || 'deadline-exceeded' => ConnectivityFailure(error),
_ => UnexpectedFailure(error),
};
}c. Provider skeleton
// packages/data/lib/src/data_providers/firestore_note_data_provider.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:domain/domain.dart';
import '../data_sources/firestore_failure_mapper.dart';
import '../guards/failure_guard.dart';
final class FirestoreNoteDataProvider implements NoteDataProvider {
FirestoreNoteDataProvider(this._firestore);
final FirebaseFirestore _firestore;
CollectionReference<Map<String, dynamic>> get _notes => _firestore.collection('notes');
@override
Future<Note?> getNoteById(String id) => guard(() async {
final doc = await _notes.doc(id).get();
return doc.exists ? _mapToEntity(doc) : null;
}, mapper: mapFirestoreError);
@override
Stream<List<Note>> watchAllNotes() => guardStream(
() => _notes.orderBy('updatedAt', descending: true).snapshots().map(
(snapshot) => snapshot.docs.map(_mapToEntity).toList(),
),
mapper: mapFirestoreError,
);
@override
Future<void> saveNote(Note note) =>
guard(() => _notes.doc(note.id).set(_mapFromEntity(note)), mapper: mapFirestoreError);
@override
Future<void> deleteNote(String id) => guard(() => _notes.doc(id).delete(), mapper: mapFirestoreError);
Note _mapToEntity(DocumentSnapshot<Map<String, dynamic>> doc) {
final data = doc.data()!;
return Note(
id: doc.id,
title: data['title'] as String,
body: data['body'] as String,
updatedAt: (data['updatedAt'] as Timestamp).toDate(),
);
}
Map<String, dynamic> _mapFromEntity(Note note) => {
'title': note.title,
'body': note.body,
'updatedAt': Timestamp.fromDate(note.updatedAt),
};
}d. Test harness
// packages/data/test/data_providers/firestore_note_data_provider_test.dart
import 'package:data/data.dart';
import 'package:domain/domain.dart';
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
late FakeFirebaseFirestore firestore;
late FirestoreNoteDataProvider provider;
setUp(() {
firestore = FakeFirebaseFirestore();
provider = FirestoreNoteDataProvider(firestore);
});
test('saveNote then getNoteById round-trips the entity', () async {
final note = Note(id: '1', title: 'Title', body: 'Body', updatedAt: DateTime(2026));
await provider.saveNote(note);
expect(await provider.getNoteById('1'), note);
});
}fake_cloud_firestore gives an in-memory Firestore instance with the real
query semantics — prefer it over mocking FirebaseFirestore piece by piece.
Supabase's equivalent is a supabase_flutter client pointed at a disposable
local/test project, since it has no first-party in-memory fake; fall back to
mocktail around its PostgrestClient if a local Supabase instance isn't
available in CI.
After adding a stack
fvm flutter pub getfrom the repo root.- If the stack needs code generation (Drift, Isar):
fvm dart run build_runner build --delete-conflicting-outputsfrompackages/data/. - Continue with
.agents/skills/data-provider/SKILL.mdfor the interface/implementation pair, and.agents/skills/new-feature/SKILL.mdfor the full entity-to-screen sequence. fvm dart run tool/quality_gate.dartfrom the repo root before considering the change done.
Common mistakes
- Adding a mocking package (
mocktail) or afake_*test double package todependenciesinstead ofdev_dependencies— forbidden by AGENTS.md section 12 regardless of layer. - Letting a package-specific exception type (
DioException,FirebaseException,SqliteException,IsarError) escape a provider method uncaught — every method body must route throughguard/guardStream. - Choosing
UnexpectedFailurefor every branch of aFailureMapper"to be safe" — defeats the purpose; map the cases the UI actually needs to react to differently (offline banner forConnectivityFailure, sign-in redirect forUnauthorizedFailure, ...). - Writing the DTO/model class directly inside the provider file's public API
instead of keeping
_mapToEntity/_mapFromEntityprivate — a shared "mapper layer" across providers is explicitly against convention (see.agents/skills/data-provider/SKILL.md). - Forgetting
build_runnerfor a codegen-based stack (Drift, Isar) and wondering why the generated.g.dartfile is missing.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

