Combining Configurations

A feature of Configurature is that you can combine configurations from multiple packages of your application into a single configuration and initialize it from a single location (e.g. main()). Given the following project layout

├── cmd
│   └── main.go
├── db
│   └── db.go
├── log
│   └── log.go
└── server
    ├── ipc
    │   └── ipc.go
    ├── rpc
    │   └── rpc.go
    └── server.go

Typically each of these packages has its own configuration requirements. The server package is in charge of passing the ipcSocketFile to the ipc package instantiation and the rpcTimeout to the rpc package. The main package needs to pass config to server. They can be passed around as distinct function arguments, which may be some variation of:

s := server.New(listenIp, listenPort, ipcSocketFile, rpcTimeout)

This can become unwieldy as the number of configuration options grows. It also requires that packages be aware of implementation details of other packages. If the ipc package is changed to require a ipcTimeout configuration option, then the server package must be updated to pass this new configuration option.

Configurature provides a way to avoid this scenario. So that adding a configuration option to the ipc package is as easy as adding a single field to its configuration struct. Neither the server or main packages need to be made aware of the new configuration or any change in configuration at all.

Nested Config

You can nest configurations in configuration structs so that they are distinct fields in a parent struct.

// main.go
type Config struct {
    ConfigFile co.ConfigFile `help:"configuration file" short:"c"`
    Logging    log.Config
    Server     server.Config
}
// server.go
type Config struct {
    ListenIP   net.IP `help:"IP address on which to listen" default:"127.0.0.1"`
    ListenPort uint   `help:"port on which to listen" default:"8080"`
    IPC        ipc.Config
    RPC        rpc.Config
}

Now server and log are initialized in main() with:

conf := co.Configure[Config](&co.Options{
    EnvPrefix: "APP_",
    Args:      os.Args[1:],
})

log.SetupLogging(&conf.Logging)
s := server.New(&conf.Server)
s.Run()

ipc and rpc can be similarly initialized from the server package without needing to be aware of their configuration details. Changes in configuration do not result in changes to function or method calls.

Nested configuration does result in nested value specification. E.g. SocketFile in the ipc configuration becomes a --server_ipc_socket_file flag, APP_SERVER_IPC_SOCKET_FILE environment variable, and in a configuration file:

# conf.yaml
server:
  ipc:
    socket_file: /tmp/server-ipc.sock

You can name sub configs and even give them empty names.

// main.go
type Config struct {
    ConfigFile co.ConfigFile `help:"configuration file" short:"c"`
    Logging    log.Config
    Server     server.Config `name:""`
}
// server.go
type Config struct {
    ListenIP   net.IP     `help:"IP address on which to listen" default:"127.0.0.1"`
    ListenPort uint       `help:"port on which to listen" default:"8080"`
    IPC        ipc.Config `name:"other"`
    RPC        rpc.Config
}

ListenIP is now specified using --listen_ip instead of --server_listen_ip because its name is empty. IPC configuration is prefixed with other_. E.g. --other_socket_file from the command line. Naming applies to environment variables and config file structure as well.

Flat Config

You can also include other config structs as anonymous fields.

// main.go
type Config struct {
    ConfigFile co.ConfigFile `help:"configuration file" short:"c"`
    log.LogConfig
    server.ServerConfig
}
// server.go
type ServerConfig struct {
    ListenIP   net.IP `help:"IP address on which to listen" default:"127.0.0.1"`
    ListenPort uint   `help:"port on which to listen" default:"8080"`
    ipc.IPCConfig
    rpc.RPCConfig
}

Now server and log are initialized in main() with:

conf := co.Configure[Config](&co.Options{
    EnvPrefix: "APP_",
    Args:      os.Args[1:],
})

log.SetupLogging(&conf.LogConfig)
s := server.New(&conf.ServerConfig)
s.Run()

A downside of this is that the names config structs from each package must be unique and there can not be duplicate field names between structs. This is usually fine for small projects. A hybrid approach can also be used where server is a flat config including ipc and rpc configs anonymously and mains config contains concrete fields that hold configurations for other packages.

Configuration structs included anonymously result in flat value specification. E.g. SocketFile in the ipc configuration becomes a --socket_file flag, APP_SOCKET_FILE environment variable. You may want to rename the field name in this case to IpcSocketFile or just let the field’s help provide context for what “socket file” refers to.

Mixed

You are free to mix and match in a way that makes sense for your project. Configure() will panic() if there are duplicate field names or short flag names instead of quietly resulting in unintended configuration.

Using Get

You can also use configurature.Get[T]() from anywhere in your app as long as Configure[T]() has previously been called.

// main.go
type Config struct {
    BuriedComponentConfig bc.Config
    StoreConfig           store.Config
}

func main() {
    conf := co.Configure[Config](&co.Options{
        EnvPrefix: "APP_",
        Args:      os.Args[1:],
    })
    // ...
}

Then anywhere else in our code, you can call Get[T]() where T is the type of config you want to retrieve from the top-level configuration.

// buried_component.go

type Config struct {
    MyInt int    `help:"integer config item"`
    MyStr string `help:"string config item"`
}

// buried_component needs its Config struct
func New() {
    if conf, err := co.Get[Config](); err != nil {
        // handle err
    } else {
        // Initialize a new BuriedComponent with conf
    }
}

// components need store Config to initialize their own stores
func doSomethingWithNewStore() (err error) {
    sConf, err := co.Get[store.Config]()
    if err != nil {
        return fmt.Errorf("error getting store config: %w", err)
    }

    store := store.New(sConf)

    // do something with store
}