11 KiB
Rust Design
1. Назначение документа
Этот документ фиксирует, как проектировать поведение в Rust-коде:
- какие методы допустимы на
structиenum; - что должно жить в
impl; - что должно быть вынесено в
trait; - что должно быть оформлено как
serviceилиuse case.
Главная цель документа - не допустить появления god-struct, когда одна сущность одновременно:
- хранит данные;
- валидирует себя целиком;
- ходит в БД;
- дергает HTTP;
- строит mapping;
- управляет publish flow;
- содержит половину бизнес-логики проекта.
2. Базовое правило
В Rust нужно разделять:
data modeldomain behaviorintegration contractsapplication 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) -> &strfn is_draft(&self) -> boolfn is_published(&self) -> boolfn protocol(&self) -> &Protocolfn 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) -> Protocolfn summary(&self) -> String
Что не должно жить здесь:
- реальный вызов REST/GraphQL/gRPC;
- загрузка descriptor set;
- introspection;
- network logic.
4.3. Schema
Допустимые методы:
fn is_object(&self) -> boolfn field(&self, name: &str) -> Option<&SchemaField>fn has_required_fields(&self) -> boolfn 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) -> boolfn validate_paths(&self) -> Result<(), MappingError>fn target_context(&self) -> MappingTargetContext
Что не должно жить здесь:
- protocol adapter branching;
- network execution;
- persistence;
- доступ к registry.
4.5. ExecutionConfig
Допустимые методы:
fn timeout(&self) -> Durationfn has_auth(&self) -> boolfn protocol_options(&self) -> Option<&ProtocolOptions>
Что не должно жить здесь:
- secret resolution;
- создание HTTP headers из env;
- динамическое чтение конфигов приложения.
5. Что нужно выносить в trait
Trait нужен там, где появляется внешний контракт, который имеет несколько реализаций или зависит от инфраструктуры.
Правильные кандидаты:
OperationRepositoryPublishedOperationRepositoryProtocolAdapterSecretResolverDescriptorStoreArtifactStoreYamlCodecDraftGenerator
Пример
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 на структуре.
Кандидаты:
CreateOperationServiceCreateOperationVersionServicePublishOperationServiceGenerateDraftServiceTestOperationServiceImportOperationYamlServiceExportOperationYamlServiceListPublishedToolsServiceOperationExecutor
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.
Особенно опасные кандидаты:
OperationAppStateOperationExecutorAdminServiceProtocolAdapter
10. Как не допустить god-struct
10.1. Для Operation
Не добавлять туда:
- repo methods;
- transport methods;
- import/export;
- publish flow;
- sample upload handling.
10.2. Для AppState
Не складывать все зависимости в один плоский объект на 20 полей.
Лучше:
RegistryServicesRuntimeServicesArtifactServicesAuthServices
10.3. Для OperationExecutor
Он может быть orchestration root, но не должен становиться монолитом.
Нужно выделять:
InputPrepareAdapterDispatchOutputFinalizeExecutionContextFactory
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:
publishcreate_versionimport_yamlexport_yamlgenerate_drafttest_runreload_published_tools
13. Практический итог
Для этого проекта хорошее правило такое:
structзнает только себя;traitзнает границу;serviceзнает сценарий;adapterзнает протокол;repositoryзнает storage.
Если придерживаться этой схемы, то Rust-код останется модульным, а Operation и связанные типы не превратятся в god-struct с разнородной логикой.