Documents
CLI Dependency Injection
CLI Dependency Injection
Type
Topic
Status
Published
Created
Feb 27, 2026
Updated
Apr 19, 2026
Created by
Dosu Bot
Updated by
Dosu Bot

CLI Dependency Injection#

CLI Dependency Injection is a design pattern used in command-line interface applications to explicitly manage and pass shared dependencies to commands, rather than relying on package-level global variables. In the context of Cobra-based CLI applications, this pattern typically involves encapsulating shared state (such as configuration and logging instances) in a context object and injecting it through Go's context.Context mechanism.

The opnDossier project implements this pattern through a CommandContext structure that holds configuration and logger dependencies, making them available to all subcommands through Cobra's command hierarchy. This approach improves testability, makes dependencies explicit, and reduces the risks associated with mutable global state in concurrent environments.

The pattern addresses several key challenges: hidden dependencies where commands implicitly rely on global state, testing complexity requiring careful setup and teardown of globals, difficulty tracing data flow through the application, concurrency risks with shared global state, and challenges injecting mock dependencies during testing. By moving from implicit global access to explicit dependency passing through context, the pattern makes command dependencies visible, simplifies testing through mock injection, and provides better control over the application's dependency graph.

Architecture Overview#

The CommandContext Pattern#

The CommandContext pattern encapsulates shared application dependencies in a dedicated struct:

type CommandContext struct {
    Config *config.Config
    Logger *logging.Logger
}

This structure replaces direct access to package-level global variables for configuration and logging. Instead of commands accessing cfg and logger globals directly, they retrieve these dependencies from the CommandContext stored in the command's Go context.

Context Key Typing#

To avoid context key collisions, the pattern uses typed context keys:

type contextKey string
const cmdContextKey contextKey = "opnDossierCmdContext"

This follows Go best practices for context keys, where using a private custom type prevents external packages from accessing or colliding with your context keys, even if they use the same string value. The type system ensures uniqueness beyond just the string content.

Dependency Injection Flow#

The dependency injection lifecycle follows these stages:

  1. Initialization: Package init() function creates a default logger and registers persistent flags
  2. Context Setup: Root command's PersistentPreRunE executes before any command, loading configuration and creating a CommandContext
  3. Context Injection: SetCommandContext() stores the CommandContext in the command's Go context
  4. Context Retrieval: Subcommands call GetCommandContext() to retrieve dependencies
  5. Dependency Usage: Commands use the extracted config and logger throughout their execution

Core Implementation#

This section covers the fundamental implementation details of the CommandContext pattern, including how contexts are stored and retrieved, how the initialization process works, and how configuration is loaded.

Context Storage and Retrieval#

The SetCommandContext function safely stores the CommandContext:

func SetCommandContext(cmd *cobra.Command, cmdCtx *CommandContext) {
    if cmd == nil {
        return
    }
    ctx := cmd.Context()
    if ctx == nil {
        ctx = context.Background()
    }
    ctx = context.WithValue(ctx, cmdContextKey, cmdCtx)
    cmd.SetContext(ctx)
}

Key implementation details:

  • Checks for nil command and returns early to prevent panics
  • Creates a background context if the command has no context
  • Uses context.WithValue() to create a new context with the CommandContext
  • Sets the new context on the command

The GetCommandContext function provides defensive retrieval:

func GetCommandContext(cmd *cobra.Command) *CommandContext {
    if cmd == nil {
        return nil
    }
    ctx := cmd.Context()
    if ctx == nil {
        return nil
    }
    cmdCtx, ok := ctx.Value(cmdContextKey).(*CommandContext)
    if !ok {
        return nil
    }
    return cmdCtx
}

This function performs multiple defensive checks:

  • Returns nil if the command is nil
  • Returns nil if the command has no context
  • Safely performs type assertion and returns nil if the key is missing or the type is wrong
  • Never panics, making it safe to call in any situation

PersistentPreRunE Setup#

The root command's PersistentPreRunE hook initializes the CommandContext before any subcommand executes:

PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
    if isLightweightCommand(cmd) {
        return setupLightweightContext(cmd)
    }
    return setupFullContext(cmd)
}

