DEV Community

Cover image for Go Beyond Viper and Cobra: Declarative Field-Driven Configuration for Go Apps
Lucas de Camargo
Lucas de Camargo

Posted on • Edited on

Go Beyond Viper and Cobra: Declarative Field-Driven Configuration for Go Apps

Production Go applications constantly require the introduction of new configuration parameters. Based on the Open-Closed Principle, once we define a strategy for managing configuration fields, introducing new values becomes only small extensions. In this article, I'm proposing the definition of a Field structure for declaring configuration settings that are easily integrated with CLI completions and documentation generation.

A template is available in my Github Repository, and it uses three packages:

  1. Viper: A complete configuration solution for Go applications that handles multiple file formats (YAML, JSON, TOML, HCL, ENV), environment variables, and provides a unified interface for accessing configuration values. Viper acts as a registry for all your application's configuration needs, with automatic environment variable binding and a precedence system for value resolution.

  2. Cobra: A powerful CLI framework that provides a simple interface to create modern command-line applications with sophisticated features like nested subcommands, POSIX-compliant flags, automatic help generation, and shell completions. It's the same framework used by Kubernetes, Docker, and GitHub CLI for building their command-line interfaces.

  3. Go Validator: A struct and field validation library that enables validation through struct tags, custom validation functions, and cross-field validation. It provides a declarative way to define validation rules directly in your struct definitions, making it easy to ensure data integrity throughout your application.

Table of Contents

Requirements

Modern production applications need robust configuration management that can adapt to different environments, validate inputs, and provide clear documentation to users. The approach presented here addresses these needs by creating a unified system where configuration metadata lives alongside the configuration values themselves. This creates a single source of truth that eliminates duplication and reduces the chance of documentation drift.

Functionalities

Single Source of Truth: Configuration fields are defined once with all metadata (validation, documentation, defaults); and can be easily extended.

Default Value Definition with Build Flags: Production apps are often built for different environments, therefore some default configuration values must be defined by Go ldflags.

Multiple Formats: Viper support for YAML, JSON, TOML, HCL, and ENV configuration files.

Auto CLI Integration: Seamless integration with Cobra for command-line interfaces, data validation, and auto-completion.

Type Safety: Strongly typed configuration with validation by Go Validator tags, custom functions, and literals.

Environment Variables: Automatic binding with configurable prefix.

Documentation: Built-in help and documentation generation.

The App Example

Our application is called confapp, and it uses two groups of configuration: application base parameters, like logging and updates, and network configuration, like proxies. The architecture demonstrates how to organize configuration fields into logical groups, making it easy for users to understand and manage related settings together. Each field carries its complete metadata, ensuring that validation rules, documentation, and defaults are always consistent across the entire application.

Users are expected to use the CLI to configure the application, like:

confapp config set --log.level debug --update.auto true
Enter fullscreen mode Exit fullscreen mode

Application Fields

Field Type Default Description
environment string dev Application environment (hidden)
log.level string info Logging level (debug, info, warn, error)
log.output string nil Log output file path
update.auto bool false Enable auto-updates
update.period duration 15m Update check period

Network Fields

Field Type Default Description
proxy.http string nil HTTP proxy
proxy.https string nil HTTPS proxy

Implementation

The implementation follows a modular approach where each component has a specific responsibility. The configuration module defines the fields and their metadata, Viper handles the actual storage and retrieval of values, and Cobra provides the user interface. This separation of concerns makes the system maintainable and allows each component to evolve independently while maintaining a stable interface between them.

File Structure

The project structure reflects the separation between CLI commands and configuration logic. The cmd directory contains all CLI-related code, while the internal/config directory houses the configuration management core. This organization makes it clear where to find specific functionality and ensures that the configuration logic remains independent of the CLI implementation.

go-appconfig-example/
├── cmd/                   # CLI command implementations
│   ├── root.go            # Root command and global flags
│   └── config.go          # Configuration management commands
├── internal/
│   ├── config/            # Configuration management core
│   │   ├── fields.go      # Field definitions and collections
│   │   ├── config.go      # Viper integration
│   │   └── validators.go  # Custom validation functions
│   └── consts/            # Application constants
│       └── consts.go      # Go flags like app name, version, etc.
├── main.go                # Application entry point
└── go.mod                 # Go module definition
Enter fullscreen mode Exit fullscreen mode

Constants

