Understanding Models, DTOs, and Entities in Flutter MVVM: A Comprehensive Guide
When building Flutter applications using the MVVM (Model-View-ViewModel) architecture, the terms Model, DTO, and Entity often cause confusion. Each has a distinct role, purpose, and set of use cases within the architecture. In this article, we will clarify the differences, explore their use cases, and provide best practices for each.
“You can use Entities and DTOs (Data Transfer Objects) in Flutter MVVM depending on the complexity of your application and your architectural requirements. However, their usage isn’t mandatory for MVVM itself but is common in Clean Architecture”
1. What Are Models, DTOs, and Entities?
Term Purpose Key Characteristics:
Model: Represents a domain-specific data structure used across the app. It may include logic, validation, and computed properties. Focused on business rules or app-specific transformations and reusable in different parts of the app.
DTO: Stands for Data Transfer Object. Used to transfer raw data between layers or over a network (e.g., API responses). Lightweight, serializable, and often maps directly to JSON data from APIs or databases.
Entity: Represents a real-world concept and is often tied to a database schema or backend structure. Persistent, immutable (in many cases), and contains only essential properties.
2. Key Differences
3. Use Cases in MVVM Architecture
In MVVM, these elements fit into the architecture as follows:
3.1. Data Transfer Object (DTO)
Purpose:
The DTO is used to transfer raw data between your app and external systems (e.g., APIs, databases).
- Example: Receiving an expense record from a REST API.
Where It Fits in MVVM:
- DTOs are used in the Service/Repository layer to handle raw input/output.
- They are not directly used by the UI but are converted to Models.
Example in Flutter:
class ExpenseDTO {
final int id;
final String title;
final double amount;
final String date;
ExpenseDTO({
required this.id,
required this.title,
required this.amount,
required this.date,
});
// Parse from JSON
factory ExpenseDTO.fromJson(Map<String, dynamic> json) {
return ExpenseDTO(
id: json['id'],
title: json['title'],
amount: json['amount'],
date: json['date'],
);
}
// Convert to JSON for sending data to an API
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'amount': amount,
'date': date,
};
}
}
Best Practices for DTOs:
- Keep DTOs Simple:
- Only include raw data fields, no logic.
2. Use Factory Methods:
- For JSON serialization/deserialization, use
fromJson
andtoJson
.
3. Avoid Using DTOs in UI:
- DTOs should not be exposed directly to the ViewModel or View.
3.2. Entity
Purpose:
Entities represent core domain objects, usually mapped to backend databases or APIs. They are persistent objects that reflect the real-world concepts of your application.
- Example: Expense entity tied to a database schema.
Where It Fits in MVVM:
- Entities are used in the Repository/Service layer.
- Like DTOs, entities are transformed into Models before reaching the ViewModel.
Example in Flutter:
class ExpenseEntity {
final int id;
final String title;
final double amount;
final DateTime date;
ExpenseEntity({
required this.id,
required this.title,
required this.amount,
required this.date,
});
}
Best Practices for Entities:
- Immutable:
- Make entities immutable to ensure data consistency.
2. Minimal Properties:
- Only include properties required for persistence.
3. Avoid UI-Specific Fields:
- Keep UI-related logic out of entities.
3.3. Model
Purpose:
Models encapsulate the app’s business logic and are used to transform data for UI consumption. They may contain computed properties, validation, or formatting logic.
- Example: An expense model that formats dates and amounts for display.
Where It Fits in MVVM:
- Models are the core component of the ViewModel layer.
- They are responsible for business logic and transforming raw DTO/Entity data for the View.
Example in Flutter:
class ExpenseModel {
final String title;
final String formattedAmount;
final String formattedDate;
ExpenseModel({
required this.title,
required this.formattedAmount,
required this.formattedDate,
});
// Transform Entity into a UI-ready model
factory ExpenseModel.fromEntity(ExpenseEntity entity) {
return ExpenseModel(
title: entity.title,
formattedAmount: "\$${entity.amount.toStringAsFixed(2)}",
formattedDate: "${entity.date.day}/${entity.date.month}/${entity.date.year}",
);
}
}
Best Practices for Models:
- Add Business Logic:
- Use models for validation, computed fields, and data formatting.
2. Keep View-Specific:
- Tailor models to UI needs, transforming raw data as required.
3. Isolate Models in the ViewModel Layer:
- Do not mix DTOs or Entities directly in the UI.
4. Putting It All Together in MVVM
Here’s how the layers interact:
Step-by-Step Data Flow
- Service Layer:
- Fetches raw data as DTOs.
- Example:
ExpenseDTO
from an API.
2. Repository Layer:
- Converts DTOs to Entities for persistence or domain logic.
- Example: Convert
ExpenseDTO
→ExpenseEntity
.
3. ViewModel Layer:
- Transforms Entities into Models for UI consumption.
- Example: Convert
ExpenseEntity
→ExpenseModel
.
4. View Layer:
- Consumes Models provided by the ViewModel for rendering.
- Example: Render
ExpenseModel
in a list.
5. Best Practices for MVVM in Flutter
- Separate Responsibilities:
- Keep DTOs, Entities, and Models distinct for cleaner architecture.
2. Avoid Direct API Usage in ViewModel:
- Use a service/repository layer for data fetching and transformation.
3. Keep UI Logic in the ViewModel:
- Use models for business logic, formatting, and validation.
4. Dependency Injection:
- Use libraries like
get_it
orriverpod
for injecting dependencies into ViewModels.
5. Testability:
- By separating DTOs, Entities, and Models, you can write unit tests for each layer independently.
6. Visual Representation
[DTO] <--(API/Service Layer)--> [Entity] <--(Repository Layer)--> [Model] <--(ViewModel Layer)--> [View]
Example in Action: Expense Tracker
1. Fetch ExpenseDTO from API: [ { id: 1, title: "Rent", amount: 1200, date: "2024-01-01" } ]
2. Convert to ExpenseEntity: ExpenseEntity(id: 1, title: "Rent", amount: 1200, date: DateTime.parse("2024-01-01"))
3. Transform to ExpenseModel: ExpenseModel(title: "Rent", formattedAmount: "$1200.00", formattedDate: "01/01/2024")
4. Display in the UI using ViewModel.
Here’s a structured example that demonstrates how to organize files and layers in Flutter using Models, DTOs, and Entities within the MVVM architecture. We’ll use an Expense Tracker App as the example.
Folder Structure
lib/
├── data/
│ ├── models/
│ │ ├── expense_model.dart # UI-specific Model
│ ├── dtos/
│ │ ├── expense_dto.dart # Data Transfer Object
│ ├── entities/
│ │ ├── expense_entity.dart # Domain Object
│ ├── services/
│ │ ├── expense_api_service.dart # Service Layer for API Calls
│ ├── repositories/
│ ├── expense_repository.dart # Repository Layer
├── viewmodel/
│ ├── expense_viewmodel.dart # ViewModel Layer
├── views/
│ ├── expense_screen.dart # View Layer
File Details
1. DTOs (Data Transfer Objects)
- Purpose: Fetch raw data from APIs or services.
- File:
lib/data/dtos/expense_dto.dart
class ExpenseDTO {
final int id;
final String title;
final double amount;
final String date;
ExpenseDTO({
required this.id,
required this.title,
required this.amount,
required this.date,
});
factory ExpenseDTO.fromJson(Map<String, dynamic> json) {
return ExpenseDTO(
id: json['id'],
title: json['title'],
amount: json['amount'],
date: json['date'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'amount': amount,
'date': date,
};
}
}
2. Entity
- Purpose: Represents the domain object with essential properties.
- File:
lib/data/entities/expense_entity.dart
class ExpenseEntity {
final int id;
final String title;
final double amount;
final DateTime date;
ExpenseEntity({
required this.id,
required this.title,
required this.amount,
required this.date,
});
}
3. Model
- Purpose: Transforms entities into UI-specific data.
- File:
lib/data/models/expense_model.dart
class ExpenseModel {
final String title;
final String formattedAmount;
final String formattedDate;
ExpenseModel({
required this.title,
required this.formattedAmount,
required this.formattedDate,
});
// Factory constructor to create a model from an entity
factory ExpenseModel.fromEntity(ExpenseEntity entity) {
return ExpenseModel(
title: entity.title,
formattedAmount: "\$${entity.amount.toStringAsFixed(2)}",
formattedDate:
"${entity.date.day}/${entity.date.month}/${entity.date.year}",
);
}
}
4. Service Layer
- Purpose: Makes API calls and fetches DTOs.
- File:
lib/data/services/expense_api_service.dart
import '../dtos/expense_dto.dart';
class ExpenseApiService {
Future<List<ExpenseDTO>> fetchExpenses() async {
// Mock API response
final response = [
{'id': 1, 'title': 'Rent', 'amount': 1200, 'date': '2024-01-01'},
{'id': 2, 'title': 'Groceries', 'amount': 200, 'date': '2024-01-02'}
];
return response.map((json) => ExpenseDTO.fromJson(json)).toList();
}
}
5. Repository Layer
- Purpose: Converts DTOs to Entities.
- File:
lib/data/repositories/expense_repository.dart
import '../dtos/expense_dto.dart';
import '../entities/expense_entity.dart';
import '../services/expense_api_service.dart';
class ExpenseRepository {
final ExpenseApiService apiService;
ExpenseRepository(this.apiService);
Future<List<ExpenseEntity>> getExpenses() async {
final dtos = await apiService.fetchExpenses();
return dtos.map((dto) {
return ExpenseEntity(
id: dto.id,
title: dto.title,
amount: dto.amount,
date: DateTime.parse(dto.date),
);
}).toList();
}
}
6. ViewModel Layer
- Purpose: Handles business logic and transforms Entities into Models for UI.
- File:
lib/viewmodel/expense_viewmodel.dart
import 'package:flutter/material.dart';
import '../data/models/expense_model.dart';
import '../data/repositories/expense_repository.dart';
class ExpenseViewModel extends ChangeNotifier {
final ExpenseRepository repository;
List<ExpenseModel> _expenses = [];
List<ExpenseModel> get expenses => _expenses;
ExpenseViewModel(this.repository);
Future<void> fetchExpenses() async {
try {
final entities = await repository.getExpenses();
_expenses = entities.map((entity) => ExpenseModel.fromEntity(entity)).toList();
notifyListeners();
} catch (e) {
// Handle error
print("Error fetching expenses: $e");
}
}
}
7. View Layer
- Purpose: Displays data to the user using Models.
- File:
lib/views/expense_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../viewmodel/expense_viewmodel.dart';
class ExpenseScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final viewModel = context.watch<ExpenseViewModel>();
return Scaffold(
appBar: AppBar(title: Text('Expenses')),
body: viewModel.expenses.isEmpty
? Center(child: Text('No expenses found'))
: ListView.builder(
itemCount: viewModel.expenses.length,
itemBuilder: (context, index) {
final expense = viewModel.expenses[index];
return ListTile(
title: Text(expense.title),
subtitle: Text(expense.formattedDate),
trailing: Text(expense.formattedAmount),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => viewModel.fetchExpenses(),
child: Icon(Icons.refresh),
),
);
}
}
How Each Layer Interacts
- View Layer (
ExpenseScreen
):
- Displays the expenses to the user.
- Fetches data by calling
fetchExpenses()
in theExpenseViewModel
.
2. ViewModel Layer:
- Calls the
ExpenseRepository
to retrieve entities. - Transforms
ExpenseEntity
objects intoExpenseModel
for the UI.
3. Repository Layer:
- Fetches raw data (DTOs) from the
ExpenseApiService
. - Converts
ExpenseDTO
objects intoExpenseEntity
objects.
4. Service Layer:
- Fetches raw JSON data from an API and converts it into
ExpenseDTO
.
Best Practices
- Separation of Concerns:
- Keep DTOs, Entities, and Models separate for modularity.
2. Dependency Injection:
- Inject dependencies like
ExpenseApiService
andExpenseRepository
usingProvider
orGetIt
.
3. Test Each Layer:
- Write unit tests for DTOs, Entities, Models, and ViewModels independently.
4. Avoid UI Logic in Models:
- Keep Models lightweight and focused on transforming data for the UI.
This structure ensures a clean and scalable architecture, making it easy to add new features or refactor the app while maintaining clarity.
Conclusion
Understanding the differences between Models, DTOs, and Entities is crucial for implementing a clean, scalable, and testable architecture in Flutter. Each type has a specific role, and by adhering to best practices, you can build robust applications with a clear separation of concerns.