Object-Relational Mapping (ORM) — технология, призванная «поженить» реляционную природу SQL-баз (PostgreSQL, MySQL, SQLite и т.п.) с объектной моделью языков программирования. Она настолько популярна, что её пытаются реализовать даже в необъектных языках — например, в Go или Erlang.

Если в Java без ORM действительно неудобно, то в экосистеме Node.js (и TypeScript в частности) ситуация принципиально иная. И ORM здесь — зачастую избыточная абстракция. В большинстве случаев рациональнее обойтись компактным SQL-билдером который сильно упрощает построение запросов, оставляя над ними полный контроль, и который совсем не занимается управлением объектами.

Disclaimer

Конечно, и в Node.js бывают случаи когда ORM необходим (они описаны ниже). Так же не надо искать повод убрать ORM из существующего проекта (игра не стоит свеч). Но вот, прежде чем добавлять его в новый проект, я бы несколько раз подумал, так как это чужая парадигма из другого языка.

Почему в Node.js ORM почти не даёт преимуществ

Большинство драйверов баз данных для JavaScript/TypeScript (в отличии от других языков, типа Java или Go) сразу возвращают массив нативных объектов, без каких-либо предупреждений или промежуточных слоёв:

const users = await db.query('SELECT id, name FROM users');
// users — это просто массив объектов: [{ id: 1, name: 'Анна' }, ...]

Типизация в TypeScript решается дженериками:

const users = await db.query<Users>('SELECT id, name FROM users');

Или одним ключевым словом as (чего не добиться в языках с номинальной типизацией, типа Java):

 const users = (await db.query('SELECT id, name FROM users')) as User[];

В типичном сценарии Node.js — REST API — данные из базы почти сразу сериализуются в JSON и уходят клиенту. Никаких сложных манипуляций с объектами, никаких долгих жизненных циклов сущностей (как в языках общего назначения, где API лишь один из немногих сценариев). Если вам всё же нужен настоящий экземпляр класса — его проще создать вручную, чем тащить за собой тяжёлый ORM.

Почему ORM так популярен в Java — и почему это не наш случай

Hibernate ORM стал стандартом в Java-мире по объективным причинам:

  • Принятая архитектура и фреймворки требуют наличия объектов созданных по строгим правилам (Enterprise Java Beans, Spring Beans) — с приватными полями, геттерами/сеттерами и конструктором без аргументов — без библиотек это порождает массу шаблонного кода. И в этих бинах не редко содержится богатая логика, что редко бывает в API серверах, где объекты часто предназначены только для сериализации.

  • Драйверы JDBC возвращают ResultSet — итератор над курсором, по которому нужно вручную итерироваться и «поштучно» извлекать колонки:

    while (resSet.next()) {
        users.add(new User(resSet.getInt("id"), resSet.getString("name")));
    }  

    ORM избавляет от этой рутины, сильно уменьшая количество бойлерплейта.

В Node.js всё иначе:

  • Объекты — нативный тип данных.

  • Драйверы сразу отдают массивы.

  • Приложения — в основном stateless HTTP-серверы, а не «жонглёры объектами».

ORM в JavaScript — это порт чужой парадигмы, которая не решает наших проблем, но создаёт новые.

И, кстати, не надо думать, что это «бесплатно» — именно из-за этих особенностей node-драйвера работают медленнее и потребляют больше памяти (чем в Java/Go), что еще больше ставит под сомнение необходимость дополнительных слоёв.

Почему это хорошая новость

  1. Производительность и размер сборки. ORM — это сложные, объёмные библиотеки. Они замедляют запуск приложения, увеличивают размер бандла и добавляют накладные расходы к каждому запросу. Без ORM — меньше зависимостей, быстрее сборка, выше производительность.

  2. SQL — человекочитаемый и мощный. Простые SQL-запросы читаются почти как разговорный английский:

    SELECT name FROM users WHERE family_name = 'Иванов' AND age > 18;   

    Это язык с десятилетиями развития, широким применением (отчёты, аналитика, ETL) и хорошей стандартизацией. ORM же прячет SQL за собственным предметно-ориентированным языком (DSL), который не переносится между библиотеками, и мешает оптимизации

  3. Вы всё равно не избежите SQL. Как только логика усложняется — JOIN’ы, оконные функции, CTE, индексы — вам понадобится знание SQL. ORM не спасает от этого, «абстракция от базы» в этом случае становится иллюзией.

