Strapi 5 на практике: Document Service API вместо Entity Service — миграция и работа с documentId — PROG-TIME

Strapi 5 на практике: Document Service API вместо Entity Service — миграция и работа с documentId

05.07.2026

В Strapi 5 привычный entityService ушёл в прошлое — вместо него основным способом работать с контентом на бэкенде стал Document Service API. Это не косметическое переименование: поменялась сама модель данных. Теперь запись — это «документ» с постоянным строковым идентификатором documentId, а черновик и опубликованная версия перестали быть двумя разными числовыми id. В этой статье разберём на практике, как устроен Document Service API, как мигрировать код с Entity Service и на какие грабли легко наступить при переходе.

Что вообще изменилось: документы вместо сущностей

В Strapi v4 каждая запись имела числовой id, а черновик и публикация фактически были отдельными строками в базе с разными id. Это порождало путаницу: фронтенд получал один идентификатор для черновика и другой — для опубликованной версии одной и той же статьи.

В Strapi 5 ввели концепцию документа. У документа есть стабильный documentId — строка вида ln1gkzs6ojl9d707xn6v86mw, которая не меняется между черновиком и публикацией, между локалями и версиями. Числовой id тоже существует, но теперь это внутренний идентификатор конкретной версии записи в БД, а не то, чем вы оперируете в API. В коде и в REST-ответах основным ключом становится documentId.

Важное следствие: старый параметр publicationState: "preview" | "live" заменён на status: "draft" | "published". А управление публикацией теперь идёт не через запись published_at, а через отдельные методы publish(), unpublish() и discardDraft().

Базовые CRUD-операции

Точка входа — функция strapi.documents(uid), куда передаётся UID типа контента (например, api::article.article). Дальше вызываются методы. Вот чтение одного документа и списка:

// Один документ по documentId
const article = await strapi.documents('api::article.article').findOne({
  documentId: 'ln1gkzs6ojl9d707xn6v86mw',
  populate: ['author', 'cover'],
});

// Список с фильтрами и статусом
const articles = await strapi.documents('api::article.article').findMany({
  filters: { title: { $contains: 'Strapi' } },
  status: 'published',
  sort: 'createdAt:desc',
  populate: ['author'],
});

Создание, обновление и удаление выглядят так. Обратите внимание: в update и delete передаётся именно documentId, а не числовой id:

// Создание (по умолчанию создаётся черновик)
const created = await strapi.documents('api::article.article').create({
  data: { title: 'Document Service API на практике', slug: 'ds-api' },
});

// Обновление
const updated = await strapi.documents('api::article.article').update({
  documentId: created.documentId,
  data: { title: 'Обновлённый заголовок' },
});

// Удаление
await strapi.documents('api::article.article').delete({
  documentId: created.documentId,
});

// Подсчёт
const total = await strapi.documents('api::article.article').count();

Есть и удобный findFirst() — он возвращает первый документ, подходящий под условия, без необходимости доставать элемент из массива вручную.

Черновики и публикация

Раз черновик и публикация — это две версии одного документа, публикацией управляют явно. По умолчанию create() и update() работают с черновиком (draft). Чтобы опубликовать документ, вызывают publish():

// Опубликовать документ
await strapi.documents('api::article.article').publish({
  documentId: article.documentId,
});

// Снять с публикации (останется только черновик)
await strapi.documents('api::article.article').unpublish({
  documentId: article.documentId,
});

// Откатить черновик к опубликованной версии
await strapi.documents('api::article.article').discardDraft({
  documentId: article.documentId,
});

Такая модель убирает главную боль v4: теперь можно спокойно редактировать черновик, не трогая «живую» версию на сайте, и публиковать одним вызовом, когда всё готово. Параметр status: 'draft' в findMany() и findOne() позволяет читать именно черновики — например, для страницы предпросмотра.

Локали: работа с i18n

Раньше локализованные версии тоже жили под разными id. В Strapi 5 у всех локалей одного материала — общий documentId, а конкретную языковую версию выбирают параметром locale:

// Прочитать английскую версию документа
const en = await strapi.documents('api::article.article').findOne({
  documentId: article.documentId,
  locale: 'en',
});

// Создать/обновить французскую локаль того же документа
await strapi.documents('api::article.article').update({
  documentId: article.documentId,
  locale: 'fr',
  data: { title: 'Le titre en français' },
});

// Опубликовать конкретную локаль
await strapi.documents('api::article.article').publish({
  documentId: article.documentId,
  locale: 'fr',
});

Это заметно упрощает мультиязычные проекты: один стабильный идентификатор материала и явное указание языка вместо жонглирования разрозненными числовыми id.

Миграция с Entity Service: что делает codemod, а что придётся руками

Официальный upgrade-инструмент запускает codemod, который автоматически переписывает вызовы. Структуру кода и замену publicationState на status он берёт на себя. Пример того, как преобразуется findMany:

// Было (Strapi v4)
strapi.entityService.findMany(uid, {
  fields: ['id', 'name', 'description'],
  populate: ['author', 'comments'],
  publicationState: 'preview',
});

// Стало (Strapi 5)
strapi.documents(uid).findMany({
  fields: ['id', 'name', 'description'],
  populate: ['author', 'comments'],
  status: 'draft',
});

Но codemod не всесилен, и есть три момента, которые нужно доработать вручную:

  • Идентификаторы. Codemod не может превратить старый числовой entityId в documentId — он просто вставит заглушку documentId: "__TODO__". Все такие места нужно найти и подставить реальные значения.
  • Публикация. Любой код, который публиковал запись через установку published_at, придётся переписать на методы publish(), unpublish(), discardDraft() — codemod это не трогает.
  • Single types. В v4 findMany() для одиночного типа возвращал один объект. В Strapi 5 findMany() всегда возвращает массив, поэтому для single type нужно брать первый элемент: const home = (await strapi.documents('api::home.home').findMany())[0];

Отдельная тема — плагины. Если вы использовали strapi.entityService.decorate() для перехвата запросов, его заменяют на middlewares Document Service через strapi.documents.use(). Идея та же (вклиниться в запрос и модифицировать ctx.params или ответ), но API другое.

Для тех, кто пока не может переписать фронтенд, Strapi оставил обратную совместимость: заголовок Strapi-Response-Format: v4 для REST заставляет API отвечать в старом формате с data.attributes и числовым id. Это позволяет мигрировать бэкенд и фронтенд независимо, по одному эндпоинту за раз.

Итоги

Document Service API — не просто новое имя для старого сервиса, а более честная модель данных: один документ, один стабильный documentId, явное управление черновиками, публикацией и локалями. При миграции с v4 большую часть механической работы сделает codemod из upgrade-инструмента, но три вещи остаются на вас: подстановка реальных documentId вместо __TODO__, переход на методы publish()/unpublish()/discardDraft() и учёт того, что findMany() теперь всегда возвращает массив. Начните с бэкенда под прикрытием заголовка Strapi-Response-Format: v4, а затем спокойно переводите фронтенд на плоский формат ответов и documentId. Так переход на Strapi 5 пройдёт без простоя и потери данных.