Do NOT use for: pure UI/state work with no network (that is add-feature), or l10n-only changes.
Every response is wrapped. ApiClient (lib/core/network/api_client.dart) already parses it — you never touch raw Dio or the envelope keys.
-
Path constant. On the first API PR, create lib/core/network/api_endpoints.dart (it does not exist yet). Use static const for fixed paths and a small helper method for {id} routes. Base URL comes from AppConfig.baseUrl; put only the path here.
class ApiEndpoints {
static const centersNearby = '/centers/nearby';
static String center(String id) => '/centers/$id';
}
-
Model (hand-written). Plain class X extends Equatable with factory X.fromJson(Map<String, dynamic>), toJson(), and props. No @freezed, no @JsonSerializable, no part '*.g.dart'. Backend keys are snake_case — map them explicitly. Pattern: lib/features/auth/data/models/auth_token_model.dart.
class CenterModel extends Equatable {
const CenterModel({required this.id, required this.name});
factory CenterModel.fromJson(Map<String, dynamic> json) => CenterModel(
id: json['id'] as String,
name: json['name'] as String,
);
final String id;
final String name;
Map<String, dynamic> toJson() => {'id': id, 'name': name};
@override
List<Object?> get props => [id, name];
}
-
Data source — call ApiClient with a parse: callback. The real signature is get/post/put/delete<T>(String path, {required T Function(Object? data) parse, Map<String,dynamic>? query, Object? body, bool authenticated = true}). parse turns data into your typed value; the method returns ApiResult<T> (read .data, and .meta for pagination). Pass authenticated: false for login / public routes.
class DiscoveryRemoteDataSource {
DiscoveryRemoteDataSource(this._client);
final ApiClient _client;
Future<List<CenterModel>> nearby({required double lat, required double lng}) async {
final res = await _client.get<List<CenterModel>>(
ApiEndpoints.centersNearby,
query: {'latitude': lat, 'longitude': lng},
parse: (data) => (data as List)
.map((e) => CenterModel.fromJson(e as Map<String, dynamic>))
.toList(),
);
return res.data; // res.meta is a PaginationMeta? when the route is paginated
}
}
-
Repository — catch ApiException, throw a Failure. ApiClient throws typed ApiExceptions; the domain layer speaks Failure (lib/core/error/failure.dart: NetworkFailure / ServerFailure / UnknownFailure, all sealed). Map each subtype by hand and throw — there is no .toFailure() helper, no Either, no Result<T>, no .fold(). Success returns the value.
class DiscoveryRepository {
DiscoveryRepository(this._ds);
final DiscoveryRemoteDataSource _ds;
Future<List<CenterModel>> nearby({required double lat, required double lng}) async {
try {
return await _ds.nearby(lat: lat, lng: lng);
} on NetworkException {
throw const NetworkFailure();
} on ServerException catch (e) {
throw ServerFailure(e.message);
} on ApiException catch (e) {
// ValidationException/Forbidden/NotFound/RateLimit/Unauthenticated
throw UnknownFailure(e.message);
}
}
}
For a form endpoint, let ValidationException reach the bloc instead (rethrow it) so it can read .fieldErrors — map only the rest to Failure.
-
Use case. A thin callable that holds the repo and exposes one method. No logic beyond delegating (add validation/composition here if the endpoint needs it).
class GetNearbyCenters {
GetNearbyCenters(this._repo);
final DiscoveryRepository _repo;
Future<List<CenterModel>> call({required double lat, required double lng}) =>
_repo.nearby(lat: lat, lng: lng);
}
-
Bloc — plain try/catch (Failure). Call the use case inside a try, emit success or error. Catch Failure (and ValidationException first if this is a form, to surface .fieldErrors).
try {
final centers = await _getNearbyCenters(lat: lat, lng: lng);
emit(DiscoveryLoaded(centers));
} on Failure catch (e) {
emit(DiscoveryError(e.message));
}
-
Register in get_it — by hand. Add one registerLazySingleton line per collaborator in configureDependencies() (lib/core/di/injection.dart). Blocs use registerFactory. No injectable, no injection.config.dart, no build_runner.
getIt
..registerLazySingleton<DiscoveryRemoteDataSource>(() => DiscoveryRemoteDataSource(getIt()))
..registerLazySingleton<DiscoveryRepository>(() => DiscoveryRepository(getIt()))
..registerLazySingleton<GetNearbyCenters>(() => GetNearbyCenters(getIt()))
..registerFactory<DiscoveryBloc>(() => DiscoveryBloc(getIt()));
-
Verify. No codegen step. If you touched ARB files run flutter gen-l10n; then dart format ., flutter analyze, flutter test. Add a data-source or repository test that maps a mocked envelope (see test/core/network/api_client_test.dart and test/core/network/fakes.dart for the http_mock_adapter pattern).