Initialize project scaffold and domain model
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
# 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`-блоки по проекту
|
||||
|
||||
### В `mcpaas-core`
|
||||
|
||||
- маленькие `impl` на domain types;
|
||||
- status helpers;
|
||||
- derived metadata methods.
|
||||
|
||||
### В `mcpaas-schema`
|
||||
|
||||
- schema validation;
|
||||
- field traversal;
|
||||
- shape helpers.
|
||||
|
||||
### В `mcpaas-mapping`
|
||||
|
||||
- JSONPath validation;
|
||||
- mapping rule helpers;
|
||||
- execution helpers.
|
||||
|
||||
### В `mcpaas-proto`
|
||||
|
||||
- metadata conversion helpers;
|
||||
- descriptor lookup helpers.
|
||||
|
||||
### В `mcpaas-registry`
|
||||
|
||||
- service methods, а не методы на доменных структурах;
|
||||
- repository implementations.
|
||||
|
||||
### В `mcpaas-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` с разнородной логикой.
|
||||
Reference in New Issue
Block a user