Lesson 38 of 51 advanced

Data Layer

The bridge between your app and the outside world

Open interactive version (quiz + challenge)

Real-world analogy

The data layer is like a translator at the United Nations. The speaker (API server) talks in one language (JSON). The listener (domain layer) understands another language (Dart entities). The translator (data layer) sits in between, converting messages back and forth perfectly. The translator also has a notebook (cache) -- if the speaker is unavailable, the translator can read from their notes instead.

What is it?

The Data Layer implements the repository interfaces defined by the domain layer. It contains DTOs (Data Transfer Objects) for JSON serialization, remote data sources for API communication via Dio, local data sources for caching via Hive, and repository implementations that orchestrate between remote and local sources. The data layer is the only layer that knows about HTTP, JSON, databases, and external services.

Real-world relevance

In team_mvp_kit, the data layer handles the messy reality of network communication. When the API returns snake_case JSON, the DTO converts it to camelCase entities. When the network is down, the repository falls back to cached data. When the auth token expires, the Dio interceptor refreshes it automatically. The presentation and domain layers never see any of this complexity -- they just get clean entities and typed failures.

Key points

Code example

import 'package:dio/dio.dart';
import 'package:json_annotation/json_annotation.dart';

// ---- DTO ----

class TaskDto {
  final String id;
  final String title;
  final String description;
  final String priority;
  final String status;
  final String createdAt;
  final String? dueDate;
  final String assigneeId;

  const TaskDto({
    required this.id,
    required this.title,
    required this.description,
    required this.priority,
    required this.status,
    required this.createdAt,
    this.dueDate,
    required this.assigneeId,
  });

  factory TaskDto.fromJson(Map<String, dynamic> json) {
    return TaskDto(
      id: json['id'] as String,
      title: json['title'] as String,
      description: json['description'] as String,
      priority: json['priority'] as String,
      status: json['status'] as String,
      createdAt: json['created_at'] as String,
      dueDate: json['due_date'] as String?,
      assigneeId: json['assignee_id'] as String,
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'title': title,
      'description': description,
      'priority': priority,
      'status': status,
      'created_at': createdAt,
      'due_date': dueDate,
      'assignee_id': assigneeId,
    };
  }

  Task toEntity() {
    return Task(
      id: id,
      title: title,
      description: description,
      priority: TaskPriority.values.firstWhere(
        (e) => e.name == priority,
        orElse: () => TaskPriority.medium,
      ),
      status: TaskStatus.values.firstWhere(
        (e) => e.name == status,
        orElse: () => TaskStatus.todo,
      ),
      createdAt: DateTime.parse(createdAt),
      dueDate: dueDate != null
          ? DateTime.parse(dueDate!)
          : null,
      assigneeId: assigneeId,
    );
  }

  static TaskDto fromEntity(Task task) {
    return TaskDto(
      id: task.id,
      title: task.title,
      description: task.description,
      priority: task.priority.name,
      status: task.status.name,
      createdAt: task.createdAt.toIso8601String(),
      dueDate: task.dueDate?.toIso8601String(),
      assigneeId: task.assigneeId,
    );
  }
}

// ---- Remote Data Source ----

abstract class TaskRemoteDataSource {
  Future<List<TaskDto>> getTasks();
  Future<TaskDto> getTaskById(String id);
  Future<TaskDto> createTask(TaskDto dto);
  Future<TaskDto> updateTask(TaskDto dto);
  Future<void> deleteTask(String id);
}

class TaskRemoteDataSourceImpl
    implements TaskRemoteDataSource {
  final Dio _dio;
  const TaskRemoteDataSourceImpl(this._dio);

  @override
  Future<List<TaskDto>> getTasks() async {
    final response = await _dio.get('/api/tasks');
    final list = response.data['data'] as List;
    return list
        .map((json) =>
            TaskDto.fromJson(json as Map<String, dynamic>))
        .toList();
  }

  @override
  Future<TaskDto> getTaskById(String id) async {
    final response = await _dio.get('/api/tasks/$id');
    return TaskDto.fromJson(
        response.data['data'] as Map<String, dynamic>);
  }

  @override
  Future<TaskDto> createTask(TaskDto dto) async {
    final response = await _dio.post(
      '/api/tasks',
      data: dto.toJson(),
    );
    return TaskDto.fromJson(
        response.data['data'] as Map<String, dynamic>);
  }

  @override
  Future<TaskDto> updateTask(TaskDto dto) async {
    final response = await _dio.put(
      '/api/tasks/${dto.id}',
      data: dto.toJson(),
    );
    return TaskDto.fromJson(
        response.data['data'] as Map<String, dynamic>);
  }

  @override
  Future<void> deleteTask(String id) async {
    await _dio.delete('/api/tasks/$id');
  }
}

