Межа даних: чому ui-chats не залежить від chat-web-sdk
Design decision (ADR-стиль). Статус: прийнято.
Контекст
@webitel/ui-chats — презентаційний Vue-пакет, що відмальовує UI чатів (ChatContainer та внутрішні компоненти). Дані надходять з @webitel/chat-web-sdk — тонкої обгортки над згенерованими бекенд-DTO (WebitelImApiGatewayV1*, orval). Ці бекенд-контракти фронтенд-команда не контролює — може впливати лише на саму обгортку.
Питання: чи робити chat-web-sdk залежністю ui-chats і передавати його IMessage/IThread напряму в UI, чи ui-chats оголошує власні вхідні інтерфейси, а конвертацію SDK→UI виконує застосунок-споживач?
Рішення
ui-chats тримає власні вхідні типи (ChatMessageType, ChatMember, ChatMessageFile) як опублікований контракт. Пакет не залежить від chat-web-sdk. Конвертація chat-web-sdk → типи ui-chats — обов'язок застосунку-споживача (agent-workspace-app) як anti-corruption layer.
Обґрунтування
1. Напрям залежностей
chat-web-sdk — волатильний: його типи розширюють raw бекенд-DTO, а бекенд поза контролем фронтенд-команди. ui-chats — стабільний переюзабельний UI. Стабільне не має залежати від нестабільного. Прив'язка UI до SDK → UI ламається щоразу, як бекенд змінює поле.
2. Переюзабельність
ui-chats живе в webitel-ui-sdk (спільний UI-kit). Залежність від chat-web-sdk затягнула б у нього весь SDK + @webitel/api-services (generated models) + транспортний шар. Постраждали б Storybook, тести та інші потенційні споживачі, яким не потрібен саме цей бекенд.
3. Поведінкові сутності не місце в презентації
IMessage/IThread — не plain data, а інстанси класів з методами (markRead, sendMessage) і ServiceConfigurable, що несе transport config. Передати таке в message-bubble = протягнути сервісний/транспортний шар у презентацію. Симптом уже видно: store в застосунку тримає їх у shallowRef, бо це живі об'єкти з методами.
4. Тестованість
Plain ChatMessageType — тривіальні фікстури. IMessage вимагає фабрик/моків із config. Простий вхідний контракт робить компоненти дешевими для тесту.
5. Володіння конвертацією
Мапити один світ в інший може лише той, хто знає обидва — це composition-шар, тобто застосунок. Це класичний anti-corruption layer.
Наслідки
- Потрібен mapper
IMessage → ChatMessageType, що годуєthe-chat-window.vue, де:messagesнаразі[]. Прецедент трансформації — inline-маппінг уchat-preview.vue. Де живе цей mapper — див. секцію нижче. - Ціна: boilerplate маппінгу + два набори типів треба тримати в синку. Це свідома ціна за здоровий шов.
ChatMessageTypeтрактується як публічний APIui-chats: чистимо від бекенд-присмаку (channelId,via, raw-stringmember.type), документуємо, версіонуємо. (Дрібний борг:chat-message.vueчитаєfile.malware, не оголошене вChatMessageFile— прибрати при формалізації контракту.)
Де живуть адаптери
Sub-decision. Статус: прийнято тимчасово (варіант C) — з явним тригером перегляду на користь B.
Adapter за визначенням залежить від обох пакетів (ui-chats + chat-web-sdk), тож постає окреме питання: у якому пакеті йому жити. Було розглянуто 4 варіанти; жоден не є безкоштовно-чистим — це справжній конфлікт вибору, тому фіксуємо і рішення, і аргументи проти нього.
Розглянуті варіанти
| # | Розташування | Головний аргумент ПРОТИ |
|---|---|---|
| C (обрано наразі) | subpath export у ui-chats: @webitel/ui-chats/adapters, chat-web-sdk як optional peerDependency | Тягне backend-SDK у dependency-граф загального UI-kit; інвертує напрям стабільності (stable ui-chats починає посилатися на unstable chat-web-sdk); реліз UI-kit чіпляється за churn бекенду |
| A | subpath export у chat-web-sdk | chat-web-sdk потенційно публічний (для інтеграторів); не можна тягнути в нього внутрішню UI-либу, якою користується лише продуктова фронтенд-команда |
| B | окремий glue-пакет @webitel/chat-ui-adapter (обидва як peerDeps) | +1 пакет і реліз-цикл на утримання зараз |
| D | у кожному застосунку окремо | Дублювання: обидва пакети юзатиме кілька наших фронтенд-апів |
Конфлікт вибору
Аргументи тягнуть у різні боки, і це не вирішується «однією правильною відповіддю»:
- Напрям стабільності (проти C, за A/B): stable не має залежати від unstable; загальний UI-kit не має нести backend-специфічний SDK.
- Публічність SDK (проти A, за B/C):
chat-web-sdkможе бути виставлений «в світ» для інтеграторів. Їм adapter під внутрішню либу зайвий, і публічний артефакт не повинен посилатися на приватний UI-пакет. - Аудиторія = перетин (за B): adapter потрібен лише тим, хто юзає обидва пакети — це вужче за аудиторію будь-якого з ендпоінтів. Найчистіше — окремий пакет, скоуплений саме під цей перетин.
chat-web-sdk consumers: багато, зовнішні (інтегратори) — ui-chats НЕ треба
ui-chats consumers: мало, внутрішні
adapter потрібен: тільки перетину (юзає обидва) ⊂ внутрішніФормально «найчистіший» — B. Але наразі свідомо беремо C.
Чому C зараз
- Один UI-таргет (
ui-chats) і жменя внутрішніх споживачів — окремий пакет (B) дає більше накладних (репо/реліз/версіювання), ніж економить. - Через subpath export core-вхід
@webitel/ui-chats/uiлишається чистим:chat-web-sdk— optionalpeerDependency, tree-shaking відрізає його для тих, хто імпортує лише/ui. Bundle-cost для не-споживачів adapter'а = 0. - Одне джерело правди для
ChatMessageType(контракт) і його adapter'а — поряд, у одному репо; синк контракту й маппінгу простіший.
Ціна C і тригер переходу на B
Свідомо приймаємо: реліз ui-chats тепер логічно зчеплений з формами chat-web-sdk (breaking change SDK → апдейт adapter'а → republish).
Мігрувати на B (окремий пакет), коли справдиться будь-що з:
- з'явиться другий UI-таргет (не лише
ui-chats), що мапить зchat-web-sdk; - adapter захоче окремий реліз-цикл від
ui-chats; - churn
chat-web-sdkпочне помітно смикати релізи UI-kit.
Схема потоку
chat-web-sdk (IMessage[])
│ @webitel/ui-chats/adapters (anti-corruption layer; варіант C)
▼
ChatMessageType[] ──► <ChatContainer :messages> (@webitel/ui-chats/ui)Контракт ui-chats (вхід)
Джерело істини: ui-chats/src/ui/messaging/types/ChatMessage.types.ts. ChatContainer приймає messages: ChatMessageType[]; кожен елемент — plain об'єкт (id, text, createdAt, member, опційний file, тощо). Жодних методів, жодного transport config.