Як ми будували data-платформу: архітектура, неймінг, ELT, модульність і робота з legacy
Привіт усім! Мене звати Андрій, я — Data Engineering Lead у продуктовій компанії HOLYWATER з екосистеми Genesis. Уже понад 8 років я працюю в ІТ, останні чотири будував data-платформу в HOLYWATER. За цей час ми кілька разів змінювали підходи, ламали власні рішення й поверталися до простіших.
Один з висновків, до якого ми дійшли: якщо архітектуру та неймінг зробити правильно, потреба в «повноцінній» документації різко зменшується. Але далі починаються питання стійкості пайплайнів, типів даних, backfill’ів, «джерела правди», модульності й того, як не перенести legacy-складність у нову систему.

З чого все почалося
Перше, з чим я зіштовхнувся — потреба каталогізації даних. На старті усі дані зберігались у DWH, джерел було багато. Це було для нас плюсом, адже зазвичай дані розкидані різними базами даних. З мінусів — регулярно виникали питання на кшталт «Звідки беруться дані?», «Як зберігаються?», «Де лежать?» та «Для чого використовуються?».
Ми спробували описати ці дані й досить швидко зрозуміли: описаний хаос не перестає бути хаосом. Документація застаріває, каталоги потребують постійної підтримки, а відповіді на базові питання все одно залишаються неочевидними.
Тоді ми вирішили сфокусуватися на іншому й зробити так, щоб сама структура даних відповідала на ці питання без додаткових пояснень.
Неймінг як частина архітектури

Ключове рішення — винести сенс у назву. Назва таблиці або view* має одразу відповідати на три питання:
- звідки дані;
- на якому етапі трансформації;
- для чого вони призначені.
Так з’явилась layered data architecture з self-documenting неймінгом.
*У нашому випадку під назвою таблиці йдеться також про назву датасету (схеми).
Шари даних:

import_*
Сирі дані з зовнішніх джерел:
- import_stripe
- import_facebook_ads
Жодних трансформацій — лише збереження «як є» (raw файл або навіть JSON-рядком).
Якщо дані віддаємо назовні для якогось сервісу — використовуємо export_*.
stage_*
Проміжний шар, де відбувається:
- дедублікація;
- перейменування колонок;
- маскування;
- об’єднання різних endpoint’ів одного джерела.
Приклад: stage_stripe.
core_*
На цьому рівні це вже не просто «дані зі Stripe чи з Facebook», а показники про бізнес, тобто це revenue або marketing_spend. Для дебагу буде не зайвим додати колонку із назвою таблиці джерела.
dm_* (data marts)
Шар для репортингу.
Назва завжди явно містить гранулярність, наприклад dm_user_date_revenue (доходи розбиті на користувача та дату).
Гранулярність — ключова характеристика датамарту після самих метрик.
sandbox_*

Єдине місце, де дозволено руками створювати й видаляти таблиці та view — це персональні sandbox аналітиків.
service_* — технічні таблиці, логи, службові дані.
dictionary_* — довідники (країни, валюти тощо).
Цей підхід спростив навігацію, зменшив кількість питань «куди класти/де шукати» і дав структурну основу для подальшої модульності, зводячи документацію до опису неймінгу (шарів).
Правила неймінгу, які ми для себе зафіксували

Це дрібниці, але саме вони зменшують когнітивне навантаження в щоденних задачах.
- Не використовуємо абревіатури, якщо це не загальноприйняті поняття. Не економимо літери коштом чужих мізків.
- Не використовуємо множину: user, а не users.
- Не використовуємо all:
revenue_ad — це дохід з реклами,
revenue — це увесь дохід. - Не додаємо слова, що не несуть сенсу: table/data/value та інші.
- Назва описує отриману сутність, а не спосіб її отримання: success_transaction, а не transactions_filtered. Це дає змогу абстрагуватися від upstream-логіки й значно спрощує рефакторинг.
Далі постало не менш критичне питання: як зробити пайплайни стійкими до змін, збоїв і зростання. Тут і з’являються ELT, типи даних, idempotency та рішення, які дали нам змогу перестати боятися backfill’ів.
Чому ми перейшли на ELT
Одна з проблем — падіння конвеєрів даних.
Серед причин:
- у джерелі з’явилась нова колонка*;
- змінився тип поля;
- API повернуло неочікувану структуру.
*Важливо не використовувати всюди «*», аби перелік колонок був детермінований, а поява нової колонки не руйнувала пайплайни.
Якщо пайплайн падає на етапі трансформації — дані втрачаються. А втрачати дані через те, що не вдалося їх розпарсити через нову колонку чи тип, доволі неприємно. Аби цього уникнути й не робити ручний backfill у випадку падіння, ми перейшли на ELT.
- Load — зберігаємо дані «як є» (raw файл або JSON рядком).
- Transform — парсимо, нормалізуємо й агрегуємо вже всередині DWH.
Таким чином, навіть якщо схема даних змінилася — load відпрацював коректно, а transform-крок можна змінити під нову схему й запустити із потрібного нам місця, не будуючи два окремі пайплайни для штатного та аварійного завантаження даних.

