Before Making It Configurable
Written at 2026-05-16Configurations exist to allow a program to behave differently without modifying its code. You have a program, you configure it, run it, and it behaves accordingly. In a way, they are like function inputs, but at the application level. They tend to reflect and affect how a system works under the hood. This also makes them closely related to the complexity of our applications.
Thinking this way, I cannot think of configurations as just simple inputs. That is why I wanted to think about this topic a bit more and write down some thoughts.
Two Kinds of Configurations
I think we can group configurations into two types: information-passing and behavior-changing. This distinction is useful because it helps us consider which configurations deserve more attention.
Information-passing Configurations
In my experience, information-passing configurations are not a big deal. This is because they mostly just pass values around. Whether you pass 5 or 10 of goroutines, or increase or decrease endpoint rate limits, it does not really change how the code is written.
To make this more concrete, consider the following example:
type Config struct {
Workers int
RateLimit int
}
func Process(cfg Config, jobs []Job) {
pool := NewWorkerPool(cfg.Workers)
client := NewAPIClient(cfg.RateLimit)
pool.Run(jobs)
client.Send()
}
Here, introducing this Config type does not really change how Process is written. It does not matter whether the values come from flags, environment variables, or a configuration file. If we replaced them with default values directly in the code, the overall structure would stay mostly the same.
Again, from a code perspective, these kinds of configurations are usually fine. That being said, simply having these values can signal complexity that already exists in the application. For example, we now know there are workers running under the hood and rate limits that need configuration.
Whether that is a good thing or not depends on the situation. Sometimes you may actually want to expose these details instead of hiding them, simply to make what is going on more visible. Compared to behavior-changing configurations, I don’t worry much about them.
Behavior-changing Configurations
Behavior-changing configurations change how the application behaves. They control things like which algorithm to use, whether features are enabled, and so on. I think these are the kinds of configurations we should be more careful about before adding them.
Unlike information-passing configurations, they signal the existence of different features being controlled. So, they hint the complexity of the application way better than information-passing configurations.
You implement a flag for every possible behavior, you may end up with code looking like this (don’t worry, you are not supposed to read all of it):
type Config struct {
UseConcurrentMode bool
UseFastAlgorithm bool
EnableCache bool
UseNewParser bool
}
func Process(cfg Config, input []byte) Result {
if cfg.UseNewParser {
input = parseV2(input)
if cfg.EnableCache {
if result, ok := cache.Get(input); ok {
return result
}
if cfg.UseConcurrentMode {
if cfg.UseFastAlgorithm {
return processV2ConcurrentFastWithCache(input)
}
return processV2ConcurrentSafeWithCache(input)
}
if cfg.UseFastAlgorithm {
return processV2SequentialFastWithCache(input)
}
return processV2SequentialSafeWithCache(input)
}
if cfg.UseConcurrentMode {
if cfg.UseFastAlgorithm {
return processV2ConcurrentFast(input)
}
return processV2ConcurrentSafe(input)
}
if cfg.UseFastAlgorithm {
return processV2SequentialFast(input)
}
return processV2SequentialSafe(input)
}
input = parseV1(input)
if cfg.EnableCache {
if result, ok := cache.Get(input); ok {
return result
}
if cfg.UseConcurrentMode {
if cfg.UseFastAlgorithm {
return processV1ConcurrentFastWithCache(input)
}
return processV1ConcurrentSafeWithCache(input)
}
if cfg.UseFastAlgorithm {
return processV1SequentialFastWithCache(input)
}
return processV1SequentialSafeWithCache(input)
}
if cfg.UseConcurrentMode {
if cfg.UseFastAlgorithm {
return processV1ConcurrentFast(input)
}
return processV1ConcurrentSafe(input)
}
if cfg.UseFastAlgorithm {
return processV1SequentialFast(input)
}
return processV1SequentialSafe(input)
}
Actually, the nested if-else conditions that you see are a good example of combinatorial explosion. Each new configuration option multiplies the number of states your application can be in.
Of course, the previous code was a bit of an exaggeration. We could have rewritten the same thing like this:
func Process(cfg Config, input []byte) Result {
if cfg.UseNewParser {
input = parseV2(input)
} else {
input = parseV1(input)
}
if cfg.EnableCache {
if result, ok := cache.Get(input); ok {
return result
}
}
if cfg.UseConcurrentMode {
return processConcurrently(input, cfg.UseFastAlgorithm)
}
if cfg.UseFastAlgorithm {
return processFast(input)
}
return processSafely(input)
}
Here, we pass the relevant configurations into helper functions. Each helper function handles relevant configs inside. This makes it look better. But you still have code with many possible execution paths, which also means many possible interactions and edge cases to think about.
Even if you use design patterns like the Strategy pattern or other techniques, the tradeoff is still there. You are often making the system more complex in exchange for making it more configurable.
So, What’s The Takeaway?
Information-passing configurations are usually fine. They mostly signal complexity that already exists rather than introducing much new complexity on their own. So, I personally would not worry too much about them in terms of adding additional complexity.
Behavior-changing configurations deserve more attention. They often create new execution paths and edge cases. In a way, each of them represents a feature. This means that they can significantly increase the complexity of your application.
I find it helpful to remember that making behavior configurable can make a system much uglier than expected. Before adding a new behavior-changing configuration, I think it is worth asking: Is this solving a real requirement? Am I adding another branch that future me will have to deal with? Was this added because the application truly needed it? Or was it added simply because “it might be useful to make this configurable”?
