mnemo_cards/mnemo_cards_backend/docs/ARCHITECTURE_MODELS.md
2026-01-03 16:14:27 +03:00

57 KiB
Raw Permalink Blame History

🏗️ Архитектура: Связь между Таблицами, Моделями, 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 когда:

  1. Работаете напрямую с БД (в DAO)
  2. Делаете простые CRUD операции
  3. Нужны только данные из одной таблицы
  4. Не нужны связанные объекты
// Пример: Простое обновление
final user = await _db.userDao.getUserById(id);
await _db.userDao.updateUser(user.copyWith(name: 'New Name'));

Используйте Domain Models когда:

  1. Реализуете бизнес-логику
  2. Нужны связанные объекты
  3. Работаете в Managers или API
  4. Нужны методы и проверки
// Пример: Бизнес-логика
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();

🎓 Ключевые выводы

  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)

Пример:

// 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 нужны для:

  1. Бизнес-логики внутри backend

    • Проверки доступа
    • Вычисления цен
    • Валидация данных
    • Работа со связанными объектами
  2. Работы со сложными структурами

    • Связанные объекты (subscriptionModel, packs, userData)
    • Методы и computed properties
    • Циклические связи
  3. Независимости от API

    • Domain Models не зависят от формата API
    • Можно менять API без изменения бизнес-логики
    • Переиспользование в разных контекстах

DTO нужны для:

  1. Передачи данных через API

    • Упрощенная структура
    • Только примитивы
    • Без циклических ссылок
  2. Версионирования API

    • Можно менять DTO без изменения Domain Models
    • Обратная совместимость
  3. Оптимизации

    • Отправляем только нужные данные
    • Предвычисленные значения

🔄 Типичный поток

// 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 нужны для:

  1. Бизнес-логики внутри backend

    • Проверки доступа
    • Вычисления цен
    • Валидация данных
    • Работа со связанными объектами
  2. Работы со сложными структурами

    • Связанные объекты (subscriptionModel, packs, userData)
    • Методы и computed properties
    • Циклические связи
  3. Независимости от API

    • Domain Models не зависят от формата API
    • Можно менять API без изменения бизнес-логики
    • Переиспользование в разных контекстах

DTO нужны для:

  1. Передачи данных через API

    • Упрощенная структура
    • Только примитивы
    • Без циклических ссылок
  2. Версионирования API

    • Можно менять DTO без изменения Domain Models
    • Обратная совместимость
  3. Оптимизации

    • Отправляем только нужные данные
    • Предвычисленные значения

🔄 Типичный поток

// 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())),
  ),
);

🎯 Итоги

Связи:

  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 - легко добавлять новые сущности


Этот документ должен помочь понять, как данные проходят через все слои приложения! 🚀