Files
crank/docs/rust-design.md
T

11 KiB
Raw Blame History

Rust Design

1. Назначение документа

Этот документ фиксирует, как проектировать поведение в Rust-коде:

  • какие методы допустимы на struct и enum;
  • что должно жить в impl;
  • что должно быть вынесено в trait;
  • что должно быть оформлено как service или use case.

Главная цель документа - не допустить появления god-struct, когда одна сущность одновременно:

  • хранит данные;
  • валидирует себя целиком;
  • ходит в БД;
  • дергает HTTP;
  • строит mapping;
  • управляет publish flow;
  • содержит половину бизнес-логики проекта.

2. Базовое правило

В Rust нужно разделять:

  • data model
  • domain behavior
  • integration contracts
  • application services

То есть:

  • struct и enum хранят состояние;
  • impl на них содержит только локально связанное поведение;
  • trait задает внешний контракт;
  • service и use case координируют несколько сущностей и внешние зависимости.

3. Что допустимо держать в impl на структурах

На impl допустимы только методы, которые:

  • опираются только на внутреннее состояние структуры;
  • не требуют инфраструктурных зависимостей;
  • не ходят в БД;
  • не выполняют сетевые вызовы;
  • не меняют чужие aggregate boundaries.

Подходящие примеры:

  • Operation::is_published()
  • Operation::supports_protocol(protocol)
  • Operation::tool_name()
  • MappingRule::is_required()
  • GeneratedDraft::is_available()
  • Schema::field(path)
  • Protocol::as_str()

Неподходящие примеры:

  • Operation::save(db)
  • Operation::publish(repo, runtime, cache)
  • Operation::call_external_api()
  • Operation::load_descriptor()

4. Какие методы должны жить на ключевых доменных структурах

4.1. Operation

Допустимые методы:

  • fn tool_name(&self) -> &str
  • fn is_draft(&self) -> bool
  • fn is_published(&self) -> bool
  • fn protocol(&self) -> &Protocol
  • fn auth_profile_ref(&self) -> Option<&str>
  • fn can_be_published(&self) -> bool

Что не должно жить здесь:

  • создание новой версии;
  • publish;
  • YAML import/export;
  • DB persistence;
  • runtime execution;
  • adapter dispatch.

4.2. Target

Допустимые методы:

  • fn kind(&self) -> Protocol
  • fn summary(&self) -> String

Что не должно жить здесь:

  • реальный вызов REST/GraphQL/gRPC;
  • загрузка descriptor set;
  • introspection;
  • network logic.

4.3. Schema

Допустимые методы:

  • fn is_object(&self) -> bool
  • fn field(&self, name: &str) -> Option<&SchemaField>
  • fn has_required_fields(&self) -> bool
  • fn validate_shape(&self, value: &serde_json::Value) -> Result<(), SchemaError>

Что не должно жить здесь:

  • UI rendering logic;
  • DB serialization logic;
  • adapter-specific request assembly.

4.4. MappingSet и MappingRule

Допустимые методы:

  • fn is_empty(&self) -> bool
  • fn validate_paths(&self) -> Result<(), MappingError>
  • fn target_context(&self) -> MappingTargetContext

Что не должно жить здесь:

  • protocol adapter branching;
  • network execution;
  • persistence;
  • доступ к registry.

4.5. ExecutionConfig

Допустимые методы:

  • fn timeout(&self) -> Duration
  • fn has_auth(&self) -> bool
  • fn protocol_options(&self) -> Option<&ProtocolOptions>

Что не должно жить здесь:

  • secret resolution;
  • создание HTTP headers из env;
  • динамическое чтение конфигов приложения.

5. Что нужно выносить в trait

Trait нужен там, где появляется внешний контракт, который имеет несколько реализаций или зависит от инфраструктуры.

Правильные кандидаты:

  • OperationRepository
  • PublishedOperationRepository
  • ProtocolAdapter
  • SecretResolver
  • DescriptorStore
  • ArtifactStore
  • YamlCodec
  • DraftGenerator

Пример

pub trait OperationRepository {
    async fn get(&self, id: &OperationId) -> Result<OperationRecord, RepoError>;
    async fn create_version(&self, cmd: CreateVersion) -> Result<OperationVersionRef, RepoError>;
    async fn publish(&self, id: &OperationId, version: u32) -> Result<(), RepoError>;
}

Почему это trait, а не метод на Operation:

  • потому что операция сама не должна знать, как она хранится;
  • потому что хранение - инфраструктурная зависимость;
  • потому что это boundary между доменом и storage.

