rdev/docs/architecture/hexagonal.md
jordan 72d16929ca feat: Implement hexagonal architecture with services, webhooks, queue, and telemetry
Major refactoring to hexagonal (ports & adapters) architecture:

- Add service layer (apikey_service, project_service) for business logic
- Add webhook system with dispatcher and delivery tracking
- Add command queue with priority-based processing
- Add rate limiting with sliding window algorithm
- Add audit logging for command execution
- Add OpenTelemetry integration (traces, metrics, spans)
- Add circuit breaker for fault tolerance
- Add cached repository wrapper for performance
- Add comprehensive validation package
- Add Kubernetes client integration for pod management
- Add database migrations (allowed_ips, audit_log, rate_limiting, queue, webhooks)
- Add network policy and PodDisruptionBudget for k8s
- Remove legacy executor and projects/registry packages
- Untrack secrets.yaml (now managed via envault)
- Add coverage.out to .gitignore
- Add e2e test infrastructure with docker-compose
- Add comprehensive documentation (API, architecture, operations, plans)
- Add golangci-lint config and pre-commit hook

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:57:46 -07:00

8.0 KiB

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

// 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

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

// 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

// 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

// 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:

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:

// 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

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

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
  • Repository Pattern: Abstracts data access
  • Service Layer Pattern: Orchestrates business logic
  • Dependency Injection: Decouples creation from usage