ADR-001: Handler-Based Action Architecture¶
Status: Accepted Date: 2026-02-05 Deciders: Engineering Team Technical Story: Action system refactoring to improve maintainability and extensibility
Context¶
The original mooncake executor implemented actions as large switch statements and monolithic step handlers within the executor package. This approach had several issues:
- Tight Coupling: All action logic was tightly coupled to the executor
- Poor Modularity: Adding new actions required changes to multiple files (executor.go, dryrun.go, schema.json, config.go)
- Test Complexity: Testing individual actions required importing the entire executor
- Code Duplication: Similar patterns repeated across action implementations
- Limited Extensibility: No clean way to add actions without modifying core executor code
The codebase had:
- ~20,000 lines of action implementation code in executor package
- 12
*_step.gofiles and 5*_step_test.gofiles - Manual dispatcher with 40+ line switch statement
- No separation between action logic and execution orchestration
Decision¶
We adopted a handler-based architecture with the following key components:
Benefits:
- Modular: Each action is self-contained in one file
- Extensible: Adding new actions requires only 1 file + registration
- Testable: Actions can be tested in isolation
- Reduced Complexity: Net reduction of ~16,000 lines of code
1. Handler Interface¶
Each action implements a 4-method interface:
type Handler interface {
Metadata() ActionMetadata // Name, description, category, version
Validate(*config.Step) error // Pre-flight validation
Execute(Context, *config.Step) (Result, error) // Main execution
DryRun(Context, *config.Step) error // Preview mode
}
2. Registry Pattern¶
- Thread-safe registry maps action names to handlers
- Handlers self-register via
init()functions - Automatic dispatch without manual routing code
3. Package Structure¶
internal/actions/- Interface definitions and registryinternal/actions/<name>/- Individual action implementationsinternal/register/- Centralized import hub (avoids circular imports)cmd/mooncake.go- Imports register package to trigger registration
4. Execution Flow¶
- User defines step in YAML (e.g.,
shell: "echo hello") - Config parser creates Step struct with appropriate action field
- Executor determines action type via
step.DetermineActionType() - Dispatcher looks up handler in registry:
actions.Get(actionType) - Handler validates, executes, and returns result
- Executor registers result and continues
Alternatives Considered¶
Alternative 1: Code Generation¶
Approach: Generate action code from JSON schema or templates
Pros:
- Guaranteed consistency
- Easy to add actions via configuration
Cons:
- Generated code harder to debug
- Less flexible for complex actions
- Build tooling complexity
- Harder to understand for contributors
Rejected: Generated code reduces flexibility and increases complexity
Alternative 2: Plugin System¶
Approach: Load actions as external plugins (.so files)
Pros:
- Users can add actions without recompiling
- Complete isolation between actions
Cons:
- Go plugin system is experimental and has limitations
- Platform-specific plugin formats
- Version compatibility issues
- Debugging complexity
- Security concerns with external code
Rejected: Go plugins are not mature enough for production use
Alternative 3: Keep Legacy Monolithic Approach¶
Approach: Continue with switch statements and executor-embedded actions
Pros:
- No migration needed
- Familiar to existing contributors
Cons:
- Continues to accumulate technical debt
- Poor modularity
- Hard to test
- Difficult to extend
Rejected: Does not address core maintainability issues
Consequences¶
Positive¶
- Reduced Code Complexity
- Net reduction of ~16,000 lines
- Each action self-contained in one file (100-1000 lines)
-
Clear separation of concerns
-
Improved Maintainability
- Easy to understand action implementation (single file)
- Clear interface contract
-
No hidden dependencies
-
Enhanced Extensibility
- Adding new action requires only 1 file + registration
- No dispatcher updates needed
-
No dry-run logger updates needed
-
Better Testability
- Actions can be tested in isolation
- Mock context for unit tests
-
816 tests covering all actions
-
Zero Breaking Changes
- Config format unchanged
- YAML schema unchanged
-
Drop-in replacement for users
-
Runtime Introspection
- Registry provides list of available actions
- Metadata queryable at runtime
- Enables future CLI features (e.g.,
mooncake actions list)
Negative¶
- More Packages
- 15 action packages vs 1 executor package
- Slightly more complex directory structure
-
Mitigated by clear naming and organization
-
Exported Test Helpers
- Some internal functions exported for testing
- Risk: Users might depend on internal API
-
Mitigated by
INTERNALgodoc comments andinternal/package -
Import Cycles Required Special Handling
- Needed separate register package
- Slight indirection in import path
- Mitigated by clear documentation
Risks¶
- API Stability
- Risk: Handler interface changes could break all actions
- Mitigation: Interface is simple and unlikely to change
-
Status: Low risk
-
Performance
- Risk: Registry lookup overhead
- Mitigation: Map lookup is O(1), negligible overhead
-
Status: No measurable impact
-
Learning Curve
- Risk: New contributors need to understand handler pattern
- Mitigation: Comprehensive documentation, clear examples
- Status: Low risk with good docs
Implementation Details¶
Migration Strategy¶
- Created handler interface and registry (foundation)
- Migrated actions one-by-one (13 actions over several days)
- Maintained dual dispatch during migration (registry + legacy fallback)
- Removed legacy code once all actions migrated
- Updated tests to use new architecture
File Organization¶
internal/
├── actions/
│ ├── handler.go # Handler interface
│ ├── registry.go # Thread-safe registry
│ ├── interfaces.go # Context/Result interfaces
│ ├── print/
│ │ └── handler.go # Print action (98 lines)
│ ├── shell/
│ │ └── handler.go # Shell action (520 lines)
│ ├── file/
│ │ └── handler.go # File action (795 lines)
│ └── ... (12 more actions)
├── register/
│ └── register.go # Import hub
└── executor/
├── executor.go # Orchestration, dispatch
├── context.go # Execution context
└── result.go # Result type
Handler Example¶
package print
import (
"github.com/alehatsman/mooncake/internal/actions"
"github.com/alehatsman/mooncake/internal/config"
"github.com/alehatsman/mooncake/internal/executor"
)
type Handler struct{}
func init() {
actions.Register(&Handler{})
}
func (h *Handler) Metadata() actions.ActionMetadata {
return actions.ActionMetadata{
Name: "print",
Description: "Output messages to console",
Category: actions.CategoryOutput,
SupportsDryRun: true,
}
}
func (h *Handler) Validate(step *config.Step) error {
if step.Print == nil || *step.Print == "" {
return fmt.Errorf("print message is empty")
}
return nil
}
func (h *Handler) Execute(ctx actions.Context, step *config.Step) (actions.Result, error) {
message := *step.Print
ctx.GetLogger().Infof(message)
result := executor.NewResult()
result.Changed = false
result.Stdout = message
return result, nil
}
func (h *Handler) DryRun(ctx actions.Context, step *config.Step) error {
message := *step.Print
ctx.GetLogger().Infof(" [DRY-RUN] Would print: %s", message)
return nil
}
Compliance¶
This ADR complies with:
- Go package design principles
- SOLID principles (especially Single Responsibility and Open/Closed)
- Clean Architecture patterns
- Mooncake code style guidelines
References¶
- Adding Actions Guide - Developer guide for implementing new actions
- Action Migration Summary - Complete migration history
- Handler Interface - Source code
- Registry Implementation - Source code
Related Decisions¶
- None (this is the first ADR)
Future Considerations¶
- Action Versioning: Consider adding versioning to handler interface for backward compatibility
- Action Discovery: Add CLI command to list available actions and their metadata
- Action Metrics: Collect performance metrics per action type
- Action Lifecycle Hooks: Consider adding BeforeExecute/AfterExecute hooks
- Async Actions: Evaluate support for long-running actions with progress callbacks
Appendix: Migration Statistics¶
- Actions Migrated: 15 total (13 core + 2 new)
- Code Reduced: ~16,000 lines deleted, ~6,000 lines added (net -10,000 lines)
- Files Deleted: 17 legacy files
- Files Created: 15 handler files + registry infrastructure
- Test Coverage: 816 tests passing, 0 failures
- Breaking Changes: Zero
- Migration Duration: ~2 weeks
- Build Status: All clean