[AIR-3][AIS-3][BPC-3][RES-3]

Hexagonal Architecture in Anya Core

This document describes the Hexagonal (Ports and Adapters) architecture used in Anya Core.

Table of Contents

Overview

Hexagonal Architecture, also known as Ports and Adapters, is an architectural pattern that isolates the core business logic from external concerns. This separation makes the application more maintainable, testable, and adaptable to change.

Core Concepts

Domain Layer

  • Contains the core business logic
  • Pure Rust code with no external dependencies
  • Defines domain models and business rules

Application Layer

  • Orchestrates the flow of data between the domain and infrastructure layers
  • Implements use cases
  • Defines ports (traits) for external interactions

Infrastructure Layer

  • Implements the ports defined by the application layer
  • Handles external concerns like:
  • Database access
  • Network communication
  • File I/O
  • External services

Directory Structure

src/
├── domain/               # Domain layer
│   ├── models/          # Domain models
│   └── services/        # Domain services
├── application/         # Application layer
│   ├── ports/           # Ports (traits)
│   └── services/        # Application services
└── infrastructure/      # Infrastructure layer
    ├── adapters/        # Adapters implementing ports
    └── config/          # Configuration

Implementing a New Feature

1. Define Domain Models

// domain/models/user.rs
pub struct User {
    pub id: UserId,
    pub username: String,
    pub email: String,
}

2. Define Ports (Traits)

// application/ports/user_repository.rs
#[async_trait]
pub trait UserRepository: Send + Sync {
    async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, Error>;
    async fn save(&self, user: &User) -> Result<(), Error>;
}

3. Implement Application Service

// application/services/user_service.rs
pub struct UserService<T: UserRepository> {
    user_repository: Arc<T>,
}

impl<T: UserRepository> UserService<T> {
    pub async fn get_user(&self, id: &UserId) -> Result<Option<User>, Error> {
        self.user_repository.find_by_id(id).await
    }
}

4. Implement Adapters

// infrastructure/adapters/postgres_user_repository.rs
pub struct PostgresUserRepository {
    pool: PgPool,
}

#[async_trait]
impl UserRepository for PostgresUserRepository {
    async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, Error> {
        // Implementation using SQLx
    }
}

Testing

Unit Tests

Test domain and application layers in isolation:

#[cfg(test)]
mod tests {
    use super::*;
    use mockall::predicate::*;

    #[tokio::test]
    async fn test_get_user() {
        // Setup mock
        let mut mock_repo = MockUserRepository::new();
        mock_repo
            .expect_find_by_id()
            .returning(|_| Ok(Some(User::new("test"))));

        // Test
        let service = UserService::new(Arc::new(mock_repo));
        let result = service.get_user(&UserId::new()).await;
        assert!(result.is_ok());
    }
}

Integration Tests

Test the entire stack:

#[tokio::test]
async fn test_user_flow() {
    // Setup test database
    let pool = setup_test_db().await;

    // Create repository with test DB
    let repo = PostgresUserRepository::new(pool);

    // Test operations
    let user = User::new("test");
    repo.save(&user).await.unwrap();

    let found = repo.find_by_id(&user.id).await.unwrap();
    assert!(found.is_some());
}

Best Practices

1. Dependency Rule

Dependencies should always point inward:

  • Domain has no dependencies
  • Application depends on domain
  • Infrastructure depends on application and domain

2. Use Traits for Dependencies

// Good: Depend on trait
struct UserService<T: UserRepository> {
    repo: T,
}

// Bad: Depend on concrete implementation
struct UserService {
    repo: PostgresUserRepository,
}

3. Error Handling

  • Define domain-specific errors
  • Use thiserror for error types
  • Convert between error types at boundaries

4. Testing

  • Test domain logic in isolation
  • Use mocks for external dependencies
  • Write integration tests for critical paths

Examples

Domain Event

// domain/events/user_created.rs
pub struct UserCreated {
    pub user_id: UserId,
    pub timestamp: DateTime<Utc>,
}

impl DomainEvent for UserCreated {
    fn event_type(&self) -> &'static str {
        "user_created"
    }

    fn timestamp(&self) -> DateTime<Utc> {
        self.timestamp
    }
}

Command Handler

// application/commands/create_user.rs
pub struct CreateUserCommand {
    pub username: String,
    pub email: String,
}

pub struct CreateUserHandler<T: UserRepository, E: EventBus> {
    user_repo: Arc<T>,
    event_bus: Arc<E>,
}

#[async_trait]
impl<T, E> CommandHandler<CreateUserCommand> for CreateUserHandler<T, E>
where
    T: UserRepository,
    E: EventBus,
{
    async fn handle(&self, cmd: CreateUserCommand) -> Result<(), Error> {
        let user = User::new(cmd.username, cmd.email);
        self.user_repo.save(&user).await?;

        let event = UserCreated {
            user_id: user.id,
            timestamp: Utc::now(),
        };

        self.event_bus.publish(event).await?;
        Ok(())
    }
}

Conclusion

The Hexagonal Architecture provides a clean separation of concerns, making the codebase more maintainable and testable. By following these patterns, we ensure that Anya Core remains flexible and adaptable to future changes.

See Also