// ---- Repository Implementation ----

class TaskRepositoryImpl implements TaskRepository {
  final TaskRemoteDataSource _remote;

  const TaskRepositoryImpl(this._remote);

  @override
  Future<List<Task>> getTasks() async {
    final dtos = await _remote.getTasks();
    return dtos.map((dto) => dto.toEntity()).toList();
  }

  @override
  Future<Task> getTaskById(String id) async {
    final dto = await _remote.getTaskById(id);
    return dto.toEntity();
  }

  @override
  Future<void> createTask(Task task) async {
    final dto = TaskDto.fromEntity(task);
    await _remote.createTask(dto);
  }

  @override
  Future<void> updateTask(Task task) async {
    final dto = TaskDto.fromEntity(task);
    await _remote.updateTask(dto);
  }

  @override
  Future<void> deleteTask(String id) async {
    await _remote.deleteTask(id);
  }
}

Line-by-line walkthrough

  1. 1. TaskDto mirrors the API response with String fields for all values including dates and enums.
  2. 2. fromJson factory reads each field from the JSON map, handling nullable fields like dueDate.
  3. 3. toJson creates a Map with snake_case keys matching the API's expected format.
  4. 4. toEntity converts strings to proper Dart types: DateTime.parse for dates, enum.values.firstWhere for enums.
  5. 5. firstWhere with orElse provides a safe default if the API returns an unexpected enum value.
  6. 6. fromEntity is the reverse: entity fields converted back to strings for JSON serialization.
  7. 7. TaskRemoteDataSource defines the abstract contract for all API operations.
  8. 8. TaskRemoteDataSourceImpl takes a Dio instance and implements each method with HTTP calls.
  9. 9. getTasks sends a GET request and maps the response JSON list to a list of TaskDtos.
  10. 10. createTask sends a POST with the DTO's JSON body and returns the server's response as a new DTO.
  11. 11. updateTask uses PUT with the task ID in the URL path.
  12. 12. deleteTask sends a DELETE request with just the ID.
  13. 13. TaskRepositoryImpl implements the domain's TaskRepository interface.
  14. 14. getTasks fetches DTOs from remote, converts each to an entity with toEntity.
  15. 15. createTask and updateTask convert entities to DTOs before sending to the remote source.

Spot the bug

class UserRepositoryImpl implements UserRepository {
  final Dio _dio;

  UserRepositoryImpl(this._dio);

  @override
  Future<User> getUser(String id) async {
    final response = await _dio.get('/api/users/$id');
    final json = response.data;
    return User(
      id: json['id'],
      name: json['first_name'] + ' ' + json['last_name'],
      email: json['email'],
    );
  }
}
Need a hint?
This repository has two architectural problems. Look at what it depends on and what it returns.
Show answer
Problem 1: The repository depends directly on Dio instead of going through a data source layer. It should depend on a UserRemoteDataSource abstract class. Problem 2: JSON parsing is happening directly in the repository instead of in a DTO class. The repository should receive a UserDto from the data source and call toEntity() on it. Fix: create UserDto with fromJson and toEntity, create UserRemoteDataSource, and have the repository depend on the data source abstraction.

Explain like I'm 5

Imagine you have a pen pal in another country. They write letters in French (JSON from the API). You read English (Dart entities). The data layer is your translator friend who sits in between. When a letter arrives, the translator reads the French (fromJson), understands it, and rewrites it in English (toEntity) for you. When you write back, the translator takes your English (entity), converts it to French (toJson), and mails it. The translator also keeps a copy of recent letters (cache) in case the mail service goes down!

Fun fact

The json_serializable package was one of the first Dart code generation packages and has been downloaded over 500 million times on pub.dev. It uses Dart's build_runner system to generate toJson and fromJson methods at compile time, which means zero runtime reflection cost. This was a deliberate design choice by the Dart team since Dart dropped runtime reflection (dart:mirrors) to enable tree-shaking and smaller binaries.

Hands-on challenge

Create the complete data layer for a 'User Profile' feature: (1) UserProfileDto with json_serializable annotations including @JsonKey for snake_case fields (first_name, last_name, profile_image_url, created_at), (2) toEntity and fromEntity conversions, (3) UserProfileRemoteDataSource with Dio for GET /api/profile and PUT /api/profile, (4) UserProfileLocalDataSource with Hive for caching, (5) UserProfileRepositoryImpl that tries remote first and falls back to cache. Include proper error mapping from DioException to typed Failures.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter & Dart