Когда ORM может быть оправдан

Конечно существуют нишевые случаи, когда ORM удобен или даже необходим:

  • Динамическое управление схемой БД. Например, в ERP-системах, где администраторы через GUI создают новые сущности и связи. Это тот случай когда существование без ORM едва ли возможно.

  • Поддержка нескольких СУБД «в бою». Если сборки вашего продукта действительно работают одновременно на разных СУБД — ORM может помочь. Но SQL-билдеры могут сделать тоже самое, при этом:

    • весят в несколько раз меньше (на конец 2025 года по данным npm — Knex в 20+ раз компактнее, чем TypeORM)

    • дают полный контроль над SQL

  • Пакетные операции. Да, ORM упрощает массовые вставки. Но с билдером это делается так же просто:

    await knex('users').insert(usersArray);  
  • Вложенные объекты «в один запрос». ORM действительно может собрать user с orders за одно обращение. Но:

    • это может вызвать взрывное размножение строк или породить множество отдельных запросов, по одному на каждого пользователя, создав "проблему N+1",

    • в большинстве случаев лучше разделить ответы: /users/1 и /users/1/orders, это позволит GUI загружать данные параллельно, что увеличит отзывчивость интерфейса

    • «Плоские» DTO (orderTotal, orderDate, userId, userName) как правило, легче читать и анализировать.

    • Если всё таки непременно нужны вложенные объекты, то современные СУБД умеют создать вложенный JSON на чистом SQL

      как сделать вложенные объекты на PostgreSQL

      Создаём и заполняем таблицы

      Юзеры и заказы. У одного юзера может быть сколько угодно заказов
      Юзеры и заказы. У одного юзера может быть сколько угодно заказов
      create schema test; -- делаем отдельную схему, чтобы не мусорить
      create table test.users(
        id serial primary key,
        name text not null
      );
      create table test.orders(
        id serial primary key,
        user_id int references test.users(id),
        date timestamptz not null default now(),
        value int not null default 0
      );
      
      -- заполняем ---
      insert into test.users(name) values ('Ваня'),('Петя'),('Вова'),('Даша'),('Маша');
      insert into test.orders(user_id, value) values (1,10),(1,11),(2,5),(3,3),(3,33),(5,5),(5,55);

      Выбираем заказы так, чтобы юзеры были вложенным объектом

      SELECT 
          o.*,                 -- все поля заказа
          to_json(u) AS user   -- юзер как JSON
        FROM test.orders o
        LEFT JOIN test.users u ON u.id = o.user_id;

      в результате получим

      [
        {
          "id": 1,
          "user_id": 1,
          "date": "2025-10-23 13:18:28.103 +0300",
          "value": 10,
          "user": {
            "id": 1,
            "name": "Ваня"
          }
        },
        {
          "id": 2,
          "user_id": 1,
          "date": "2025-10-23 13:18:28.103 +0300",
          "value": 11,
          "user": {
            "id": 1,
            "name": "Ваня"
          }
        },
        
        ...
      ]

      Теперь выбираем юзеров,так чтобы заказы были массивом объектов

      SELECT 
          u.*,
          COALESCE(           -- на случай отсутствия заказов
              json_agg(       -- массив
                  to_json(o)  -- один заказ как JSON
                  ORDER BY o.date
              ) FILTER (WHERE o.id IS NOT NULL), -- исключаем заказы типа {"id" : null,...}, они возникнут, когда у юзера нет заказов
              '[]' -- подставится, когда нет ни одного заказа (т.к. null-заказы мы отфильтровали)
          ) AS orders
        FROM test.users u
        LEFT JOIN test.orders o ON u.id = o.user_id
        GROUP BY u.id; -- Из-за JOIN каждый заказ будет отдельной строкой, поэтому "схлопываем" их по юзеру

      Получаем:

      [
       {
          "id": 1,
          "name": "Ваня",
          "orders": [
            { "id": 2, "user_id": 1, "date": "2025-10-23T13:18:28.103179+03:00", "value": 11  },
            { "id": 1, "user_id": 1, "date": "2025-10-23T13:18:28.103179+03:00", "value": 10 }
          ]
      },
      {
          "id": 2,
          "name": "Петя",
          "orders": [
            { "id": 3, "user_id": 2, "date": "2025-10-23T13:18:28.103179+03:00", "value": 5 }
          ]
      },
      {
          "id": 3,
          "name": "Вова",
          "orders": [
            { "id": 5, "user_id": 3, "date": "2025-10-23T13:18:28.103179+03:00", "value": 33 },
            { "id": 4, "user_id": 3, "date": "2025-10-23T13:18:28.103179+03:00", "value": 3 }
          ]
      },
      {
          "id": 4,
          "name": "Даша",
          "orders": []
      },
      {
          "id": 5,
          "name": "Маша",
          "orders": [
            { "id": 7, "user_id": 5, "date": "2025-10-23T13:18:28.103179+03:00", "value": 55 },
            { "id": 6, "user_id": 5, "date": "2025-10-23T13:18:28.103179+03:00", "value": 5 }
          ]
      }
      ]

      В MySQL и MariaDB есть аналогичные функции, немного отличающиеся по синтаксису.

      Это не очень удобные конструкции, поэтому мой выбор — разделение API, но если вы не используете ORM и тут вдруг понадобились вложенные объекты — то это не проблема.

      Если нужны не все поля, то вместо to_json() лучше использовать json_build_object() - но это более многословно

  • Когда SQL пишут отдельные специалисты. Если у вас есть DBA, который создаёт VIEW, функции и триггеры, а разработчики работают только с простыми таблицами — ORM может быть безвреден. Но он не добавляет ценности, просто не мешает.

