App Architecture & Design Patterns

The Data Layer: Models & Data Sources

16 min Lesson 4 of 12

The Data Layer: Models & Data Sources

In Clean Architecture, the Data layer is the outermost layer responsible for all data operations. It talks directly to the network, databases, and local storage. The rest of your application never needs to know whether data came from a remote API or a local cache — that detail is hidden here, behind well-defined interfaces.

This lesson covers two key responsibilities of the Data layer: Model classes (which handle serialisation) and DataSource classes (which perform the actual I/O). Together they form the plumbing that feeds clean Entity objects up to your domain and presentation layers.

Models vs Entities

A common point of confusion is the difference between a Model and an Entity:

  • Entity — a pure Dart class in the domain layer. It has no JSON knowledge, no framework imports, and no dependency on anything external.
  • Model — a class in the data layer that extends the entity and adds fromJson / toJson serialisation. It knows about the outside world so the entity does not have to.
Note: By extending the entity, a Model is-a entity. You can pass a UserModel anywhere a User entity is expected, keeping your domain and presentation layers completely decoupled from serialisation details.

Writing a Model Class

Suppose your domain entity looks like this:

Domain Entity (lib/features/auth/domain/entities/user.dart)

class User {
  final String id;
  final String name;
  final String email;

  const User({
    required this.id,
    required this.name,
    required this.email,
  });
}

The corresponding Model in the data layer extends User and adds JSON support:

Data Model (lib/features/auth/data/models/user_model.dart)

import '../../domain/entities/user.dart';

class UserModel extends User {
  const UserModel({
    required super.id,
    required super.name,
    required super.email,
  });

  /// Deserialise from a JSON map (e.g. an HTTP response body).
  factory UserModel.fromJson(Map<String, dynamic> json) {
    return UserModel(
      id: json['id'] as String,
      name: json['name'] as String,
      email: json['email'] as String,
    );
  }

  /// Serialise to a JSON map (e.g. before writing to local storage).
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'name': name,
      'email': email,
    };
  }

  /// Create a copy with some fields overridden.
  UserModel copyWith({String? id, String? name, String? email}) {
    return UserModel(
      id: id ?? this.id,
      name: name ?? this.name,
      email: email ?? this.email,
    );
  }
}
Tip: Keep fromJson and toJson strictly in the Model. If you ever swap your HTTP client or switch from JSON to Protobuf, you only change this one file — the entity and everything above it stays untouched.

Abstract DataSource Contracts

A DataSource is an interface (abstract class) that declares what data operations are available, without saying how they are performed. Defining the contract as an abstract class gives you two critical benefits:

  • You can swap implementations (e.g. swap a real HTTP source for a mock in tests) without touching any calling code.
  • The Repository depends only on the abstraction, not on any concrete SDK or library.

Abstract Contracts (lib/features/auth/data/datasources/auth_remote_data_source.dart)

// Remote data source contract
abstract class AuthRemoteDataSource {
  /// Sends login credentials to the API and returns a UserModel on success.
  /// Throws a [ServerException] on failure.
  Future<UserModel> login({required String email, required String password});

  /// Fetches the currently authenticated user profile.
  Future<UserModel> getProfile();
}

// Local data source contract
abstract class AuthLocalDataSource {
  /// Persists the user model to SharedPreferences.
  Future<void> cacheUser(UserModel user);

  /// Returns the last cached user, or throws [CacheException] if none exists.
  Future<UserModel> getCachedUser();
}

Concrete DataSource Implementations

Concrete classes implement the abstract contracts. The remote source uses an HTTP client; the local source uses shared_preferences. Both classes only care about their own technology — no business logic leaks in here.

Concrete Implementations

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';

// ─── Remote Implementation ────────────────────────────────────────────────
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
  final http.Client client;

  const AuthRemoteDataSourceImpl({required this.client});

  @override
  Future<UserModel> login({
    required String email,
    required String password,
  }) async {
    final response = await client.post(
      Uri.parse('https://api.example.com/auth/login'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({'email': email, 'password': password}),
    );

    if (response.statusCode == 200) {
      return UserModel.fromJson(
        jsonDecode(response.body) as Map<String, dynamic>,
      );
    } else {
      throw ServerException(message: 'Login failed: ${response.statusCode}');
    }
  }

  @override
  Future<UserModel> getProfile() async {
    final response = await client.get(
      Uri.parse('https://api.example.com/auth/me'),
    );
    if (response.statusCode == 200) {
      return UserModel.fromJson(
        jsonDecode(response.body) as Map<String, dynamic>,
      );
    }
    throw ServerException(message: 'Could not fetch profile');
  }
}

// ─── Local Implementation ─────────────────────────────────────────────────
const _kCachedUser = 'CACHED_USER';

class AuthLocalDataSourceImpl implements AuthLocalDataSource {
  final SharedPreferences prefs;

  const AuthLocalDataSourceImpl({required this.prefs});

  @override
  Future<void> cacheUser(UserModel user) async {
    await prefs.setString(_kCachedUser, jsonEncode(user.toJson()));
  }

  @override
  Future<UserModel> getCachedUser() {
    final jsonString = prefs.getString(_kCachedUser);
    if (jsonString != null) {
      return Future.value(
        UserModel.fromJson(jsonDecode(jsonString) as Map<String, dynamic>),
      );
    }
    throw CacheException(message: 'No cached user found');
  }
}
Warning: Never put business rules inside a DataSource. If you find yourself writing if (user.role == 'admin') in a DataSource class, stop — that logic belongs in a Use Case in the domain layer. DataSources are dumb I/O adapters only.

How It All Fits Together

The data flow follows a strict one-way dependency rule:

  • Presentation calls a Use Case.
  • Use Case calls a Repository interface (domain layer).
  • Repository implementation (data layer) calls the abstract DataSource contract.
  • Concrete DataSource performs the actual HTTP or SharedPreferences call and returns a Model.
  • The Model is mapped to an Entity before being returned up the chain.
Key Takeaway: The Data layer is the only place in your app that knows about JSON, HTTP status codes, SharedPreferences keys, and external SDKs. Everything above it works with clean Entities and abstract contracts. This separation makes your app easy to test, maintain, and refactor.