Application constants define global values that remain consistent throughout the application's lifecycle. These can be overridden at build time using Go's -ldflags feature, allowing you to customize the application name, version, or other constants without modifying the source code. This is particularly useful for CI/CD pipelines where different builds might need different configurations.

// internal/consts/consts.go

var (
  // Application parameters
  AppName    = "confapp"
  AppVersion = "v0.1.0"

  // Config parameters
  ConfigEnvPrefix = "CONFAPP"
)
Enter fullscreen mode Exit fullscreen mode

Field-Driven Configuration

Field-Driven Configuration is a design pattern where configuration parameters are defined as structured data containing all their metadata. Instead of scattering validation rules, documentation, and default values across different parts of the codebase, each field becomes a self-contained unit that describes everything about a configuration parameter. This approach ensures consistency and makes it trivial to add new configuration options without touching multiple files.

The Field Structure

The Field structure is the cornerstone of our configuration system. It encapsulates not just the value of a configuration parameter, but also its type, validation rules, documentation, and any other metadata needed to work with that parameter. This rich metadata enables automatic generation of CLI flags, validation logic, and documentation, all from a single definition.

// internal/config/fields.go

type FieldType string

const (
  FieldTypeString   FieldType = "string"
  FieldTypeBool     FieldType = "bool"
  FieldTypeInt      FieldType = "int"
  FieldTypeFloat    FieldType = "float"
  FieldTypeDuration FieldType = "duration"
)

type Field struct {
  Name         string            // Configuration key: "log.level"
  Group        string            // Logical grouping: "logging"
  Type         FieldType         // Data type: string, bool, duration
  Default      any               // Default value
  Description  string            // CLI help text
  Docstring    string            // Detailed documentation
  ValidValues  []any             // Allowed values: ["debug", "info", "warn"]
  ValidateTag  string            // Go validator tag: "required,oneof=debug info warn"
  ValidateFunc func(any) error   // Custom validation function
  Example      string            // Usage example
  Hidden       bool              // Hide from standard listings
  Deprecated   string            // Deprecation message if field is deprecated
}

// Validate performs validation on a field value using the configured validation rules.
// Returns nil if validation passes, or an error describing the validation failure.
func (f *Field) Validate(value any) error {
  if value == nil {
    return nil
  }

  // Check against valid values if specified
  if f.ValidValues != nil {
    if slices.Contains(f.ValidValues, value) {
      return nil
    }
    return fmt.Errorf("%s: valid values: %v", f.Name, f.ValidValues)
  }

  // Apply validator tag if specified
  if f.ValidateTag != "" {
    if err := validator.New().Var(value, f.ValidateTag); err != nil {
      return fmt.Errorf("%s: %w", f.Name, err)
    }
  }

  // Apply custom validation function if specified
  if f.ValidateFunc != nil {
    if err := f.ValidateFunc(value); err != nil {
      return fmt.Errorf("%s: %w", f.Name, err)
    }
  }

  return nil
}
Enter fullscreen mode Exit fullscreen mode

Field Collection

The FieldCollection acts as a registry for all configuration fields in your application. It provides methods to add new fields dynamically and retrieve them efficiently. This centralized collection ensures that all parts of the application work with the same field definitions, maintaining consistency across CLI commands, validation, and documentation generation.

// internal/config/fields.go

// Fields is the global collection of all configuration fields
var Fields = FieldCollection{}

type FieldCollection []*Field

func (fc *FieldCollection) Add(fields ...*Field) {
  for _, field := range fields {
    // Check if field already exists and replace it
    for i, existingField := range *fc {
      if existingField.Name == field.Name {
        (*fc)[i] = field
        goto nextField
      }
    }
    // Field doesn't exist, append it
    *fc = append(*fc, field)
  nextField:
  }
}

// Map returns a map for O(1) field lookup by name
func (fc *FieldCollection) Map() FieldMap {
  fields := make(FieldMap)
  for _, f := range *fc {
    fields[f.Name] = f
  }
  return fields
}
Enter fullscreen mode Exit fullscreen mode

Real Field Definitions

With the Field structure and FieldCollection in place, we can now define actual configuration parameters. Each field definition becomes a single source of truth that contains everything needed to work with that configuration value. The use of init() functions ensures that fields are automatically registered when the package is imported, eliminating the need for manual registration and reducing the chance of forgetting to register a field.

Application Fields

