Clean Architecture: Entities, Data Models, and View Models - momshaddinury/flutter_template GitHub Wiki
In software development, especially when working with architectural patterns like Clean Architecture, terms like "model," "entity," and "DTO" (Data Transfer Object) can sometimes cause confusion. This document aims to clarify the distinct roles of Entities, Data Models, and View Models within a Clean Architecture paradigm.
Understanding these distinctions is crucial for building maintainable, testable, and scalable applications by enforcing strict separation of concerns.
What is it? An Entity represents the core business concept or object in your application's domain. It encapsulates the fundamental business rules and data that are independent of any external concerns like databases, user interfaces, or specific technologies.
Key Characteristics:
- Pure Business Logic: Contains methods that implement and enforce the invariant business rules related to that concept.
- Independent: Has no knowledge of how it's stored (database), how it's displayed (UI), or how it's transferred (API). It's a plain object (POJO/POCO).
- Stable: The business rules it embodies are considered the most stable part of your application. They rarely change, even if technologies or user interfaces evolve.
- Contains Behavior: Entities are not just data bags; they have behaviors (methods) that operate on their own data to ensure business invariants are always met.
- Resides In: The innermost layer of Clean Architecture – the Domain Layer.
When do we use it?
- Whenever we are implementing core business rules that define the behavior and state of a fundamental business object (e.g., deducting stock, calculating a discount, validating user registration).
- When passing data within the Domain Layer or between the Use Cases and Entities.
Example: A Product
entity might have methods like deductStock(quantity)
which contains the logic to prevent negative stock, or calculateDiscountedPrice(percentage)
to apply business-specific pricing rules.
// lib/domain/entities/product.dart
// Represents the core business concept of a Product.
class Product {
final String id;
final String name;
final String description;
final double price;
int stock; // Stock can change, hence not final
Product({
required this.id,
required this.name,
required this.description,
required this.price,
required this.stock,
}) {
// --- Invariant Business Rules ---
if (id.isEmpty) {
throw ArgumentError('Product ID cannot be empty.');
}
if (name.isEmpty) {
throw ArgumentError('Product name cannot be empty.');
}
if (price <= 0) {
throw ArgumentError('Product price must be positive.');
}
if (stock < 0) {
throw ArgumentError('Product stock cannot be negative.');
}
}
// --- Core Business Logic Methods ---
/// Reduces the stock of the product.
/// Throws a StateError if there's insufficient stock.
void deductStock(int quantity) {
if (quantity <= 0) {
throw ArgumentError('Quantity to deduct must be positive.');
}
if (stock < quantity) {
throw StateError('Insufficient stock for product "$name". Current stock: $stock, requested: $quantity');
}
stock -= quantity;
print('DEBUG: Stock for "$name" (ID: $id) reduced by $quantity. New stock: $stock');
}
/// Calculates the final price after applying a discount.
/// Throws an ArgumentError if the discount percentage is invalid.
double calculateDiscountedPrice(double discountPercentage) {
if (discountPercentage < 0 || discountPercentage > 100) {
throw ArgumentError('Discount percentage must be between 0 and 100.');
}
return price * (1 - discountPercentage / 100);
}
/// Checks if the product is currently available for purchase.
bool isAvailable() => stock > 0;
@override
String toString() {
return 'Product(id: $id, name: $name, price: \$${price.toStringAsFixed(2)}, stock: $stock)';
}
}
What is it? A Data Model (often referred to as a "DTO" - Data Transfer Object, specifically for data persistence) is a data structure designed to represent how data is stored in a specific persistence mechanism (e.g., a database table, a JSON structure from a REST API, or a document in a NoSQL database).
Key Characteristics:
- Specific to Data Source: Its structure is often optimized for the database schema or external API contract. Field names, types, and relationships might differ from the Entity.
-
Serialization/Deserialization: Typically includes methods to convert to/from the data source's format (e.g.,
toJson
/toMap
,fromJson
/fromMap
). - No Business Logic: Should be a pure data container with little to no behavior beyond simple getters/setters or conversion methods.
- Resides In: The Data Layer (or Infrastructure Layer).
When do we use it?
- When retrieving data from a database or external API.
- When sending data to be persisted in a database or to an external API.
- Within the
Repository
implementation, which is responsible for mapping between Data Models and Entities.
Example: A ProductDataModel
might have product_name
instead of name
and include a created_at
timestamp, reflecting the database column names and specific data types.
// lib/data/models/product_data_model.dart
// Represents how a product is stored in a NoSQL database (e.g., Firestore document).
// Note: Field names might differ from the Entity to match the database schema.
class ProductDataModel {
final String id;
final String productName; // Matches 'product_name' in DB
final String productDescription;
final double unitPrice; // Matches 'unit_price' in DB
final int availableStock;
final DateTime createdAt; // Database-specific field (e.g., for auditing)
final DateTime? lastUpdatedAt; // Database-specific field (nullable)
ProductDataModel({
required this.id,
required this.productName,
required this.productDescription,
required this.unitPrice,
required this.availableStock,
required this.createdAt,
this.lastUpdatedAt,
});
// Factory constructor to create ProductDataModel from a Map (e.g., from a Firestore document).
factory ProductDataModel.fromMap(Map<String, dynamic> map) {
return ProductDataModel(
id: map['id'] as String,
productName: map['product_name'] as String,
productDescription: map['product_description'] as String,
unitPrice: (map['unit_price'] as num).toDouble(),
availableStock: map['available_stock'] as int,
createdAt: DateTime.parse(map['created_at'] as String), // Assuming ISO 8601 string
lastUpdatedAt: map['last_updated_at'] != null
? DateTime.parse(map['last_updated_at'] as String)
: null,
);
}
// Convert ProductDataModel to a Map for storage (e.g., to a Firestore document).
Map<String, dynamic> toMap() {
return {
'id': id,
'product_name': productName,
'product_description': productDescription,
'unit_price': unitPrice,
'available_stock': availableStock,
'created_at': createdAt.toIso8601String(),
'last_updated_at': lastUpdatedAt?.toIso8601String(),
};
}
}
What is it? A View Model (sometimes also called a "Presentation Model" or "UI Model") is a data structure specifically designed to present data to the User Interface (UI). It contains only the data that a particular screen or UI component needs, often formatted or combined for immediate display.
Key Characteristics:
- UI-Specific: Its structure is tailored for a specific UI view. It might combine data from multiple entities or present derived properties suitable for display.
- Formatted Data: Data is often pre-formatted for direct rendering (e.g., currency strings, dates in a specific format, status messages).
-
No Business Logic: Should not contain core business rules. Its primary purpose is to hold data for rendering. It might contain very simple UI-related logic (e.g.,
get buttonColorBasedOnState()
). - Resides In: The Presentation Layer (or UI Layer).
When do we use it?
- Whenever we need to display data on a screen.
- When passing data from the Presenter/Controller/ViewModel to the actual UI widgets/components.
Example: A ProductListItemViewModel
might include a formattedPrice
string and a stockStatus
message ("In Stock", "Out of Stock") rather than raw numbers, making it easier for the UI to consume directly.
// lib/presentation/models/product_view_model.dart
import 'package:intl/intl.dart'; // For currency formatting, typically from pubspec.yaml
// Represents how a product is displayed on a list screen item.
class ProductListItemViewModel {
final String id;
final String title;
final String descriptionPreview;
final String formattedPrice;
final String stockStatus; // e.g., "In Stock", "Low Stock", "Out of Stock"
final bool isAvailableForPurchase;
final String? thumbnailUrl; // UI-specific: path to image, not directly in core entity
ProductListItemViewModel({
required this.id,
required this.title,
required this.descriptionPreview,
required this.formattedPrice,
required this.stockStatus,
required this.isAvailableForPurchase,
this.thumbnailUrl,
});
}
// Represents how a product is displayed on a detailed product page.
class ProductDetailViewModel {
final String id;
final String name;
final String fullDescription;
final String formattedPrice;
final String stockAvailabilityMessage; // e.g., "Only 5 left!", "Currently Unavailable"
final String mainImageUrl; // UI-specific: larger image for detail page
final List<String> tags; // UI-specific: might be derived from description or a separate field
ProductDetailViewModel({
required this.id,
required this.name,
required this.fullDescription,
required this.formattedPrice,
required this.stockAvailabilityMessage,
required this.mainImageUrl,
required this.tags,
});
}
The clear separation between these models is enforced by mapping processes:
-
Repository Layer (Data Model <-> Entity):
- When retrieving data from the database, the
Repository
implementation takes theData Model
(e.g.,ProductDataModel
) received from the data source and maps it into anEntity
(e.g.,Product
). This ensures the Domain Layer only ever works with pure Entities. - When saving data, the
Repository
takes anEntity
and maps it into aData Model
suitable for persistence.
- When retrieving data from the database, the
-
Presentation Layer (Entity -> View Model):
- After a Use Case has executed and returned
Entities
, the Presenter/Controller/UI-ViewModel takes theseEntities
and maps them into one or moreView Models
(e.g.,ProductListItemViewModel
orProductDetailViewModel
) specifically tailored for the UI.
- After a Use Case has executed and returned
This explicit mapping prevents direct dependencies between layers and ensures that changes in one layer (e.g., database schema changes) do not ripple through to other layers (e.g., UI), as long as the core business rules (Entities) remain consistent.
- Testability: Each layer can be tested in isolation. Entities can be tested without needing a database or UI.
- Maintainability: Changes in the UI or database technology have minimal impact on the core business logic.
- Flexibility: It's easier to swap out persistence mechanisms (e.g., from SQL to NoSQL) or UI frameworks without rewriting large parts of the application.
- Scalability: Clear boundaries and responsibilities make it easier for larger teams to work on different parts of the system concurrently.
- Reduced Complexity: By focusing each model on a single responsibility, the cognitive load for developers is reduced.
By consistently applying these distinctions, we ensure our projects adhere to Clean Architecture principles, leading to more robust, adaptable, and understandable software. If you have further questions, please don't hesitate to ask!