Проектирование схем и нормализация

Схема базы данных - это не техническая формальность после написания Go-кода. Это модель предметной области, контракт между сервисами и набор инвариантов, которые переживут несколько версий приложения.

Хорошая схема:

  • выражает бизнес-сущности и связи между ними;
  • запрещает невозможные состояния;
  • поддерживает основные запросы без мучений;
  • допускает эволюцию через миграции;
  • не заставляет backend держать всю целостность "на честном слове".

Плохая схема обычно проявляется позже: дубли, несогласованные статусы, тяжелые отчеты, невозможность удалить данные, миграции на часы, NULL в половине колонок и бизнес-логика, размазанная по SQL-скриптам.


ER-модель и ограничения

От предметной области к ER-модели

ER-модель описывает сущности, атрибуты и связи.

text
User 1 ─── * Order 1 ─── * OrderItem * ─── 1 Product | * Payment

Пример для учебной платформы:

СущностьЧто хранит
usersАккаунты студентов, менторов и админов
coursesКурсы
lessonsУроки внутри курса
enrollmentsЗапись пользователя на курс
submissionsОтправленные решения задач
reviewsПроверки и комментарии ментора

Сначала полезно писать модель словами:

  • пользователь может быть записан на много курсов;
  • курс содержит много уроков;
  • урок принадлежит одному курсу;
  • пользователь может отправить много решений;
  • решение относится к конкретной задаче и пользователю;
  • проверка относится к решению и автору-ментору.

Потом это превращается в таблицы, ключи и constraints.


Иллюстративная схема для RateDesk

Для RateDesk полезно сразу разделять разные типы фактов. "Курс" в разговоре может означать сырое значение от провайдера, выбранную сервисом ставку, правило расчета, заявку пользователя или audit-запись. Если смешать это в одной таблице, production-проблемы появятся быстро: невозможно объяснить расчет, трудно переиграть импорт, отчеты расходятся с текущим состоянием.

Упрощенная write model может выглядеть так:

text
rate_providers(id, code, title, enabled) currency_pairs(id, base_currency, quote_currency) provider_rate_quotes(id, provider_id, pair_id, bid, ask, received_at, provider_trace_id) rate_rules(id, pair_id, priority, spread_bps, valid_from, valid_to, enabled) rate_decisions(id, pair_id, quote_id, rule_id, final_rate, decided_at) rate_decision_audit(id, decision_id, actor, reason, created_at)

Минимальный DDL-фрагмент:

sql
CREATE TABLE currency_pairs ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, base_currency char(3) NOT NULL, quote_currency char(3) NOT NULL, CHECK (base_currency <> quote_currency), UNIQUE (base_currency, quote_currency) ); CREATE TABLE provider_rate_quotes ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, provider_id bigint NOT NULL REFERENCES rate_providers(id), pair_id bigint NOT NULL REFERENCES currency_pairs(id), bid numeric(20, 8) NOT NULL CHECK (bid > 0), ask numeric(20, 8) NOT NULL CHECK (ask > 0), received_at timestamptz NOT NULL, provider_trace_id text NOT NULL, CHECK (ask >= bid), UNIQUE (provider_id, provider_trace_id) ); CREATE TABLE rate_decisions ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, pair_id bigint NOT NULL REFERENCES currency_pairs(id), quote_id bigint NOT NULL REFERENCES provider_rate_quotes(id), rule_id bigint NOT NULL REFERENCES rate_rules(id), final_rate numeric(20, 8) NOT NULL CHECK (final_rate > 0), decided_at timestamptz NOT NULL DEFAULT now() );

Что здесь важно именно как теория проектирования:

  • provider_rate_quotes хранит входящий факт от провайдера и не должна перетираться "текущим курсом";
  • rate_decisions хранит результат выбора и ссылку на quote/rule, чтобы расчет можно было объяснить после инцидента;
  • UNIQUE (provider_id, provider_trace_id) делает импорт идемпотентным при retry;
  • CHECK (ask >= bid) ловит невозможную рыночную форму до попадания в доменную логику;
  • денежные и курсовые значения не хранятся в float, потому что округление становится частью финансового бага;
  • если правило меняется во времени, valid_from/valid_to и audit важнее, чем обновление одной "активной" строки без истории.