// internal/config/fields.go

const GroupApplication = "Application"

func init() {
  Fields.Add(
    FieldAppEnvironment,
    FieldAppLogLevel,
    FieldAppLogOutput,
    FieldAppUpdateAuto,
    FieldAppUpdatePeriod,
  )
}

var FieldAppEnvironment = &Field{
  Name:        "environment",
  Group:       GroupApplication,
  Type:        FieldTypeString,
  Default:     "dev",
  Description: "The environment in which the application runs.",
  Example:     "prod, test, dev",
  Hidden:      true, // This field is not shown in the config list
}

var FieldAppLogLevel = &Field{
  Name:        "log.level",
  Group:       GroupApplication,
  Type:        FieldTypeString,
  Default:     "info",
  Description: "The log level to use for the application.",
  ValidValues: []any{"debug", "info", "warn", "error"},
}

var FieldAppLogOutput = &Field{
  Name:        "log.output",
  Group:       GroupApplication,
  Type:        FieldTypeString,
  Default:     nil,
  Description: "The output file to use for the application logs, if set.",
  ValidateTag: "filepath",
  Example:     "/var/log/app.log",
}

var FieldAppUpdateAuto = &Field{
  Name:        "update.auto",
  Group:       GroupApplication,
  Type:        FieldTypeBool,
  Default:     false,
  Description: "Automatically update the application when a new version is available.",
}

var FieldAppUpdatePeriod = &Field{
  Name:         "update.period",
  Group:        GroupApplication,
  Type:         FieldTypeDuration,
  Default:      "15m",
  Description:  "The period to check for updates, if enabled.",
  Docstring:    `The period can be a number of seconds, or a valid duration string.`,
  ValidateFunc: validateDuration,
  Example:      "1h, 15m, 10 (seconds)",
}
Enter fullscreen mode Exit fullscreen mode

Network Fields

// fields.go

const GroupNetwork = "Network"

func init() {
  Fields.Add(
    FieldNetworkProxyHttp,
    FieldNetworkProxyHttps,
  )
}

var FieldNetworkProxyHttp = &Field{
  Name:        "proxy.http",
  Group:       GroupNetwork,
  Type:        FieldTypeString,
  Default:     nil,
  Description: "Set a proxy server for HTTP traffic",
  ValidateTag: "url",
  Example:     "http://user:password@host:port",
}

var FieldNetworkProxyHttps = &Field{
  Name:        "proxy.https",
  Group:       GroupNetwork,
  Type:        FieldTypeString,
  Default:     nil,
  Description: "Set a proxy server for HTTPS traffic",
  ValidateTag: "url",
  Example:     "http://user:password@host:port",
}
Enter fullscreen mode Exit fullscreen mode

Validators

Validation is handled through the Go Validator library, which provides a declarative way to define validation rules using struct tags. The library supports a wide range of built-in validators like required, email, url, min, max, and many more. You can combine multiple validators using commas (AND logic) or pipes (OR logic). For example, validate:"required,email" ensures a field is both present and a valid email, while validate:"rgb|rgba" accepts either RGB or RGBA color formats.

In our Field structure, we use the ValidateTag field to specify these validation rules, allowing us to leverage the full power of the validator library without writing repetitive validation code.

Beyond the built-in validators, the system supports custom validation functions for complex business logic that can't be expressed through tags alone. These functions receive the value to validate and return an error if validation fails, providing complete flexibility for domain-specific rules.

// internal/config/validators.go

func validateDuration(v any) error {
  switch v := v.(type) {
  case int, int64, int32, int16, int8, uint, uint64, uint32, uint16, uint8:
    // Numeric values are valid (interpreted as seconds)
    return nil
  case float32, float64:
    // Float values are valid (interpreted as seconds)
    return nil
  case string:
    if v == "" {
      return nil // empty value is allowed
    }
    _, err := time.ParseDuration(v)
    if err != nil {
      return fmt.Errorf("invalid duration format: %s (examples: 1h30m, 15m, 10s)", v)
    }
    return nil
  default:
    return fmt.Errorf("duration must be a string or numeric value")
  }
}
Enter fullscreen mode Exit fullscreen mode

Configs Powered by Viper

