I’ll use Go to demonstrate and discuss this idea, but the concept is general and applies to applications written in any programming language. The problems this idea is meant to address are: code that is difficult to test, reuse, read, and maintain. In addition, the approach aims to empower the developer to be more dynamic: to quickly create new, related applications, in a way that feels easy and modular. In particular, it is aimed at moving away from monolithic applications, highly interdependent packages, and unwieldy configurations.

The basic idea is to trade out application-specific code by growing library code. This is an architectural point. There are many steps to this, and I won’t cover all of them. Fewer monoliths are being developed or maintained these days anyway, but elements that tend toward monoliths are still fairly common in logging and configuration, so I will focus on those specifically. Here’s a brief description of how to resolve each, followed by an example.

Logging

The solution: In most of your packages (potentially all but main), make the functions return errors rather than log errors. This removes the need for some global log instance variable (*log.Logger). Without that dependency your code is easier to test and reuse. And once the tether to some application-specific log or application-specific way of handling errors is gone, function reuse is more practical and approachable.

Configuration

The solution: Instead of accessing application-wide configuration in your package, set package-specific configuration values on package types. Convert the package functions that need these values into methods on those types. Some developers expect this to be hard, and then are surprised to discover how easy it is. This will become clearer in the example below.

Example

Let’s say your application has some global configuration struct. Most packages end up needing access to values in this config struct: to be able to log errors at a minimum, but often for behavioral settings and instance/environment/database variables as well.

Let’s consider an example that uses three values from the global config: Logger, ItemsFile and UseFileArgs. Logger is a log.Logger instance for error, warnings, and informational logging. ItemsFile is the path to a data file the application processes. UseFileArgs is a boolean value that represents a decision whether to pass parameters to another application through arguments on the command line or in a file.

In the old system, you had a configuration monolith somewhere, perhaps in a configuration package or in main:

type Config struct {
    Logger          *log.Logger
    // ...
    // long list of other settings
    // ...
    ItemsFile       string
    // ...
    UseFileArgs     bool
    // ...
}

Your config is long and not that easy to read and understand, as you have many variables and they aren’t necessarily related to each other or organized. Even the variable names may not make sense without a more particular context, and you may also run into naming conflicts where your ideal name has already been used.

Let’s say ItemsFile is used in the item package, UseFileArgs is needed in the archive package, and Logger is used in both. Somehow you hand off this global config struct to your package. Maybe it’s a package variable in a config package that is exported to other packages, e.g. config.Cfg here:

package item

import(
    "config"
)

type Item struct {
    // ...
}

func Read(itemsFile string) ([]Item) {
    // ...
    if err != nil {
        config.Cfg.Logger.Printf("Failed to read file %s: %s", itemsFile, err)
    }
    // ...
}

func Process() {
    items := Read(config.Cfg.ItemsFile)
    // ...
}

It’s a little more grunt work, but you could create an exported package variable for each member of the config struct, so you can access each with one less dot connector:

func Read(itemsFile string) ([]Item) {
    // ...
    if err != nil {
        config.Logger.Printf("Failed to read file %s: %s", itemsFile, err)
    }
    // ...
}

func Process() {
    items := Read(config.ItemsFile)
    // ...
}

And of course there are other ways you might try to pass or inject the global configuration struct into the item package, such as passing it to functions as an argument, calling setters, or copying it or the relevant variables to an item package variable struct. Whatever you chose, you’d do the same thing over in the archive package:

package archive

import(
    "config"
)

type Thing struct {
    // ...
}

func Execute(data Thing) {
    // prepare arguments
    var err error
    if config.Cfg.UseFileArgs {
        // ..
    } else {
        // ..
    }
    if err != nil {
        config.Cfg.Logger.Printf("Error executing archive commands: %s", err)
    }
}

This is a little tedious, but the bigger problem is that it leads to a design that discourages testing, reuse, and maintenance, and makes the job of learning the code more challenging for a newcomer – compared to the alternative we will look at next:

package main

import(
    "archive"
    "item"
)

func main() {
    // do something to get configuration values:
    // itemsFile, useFileArgs
    p := item.NewProcessor()
    p.ItemsFile = itemsFile
    err := p.Process()
    if err != nil {
        // log or print or whatever is appropriate...
    }
    arch := archive.NewArchiver()
    arch.UseFileArgs = useFileArgs
    err = arch.Execute()
    if err != nil {
        // handle
    }
}

The use of an item.Processor and an archive.Archiver obviate the need for config to be imported in the item and archive packages. And the settings in your main have a clear context: itemsFile relates to the item package and is set on the item.Processor. useFileArgs relates to the archive package and is set on the archive.Archiver. The Execute function in the archive package is now a method on Archiver, and would look more like this:

func (a *Archiver) Execute(data Things) (err error) {
    // prepare arguments...
    if a.UseFileArgs {
        // ..
    } else {
        // ..
    }
    return err
}

The archive package is no longer dependent on a configuration package. It does not use global variables. You can write a test for it without worrying about how you’re going to mock the config or create a test config. You can import it in a different application that doesn’t use the same configuration. And you don’t need a log instance for a test or another application either.

Aside

Incidentally, you could pass these configuration variables as parameters to the constructors if you prefer:

    p := item.NewProcessor(itemsFile)
    err := p.Process()
    // ...
    arch := archive.NewArchiver(useFileArgs)
    err = arch.Execute()

But the issue with that in Go is that arguments are not optional when calling Go functions.

Alternatively you could use functional options if that is appropriate to your application’s use cases.

Conclusion

What are the benefits? Well it tends to make your code more testable and reusable. It encourages a design that is easier to learn, grow and maintain. Perhaps more significantly, it empowers you to quickly create several small applications that meet varied needs or run in different environments.

monolith <-------------- your application --------------> components

    ==> force of trading application code for library code ==>

Once you have useful libraries, creating a new application is much less overwhelming if you can leverage those libraries to complete some portion of it. If you picture a spectrum with the monolith on one end and components or smaller applications on the other, it moves you away from the monolith.