almdev Разработка с ИИ

Связи между модулями без десяти таблиц
Обобщённая таблица связей: чем платишь за отказ от внешних ключей.

Как связать задачу со страницей, страницу со страницей и задачу с задачей одной таблицей: схема обобщённой связи, цена отказа от внешних ключей, компенсация потери целостности и загрузка без N+1.

Подкатегория: Бэкенд

Чтение
6 мин
Технологии / версии
DDD · Архитектура · Doctrine · PostgreSQL
19 сен 2026 · 6 мин · 12 просмотров · Архив
Общая модель связей между модулями
Бэкенд 09/2026

Страница базы знаний ссылается на задачу. Задача ссылается на другую задачу как дубликат. Страница ссылается на страницу. Задача ссылается на страницу с техническим заданием.

Четыре типа связей, четыре таблицы. Потом появляется модуль helpdesk с обращениями, и таблиц становится девять. Каждая с двумя внешними ключами, своим репозиторием и своим кодом загрузки, отличающимся только именами.

Я посчитал это на бумаге до того, как написал первую, и остановился.

Одна таблица вместо девяти

#[ORM\Entity]
#[ORM\Table(name: 'entity_link')]
#[ORM\UniqueConstraint(columns: ['source_type', 'source_id', 'target_type', 'target_id', 'link_type'])]
#[ORM\Index(columns: ['source_type', 'source_id'])]
#[ORM\Index(columns: ['target_type', 'target_id'])]
class EntityLink
{
    #[ORM\Column(enumType: LinkableEntity::class)]
    private LinkableEntity $sourceType;

    #[ORM\Column(type: UuidType::NAME)]
    private Uuid $sourceId;

    #[ORM\Column(enumType: LinkableEntity::class)]
    private LinkableEntity $targetType;

    #[ORM\Column(type: UuidType::NAME)]
    private Uuid $targetId;

    #[ORM\Column(enumType: EntityLinkType::class)]
    private EntityLinkType $linkType;
}

Два энума делают конструкцию управляемой. LinkableEntity перечисляет, что вообще можно связывать: задача, страница, проект, пространство, обращение. EntityLinkType — характер связи: относится к, блокирует, дублирует, документирует, порождает.

Строковый тип вместо энума я тоже рассматривал — и отказался. Энум даёт автодополнение, статический анализ и невозможность записать 'isue' вместо 'issue'. Расширяется он одной строкой, а перечисление в коде не даёт списку типов расползтись.

Три индекса вместо одного: уникальный не даёт создать одну и ту же направленную запись дважды, а два обычных обслуживают поиск в обе стороны. Для симметричных типов вроде relates_to сервис сначала приводит концы к каноническому порядку; иначе пары A→B и B→A прошли бы уникальный ключ как разные. Сам ключ из пяти колонок весит прилично, но проверка существования запросом и медленнее, и не защищает от гонки.

Чем платим

Внешних ключей нет. База не знает, что target_id указывает на строку в wm_issue. Удалили задачу — связь осталась висеть в никуда.

Это главная цена. В статьях про обобщённые связи её обычно смягчают.

Второе следствие: универсального джойна к одной целевой таблице больше нет. Условные JOIN или UNION ALL по известным типам написать можно, и PostgreSQL умеет их планировать, но запрос быстро становится громоздким. В прикладном коде я делаю два шага: сначала связи, потом сущности по списку идентификаторов.

Третье: ON DELETE CASCADE не работает, каскад надо писать руками.

Как компенсировать потерю целостности

Три механизма не заменяют внешний ключ, но сокращают окно для висячих связей и регулярно убирают остаток.

Проверка при создании. Сервис не принимает идентификатор на веру. Ниже фрагмент после нормализации концов симметричной связи:

public function link(
    LinkableEntity $sourceType, Uuid $sourceId,
    LinkableEntity $targetType, Uuid $targetId,
    EntityLinkType $type,
): EntityLink {
    if ($sourceType === $targetType && $sourceId->equals($targetId)) {
        throw new \DomainException('Сущность нельзя связать саму с собой');
    }

    foreach ([[$sourceType, $sourceId], [$targetType, $targetId]] as [$type_, $id]) {
        if (!$this->resolver->exists($type_, $id)) {
            throw new \DomainException("Не найдено: {$type_->value} {$id}");
        }
    }

    return $this->repository->create(/* … */);
}

