На public Category page у ICanUp я помітила неочевидну невідповідність.
Список Post уже формувався рекурсивно: сторінка батьківської категорії могла показувати Post не тільки з неї самої, а й із вкладених public categories на кількох рівнях.
Але блок навігації категоріями показував лише безпосередніх дітей першого рівня.
У результаті користувач міг бачити Post із глибоко вкладеної категорії, але не бачити саму цю категорію серед доступних посилань.

Простий приклад дерева показав проблему
Уявімо три рівні категорій.
Development|+-- Backend| || +-- Laravel|+-- DevOpsЯкщо відкрити Development, recursive feed може коректно включати Post із Laravel.
Але direct-children navigation покаже тільки Backend і DevOps.
Самої категорії Laravel користувач на цій сторінці не побачить, хоча її контент уже є у feed.
Тут існували два різні tree contracts
Проблема стала зрозумілішою, коли я перестала дивитися на це як на один category query.
На сторінці насправді працюють два різні контракти.
Контракт | Scope |
|---|---|
Post feed | Поточна category + усі доступні public descendants |
Category navigation | Лише direct public children |
Кожен із цих контрактів може бути правильним у певному інтерфейсі.
Наприклад, меню часто навмисно показує тільки один рівень дерева.
Але на цій конкретній сторінці navigation одночасно пояснює, з яких категорій складається recursive content scope. Саме тому різні scopes стали проблемою.
Recursive feed я не хотіла змінювати
Найпростіший спосіб зробити обидва блоки однаковими міг би бути неправильним: обмежити feed тільки direct children.
Але recursive feed уже мав потрібну продуктову поведінку.
Сторінка великої категорії повинна збирати релевантний контент із її public subtree, а не змушувати користувача відкривати кожен рівень окремо.
Тому змінювати потрібно не scope контенту, а category navigation contract.
Правило стало простим: якщо content scope recursive, category scope теж має його пояснювати
Current category | +-----------------------+ | | v vPost feed Category list | | v vpublic descendants public descendants Same subtree.Different presentation.Це не означає, що feed і navigation повинні використовувати однаковий SQL або повертати однакові DTO.
Вони вирішують різні presentation задачі.
Але boundary дерева має бути спільною: обидва блоки працюють у межах одного current category subtree.
Direct children все одно залишаються descendants
Перехід від direct children до all descendants не означає, що перший рівень зникає.
Безпосередні дочірні categories залишаються у результаті, просто до них додаються доступні public categories із глибших рівнів.
Before: Development|+-- Backend+-- DevOps Expected category scope: Development|+-- Backend| || +-- Laravel|+-- DevOpsАле all descendants не означає всі categories у базі
Після слова "recursive" легко випадково розширити query надто далеко.
Потрібні лише descendants поточної category branch.
Sibling branch або зовсім інша root category не повинні з'явитися в navigation лише тому, що вони теж public.
Включати | Не включати |
|---|---|
Direct public children | Categories з іншої branch |
Public grandchildren | Inactive categories |
Глибші public descendants | Hidden categories |
Descendants у стабільному tree order | Deleted categories |
Public visibility є частиною tree contract
Мені недостатньо просто отримати всі descendant IDs.
Public Category page повинна працювати тільки з categories, які дозволено показувати користувачу.
Inactive, hidden або deleted node не повинна раптом з'явитися лише через те, що recursive tree query її технічно бачить.
Дублікати тут теж були б помилкою контракту
Якщо descendant scope будується в кількох місцях або об'єднується з direct children окремо, легко отримати одну category двічі.
Тому результат має бути не просто recursive, а deterministic: одна category, одна позиція, стабільний порядок.
Порядок дерева не можна замінити випадковим order by name
Ще одна деталь - порядок.
Category tree має власну структуру і порядок вузлів. Якщо descendants після query просто відсортувати за назвою або id, navigation може перестати відповідати структурі дерева.
Recursive scope повинен зберігати tree/order contract домену.
Expected: Backend Laravel SymfonyDevOps Docker Nginx Not an arbitrary flat sort: BackendDockerLaravelNginxSymfonyDevOpsНе обов’язково писати другу рекурсію вручну
Для мене важливий ще один архітектурний принцип.
Якщо category domain уже вміє визначати descendants, public feed і navigation не повинні кожен окремо винаходити власну рекурсію.
Інакше з часом одна реалізація почне враховувати visibility, depth або ordering інакше за іншу.
/* * Conceptual contract, not the project API. */ $publicDescendantIds = $treeScope ->for($currentCategory) ->publicDescendants(); $feedCategoryIds = [ $currentCategory->id, ...$publicDescendantIds,]; $navigationCategoryIds = [ ...$publicDescendantIds,];Тут важлива не назва методу, а сам напрямок залежності: descendant scope визначається один раз як domain rule, а feed і navigation використовують його для своїх задач.
Localization не повинна змінювати tree membership
У ICanUp UK і EN використовують ті самі category entities та shared slug architecture.
Тому localization відповідає за правильний public URL і текст category link, але не повинна змінювати саме дерево.
Один і той самий descendant має залишатися descendant незалежно від locale.
UK: /categories/laravel EN: /en/categories/laravel Same category node.Same subtree membership.Localized public URL.Це не завдання на зміну slug або SEO architecture
Виправлення tree scope не потребує нового slug contract, зміни canonical або перебудови hreflang.
Category links повинні просто використовувати вже існуючий localized routing layer.
Це важлива межа scope: не потрібно перетворювати невеликий navigation bug на переписування URL architecture.
Найкращий regression test потребує щонайменше трьох рівнів
Якщо тест створює тільки parent і child, він не відтворює цей bug.
На одному рівні direct children і recursive descendants фактично дають той самий набір.
Проблема стає видимою лише коли з'являється хоча б grandchild.
Root|+-- Child A| || +-- Grandchild A1| || +-- Post X|+-- Child B Other Root|+-- Unrelated Category- Root page показує
Child A. - Root page показує
Grandchild A1. - Recursive feed Root містить
Post X. - Inactive descendant не показується.
- Hidden або deleted descendant не показується.
- Category з
Other Rootне потрапляє в результат. - Одна category не дублюється.
- Порядок відповідає tree contract.
- UK і EN links ведуть на правильні localized routes.
- Поточний recursive Post feed не регресує.
Mobile UI теж важливий, хоча bug був на рівні даних
Після переходу від одного рівня до всіх descendants кількість category links природно може зрости.
Тому backend fix недостатньо перевірити лише тестом response data.
Chips або category links мають залишатися придатними для використання на desktop і mobile без виходу за межі контейнера чи поламаного wrapping.
Коли direct children справді були б правильними
Я не вважаю direct-children navigation неправильною сама по собі.
У hierarchical menu вона часто є саме тим, що потрібно.
Різниця в семантиці сторінки.
UI | Логічний scope |
|---|---|
Tree navigation menu | Direct children можуть бути правильними |
Breadcrumbs | Ancestors поточного node |
Recursive category feed | Current node + descendants |
Category filters, що пояснюють recursive feed | Ті самі public descendants, що формують content scope |
Тому головний урок цього кейсу не "завжди показувати всі descendants".
Головний урок - спочатку визначити семантичний contract кожного представлення дерева.
Що я б не робила
- Не змінювала б recursive Post feed на direct-only лише для узгодження з navigation.
- Не отримувала б усі public categories без обмеження current subtree.
- Не включала б inactive, hidden або deleted descendants.
- Не об’єднувала б direct children і descendants без deduplication.
- Не сортувала б tree result випадковим способом, що руйнує hierarchy/order.
- Не писала б окрему recursive logic у кожному query, якщо domain уже має tree abstraction.
- Не змішувала б цей fix зі зміною shared slug architecture.
- Не перевіряла б regression лише на двох рівнях hierarchy.
У дереві даних правильний query ще не гарантує правильний інтерфейс. Кілька блоків однієї сторінки повинні домовитися, яку частину дерева вони представляють.
Що я залишила собі на майбутнє
- Для кожного tree-based UI явно визначати current node, ancestors, children або descendants.
- Не вважати direct children і recursive descendants взаємозамінними поняттями.
- Якщо navigation пояснює recursive content scope, використовувати спільну subtree boundary.
- Public visibility застосовувати окремо від tree membership.
- Не дозволяти category з іншої branch потрапити в descendant result.
- Зберігати domain tree order.
- Regression test для recursive behavior будувати мінімум на трьох рівнях.
- Localization застосовувати до URL і presentation, а не до membership дерева.
- Перевіряти responsive UI після збільшення кількості descendant links.
Висновок
У цьому кейсі recursive feed у ICanUp робив саме те, що від нього очікувалося: збирав Post із усього public category subtree.
Невідповідність виникла поруч: navigation показувала лише direct children і тому не відображала повний scope контенту, який користувач уже бачив нижче.
Правильний напрямок для fix - не прибирати рекурсію з feed, а дати обом блокам спільну boundary дерева: current category subtree з public descendants, стабільним порядком і без categories з інших branches.
Найцікавіше тут те, що обидва queries могли бути локально правильними. Bug з'явився через те, що їхні contracts не збігалися на рівні всієї сторінки.



