Giao diện
Hexagonal Architecture Clean Code
Ports & Adapters — Tách business logic khỏi infrastructure để dễ test và thay đổi
Hexagonal Architecture là gì?
Còn gọi là Ports & Adapters hoặc Clean Architecture, mô hình này đặt business logic (domain) ở trung tâm, cách ly hoàn toàn khỏi:
- Database
- HTTP framework
- External APIs
- File system
┌─────────────────────────────────────────────────────────────────────┐
│ HEXAGONAL ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────┐ │
│ │ │ │
│ ┌──────┐ │ ┌───────────────────────────┐ │ ┌──────┐ │
│ │ HTTP │◀──┼──▶│ DOMAIN CORE │◀────┼──▶│ DB │ │
│ │Adapter │ │ (Pure Business Logic) │ │ │Adapter │
│ └──────┘ │ │ │ │ └──────┘ │
│ │ │ - Entities │ │ │
│ ┌──────┐ │ │ - Value Objects │ │ ┌──────┐ │
│ │ CLI │◀──┼──▶│ - Domain Services │◀────┼──▶│ Cache│ │
│ │Adapter │ │ - Repository TRAITS │ │ │Adapter │
│ └──────┘ │ └───────────────────────────┘ │ └──────┘ │
│ │ ▲ │ │
│ │ │ │ │
│ │ (PORTS) │ │
│ │ Traits định nghĩa │ │
│ │ interface cho adapters │ │
│ └─────────────────────────────────────┘ │
│ │
│ INBOUND ADAPTERS CORE OUTBOUND ADAPTERS │
│ (Driving) (Driven) │
└─────────────────────────────────────────────────────────────────────┘Triển khai trong Rust
Cấu trúc Thư mục (Workspace Layout)
my_app/
├── Cargo.toml # Workspace manifest
├── domain/ # CORE - Pure business logic
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs
│ ├── entities/
│ ├── value_objects/
│ ├── services/
│ └── ports/ # Trait definitions (interfaces)
│
├── adapters/ # INFRASTRUCTURE
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs
│ ├── postgres/ # DB adapter
│ ├── redis/ # Cache adapter
│ └── http/ # API adapter (Axum/Actix)
│
├── app/ # APPLICATION - Orchestration
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs
│ └── use_cases/ # Application services
│
└── bin/ # ENTRYPOINT
└── main.rs # Wiring everything togetherWorkspace Cargo.toml
toml
[workspace]
members = ["domain", "adapters", "app", "bin"]
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"Step 1: Domain Layer (Ports)
Domain chứa pure business logic — không có dependencies bên ngoài ngoại trừ std.
Entities & Value Objects
rust
// domain/src/entities/user.rs
use crate::value_objects::Email;
#[derive(Debug, Clone)]
pub struct User {
pub id: UserId,
pub email: Email,
pub name: String,
pub active: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct UserId(pub uuid::Uuid);
impl User {
pub fn new(email: Email, name: String) -> Self {
Self {
id: UserId(uuid::Uuid::new_v4()),
email,
name,
active: true,
}
}
pub fn deactivate(&mut self) {
self.active = false;
}
}Ports: Repository Traits
Port là một Trait định nghĩa interface mà Domain cần từ bên ngoài.
rust
// domain/src/ports/user_repository.rs
use crate::entities::{User, UserId};
use async_trait::async_trait;
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, RepositoryError>;
async fn save(&self, user: &User) -> Result<(), RepositoryError>;
async fn delete(&self, id: &UserId) -> Result<(), RepositoryError>;
}
#[derive(Debug, thiserror::Error)]
pub enum RepositoryError {
#[error("Connection failed: {0}")]
Connection(String),
#[error("Not found")]
NotFound,
}💡 KEY INSIGHT: DEPENDENCY INVERSION
Domain KHÔNG phụ thuộc vào PostgreSQL hay Redis. Domain chỉ định nghĩa "tôi cần một thứ có khả năng lưu User" thông qua Trait.
Adapter implementations sẽ implement Trait này, tạo ra Dependency Inversion (D trong SOLID).
Step 2: Adapters (Infrastructure)
Adapters implement các Traits được định nghĩa trong Domain.
PostgreSQL Adapter
rust
// adapters/src/postgres/user_repository.rs
use domain::ports::{UserRepository, RepositoryError};
use domain::entities::{User, UserId};
use sqlx::PgPool;
use async_trait::async_trait;
pub struct PostgresUserRepository {
pool: PgPool,
}
impl PostgresUserRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl UserRepository for PostgresUserRepository {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, RepositoryError> {
let row = sqlx::query_as!(
UserRow,
"SELECT id, email, name, active FROM users WHERE id = $1",
id.0
)
.fetch_optional(&self.pool)
.await
.map_err(|e| RepositoryError::Connection(e.to_string()))?;
Ok(row.map(|r| r.into()))
}
async fn save(&self, user: &User) -> Result<(), RepositoryError> {
sqlx::query!(
"INSERT INTO users (id, email, name, active) VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE SET email = $2, name = $3, active = $4",
user.id.0,
user.email.as_str(),
user.name,
user.active
)
.execute(&self.pool)
.await
.map_err(|e| RepositoryError::Connection(e.to_string()))?;
Ok(())
}
async fn delete(&self, id: &UserId) -> Result<(), RepositoryError> {
// ...implementation
Ok(())
}
}In-Memory Adapter (cho Testing)
rust
// adapters/src/memory/user_repository.rs
use domain::ports::{UserRepository, RepositoryError};
use domain::entities::{User, UserId};
use std::sync::RwLock;
use std::collections::HashMap;
pub struct InMemoryUserRepository {
storage: RwLock<HashMap<UserId, User>>,
}
impl InMemoryUserRepository {
pub fn new() -> Self {
Self {
storage: RwLock::new(HashMap::new()),
}
}
}
#[async_trait]
impl UserRepository for InMemoryUserRepository {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, RepositoryError> {
let storage = self.storage.read().unwrap();
Ok(storage.get(id).cloned())
}
async fn save(&self, user: &User) -> Result<(), RepositoryError> {
let mut storage = self.storage.write().unwrap();
storage.insert(user.id.clone(), user.clone());
Ok(())
}
// ...
}Step 3: Application Layer (Use Cases)
Application layer orchestrate domain logic và sử dụng ports.
rust
// app/src/use_cases/register_user.rs
use domain::ports::UserRepository;
use domain::entities::User;
use domain::value_objects::Email;
use std::sync::Arc;
pub struct RegisterUserUseCase<R: UserRepository> {
user_repo: Arc<R>,
}
impl<R: UserRepository> RegisterUserUseCase<R> {
pub fn new(user_repo: Arc<R>) -> Self {
Self { user_repo }
}
pub async fn execute(&self, email: String, name: String) -> Result<User, UseCaseError> {
// 1. Validate email (domain logic)
let email = Email::parse(&email)
.map_err(|_| UseCaseError::InvalidEmail)?;
// 2. Create user entity
let user = User::new(email, name);
// 3. Persist (through port/trait)
self.user_repo.save(&user).await
.map_err(|e| UseCaseError::Repository(e.to_string()))?;
Ok(user)
}
}
#[derive(Debug, thiserror::Error)]
pub enum UseCaseError {
#[error("Invalid email format")]
InvalidEmail,
#[error("Repository error: {0}")]
Repository(String),
}Step 4: Dependency Injection (Wiring)
Rust không có reflection như Java/C#. Ta dùng Generics hoặc Trait Objects.
Approach 1: Generics (Zero-cost, compile-time)
rust
// bin/main.rs
use adapters::postgres::PostgresUserRepository;
use app::use_cases::RegisterUserUseCase;
use std::sync::Arc;
#[tokio::main]
async fn main() {
let pool = PgPool::connect("postgres://...").await.unwrap();
// DI: Inject concrete adapter
let user_repo = Arc::new(PostgresUserRepository::new(pool));
let register_use_case = RegisterUserUseCase::new(user_repo);
// Use case ready to use
let user = register_use_case.execute("test@example.com".into(), "Test".into()).await;
}
)}Approach 2: Trait Objects (Dynamic, runtime flexibility)
rust
// Khi cần swap implementation at runtime
type DynUserRepository = Arc<dyn UserRepository>;
struct AppState {
user_repo: DynUserRepository,
}
fn create_app_state(config: &Config) -> AppState {
let user_repo: DynUserRepository = if config.use_mock {
Arc::new(InMemoryUserRepository::new())
} else {
Arc::new(PostgresUserRepository::new(pool))
};
AppState { user_repo }
}Case Study: Refactoring Monolithic API
Before (Coupled)
rust
// ❌ BAD: Domain logic mixed with HTTP và DB
async fn register_user_handler(
State(pool): State<PgPool>,
Json(payload): Json<RegisterRequest>,
) -> impl IntoResponse {
// Validation mixed in handler
if !payload.email.contains('@') {
return (StatusCode::BAD_REQUEST, "Invalid email");
}
// DB logic in handler
sqlx::query!("INSERT INTO users ...")
.execute(&pool)
.await
.unwrap();
(StatusCode::OK, "Created")
}After (Hexagonal)
rust
// ✅ GOOD: Handler chỉ là thin adapter
async fn register_user_handler(
State(state): State<AppState>,
Json(payload): Json<RegisterRequest>,
) -> impl IntoResponse {
match state.register_use_case.execute(payload.email, payload.name).await {
Ok(user) => (StatusCode::CREATED, Json(UserResponse::from(user))),
Err(UseCaseError::InvalidEmail) => (StatusCode::BAD_REQUEST, "Invalid email".into()),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string().into()),
}
}Lợi ích
| Aspect | Benefit |
|---|---|
| Testability | Test domain với InMemory adapters, không cần DB |
| Maintainability | Thay đổi framework (Axum → Actix) không ảnh hưởng domain |
| Team Scaling | Team A làm domain, Team B làm adapters, không conflict |
| Replaceability | Thay PostgreSQL bằng MongoDB chỉ cần viết adapter mới |