This implementation uses a two-path initialization strategy:

Lightweight Context (for help, version, completion commands):

  • Creates minimal configuration without loading files
  • Uses the default logger from init()
  • Optimizes startup time for commands that don't need full configuration

Full Context (for convert, display, validate commands):

  • Loads configuration from files, environment variables, and flags
  • Creates a properly configured logger based on verbosity settings
  • Provides complete application state for data processing commands

Configuration Loading#

The configuration loading process uses Viper with layered precedence:

  1. CLI flags (highest priority)
  2. Environment variables (OPNDOSSIER_*)
  3. Configuration file (~/.opnDossier.yaml)
  4. Default values (lowest priority)

Logger initialization occurs in two stages:

  • Default Logger: Created in init() for early use
  • Command Logger: Recreated in setupFullContext() with proper log level based on --verbose or --quiet flags

Usage in Subcommands#

This section demonstrates how subcommands access and use the CommandContext in practice, including standard access patterns, logging enhancements, and graceful degradation strategies.

Standard Access Pattern#

All subcommands follow a consistent pattern for accessing the CommandContext:

RunE: func(cmd *cobra.Command, args []string) error {
    cmdCtx := GetCommandContext(cmd)
    if cmdCtx == nil {
        return errors.New("command context not initialized")
    }
    cmdLogger := cmdCtx.Logger
    cmdConfig := cmdCtx.Config

    // Use logger and config throughout the command
    cmdLogger.Info("starting operation")
    // ...
}

This pattern is used consistently across:

Context-Aware Logging#

Commands create enhanced loggers with contextual information:

ctxLogger := cmdLogger.WithContext(ctx).WithFields("input_file", filePath)

This pattern adds:

  • Go context for cancellation propagation
  • Structured fields for better log filtering and analysis
  • Progressive enhancement as more contextual information becomes available

Example of progressive enhancement:

var enhancedLogger *logging.Logger
if actualOutputFile != "" {
    enhancedLogger = ctxLogger.WithFields("output_file", actualOutputFile)
} else {
    enhancedLogger = ctxLogger.WithFields("output_mode", "stdout")
}

Graceful Degradation#

Some commands allow nil context in PreRunE hooks for validation:

PreRunE: func(cmd *cobra.Command, _ []string) error {
    cmdCtx := GetCommandContext(cmd)
    var cmdLogger *logging.Logger
    if cmdCtx != nil {
        cmdLogger = cmdCtx.Logger
    }
    // Validation can proceed with or without logger
    return validateConvertFlags(cmd.Flags(), cmdLogger)
}

This allows validation functions to work before full context initialization, falling back to stderr output when a logger is unavailable.

Flag Binding and Package-Level Variables#

One of the key constraints when implementing dependency injection in Cobra applications is the framework's requirement for package-level flag variables. This section explains why this requirement exists and how to work within these constraints.

Cobra's Flag Binding Requirement#

Cobra requires package-level flag variables because flags are bound during package initialization:

var (
    cfgFile string // CLI config file path
    cfg *config.Config // Application configuration
    logger *logging.Logger // Application logger
)

Flag definitions in init() reference these variables:

func init() {
    rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file path")
}

This is a Cobra framework requirement that cannot be avoided - flags must bind to variables that exist at package load time.

What Stays Global#

Acceptable global variables in the Cobra pattern:

Migration to CommandContext#

The migration strategy moves runtime state from globals to context:

  • Before: Commands directly access cfg and logger package variables
  • After: Commands retrieve dependencies from CommandContext
  • Flag Variables: Remain as package-level (Cobra requirement), but values are extracted into structs during RunE

Testing Patterns#

Testing CLI applications with dependency injection requires special consideration, particularly when dealing with package-level flag variables. This section covers unit testing, mock injection, test isolation, and concurrent testing strategies.

Unit Testing CommandContext#

Comprehensive unit tests cover all edge cases:

Valid Context Test:

