Initialize project scaffold and domain model

This commit is contained in:
a.tolmachev
2026-03-25 12:20:42 +03:00
commit fb302b2a2c
51 changed files with 6815 additions and 0 deletions
+402
View File
@@ -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` с разнородной логикой.