Это не единственно правильная схема. На практике могут появиться tenancy, версии правил, manual override, outbox, read model для dashboard и retention policy для старых quote'ов. Но базовая эвристика остается: отдельный факт - отдельная таблица или явно названный snapshot.


Cardinality

Cardinality описывает, сколько объектов одной сущности может быть связано с объектом другой сущности.

СвязьПримерРеализация
One-to-oneuser - user_profileFK с UNIQUE или PK на FK
One-to-manycourse - lessonsFK на стороне many
Many-to-manyusers - coursesТаблица связей

One-to-many:

sql
CREATE TABLE lessons ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, course_id bigint NOT NULL REFERENCES courses(id), title text NOT NULL, position integer NOT NULL CHECK (position > 0), UNIQUE (course_id, position) );

UNIQUE (course_id, position) говорит: внутри одного курса позиция урока уникальна. Но разные курсы могут иметь урок с position = 1.

Many-to-many:

sql
CREATE TABLE enrollments ( user_id bigint NOT NULL REFERENCES users(id), course_id bigint NOT NULL REFERENCES courses(id), status text NOT NULL CHECK (status IN ('active', 'paused', 'completed')), joined_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (user_id, course_id) );

Таблица связей почти всегда со временем получает собственные атрибуты: роль, статус, дату вступления, источник, прогресс. Поэтому даже если кажется, что связь "просто many-to-many", проектируйте ее как полноценный факт.


Primary, foreign, unique, check

Четыре constraint'а дают большую часть целостности.

sql
CREATE TABLE submissions ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL REFERENCES users(id), lesson_id bigint NOT NULL REFERENCES lessons(id), status text NOT NULL CHECK (status IN ('draft', 'submitted', 'accepted', 'rejected')), attempt_no integer NOT NULL CHECK (attempt_no > 0), code text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE (user_id, lesson_id, attempt_no) );

Что здесь защищено:

  • submission не существует без пользователя и урока;
  • status не может быть произвольной строкой;
  • номер попытки положительный;
  • у пользователя не может быть двух попыток с одинаковым номером для одного урока.

Если это оставить только в Go-коде, любое место, которое пишет в таблицу в обход нужного service-метода, сможет нарушить модель.


Surrogate vs natural keys

Natural key берется из предметной области: email, номер паспорта, ISO-код страны, slug курса. Surrogate key создается системой: bigint identity, uuid, ulid.

КритерийNatural keySurrogate key
Смысл для бизнесаЕстьНет
Может изменитьсяИногда даОбычно нет
Размер индексаМожет быть большимОбычно компактный
Удобство ссылокЗависит от типаВысокое
Риск неверной уникальностиВышеНиже

Практическое правило:

  • primary key часто делайте surrogate;
  • бизнес-уникальность фиксируйте отдельным UNIQUE;
  • не используйте изменяемый атрибут как primary key;
  • не храните только surrogate key, если есть важная domain uniqueness.
sql
CREATE TABLE courses ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, slug text NOT NULL UNIQUE, title text NOT NULL, published boolean NOT NULL DEFAULT false );

Здесь id удобен для ссылок, а slug защищает публичный URL от дублей.


Индексы концептуально

Индекс - структура данных, которая помогает быстро найти строки, но замедляет запись и занимает место. В PostgreSQL основной тип индекса - B-tree. Он подходит для равенства, диапазонов, сортировки и уникальности.

sql
CREATE INDEX idx_orders_user_created_at ON orders (user_id, created_at DESC);

Такой индекс помогает запросу:

sql
SELECT * FROM orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20;

Ключевая идея composite index: порядок колонок важен. Индекс (user_id, created_at) хорошо работает, когда запрос ограничивает user_id, а потом сортирует или фильтрует по created_at. Он не так же полезен для запроса только по created_at.

Частые типы:

