Refactoring Toward Configurability
RyTheTurtle
Posted on February 26, 2024
Designing software behavior to be configurable is an incredibly powerful tool for making software easily readable and maintainable. Configurable software behavior reduces the cost of modifying and extending application behaviors and makes it easier for engineers and non-engineers alike to read and understand application logic. This article explores the differences between application capability and behavior, outlines general steps to refactoring toward configurable behavior, and demonstrates an example of writing configurable application logic for a Mechanic Shop.
Refactoring Towards Configurability
When designing application software, we must consider three aspects of the software's implementation:
- Capability What the software is capable of doing
- Behavior How the software behaves at runtime. Behavior can be thought of as what capabilities are executed under a particular set of circumstances.
- Context The situational information and values that drive the software behavior.
While software capabilities need to be coded in to the implementation, software behavior can be refactored to configuration. Compared to implementing software behavior in code, making software behavior configurable offers important advantages for readability and maintainability:
- Configuration is concise Relative to the equivalent code, configuration can eliminate hundreds of lines of code and dozens of logical branches.
- Configuration is readable Reading configuration typically does not require someone to be familiar with reading code. Configuration is usually organized in to a relatively small set of files, which is easier to traverse than sprawling directories of code packages.
To design (or refactor) software behavior to be configurable, the high level process is:
- Identify functionality where the behavioral requirements are driven by contextual information.
- Decide on a technology to use for implementing your configuration, along with a configuration schema. An appropriate schema will depend on the technology and means for reading the configuration.
- Implement application logic, capable of receiving contextual values, looking up the appropriate behavior, and executing the behavior.
Example: Mechanic Shop
We are writing code for a mechanics shop that needs to implement different maintenance work for vehicles. When a customer brings their vehicle to the shop, the application needs to tell the mechanics what maintenance work needs to be done, the parts required for the maintenance work and charge the customer for the work.
We could certainly model all of this software exclusively in code, neatly organizing our code in well-known design patterns and thoroughly unit testing the code (which we should still do!). However, even limiting our mechanic shop to a handful of vehicle makes and models, this would quickly balloon in to many classes and hundreds of lines of code. For example, here's a simple UML Diagram implementing three types of maintenance work items for a single vehicle using a visitor pattern.
Instead, we'll design our software behavior to be configurable to minimize changes as the mechanic shop expands to working on more types of vehicles.
Step 1: Identify Functional Behavior Driven by Context
While each individual maintenance job is a core capability of the software (such as being able to change oil), the specific maintenance to perform on the vehicle is driven by the vehicle's
make
,model
,year
, andodometer
mileage. For example, a 2023 Honda CR-V with 10,000 miles will need an oil change and tire rotation.The parts required for any maintenance job is dependent on what the job is, and what
make
,model
, andyear
the vehicle is. A Ferrari will need different oil for an oil change than a Honda.The cost for each maintenance job performed on a vehicle will be determined from some base price and the cost of all the required parts for the job.
Step 2: Pick A Technology and Schema for Configuration
For this example, we will use JSON
files to encode the application's behavior and query it using JSONPath.
There's more than one way to define a configuration scheme that would be suitable for encoding the configuration. Here's an example where we use a string concatenation of <year> <make> <model>
as the key to look up the maintenance schedule, required parts, and part costs for a particular vehicle.
// config.json
{
"schedule":{
"2023 Honda CR-V": {
"7500":["oil_change","tire_rotation"],
"15000":["oil_change","tire_rotation","air_filter_replacement"]
}
},
"tasks":{
"oil_change":{
"2023 Honda CR-V": {
"parts":["synthetic_oil", "oil filter"],
"base_price": 20.0
}
},
"tire_rotation": {
"2023 Honda CR-V": {
"parts": [],
"base_price": 0
}
},
"air_filter_replacement": {
"2023 Honda CR-V": {
"parts": ["honda_crv_air_filter"],
"base_price": 10
}
}
},
"part_cost": {
"honda_crv_air_filter": 35,
"synthetic_oil": 50,
"oil_filter": 10
}
}
Step 3: Implement Application Logic
We now can implement our application logic as a simple shell where the specific behavior of the application (what maintenance jobs to perform, the associated costs) is dynamically determined from our configuration based on the context of what vehicle is being worked on.
class AppConfig {
Object read(String query){
//...
}
AppConfig(String source){
//load config to memory...
}
}
// MaintenanceShop.java
public class MaintenanceShop {
private static AppConfig config = new AppConfig("config.json");
public static void main(String[] args) {
final var year = args[0];
final var make = args[1];
final var model = args[2];
final var odometer = args[3];
final String scheduleQuery = String.format(
"$.schedule.\"%s %s %s\".%s",
year, make, model, odometer);
List<MaintenanceJob> jobs = config.read(scheduleQuery);
List<Part> requiredParts = new ArrayList<>();
for(MaintenanceJob job: jobs){
final String partsQuery = String.format("$.tasks.%s.\"%s %s %s\".parts",
job.title, year,make,model);
requiredParts.addAll(config.read(partsQuery));
}
performMaintenance(jobs);
computeTotalCharge(jobs, requiredParts);
}
}
The Power of Configuration
Having designed our code as a thin shell of capabilities who's behavior is driven by configuration and contextual information, we now have software that is simple to read and modify.
If asked to figure out what Honda vehicles the maintenance shop services, we need only look at a single file. No need to dig through source code or make sense of class hierarchies to find the information.
Adapting to new requirements is also far less work than dealing with complex class hierarchies. Let's imagine that our mechanic shop is very popular and we need to support many different cars. If we had implemented our application behavior directly in the code, the changes for adding a handful of new makes and models of vehicles would involve creating a dozen new classes, modifying supporting classes, and writing additional unit tests to make sure we've covered all the correct behavior in code.
With our configuration, extending our application to support new cars, service types, and parts can be done in a single file with only a few lines of configuration. Configurable application behavior makes the software readable and less expensive to maintain and evolve over time.
Posted on February 26, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.