# Hexagonal Architecture (Ports & Adapters) rdev implements hexagonal architecture to achieve clean separation of concerns, testability, and flexibility in infrastructure choices. ## Overview Hexagonal architecture organizes code into three layers: 1. **Domain** - Core business logic and models 2. **Ports** - Abstract interfaces defining capabilities 3. **Adapters** - Concrete implementations of ports ``` ┌─────────────────────────┐ │ Domain │ │ │ Driving │ ┌─────────────────┐ │ Driven (Primary) │ │ Models │ │ (Secondary) Adapters │ │ Project, Cmd │ │ Adapters │ │ │ APIKey, Scope │ │ │ │ │ └─────────────────┘ │ │ │ │ │ │ │ │ ┌─────────────────┐ │ │ │ │ │ Ports │ │ │ ▼ │ │ (Interfaces) │ │ ▼ ┌───────┐ │ └─────────────────┘ │ ┌───────┐ │ HTTP │───────────▶│ │◀───────────│ K8s │ │Handler│ │ ┌─────────────────┐ │ │Adapter│ └───────┘ │ │ Services │ │ └───────┘ │ │ ProjectService │ │ │ │ AuthService │ │ ┌───────┐ │ └─────────────────┘ │◀───────────│ DB │ │ │ │Adapter│ └─────────────────────────┘ └───────┘ ``` ## Domain Layer Located in `internal/domain/`. ### Models ```go // Project represents a development environment type Project struct { ID ProjectID Name string PodName string Status ProjectStatus LastSeen time.Time Labels map[string]string Annotations map[string]string } // Command represents an executable command type Command struct { ID CommandID ProjectID ProjectID Type CommandType Args []string Status CommandStatus StartedAt time.Time EndedAt *time.Time ExitCode *int } ``` ### Domain Errors ```go var ( ErrProjectNotFound = errors.New("project not found") ErrCommandNotFound = errors.New("command not found") ErrInvalidCommand = errors.New("invalid command") ErrCommandSanitization = errors.New("command failed sanitization") ) ``` ## Ports Layer Located in `internal/port/`. ### Port Interfaces ```go // ProjectRepository defines project data access type ProjectRepository interface { List(ctx context.Context) ([]domain.Project, error) Get(ctx context.Context, id domain.ProjectID) (*domain.Project, error) Exists(ctx context.Context, id domain.ProjectID) (bool, error) RefreshStatus(ctx context.Context) error } // CommandExecutor defines command execution capability type CommandExecutor interface { Execute(ctx context.Context, cmd *domain.Command, podName string, outputFn func(domain.OutputLine)) (*domain.CommandResult, error) Cancel(id domain.CommandID) error ActiveCount() int } ``` ### Benefits of Ports 1. **Testability**: Mock implementations for unit tests 2. **Flexibility**: Swap adapters without changing business logic 3. **Documentation**: Interfaces define contracts clearly ## Adapters Layer Located in `internal/adapter/`. ### Kubernetes Adapter ```go // kubernetes/project_repository.go type ProjectRepository struct { namespace string client kubernetes.Interface projects []domain.Project mu sync.RWMutex } func (r *ProjectRepository) List(ctx context.Context) ([]domain.Project, error) { // Uses K8s API to discover pods with rdev labels } // kubernetes/executor.go type Executor struct { namespace string activeCommands map[domain.CommandID]context.CancelFunc } func (e *Executor) Execute(ctx context.Context, cmd *domain.Command, podName string, outputFn func(domain.OutputLine)) (*domain.CommandResult, error) { // Uses kubectl exec to run commands } ``` ### Caching Adapter ```go // cached/project_repository.go type ProjectRepository struct { inner port.ProjectRepository ttl time.Duration projectsCache []domain.Project lastFetch time.Time } func (r *ProjectRepository) List(ctx context.Context) ([]domain.Project, error) { if r.isCacheFresh() { return r.projectsCache, nil } return r.inner.List(ctx) } ``` ## Service Layer Located in `internal/service/`. Services orchestrate domain logic using ports: ```go type ProjectService struct { repo port.ProjectRepository executor port.CommandExecutor streams port.StreamManager } func (s *ProjectService) ExecuteClaude(ctx context.Context, req ExecuteClaudeRequest) (*ExecuteResult, error) { // 1. Validate project exists project, err := s.repo.Get(ctx, req.ProjectID) if err != nil { return nil, err } // 2. Sanitize command if err := sanitize.ClaudePrompt(req.Prompt); err != nil { return nil, domain.ErrCommandSanitization } // 3. Execute via port result, err := s.executor.Execute(ctx, cmd, project.PodName, s.handleOutput) return result, nil } ``` ## Dependency Injection Dependencies flow inward: ```go // cmd/rdev-api/main.go func main() { // Create adapters k8sClient := kubernetes.NewClientset() projectRepo := kubernetes.NewProjectRepositoryWithClient(namespace, k8sClient) cachedRepo := cached.NewProjectRepository(projectRepo, 30*time.Second) executor := kubernetes.NewExecutor(namespace) // Create services with ports projectService := service.NewProjectService(cachedRepo, executor) // Create handlers with services projectsHandler := handlers.NewProjectsHandlerWithService(projectService) } ``` ## Testing ### Unit Tests with Mocks ```go type mockProjectRepo struct { projects []domain.Project } func (m *mockProjectRepo) Get(ctx context.Context, id domain.ProjectID) (*domain.Project, error) { for _, p := range m.projects { if p.ID == id { return &p, nil } } return nil, domain.ErrProjectNotFound } func TestProjectService_ExecuteClaude(t *testing.T) { repo := &mockProjectRepo{projects: testProjects} exec := &mockExecutor{} svc := service.NewProjectService(repo, exec) result, err := svc.ExecuteClaude(ctx, req) // Assert... } ``` ### Integration Tests with Real Adapters ```go func TestKubernetesAdapter_Execute(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } executor := kubernetes.NewExecutor("test-namespace") // Test with real K8s... } ``` ## Trade-offs ### Benefits - Clear separation of concerns - Easy to test in isolation - Flexible infrastructure choices - Domain logic remains pure ### Costs - More interfaces and types - Initial setup complexity - Some indirection overhead ## Related Patterns - **Repository Pattern**: Abstracts data access - **Service Layer Pattern**: Orchestrates business logic - **Dependency Injection**: Decouples creation from usage