ИндексДля чего
B-treeРавенство, диапазоны, ORDER BY, UNIQUE
HashРавенство, используется реже
GINМассивы, JSONB, full-text search
GiSTГеоданные, ranges, специальные операторы
Partial indexИндекс только по части строк
Expression indexИндекс по выражению

Partial index:

sql
CREATE INDEX idx_orders_unpaid ON orders (created_at) WHERE status = 'draft';

Expression index:

sql
CREATE UNIQUE INDEX idx_users_email_lower ON users (lower(email));

Теперь User@Example.com и user@example.com конфликтуют, если бизнес считает email case-insensitive.

Индекс не надо добавлять "на всякий случай". Каждый индекс:

  • обновляется при INSERT, UPDATE, DELETE;
  • занимает место;
  • может сбивать оптимизатор, если статистика плохая;
  • усложняет миграции.

Обычно индекс появляется из конкретного запроса и проверяется через EXPLAIN.


Нормализация

Зачем она нужна

Нормализация уменьшает дублирование и защищает от аномалий данных. Это не религиозное правило "всегда дробить все на максимальное количество таблиц". Это техника проектирования, которая помогает понять, где один факт хранится в нескольких местах и может разъехаться.

Плохая таблица:

order_iduser_emailuser_nameproduct_nameproduct_priceqty
1a@example.comAnnaGo Basics50001
1a@example.comAnnaSQL Pro70001
2a@example.comAnnaGo Basics50002

Проблемы:

  • email и имя пользователя повторяются;
  • цена и название продукта повторяются;
  • изменение имени пользователя нужно делать в нескольких строках;
  • нельзя добавить продукт, пока нет заказа;
  • удаление последнего заказа может удалить знание о продукте.

Нормализованная модель:

text
users(id, email, name) products(id, name, current_price_cents) orders(id, user_id, created_at) order_items(order_id, product_id, price_cents, qty)

Важно: order_items.price_cents может намеренно дублировать цену продукта. Это не ошибка, если это цена на момент покупки. Данные нужно нормализовать по смыслу факта, а не механически.


Functional dependencies

Functional dependency: если значение A определяет значение B, пишут A -> B.

Пример:

text
user_id -> email, name product_id -> product_name, current_price order_id -> user_id, created_at (order_id, product_id) -> qty, price_at_purchase

Если в таблице есть order_id, user_id, user_email, и order_id -> user_id, а user_id -> user_email, то user_email зависит от order_id транзитивно. Это сигнал, что email пользователя в заказе может быть лишним дублированием.

Но есть важная оговорка: иногда "дублирование" фиксирует исторический факт.

ПолеНормализовать?Почему
orders.user_email для текущего emailОбычно нетEmail можно взять через users
orders.contact_email_at_checkoutВозможно даЭто исторический email на момент заказа
order_items.product_name_snapshotВозможно даНазвание в чеке должно остаться прежним
order_items.current_product_nameНетЭто не факт строки заказа

Нормальные формы

1NF

Первая нормальная форма: значения атомарны, нет повторяющихся групп колонок.

Плохо:

user_idphones
1+7999,+7888

Лучше:

sql
CREATE TABLE user_phones ( user_id bigint NOT NULL REFERENCES users(id), phone text NOT NULL, PRIMARY KEY (user_id, phone) );

Не путайте 1NF с запретом JSON вообще. JSONB может быть разумным для настроек, внешних payload'ов или редко фильтруемых данных. Но если по элементу нужно часто искать, соединять, валидировать и ссылаться внешними ключами, это кандидат на отдельную таблицу.

2NF

Вторая нормальная форма: таблица в 1NF, и каждый неключевой атрибут зависит от всего составного ключа, а не от его части.

Плохо:

order_idproduct_idproduct_nameqty
110Go Basics1

Ключ (order_id, product_id), но product_name зависит только от product_id. Значит, название продукта лучше хранить в products.

3NF

Третья нормальная форма: нет транзитивных зависимостей неключевых атрибутов от ключа.

Плохо:

user_idcity_idcity_name
177Moscow

user_id -> city_id, city_id -> city_name, значит city_name транзитивно зависит от user_id.