Viper provides the backbone for our configuration management system. It handles the complexity of merging configuration from multiple sources according to a well-defined precedence order: command-line flags override environment variables, which override config file values, which override defaults. This layered approach allows users to define base configurations in files while still being able to override specific values through environment variables in production or flags during development. Viper also manages the serialization and deserialization of configuration files in various formats, making it easy to work with YAML, JSON, TOML, or any other supported format without changing your code.

// internal/config/config.go

func Init() error {
  // Enable automatic environment variable binding
  viper.AutomaticEnv()
  viper.SetEnvPrefix(consts.ConfigEnvPrefix)
  // Replace dots with underscores in environment variable names
  viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))

  // Set default values for all defined fields
  for _, field := range Fields {
    if field.Default != nil {
      viper.SetDefault(field.Name, field.Default)
    }
  }

  // Validate and process config file if specified
  cfgFile := viper.GetString(FieldFlagConfig.Name)
  if err := FieldFlagConfig.Validate(cfgFile); err != nil {
    return fmt.Errorf("config file validation failed: %w", err)
  }

  if cfgFile != "" {
    if err := loadConfigFile(cfgFile); err != nil {
      return fmt.Errorf("failed to load config file: %w", err)
    }
  }

  return nil
}

// The file extension determines the format (yaml, json, toml, etc.).
func loadConfigFile(cfgFile string) error {
  cfgFileDir := path.Dir(cfgFile)
  cfgFileBase := path.Base(cfgFile)
  cfgFileExt := path.Ext(cfgFile)
  cfgFileName := strings.TrimSuffix(cfgFileBase, cfgFileExt)

  viper.SetConfigName(cfgFileName)
  viper.SetConfigType(cfgFileExt[1:])
  viper.AddConfigPath(cfgFileDir)

  // Attempt to read the config file, but don't fail if it doesn't exist
  if err := viper.ReadInConfig(); err != nil && !errors.As(err, &viper.ConfigFileNotFoundError{}) {
    return fmt.Errorf("failed to read config file: %w", err)
  }

  return nil
}

func ReadField(f *Field) any {
  return viper.Get(f.Name)
}

func WriteField(f *Field, value any) error {
  if err := f.Validate(value); err != nil {
    return fmt.Errorf("validation failed: %w", err)
  }

  viper.Set(f.Name, value)
  return nil
}

func Save() error {
  cfgFile := viper.GetString(FieldFlagConfig.Name)
  if cfgFile == "" {
    return fmt.Errorf("no config file specified")
  }

  // Try to write the config file
  if err := viper.WriteConfigAs(cfgFile); err != nil {
    if errors.Is(err, os.ErrNotExist) {
      // Create directory structure and file if they don't exist
      return createAndWriteConfigFile(cfgFile)
    }
    return fmt.Errorf("failed to write config file %s: %w", cfgFile, err)
  }

  return nil
}

func createAndWriteConfigFile(cfgFile string) error {
  // Create the directory structure
  if err := os.MkdirAll(path.Dir(cfgFile), 0755); err != nil {
    return fmt.Errorf("failed to create config directory: %w", err)
  }

  // Create an empty config file
  if f, err := os.Create(cfgFile); err != nil {
    return fmt.Errorf("failed to create config file %s: %w", cfgFile, err)
  } else {
    f.Close()
  }

  // Write the configuration to the file
  if err := viper.WriteConfigAs(cfgFile); err != nil {
    return fmt.Errorf("failed to write configuration: %w", err)
  }

  return nil
}
Enter fullscreen mode Exit fullscreen mode

Writing the CLI only once with Cobra

The beauty of our Field-Driven approach truly shines when building the CLI with Cobra. Instead of manually defining flags for each configuration parameter and keeping them in sync with validation rules and documentation, our CLI commands automatically derive everything they need from the Field definitions. This means you write the CLI structure once, and it automatically adapts as you add new configuration fields. The commands can iterate through the FieldCollection to generate flags, completions, and documentation dynamically, ensuring that the CLI always reflects the current state of your configuration schema.

The Root Command

The root command serves as the entry point for your CLI application. While you can generate the initial structure using cobra-cli init for the root command and cobra-cli add for subcommands, the real power comes from integrating it with our Field system. The root command sets up global flags and initializes the configuration system before any subcommand runs, ensuring that all parts of the application work with properly loaded and validated configuration.

// cmd/root.go

// rootCmd represents the base command when called without any subcommands.
// It serves as the entry point for the CLI application.
var rootCmd = &cobra.Command{
  Use:     consts.AppName,
  Short:   "Go application with structured configuration example",
  Long:    `A demonstration of production-ready configuration management in Go using Viper and Cobra.`,
  Version: consts.AppVersion,
}

// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
  err := rootCmd.Execute()
  if err != nil {
    os.Exit(1)
  }
}
Enter fullscreen mode Exit fullscreen mode

Binding Flags

Cobra provides three types of flags: local flags (specific to a command), persistent flags (available to a command and all its subcommands), and the special PFlags type that integrates with Viper. When you bind a flag to Viper using viper.BindPFlag(), Viper automatically reads the flag value if it's set, allowing seamless integration between command-line arguments and your configuration system. This binding creates a unified interface where users can set values through flags, environment variables, or config files, and your application code doesn't need to know which source provided the value.

Our implementation uses persistent flags for global options like the config file path and verbosity level, ensuring these are available to all subcommands. The initialization happens in Cobra's OnInitialize hook, which runs before any command execution, guaranteeing that configuration is properly loaded before your command logic runs.

// cmd/root.go

var (
  FlagConfig  string // Path to the configuration file
  FlagVerbose bool   // Enable verbose output
)

func init() {
  // Initialize configuration before any command is executed
  cobra.OnInitialize(initConfig)
  // Set up persistent flags that are available to all commands
  setupPersistentFlags()
}

func setupPersistentFlags() {
  // Determine default config file location
  configDir, _ := os.UserConfigDir()
  defaultConfig := path.Join(configDir, consts.AppName, "config.yaml")

  // Config file flag
  rootCmd.PersistentFlags().StringVarP(
    &FlagConfig,
    config.FieldFlagConfig.Name,
    config.FieldFlagConfig.Shorthand,
    defaultConfig,
    config.FieldFlagConfig.Description,
  )
  viper.BindPFlag(
    config.FieldFlagConfig.Name,
    rootCmd.PersistentFlags().Lookup(config.FieldFlagConfig.Name),
  )

  // Verbose flag
  rootCmd.PersistentFlags().BoolVarP(
    &FlagVerbose,
    config.FieldFlagVerbose.Name,
    config.FieldFlagVerbose.Shorthand,
    false,
    config.FieldFlagVerbose.Description,
  )
  viper.BindPFlag(
    config.FieldFlagVerbose.Name,
    rootCmd.PersistentFlags().Lookup(config.FieldFlagVerbose.Name),
  )
}

func initConfig() {
  if err := config.Init(); err != nil {
    fmt.Printf("Configuration error: %v\n", err)
    os.Exit(1)
  }

  if FlagVerbose {
    printConfigInfo()
  }
}

func printConfigInfo() {
  cfgFile := config.ReadFieldString(config.FieldFlagConfig)
  if cfgFile != "" {
    fmt.Printf("# Using config file: %s\n", cfgFile)
  } else {
    fmt.Printf("# No config file found, using default values\n")
  }
}
Enter fullscreen mode Exit fullscreen mode

The Config Commands

The configuration commands provide users with a powerful interface to interact with your application's settings. The beauty of this implementation is that these commands automatically work with any fields you define in your FieldCollection. The list command shows current values, describe provides detailed documentation, and set allows modification - all without hardcoding any specific field names. This generic approach means that adding a new configuration field automatically makes it available in all these commands without any additional code changes.

// cmd/config.go

var configCmd = &cobra.Command{
  Use:   "config",
  Short: "Configuration management commands",
  Example: `confapp config list
confapp config describe
confapp config set --log.level debug`,
}

var configListCmd = &cobra.Command{
  Use:               "list [prefix] ...",
  Short:             "List configuration values",
  RunE:              listConfig,
  ValidArgsFunction: generateFieldCompletions,
  Example: `confapp config list
confapp config list log
confapp config list proxy
confapp config list --hidden`,
}

var configSetCmd = &cobra.Command{
  Use:   "set",
  Short: "Set configuration values",
  Long:  `For more information about the configuration values, use the "describe" command.`,
  Args:  cobra.NoArgs,
  RunE:  setConfig,
  Example: `confapp config set --log.level info
confapp config set --log.level debug --log.output /var/log/app.log
confapp config set --update.auto true --update.period 1h
confapp config set --proxy.http http://proxy:8080`,
  ValidArgsFunction: generateSetCompletions,
}