func TestGetCommandContext_ValidContext(t *testing.T) {
    cmd := &cobra.Command{Use: "test"}
    cmd.SetContext(context.Background())

    cmdCtx := &CommandContext{
        Config: &config.Config{Verbose: true},
        Logger: testLogger,
    }
    SetCommandContext(cmd, cmdCtx)

    result := GetCommandContext(cmd)
    require.NotNil(t, result)
    assert.Equal(t, cmdCtx.Config, result.Config)
}

Nil Handling Tests:

func TestGetCommandContext_NilCommand(t *testing.T) {
    result := GetCommandContext(nil)
    assert.Nil(t, result)
}

func TestGetCommandContext_NilContext(t *testing.T) {
    cmd := &cobra.Command{Use: "test"}
    result := GetCommandContext(cmd)
    assert.Nil(t, result)
}

Mock Injection Pattern#

Tests inject mock dependencies without modifying global state:

// Create mock config and logger
testConfig := &config.Config{Verbose: true}
testLogger, _ := logging.New(logging.Config{Level: "info"})

// Create and inject CommandContext
cmdCtx := &CommandContext{
    Config: testConfig,
    Logger: testLogger,
}
SetCommandContext(cmd, cmdCtx)

// Command can now retrieve mocked dependencies
result := GetCommandContext(cmd)

Test Isolation with t.Cleanup()#

For package-level flag variables, tests use snapshot patterns:

type sharedFlagSnapshot struct {
    theme string
    wrapWidth int
    noWrap bool
    sections []string
}

func captureSharedFlags() sharedFlagSnapshot {
    return sharedFlagSnapshot{
        theme: sharedTheme,
        wrapWidth: sharedWrapWidth,
        noWrap: sharedNoWrap,
        sections: sharedSections,
    }
}

func (s sharedFlagSnapshot) restore() {
    sharedTheme = s.theme
    sharedWrapWidth = s.wrapWidth
    sharedNoWrap = s.noWrap
    sharedSections = s.sections
}

func TestBuildDisplayOptions(t *testing.T) {
    snapshot := captureSharedFlags()
    t.Cleanup(snapshot.restore)

    // Test can safely modify shared flags
    sharedWrapWidth = 80
    // ... test logic ...
}

Simpler cleanup for individual flags:

func TestBuildOptions(t *testing.T) {
    origValue := sharedRedact
    t.Cleanup(func() { sharedRedact = origValue })

    sharedRedact = true
    // ... test logic ...
}

Integration Testing#

Integration tests verify context propagation:

func TestRootCmdPersistentPreRunE(t *testing.T) {
    testCmd := &cobra.Command{Use: "test"}
    testCmd.PersistentFlags().AddFlagSet(rootCmd.PersistentFlags())

    err := rootCmd.PersistentPreRunE(testCmd, []string{})
    require.NoError(t, err)

    cmdCtx := GetCommandContext(testCmd)
    require.NotNil(t, cmdCtx)
    assert.NotNil(t, cmdCtx.Config)
    assert.NotNil(t, cmdCtx.Logger)
}

Concurrent Testing Limitations#

The dependency injection pattern enables test isolation, but cmd/ tests cannot run in parallel due to package-level CLI flag globals. The cmd package uses package-level flag variables (sharedDeviceType, sharedAuditMode, outputFile, format, force, etc.) that bind to Cobra flags during initialization. Tests that call t.Parallel() risk data races when sibling tests mutate those globals concurrently.

A forbidigo linter rule enforces this ban automatically:

forbidigo:
  analyze-types: true
  forbid:
    - pattern: '^testing\.T\.Parallel$'
      msg: 'cmd/ tests must not call t.Parallel(): cmd uses package-level CLI flag globals that race under parallel execution. See GOTCHAS §1.1.'

The rule is scoped to cmd/ via path-except: 'cmd/.*\.go$' so it does not affect other packages.

Tests that previously used t.Parallel() now save and restore global state with t.Cleanup():

func TestNormalizeFormat(t *testing.T) {
    // Do NOT use t.Parallel() — cmd package uses package-level flag globals.
    // See GOTCHAS §1.1.
    tests := []struct {
        name string
        input string
        expected converter.Format
    }{
        {name: "md to markdown", input: "md", expected: converter.FormatMarkdown},
        // ... more cases
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Do NOT use t.Parallel() — cmd package uses package-level flag globals.
            // See GOTCHAS §1.1.
            result := normalizeFormat(tt.input)
            assert.Equal(t, tt.expected, result)
        })
    }
}