Лучше:

text
users(id, city_id) cities(id, name)

BCNF

Boyce-Codd Normal Form строже 3NF: для любой нетривиальной functional dependency X -> Y X должен быть superkey.

Пример проблемы:

studentcourseinstructor
AnnaGoIvan
BorisGoIvan

Если правило такое: каждый instructor ведет только один course, и каждый course может иметь несколько instructors, то instructor -> course. Но instructor не superkey этой таблицы. Значит, модель может хранить противоречия.

4NF

Четвертая нормальная форма борется с независимыми многозначными зависимостями.

Плохо:

coursementorlanguage
Go BackendIvanRussian
Go BackendIvanEnglish
Go BackendMariaRussian
Go BackendMariaEnglish

Если mentors и languages независимы, их нужно хранить отдельно:

text
course_mentors(course_id, mentor_id) course_languages(course_id, language)

5NF

Пятая нормальная форма связана с join dependencies: таблицу можно разложить на меньшие таблицы без потери информации, и все зависимости выражаются через ключи. На практике в backend-собеседованиях редко просят формально доказать 5NF. Важно понимать идею: некоторые многосвязные факты лучше хранить несколькими отношениями, если исходная таблица создает искусственные комбинации.


Аномалии вставки, обновления, удаления

Аномалия обновления

Если имя пользователя хранится в каждой строке заказа, изменение имени требует обновить много строк. Одна строка забыта - данные противоречивы.

Аномалия вставки

Если продукт хранится только внутри заказа, нельзя создать продукт без заказа.

Аномалия удаления

Если удалили последний заказ с продуктом, потеряли информацию о продукте.

Нормализация убирает эти аномалии, потому что каждый факт живет в одном месте.


Денормализация и read/write модели

Денормализация

Денормализация - осознанное дублирование данных ради чтения, производительности или автономности. Это не "мы не знаем нормализацию", а инженерный компромисс.

Примеры:

Денормализованное полеЗачем
courses.lessons_countБыстро показывать список курсов
orders.total_centsНе суммировать строки заказа каждый раз
order_items.product_name_snapshotИсторическая точность чека
user_progress.completed_lessons_countБыстрый dashboard

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

text
Источник истины: order_items Денормализованное поле: orders.total_cents Обновление: в одной транзакции с изменением order_items Проверка: периодический consistency job

Опасный вариант - когда непонятно, кто главный:

text
orders.total_cents order_items sum payment.amount_cents invoice.total_cents Все "примерно одно и то же", но расходятся при сбое.

Read model и write model

Write model оптимизирована под корректные изменения. Read model оптимизирована под чтение.

Для записи:

text
users courses lessons submissions reviews

Для чтения dashboard:

text
student_course_progress user_id course_id completed_lessons accepted_submissions last_activity_at

Read model можно строить:

  • SQL view;
  • materialized view;
  • отдельной таблицей, обновляемой транзакционно;
  • фоновой задачей;
  • через event-driven pipeline.

Чем дальше read model от источника истины, тем больше нужно думать про задержку, повторы, idempotency и восстановление.


Паттерны и миграции

Паттерны проектирования схем

Lookup table вместо свободной строки

Если список значений управляется бизнесом и расширяется, отдельная таблица лучше CHECK.

sql
CREATE TABLE lesson_statuses ( code text PRIMARY KEY, title text NOT NULL ); CREATE TABLE lessons ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, status text NOT NULL REFERENCES lesson_statuses(code) );

Если список маленький и меняется только с релизом, CHECK может быть проще.

Audit columns

sql
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), created_by bigint REFERENCES users(id), updated_by bigint REFERENCES users(id)

updated_at обычно требует явного обновления из приложения или trigger. Не добавляйте колонку, если никто не гарантирует ее актуальность.

Soft delete

sql
deleted_at timestamptz

Плюсы:

  • можно восстановить данные;
  • проще аудит;
  • ссылки не ломаются.

Минусы:

  • каждый запрос должен учитывать deleted_at IS NULL;
  • unique constraints усложняются;
  • таблицы растут;
  • очистка данных становится отдельной задачей.

