Skip to content

ADR-001: Выбор ORM / Query Builder

Дата: 2026-01-04
Статус: Принято
Авторы: Backend Team


Контекст

Проект «Маркетплейс автозапчастей СПб» использует PostgreSQL 16.x в качестве основной СУБД. Необходимо выбрать инструмент для работы с базой данных, который обеспечит:

  • Типобезопасность (проект на TypeScript)
  • Поддержку сложных запросов (JOIN через 5+ таблиц, CTE, оконные функции)
  • Интеграцию с Medusa.js 2.x
  • Поддержку миграций
  • Работу с PostGIS для геопространственных запросов

Структура данных проекта

Согласно autoparts-contracts-v5.md, система включает 21 таблицу со сложными связями:

  • M:N связи: products ↔ categories, oem_numbers ↔ oem_cross_references
  • Иерархии: car_makes → car_models → car_generations
  • Геоданные: regions → cities → districts/metro_stations
  • Денормализованные счётчики с триггерами

Рассмотренные альтернативы

РешениеТипобезопасностьСложные запросыMedusa.jsМиграцииPostGIS
MikroORM✅ Excellent✅ QueryBuilder✅ Встроен⚠️ Плагин
Prisma✅ Excellent⚠️ Ограничен❌ Замена⚠️ Raw SQL
TypeORM⚠️ Частичная✅ QueryBuilder❌ Legacy
Drizzle✅ Excellent✅ SQL-like❌ Замена⚠️ Raw SQL
Knex.js❌ Нет✅ Любые❌ Замена

Решение

Используем MikroORM — ORM, встроенный в Medusa.js 2.x.

Обоснование

  1. Нативная интеграция с Medusa.js

    • Medusa 2.x использует MikroORM как основной ORM
    • Все модули Medusa (Products, Customers, Orders) уже работают через MikroORM
    • Единообразие: кастомные модули используют тот же инструмент
  2. Типобезопасность

    typescript
    // Полная типизация сущностей и отношений
    @Entity()
    class Product {
      @ManyToMany(() => Category)
      categories = new Collection<Category>(this);
      
      @OneToMany(() => ProductCompatibility, pc => pc.product)
      compatibility = new Collection<ProductCompatibility>(this);
    }
  3. Мощный QueryBuilder для сложных запросов

    typescript
    // Поиск товаров с совместимостью и категориями
    const products = await em.createQueryBuilder(Product, 'p')
      .select('p.*')
      .leftJoinAndSelect('p.categories', 'c')
      .leftJoin('p.compatibility', 'pc')
      .where({ 'pc.make_id': makeId })
      .andWhere({ 'p.steering': { $in: ['left', 'universal'] } })
      .andWhere({ 'p.status': 'active' })
      .orderBy({ 'p.created_at': 'DESC' })
      .limit(20)
      .getResultList();
  4. Поддержка Raw SQL для PostGIS

    typescript
    // Геопоиск в радиусе
    const nearbyProducts = await em.execute(`
      SELECT p.*, 
        ST_Distance(c.location, ST_SetSRID(ST_MakePoint($1, $2), 4326)) / 1000 as distance_km
      FROM products p
      JOIN users u ON p.seller_id = u.id
      JOIN cities c ON u.city_id = c.id
      WHERE ST_DWithin(c.location, ST_SetSRID(ST_MakePoint($1, $2), 4326), $3 * 1000)
      ORDER BY distance_km ASC
    `, [lon, lat, radiusKm]);
  5. Unit of Work паттерн

    • Автоматическое отслеживание изменений
    • Batch-операции для производительности
    • Транзакции из коробки

Паттерны использования

Кастомные модули Medusa

typescript
// src/modules/cars/models/car-make.ts
import { model } from "@medusajs/framework/utils";

export const CarMake = model.define("car_make", {
  id: model.id().primaryKey(),
  name: model.text(),
  slug: model.text().unique(),
  logo_url: model.text().nullable(),
  models: model.hasMany(() => CarModel),
});

Репозитории

typescript
// src/modules/cars/repositories/car-repository.ts
@Injectable()
export class CarRepository {
  constructor(
    @InjectRepository(CarMake)
    private readonly makeRepo: EntityRepository<CarMake>,
  ) {}

  async findMakesWithModels(): Promise<CarMake[]> {
    return this.makeRepo.findAll({
      populate: ['models', 'models.generations'],
      orderBy: { name: 'ASC' },
    });
  }
}

Миграции

bash
# Генерация миграции
npx medusa db:generate car_makes_table

# Применение миграций
npx medusa db:migrate

Последствия

Положительные

  • Консистентность: единый ORM для всего проекта
  • Документация: примеры Medusa применимы напрямую
  • Поддержка: активное сообщество Medusa и MikroORM
  • Производительность: Identity Map, lazy loading, batch inserts
  • Гибкость: QueryBuilder + Raw SQL для edge cases

Отрицательные

  • Кривая обучения: MikroORM менее популярен, чем Prisma/TypeORM
  • PostGIS: нет нативной поддержки, требуется raw SQL
  • Миграции: менее удобны, чем в Prisma

Риски и митигация

РискМитигация
Сложные геозапросыВыделить в отдельный сервис с raw SQL
Производительность M:NИспользовать populate выборочно, добавить индексы
Сложность отладкиВключить логирование SQL в dev-окружении

Связанные решения