1506 lines
57 KiB
Markdown
1506 lines
57 KiB
Markdown
|
|
# 🏗️ Архитектура: Связь между Таблицами, Моделями, DTO и DAO
|
|||
|
|
|
|||
|
|
Документ объясняет, как связаны компоненты данных в проекте Mnemo Cards Backend.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 📊 Общая схема связей
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|||
|
|
│ PostgreSQL Database │
|
|||
|
|
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
|||
|
|
│ │ users table │ │ user_datas │ │ card_packs │ │
|
|||
|
|
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
|||
|
|
└─────────────────────────────────────────────────────────────────┘
|
|||
|
|
↕ (SQL queries)
|
|||
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|||
|
|
│ Drift ORM (Code Generation) │
|
|||
|
|
│ │
|
|||
|
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
|||
|
|
│ │ 1. TABLES (lib/database/tables/*.dart) │ │
|
|||
|
|
│ │ - Определение структуры таблиц │ │
|
|||
|
|
│ │ class Users extends Table { ... } │ │
|
|||
|
|
│ └──────────────────────────────────────────────────────────┘ │
|
|||
|
|
│ ↕ (build_runner) │
|
|||
|
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
|||
|
|
│ │ 2. MODELS (database.g.dart) │ │
|
|||
|
|
│ │ - Автогенерация классов данных │ │
|
|||
|
|
│ │ class User extends DataClass { ... } │ │
|
|||
|
|
│ │ class UsersCompanion { ... } (для insert/update) │ │
|
|||
|
|
│ └──────────────────────────────────────────────────────────┘ │
|
|||
|
|
│ ↕ (DAO methods) │
|
|||
|
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
|||
|
|
│ │ 3. DAO (lib/database/daos/*_dao.dart) │ │
|
|||
|
|
│ │ - Методы для работы с БД │ │
|
|||
|
|
│ │ class UserDao { Future<User?> getUserById(...) } │ │
|
|||
|
|
│ └──────────────────────────────────────────────────────────┘ │
|
|||
|
|
└─────────────────────────────────────────────────────────────────┘
|
|||
|
|
↕ (Extensions)
|
|||
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|||
|
|
│ Domain Models (mnemo_cards_common_backend) │
|
|||
|
|
│ │
|
|||
|
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
|||
|
|
│ │ 4. Domain Models (UserModel, UserDataModel) │ │
|
|||
|
|
│ │ - Бизнес-логика приложения │ │
|
|||
|
|
│ │ - Расширения для конвертации │ │
|
|||
|
|
│ │ extension UserToUserModel on User { ... } │ │
|
|||
|
|
│ └──────────────────────────────────────────────────────────┘ │
|
|||
|
|
└─────────────────────────────────────────────────────────────────┘
|
|||
|
|
↕ (toDto())
|
|||
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|||
|
|
│ DTO (mnemo_cards_common) │
|
|||
|
|
│ │
|
|||
|
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
|||
|
|
│ │ 5. DTO (UserDto, CardPackDto, etc.) │ │
|
|||
|
|
│ │ - JSON сериализуемые объекты для API │ │
|
|||
|
|
│ │ - Только данные, без логики │ │
|
|||
|
|
│ │ extension UserModelExtension { UserDto toDto() } │ │
|
|||
|
|
│ └──────────────────────────────────────────────────────────┘ │
|
|||
|
|
└─────────────────────────────────────────────────────────────────┘
|
|||
|
|
↕ (toJson())
|
|||
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|||
|
|
│ API Layer (Shelf) │
|
|||
|
|
│ │
|
|||
|
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
|||
|
|
│ │ 6. API Endpoints │ │
|
|||
|
|
│ │ Response.ok(userDto.toJson()) │ │
|
|||
|
|
│ │ - HTTP JSON responses │ │
|
|||
|
|
│ └──────────────────────────────────────────────────────────┘ │
|
|||
|
|
└─────────────────────────────────────────────────────────────────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🔄 Детальный поток данных
|
|||
|
|
|
|||
|
|
### Пример: Получение пользователя через API
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// 1. HTTP Request
|
|||
|
|
GET /api/v2/users/me
|
|||
|
|
|
|||
|
|
// 2. API Endpoint (users_api_v2.dart)
|
|||
|
|
@Route.get('/users/me')
|
|||
|
|
Future<Response> getCurrentUser(Request request) async {
|
|||
|
|
final user = request.user; // UserModel (domain model)
|
|||
|
|
|
|||
|
|
// 3. Конвертация в DTO
|
|||
|
|
final dto = await user.toDto(); // UserModel → UserDto
|
|||
|
|
|
|||
|
|
// 4. JSON Response
|
|||
|
|
return _json(dto.toJson()); // UserDto → JSON
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Пример: Получение данных из БД
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// 1. Manager (user_manager.dart)
|
|||
|
|
Future<UserModel?> fetchUser(String id) async {
|
|||
|
|
// 2. DAO обращается к БД через Drift
|
|||
|
|
final user = await _db.userDao.getUserById(id); // Returns: User (Drift model)
|
|||
|
|
|
|||
|
|
// 3. Конвертация Drift User → Domain UserModel
|
|||
|
|
return await user?.toUserModel(); // User → UserModel
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Пример: Создание нового пользователя
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// 1. API получает данные
|
|||
|
|
POST /api/v2/auth/register
|
|||
|
|
{ "email": "user@example.com", "password": "..." }
|
|||
|
|
|
|||
|
|
// 2. API вызывает Manager
|
|||
|
|
await _userManager.createUser(email, password);
|
|||
|
|
|
|||
|
|
// 3. Manager использует DAO
|
|||
|
|
final companion = UsersCompanion.insert(
|
|||
|
|
email: email,
|
|||
|
|
externalUserId: generateId(),
|
|||
|
|
// ...
|
|||
|
|
);
|
|||
|
|
final userId = await _db.userDao.createUser(companion); // Insert в БД
|
|||
|
|
|
|||
|
|
// 4. Возврат созданной модели
|
|||
|
|
final user = await _db.userDao.getUserById(userId);
|
|||
|
|
return await user.toUserModel();
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 📦 Компоненты детально
|
|||
|
|
|
|||
|
|
### 1️⃣ **TABLES** (Определение структуры БД)
|
|||
|
|
|
|||
|
|
**Расположение:** `lib/database/tables/*.dart`
|
|||
|
|
|
|||
|
|
**Назначение:** Определяют структуру таблиц PostgreSQL через Drift
|
|||
|
|
|
|||
|
|
**Пример:**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// lib/database/tables/users.dart
|
|||
|
|
class Users extends Table {
|
|||
|
|
TextColumn get id => text()
|
|||
|
|
.withDefault(const CustomExpression('gen_random_uuid()::text'))();
|
|||
|
|
TextColumn get name => text().nullable()();
|
|||
|
|
TextColumn get email => text().nullable()();
|
|||
|
|
BoolColumn get admin => boolean()
|
|||
|
|
.withDefault(const Constant(false))();
|
|||
|
|
|
|||
|
|
DateTimeColumn get createdAt => dateTime()
|
|||
|
|
.withDefault(currentDateAndTime)();
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Set<Column> get primaryKey => {id};
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Что это дает:**
|
|||
|
|
- ✅ Type-safe определение схемы БД
|
|||
|
|
- ✅ Автоматическая генерация миграций
|
|||
|
|
- ✅ Валидация на уровне компиляции
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 2️⃣ **MODELS** (Drift генерирует классы данных)
|
|||
|
|
|
|||
|
|
**Расположение:** `lib/database/database.g.dart` (автогенерируемый файл)
|
|||
|
|
|
|||
|
|
**Назначение:** Immutable классы данных для работы с записями из БД
|
|||
|
|
|
|||
|
|
**Пример:**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// Автогенерируется Drift из таблицы Users
|
|||
|
|
class User extends DataClass implements Insertable<User> {
|
|||
|
|
final String id;
|
|||
|
|
final String externalUserId;
|
|||
|
|
final String? name;
|
|||
|
|
final String? email;
|
|||
|
|
final bool admin;
|
|||
|
|
final List<String> purchases;
|
|||
|
|
final PgDateTime createdAt;
|
|||
|
|
final PgDateTime updatedAt;
|
|||
|
|
final bool isDeleted;
|
|||
|
|
|
|||
|
|
const User({
|
|||
|
|
required this.id,
|
|||
|
|
required this.externalUserId,
|
|||
|
|
this.name,
|
|||
|
|
this.email,
|
|||
|
|
required this.admin,
|
|||
|
|
// ...
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Автоматически генерируются:
|
|||
|
|
// - toJson()
|
|||
|
|
// - fromJson()
|
|||
|
|
// - toCompanion()
|
|||
|
|
// - copyWith()
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Companion классы** (для insert/update):
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// Для создания/обновления записей
|
|||
|
|
class UsersCompanion {
|
|||
|
|
final Value<String> id;
|
|||
|
|
final Value<String> externalUserId;
|
|||
|
|
final Value<String?> name;
|
|||
|
|
// ...
|
|||
|
|
|
|||
|
|
UsersCompanion.insert({
|
|||
|
|
this.id = const Value.absent(), // auto-generated
|
|||
|
|
required String externalUserId,
|
|||
|
|
String? name,
|
|||
|
|
// ...
|
|||
|
|
}) : externalUserId = Value(externalUserId),
|
|||
|
|
name = Value(name);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Ключевые особенности:**
|
|||
|
|
- ✅ Immutable (неизменяемые после создания)
|
|||
|
|
- ✅ Type-safe (все типы проверяются компилятором)
|
|||
|
|
- ✅ Автоматическая сериализация JSON
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 3️⃣ **DAO** (Data Access Objects)
|
|||
|
|
|
|||
|
|
**Расположение:** `lib/database/daos/*_dao.dart`
|
|||
|
|
|
|||
|
|
**Назначение:** Методы для CRUD операций с БД через Drift
|
|||
|
|
|
|||
|
|
**Пример:**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// lib/database/daos/user_dao.dart
|
|||
|
|
@DriftAccessor(tables: [Users, UserDatas, Tokens, RefreshTokens, UserPacks])
|
|||
|
|
class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
|
|||
|
|
UserDao(super.db);
|
|||
|
|
|
|||
|
|
// GET operations
|
|||
|
|
Future<User?> getUserById(String id) {
|
|||
|
|
return (select(users)..where((u) => u.id.equals(id)))
|
|||
|
|
.getSingleOrNull();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<User?> getUserByEmail(String email) {
|
|||
|
|
return (select(users)..where((u) => u.email.equals(email)))
|
|||
|
|
.getSingleOrNull();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CREATE operations
|
|||
|
|
Future<String> createUser(UsersCompanion user) async {
|
|||
|
|
final inserted = await into(users).insertReturning(user);
|
|||
|
|
return inserted.id;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UPDATE operations
|
|||
|
|
Future<bool> updateUser(User user) {
|
|||
|
|
return update(users).replace(user);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> updateUserPartial(UsersCompanion updates) {
|
|||
|
|
final userId = updates.id.value;
|
|||
|
|
return (update(users)..where((u) => u.id.equals(userId)))
|
|||
|
|
.write(updates);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// JOIN queries
|
|||
|
|
Future<UserWithData?> getUserWithDataById(String id) async {
|
|||
|
|
final query = select(users).join([
|
|||
|
|
leftOuterJoin(userDatas, userDatas.userId.equalsExp(users.id)),
|
|||
|
|
])..where(users.id.equals(id));
|
|||
|
|
|
|||
|
|
final result = await query.getSingleOrNull();
|
|||
|
|
if (result == null) return null;
|
|||
|
|
|
|||
|
|
return UserWithData(
|
|||
|
|
user: result.readTable(users),
|
|||
|
|
userData: result.readTableOrNull(userDatas),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Ключевые особенности:**
|
|||
|
|
- ✅ Type-safe запросы (проверяются компилятором)
|
|||
|
|
- ✅ Абстракция над SQL
|
|||
|
|
- ✅ Автоматическая обработка транзакций
|
|||
|
|
- ✅ Поддержка JOIN операций
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 4️⃣ **Domain Models** (Бизнес-логика)
|
|||
|
|
|
|||
|
|
**Расположение:** `mnemo_cards_common_backend/lib/src/models/`
|
|||
|
|
|
|||
|
|
**Назначение:** Бизнес-модели с логикой приложения
|
|||
|
|
|
|||
|
|
**Пример:**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// mnemo_cards_common_backend/lib/src/models/user/user_model.dart
|
|||
|
|
class UserModel {
|
|||
|
|
final String? id;
|
|||
|
|
final String? name;
|
|||
|
|
final String? email;
|
|||
|
|
final bool admin;
|
|||
|
|
final List<String> purchases;
|
|||
|
|
final UserSettings? userSettings;
|
|||
|
|
|
|||
|
|
// Бизнес-логика
|
|||
|
|
UserDataModel? userData;
|
|||
|
|
CardPackModel? subscriptionModel;
|
|||
|
|
List<CardPackModel> packs = [];
|
|||
|
|
|
|||
|
|
// Методы бизнес-логики
|
|||
|
|
bool get hasActiveSubscription =>
|
|||
|
|
subscriptionModel != null && subscriptionModel!.isActive;
|
|||
|
|
|
|||
|
|
bool canAccessPack(String packId) {
|
|||
|
|
return packs.any((p) => p.id == packId) || admin;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Конвертация Drift → Domain Model:**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// lib/user/user_drift_extension.dart
|
|||
|
|
extension UserToUserModel on User {
|
|||
|
|
Future<UserModel> toUserModel() async {
|
|||
|
|
return UserModel(
|
|||
|
|
id: id,
|
|||
|
|
name: name,
|
|||
|
|
email: email,
|
|||
|
|
admin: admin,
|
|||
|
|
purchases: purchases,
|
|||
|
|
userSettings: userSettings != null
|
|||
|
|
? UserSettings.fromJson(userSettings!)
|
|||
|
|
: null,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Обратная конвертация (Domain → Drift):**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
extension UserModelToUser on UserModel {
|
|||
|
|
UsersCompanion toUsersCompanion() {
|
|||
|
|
return UsersCompanion(
|
|||
|
|
id: id != null ? Value(id!) : const Value.absent(),
|
|||
|
|
name: Value(name),
|
|||
|
|
email: Value(email),
|
|||
|
|
admin: Value(admin),
|
|||
|
|
purchases: Value(purchases),
|
|||
|
|
userSettings: Value(userSettings?.toJson()),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Ключевые особенности:**
|
|||
|
|
- ✅ Содержат бизнес-логику
|
|||
|
|
- ✅ Могут иметь связанные объекты (UserData, Packs, etc.)
|
|||
|
|
- ✅ Независимы от слоя БД
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🔄 Drift Models vs Domain Models - В чем разница?
|
|||
|
|
|
|||
|
|
**Это критически важное различие!** Давайте разберем детально.
|
|||
|
|
|
|||
|
|
### 📊 Сравнение
|
|||
|
|
|
|||
|
|
| Характеристика | Drift Models (из DAO) | Domain Models |
|
|||
|
|
|----------------|----------------------|---------------|
|
|||
|
|
| **Источник** | Генерируются Drift из таблиц БД | Создаются вручную для бизнес-логики |
|
|||
|
|
| **Соответствие БД** | Точное соответствие структуре таблиц | Может отличаться от структуры БД |
|
|||
|
|
| **Связанные объекты** | ❌ Нет (только FK в таблицах) | ✅ Да (загружаются отдельно) |
|
|||
|
|
| **Бизнес-логика** | ❌ Нет | ✅ Да (методы, проверки) |
|
|||
|
|
| **Где используются** | Только в слое БД (DAO) | В бизнес-логике (Managers, API) |
|
|||
|
|
| **Изменения** | Автогенерируются при изменении таблиц | Изменяются вручную |
|
|||
|
|
| **Пример** | `User`, `UserData` (из database.g.dart) | `UserModel`, `UserDataModel` |
|
|||
|
|
|
|||
|
|
### 🔍 Детальное сравнение на примере User
|
|||
|
|
|
|||
|
|
#### Drift Model: `User` (из DAO)
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// lib/database/database.g.dart (автогенерируется Drift)
|
|||
|
|
class User extends DataClass implements Insertable<User> {
|
|||
|
|
final String id;
|
|||
|
|
final String externalUserId;
|
|||
|
|
final String? name;
|
|||
|
|
final String? email;
|
|||
|
|
final bool admin;
|
|||
|
|
final String? userSettings;
|
|||
|
|
final List<String> purchases;
|
|||
|
|
final PgDateTime createdAt;
|
|||
|
|
final PgDateTime updatedAt;
|
|||
|
|
final bool isDeleted;
|
|||
|
|
|
|||
|
|
// ❌ НЕТ связанных объектов
|
|||
|
|
// ❌ НЕТ бизнес-логики
|
|||
|
|
// ❌ Только данные, точно соответствующие таблице users
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Особенности:**
|
|||
|
|
- ✅ Точное соответствие таблице `users` в PostgreSQL
|
|||
|
|
- ✅ Все поля из БД присутствуют
|
|||
|
|
- ❌ Нет связанных объектов (packs, subscriptionModel, userData)
|
|||
|
|
- ❌ Нет методов бизнес-логики
|
|||
|
|
- ❌ Используется только для работы с БД
|
|||
|
|
|
|||
|
|
**Откуда берется:**
|
|||
|
|
```dart
|
|||
|
|
// DAO возвращает Drift Model
|
|||
|
|
final user = await _db.userDao.getUserById(id); // Returns: User (Drift)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Domain Model: `UserModel`
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// mnemo_cards_common_backend/lib/src/models/user/user_model.dart
|
|||
|
|
class UserModel {
|
|||
|
|
String? id;
|
|||
|
|
final String? name;
|
|||
|
|
final String? email;
|
|||
|
|
final bool admin;
|
|||
|
|
final List<String> purchases;
|
|||
|
|
final String? userSettings;
|
|||
|
|
|
|||
|
|
// ✅ Связанные объекты (загружаются отдельно)
|
|||
|
|
final List<CardPackModel> packs = [];
|
|||
|
|
UserDataModel? userData;
|
|||
|
|
UserSubscriptionModel? subscriptionModel;
|
|||
|
|
|
|||
|
|
// ✅ Может содержать бизнес-логику через extensions
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Особенности:**
|
|||
|
|
- ✅ Может отличаться от структуры БД
|
|||
|
|
- ✅ Содержит связанные объекты (загружаются отдельно)
|
|||
|
|
- ✅ Используется для бизнес-логики
|
|||
|
|
- ✅ Может иметь методы (через extensions)
|
|||
|
|
- ✅ Независим от структуры БД
|
|||
|
|
|
|||
|
|
**Откуда берется:**
|
|||
|
|
```dart
|
|||
|
|
// Конвертация Drift → Domain Model
|
|||
|
|
final user = await _db.userDao.getUserById(id); // User (Drift)
|
|||
|
|
final userModel = await user.toUserModel(); // UserModel (Domain)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 🔄 Процесс конвертации
|
|||
|
|
|
|||
|
|
#### Шаг 1: Получение Drift Model из БД
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// DAO возвращает Drift Model
|
|||
|
|
final user = await _db.userDao.getUserById(userId);
|
|||
|
|
// user это: User (Drift Model)
|
|||
|
|
// Содержит только поля из таблицы users
|
|||
|
|
// user.id, user.name, user.email, etc.
|
|||
|
|
// НО: НЕТ user.packs, НЕТ user.subscriptionModel
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Шаг 2: Конвертация в Domain Model
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// lib/user/user_drift_extension.dart
|
|||
|
|
extension UserToUserModel on User {
|
|||
|
|
Future<UserModel> toUserModel() async {
|
|||
|
|
// Создаем Domain Model из Drift Model
|
|||
|
|
final userModel = UserModel(
|
|||
|
|
id: id,
|
|||
|
|
name: name,
|
|||
|
|
email: email,
|
|||
|
|
admin: admin,
|
|||
|
|
purchases: purchases,
|
|||
|
|
userSettings: userSettings,
|
|||
|
|
);
|
|||
|
|
// ⚠️ На этом этапе связанные объекты еще НЕ загружены!
|
|||
|
|
// packs, userData, subscriptionModel будут null/пустыми
|
|||
|
|
|
|||
|
|
return userModel;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Шаг 3: Загрузка связанных объектов (опционально)
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// Если нужны связанные объекты, загружаем их отдельно
|
|||
|
|
final userModel = await user.toUserModel();
|
|||
|
|
|
|||
|
|
// Загружаем паки пользователя
|
|||
|
|
final userPacks = await _db.userDao.getUserPacks(userId);
|
|||
|
|
userModel.packs.addAll(userPacks.map((p) => await p.toCardPackModel()));
|
|||
|
|
|
|||
|
|
// Загружаем подписку
|
|||
|
|
final subscription = await _db.subscriptionDao.getActiveSubscription(userId);
|
|||
|
|
userModel.subscriptionModel = subscription?.toUserSubscriptionModel();
|
|||
|
|
|
|||
|
|
// Загружаем userData
|
|||
|
|
final userData = await _db.userDao.getUserData(userId);
|
|||
|
|
userModel.userData = await userData?.toUserDataModel();
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 💡 Почему нужны оба типа?
|
|||
|
|
|
|||
|
|
#### ❌ Если использовать только Drift Models:
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// ❌ Проблема: Нет связанных объектов
|
|||
|
|
final user = await _db.userDao.getUserById(id);
|
|||
|
|
|
|||
|
|
// ❌ Нельзя проверить доступ к паку
|
|||
|
|
if (user.packs.contains(pack)) { ... } // НЕТ такого поля!
|
|||
|
|
|
|||
|
|
// ❌ Нельзя проверить подписку
|
|||
|
|
if (user.subscriptionModel?.isActive) { ... } // НЕТ такого поля!
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### ✅ С Domain Models:
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// ✅ Получаем Domain Model
|
|||
|
|
final userModel = await _userManager.fetchUser(id);
|
|||
|
|
|
|||
|
|
// ✅ Можем работать со связанными объектами
|
|||
|
|
if (userModel.packs.contains(pack)) { ... } // Работает!
|
|||
|
|
|
|||
|
|
// ✅ Можем проверять подписку
|
|||
|
|
if (userModel.subscriptionModel?.isActive) { ... } // Работает!
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 📋 Сравнение на конкретных примерах
|
|||
|
|
|
|||
|
|
#### Пример 1: Структура данных
|
|||
|
|
|
|||
|
|
**Drift Model (User):**
|
|||
|
|
```dart
|
|||
|
|
class User {
|
|||
|
|
final String id;
|
|||
|
|
final String name;
|
|||
|
|
final String email;
|
|||
|
|
// Только поля из таблицы users
|
|||
|
|
// НЕТ packs
|
|||
|
|
// НЕТ subscriptionModel
|
|||
|
|
// НЕТ userData
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Domain Model (UserModel):**
|
|||
|
|
```dart
|
|||
|
|
class UserModel {
|
|||
|
|
final String? id;
|
|||
|
|
final String? name;
|
|||
|
|
final String? email;
|
|||
|
|
// ✅ Может иметь связанные объекты
|
|||
|
|
final List<CardPackModel> packs = [];
|
|||
|
|
UserSubscriptionModel? subscriptionModel;
|
|||
|
|
UserDataModel? userData;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Пример 2: Использование в коде
|
|||
|
|
|
|||
|
|
**С Drift Model:**
|
|||
|
|
```dart
|
|||
|
|
// ❌ Ограничено только данными из таблицы
|
|||
|
|
final user = await _db.userDao.getUserById(id);
|
|||
|
|
print(user.name); // ✅ Работает
|
|||
|
|
print(user.packs); // ❌ Нет такого поля
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**С Domain Model:**
|
|||
|
|
```dart
|
|||
|
|
// ✅ Можем использовать связанные объекты
|
|||
|
|
final userModel = await _userManager.fetchUser(id);
|
|||
|
|
print(userModel.name); // ✅ Работает
|
|||
|
|
print(userModel.packs.length); // ✅ Работает
|
|||
|
|
print(userModel.subscriptionModel); // ✅ Работает
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Пример 3: Бизнес-логика
|
|||
|
|
|
|||
|
|
**Drift Model - только данные:**
|
|||
|
|
```dart
|
|||
|
|
class User {
|
|||
|
|
// Только поля, никакой логики
|
|||
|
|
final bool admin;
|
|||
|
|
|
|||
|
|
// ❌ Нет методов бизнес-логики
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Domain Model - может иметь логику:**
|
|||
|
|
```dart
|
|||
|
|
class UserModel {
|
|||
|
|
final bool admin;
|
|||
|
|
final List<CardPackModel> packs = [];
|
|||
|
|
|
|||
|
|
// ✅ Можем добавить методы через extensions
|
|||
|
|
bool canAccessPack(String packId) {
|
|||
|
|
return packs.any((p) => p.id == packId) || admin;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 🎯 Когда использовать что?
|
|||
|
|
|
|||
|
|
#### Используйте Drift Models когда:
|
|||
|
|
|
|||
|
|
1. ✅ Работаете напрямую с БД (в DAO)
|
|||
|
|
2. ✅ Делаете простые CRUD операции
|
|||
|
|
3. ✅ Нужны только данные из одной таблицы
|
|||
|
|
4. ✅ Не нужны связанные объекты
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// Пример: Простое обновление
|
|||
|
|
final user = await _db.userDao.getUserById(id);
|
|||
|
|
await _db.userDao.updateUser(user.copyWith(name: 'New Name'));
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Используйте Domain Models когда:
|
|||
|
|
|
|||
|
|
1. ✅ Реализуете бизнес-логику
|
|||
|
|
2. ✅ Нужны связанные объекты
|
|||
|
|
3. ✅ Работаете в Managers или API
|
|||
|
|
4. ✅ Нужны методы и проверки
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// Пример: Бизнес-логика
|
|||
|
|
final userModel = await _userManager.fetchUser(id);
|
|||
|
|
if (userModel.subscriptionModel?.isActive == true) {
|
|||
|
|
// Логика с подпиской
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 🔄 Типичный поток работы
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// 1. DAO возвращает Drift Model (только данные из БД)
|
|||
|
|
final user = await _db.userDao.getUserById(id); // User (Drift)
|
|||
|
|
|
|||
|
|
// 2. Конвертируем в Domain Model (базовая конвертация)
|
|||
|
|
final userModel = await user.toUserModel(); // UserModel (Domain)
|
|||
|
|
|
|||
|
|
// 3. При необходимости загружаем связанные объекты
|
|||
|
|
if (needFullData) {
|
|||
|
|
final packs = await _db.userDao.getUserPacks(id);
|
|||
|
|
userModel.packs.addAll(/* конвертируем packs */);
|
|||
|
|
|
|||
|
|
final subscription = await _db.subscriptionDao.getActiveSubscription(id);
|
|||
|
|
userModel.subscriptionModel = /* конвертируем subscription */;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. Используем Domain Model для бизнес-логики
|
|||
|
|
if (userModel.canAccessPack(packId)) {
|
|||
|
|
// ...
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 5. Конвертируем в DTO для API
|
|||
|
|
final dto = await userModel.toDto();
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 🎓 Ключевые выводы
|
|||
|
|
|
|||
|
|
1. **Drift Models** = прямые маппинги строк БД
|
|||
|
|
- Только данные из таблиц
|
|||
|
|
- Нет связанных объектов
|
|||
|
|
- Используются только в слое БД
|
|||
|
|
|
|||
|
|
2. **Domain Models** = модели для бизнес-логики
|
|||
|
|
- Могут иметь связанные объекты
|
|||
|
|
- Используются в бизнес-логике
|
|||
|
|
- Независимы от структуры БД
|
|||
|
|
|
|||
|
|
3. **Конвертация** всегда идет Drift → Domain
|
|||
|
|
- DAO возвращает Drift Models
|
|||
|
|
- Конвертируем в Domain Models через extensions
|
|||
|
|
- Domain Models используются в остальном коде
|
|||
|
|
|
|||
|
|
4. **Разделение ответственности:**
|
|||
|
|
- **Drift Models** = работа с БД
|
|||
|
|
- **Domain Models** = бизнес-логика
|
|||
|
|
- **DTO** = передача данных через API
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 5️⃣ **DTO** (Data Transfer Objects)
|
|||
|
|
|
|||
|
|
**Расположение:** `mnemo_cards_common/lib/src/dtos/`
|
|||
|
|
|
|||
|
|
**Назначение:** Объекты для передачи данных через API (JSON)
|
|||
|
|
|
|||
|
|
**Пример:**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// mnemo_cards_common/lib/src/dtos/user/user_dto.dart
|
|||
|
|
@JsonSerializable()
|
|||
|
|
@CopyWith()
|
|||
|
|
class UserDto {
|
|||
|
|
String? id;
|
|||
|
|
final String? name;
|
|||
|
|
final String? email;
|
|||
|
|
final bool admin;
|
|||
|
|
final List<String> packs;
|
|||
|
|
final bool? subscription;
|
|||
|
|
final Set<SubscriptionFeatureEnum> subscriptionFeatures;
|
|||
|
|
final UserDataDto? userDataDto;
|
|||
|
|
|
|||
|
|
UserDto({
|
|||
|
|
this.id,
|
|||
|
|
this.name,
|
|||
|
|
this.email,
|
|||
|
|
this.admin = false,
|
|||
|
|
this.packs = const [],
|
|||
|
|
this.subscription = false,
|
|||
|
|
this.subscriptionFeatures = const {},
|
|||
|
|
this.userDataDto,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// JSON сериализация
|
|||
|
|
factory UserDto.fromJson(Map<String, dynamic> json) =>
|
|||
|
|
_$UserDtoFromJson(json);
|
|||
|
|
|
|||
|
|
Map<String, Object?> toJson() => _$UserDtoToJson(this);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Конвертация Domain Model → DTO:**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// lib/user/user_model.dart
|
|||
|
|
extension UserModelExtension on UserModel {
|
|||
|
|
Future<UserDto> toDto() async {
|
|||
|
|
final activeSubscription = subscriptionModel != null
|
|||
|
|
&& subscriptionModel!.isActive;
|
|||
|
|
|
|||
|
|
return UserDto(
|
|||
|
|
id: id,
|
|||
|
|
name: name,
|
|||
|
|
email: email,
|
|||
|
|
admin: admin,
|
|||
|
|
packs: packs.map((e) => e.id?.toString())
|
|||
|
|
.whereNotNull()
|
|||
|
|
.toList(),
|
|||
|
|
subscription: activeSubscription,
|
|||
|
|
subscriptionFeatures: subscriptionModel?.features.toSet() ?? {},
|
|||
|
|
userDataDto: userData?.toDto(),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Ключевые особенности:**
|
|||
|
|
- ✅ Только данные (no logic)
|
|||
|
|
- ✅ JSON сериализуемые
|
|||
|
|
- ✅ Могут отличаться от структуры БД
|
|||
|
|
- ✅ Версионирование API через DTO
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🤔 Зачем нужны Domain Models, если есть DTO?
|
|||
|
|
|
|||
|
|
**Это частый вопрос!** Давайте разберемся на конкретных примерах из кода.
|
|||
|
|
|
|||
|
|
### ❌ Почему нельзя использовать только DTO?
|
|||
|
|
|
|||
|
|
#### Проблема 1: **DTO не содержат связанные объекты**
|
|||
|
|
|
|||
|
|
**UserModel (Domain):**
|
|||
|
|
```dart
|
|||
|
|
class UserModel {
|
|||
|
|
UserSubscriptionModel? subscriptionModel; // ✅ Полный объект подписки
|
|||
|
|
List<CardPackModel> packs = []; // ✅ Полные объекты паков
|
|||
|
|
UserDataModel? userData; // ✅ Полный объект данных
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**UserDto:**
|
|||
|
|
```dart
|
|||
|
|
class UserDto {
|
|||
|
|
bool? subscription; // ❌ Только boolean
|
|||
|
|
Set<SubscriptionFeatureEnum> subscriptionFeatures; // ❌ Только фичи
|
|||
|
|
List<String> packs; // ❌ Только ID строки
|
|||
|
|
UserDataDto? userDataDto; // ❌ Упрощенная версия
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Проблема 2: **Внутри backend нужна бизнес-логика**
|
|||
|
|
|
|||
|
|
**Пример из реального кода (`lib/packs/pack_dto_converter.dart`):**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
Future<CardPackPreviewDto> toCardPackPreviewDto(
|
|||
|
|
CardPackModel model,
|
|||
|
|
UserModel? userModel, // ← Domain Model!
|
|||
|
|
) async {
|
|||
|
|
// Проверка доступа через связанные объекты
|
|||
|
|
bool available = model.users.contains(userModel);
|
|||
|
|
|
|||
|
|
if (userModel != null && !available) {
|
|||
|
|
// Используем subscriptionModel с методами
|
|||
|
|
available = userModel.subscriptionModel?.features
|
|||
|
|
.contains(SubscriptionFeatureEnum.packs) == true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Вычисляем цену через UserModel
|
|||
|
|
String? price = model.price;
|
|||
|
|
if (userModel != null && !available) {
|
|||
|
|
price = await _productsPriceResolver.userPackPrice(userModel, model);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ...
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**С DTO это невозможно:**
|
|||
|
|
```dart
|
|||
|
|
// ❌ Невозможно - DTO не имеет subscriptionModel
|
|||
|
|
available = userDto.subscriptionModel?.features... // Нет такого поля!
|
|||
|
|
|
|||
|
|
// ❌ Невозможно - DTO содержит только ID паков
|
|||
|
|
userDto.packs.contains(model) // packs это List<String>, не объекты!
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Проблема 3: **DTO уже вычислены и упрощены**
|
|||
|
|
|
|||
|
|
**При создании DTO (`lib/user/user_model.dart`):**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
extension UserModelExtension on UserModel {
|
|||
|
|
Future<UserDto> toDto() async {
|
|||
|
|
// Вычисляем активность подписки из Domain Model
|
|||
|
|
final activeSubscription = subscriptionModel != null
|
|||
|
|
&& subscriptionModel!.isActive; // ← Используем Domain Model!
|
|||
|
|
|
|||
|
|
// Извлекаем только ID из паков
|
|||
|
|
final packIds = packs.map((e) => e.id?.toString())
|
|||
|
|
.whereNotNull()
|
|||
|
|
.toList(); // ← Упрощаем список объектов
|
|||
|
|
|
|||
|
|
return UserDto(
|
|||
|
|
subscription: activeSubscription, // Предвычисленное значение
|
|||
|
|
packs: packIds, // Только строки
|
|||
|
|
subscriptionFeatures: subscriptionModel?.features.toSet() ?? {},
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**DTO - это "моментальный снимок" состояния Domain Model для клиента.**
|
|||
|
|
|
|||
|
|
### ✅ Правильное использование
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌─────────────────────────────────────────────────────────┐
|
|||
|
|
│ Backend Business Logic │
|
|||
|
|
│ │
|
|||
|
|
│ ┌──────────────────────────────────────────────────┐ │
|
|||
|
|
│ │ Domain Models (UserModel) │ │
|
|||
|
|
│ │ - Связанные объекты (subscriptionModel, packs) │ │
|
|||
|
|
│ │ - Бизнес-логика │ │
|
|||
|
|
│ │ - Методы и проверки │ │
|
|||
|
|
│ └──────────────────────────────────────────────────┘ │
|
|||
|
|
│ ↕ (toDto()) │
|
|||
|
|
│ ┌──────────────────────────────────────────────────┐ │
|
|||
|
|
│ │ DTO (UserDto) │ │
|
|||
|
|
│ │ - Только примитивы │ │
|
|||
|
|
│ │ - Предвычисленные значения │ │
|
|||
|
|
│ │ - Упрощенная структура │ │
|
|||
|
|
│ └──────────────────────────────────────────────────┘ │
|
|||
|
|
│ ↕ (toJson()) │
|
|||
|
|
└─────────────────────────────────────────────────────────┘
|
|||
|
|
↓
|
|||
|
|
JSON Response
|
|||
|
|
↓
|
|||
|
|
Client App
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 📊 Сравнение на практике
|
|||
|
|
|
|||
|
|
| Задача | Domain Model (UserModel) | DTO (UserDto) |
|
|||
|
|
|--------|-------------------------|---------------|
|
|||
|
|
| **Проверить доступ к паку** | ✅ `user.packs.contains(pack)` | ❌ Нет объектов паков, только ID |
|
|||
|
|
| **Проверить активную подписку** | ✅ `user.subscriptionModel?.isActive` | ❌ Только boolean `subscription` |
|
|||
|
|
| **Получить фичи подписки** | ✅ `user.subscriptionModel?.features` | ✅ `userDto.subscriptionFeatures` |
|
|||
|
|
| **Вычислить цену с учетом скидок** | ✅ Использует `user.userData` | ❌ Нет userData для логики |
|
|||
|
|
| **Отправить через API** | ❌ Слишком сложная структура | ✅ Упрощенная структура |
|
|||
|
|
| **JSON сериализация** | ❌ Может содержать циклические ссылки | ✅ Безопасная сериализация |
|
|||
|
|
|
|||
|
|
### 💡 Реальные примеры из кода
|
|||
|
|
|
|||
|
|
#### Пример 1: Проверка доступа через подписку
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// ✅ С Domain Model - работает
|
|||
|
|
// lib/packs/pack_dto_converter.dart
|
|||
|
|
bool canAccess = userModel.subscriptionModel?.features
|
|||
|
|
.contains(SubscriptionFeatureEnum.packs) == true;
|
|||
|
|
|
|||
|
|
// ❌ С DTO - невозможно (нет subscriptionModel)
|
|||
|
|
// Можно только проверить subscriptionFeatures, но логика сложнее
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Пример 2: Вычисление цены со скидкой
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// ✅ С Domain Model - используем userData для скидок
|
|||
|
|
// lib/packs/products_price_resolver.dart
|
|||
|
|
Future<String> userPackPrice(
|
|||
|
|
UserModel userModel, // Domain Model
|
|||
|
|
CardPackModel model,
|
|||
|
|
) async {
|
|||
|
|
if (userModel.id != null) {
|
|||
|
|
final userData = await _db.userDao.getUserData(userModel.id!);
|
|||
|
|
final maxDiscount = await _discountsManager.getProductDiscount(
|
|||
|
|
model,
|
|||
|
|
userData, // Используем UserDataModel для логики
|
|||
|
|
);
|
|||
|
|
// ... вычисление скидки
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ❌ С DTO - нет доступа к userData для вычислений
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Пример 3: Конвертация в DTO использует Domain Model
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// Этот код находится ВНУТРИ backend и использует Domain Model
|
|||
|
|
extension UserModelExtension on UserModel {
|
|||
|
|
Future<UserDto> toDto() async {
|
|||
|
|
// Используем Domain Model для вычислений
|
|||
|
|
final activeSubscription = subscriptionModel != null
|
|||
|
|
&& subscriptionModel!.isActive;
|
|||
|
|
|
|||
|
|
// Извлекаем данные из связанных объектов
|
|||
|
|
final packIds = packs.map((e) => e.id?.toString())
|
|||
|
|
.whereNotNull()
|
|||
|
|
.toList();
|
|||
|
|
|
|||
|
|
// Создаем упрощенный DTO для клиента
|
|||
|
|
return UserDto(
|
|||
|
|
subscription: activeSubscription, // Предвычисленное
|
|||
|
|
packs: packIds, // Упрощенное
|
|||
|
|
subscriptionFeatures: subscriptionModel?.features.toSet() ?? {},
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 🎯 Вывод
|
|||
|
|
|
|||
|
|
**Domain Models нужны для:**
|
|||
|
|
|
|||
|
|
1. ✅ **Бизнес-логики внутри backend**
|
|||
|
|
- Проверки доступа
|
|||
|
|
- Вычисления цен
|
|||
|
|
- Валидация данных
|
|||
|
|
- Работа со связанными объектами
|
|||
|
|
|
|||
|
|
2. ✅ **Работы со сложными структурами**
|
|||
|
|
- Связанные объекты (subscriptionModel, packs, userData)
|
|||
|
|
- Методы и computed properties
|
|||
|
|
- Циклические связи
|
|||
|
|
|
|||
|
|
3. ✅ **Независимости от API**
|
|||
|
|
- Domain Models не зависят от формата API
|
|||
|
|
- Можно менять API без изменения бизнес-логики
|
|||
|
|
- Переиспользование в разных контекстах
|
|||
|
|
|
|||
|
|
**DTO нужны для:**
|
|||
|
|
|
|||
|
|
1. ✅ **Передачи данных через API**
|
|||
|
|
- Упрощенная структура
|
|||
|
|
- Только примитивы
|
|||
|
|
- Без циклических ссылок
|
|||
|
|
|
|||
|
|
2. ✅ **Версионирования API**
|
|||
|
|
- Можно менять DTO без изменения Domain Models
|
|||
|
|
- Обратная совместимость
|
|||
|
|
|
|||
|
|
3. ✅ **Оптимизации**
|
|||
|
|
- Отправляем только нужные данные
|
|||
|
|
- Предвычисленные значения
|
|||
|
|
|
|||
|
|
### 🔄 Типичный поток
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// 1. Получаем Domain Model из БД
|
|||
|
|
final user = await _userManager.fetchUser(userId); // UserModel
|
|||
|
|
|
|||
|
|
// 2. Используем Domain Model для бизнес-логики
|
|||
|
|
if (user.subscriptionModel?.isActive == true) {
|
|||
|
|
// Логика с использованием subscriptionModel
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. Конвертируем в DTO для API
|
|||
|
|
final dto = await user.toDto(); // UserDto
|
|||
|
|
|
|||
|
|
// 4. Возвращаем JSON клиенту
|
|||
|
|
return Response.ok(dto.toJson());
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Внутри backend работаем с Domain Models, клиенту отправляем DTO!** 🎯
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🤔 Зачем нужны Domain Models, если есть DTO?
|
|||
|
|
|
|||
|
|
**Это частый вопрос!** Давайте разберемся на конкретных примерах.
|
|||
|
|
|
|||
|
|
### ❌ Почему нельзя использовать только DTO?
|
|||
|
|
|
|||
|
|
#### Проблема 1: **DTO не содержат связанные объекты**
|
|||
|
|
|
|||
|
|
**UserModel (Domain):**
|
|||
|
|
```dart
|
|||
|
|
class UserModel {
|
|||
|
|
UserSubscriptionModel? subscriptionModel; // ✅ Полный объект подписки
|
|||
|
|
List<CardPackModel> packs = []; // ✅ Полные объекты паков
|
|||
|
|
UserDataModel? userData; // ✅ Полный объект данных
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**UserDto:**
|
|||
|
|
```dart
|
|||
|
|
class UserDto {
|
|||
|
|
bool? subscription; // ❌ Только boolean
|
|||
|
|
Set<SubscriptionFeatureEnum> subscriptionFeatures; // ❌ Только фичи
|
|||
|
|
List<String> packs; // ❌ Только ID строки
|
|||
|
|
UserDataDto? userDataDto; // ❌ Упрощенная версия
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Проблема 2: **Внутри backend нужна бизнес-логика**
|
|||
|
|
|
|||
|
|
**Пример из реального кода:**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// lib/packs/pack_dto_converter.dart
|
|||
|
|
Future<CardPackPreviewDto> toCardPackPreviewDto(
|
|||
|
|
CardPackModel model,
|
|||
|
|
UserModel? userModel, // ← Domain Model!
|
|||
|
|
) async {
|
|||
|
|
// Проверка доступа через связанные объекты
|
|||
|
|
bool available = model.users.contains(userModel);
|
|||
|
|
|
|||
|
|
if (userModel != null && !available) {
|
|||
|
|
// Используем subscriptionModel с методами
|
|||
|
|
available = userModel.subscriptionModel?.features
|
|||
|
|
.contains(SubscriptionFeatureEnum.packs) == true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Вычисляем цену через UserModel
|
|||
|
|
String? price = model.price;
|
|||
|
|
if (userModel != null && !available) {
|
|||
|
|
price = await _productsPriceResolver.userPackPrice(userModel, model);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ...
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**С DTO это невозможно:**
|
|||
|
|
```dart
|
|||
|
|
// ❌ Невозможно - DTO не имеет subscriptionModel
|
|||
|
|
available = userDto.subscriptionModel?.features... // Нет такого поля!
|
|||
|
|
|
|||
|
|
// ❌ Невозможно - DTO содержит только ID паков
|
|||
|
|
userDto.packs.contains(model) // packs это List<String>, не объекты!
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Проблема 3: **DTO уже вычислены и упрощены**
|
|||
|
|
|
|||
|
|
**При создании DTO:**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
extension UserModelExtension on UserModel {
|
|||
|
|
Future<UserDto> toDto() async {
|
|||
|
|
// Вычисляем активность подписки
|
|||
|
|
final activeSubscription = subscriptionModel != null
|
|||
|
|
&& subscriptionModel!.isActive; // ← Используем Domain Model!
|
|||
|
|
|
|||
|
|
// Извлекаем только ID из паков
|
|||
|
|
final packIds = packs.map((e) => e.id?.toString())
|
|||
|
|
.whereNotNull()
|
|||
|
|
.toList(); // ← Упрощаем список объектов
|
|||
|
|
|
|||
|
|
return UserDto(
|
|||
|
|
subscription: activeSubscription, // Предвычисленное значение
|
|||
|
|
packs: packIds, // Только строки
|
|||
|
|
subscriptionFeatures: subscriptionModel?.features.toSet() ?? {},
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**DTO - это "моментальный снимок" состояния Domain Model для клиента.**
|
|||
|
|
|
|||
|
|
### ✅ Правильное использование
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌─────────────────────────────────────────────────────────┐
|
|||
|
|
│ Backend Business Logic │
|
|||
|
|
│ │
|
|||
|
|
│ ┌──────────────────────────────────────────────────┐ │
|
|||
|
|
│ │ Domain Models (UserModel) │ │
|
|||
|
|
│ │ - Связанные объекты (subscriptionModel, packs) │ │
|
|||
|
|
│ │ - Бизнес-логика │ │
|
|||
|
|
│ │ - Методы и проверки │ │
|
|||
|
|
│ └──────────────────────────────────────────────────┘ │
|
|||
|
|
│ ↕ (toDto()) │
|
|||
|
|
│ ┌──────────────────────────────────────────────────┐ │
|
|||
|
|
│ │ DTO (UserDto) │ │
|
|||
|
|
│ │ - Только примитивы │ │
|
|||
|
|
│ │ - Предвычисленные значения │ │
|
|||
|
|
│ │ - Упрощенная структура │ │
|
|||
|
|
│ └──────────────────────────────────────────────────┘ │
|
|||
|
|
│ ↕ (toJson()) │
|
|||
|
|
└─────────────────────────────────────────────────────────┘
|
|||
|
|
↓
|
|||
|
|
JSON Response
|
|||
|
|
↓
|
|||
|
|
Client App
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 📊 Сравнение на практике
|
|||
|
|
|
|||
|
|
| Задача | Domain Model (UserModel) | DTO (UserDto) |
|
|||
|
|
|--------|-------------------------|---------------|
|
|||
|
|
| **Проверить доступ к паку** | ✅ `user.packs.contains(pack)` | ❌ Нет объектов паков, только ID |
|
|||
|
|
| **Проверить активную подписку** | ✅ `user.subscriptionModel?.isActive` | ❌ Только boolean `subscription` |
|
|||
|
|
| **Получить фичи подписки** | ✅ `user.subscriptionModel?.features` | ✅ `userDto.subscriptionFeatures` |
|
|||
|
|
| **Вычислить цену с учетом скидок** | ✅ Использует `user.userData` | ❌ Нет userData для логики |
|
|||
|
|
| **Отправить через API** | ❌ Слишком сложная структура | ✅ Упрощенная структура |
|
|||
|
|
| **JSON сериализация** | ❌ Может содержать циклические ссылки | ✅ Безопасная сериализация |
|
|||
|
|
|
|||
|
|
### 💡 Реальные примеры из кода
|
|||
|
|
|
|||
|
|
#### Пример 1: Проверка доступа через подписку
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// ✅ С Domain Model - работает
|
|||
|
|
bool canAccess = userModel.subscriptionModel?.features
|
|||
|
|
.contains(SubscriptionFeatureEnum.packs) == true;
|
|||
|
|
|
|||
|
|
// ❌ С DTO - невозможно (нет subscriptionModel)
|
|||
|
|
// Можно только проверить subscriptionFeatures, но логика сложнее
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Пример 2: Вычисление цены со скидкой
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// ✅ С Domain Model - используем userData для скидок
|
|||
|
|
Future<String> userPackPrice(
|
|||
|
|
UserModel userModel, // Domain Model
|
|||
|
|
CardPackModel model,
|
|||
|
|
) async {
|
|||
|
|
if (userModel.id != null) {
|
|||
|
|
final userData = await _db.userDao.getUserData(userModel.id!);
|
|||
|
|
final maxDiscount = await _discountsManager.getProductDiscount(
|
|||
|
|
model,
|
|||
|
|
userData, // Используем UserDataModel для логики
|
|||
|
|
);
|
|||
|
|
// ... вычисление скидки
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ❌ С DTO - нет доступа к userData для вычислений
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Пример 3: Конвертация в DTO использует Domain Model
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// Этот код находится ВНУТРИ backend и использует Domain Model
|
|||
|
|
extension UserModelExtension on UserModel {
|
|||
|
|
Future<UserDto> toDto() async {
|
|||
|
|
// Используем Domain Model для вычислений
|
|||
|
|
final activeSubscription = subscriptionModel != null
|
|||
|
|
&& subscriptionModel!.isActive;
|
|||
|
|
|
|||
|
|
// Извлекаем данные из связанных объектов
|
|||
|
|
final packIds = packs.map((e) => e.id?.toString())
|
|||
|
|
.whereNotNull()
|
|||
|
|
.toList();
|
|||
|
|
|
|||
|
|
// Создаем упрощенный DTO для клиента
|
|||
|
|
return UserDto(
|
|||
|
|
subscription: activeSubscription, // Предвычисленное
|
|||
|
|
packs: packIds, // Упрощенное
|
|||
|
|
subscriptionFeatures: subscriptionModel?.features.toSet() ?? {},
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 🎯 Вывод
|
|||
|
|
|
|||
|
|
**Domain Models нужны для:**
|
|||
|
|
|
|||
|
|
1. ✅ **Бизнес-логики внутри backend**
|
|||
|
|
- Проверки доступа
|
|||
|
|
- Вычисления цен
|
|||
|
|
- Валидация данных
|
|||
|
|
- Работа со связанными объектами
|
|||
|
|
|
|||
|
|
2. ✅ **Работы со сложными структурами**
|
|||
|
|
- Связанные объекты (subscriptionModel, packs, userData)
|
|||
|
|
- Методы и computed properties
|
|||
|
|
- Циклические связи
|
|||
|
|
|
|||
|
|
3. ✅ **Независимости от API**
|
|||
|
|
- Domain Models не зависят от формата API
|
|||
|
|
- Можно менять API без изменения бизнес-логики
|
|||
|
|
- Переиспользование в разных контекстах
|
|||
|
|
|
|||
|
|
**DTO нужны для:**
|
|||
|
|
|
|||
|
|
1. ✅ **Передачи данных через API**
|
|||
|
|
- Упрощенная структура
|
|||
|
|
- Только примитивы
|
|||
|
|
- Без циклических ссылок
|
|||
|
|
|
|||
|
|
2. ✅ **Версионирования API**
|
|||
|
|
- Можно менять DTO без изменения Domain Models
|
|||
|
|
- Обратная совместимость
|
|||
|
|
|
|||
|
|
3. ✅ **Оптимизации**
|
|||
|
|
- Отправляем только нужные данные
|
|||
|
|
- Предвычисленные значения
|
|||
|
|
|
|||
|
|
### 🔄 Типичный поток
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// 1. Получаем Domain Model из БД
|
|||
|
|
final user = await _userManager.fetchUser(userId); // UserModel
|
|||
|
|
|
|||
|
|
// 2. Используем Domain Model для бизнес-логики
|
|||
|
|
if (user.subscriptionModel?.isActive == true) {
|
|||
|
|
// Логика с использованием subscriptionModel
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. Конвертируем в DTO для API
|
|||
|
|
final dto = await user.toDto(); // UserDto
|
|||
|
|
|
|||
|
|
// 4. Возвращаем JSON клиенту
|
|||
|
|
return Response.ok(dto.toJson());
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Внутри backend работаем с Domain Models, клиенту отправляем DTO!** 🎯
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 6️⃣ **API Layer** (HTTP Endpoints)
|
|||
|
|
|
|||
|
|
**Расположение:** `lib/api/v2/*_api_v2.dart`
|
|||
|
|
|
|||
|
|
**Назначение:** HTTP endpoints, которые возвращают JSON
|
|||
|
|
|
|||
|
|
**Пример:**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// lib/api/v2/users_api_v2.dart
|
|||
|
|
@lazySingleton
|
|||
|
|
class UsersApiV2 {
|
|||
|
|
final UserManager _userManager;
|
|||
|
|
final AppDatabase _db;
|
|||
|
|
|
|||
|
|
@Route.get('/users/me')
|
|||
|
|
Future<Response> getCurrentUser(Request request) async {
|
|||
|
|
final user = request.user; // UserModel (domain)
|
|||
|
|
if (user == null) {
|
|||
|
|
return _unauthorized();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Конвертация: UserModel → UserDto
|
|||
|
|
final dto = await user.toDto();
|
|||
|
|
|
|||
|
|
// Конвертация: UserDto → JSON
|
|||
|
|
return _json(dto.toJson());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Route('PATCH', '/users/me')
|
|||
|
|
Future<Response> updateCurrentUser(Request request) async {
|
|||
|
|
final user = request.user;
|
|||
|
|
final body = jsonDecode(await request.readAsString());
|
|||
|
|
|
|||
|
|
// Обновление через DAO
|
|||
|
|
await _db.userDao.updateUserPartial(
|
|||
|
|
UsersCompanion(
|
|||
|
|
id: Value(user.id!),
|
|||
|
|
name: body['name'] != null
|
|||
|
|
? Value(body['name'])
|
|||
|
|
: const Value.absent(),
|
|||
|
|
email: body['email'] != null
|
|||
|
|
? Value(body['email'])
|
|||
|
|
: const Value.absent(),
|
|||
|
|
updatedAt: Value(PgDateTime(DateTime.now())),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Возврат обновленных данных
|
|||
|
|
final refreshedUser = await _userManager.fetchUser(user.id!);
|
|||
|
|
final dto = await refreshedUser.toDto();
|
|||
|
|
return _json(dto.toJson());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🔄 Полный цикл: Создание пользователя
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// 1. HTTP Request
|
|||
|
|
POST /api/v2/auth/register
|
|||
|
|
{ "email": "test@example.com", "password": "secret123" }
|
|||
|
|
|
|||
|
|
// 2. API Handler (auth_api_v2.dart)
|
|||
|
|
final userModel = await _userManager.createUser(email, password);
|
|||
|
|
|
|||
|
|
// 3. UserManager (user_manager.dart)
|
|||
|
|
Future<UserModel> createUser(String email, String password) async {
|
|||
|
|
// Создание Drift Companion для insert
|
|||
|
|
final companion = UsersCompanion.insert(
|
|||
|
|
externalUserId: generateId(),
|
|||
|
|
email: email,
|
|||
|
|
// ...
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 4. DAO Insert (user_dao.dart)
|
|||
|
|
final userId = await _db.userDao.createUser(companion);
|
|||
|
|
|
|||
|
|
// 5. Получение созданного User (Drift model)
|
|||
|
|
final user = await _db.userDao.getUserById(userId);
|
|||
|
|
|
|||
|
|
// 6. Конвертация User → UserModel
|
|||
|
|
return await user!.toUserModel();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 7. API Response: UserModel → UserDto → JSON
|
|||
|
|
final dto = await userModel.toDto();
|
|||
|
|
return Response.ok(dto.toJson());
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Что происходит в БД:**
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- Drift автоматически генерирует SQL:
|
|||
|
|
INSERT INTO users (id, external_user_id, email, created_at, updated_at)
|
|||
|
|
VALUES (
|
|||
|
|
gen_random_uuid()::text, -- auto-generated UUID
|
|||
|
|
'generated-id-123',
|
|||
|
|
'test@example.com',
|
|||
|
|
NOW(),
|
|||
|
|
NOW()
|
|||
|
|
)
|
|||
|
|
RETURNING *;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 📋 Сравнительная таблица
|
|||
|
|
|
|||
|
|
| Компонент | Расположение | Тип данных | Назначение | Жизненный цикл |
|
|||
|
|
|-----------|--------------|------------|------------|----------------|
|
|||
|
|
| **Tables** | `lib/database/tables/` | Class extends `Table` | Определение схемы БД | Статичен (изменяется миграциями) |
|
|||
|
|
| **Models (Drift)** | `lib/database/database.g.dart` | Immutable `DataClass` | Данные из БД | Генерируется Drift |
|
|||
|
|
| **DAO** | `lib/database/daos/` | Methods | CRUD операции | Статичен |
|
|||
|
|
| **Domain Models** | `mnemo_cards_common_backend/models/` | Classes with logic | Бизнес-логика | Может содержать состояние |
|
|||
|
|
| **DTO** | `mnemo_cards_common/dtos/` | JSON-serializable | API responses | Версионируется |
|
|||
|
|
| **API** | `lib/api/v2/` | HTTP handlers | Endpoints | Обрабатывает запросы |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🔑 Ключевые принципы
|
|||
|
|
|
|||
|
|
### 1. **Разделение ответственности**
|
|||
|
|
|
|||
|
|
- **Tables** → Определяют структуру БД
|
|||
|
|
- **Drift Models** → Представляют данные из БД
|
|||
|
|
- **DAO** → Абстракция над SQL запросами
|
|||
|
|
- **Domain Models** → Содержат бизнес-логику
|
|||
|
|
- **DTO** → Для передачи данных через API
|
|||
|
|
- **API** → HTTP endpoints
|
|||
|
|
|
|||
|
|
### 2. **Направление конвертации**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
Drift Model (User)
|
|||
|
|
→ Domain Model (UserModel) [через extension]
|
|||
|
|
→ DTO (UserDto) [через toDto()]
|
|||
|
|
→ JSON [через toJson()]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3. **Type Safety**
|
|||
|
|
|
|||
|
|
Все преобразования типизированы на уровне компилятора:
|
|||
|
|
- ✅ Drift проверяет запросы к БД
|
|||
|
|
- ✅ Dart проверяет конвертации
|
|||
|
|
- ✅ JSON сериализация с кодогенерацией
|
|||
|
|
|
|||
|
|
### 4. **Независимость слоев**
|
|||
|
|
|
|||
|
|
- **Domain Models** не зависят от БД
|
|||
|
|
- **DTO** не зависят от Domain Models (но конвертируются из них)
|
|||
|
|
- **API** использует Domain Models и DTO
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 💡 Практические примеры
|
|||
|
|
|
|||
|
|
### Пример 1: Получение пользователя с данными
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// DAO возвращает User (Drift) + UserData (Drift)
|
|||
|
|
final userWithData = await _db.userDao.getUserWithDataById(userId);
|
|||
|
|
|
|||
|
|
// Конвертация в Domain Models
|
|||
|
|
final userModel = await userWithData.user.toUserModel();
|
|||
|
|
final userDataModel = userWithData.userData?.toUserDataModel();
|
|||
|
|
|
|||
|
|
// Связывание
|
|||
|
|
userModel.userData = userDataModel;
|
|||
|
|
|
|||
|
|
// Конвертация в DTO для API
|
|||
|
|
final dto = await userModel.toDto(); // Включает userDataDto
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Пример 2: Создание записи с транзакцией
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// Использование Companion для insert
|
|||
|
|
await _db.transaction(() async {
|
|||
|
|
// Создать пользователя
|
|||
|
|
final userCompanion = UsersCompanion.insert(
|
|||
|
|
externalUserId: 'telegram_123',
|
|||
|
|
name: 'John Doe',
|
|||
|
|
);
|
|||
|
|
final userId = await _db.userDao.createUser(userCompanion);
|
|||
|
|
|
|||
|
|
// Создать UserData
|
|||
|
|
final userDataCompanion = UserDatasCompanion.insert(
|
|||
|
|
userId: userId,
|
|||
|
|
totalStudyTimeMinutes: 0,
|
|||
|
|
);
|
|||
|
|
await _db.userDao.createUserData(userDataCompanion);
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Пример 3: Обновление частичных данных
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// Использование Companion для partial update
|
|||
|
|
await _db.userDao.updateUserPartial(
|
|||
|
|
UsersCompanion(
|
|||
|
|
id: Value(userId),
|
|||
|
|
name: Value('New Name'), // Обновить только name
|
|||
|
|
// email остается без изменений (Value.absent())
|
|||
|
|
updatedAt: Value(PgDateTime(DateTime.now())),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🎯 Итоги
|
|||
|
|
|
|||
|
|
### Связи:
|
|||
|
|
|
|||
|
|
1. **Tables** → определяют структуру БД
|
|||
|
|
2. **Drift Models** → автогенерируются из Tables
|
|||
|
|
3. **DAO** → используют Drift Models для работы с БД
|
|||
|
|
4. **Domain Models** → конвертируются из Drift Models через extensions
|
|||
|
|
5. **DTO** → конвертируются из Domain Models через toDto()
|
|||
|
|
6. **API** → используют Domain Models и возвращают DTO как JSON
|
|||
|
|
|
|||
|
|
### Преимущества этой архитектуры:
|
|||
|
|
|
|||
|
|
✅ **Type Safety** - все проверяется компилятором
|
|||
|
|
✅ **Separation of Concerns** - каждый слой решает свою задачу
|
|||
|
|
✅ **Maintainability** - легко изменять отдельные слои
|
|||
|
|
✅ **Testability** - можно мокировать любой слой
|
|||
|
|
✅ **Scalability** - легко добавлять новые сущности
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
**Этот документ должен помочь понять, как данные проходят через все слои приложения!** 🚀
|