Два запроса на создание связи. Связи создают редко, так что цена приемлемая. Целостность они всё равно не гарантируют: сущность можно удалить между проверкой и вставкой. Это осознанная eventual consistency, которую подстраховывает уборка мусора.

Уборка при удалении. Каждый модуль, удаляя свою сущность, обязан позвать уборку связей. Обеспечивается это событием домена, а не памятью разработчика:

#[AsEventListener]
public function onEntityDeleted(EntityDeletedEvent $event): void
{
    $this->links->deleteAllFor($event->type, $event->id);
}

Уборка мусора. Раз в неделю команда проходит по связям и проверяет, что обе стороны на месте. Находки логируются и удаляются. За год она нашла 34 висячие связи — все после ручных удалений в базе во время отладки.

Загрузка без N+1

Наивный вариант ужасен: получить связи страницы, для каждой сходить в свой репозиторий. Десять связей — одиннадцать запросов.

Резолвер группирует по типу и делает один запрос на тип:

final class EntityLinkResolver
{
    /** @return array<string, LinkedEntityView> ключ — "type:id" */
    public function resolve(array $links): array
    {
        $byType = [];
        foreach ($links as $link) {
            $byType[$link->getTargetType()->value][] = $link->getTargetId();
        }

        $result = [];
        foreach ($byType as $type => $ids) {
            $provider = $this->providers[$type]
                ?? throw new \LogicException("Нет провайдера для {$type}");

            foreach ($provider->loadMany($ids) as $id => $view) {
                $result["{$type}:{$id}"] = $view;
            }
        }

        return $result;
    }
}

Провайдер — маленький интерфейс, который каждый модуль реализует для своих сущностей:

interface LinkedEntityProvider
{
    /** @return array<string, LinkedEntityView> */
    public function loadMany(array $ids): array;
}

LinkedEntityView — плоский объект с тем, что нужно для отображения: заголовок, ссылка, иконка, статус. Не сущность. Это существенно: модуль задач отдаёт представление, а не свою внутреннюю модель, и граница остаётся на месте.

Максимум четыре запроса на любую страницу связей, независимо от их количества. Больше типов у меня нет.

Симметричность связи

Вопрос, который я недооценил. Если задача A блокирует задачу B, это одна запись или две?

Я выбрал одну запись и вычисление обратной стороны при чтении. У каждого типа связи есть обратный:

enum EntityLinkType: string
{
    case RelatesTo = 'relates_to';
    case Blocks = 'blocks';
    case BlockedBy = 'blocked_by';
    case Duplicates = 'duplicates';

    public function inverse(): self
    {
        return match ($this) {
            self::RelatesTo => self::RelatesTo,
            self::Blocks => self::BlockedBy,
            self::BlockedBy => self::Blocks,
            self::Duplicates => self::Duplicates,
        };
    }
}

Загрузка связей сущности идёт двумя условиями — где она источник и где она цель, — и во втором случае тип связи переворачивается.

Альтернатива с двумя записями проще в чтении и хуже во всём остальном: две записи надо синхронно создавать, синхронно удалять и как-то не давать им разъехаться. Одна запись плюс десять строк в энуме показались мне честнее.

Когда обобщённой связи не место

Первый сигнал отдельной сущности — собственные данные у связи.

Участие пользователя в проекте с ролью и датой приглашения — отдельная сущность с внешними ключами. Позиция задачи в спринте с порядковым номером — тоже. Одно техническое поле ещё ничего не решает; граница проходит там, где у связи появляются собственное поведение, жизненный цикл или данные, по которым делают выборку.

Второй признак — частота обращения. Связь «задача принадлежит проекту» читается в каждом запросе, по ней сортируют и фильтруют. Это обычный внешний ключ, и попытка обобщить его убила бы производительность списка задач.

Обобщённая таблица хороша ровно для того, для чего задумана: разреженные необязательные связи, которые показываются на карточке сущности и больше нигде не участвуют.

Через год

Девять таблиц не появились, появилась одна. Связей в ней около четырёх тысяч, самый частый тип — «относится к», самый полезный — «документирует», соединяющий задачу с её техническим заданием в базе знаний.

Единственное, чего мне не хватает, — представления в базе, которое разворачивало бы связи в читаемый вид для ручных запросов. Сейчас при отладке я каждый раз пишу выражение с условиями по типу. И обычное, и материализованное представление можно собрать через UNION ALL всех известных типов; я это откладываю, потому что при добавлении типа придётся менять ещё и представление.

#ddd #arhitektura #doctrine #postgresql