57 KiB
🏗️ Архитектура: Связь между Таблицами, Моделями, 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
// 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
}
Пример: Получение данных из БД
// 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
}
Пример: Создание нового пользователя
// 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
Пример:
// 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 классы данных для работы с записями из БД
Пример:
// Автогенерируется 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):
// Для создания/обновления записей
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
Пример:
// 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/
Назначение: Бизнес-модели с логикой приложения
Пример:
// 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:
// 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):
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)
// 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)
- ❌ Нет методов бизнес-логики
- ❌ Используется только для работы с БД
Откуда берется:
// DAO возвращает Drift Model
final user = await _db.userDao.getUserById(id); // Returns: User (Drift)
Domain Model: UserModel
// 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)
- ✅ Независим от структуры БД
Откуда берется:
// Конвертация Drift → Domain Model
final user = await _db.userDao.getUserById(id); // User (Drift)
final userModel = await user.toUserModel(); // UserModel (Domain)
🔄 Процесс конвертации
Шаг 1: Получение Drift Model из БД
// 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
// 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: Загрузка связанных объектов (опционально)
// Если нужны связанные объекты, загружаем их отдельно
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:
// ❌ Проблема: Нет связанных объектов
final user = await _db.userDao.getUserById(id);
// ❌ Нельзя проверить доступ к паку
if (user.packs.contains(pack)) { ... } // НЕТ такого поля!
// ❌ Нельзя проверить подписку
if (user.subscriptionModel?.isActive) { ... } // НЕТ такого поля!
✅ С Domain Models:
// ✅ Получаем Domain Model
final userModel = await _userManager.fetchUser(id);
// ✅ Можем работать со связанными объектами
if (userModel.packs.contains(pack)) { ... } // Работает!
// ✅ Можем проверять подписку
if (userModel.subscriptionModel?.isActive) { ... } // Работает!
📋 Сравнение на конкретных примерах
Пример 1: Структура данных
Drift Model (User):
class User {
final String id;
final String name;
final String email;
// Только поля из таблицы users
// НЕТ packs
// НЕТ subscriptionModel
// НЕТ userData
}
Domain Model (UserModel):
class UserModel {
final String? id;
final String? name;
final String? email;
// ✅ Может иметь связанные объекты
final List<CardPackModel> packs = [];
UserSubscriptionModel? subscriptionModel;
UserDataModel? userData;
}
Пример 2: Использование в коде
С Drift Model:
// ❌ Ограничено только данными из таблицы
final user = await _db.userDao.getUserById(id);
print(user.name); // ✅ Работает
print(user.packs); // ❌ Нет такого поля
С Domain Model:
// ✅ Можем использовать связанные объекты
final userModel = await _userManager.fetchUser(id);
print(userModel.name); // ✅ Работает
print(userModel.packs.length); // ✅ Работает
print(userModel.subscriptionModel); // ✅ Работает
Пример 3: Бизнес-логика
Drift Model - только данные:
class User {
// Только поля, никакой логики
final bool admin;
// ❌ Нет методов бизнес-логики
}
Domain Model - может иметь логику:
class UserModel {
final bool admin;
final List<CardPackModel> packs = [];
// ✅ Можем добавить методы через extensions
bool canAccessPack(String packId) {
return packs.any((p) => p.id == packId) || admin;
}
}
🎯 Когда использовать что?
Используйте Drift Models когда:
- ✅ Работаете напрямую с БД (в DAO)
- ✅ Делаете простые CRUD операции
- ✅ Нужны только данные из одной таблицы
- ✅ Не нужны связанные объекты
// Пример: Простое обновление
final user = await _db.userDao.getUserById(id);
await _db.userDao.updateUser(user.copyWith(name: 'New Name'));
Используйте Domain Models когда:
- ✅ Реализуете бизнес-логику
- ✅ Нужны связанные объекты
- ✅ Работаете в Managers или API
- ✅ Нужны методы и проверки
// Пример: Бизнес-логика
final userModel = await _userManager.fetchUser(id);
if (userModel.subscriptionModel?.isActive == true) {
// Логика с подпиской
}
🔄 Типичный поток работы
// 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();
🎓 Ключевые выводы
-
Drift Models = прямые маппинги строк БД
- Только данные из таблиц
- Нет связанных объектов
- Используются только в слое БД
-
Domain Models = модели для бизнес-логики
- Могут иметь связанные объекты
- Используются в бизнес-логике
- Независимы от структуры БД
-
Конвертация всегда идет Drift → Domain
- DAO возвращает Drift Models
- Конвертируем в Domain Models через extensions
- Domain Models используются в остальном коде
-
Разделение ответственности:
- Drift Models = работа с БД
- Domain Models = бизнес-логика
- DTO = передача данных через API
5️⃣ DTO (Data Transfer Objects)
Расположение: mnemo_cards_common/lib/src/dtos/
Назначение: Объекты для передачи данных через API (JSON)
Пример:
// 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:
// 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):
class UserModel {
UserSubscriptionModel? subscriptionModel; // ✅ Полный объект подписки
List<CardPackModel> packs = []; // ✅ Полные объекты паков
UserDataModel? userData; // ✅ Полный объект данных
}
UserDto:
class UserDto {
bool? subscription; // ❌ Только boolean
Set<SubscriptionFeatureEnum> subscriptionFeatures; // ❌ Только фичи
List<String> packs; // ❌ Только ID строки
UserDataDto? userDataDto; // ❌ Упрощенная версия
}
Проблема 2: Внутри backend нужна бизнес-логика
Пример из реального кода (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 это невозможно:
// ❌ Невозможно - DTO не имеет subscriptionModel
available = userDto.subscriptionModel?.features... // Нет такого поля!
// ❌ Невозможно - DTO содержит только ID паков
userDto.packs.contains(model) // packs это List<String>, не объекты!
Проблема 3: DTO уже вычислены и упрощены
При создании DTO (lib/user/user_model.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: Проверка доступа через подписку
// ✅ С Domain Model - работает
// lib/packs/pack_dto_converter.dart
bool canAccess = userModel.subscriptionModel?.features
.contains(SubscriptionFeatureEnum.packs) == true;
// ❌ С DTO - невозможно (нет subscriptionModel)
// Можно только проверить subscriptionFeatures, но логика сложнее
Пример 2: Вычисление цены со скидкой
// ✅ С 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
// Этот код находится ВНУТРИ 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 нужны для:
-
✅ Бизнес-логики внутри backend
- Проверки доступа
- Вычисления цен
- Валидация данных
- Работа со связанными объектами
-
✅ Работы со сложными структурами
- Связанные объекты (subscriptionModel, packs, userData)
- Методы и computed properties
- Циклические связи
-
✅ Независимости от API
- Domain Models не зависят от формата API
- Можно менять API без изменения бизнес-логики
- Переиспользование в разных контекстах
DTO нужны для:
-
✅ Передачи данных через API
- Упрощенная структура
- Только примитивы
- Без циклических ссылок
-
✅ Версионирования API
- Можно менять DTO без изменения Domain Models
- Обратная совместимость
-
✅ Оптимизации
- Отправляем только нужные данные
- Предвычисленные значения
🔄 Типичный поток
// 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):
class UserModel {
UserSubscriptionModel? subscriptionModel; // ✅ Полный объект подписки
List<CardPackModel> packs = []; // ✅ Полные объекты паков
UserDataModel? userData; // ✅ Полный объект данных
}
UserDto:
class UserDto {
bool? subscription; // ❌ Только boolean
Set<SubscriptionFeatureEnum> subscriptionFeatures; // ❌ Только фичи
List<String> packs; // ❌ Только ID строки
UserDataDto? userDataDto; // ❌ Упрощенная версия
}
Проблема 2: Внутри backend нужна бизнес-логика
Пример из реального кода:
// 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 это невозможно:
// ❌ Невозможно - DTO не имеет subscriptionModel
available = userDto.subscriptionModel?.features... // Нет такого поля!
// ❌ Невозможно - DTO содержит только ID паков
userDto.packs.contains(model) // packs это List<String>, не объекты!
Проблема 3: DTO уже вычислены и упрощены
При создании DTO:
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: Проверка доступа через подписку
// ✅ С Domain Model - работает
bool canAccess = userModel.subscriptionModel?.features
.contains(SubscriptionFeatureEnum.packs) == true;
// ❌ С DTO - невозможно (нет subscriptionModel)
// Можно только проверить subscriptionFeatures, но логика сложнее
Пример 2: Вычисление цены со скидкой
// ✅ С 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
// Этот код находится ВНУТРИ 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 нужны для:
-
✅ Бизнес-логики внутри backend
- Проверки доступа
- Вычисления цен
- Валидация данных
- Работа со связанными объектами
-
✅ Работы со сложными структурами
- Связанные объекты (subscriptionModel, packs, userData)
- Методы и computed properties
- Циклические связи
-
✅ Независимости от API
- Domain Models не зависят от формата API
- Можно менять API без изменения бизнес-логики
- Переиспользование в разных контекстах
DTO нужны для:
-
✅ Передачи данных через API
- Упрощенная структура
- Только примитивы
- Без циклических ссылок
-
✅ Версионирования API
- Можно менять DTO без изменения Domain Models
- Обратная совместимость
-
✅ Оптимизации
- Отправляем только нужные данные
- Предвычисленные значения
🔄 Типичный поток
// 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
Пример:
// 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());
}
}
🔄 Полный цикл: Создание пользователя
// 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());
Что происходит в БД:
-- 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: Получение пользователя с данными
// 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: Создание записи с транзакцией
// Использование 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: Обновление частичных данных
// Использование Companion для partial update
await _db.userDao.updateUserPartial(
UsersCompanion(
id: Value(userId),
name: Value('New Name'), // Обновить только name
// email остается без изменений (Value.absent())
updatedAt: Value(PgDateTime(DateTime.now())),
),
);
🎯 Итоги
Связи:
- Tables → определяют структуру БД
- Drift Models → автогенерируются из Tables
- DAO → используют Drift Models для работы с БД
- Domain Models → конвертируются из Drift Models через extensions
- DTO → конвертируются из Domain Models через toDto()
- API → используют Domain Models и возвращают DTO как JSON
Преимущества этой архитектуры:
✅ Type Safety - все проверяется компилятором
✅ Separation of Concerns - каждый слой решает свою задачу
✅ Maintainability - легко изменять отдельные слои
✅ Testability - можно мокировать любой слой
✅ Scalability - легко добавлять новые сущности
Этот документ должен помочь понять, как данные проходят через все слои приложения! 🚀