How to maintain YAML configurations without duplication

Goal

Create and maintain YAML configuration files that share a common base while avoiding duplication of configuration code.

This guide presents three options to define a base YAML configuration file and extend it to create case-specific variants, such as development, production, or testing. Each option is illustrated with an example that starts from a base MATLAB simulation configuration and extends it into a high-precision configuration, inheriting from the base while overriding specific parameters.

Prerequisites:

  • MATLAB R2019b or later.

  • yaml package v1.6.0, installed and added to the path.

  • Intermediate familiarity with YAML syntax.

Options summary:

Option

Scalability

Use this is you want…

YAML merge keys

Low

to keep everything in a single config file

Implicit composition

Medium

to have separate config files

Explicit composition

High

to have complex inheritance chains between config files

YAML merge keys

How to implement

  1. Create a single YAML file to store all configuration cases.

  2. Define the shared base configuration in that file.

  3. Use YAML anchors and merge keys to derive the remaining configurations in that file.

  4. Load the file with yaml.loadFile().

  5. Select the required configuration from the loaded data.

Example

Create a single YAML, config.yaml, to store all the configuration cases. Define the shared configuration in that file in the default field. Use YAML anchors (&default and &solver_options) and merge keys (<<: *default and <<: *solver_options) to derive base and high-precicion configurations in base and high_precision fields.

config.yaml
 1default: &default
 2  odefun: exponential_decay
 3  tspan: [0, 10]
 4  y0: 1
 5  solver: ode45
 6  solver_options: &solver_options
 7    RelTol: 1.0e-3
 8    AbsTol: 1.0e-6
 9    MaxStep: 0.1
10    InitialStep: 0.01
11
12base:
13  <<: *default
14
15high_precision:
16  <<: *default
17  solver_options:
18    <<: *solver_options
19    RelTol: 1.0e-6
20    AbsTol: 1.0e-9

Load the config.yaml with yaml.loadFile() and select the required configuration from the loaded data:

config = yaml.loadFile("config.yaml", "ConvertToArray", true);

% Load base configuration.
cfg = config.base;

% Load high-precision configuration.
cfg = config.high_precision;

Implicit composition

How to implement

  1. Create a YAML file that defines the base configuration.

  2. Put all shared configuration values in that file.

  3. Create a separate YAML file for each configuration case.

  4. In each case-specific file, define only the values that differ from the base.

  5. Select an external tool or implement a custom function to load the base configuration and apply the case-specific overrides.

  6. Load the required configuration using that tool or function.

Example

Create a YAML file, base.yaml, with the base configuration values.

base.yaml
1odefun: exponential_decay
2tspan: [0, 10]
3y0: 1
4solver: ode45
5solver_options:
6  RelTol: 1.0e-3
7  AbsTol: 1.0e-6
8  MaxStep: 0.1
9  InitialStep: 0.01

Create a separate YAML file, high_precision.yaml, defining only the values that differ from the base.yaml:

high_precision.yaml
1solver_options:
2  RelTol: 1.0e-6
3  AbsTol: 1.0e-9

Implement the following custom funtion, mergeStructs(), to override the base configuration with the high-precision configuration.

mergeStructs.m
 1function [result] = mergeStructs(override, base)
 2%MERGESTRUCTS Recursively merge two structures.
 3%
 4%   RESULT = MERGESTRUCTS(OVERRIDE, BASE) returns a structure where fields
 5%   in OVERRIDE replace those in BASE and nested structs are also merged
 6%   recursively.
 7
 8    result = base;
 9
10    for fn = fieldnames(override)'
11        field = fn{1};
12
13        if isfield(result, field) && isstruct(result.(field)) ...
14            && isstruct(override.(field))
15            % Recursively merge nested structs.
16            result.(field) = mergeStructs(override.(field), result.(field));
17        else
18            % Override or add new field.
19            result.(field) = override.(field);
20        end
21    end
22end

Load the required configuration using yaml.loadFile() and mergeStructs():

% For loading base configuration.
cfg = yaml.loadFile("base.yaml", "ConvertToArray", true);

% For loading high-precision configuration.
base = yaml.loadFile("base.yaml", "ConvertToArray", true);
high_precision = yaml.loadFile("high_precision.yaml", "ConvertToArray", true);
cfg = mergeStructs(high_precision, base);

Explicit composition

How to implement

  1. Create a separate YAML file for each base configuration.

  2. Define all values for each base configuration case in its file.

  3. Create a separate YAML file for each extended configuration.

  4. In each extended configuration file, explicitly specify which base configuration it extends and define only the values that differ from that base.

  5. Implement a custom function to load configurations, taking extensions into account.

  6. Load the required configuration using this custom function.

Example

Create a YAML file, base.yaml, and define the base configuration:

base.yaml
1odefun: exponential_decay
2tspan: [0, 10]
3y0: 1
4solver: ode45
5solver_options:
6  RelTol: 1.0e-3
7  AbsTol: 1.0e-6
8  MaxStep: 0.1
9  InitialStep: 0.01

Create a YAML file, high_precision.yaml, define it is a extension of base.yaml writing extends: base.yaml and define the only the values that differ from base.yaml.

high_precision.yaml
1extends: base.yaml
2solver_options:
3  RelTol: 1.0e-6
4  AbsTol: 1.0e-9

Implement the custom function, loadSimulationConfig(), to load a configuration file taking extensions into account.

loadSimulationConfig.m
 1function [config] = loadSimulationConfig(filename)
 2%LOADSIMULATIONCONFIG Load a simulation configuration from a YAML file.
 3%
 4%   CONFIG = LOADSIMULATIONCONFIG(FILENAME) loads the YAML configuration
 5%   stored in FILENAME. If the configuration contains an 'extends' field,
 6%   the parent configuration is loaded and merged recursively.
 7
 8    % Load the configuration from YAML file.
 9    config = yaml.loadFile(filename, "ConvertToArray", true);
10
11    % Merge with parent config if 'extends' is specified
12    if isfield(config, "extends")
13        parentConfig = loadSimulationConfig(config.extends);
14        config = rmfield(config, "extends");
15        config = mergeStructs(config, parentConfig);
16    end
17end

Load the required configuration file with loadSimulationConfig():

% Load base configuration.
cfg = loadSimulationConfig("base.yaml");

% Load high-precision configuration.
cfg = loadSimulationConfig("high_precision.yaml");

Appendix

Here is the ODE function definition:

exponential_decay.m
1function dydt = exponential_decay(t, y)
2%EXPONENTIAL_DECAY Simple first-order ODE.
3%
4%   dydt = EXPONENTIAL_DECAY(t, y) returns the time derivative of y
5%   for the equation: dy/dt = -k*y, where k is the decay rate.
6
7    k = 0.5;
8    dydt = -k*y;
9end

Here is how to run a simulation with the loaded configuration:

% Use the selected configuration for the simulator.
solver = str2func(cfg.solver);
odefun = str2func(cfg.odefun);
[t, y] = solver(odefun, cfg.tspan, cfg.y0, cfg.solver_options);