Partial unique index для soft delete:

sql
CREATE UNIQUE INDEX users_email_active_unique ON users (lower(email)) WHERE deleted_at IS NULL;

Status history

Если важно знать историю статусов, одной колонки мало.

sql
CREATE TABLE order_status_events ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, order_id bigint NOT NULL REFERENCES orders(id), old_status text, new_status text NOT NULL, changed_at timestamptz NOT NULL DEFAULT now(), changed_by bigint REFERENCES users(id) );

orders.status хранит текущее состояние для быстрых запросов, order_status_events хранит историю.

Outbox

Если нужно изменить БД и надежно отправить событие, событие сначала пишут в outbox в той же транзакции.

sql
CREATE TABLE outbox_events ( id uuid PRIMARY KEY, aggregate_id text NOT NULL, event_type text NOT NULL, payload jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), published_at timestamptz );

Фоновый publisher читает неопубликованные события и отправляет их в Kafka, Redis Streams или другой broker. Это снижает риск "данные сохранили, событие потеряли".


Миграции

Миграция - изменение схемы или справочных данных, которое можно применить к базе.

Хорошая миграция:

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

Expand-contract

Для production часто используют подход expand-contract:

  1. Добавить новую nullable-колонку или таблицу.
  2. Выпустить приложение, которое пишет и старое, и новое.
  3. Backfill существующих данных.
  4. Переключить чтение на новое поле.
  5. Добавить NOT NULL, UNIQUE, FK или удалить старое поле.

Пример:

sql
-- 1. Быстрое расширение схемы. ALTER TABLE users ADD COLUMN display_name text; -- 2. Backfill отдельной задачей или батчами. UPDATE users SET display_name = name WHERE display_name IS NULL; -- 3. После проверки. ALTER TABLE users ALTER COLUMN display_name SET NOT NULL;

На больших таблицах backfill одним UPDATE может создать долгую транзакцию, раздуть WAL и заблокировать важные операции. Лучше делать батчами.

Constraint без долгой блокировки

В PostgreSQL для некоторых сценариев удобно добавлять constraint как NOT VALID, а потом валидировать отдельно:

sql
ALTER TABLE orders ADD CONSTRAINT orders_user_id_fk FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID; ALTER TABLE orders VALIDATE CONSTRAINT orders_user_id_fk;

Это не магическая кнопка "без блокировок вообще", но полезный инструмент для больших таблиц.

Миграционная безопасность: backfill, rollback, validation

Production-миграция должна отвечать не только на вопрос "какой SQL выполнить", но и на вопрос "что будет, если релиз остановится посередине". Особенно опасны изменения, которые одновременно меняют схему, код записи, код чтения и исторические данные.

Перед backfill полезно явно зафиксировать:

ВопросЗачем
Какой source of truth для старых данных?Чтобы не заполнить новое поле приблизительным значением
Можно ли запускать backfill повторно?Retry не должен создавать дубли или менять уже проверенные строки
Какой размер батча и пауза между батчами?Чтобы не раздуть WAL и не забить autovacuum/replication
Какие запросы проверяют прогресс?Нужны понятные метрики remaining, updated, failed
Что делает старая версия приложения?Expand-contract требует совместимости между релизами
Как откатиться?Иногда rollback - это не DROP COLUMN, а возврат чтения на старое поле

Пример безопасного направления мысли:

sql
-- 1. Добавляем поле без жесткого ограничения. ALTER TABLE rate_decisions ADD COLUMN rule_version integer; -- 2. Новая версия приложения начинает писать rule_version. -- 3. Backfill идет батчами и может быть перезапущен. UPDATE rate_decisions SET rule_version = 1 WHERE rule_version IS NULL AND id >= $1 AND id < $2; -- 4. Проверяем, что дыр больше нет. SELECT count(*) AS missing_rule_version FROM rate_decisions WHERE rule_version IS NULL; -- 5. Только после проверки усиливаем constraint. ALTER TABLE rate_decisions ALTER COLUMN rule_version SET NOT NULL;

