Architecture diagrams are easy to agree with and difficult to apply. The useful question is what each layer does when one feature loads real API data.
This example follows a “today's lessons” screen:
UI → BLoC → Use case → Repository → Service → API
API → Model → Entity → State → UI
Each boundary should remove a concern from the next layer.
Service: make the request and parse models#
class LessonsService {
LessonsService(this._dio);
final Dio _dio;
Future<List<LessonModel>> getTodaysLessons() async {
final response = await _dio.get<Map<String, dynamic>>(
'/lessons/today',
);
final items = response.data!['items'] as List<dynamic>;
return items
.map((item) => LessonModel.fromJson(
item as Map<String, dynamic>,
))
.toList();
}
}
The service knows Dio, the endpoint, response envelope, and JSON model. It does not emit UI state.
Model: describe the wire format#
@JsonSerializable()
class LessonModel {
const LessonModel({
required this.id,
required this.title,
required this.durationSeconds,
});
@JsonKey(name: '_id')
final String id;
final String title;
@JsonKey(name: 'duration_seconds')
final int durationSeconds;
factory LessonModel.fromJson(Map<String, dynamic> json) =>
_$LessonModelFromJson(json);
}
API names remain here.
Entity: describe the application#
@freezed
abstract class Lesson with _$Lesson {
const factory Lesson({
required String id,
required String title,
required Duration duration,
}) = _Lesson;
}
Duration is more useful to the application than a raw integer. The entity has no knowledge of JSON or Dio.
Repository: cross the boundary#
abstract interface class LessonsRepository {
Future<Either<LessonsFailure, List<Lesson>>> getTodaysLessons();
}
class ApiLessonsRepository implements LessonsRepository {
ApiLessonsRepository(this._service);
final LessonsService _service;
@override
Future<Either<LessonsFailure, List<Lesson>>>
getTodaysLessons() async {
try {
final models = await _service.getTodaysLessons();
return Right(
models
.map(
(model) => Lesson(
id: model.id,
title: model.title.trim(),
duration: Duration(
seconds: model.durationSeconds,
),
),
)
.toList(),
);
} on DioException catch (error) {
return Left(mapLessonsFailure(error));
}
}
}
The repository translates transport data and errors into domain values.
Use case: name the feature operation#
class GetTodaysLessons {
const GetTodaysLessons(this._repository);
final LessonsRepository _repository;
Future<Either<LessonsFailure, List<Lesson>>> call() {
return _repository.getTodaysLessons();
}
}
This use case is thin, which is fine. It gives the BLoC a stable feature action and a future home for rules such as ordering or entitlement checks.
Do not add a use case merely to rename every repository method in a tiny app. It becomes valuable when the architecture consistently uses actions as the BLoC boundary or when multiple data sources and rules are involved.
BLoC: coordinate interaction and state#
class LessonsBloc extends Bloc<LessonsEvent, LessonsState> {
LessonsBloc(this._getTodaysLessons)
: super(const LessonsState()) {
on<LessonsRequested>(_onRequested);
}
final GetTodaysLessons _getTodaysLessons;
Future<void> _onRequested(
LessonsRequested event,
Emitter<LessonsState> emit,
) async {
emit(state.copyWith(status: LessonsStatus.loading));
final result = await _getTodaysLessons();
result.fold(
(failure) => emit(
state.copyWith(
status: LessonsStatus.failure,
failure: failure,
),
),
(lessons) => emit(
state.copyWith(
status: lessons.isEmpty
? LessonsStatus.empty
: LessonsStatus.success,
lessons: lessons,
),
),
);
}
}
The BLoC understands user actions and screen states. It does not parse JSON or inspect HTTP status codes.
UI: render and dispatch#
BlocBuilder<LessonsBloc, LessonsState>(
builder: (context, state) {
return switch (state.status) {
LessonsStatus.initial ||
LessonsStatus.loading =>
const LessonsLoadingView(),
LessonsStatus.empty =>
const EmptyLessonsView(),
LessonsStatus.failure =>
LessonsErrorView(
onRetry: () => context
.read<LessonsBloc>()
.add(const LessonsRequested()),
),
LessonsStatus.success =>
LessonsList(lessons: state.lessons),
};
},
)
Widgets consume entities and state. They do not know LessonModel exists.
Test each boundary for its responsibility#
- Model test: unusual API keys and missing values.
- Repository test: mapping and failure translation.
- Use-case test: domain rules.
- BLoC test: state sequence.
- Widget test: rendering and dispatched actions.
- Integration test: dependency and route wiring.
Avoid one enormous test that mocks the server and asserts a text widget for every case. Smaller boundary tests show exactly what broke.
Add layers because they remove coupling#
This pipeline is useful when an API-backed feature will grow, be shared, or need independent testing. A local counter does not need seven files.
The rule I use is simple: each layer must protect the next layer from a detail it should not own. If a layer only forwards calls forever and no variation is expected, reconsider it. If widgets know JSON keys or BLoCs know Dio errors, a missing boundary is already costing the project.