Skip to content
AB

From API Response to Flutter Screen: A Complete Feature Pipeline

Follow one Flutter feature through service, model, repository, entity, use case, BLoC, and UI, with a reason for every boundary.

Ashish BhakhandPublished

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:

Code
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#

Code
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#

Code
@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#

Code
@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#

Code
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#

Code
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#

Code
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#

Code
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.

← Back to all writing