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