Architecture
AWF follows Hexagonal (Ports and Adapters) / Clean Architecture with strict dependency inversion.
Overview
┌─────────────────────────────────────────────────────────────┐
│ INTERFACES LAYER │
│ CLI (current) │ API (future) │ MQ (future) │
└───────────────────────────┬─────────────────────────────────┘
│
┌───────────────────────────┴─────────────────────────────────┐
│ APPLICATION LAYER │
│ WorkflowService │ ExecutionService │ PluginService │
└───────────────────────────┬─────────────────────────────────┘
│
┌───────────────────────────┴─────────────────────────────────┐
│ DOMAIN LAYER │
│ Workflow │ Step │ Plugin │ Operation │ Ports (Interfaces) │
└───────────────────────────┬─────────────────────────────────┘
│
┌───────────────────────────┴─────────────────────────────────┐
│ INFRASTRUCTURE LAYER │
│ YAMLRepository │ JSONStateStore │ AgentProviders │ GitHub │ Notify │
└─────────────────────────────────────────────────────────────┘Dependency Rule
Domain layer depends on nothing. All other layers depend inward toward domain.
Interfaces → Application → Domain ← Infrastructure- Domain defines interfaces (Ports)
- Infrastructure implements those interfaces (Adapters)
- Application orchestrates domain logic
- Interfaces handle external communication
Layers
Domain Layer
The core business logic. No external dependencies.
Location: internal/domain/
Components:
workflow/- Workflow, Step, State, Context entitiesplugin/- Plugin manifest, operation schema, state entitiesoperation/- Operation interface, OperationRegistry, input validation (ValidateInputs)ports/- Repository, StateStore, Executor, PluginManager, OperationProvider interfaces
Key Entities:
type Workflow struct {
ID string
Name string
Version string
Description string
Inputs []InputDef
States map[string]State
Initial string
}
type State interface {
GetName() string
GetType() StateType
}
type ExecutionContext struct {
WorkflowID string
Inputs map[string]interface{}
States map[string]StateResult
}Operation Interface and Registry (F057):
// Operation defines the contract for executable operations.
type Operation interface {
Name() string
Execute(ctx context.Context, inputs map[string]any) (*plugin.OperationResult, error)
Schema() *plugin.OperationSchema
}
// OperationRegistry manages operation lifecycle with thread-safe access.
// Implements ports.OperationProvider for seamless ExecutionService integration.
type OperationRegistry struct {
operations map[string]Operation
mu sync.RWMutex
}The Operation interface is distinct from OperationProvider: an Operation is a single executable operation (e.g., http.get), while OperationProvider manages a collection. The registry bridges the two by implementing OperationProvider using registered Operation instances.
ValidateInputs(schema, inputs) is a standalone function that checks required fields, type correctness (string, integer, boolean, array, object), and applies defaults for optional inputs. It handles JSON float64→int coercion.
Ports (Interfaces):
type Repository interface {
Load(name string) (*Workflow, error)
List() ([]WorkflowInfo, error)
}
type StateStore interface {
Save(ctx *ExecutionContext) error
Load(id string) (*ExecutionContext, error)
Delete(id string) error
}
type Executor interface {
Execute(ctx context.Context, cmd Command) (Result, error)
}
type PluginManager interface {
// Discover finds plugins in the plugins directory.
Discover(ctx context.Context) ([]*plugin.PluginInfo, error)
// Load loads a plugin by name.
Load(ctx context.Context, name string) error
// Init initializes a loaded plugin.
Init(ctx context.Context, name string, config map[string]any) error
// Shutdown stops a running plugin.
Shutdown(ctx context.Context, name string) error
// ShutdownAll stops all running plugins.
ShutdownAll(ctx context.Context) error
// Get returns plugin info by name.
Get(name string) (*plugin.PluginInfo, bool)
// List returns all known plugins.
List() []*plugin.PluginInfo
}
// Interactive mode ports follow ISP with focused interfaces:
type StepPresenter interface {
ShowHeader(workflowName string)
ShowStepDetails(info *workflow.InteractiveStepInfo)
ShowExecuting(stepName string)
ShowStepResult(state *workflow.StepState, nextStep string)
}
type StatusPresenter interface {
ShowAborted()
ShowSkipped(stepName string, nextStep string)
ShowCompleted(status workflow.ExecutionStatus)
ShowError(err error)
}
type UserInteraction interface {
PromptAction(hasRetry bool) (workflow.InteractiveAction, error)
EditInput(name string, current any) (any, error)
ShowContext(ctx *workflow.RuntimeContext)
}
// Composite for backward compatibility (io.ReadWriter pattern):
type InteractivePrompt interface {
StepPresenter
StatusPresenter
UserInteraction
}Application Layer
Orchestrates use cases using domain entities and ports.
Location: internal/application/
Services:
WorkflowService- Workflow loading, validation, listingExecutionService- Workflow execution engineStateManager- State persistence managementTemplateService- Template loading and resolutionPluginService- Plugin lifecycle orchestration
Key Responsibilities:
- Coordinate domain operations
- Handle transactions/rollbacks
- Implement use case logic
- No direct infrastructure access (only through ports)
Infrastructure Layer
Implements domain ports with concrete technologies.
Location: internal/infrastructure/
Adapters:
agents/- AI agent providers (Claude, Gemini, Codex, OpenCode, OpenAI-Compatible) implementingAgentProviderconfig/- Configuration file loadingdiagram/- Workflow diagram generation (DOT/Graphviz)errors/- Error formatting adapters implementingErrorFormatterexecutor/- Shell executor implementingExecutorexpression/- Expression evaluator implementingExpressionEvaluatorandExpressionValidatorgithub/- Built-in GitHub operation provider implementingOperationProvider(issue/PR/label/project operations, batch executor, auth fallback)logger/- Zap logger implementation (console, JSON, multi-logger, secret masking)notify/- Built-in notification operation provider implementingOperationProvider(desktop, webhook backends)plugin/- RPC plugin manager, manifest parser, state store,CompositeOperationProviderfor multi-provider dispatchrepository/- YAML file loader implementingRepositorystore/- JSON state store implementingStateStore, SQLite history storagetokenizer/- Token counting for conversation context managementxdg/- XDG directory discovery
Implementation Details:
// YAML Repository
type YAMLRepository struct {
paths []string // Search paths for workflows
}
func (r *YAMLRepository) Load(name string) (*Workflow, error) {
// Find and parse YAML file
}
// JSON State Store
type JSONStateStore struct {
dir string // Storage directory
}
func (s *JSONStateStore) Save(ctx *ExecutionContext) error {
// Atomic write via temp file + rename
}
// Shell Executor
type ShellExecutor struct{}
func (e *ShellExecutor) Execute(ctx context.Context, cmd Command) (Result, error) {
// Execute via detected shell ($SHELL or /bin/sh fallback)
}
// RPC Plugin Manager
type RPCPluginManager struct {
pluginsDir string
clients map[string]*plugin.Client
}
func (m *RPCPluginManager) Load(name string) error {
// Start plugin process via HashiCorp go-plugin
}Interfaces Layer
External communication adapters.
Location: internal/interfaces/
Components:
cli/- Cobra command handlerscli/ui/- Terminal UI components (colors, progress bars)
Future:
- REST API adapter
- gRPC adapter
- Message queue adapter
Key Patterns
Dependency Injection
Services receive dependencies through constructors:
func NewWorkflowService(
repo ports.Repository,
store ports.StateStore,
executor ports.Executor,
logger ports.Logger,
validator ports.ExpressionValidator,
) *WorkflowService {
return &WorkflowService{
repo: repo,
store: store,
executor: executor,
logger: logger,
validator: validator,
}
}All dependencies are domain port interfaces, never infrastructure implementations. The interfaces layer (CLI) wires concrete adapters at the composition root.
State Machine
Workflow execution follows a state machine pattern:
- Load initial state
- Execute state (step/parallel/loop)
- Evaluate transitions
- Move to next state
- Repeat until terminal state
Atomic Operations
State persistence uses atomic writes:
// Write to temp file
tmpFile := fmt.Sprintf("%s.%d.%d.tmp", path, os.Getpid(), time.Now().UnixNano())
// Write content
// Rename to target (atomic on POSIX)
os.Rename(tmpFile, path)Context Propagation
Go contexts are used for cancellation and timeouts:
ctx, cancel := context.WithTimeout(parentCtx, timeout)
defer cancel()
result, err := executor.Execute(ctx, cmd)Parallel Execution
Uses errgroup with semaphore for controlled concurrency:
g, ctx := errgroup.WithContext(ctx)
sem := make(chan struct{}, maxConcurrent)
for _, step := range steps {
g.Go(func() error {
sem <- struct{}{}
defer func() { <-sem }()
return executeStep(ctx, step)
})
}
return g.Wait()Data Flow
Workflow Execution
CLI Command
│
▼
WorkflowService.Load()
│
▼
ExecutionService.Execute()
│
├──► StateStore.Save() (persist state)
│
├──► Executor.Execute() (run commands)
│
└──► StateStore.Load() (resume if interrupted)Signal Handling
SIGINT/SIGTERM
│
▼
Context Cancellation
│
├──► Executor stops command
│
├──► StateStore saves checkpoint
│
└──► Clean exit with state preservedTesting Strategy
- Domain: Pure unit tests, no mocks needed
- Application: Mock ports for isolation
- Infrastructure: Integration tests with real resources
- Interfaces: End-to-end CLI tests
See Testing for details.
Interface Design Decisions
ExpressionValidator: Constructor Injection (C051)
The WorkflowService previously instantiated expression.NewExprValidator() directly, violating the Dependency Inversion Principle by importing infrastructure from the application layer. This was fixed by:
- Adding
validator ports.ExpressionValidatoras a constructor parameter - Removing the
internal/infrastructure/expressionimport fromservice.go - Wiring the concrete
ExpressionValidatoradapter at CLI call sites (composition root)
The ExpressionValidator port interface was already defined in internal/domain/ports/expression_validator.go. The fix makes the application layer depend only on the port abstraction.
Architecture Enforcement (C051)
Architecture constraints are enforced automatically using
go-arch-lint, configured in .go-arch-lint.yml. This provides AST-based validation of the full dependency graph across all layers.
Usage:
make lint-arch # Check architecture constraints
make lint-arch-map # Show component-to-package mappingIntegration:
lint-archtarget is integrated intomake qualitygate- Runs in CI via
.github/workflows/quality.ymlon every push - Blocks merge if violations detected
- All dependencies checked via AST analysis (deepScan mode)
Configuration Structure (.go-arch-lint.yml):
| Section | Purpose |
|---|---|
version: 3 | Configuration version |
workdir: internal | Scope for component paths |
commonVendors | Shared libraries (all components) |
commonComponents | Shared packages (all components) |
vendors | Vendor library definitions |
components | 20 components across 4 layers |
deps | Dependency rules per component |
Validation Rules by Layer:
| Layer | Count | Max Dependencies | Rationale |
|---|---|---|---|
| Domain | 5 | stdlib + sync only | Pure business logic |
| Application | 1 | domain only + stdlib | Orchestration, no infra |
| Infrastructure | 12 | domain + vendors + stdlib | Concrete implementations |
| Interfaces | 2 | all layers + stdlib | Delivery/wiring |
| Testutil | 1 | domain + selected infra | Test helpers |
Example: Application Layer Rule:
application:
mayDependOn:
- domain-workflow
- domain-ports
- domain-errors
- domain-plugin
canUse:
- go-stdlib
- go-syncThis means the application layer can depend on domain components and stdlib only — any attempt to import from infrastructure, vendor libraries, or other layers will fail the build.
Debugging Component Mapping:
$ make lint-arch-map
Component mapping:
domain-workflow → internal/domain/workflow
domain-ports → internal/domain/ports
application → internal/application
infra-expression → internal/infrastructure/expression
interfaces-cli → internal/interfaces/cli
... (14 more)C042 + C051 Alignment:
- C042 tests verify DIP compliance at runtime via integration tests
- C051 enforces DIP compliance at build time via static analysis
- Together they provide defense-in-depth: testing catches logic errors, linting catches architectural violations
PluginManager: Keep Unified (C050)
The PluginManager interface (7 methods) was evaluated for ISP compliance and kept unified.
Rationale:
- Single consumer: Only PluginService uses PluginManager, calling all 7 methods
- Cross-concern coupling: DisablePlugin uses both Get() (query) and Shutdown() (lifecycle) together
- 7 methods is within acceptable threshold (Go stdlib net.Conn has 8)
- Plugin subsystem already has 8 focused interfaces averaging 3.75 methods each
Contrast with C049: InteractivePrompt (11 methods, 3 consumers) was split into focused interfaces because different consumers needed distinct method subsets.
See Also
- Project Structure - Directory organization
- Testing - Testing conventions
- Contributing - Development workflow