Rollback-план здесь может быть таким: если новая версия приложения работает плохо, старая версия продолжает читать прежние поля, а rule_version остается безвредным расширением схемы. Удаление колонки можно отложить на отдельную contract-миграцию после стабилизации.

Reviewer в PR с миграцией обычно смотрит не только SQL-файл, но и порядок релизов:

  • новая схема совместима со старым кодом?
  • старые данные валидируются до добавления NOT NULL/FK/UNIQUE?
  • backfill батчевый и идемпотентный?
  • есть ли запрос проверки результата?
  • понятен ли rollback без потери данных?
  • не появится ли долгий lock на горячей таблице?
  • обновлены ли read/write paths так, чтобы не было расхождения source of truth?

Как проектировать схему practically

  1. Выпишите сценарии записи: создание, изменение, удаление, смена статуса.
  2. Выпишите основные сценарии чтения: список, карточка, dashboard, админка, отчеты.
  3. Найдите сущности и связи.
  4. Определите ключи и бизнес-уникальность.
  5. Добавьте constraints для простых инвариантов.
  6. Нормализуйте повторяющиеся факты.
  7. Добавьте индексы под реальные запросы.
  8. Отдельно решите, где нужна денормализация.
  9. Спроектируйте миграции с учетом существующих данных.
  10. Проверьте критичные запросы через EXPLAIN.

Собеседование: как отвечать

Что такое нормализация?

Это процесс организации данных так, чтобы уменьшить дублирование и исключить аномалии вставки, обновления и удаления. Обычно обсуждают 1NF, 2NF, 3NF, BCNF и дальше.

Всегда ли нормализация хороша?

Нет. Нормализованная write model хороша для целостности, но некоторые read-сценарии требуют денормализации: счетчики, snapshots, materialized views, read models. Важно понимать источник истины и механизм синхронизации.

Что выбрать: natural key или surrogate key?

Часто primary key делают surrogate, потому что он стабилен и компактен. Natural key фиксируют через UNIQUE, если он отражает бизнес-уникальность. Изменяемый natural key как PK обычно создает боль со ссылками.

Почему индекс не всегда ускоряет?

Индекс помогает только определенным запросам, но замедляет запись, занимает место и может быть бесполезен при низкой селективности или неподходящем порядке колонок. Нужен EXPLAIN, статистика и понимание конкретного запроса.

Как безопасно добавить NOT NULL колонку?

Обычно: добавить nullable-колонку, начать писать значение, сделать backfill, проверить отсутствие NULL, затем добавить NOT NULL. На большой таблице важно не держать долгую транзакцию и не блокировать production.


Что запомнить

  • Нормализация уменьшает дублирование и защищает от аномалий изменения данных.
  • Surrogate primary key удобен как техническая ссылка, natural key часто фиксируют через UNIQUE.
  • Денормализация допустима, если известен source of truth и механизм синхронизации.
  • Индекс проектируют под запрос, а не "на всякий случай".
  • Миграции больших таблиц требуют expand-contract, backfill батчами и проверки locks.

Практика

  1. Возьмите сущности учебной платформы и выпишите functional dependencies: курс, модуль, урок, пользователь, прогресс.
  2. Найдите пример нарушения 1NF/2NF/3NF и разложите таблицу.
  3. Добавьте read model для dashboard прогресса и опишите, откуда она обновляется.
  4. Спланируйте безопасную миграцию добавления обязательного display_name.

Интерактивная практика

Quiz+10 XP

Что лучше всего описывает безопасную денормализацию?

  • Скопировать данные в новую таблицу и забыть source of truth
  • Удалить constraints, чтобы writes стали быстрее
  • Явно назвать source of truth и способ синхронизации read model
  • Всегда хранить всё в JSONB, чтобы не думать о схеме
Predict+15 XP

Что выведет этот код?

sql
WITH tables(name, source_of_truth) AS ( VALUES ('courses', true), ('course_progress_dashboard', false) ) SELECT CASE WHEN source_of_truth THEN 'normalized' ELSE 'read-model' END AS role FROM tables ORDER BY source_of_truth DESC;

Полезные источники