UNPKG

@maxdev1/sotajs

Version:

A TypeScript framework for building applications using Hexagonal Architecture and Domain-Driven Design principles

460 lines (362 loc) 29.7 kB
# SotaJS: Руководство по архитектуре и разработке ## 1. Введение: Философия фреймворка Sota (Сота) — это TypeScript-фреймворк для разработчиков, которые считают, что бизнес-логика является самым ценным активом проекта. Он предлагает простой, функциональный и мощный способ создания приложений с использованием принципов Гексагональной Архитектуры и подходов Domain-Driven Design (DDD). Ключевые принципы Sota: - **Фокус на бизнес-логике:** Фреймворк направляет разработку таким образом, чтобы в центре внимания всегда оставалась предметная область. - **Явные зависимости:** Вместо "магии" и скрытых механизмов, Sota использует прозрачный хук `usePort()` для объявления зависимостей, что делает код легко отслеживаемым и тестируемым. - **Функциональный подход:** Логика приложения описывается в виде простых асинхронных функций (Use Cases), что уменьшает количество шаблонного кода по сравнению с традиционными сервисными классами. - **Тестируемость как основа:** Архитектура спроектирована для легкого тестирования. Комбинация чистой доменной логики и явных зависимостей позволяет тестировать бизнес-процессы в полной изоляции. - **Разделение логики и представления:** Use Cases не возвращают данные напрямую. Вместо этого они сообщают о результатах своей работы через **выходные порты (Output Ports)**, делая бизнес-логику полностью независимой от способа представления данных (API, консоль, UI). ## 2. Ключевая терминология - **Доменный объект (Domain Object):** Общее название для Агрегатов, Сущностей и Объектов-значений, которые моделируют предметную область. - **Агрегат (Aggregate):** Кластер доменных объектов (Сущностей и Объектов-значений), который рассматривается как единое целое для обеспечения транзакционной целостности. Агрегат — это основная единица сохранения и загрузки из репозитория. - **Сущность (Entity):** Объект с уникальным идентификатором и жизненным циклом. Его идентичность сохраняется, даже если его атрибуты меняются. - **Объект-значение (Value Object):** Неизменяемый объект, который определяется своими атрибутами, а не уникальным идентификатором (например, `Money` или `Address`). - **Use Case:** Атомарная операция на уровне приложения, которая оркеструет взаимодействие между доменными моделями и внешними сервисами. **Use Case ничего не возвращает**, а о результатах сообщает через выходные порты. - **Порт (Port):** Абстрактный контракт (тип) для взаимодействия с внешней инфраструктурой (например, с базой данных). - **Выходной порт (Output Port):** Особый вид порта, который Use Case вызывает для уведомления внешнего мира о результате операции (например, `userCreatedOutPort`). Всегда именуется с суффиксом `OutPort`. - **Адаптер (Adapter):** Конкретная реализация порта. Адаптеры для портов данных содержат технологическую логику (SQL, HTTP), а для выходных портов — логику представления (отправка JSON, рендеринг HTML). - **DTO (Data Transfer Object):** Простой объект для передачи данных между слоями. ## 3. Основные строительные блоки: Доменные объекты Проектирование домена в SotaJS начинается с простейших концепций и движется к более сложным. Сначала определяются Объекты-значения и Сущности, и только потом они группируются в Агрегаты для защиты бизнес-правил. ### 3.1. Объекты-значения (Value Objects) Объект-значение — неизменяемый объект без ID, определяемый своими атрибутами. Для создания VO используется функция `createValueObject`, которая принимает конфигурационный объект со схемой `zod` и опциональным списком `actions`. > **Критерий выбора:** Задайте вопрос: "Описывает ли этот объект некую характеристику, не имеет собственной идентичности и полностью определяется своими атрибутами (как цвет или сумма)?" Действия (`actions`) в Value Object **изменяют его внутреннее состояние** напрямую, аналогично поведению `Entity`. Это позволяет использовать более удобный синтаксис, без необходимости переприсваивания. ```typescript import { z } from 'zod'; import { createValueObject } from '@maxdev1/sotajs'; // 1. Определяем схему и тип const MoneySchema = z.object({ amount: z.number(), currency: z.string().length(3), }); type MoneyProps = z.infer<typeof MoneySchema>; // 2. Создаем класс Value Object с действиями export const Money = createValueObject({ schema: MoneySchema, actions: { add(state: MoneyProps, amountToAdd: number) { state.amount += amountToAdd; }, changeCurrency(state: MoneyProps, newCurrency: string) { state.currency = newCurrency.toUpperCase(); }, }, }); export type Money = ReturnType<typeof Money.create>; // --- Использование --- // const price = Money.create({ amount: 100, currency: 'USD' }); // price.actions.add(50); // price.props.amount === 150 (объект изменился) ``` ### 3.2. Сущности (Entities) Сущность — это объект с уникальным ID и жизненным циклом. Используйте `createEntity`, когда объект имеет собственную идентичность, но не является корнем агрегата. > **Критерий выбора:** Задайте вопрос: "Является ли этот объект уникальной 'вещью', которую нужно отслеживать во времени, даже если ее свойства изменятся? Важна ли ее история?" ```typescript import { z } from 'zod'; import { createEntity } from '@maxdev1/sotajs'; const UserProfileSchema = z.object({ id: z.string().uuid(), username: z.string(), bio: z.string().optional(), }); export const UserProfile = createEntity(UserProfileSchema, 'UserProfile'); export type UserProfile = ReturnType<typeof UserProfile.create>; ``` ### 3.3. Агрегаты (Aggregates) Агрегат — это транзакционная граница, которая объединяет одну или несколько сущностей и VO для обеспечения их согласованности. Границы агрегата не всегда очевидны на старте. Они выявляются, когда появляется бизнес-требование. > **Критерий выбора:** Необходимость в агрегате возникает, когда бизнес-правило требует, чтобы изменения в нескольких сущностях происходили атомарно, в рамках одной транзакции. Если для сохранения консистентности вам нужно обновить Сущность А и Сущность Б вместе, они являются кандидатами на объединение в один агрегат. **Пример: Агрегат `Order`** В этом примере мы создадим агрегат `Order`, который включает в себя вложенную сущность `CustomerInfo`. Это покажет, как работать с богатой доменной моделью внутри агрегата, а также как использовать вычисляемые свойства. ```typescript import { z } from 'zod'; import { createAggregate, createEntity } from '@maxdev1/sotajs'; // 1. Определяем дочернюю сущность для информации о клиенте const CustomerInfoSchema = z.object({ id: z.string().uuid(), name: z.string(), address: z.string(), }); type CustomerInfoState = z.infer<typeof CustomerInfoSchema>; const CustomerInfo = createEntity({ schema: CustomerInfoSchema, actions: { updateAddress: (state: CustomerInfoState, newAddress: string) => { if (newAddress.length < 10) { throw new Error('Address is too short'); } state.address = newAddress; }, }, }); // 2. Определяем схему для самого агрегата Order const OrderSchema = z.object({ id: z.string().uuid(), status: z.enum(['pending', 'paid', 'shipped']), items: z.array(z.object({ productId: z.string(), price: z.number() })), // Включаем схему CustomerInfo как часть схемы Order customer: CustomerInfoSchema, }); type OrderState = z.infer<typeof OrderSchema>; // 3. Создаем агрегат с расширенной конфигурацией export const Order = createAggregate({ name: 'Order', schema: OrderSchema, // 3a. Указываем, какие свойства состояния являются сущностями entities: { customer: CustomerInfo, }, // 3b. Добавляем вычисляемые свойства computed: { isPaid: (state) => state.status === 'paid', totalPrice: (state) => state.items.reduce((sum, item) => sum + item.price, 0), }, invariants: [ (state) => { if (state.status === 'shipped' && state.items.length === 0) { throw new Error('Cannot ship an empty order.'); } }, ], // 3c. Actions теперь работают с "гидрированным" состоянием actions: { pay: (state) => { if (state.status !== 'pending') { throw new Error('Only pending orders can be paid.'); } state.status = 'paid'; }, // Action, который вызывает метод дочерней сущности updateCustomerAddress: (state, newAddress: string) => { // state.customer - это полноценный экземпляр CustomerInfo // с доступом к его методам. state.customer.actions.updateAddress(newAddress); }, }, }); // Тип экземпляра агрегата будет включать вычисляемые свойства export type Order = ReturnType<typeof Order.create>; // --- Использование --- // const order = Order.create({ id: '...', status: 'pending', ..., customer: { ... } }); // // // Доступ к вычисляемому свойству // console.log(order.totalPrice); // // // Вызов action, который делегирует работу сущности // order.actions.updateCustomerAddress('Новый адрес клиента'); // // // Получение чистого состояния для сохранения // const cleanState = order.state; // console.log(cleanState.customer.address); // 'Новый адрес клиента' ``` ## 4. Оркестрация: Use Cases и Порты Use Case — это `async` функция, которая является точкой входа для выполнения бизнес-операции. Она валидирует входные данные, использует хук `usePort` для получения зависимостей и **ничего не возвращает**. **Пример: `createOrderUseCase`** ```typescript import { z } from 'zod'; import { usePort, createPort } from '@maxdev1/sotajs/lib/di.v2'; import { findUserByIdPort, saveOrderPort } from '@domain/ports'; import { Order } from '@domain/order.aggregate'; // DTO для выходных портов type OrderCreatedOutput = { orderId: string; total: number; }; type OrderFailedOutput = { userId: string; reason: string; }; // Выходные порты для информирования о результате const orderCreatedOutPort = createPort<(dto: OrderCreatedOutput) => Promise<void>>(); const orderCreationFailedOutPort = createPort<(dto: OrderFailedOutput) => Promise<void>>(); const CreateOrderInputSchema = z.object({ userId: z.string().uuid(), items: z.array(z.object({ productId: z.string().uuid(), quantity: z.number().positive() })), }); type CreateOrderInput = z.infer<typeof CreateOrderInputSchema>; export const createOrderUseCase = async (input: CreateOrderInput): Promise<void> => { const command = CreateOrderInputSchema.parse(input); // Получение зависимостей, включая выходные порты const findUserById = usePort(findUserByIdPort); const saveOrder = usePort(saveOrderPort); const orderCreated = usePort(orderCreatedOutPort); const orderFailed = usePort(orderCreationFailedOutPort); const user = await findUserById({ id: command.userId }); if (!user) { await orderFailed({ userId: command.userId, reason: 'User not found' }); return; } try { const order = Order.create(command); await saveOrder(order); await orderCreated({ orderId: order.id, total: order.state.items.reduce((sum, i) => sum + i.price, 0) }); } catch (error: any) { await orderFailed({ userId: command.userId, reason: error.message }); } }; ``` ## 5. Внедрение зависимостей: связующее звено Механизм DI в Sota построен на нескольких ключевых функциях: - `createPort`: Создает типизированный контракт для зависимости. - `setPortAdapter`: Связывает порт с его конкретной реализацией (адаптером). - `usePort`: Получает реализацию порта внутри Use Case. - `setPortAdapters`: Позволяет связать несколько пар порт-адаптер за один вызов. - `usePorts`: Позволяет получить несколько реализаций портов за один вызов. **Пример полного цикла DI** ```typescript import { createPort, setPortAdapter, usePort, resetDI } from '@maxdev1/sotajs'; // 1. Определение порта данных и DTO interface FindUserByIdDto { id: string; } const findUserByIdPort = createPort<(dto: FindUserByIdDto) => Promise<{ id: string; name: string } | null>>(); // 2. Определение выходного порта и его DTO type UserFoundOutput = { id: string; name: string; }; const userFoundOutPort = createPort<(dto: UserFoundOutput) => Promise<void>>(); // 3. Адаптер для базы данных (из @infra) const userDbAdapter = async (dto: FindUserByIdDto) => { // ...логика для получения пользователя из БД return { id: dto.id, name: 'Real User' }; }; // 4. Презентационный адаптер для вывода в консоль (из @infra) const consolePresenterAdapter = async (dto: UserFoundOutput) => { console.log(`User Found: ${dto.name} (ID: ${dto.id})`); }; // 5. Мок-адаптеры для тестов const mockUserAdapter = async (dto: FindUserByIdDto) => ({ id: dto.id, name: 'Mock User' }); const mockPresenter = async (dto: UserFoundOutput) => { /* do nothing in test */ }; // 6. Use Case (из @app) const getUserUseCase = async (id: string): Promise<void> => { const findUserById = usePort(findUserByIdPort); const userFound = usePort(userFoundOutPort); const user = await findUserById({ id }); if (user) { await userFound(user); } }; // 7. Связывание в точке композиции (composition root) setPortAdapter(findUserByIdPort, userDbAdapter); setPortAdapter(userFoundOutPort, consolePresenterAdapter); // 8. Связывание в тесте resetDI(); // Очистка контейнера перед тестом setPortAdapter(findUserByIdPort, mockUserAdapter); setPortAdapter(userFoundOutPort, mockPresenter); ``` ### Массовое связывание и использование портов Для удобства можно регистрировать и получать несколько адаптеров за один раз. ```typescript import { createPort, setPortAdapters, usePorts } from '@maxdev1/sotajs'; // Определение портов const portA = createPort<() => string>(); const portB = createPort<(n: number) => number>(); // Массовое связывание setPortAdapters([ [portA, () => 'Результат A'], [portB, (n: number) => n * 10], ]); // Массовое получение зависимостей внутри Use Case const myUseCase = async () => { const [adapterA, adapterB] = usePorts(portA, portB); console.log(adapterA()); // 'Результат A' console.log(adapterB(5)); // 50 }; ``` ### Группировка портов в Модули Для еще большего удобства можно группировать связанные порты в модули: ```typescript import { createPort, setPortAdapter } from '@maxdev1/sotajs'; import { createModule, useModule } from '@maxdev1/sotajs/lib/module'; // Определение портов const findUserPort = createPort<() => Promise<{ id: string; name: string }>>(); const saveOrderPort = createPort<(order: { id: string; userId: string }) => Promise<void>>(); const sendEmailPort = createPort<(email: { to: string; subject: string }) => Promise<void>>(); // Создание модуля, объединяющего связанные порты const orderModule = createModule({ findUser: findUserPort, saveOrder: saveOrderPort, sendEmail: sendEmailPort }); // Связывание портов с адаптерами setPortAdapter(findUserPort, async () => ({ id: "1", name: "John Doe" })); setPortAdapter(saveOrderPort, async (order) => { /* логика сохранения */ }); setPortAdapter(sendEmailPort, async (email) => { /* логика отправки */ }); // Использование модуля в Use Case const createOrderUseCase = async (userId: string) => { // Вместо нескольких вызовов usePort() // const findUser = usePort(findUserPort); // const saveOrder = usePort(saveOrderPort); // const sendEmail = usePort(sendEmailPort); // Можно использовать один вызов useModule() const { findUser, saveOrder, sendEmail } = useModule(orderModule); const user = await findUser(); await saveOrder({ id: "order-123", userId }); await sendEmail({ to: user.name, subject: "Order Confirmation" }); }; ``` Модули позволяют: - Сократить шаблонный код при работе с несколькими зависимостями - Логически группировать связанные порты - Сохранить явность зависимостей без "магии" ### Composition Roots для Модулей Для еще большей гибкости можно создавать composition roots для модулей, которые позволяют использовать разные реализации адаптеров в зависимости от среды выполнения (тестирование, разработка, production): ```typescript import { createPort } from '@maxdev1/sotajs'; import { createModule, useModule } from '@maxdev1/sotajs/lib/module'; import { createModuleComposition, createEnvironmentModuleComposition, applyModuleComposition } from '@maxdev1/sotajs/lib/module-composition'; // Определение портов const findUserPort = createPort<() => Promise<{ id: string; name: string }>>(); const saveOrderPort = createPort<(order: { id: string; userId: string }) => Promise<void>>(); const sendEmailPort = createPort<(email: { to: string; subject: string }) => Promise<void>>(); // Создание модуля const orderModule = createModule({ findUser: findUserPort, saveOrder: saveOrderPort, sendEmail: sendEmailPort }); // Определение compositions для разных сред const testComposition = createModuleComposition({ findUser: async () => ({ id: "test-1", name: "Test User" }), saveOrder: async (order) => { /* in-memory implementation */ }, sendEmail: async (email) => { /* mock implementation */ } }); const productionComposition = createModuleComposition({ findUser: async () => { /* database implementation */ }, saveOrder: async (order) => { /* database implementation */ }, sendEmail: async (email) => { /* email service implementation */ } }); // Создание environment compositions const compositions = { test: testComposition, development: testComposition, production: productionComposition }; // Применение соответствующей composition в зависимости от среды const environment = process.env.NODE_ENV || 'test'; const environmentComposition = createEnvironmentModuleComposition(environment, compositions); applyModuleComposition(orderModule, environmentComposition); // Теперь модуль использует правильные адаптеры для текущей среды const { findUser, saveOrder, sendEmail } = useModule(orderModule); ``` Этот подход позволяет: - Легко переключаться между разными средами выполнения - Иметь чистую конфигурацию для тестирования с in-memory адаптерами - Использовать production адаптеры в реальной среде - Сохранять четкое разделение между бизнес-логикой и инфраструктурой - Создавать любое количество пользовательских compositions для разных нужд ## 6. Процесс разработки в Sota Sota предлагает строгий "inside-out" подход к разработке. - **Шаг 1: Определение контракта.** Создайте файл Use Case в `@app`. Определите его входные данные с помощью `zod` и объявите необходимые порты: как для получения данных, так и **выходные порты (Output Ports)** для всех возможных исходов операции. - **Шаг 2: Реализация доменной логики.** Смоделируйте домен (`@domain`), создавая Сущности и Объекты-значения. Затем, при необходимости, сгруппируйте их в Агрегаты для обеспечения транзакционной целостности. - **Шаг 3: Изолированное тестирование.** Напишите тесты для Use Case. Подмените реализации всех портов моками с помощью `setPortAdapter`. Тест должен проверять, что в зависимости от входных данных вызывается правильный выходной порт с корректным DTO. - **Шаг 4: Реализация адаптеров.** В `@infra` напишите код, который будет взаимодействовать с базой данных (адаптеры данных) и который будет обрабатывать результаты для пользователя (презентационные адаптеры). - **Шаг 5: Связывание в точке композиции.** В главном файле вашего приложения (например, `index.ts`) свяжите все порты с их реальными адаптерами. ## 7. Архитектурные принципы и лучшие практики ### 7.1. Моделирование отношений между Агрегатами - **Правило №1: Ссылайтесь на другие агрегаты только по ID.** Агрегат `Order` должен хранить `customerId`, а не весь объект `Customer`. Это предотвращает создание больших, связанных графов объектов и обеспечивает слабую связанность. - **Правило №2: Одна транзакция — один изменяемый агрегат.** Каждый Use Case должен загружать, изменять и сохранять только один экземпляр агрегата. Если бизнес-процесс требует изменения нескольких агрегатов, используйте доменные события или многошаговые Use Cases. ### 7.2. Проектирование Портов (Принцип CQRS) - **Порты для записи (команды):** Должны всегда принимать на вход полный экземпляр Агрегата или Сущности. Это гарантирует, что все бизнес-правила (инварианты) будут проверены перед сохранением состояния. - **Порты для чтения (запросы):** Могут возвращать любые удобные структуры данных (DTO), оптимизированные для конкретного отображения. Это обеспечивает гибкость и производительность. ### 7.3. Разделение логики и представления через Output Ports - **Use Cases ничего не возвращают.** Вместо `return data` они вызывают семантически именованный выходной порт, например, `userFoundOutPort(userDto)`. - **Один порт для каждого бизнес-исхода.** Вместо обработки ошибок по цепочке, используйте отдельные порты для успеха и для каждого типа значимой ошибки (`userNotFoundOutPort`, `invalidInputOutPort` и т.д.). - **Презентационные адаптеры.** Адаптеры, реализующие выходные порты, отвечают за преобразование чистого DTO в конкретный формат (JSON, HTML, gRPC ответ). Это позволяет менять способ представления данных, не затрагивая бизнес-логику. ### 7.4. Как избежать распространенных ошибок - **Избегайте "Божественных Агрегатов" (God Aggregates).** Не создавайте агрегаты, которые отвечают за слишком много бизнес-концепций. Агрегаты должны быть маленькими и сфокусированными на одной четкой задаче. Если агрегат становится слишком большим, это верный признак того, что его пора разделить.