Race detection is still enabled with:

go test -race ./cmd/...

Design Rationale#

Understanding the trade-offs and benefits of the CommandContext pattern helps developers make informed decisions about when and how to apply it in their own CLI applications.

Problems Solved#

The CommandContext pattern addresses several issues with pure global state:

  1. Hidden Dependencies: Commands implicitly depend on globals that can be modified from anywhere
  2. Testing Challenges: Tests require careful setup/teardown with t.Cleanup patterns
  3. Data Flow Opacity: Harder to trace how configuration flows through the application
  4. Concurrency Risks: Global state in concurrent tests needs careful coordination
  5. Mock Injection Difficulty: Requires modifying globals rather than passing test doubles

Benefits of the Pattern#

  • Explicit Dependencies: Dependencies are visible in function signatures and access patterns
  • Improved Testability: Mock injection is straightforward without global modification
  • Better Isolation: Context values are immutable once set, enabling test isolation
  • Clear Data Flow: Easy to trace where config and logger originate and how they propagate
  • Race-Free Context Access: While cmd/ tests cannot use t.Parallel() due to flag globals, CommandContext values themselves are safe from races

Trade-offs#

  • Cobra Flag Constraint: Package-level flag variables remain necessary due to framework requirements
  • Parallel Testing Ban: cmd/ tests cannot use t.Parallel() due to flag globals; enforced by forbidigo linter
  • Migration Complexity: Requires phased migration from existing global-based code
  • Boilerplate: Each command needs nil-checking and context extraction code
  • Learning Curve: Developers must understand both Go context patterns and Cobra lifecycle

The CommandContext pattern builds upon and relates to several established patterns and concepts in Go and software engineering more broadly.

Go Context Best Practices#

Cobra CLI Framework#

Dependency Injection Patterns#

  • Constructor injection vs. context-based injection
  • Service locator pattern
  • Inversion of Control (IoC) containers

Testing Strategies#

  • Table-driven tests with t.Run() subtests
  • Test isolation with t.Cleanup()
  • Sequential testing in cmd/ (no t.Parallel() due to flag globals)
  • Race detection with go test -race

Relevant Code Files#

FileDescriptionKey Components
cmd/context.goCommandContext implementationCommandContext struct, GetCommandContext(), SetCommandContext(), typed context key
cmd/root.goRoot command and context setupPersistentPreRunE, setupFullContext(), setupLightweightContext(), flag registration
cmd/convert.goConvert command using contextContext retrieval, nil handling, context-aware logging
cmd/display.goDisplay command using contextStandard access pattern, enhanced logging
cmd/validate.goValidate command using contextContext retrieval in concurrent operations
cmd/context_test.goUnit tests for CommandContextEdge case coverage, nil handling tests, type safety tests
cmd/root_test.goIntegration tests for context setupPersistentPreRunE testing, context propagation verification
cmd/display_test.goDisplay command testsSnapshot pattern for test isolation, t.Cleanup() usage
cmd/shared_flags.goShared flag definitionsPackage-level flag variables with linter suppressions

Key Takeaways#

  1. Private Context Keys: Always use custom private types like type contextKey string to avoid key collisions
  2. Defensive Access: Check for nil context and perform type assertions safely with GetCommandContext()
  3. PersistentPreRunE Hook: Use PersistentPreRunE in root command to set context for all subcommands
  4. Cobra Flag Requirement: Package-level flag variables are a framework requirement and cannot be avoided
  5. Test Isolation: Use t.Cleanup() with snapshot patterns to save and restore package-level state
  6. Progressive Enhancement: Start with basic logger, enhance with context and structured fields as needed
  7. Graceful Degradation: Allow nil context in PreRunE for validation that can work without full initialization
  8. Mock Injection: Create mock Config and Logger, wrap in CommandContext, inject with SetCommandContext()