Что применять вместо ORM

Для большинства задач в Node.js лучше использовать:

  • Лёгкий SQL-билдер — всё равно какой (Knex.js один из самых популярных). Они обеспечивают параметризацию, защиту от SQL-инъекций, динамические условия, простую пакетную вставку/обновление и поддержку нескольких диалектов.

  • Типизацию через интерфейсы и as.

  • Простые маппинг-функции, если нужны классы:

        const toUser = (row: any): User => new User(row.id, row.name);  
        const users  = rows.map(toUser);  
  • Прямой SQL через pg, mysql2 и т.п.для простого чтения или одиночной вставки/обновления

Вывод

ORM — мощный инструмент, но не универсальный. В экосистеме Node.js он:

  • не решает ключевых проблем,

  • добавляет сложность и накладные расходы,

  • мешает прямому использованию SQL — языка, который проще, мощнее и универсальнее любой ORM-обёртки.

Перед тем как добавлять ORM в проект на Node.js, честно ответьте себе: какие конкретные преимущества он принесёт? Если ответ — «все так делают» или «вдруг пригодится» — то это не повод. Чаще всего вам хватит драйвера и пары строк на TypeScript.

Комментарии (6)


  1. Dhwtj
    29.10.2025 06:23

    const users = await db.query('SELECT * FROM users');
    // users уже массив объектов

    Node.js драйвер сразу возвращает объекты. И ORM сразу теряет смысл


    1. mkant Автор
      29.10.2025 06:23

      Да, да. И что важно, node-драйвера из-за этого работают несколько медленнее - т.е. они тратят доп. ресурсы, чтобы вернуть нативные объекты вместо итератора. И поверх этого еще одну жирную абстракцию навертеть - это какая-то безудержная расточительность


  1. Ares_ekb
    29.10.2025 06:23

    Да, и в Java не факт, что ORM всегда нужен. Есть гораздо более легковесные решения, например, для некоторых наших проектов JdbcClient на порядок удобнее и проще, чем Hibernate


    1. mkant Автор
      29.10.2025 06:23

      Да, я напарывался, когда надо было сделать запрос посложнее, Hibernate скорее тормозит чем помогает. Но в Java хоть какое-то рациональное объяснение есть. А в Node который специально проектировался для HTTP серверов это бессмысленно гораздо чаще


  1. zartdinov
    29.10.2025 06:23

    Ну prisma это удобно, видишь схему и он сам готовит миграции


    1. mkant Автор
      29.10.2025 06:23

      Ну вроде да - местами удобно. Но такие решения часто сфокусированы на определенном виде удобства. CRUD - удобно, миграция к новой версии - вроде удобно, откат к старой - уже не понято. Сделать отчет выкачав данные из нескольких таблиц с предварительным расчетом агрегатов - не понятно. Миграции на knex - считай, чистый SQL с явным описанием как провести откат - вроде тоже удобно и прозрачно. Знание SQL кажется куда как более универсальным навыком, чем умение писать призма-схемы