Трохи особливостей використання різних типів даних
Типи даних — ще одне джерело прихованих проблем.
- string — для ідентифікаторів. Часто вони прилітають з різних джерел в одну колонку та можуть бути схожими на integer. Але з іншого джерела це може бути uuid чи base64. Автовизначення типів — зло. Адже ідентифікатор виду «36478890184» у BigQuery легко приводиться до дати «Monday, December 21, 3125 9:03:04 AM».
- integer — конкретна кількість/лічильники.
- float — обʼєм і суми з дробами.
- datetime — для дат, завжди в UTC, щоб не думати про часові зони.

Діапазони замість «за вчора»
Пайплайн, що обробляє батчі, має залежати від діапазону дат у вхідних параметрах, а не обробляти дані «за вчора». Це:
- спрощує backfill даних при падінні, зміні схеми;
- дозволяє більш гнучко працювати із великими періодами часу, щоб пробігтися по всьому попередньому року, простіше передати діапазон, аніж 365.2425 раз викликати джобу;
- дає гнучкість при переході на частіші батчі та дозволяє розбити день на менші частини — до прикладу, по 8 годин.
Discrepancy, SSOT і модульність

На певному етапі до нас почали регулярно приходити з питанням: «Чому одна й та ж метрика в різних таблицях має різні значення?»
Типові поради:
- навісити більше тестів;
- додати семантичний шар.
Ми почали з іншого — дослідження причини розбіжностей. Результат — виявили, що проблема майже завжди виникала через дублювання логіки розрахунків у різних місцях.
Розрахунки повторюються, але з певними відмінностями, що викликані хаотичністю розробки через відсутність пріоритетів, поспіхом, плаваючими відповідальностями... Та ми тут про технічні виклики. Отож, ми пішли шляхом модульності:
- метрика рахується в єдиному місці;
- використовується в кількох датамартах;
- зміни вносяться централізовано.
Розрахунки декомпозуються згідно з naming convention і відповідальністю шарів.
Результат: зменшення discrepancy, швидші правки та вища довіра до даних.
Швидкість vs якість: як ми домовились із бізнесом

