Files
crank/docs/rust-design.md
T
2026-03-28 00:58:56 +03:00

403 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`
### Пример
```rust
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. Пример правильного разделения
### Плохо
```rust
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.
### Правильно
```rust
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-proto`
- metadata conversion helpers;
- descriptor lookup 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` с разнородной логикой.