var configDescribeCmd = &cobra.Command{
  Use:               "describe [prefix] ...",
  Short:             "Describe configuration parameters",
  RunE:              describeConfig,
  ValidArgsFunction: generateFieldCompletions,
  Example: `confapp config describe
confapp config describe log
confapp config describe update
confapp config describe --hidden`,
}
Enter fullscreen mode Exit fullscreen mode

Generating Completions

Shell completion is one of Cobra's most powerful features, dramatically improving the user experience by providing intelligent suggestions as users type. The completion system works through ValidArgsFunction callbacks that receive the current command state and return possible completions. The args parameter contains arguments already provided, while toComplete holds the partial text being typed. The function returns a list of completion suggestions and a directive that controls shell behavior (like whether to also suggest files).

Our implementation leverages the Field definitions to provide context-aware completions. When users type config list lo, the system suggests log as a completion. When setting values with valid options, like --log.level, the completion can even suggest the valid values (debug, info, warn, error) defined in the field. This deep integration between the configuration schema and the CLI ensures users always have helpful guidance when interacting with your application.

// cmd/config.go

func generateFieldCompletions(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
  var completions []string
  for key := range config.Fields.Map() {
    if len(toComplete) == 0 || (len(key) >= len(toComplete) && key[:len(toComplete)] == toComplete) {
      completions = append(completions, key)
    }
  }
  return completions, cobra.ShellCompDirectiveNoFileComp
}

func generateSetCompletions(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
  if len(args) == 0 {
    completions := make([]string, 0, len(config.Fields))
    for _, field := range config.Fields {
      if field.Hidden {
        continue
      }
      completions = append(completions, "--"+field.Name)
    }
    return completions, cobra.ShellCompDirectiveNoFileComp
  }
  return nil, cobra.ShellCompDirectiveNoFileComp
}

func generateValidValueCompletions(validValues []any, toComplete string) ([]string, cobra.ShellCompDirective) {
  completions := make([]string, 0, len(validValues))
  for _, value := range validValues {
    if strings.HasPrefix(value.(string), toComplete) {
      completions = append(completions, value.(string))
    }
  }
  return completions, cobra.ShellCompDirectiveNoFileComp
}
Enter fullscreen mode Exit fullscreen mode

Listing Configuration Values

The list command provides users with a clear view of their current configuration state. By calculating the maximum field name length, the output is neatly aligned, making it easy to scan through settings. The ability to filter by prefix allows users to focus on specific configuration groups, while the hidden flag reveals internal settings that are normally concealed. This command is essential for debugging and verifying that configuration values are being loaded correctly from all sources.

// cmd/config.go

func listConfig(cmd *cobra.Command, args []string) error {
  selectedFields := selectFieldsByPrefix(args)

  if len(selectedFields) == 0 {
    return fmt.Errorf("no configuration fields found")
  }

  // Calculate maximum field name length for alignment
  maxNameLen := calculateMaxFieldNameLength(selectedFields)

  // Display each field
  for _, field := range selectedFields {
    if field.Hidden && !FlagShowHidden {
      continue
    }
    fmt.Printf("%-*s = %v\n", maxNameLen+1, field.Name, config.ReadField(field))
  }

  return nil
}

func selectFieldsByPrefix(prefixes []string) config.FieldCollection {
  if len(prefixes) == 0 {
    return config.Fields
  }

  selectedFields := config.FieldCollection{}
  for _, prefix := range prefixes {
    for _, field := range config.Fields {
      if strings.HasPrefix(field.Name, prefix) {
        selectedFields = append(selectedFields, field)
      }
    }
  }
  return selectedFields
}

func calculateMaxFieldNameLength(fields config.FieldCollection) int {
  maxLen := 0
  for _, field := range fields {
    if len(field.Name) > maxLen {
      maxLen = len(field.Name)
    }
  }
  return maxLen
}
Enter fullscreen mode Exit fullscreen mode

Setting Configuration Values

The set command demonstrates the power of our Field-Driven approach by automatically creating flags for all configuration fields. When a user runs config set --log.level debug, Cobra parses the flag, our code validates it against the Field definition, and if valid, updates the value through Viper. The command then saves the configuration to disk, ensuring changes persist across application restarts. The validation happens before any values are saved, preventing invalid configurations from being written to disk and ensuring the application always works with valid settings.

// cmd/config.go