6. Что нужно выносить в service/use-case слой

Если логика:

  • координирует несколько сущностей;
  • использует trait-зависимости;
  • меняет состояние нескольких aggregate boundaries;
  • имеет бизнес-шаги;

то это service, а не impl на структуре.

Кандидаты:

  • CreateOperationService
  • CreateOperationVersionService
  • PublishOperationService
  • GenerateDraftService
  • TestOperationService
  • ImportOperationYamlService
  • ExportOperationYamlService
  • ListPublishedToolsService
  • OperationExecutor

7. Рекомендуемое распределение поведения

Domain impl

Хранит:

  • локальную валидацию;
  • derived methods;
  • простые status checks;
  • инварианты одной сущности.

trait

Хранит:

  • внешние контракты;
  • infrastructure boundaries;
  • replaceable dependencies.

service

Хранит:

  • orchestration;
  • use case sequence;
  • transaction boundaries;
  • вызовы нескольких зависимостей.

8. Пример правильного разделения

Плохо

impl Operation {
    pub async fn publish(
        &mut self,
        repo: &SqlOperationRepository,
        cache: &RuntimeCache,
        secret_resolver: &EnvSecretResolver,
    ) -> Result<(), Error> {
        self.validate()?;
        repo.save(self).await?;
        cache.reload().await?;
        let _ = secret_resolver.resolve(...)?;
        self.status = Status::Published;
        Ok(())
    }
}

Почему плохо:

  • доменная сущность знает про SQL;
  • знает про кэш;
  • знает про secret resolver;
  • меняет себя и внешний мир одновременно;
  • содержит orchestration.

Правильно

impl Operation {
    pub fn can_be_published(&self) -> bool {
        matches!(self.status, Status::Draft | Status::Testing)
            && !self.input_mapping.rules.is_empty()
            && !self.output_mapping.rules.is_empty()
    }
}

pub struct PublishOperationService<R> {
    repo: R,
}

impl<R> PublishOperationService<R>
where
    R: OperationRepository,
{
    pub async fn execute(
        &self,
        operation_id: &OperationId,
        version: u32,
    ) -> Result<(), PublishError> {
        let op = self.repo.get_version(operation_id, version).await?;
        if !op.can_be_published() {
            return Err(PublishError::InvalidState);
        }
        self.repo.publish(operation_id, version).await
    }
}

9. Признаки god-struct

Если у структуры:

  • слишком много полей из разных bounded contexts;
  • методы и на schema, и на DB, и на adapters, и на YAML;
  • методы с кучей зависимостей в аргументах;
  • методы длиннее, чем небольшой локальный инвариант;
  • много match protocol прямо внутри доменной модели;

то это уже god-struct.

Особенно опасные кандидаты:

  • Operation
  • AppState
  • OperationExecutor
  • AdminService
  • ProtocolAdapter

10. Как не допустить god-struct

10.1. Для Operation

Не добавлять туда:

  • repo methods;
  • transport methods;
  • import/export;
  • publish flow;
  • sample upload handling.

10.2. Для AppState

Не складывать все зависимости в один плоский объект на 20 полей.

Лучше:

  • RegistryServices
  • RuntimeServices
  • ArtifactServices
  • AuthServices

10.3. Для OperationExecutor

Он может быть orchestration root, но не должен становиться монолитом.

Нужно выделять:

  • InputPrepare
  • AdapterDispatch
  • OutputFinalize
  • ExecutionContextFactory

11. Рекомендуемые impl-блоки по проекту

В crank-core

  • маленькие impl на domain types;
  • status helpers;
  • derived metadata methods.

В crank-schema

  • schema validation;
  • field traversal;
  • shape helpers.

В crank-mapping

  • JSONPath validation;
  • mapping rule helpers;
  • execution helpers.

В crank-registry

  • service methods, а не методы на доменных структурах;
  • repository implementations.

В crank-runtime

  • orchestration services;
  • adapter dispatch;
  • runtime context management.

12. Что лучше описывать не как методы структур

Следующие вещи лучше описывать отдельными сервисами даже если технически их можно записать как impl:

  • publish
  • create_version
  • import_yaml
  • export_yaml
  • generate_draft
  • test_run
  • reload_published_tools

13. Практический итог

Для этого проекта хорошее правило такое:

  • struct знает только себя;
  • trait знает границу;
  • service знает сценарий;
  • adapter знает протокол;
  • repository знает storage.

Если придерживаться этой схемы, то Rust-код останется модульным, а Operation и связанные типы не превратятся в god-struct с разнородной логикой.