Skip to content

Cobra PersistentPreRun dependency injection trap: why is your client always nil? ​

Cobra PersistentPreRun dependency injection pitfall diagram

A real-world case: when building CLI tools with Cobra,AddCommands passed in from client Why is it nil? How to solve it?

Have you ever encountered a situation like this:

go
// In AddCommands, the client is clearly passed in, but when the subcommand executes, client is nil.
func (a *Application) AddCommands() *Application {
    newOrder := order.NewOrder(a.client, ...) // a.client is nil
    a.command.AddCommand(newOrder.GetNode().Command())
    return a
}

This problem troubled me for an entire day. Today, through a real-world case, we will thoroughly understand the correct approach to dependency injection in Cobra.

I. Problem Scenario ​

Suppose we are building one named agentctl CLI tool that requires:

  • Load configuration file (in PersistentPreRun completed in)

  • Initialize gRPC client (depends on configuration file)

  • The subcommand uses this client to call the remote service.

The code structure is as follows:

go
type Application struct {
    config *config.Config
    client client.AgentClient  // Needs to be injected into the subcommand
    command *cobra.Command
}

func (a *Application) BuildCommand() *Application {
    a.command = &cobra.Command{
        Use: "agentctl",
    }

    // Initialize client in PersistentPreRunE
    a.command.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
        // 1. Load configuration
        // 2. Initialize client
        a.client, err = client.NewClient(...)
        return err
    }

    return a
}

func (a *Application) AddCommands() *Application {
    // At this point, a.client is still nil
    newOrder := order.NewOrder(a.client, a.embeds, a.versionInfo)
    a.command.AddCommand(newOrder.GetNode().Command())
    return a
}

func main() {
    app := NewApplication()
    app.BuildCommand()
    app.AddCommands().Execute()  // PersistentPreRun is executed only when Execute is called
}

Question:AddCommands() at Execute() executed before, while client at PersistentPreRunE Initialize in (Execute executed during the period). Therefore AddCommands passed-in client Yes nil.

II. Understand the Execution Order at a Glance ​

Key contradiction:

  • AddCommands 在 Before command execution Execution (dependency injection timing is too early)

  • client At During command execution of PersistentPreRun Initialized only midway (dependency availability timing is too late)

III. Why does this problem exist? ​

Cobra's design philosophy is:

  • Command structure definition(AddCommands) should be in Execute Completed previously

  • Runtime initialization(PersistentPreRun) at Execute Execute during the period

  • There exists between the two Timing Gap

go
// Cobra internal simplified execution flow
func (c *Command) Execute() error {
    // 1. The command structure has been built (AddCommands has been executed)
    // 2. Start executing the lifecycle
    c.persistentPreRun()  // Dependencies are initialized only here
    c.preRun()
    c.run()               // A dependency is needed here.
    c.postRun()
    return nil
}

IV. Solution: Lazy Dependency Injection (Getter Pattern) ​

Core idea:Instead of passing in a value at construction time, pass in a "method for getting the value".

Solution Implementation ​

1. Modify the Order layer: accept a Getter function ​

go
// order/order.go
type Order interface {
    GetNode() getnode.Commander
}

type order struct {
    // Change to a function type, retrieve lazily.
    clientGetter func() client.AgentClient
    embeds       embed.FS
    versionInfo  *versionInfo.Version
}

// Change the constructor to accept a getter
func NewOrder(clientGetter func() client.AgentClient, embeds embed.FS, versionInfo *versionInfo.Version) Order {
    return &order{
        clientGetter: clientGetter,
        embeds:       embeds,
        versionInfo:  versionInfo,
    }
}

func (o *order) GetNode() getnode.Commander {
    // Pass in a getter when creating a subcommand.
    return getnode.NewCommander(o.clientGetter, o.embeds)
}

2. Modify the subcommand layer: retrieve dependencies during Run ​

go
// order/getnode/getnode.go
type getnode struct {
    // Store the getter instead of the client itself
    clientGetter func() client.AgentClient
    embeds       embed.FS
}

func NewCommander(clientGetter func() client.AgentClient, embeds embed.FS) Commander {
    return &getnode{
        clientGetter: clientGetter,
        embeds:       embeds,
    }
}

func (d *getnode) getCommand() *cobra.Command {
    return &cobra.Command{
        Use: "getnode",
        Run: func(cmd *cobra.Command, args []string) {
            // Only acquire the client at Run time (by then PersistentPreRun has already executed)
            client := d.clientGetter()
            if client == nil {
                gologger.Error().Msg("client not initialized")
                return
            }

            // Use client to call remote service
            response, err := client.GetNode(cmd.Context(), &pb.GetNodeRequest{...})
            // ...
        },
    }
}

3. Modify the main program: pass in the closure ​

go
// main.go
func (a *Application) AddCommands() *Application {
    // Transitive closure, lazy client retrieval
    clientGetter := func() client.AgentClient {
        return a.client
    }

    newOrder := order.NewOrder(clientGetter, a.embeds, a.versionInfo)
    a.command.AddCommand(newOrder.GetNode().Command())
    return a
}

Execution timing (after fix) ​

1. BuildCommand() - Define PersistentPreRunE (not executed)
2. AddCommands() - pass in clientGetter (at this point client is still nil, but that's okay)
3. Execute() starts execution
   ├── PersistentPreRunE execution
   │   └── Initialize a.client (now it has a value)
   ├── Subcommand Run execution
   │   └── client := clientGetter() <- get the initialized client
   └── Call remote service

V. Comparison of Other Possible Solutions ​

PlanAdvantagesshortcomingRecommendation rating
Getter closure (this approach)Small changes, consistent with the Cobra designNeed to understand the concept of closures.5 stars
Initialize the client in advanceSimple and directConfiguration may not be available in advance.2 stars
Independent initialization methodClear and controllable, easy to testConsiderable refactoring4 stars
Dynamically add commands in PersistentPreRun-Anti-pattern, breaking designNot recommended

VI. Summary ​

Key points ​

  • Execution order is key.:AddCommands at PersistentPreRun Execute before; do not use uninitialized dependencies at this time.

  • Delay injection is king.: Use function closures (Getter) to obtain dependencies only when actually needed

  • Three Principles of Dependency Injection:

  • Injected at construction time Stable dependencies(such as embed.FS,aurora)

  • Obtain at runtime dynamic dependency(e.g.client,config)

  • use Getter function The timing gap between bridge construction time and runtime

Remember in one sentence. ​

In Cobra,AddCommands only responsible for "building the framework"; the actual "loading" will have to wait until PersistentPreRun After.

Supporting code pattern ​

go
// Correct dependency injection pattern
type App struct {
    client *Client  // Runtime initialization
}

// When constructing: pass in Getter
func NewOrder(getter func() *Client) *Order {
    return &Order{clientGetter: getter}
}

// At execution: obtain via Getter
func (o *Order) Run() {
    client := o.clientGetter()  // It has already been initialized at this point.
    client.DoSomething()
}

Reference resources ​

Last updated on11/07/2026, 02:16 PM
@fishfinal | @fishfinals | [email protected]