func setConfig(cmd *cobra.Command, args []string) error {
  // Collect and process all set flags
  fieldCount := 0
  for _, field := range config.Fields {
    if cmd.Flags().Changed(field.Name) {
      if err := processFieldFlag(cmd, field); err != nil {
        return err
      }
      fieldCount++
    }
  }

  if fieldCount == 0 {
    return cmd.Help()
  }

  // Save the configuration to file
  if err := config.Save(); err != nil {
    fmt.Printf("Failed to save configuration: %v\n", err)
    os.Exit(1)
  }

  return nil
}

func processFieldFlag(cmd *cobra.Command, field *config.Field) error {
  flag := cmd.Flags().Lookup(field.Name)
  if flag == nil {
    return fmt.Errorf("flag not found: %s", field.Name)
  }

  if FlagVerbose {
    fmt.Printf("# Setting: %s: %s\n", field.Name, flag.Value.String())
  }

  if err := config.WriteField(field, flag.Value.String()); err != nil {
    return fmt.Errorf("%s: %w", field.Name, err)
  }

  return nil
}
Enter fullscreen mode Exit fullscreen mode

Auto-Documentation: Describing Parameters

The describe command showcases how rich metadata in Field definitions enables automatic documentation generation. Each field's description, type, valid values, examples, and extended documentation are displayed in a structured format that helps users understand not just what a setting does, but how to use it effectively. The grouping feature organizes related fields together, making it easier to understand the relationships between different configuration options. This self-documenting nature ensures that documentation always stays in sync with the actual implementation.

// cmd/config.go

func describeConfig(cmd *cobra.Command, args []string) error {
  selectedFields := selectFieldsByPrefix(args)

  if len(selectedFields) == 0 {
    return fmt.Errorf("no configuration fields found")
  }

  // Use a writer to allow extending writing to a Pager
  w := os.Stdout

  // Display fields grouped by their group name
  for group, fields := range selectedFields.GroupIter() {
    fmt.Fprintf(w, "\n%s\n", group)

    for _, field := range fields {
      if field.Hidden && !FlagShowHidden {
        continue
      }
      writeFieldDescription(w, field)
    }
    fmt.Fprintf(w, "\n")
  }

  return nil
}

