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

274 lines
8.0 KiB
Markdown

# 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