Como aplicar clean architecture em projetos Rust
1. Fundamentos da Clean Architecture no Contexto do Rust
A Clean Architecture, proposta por Robert C. Martin, estabelece que o código deve ser organizado em camadas concêntricas, onde as regras de negócio ficam no centro e dependências externas (frameworks, bancos de dados, interfaces) são mantidas nas bordas. Os princípios fundamentais são: independência de frameworks, testabilidade, separação em camadas e inversão de dependências.
No Rust, aplicar esses princípios apresenta desafios específicos. O sistema de ownership e borrowing exige cuidado redobrado ao definir limites entre camadas, pois referências emprestadas não podem cruzar fronteiras de forma arbitrária. Além disso, Rust não possui herança tradicional, o que torna necessário usar traits e composição para implementar polimorfismo.
Uma estrutura de diretórios base para um projeto Rust com Clean Architecture pode ser:
src/
├── domain/ # Entidades, value objects, regras de negócio
│ ├── entities/
│ ├── value_objects/
│ └── errors.rs
├── application/ # Casos de uso (interactors)
│ ├── use_cases/
│ └── ports/ # Interfaces para repositórios e serviços
├── infrastructure/ # Adaptadores concretos
│ ├── persistence/
│ ├── http/
│ └── messaging/
└── main.rs
2. Camada de Domínio: Entidades e Regras de Negócio
A camada de domínio é o centro da aplicação. Ela não deve ter dependências externas — nem mesmo de bibliotecas de serialização ou banco de dados. Aqui definimos entidades ricas com structs e validação via construtores seguros.
// domain/entities/task.rs
pub struct Task {
id: TaskId,
title: String,
description: String,
status: TaskStatus,
created_at: chrono::DateTime<chrono::Utc>,
}
impl Task {
pub fn new(id: TaskId, title: String, description: String) -> Result<Self, DomainError> {
if title.trim().is_empty() {
return Err(DomainError::InvalidTitle("Title cannot be empty".into()));
}
Ok(Self {
id,
title,
description,
status: TaskStatus::Pending,
created_at: chrono::Utc::now(),
})
}
pub fn complete(&mut self) -> Result<(), DomainError> {
if self.status == TaskStatus::Completed {
return Err(DomainError::AlreadyCompleted);
}
self.status = TaskStatus::Completed;
Ok(())
}
}
Value objects representam conceitos imutáveis. Por exemplo, TaskId e TaskStatus:
// domain/value_objects.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskId(Uuid);
impl TaskId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskStatus {
Pending,
Completed,
Cancelled,
}
3. Camada de Casos de Uso: Orquestração da Lógica de Aplicação
Os casos de uso (interactors) orquestram o fluxo da aplicação. Eles dependem de interfaces (traits) definidas como portas, nunca de implementações concretas.
// application/ports/repositories.rs
#[async_trait]
pub trait TaskRepository: Send + Sync {
async fn save(&self, task: Task) -> Result<(), RepositoryError>;
async fn find_by_id(&self, id: &TaskId) -> Result<Option<Task>, RepositoryError>;
async fn find_all(&self) -> Result<Vec<Task>, RepositoryError>;
}
// application/use_cases/create_task.rs
pub struct CreateTaskUseCase<R: TaskRepository> {
repository: R,
}
impl<R: TaskRepository> CreateTaskUseCase<R> {
pub fn new(repository: R) -> Self {
Self { repository }
}
pub async fn execute(&self, input: CreateTaskInput) -> Result<TaskOutput, ApplicationError> {
let id = TaskId::new();
let task = Task::new(id, input.title, input.description)?;
self.repository.save(task.clone()).await?;
Ok(TaskOutput::from(task))
}
}
O tratamento de erros é feito com tipos Result personalizados, evitando vazamento de detalhes de infraestrutura:
// application/errors.rs
#[derive(Debug)]
pub enum ApplicationError {
Domain(DomainError),
Repository(RepositoryError),
NotFound,
}
4. Camada de Interface e Adaptadores: Portas e Adaptadores
As portas são as interfaces que o domínio e os casos de uso definem. Os adaptadores são implementações concretas dessas interfaces. Para persistência, criamos um repositório em memória para testes e um real com banco de dados.
// infrastructure/persistence/in_memory_task_repository.rs
pub struct InMemoryTaskRepository {
tasks: Arc<RwLock<HashMap<TaskId, Task>>>,
}
#[async_trait]
impl TaskRepository for InMemoryTaskRepository {
async fn save(&self, task: Task) -> Result<(), RepositoryError> {
let mut map = self.tasks.write().await;
map.insert(task.id().clone(), task);
Ok(())
}
async fn find_by_id(&self, id: &TaskId) -> Result<Option<Task>, RepositoryError> {
let map = self.tasks.read().await;
Ok(map.get(id).cloned())
}
async fn find_all(&self) -> Result<Vec<Task>, RepositoryError> {
let map = self.tasks.read().await;
Ok(map.values().cloned().collect())
}
}
Adaptadores de entrada (controllers) recebem requisições HTTP e chamam os casos de uso:
// infrastructure/http/task_controller.rs
pub async fn create_task(
State(state): State<AppState>,
Json(input): Json<CreateTaskInput>,
) -> Result<Json<TaskOutput>, StatusCode> {
let use_case = CreateTaskUseCase::new(state.task_repository.clone());
match use_case.execute(input).await {
Ok(output) => Ok(Json(output)),
Err(ApplicationError::Domain(_)) => Err(StatusCode::BAD_REQUEST),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
5. Infraestrutura e Frameworks: Adaptação ao Ecossistema Rust
A integração com frameworks como Axum ou Actix-web deve ser feita sem vazar detalhes para o domínio. A configuração da aplicação e a injeção de dependências podem ser centralizadas:
// main.rs
#[tokio::main]
async fn main() {
let repository = Arc::new(InMemoryTaskRepository::new());
let app_state = AppState { task_repository: repository };
let app = Router::new()
.route("/tasks", post(create_task).get(list_tasks))
.with_state(app_state);
axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
.serve(app.into_make_service())
.await
.unwrap();
}
Para testes, o crate mockall permite criar mocks automáticos das portas:
// application/ports/repositories.rs (com mockall)
#[cfg_attr(test, mockall::automock)]
#[async_trait]
pub trait TaskRepository: Send + Sync {
async fn save(&self, task: Task) -> Result<(), RepositoryError>;
async fn find_all(&self) -> Result<Vec<Task>, RepositoryError>;
}
6. Testabilidade e Manutenção com Rust
A Clean Architecture torna os testes muito mais fáceis. O domínio é testado sem dependências externas:
#[test]
fn test_task_creation() {
let id = TaskId::new();
let task = Task::new(id, "Valid title".into(), "Description".into());
assert!(task.is_ok());
}
#[test]
fn test_task_empty_title() {
let id = TaskId::new();
let task = Task::new(id, "".into(), "Description".into());
assert!(matches!(task, Err(DomainError::InvalidTitle(_))));
}
Testes de integração usam mocks para isolar os casos de uso:
#[tokio::test]
async fn test_create_task_use_case() {
let mut mock_repo = MockTaskRepository::new();
mock_repo.expect_save().returning(|_| Ok(()));
let use_case = CreateTaskUseCase::new(mock_repo);
let input = CreateTaskInput {
title: "Test task".into(),
description: "Test description".into(),
};
let result = use_case.execute(input).await;
assert!(result.is_ok());
}
O sistema de tipos do Rust permite refatorações seguras: se uma interface mudar, o compilador aponta todos os pontos que precisam ser ajustados.
7. Exemplo Prático: Aplicação de Tarefas (Todo List)
Vamos compor um exemplo funcional de uma aplicação de tarefas seguindo a Clean Architecture.
Domínio:
// domain/entities/task.rs
pub struct Task {
id: TaskId,
title: String,
status: TaskStatus,
}
impl Task {
pub fn new(id: TaskId, title: String) -> Result<Self, DomainError> {
if title.trim().is_empty() {
return Err(DomainError::InvalidTitle);
}
Ok(Self { id, title, status: TaskStatus::Pending })
}
pub fn complete(&mut self) {
self.status = TaskStatus::Completed;
}
}
Casos de uso:
// application/use_cases/list_tasks.rs
pub struct ListTasksUseCase<R: TaskRepository> {
repository: R,
}
impl<R: TaskRepository> ListTasksUseCase<R> {
pub async fn execute(&self) -> Result<Vec<TaskOutput>, ApplicationError> {
let tasks = self.repository.find_all().await?;
Ok(tasks.into_iter().map(TaskOutput::from).collect())
}
}
Adaptador HTTP com Axum:
// infrastructure/http/routes.rs
pub fn task_routes<T: TaskRepository + Clone + 'static>() -> Router<T> {
Router::new()
.route("/tasks", post(create_task_handler::<T>).get(list_tasks_handler::<T>))
}
A execução completa demonstra como as camadas se conectam: o controller chama o caso de uso, que depende da porta do repositório, que é implementada pelo adaptador em memória ou por um banco real. Nenhuma dessas camadas conhece os detalhes internos das outras.
Referências
- Clean Architecture: A Craftsman's Guide by Robert C. Martin — Livro fundamental que define os princípios da Clean Architecture, base conceitual para todo o artigo.
- The Rust Programming Language Book — Documentação oficial do Rust, essencial para entender ownership, borrowing e traits aplicados na prática.
- Axum Web Framework Documentation — Documentação oficial do Axum, framework usado nos exemplos de adaptadores HTTP.
- Mockall: Mocking Library for Rust — Documentação do crate mockall, utilizado para criar mocks de traits em testes de integração.
- Domain-Driven Design in Rust — Artigo técnico de Sylvain Kerkour que explora como aplicar DDD e Clean Architecture em Rust com exemplos práticos.