func writeFieldDescription(w interface{ Write([]byte) (int, error) }, field *config.Field) {
  // Field name and deprecation status
  if field.Deprecated != "" {
    fmt.Fprintf(w, "\n  %s (deprecated: %s)\n", field.Name, field.Deprecated)
  } else {
    fmt.Fprintf(w, "\n  %s\n", field.Name)
  }

  // Basic field information
  fmt.Fprintf(w, "    %s\n", field.Description)
  fmt.Fprintf(w, "    Type: %s\n", field.Type)

  // Current value (if different from default)
  val := config.ReadField(field)
  if val != nil && val != field.Default {
    fmt.Fprintf(w, "    Value: %v\n", val)
  }

  // Default value
  if field.Default != nil {
    fmt.Fprintf(w, "    Default: %v\n", field.Default)
  }

  // Valid values
  if field.ValidValues != nil {
    fmt.Fprintf(w, "    Valid values: %v\n", field.ValidValues)
  }

  // Validation rules
  if field.ValidateTag != "" {
    fmt.Fprintf(w, "    Validation: %s\n", field.ValidateTag)
  }

  // Example value
  if field.Example != "" {
    fmt.Fprintf(w, "    Example: %s\n", field.Example)
  }

  // Detailed documentation
  if field.Docstring != "" {
    fmt.Fprintf(w, "    Doc:\n")
    lines := strings.Split(field.Docstring, "\n")
    for _, line := range lines {
      fmt.Fprintf(w, "      %s\n", line)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Results

The Field-Driven Configuration approach delivers a powerful, user-friendly CLI that adapts automatically as your application evolves. Users benefit from intelligent completions, comprehensive documentation, and robust validation, while developers enjoy a maintainable system where adding new configuration options requires minimal code changes. The integration between Viper and Cobra through our Field abstraction creates a seamless experience where configuration can be managed through files, environment variables, or command-line flags with equal ease.

Building

The build process leverages Go's -ldflags feature to inject build-time values into the application. This allows you to customize constants like application name, version, or even default configuration values without modifying source code. This is particularly useful in CI/CD pipelines where different environments might need different defaults, or when building white-labeled versions of your application.

# Basic build
go build -o confapp

# Build with custom constants
go build -ldflags "-X github.com/lucasdecamargo/go-appconfig-example/internal/consts.AppName=myapp" -o myapp
Enter fullscreen mode Exit fullscreen mode

Usage

Shell completions transform the user experience by providing context-aware suggestions as users type. Once enabled, users can press Tab to see available options, making it easy to discover configuration fields without consulting documentation. The completion system understands the command structure and provides appropriate suggestions based on context, such as showing only valid values for fields with enumerated options.

# MacOS
source <(./confapp completion zsh)
# Linux
source <(./confapp completion bash)
Enter fullscreen mode Exit fullscreen mode
# List all configuration values
./confapp config list

# List specific configuration groups
./confapp config list log
./confapp config list proxy

# Describe configuration fields
./confapp config describe

# Describe specific fields
./confapp config describe log.level update

# Set configuration values
./confapp config set --log.level debug
./confapp config set --log.level info --log.output /var/log/app.log

# Show hidden fields
./confapp config list --hidden
./confapp config describe --hidden

# Verbose output
./confapp --verbose config list
Enter fullscreen mode Exit fullscreen mode

Checking the documentation with describe
Checking out the documentation with describe

Retrieving values with list
Retrieving values with list

Generating completions for the list command
Generating completions for the list command

Generating completions for the flags in set command
Generating completions for the flags in set command

Generating completions for the flag values in set command
Generating completions for the flag values in set command

Conclusion

The Field-Driven Configuration pattern presented in this article demonstrates how thoughtful abstraction can transform configuration management from a maintenance burden into a self-maintaining system. By defining configuration fields as first-class entities with rich metadata, we've created a solution that respects the Open-Closed Principle while providing exceptional developer and user experiences.

The integration of Viper, Cobra, and Go Validator through our Field abstraction eliminates the common pain points of configuration management: keeping documentation in sync, maintaining validation rules, and providing good CLI experiences. The result is a system where adding new configuration options is as simple as defining a new Field struct, and everything else - from CLI flags to validation to documentation - automatically adapts.

This approach scales elegantly from simple applications with a handful of settings to complex systems with hundreds of configuration parameters organized into logical groups. The automatic generation of completions and documentation ensures that as your application grows, it remains discoverable and user-friendly.

Extending the Solution

The architecture presented here provides a solid foundation that can be extended in several ways:

Configuration Profiles: Add support for multiple configuration profiles (development, staging, production) by extending the FieldCollection to support profile-specific overrides while maintaining the same validation and documentation capabilities.

Dynamic Reloading: Implement configuration hot-reloading using Viper's WatchConfig functionality, with the Field definitions providing the validation layer to ensure changes are safe before applying them.

API Integration: Generate OpenAPI specifications or GraphQL schemas from Field definitions, ensuring that API documentation stays synchronized with configuration capabilities.

Nested Structures: Extend the Field structure to support complex nested configurations while maintaining the same validation and documentation benefits.

For a complete implementation with additional features like configuration profiles, pager support for long documentation, and more sophisticated validation examples, check out my GitHub repository.

GitHub logo lucasdecamargo / go-appconfig-example

Guidelines for setting up configuration parameters in large Go applications with Viper and Cobra.

Go Application Configuration Template

A production-ready template for managing configuration parameters in Go applications using Viper and Cobra. This template demonstrates a clean, maintainable approach to configuration management with a single source of truth for all configuration metadata.

🎯 Key Features

  • Single Source of Truth: Configuration fields are defined once with all metadata (validation, documentation, defaults)
  • Type Safety: Strongly typed configuration with validation
  • CLI Integration: Seamless integration with Cobra for command-line interfaces
  • Multiple Formats: Support for YAML, JSON, TOML, HCL, and ENV files
  • Environment Variables: Automatic binding with configurable prefix
  • Shell Completion: Auto-completion for configuration field names and values
  • Documentation: Built-in help and documentation generation
  • Validation: Multiple validation strategies (tags, custom functions, valid values)

🏗️ Architecture Overview

Core Concept: Field-Driven Configuration

The central idea is to define configuration fields as structured data that contains everything needed to:

  • Validate values
  • Generate CLI…




The repository includes comprehensive examples and can serve as a starting template for your own production applications. Feel free to star the repository if you find it useful, and don't hesitate to open issues or contribute improvements!

Buy me a coffee

Buy Me A Coffee

Top comments (0)