Запит «зробіть якось, аби швидше» — неминучий. І, як ми знаємо, nothing`s more permanent than a temporary solution. Ще на початку своєї кар’єри я зауважив: менеджери часто просять реалізувати завдання швидко і водночас оцінюють його виконання за якістю. Сліпе слідування вимогам дуже швидко призводить до скриптів-милиць, крихкості системи, відсутності довіри до даних, неможливості підтримки й розвитку.
Ми спробували розглянути бізнес-замовників як наших партнерів, а не ворогів інженерії, та почали шукати спосіб працювати і швидко, і якісно. Рідко це можна зробити одночасно, тому для себе зафіксували процесний контракт:
- MVP (aka «на милицях») можна робити швидко за умови, що ми знаємо правильний спосіб (який довший);
- для MVP є дата/спринт, коли рішення допрацьовується;
- обмеження MVP чітко проговорені з бізнесом, щоб на його базі не ухвалювали ризиковані рішення.
Наприклад, для обробки S2S-подій ми швидко реалізували Cloud Function, але одразу зафіксували, що це тимчасове рішення до рефактору основного сервісу.
Legacy: як стикувати старе і нове

Тут все просто: створили нове, у 10 разів краще — переїхали, старе вимкнули — й насолоджуйтеся.
Звісно, я жартую. Деякі переїзди займають роки й часто постає питання: «Як стикувати частини нової інфраструктури зі старою?». В цьому випадку ми намагаємось адаптувати legacy під нове, бо legacy ми зрештою вимкнемо, а перенос складності в нову «чисту» інфраструктуру — це створення нового legacy.
Принцип: складність має залишатися в legacy, а не мігрувати в нову систему.
Для нас «нове» майже завжди означає «правильне». Тому базове правило звучить так: нові компоненти не повинні підлаштовуватися під старі обмеження, якщо ми плануємо від них відмовлятися. Якщо ж адаптувати нову архітектуру під legacy, ми фактично створюємо новий legacy, тільки з кращою документацією.
Практичний кейс: уніфікація аналітики кількох продуктів

Компанія розвиває багато застосунків. Колись їхня аналітика розвивалася паралельно в різних проєктах зі своїми особливостями. У новій архітектурі ми пішли шляхом уніфікації й створили спільне джерело для всіх. Умовно: є спільна таблиця маркетингових витрат із колонкою «назва застосунку». Далі частина legacy-розрахунків почала використовувати нове спільне джерело, але фільтрувати дані тільки для «свого» застосунку.
Таким чином:
- нова система розвивається незалежно;
- старі частини поступово «підʼїжджають» до неї;
- ми не дублюємо логіку у двох місцях.
Звісно, виключення можуть бути й без compatibility layer не обійтися. У таких випадках ми, до прикладу:
- додаємо окремі поля для зворотної сумісності з legacy (legacy_app_name);
- не використовуємо ці поля в нових розрахунках.
Ключова ідея — можливість легко відʼєднати legacy, коли настане час. Якщо для цього достатньо видалити одну колонку або джойн — значить рішення було правильним.
Підсумок

У цій частині ми:
- заклали фундамент data-платформи через layered architecture і self-documenting неймінг;
- перейшли на ELT, щоб зробити пайплайни стійкими до змін і backfill’ів;
- зменшили discrepancy між метриками завдяки модульності й SSOT;
- виробили підхід до компромісів між швидкістю, якістю та legacy.
У наступній частині я розповім про:
- те, чим workaround відрізняється від «милиць»;
- «не ускладнювати» як головний принцип розробки;
- data-продукти, незалежні від застосунків;
- observability, алерти й контроль костів.
Якщо знаєте краще, як з’їжджати із legacy, поділіться інструкцією в коментарях :)
17 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівСтаття клас, дуже цікаво!
Дякую!
маю продовження dou.ua/forums/topic/57650
з даними не працюю але цікаво
особливо початкова система про неймінги і підхід до цього
Дякую, Станіславе!
Боротьба з ентропією може набувати різних форм:)
Найближчим часом вийде продовження
Дуже цікаво!
Радий чути!
Дякую за статтю, досить гарно продуманий підхід до побудови архітектури. На нашому проєкті ми використовуємо концепцію MIO (Managed Information Object) і вся архітектура побудована навколо MIOs (можна почитати тут: www.linkedin.com/...l-redshift-bruno-freitag). Цей підхід схожий в тому, що використовується ELT. Але знаючи всі його недоліки, розумію що підхід, описаний тут, гарно вирішує багато з них. Особливо коли DWH дуже розростається, JOIN операції між різними MIO стаються надто заплутаними.
Дякую за коментар і цікаве посилання на концепцію MIO!
Ми вже встигли поспілкуватися з Юрієм «off the record» про виклики оперування великим контекстом на рівні організації. Дійшли висновку, що виклики в компаніях різного розміру так чи інакше схожі. Все зводиться до типових питань поділу відповідальності: як між командами, так і між частинами інфраструктури (наприклад, через шари).
Ті ділянки, що обділені увагою, неминуче відставатимуть, а технічний борг там лише накопичуватиметься. Саме тому ми намагаємося фіксувати архітектурні «правила гри» ще на старті.
Який у Вас набір інструментів/ технологічний стек на проекті?
В якому шарі виконуєте версіонування і чи є у Вас датамарти з Time Travel?
Де виконуються flatten-like операції для складних структур даних?
На якому шарі зʼявляються перші дата модельки і яку взагалі архітектуру DWH обрали?
Стек: Наш підхід базується на Modern Data Stack в екосистемі GCP. Використовуємо BigQuery як основне сховище, Airflow для оркестрації та Cloud Functions / PubSub / GCS. Для підключення джерел також комбінуємо Fivetran та Airbyte (Open Source). Трансформації — на dbt, а вся інфраструктура описана через Terraform (IaC).
Версіонування: Ми підтримуємо історичність (SCD2 / Snapshots) на різних рівнях, залежно від бізнес-логіки.
Flatten-операції: Ми виконуємо розгортання складних структур саме в Stage-шарі. Це дозволяє ізолювати «сирі» формати (JSON, вкладені структури) і передавати в наступні шари вже структуровані пласкі таблиці з чіткою схемою.
Моделювання та архітектура: Ми дотримуємося Layered Architecture з елементами Data Vault. Логіка наступна: у Stage ми можемо робити Join-и в межах таблиць одного джерела для первинної очистки. А от об’єднання різних джерел та формування єдиних бізнес-сутностей відбувається виключно на рівні Core. Це дозволяє тримати Core як «Single Source of Truth».
Якщо є додаткові питання щодо архітектурних нюансів або хочете обговорити детальніше — буду радий поспілкуватися в особистих: LinkedIn
Це ж не версіонування, а просто фіксація історії змін. Версіонування передбачає зміну схеми данних, а не самі данні.
Дмитре, опишіть, будь ласка, use-case, щоб я міг відповісти предметно
OLTP system (data source) — table `Address`
1. Address schema version 1 (Schema Active since-to: 2024-01-01 - 2024-12-31) CREATE TABLE Address ( StreetLine NVARCHAR(100), # e.g.: Lincoln street 30 ... ) 2. Address schema version 2 (Schema Active since-to: 2025-01-01 - 2024-06-01) CREATE TABLE Address ( Street NVARCHAR(100), #e.g.: Lincoln StreetKind NVARCHAR(20), # e.g.: street BuildingNum NVARCHAR(10), # e.g.: 30 ... ) 3. Address schema version 3(Schema Active since-to: 2025-06-02 - ..) CREATE TABLE Address ( StreetAddress JSON # e.g.: {"name": "Lincoln", "kind": "street", "building": "30", block: "B" } )Потрібно зробити репорти де фільтраціі/агрегація/сортування може відбуватись по складовим Address за період відДякую за наочний приклад, це типова ситуація! Ми проходили етап, коли підтримували кілька версій парсерів одночасно, але згодом обрали більш прагматичний шлях, щоб не накопичувати технічний борг у коді трансформацій.
Наш підхід у таких випадках наступний:
* Стандартизація: Якщо історичні дані (версії 1 та 2) вже статичні й не оновлюються, ми виконуємо одноразову зміни трансформації до найактуальнішої структури. Немає сенсу тримати старі парсери в активному коді, якщо дані «заморожені».
* Forward-migration даних: Ми адаптуємо історію під нову структуру, а не навпаки. Це забезпечує стабільний контракт для Core-шару. Для полів, яких раніше не існувало, використовуємо null або дефолтні значення-заглушки.
* Спрощення логіки: Це дозволяє нам мати одну чисту модель у dbt, яка працює за єдиним стандартом, замість того, щоб нагромаджувати проєкт складними CASE або UNION для кожної зміни схеми в джерелі.
Такий підхід значно спрощує підтримку репортингу в майбутньому, оскільки аналітики завжди працюють із передбачуваною структурою, незалежно від «віку» даних.
Тобто ви з кожним оновленням схеми oltp системи плануєте новий реліз і міграцію всього репортингу і всієї логіки у всіх репортах під кожну зміну source таблиці щоб ваша схема відповідала останній версії контракту джерела? У вас напевно дуже відповідальні команди залежних систем, що у вас э для цього можливість і час) правда я назвав би це не прагматичний, а ідеалістичний скоріше варіант)
🤔Цікавий кейс! На щастя, ми з такою частою зміною схеми не зіштовхувалися: у нашому випадку це стається1-2 рази на рік, а деструктивні зміни — ще рідше. А чим викликана така періодичність у вас? Це якась особливість бізнесу чи специфіка джерел даних?
Хіба Airflow з dbt при всій повазі — це не той дядько в зеленому костюмі на ваших слайдах?