Skip to content

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:

  1. Tight Coupling: All action logic was tightly coupled to the executor
  2. Poor Modularity: Adding new actions required changes to multiple files (executor.go, dryrun.go, schema.json, config.go)
  3. Test Complexity: Testing individual actions required importing the entire executor
  4. Code Duplication: Similar patterns repeated across action implementations
  5. 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.go files and 5 *_step_test.go files
  • 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
func init() {
    actions.Register(&Handler{})
}

3. Package Structure

  • internal/actions/ - Interface definitions and registry
  • internal/actions/<name>/ - Individual action implementations
  • internal/register/ - Centralized import hub (avoids circular imports)
  • cmd/mooncake.go - Imports register package to trigger registration

4. Execution Flow

  1. User defines step in YAML (e.g., shell: "echo hello")
  2. Config parser creates Step struct with appropriate action field
  3. Executor determines action type via step.DetermineActionType()
  4. Dispatcher looks up handler in registry: actions.Get(actionType)
  5. Handler validates, executes, and returns result
  6. 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

  1. Reduced Code Complexity
  2. Net reduction of ~16,000 lines
  3. Each action self-contained in one file (100-1000 lines)
  4. Clear separation of concerns

  5. Improved Maintainability

  6. Easy to understand action implementation (single file)
  7. Clear interface contract
  8. No hidden dependencies

  9. Enhanced Extensibility

  10. Adding new action requires only 1 file + registration
  11. No dispatcher updates needed
  12. No dry-run logger updates needed

  13. Better Testability

  14. Actions can be tested in isolation
  15. Mock context for unit tests
  16. 816 tests covering all actions

  17. Zero Breaking Changes

  18. Config format unchanged
  19. YAML schema unchanged
  20. Drop-in replacement for users

  21. Runtime Introspection

  22. Registry provides list of available actions
  23. Metadata queryable at runtime
  24. Enables future CLI features (e.g., mooncake actions list)

Negative

  1. More Packages
  2. 15 action packages vs 1 executor package
  3. Slightly more complex directory structure
  4. Mitigated by clear naming and organization

  5. Exported Test Helpers

  6. Some internal functions exported for testing
  7. Risk: Users might depend on internal API
  8. Mitigated by INTERNAL godoc comments and internal/ package

  9. Import Cycles Required Special Handling

  10. Needed separate register package
  11. Slight indirection in import path
  12. Mitigated by clear documentation

Risks

  1. API Stability
  2. Risk: Handler interface changes could break all actions
  3. Mitigation: Interface is simple and unlikely to change
  4. Status: Low risk

  5. Performance

  6. Risk: Registry lookup overhead
  7. Mitigation: Map lookup is O(1), negligible overhead
  8. Status: No measurable impact

  9. Learning Curve

  10. Risk: New contributors need to understand handler pattern
  11. Mitigation: Comprehensive documentation, clear examples
  12. Status: Low risk with good docs

Implementation Details

Migration Strategy

  1. Created handler interface and registry (foundation)
  2. Migrated actions one-by-one (13 actions over several days)
  3. Maintained dual dispatch during migration (registry + legacy fallback)
  4. Removed legacy code once all actions migrated
  5. 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

  • None (this is the first ADR)

Future Considerations

  1. Action Versioning: Consider adding versioning to handler interface for backward compatibility
  2. Action Discovery: Add CLI command to list available actions and their metadata
  3. Action Metrics: Collect performance metrics per action type
  4. Action Lifecycle Hooks: Consider adding BeforeExecute/AfterExecute hooks
  5. 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