# WebConfig Configuration System Architecture ## Overview This document describes the architecture for a JSON-based configuration system for Astralis. The system reads a `web-config.json` file and makes configuration values available through the Dependency Injection container. The system supports **configuration layering**, allowing multiple configuration files to be merged with later files overriding values from earlier files. This enables environment-specific configurations without duplicating common settings. ## Design Goals 1. **DI Integration**: Configuration should be injectable like other dependencies 2. **Flexible Discovery**: Support both explicit path via environment variable and default location 3. **Type-Safe Access**: Provide typed getters with default values 4. **Minimal Dependencies**: Leverage existing json-glib-1.0 dependency 5. **Consistent Patterns**: Follow existing Astralis conventions 6. **Configuration Layering**: Support merging multiple config files with override semantics ## Architecture Overview ```mermaid flowchart TB subgraph Discovery[Configuration Layering] A[Check ASTRALIS_CONFIG_PATH] -->|Path set| B[Parse semicolon-delimited paths] A -->|Not set| C[Check CWD for web-config.json] C -->|Found| D[Load base config from CWD] C -->|Not found| E[Use empty config] B --> F[Load base: web-config.json] F --> G[Merge override 1] G --> H[Merge override 2] H --> I[... Merge override N] I --> J[Final merged WebConfig] D --> J E --> K[Empty WebConfig] J --> L[Register in DI Container] K --> L end subgraph DI[Dependency Injection] M[WebApplication] -->|register_singleton| N[Container] L --> N N -->|inject| O[Endpoint/Component] end Discovery --> DI ``` ### Configuration Layering Flow The layering system processes configurations in a specific order: 1. **Base Configuration**: Always load `web-config.json` from CWD as the foundation 2. **Override Configurations**: Parse `ASTRALIS_CONFIG_PATH` for semicolon-delimited override paths 3. **Sequential Merging**: Apply each override file in order, with later files taking precedence 4. **Result**: Single merged `WebConfig` instance registered in the DI container ## Component Design ### Class Diagram ```mermaid classDiagram class WebConfig { -Json.Object root_object -bool loaded -string[] loaded_files +WebConfig.from_json_object(Json.Object obj, string[] sources) +WebConfig.empty() +get_string(string key, string default) string +get_int(string key, int default) int +get_bool(string key, bool default) bool +get_string_array(string key, string[] default) string[] +has_key(string key) bool +get_section(string key) WebConfigSection? +is_loaded() bool +get_loaded_files() string[] } class WebConfigSection { -Json.Object section_object +get_string(string key, string default) string +get_int(string key, int default) int +get_bool(string key, bool default) bool +get_string_array(string key, string[] default) string[] +has_key(string key) bool +get_section(string key) WebConfigSection? } class WebConfigLoader { +const DEFAULT_CONFIG_FILENAME string +const ENV_CONFIG_PATH string +const PATH_SEPARATOR string +static load() WebConfig +static discover_config_path() string? +static parse_override_paths(string env_value) string[] } class ConfigMerger { +static merge_objects(Json.Object base, Json.Object override) Json.Object +static deep_merge(Json.Object base, Json.Object override) Json.Object -static merge_value(Json.Node base_node, Json.Node override_node) Json.Node } WebConfig --> WebConfigSection : creates WebConfigLoader --> WebConfig : creates WebConfigLoader --> ConfigMerger : uses ConfigMerger --> WebConfig : produces merged config ``` ### File Structure ``` src/ ├── Core/ │ ├── WebConfig.vala # Public: Configuration access class │ ├── WebConfigSection.vala # Public: Nested section access │ ├── WebConfigLoader.vala # Public: File discovery and loading with layering │ └── ConfigMerger.vala # Internal: Deep merge logic for config objects └── meson.build # Modified: add new files ``` ## Merge Semantics The configuration layering system uses a **deep merge** strategy for combining multiple configuration files. Understanding these semantics is crucial for predictable override behavior. ### Scalar Values (Strings, Numbers, Booleans, Null) Scalar values are **replaced entirely** by the override value. ``` Base: { "port": 8080, "host": "localhost" } Override: { "port": 3000 } Result: { "port": 3000, "host": "localhost" } ``` ### Arrays Arrays are **replaced entirely** by the override array (not concatenated). ``` Base: { "allowed_hosts": ["localhost", "127.0.0.1"] } Override: { "allowed_hosts": ["example.com"] } Result: { "allowed_hosts": ["example.com"] } ``` **Rationale**: Array replacement provides predictable behavior. Concatenation could lead to unintended accumulation of values across environments. If array extension is needed, specify the complete array in the override. ### Nested Objects (Deep Merge) Nested objects are **merged recursively**, applying the same rules at each level. ``` Base: { "database": { "host": "localhost", "port": 5432, "pool_size": 10 } } Override: { "database": { "host": "prod-db.example.com", "pool_size": 20 } } Result: { "database": { "host": "prod-db.example.com", "port": 5432, "pool_size": 20 } } ``` ### Null Values Setting a value to `null` in an override **removes the key** from the merged result. ``` Base: { "debug": true, "feature_x": "enabled" } Override: { "debug": null } Result: { "feature_x": "enabled" } // debug key removed ``` ### Missing Keys Keys present in base but absent in override are **preserved**. ``` Base: { "a": 1, "b": 2, "c": 3 } Override: { "b": 20 } Result: { "a": 1, "b": 20, "c": 3 } ``` ### Merge Algorithm Pseudocode ``` function deep_merge(base_obj, override_obj): result = copy(base_obj) for each key, value in override_obj: if value is null: remove key from result else if key not in result: result[key] = copy(value) else if both result[key] and value are objects: result[key] = deep_merge(result[key], value) else: result[key] = copy(value) // Replace scalars and arrays return result ``` ## Detailed Component Specifications ### 1. WebConfig - Core/WebConfig.vala The main configuration class that holds parsed JSON values. Tracks source files for debugging. ```vala using Json; namespace Astralis { public errordomain WebConfigError { FILE_NOT_FOUND, PARSE_ERROR, INVALID_JSON } public class WebConfig : Object { private Json.Object? root_object; private bool loaded; private string[] loaded_files; internal WebConfig.from_json_object(Json.Object? obj, string[] sources = {}) { this.root_object = obj; this.loaded = obj != null; this.loaded_files = sources; } public WebConfig.empty() { this.root_object = null; this.loaded = false; this.loaded_files = {}; } // String access public string get_string(string key, string default = "") { if (root_object == null || !root_object.has_member(key)) { return default; } var node = root_object.get_member(key); if (node.get_node_type() != NodeType.VALUE) { return default; } return node.get_string() ?? default; } // Integer access public int get_int(string key, int default = 0) { if (root_object == null || !root_object.has_member(key)) { return default; } var node = root_object.get_member(key); if (node.get_node_type() != NodeType.VALUE) { return default; } return (int) node.get_int(); } // Boolean access public bool get_bool(string key, bool default = false) { if (root_object == null || !root_object.has_member(key)) { return default; } var node = root_object.get_member(key); if (node.get_node_type() != NodeType.VALUE) { return default; } return node.get_boolean(); } // String array access public string[] get_string_array(string key, string[] default = {}) { if (root_object == null || !root_object.has_member(key)) { return default; } var node = root_object.get_member(key); if (node.get_node_type() != NodeType.ARRAY) { return default; } var array = node.get_array(); var result = new string[array.get_length()]; for (uint i = 0; i < array.get_length(); i++) { result[i] = array.get_string_element(i) ?? ""; } return result; } // Check if key exists public bool has_key(string key) { return root_object != null && root_object.has_member(key); } // Get nested section public WebConfigSection? get_section(string key) { if (root_object == null || !root_object.has_member(key)) { return null; } var node = root_object.get_member(key); if (node.get_node_type() != NodeType.OBJECT) { return null; } return new WebConfigSection(node.get_object()); } // Check if config was loaded from file public bool is_loaded() { return loaded; } // Get list of files that contributed to this config (for debugging) public string[] get_loaded_files() { return loaded_files; } } } ``` ### 2. WebConfigSection - Core/WebConfigSection.vala Provides access to nested configuration sections. ```vala using Json; namespace Astralis { public class WebConfigSection : Object { private Json.Object section_object; internal WebConfigSection(Json.Object obj) { this.section_object = obj; } public string get_string(string key, string default = "") { if (!section_object.has_member(key)) { return default; } var node = section_object.get_member(key); if (node.get_node_type() != NodeType.VALUE) { return default; } return node.get_string() ?? default; } public int get_int(string key, int default = 0) { if (!section_object.has_member(key)) { return default; } var node = section_object.get_member(key); if (node.get_node_type() != NodeType.VALUE) { return default; } return (int) node.get_int(); } public bool get_bool(string key, bool default = false) { if (!section_object.has_member(key)) { return default; } var node = section_object.get_member(key); if (node.get_node_type() != NodeType.VALUE) { return default; } return node.get_boolean(); } public string[] get_string_array(string key, string[] default = {}) { if (!section_object.has_member(key)) { return default; } var node = section_object.get_member(key); if (node.get_node_type() != NodeType.ARRAY) { return default; } var array = node.get_array(); var result = new string[array.get_length()]; for (uint i = 0; i < array.get_length(); i++) { result[i] = array.get_string_element(i) ?? ""; } return result; } public bool has_key(string key) { return section_object.has_member(key); } public WebConfigSection? get_section(string key) { if (!section_object.has_member(key)) { return null; } var node = section_object.get_member(key); if (node.get_node_type() != NodeType.OBJECT) { return null; } return new WebConfigSection(node.get_object()); } } } ``` ### 3. WebConfigLoader - Core/WebConfigLoader.vala Handles file discovery, layering, and parsing. Supports semicolon-delimited override paths. ```vala using Json; namespace Astralis { public class WebConfigLoader : Object { public const string DEFAULT_CONFIG_FILENAME = "web-config.json"; public const string ENV_CONFIG_PATH = "ASTRALIS_CONFIG_PATH"; public const string PATH_SEPARATOR = ";"; public static WebConfig load() { string[] config_paths = discover_config_paths(); if (config_paths.length == 0) { printerr(@"[Astralis] No config file found, using empty configuration\n"); return new WebConfig.empty(); } return load_and_merge_files(config_paths); } /// Discovers all configuration paths in priority order: /// 1. Base config from CWD (web-config.json) /// 2. Override configs from ASTRALIS_CONFIG_PATH (semicolon-delimited) public static string[] discover_config_paths() { var paths = new GenericArray(); // Always start with base config from CWD if it exists string cwd_path = Path.build_filename( Environment.get_current_dir(), DEFAULT_CONFIG_FILENAME ); bool has_base = File.new_for_path(cwd_path).query_exists(); if (has_base) { paths.add(cwd_path); } // Check environment variable for override paths string? env_path = Environment.get_variable(ENV_CONFIG_PATH); if (env_path != null && env_path != "") { string[] override_paths = parse_override_paths(env_path); foreach (string override_path in override_paths) { if (File.new_for_path(override_path).query_exists()) { paths.add(override_path); } else { printerr(@"[Astralis] Warning: Override config not found: $override_path\n"); } } } // Return empty array if no base and no valid overrides if (paths.length == 0) { return {}; } // If we have overrides but no base, first override becomes base // (This handles the case where ASTRALIS_CONFIG_PATH is set but no CWD config exists) var result = new string[paths.length]; for (int i = 0; i < paths.length; i++) { result[i] = paths[i]; } return result; } /// Parses semicolon-delimited paths from environment variable /// Handles edge cases: empty segments, whitespace, relative vs absolute paths public static string[] parse_override_paths(string env_value) { if (env_value == null || env_value.strip() == "") { return {}; } string[] raw_paths = env_value.split(PATH_SEPARATOR); var valid_paths = new GenericArray(); foreach (unowned string raw_path in raw_paths) { string trimmed = raw_path.strip(); if (trimmed == "") { continue; // Skip empty segments } // Resolve relative paths against CWD string resolved_path; if (Path.is_absolute(trimmed)) { resolved_path = trimmed; } else { resolved_path = Path.build_filename( Environment.get_current_dir(), trimmed ); } valid_paths.add(resolved_path); } var result = new string[valid_paths.length]; for (int i = 0; i < valid_paths.length; i++) { result[i] = valid_paths[i]; } return result; } /// Loads and merges multiple configuration files /// Files are processed in order; later files override earlier ones private static WebConfig load_and_merge_files(string[] paths) { if (paths.length == 0) { return new WebConfig.empty(); } Json.Object? merged_object = null; var loaded_files = new GenericArray(); foreach (string path in paths) { try { Json.Object config_object = load_json_object(path); loaded_files.add(path); if (merged_object == null) { merged_object = config_object; } else { merged_object = ConfigMerger.deep_merge(merged_object, config_object); } printerr(@"[Astralis] Loaded config: $path\n"); } catch (WebConfigError e) { printerr(@"[Astralis] Error loading config '$path': $(e.message)\n"); // Continue processing other files; don't fail entirely } } if (merged_object == null) { return new WebConfig.empty(); } var sources = new string[loaded_files.length]; for (int i = 0; i < loaded_files.length; i++) { sources[i] = loaded_files[i]; } return new WebConfig.from_json_object(merged_object, sources); } /// Loads a single JSON file and returns its root object public static Json.Object load_json_object(string path) throws WebConfigError { try { string contents; FileUtils.get_contents(path, out contents); var parser = new Json.Parser(); if (!parser.load_from_data(contents)) { throw new WebConfigError.PARSE_ERROR( "Failed to parse JSON from config file" ); } var root = parser.get_root(); if (root.get_node_type() != NodeType.OBJECT) { throw new WebConfigError.INVALID_JSON( "Config file root must be a JSON object" ); } return root.get_object(); } catch (FileError.NOENT e) { throw new WebConfigError.FILE_NOT_FOUND( @"Config file not found: $path" ); } catch (FileError e) { throw new WebConfigError.PARSE_ERROR( @"Error reading config file: $(e.message)" ); } catch (Error e) { throw new WebConfigError.PARSE_ERROR( @"Error parsing config file: $(e.message)" ); } } // Backwards compatibility: Load single file public static WebConfig load_from_file(string path) throws WebConfigError { Json.Object obj = load_json_object(path); return new WebConfig.from_json_object(obj, { path }); } } } ``` ### 4. ConfigMerger - Core/ConfigMerger.vala Implements deep merge logic for combining JSON configuration objects. ```vala using Json; namespace Astralis { /// Internal class for merging JSON configuration objects internal class ConfigMerger : Object { /// Performs a deep merge of two JSON objects. /// The override object's values take precedence. public static Json.Object deep_merge(Json.Object base, Json.Object override) { // Start with a copy of the base object var result = new Json.Object(); // Copy all members from base foreach (string member_name in base.get_members()) { var node = copy_node(base.get_member(member_name)); result.set_member(member_name, node); } // Merge override values foreach (string member_name in override.get_members()) { var override_node = override.get_member(member_name); // Handle null as deletion if (override_node.get_node_type() == NodeType.NULL) { if (result.has_member(member_name)) { result.remove_member(member_name); } continue; } // If key doesn't exist in result, just copy it if (!result.has_member(member_name)) { result.set_member(member_name, copy_node(override_node)); continue; } // Get existing node for merging var base_node = result.get_member(member_name); var merged_node = merge_nodes(base_node, override_node); result.set_member(member_name, merged_node); } return result; } /// Merges two JSON nodes based on their types private static Json.Node merge_nodes(Json.Node base_node, Json.Node override_node) { var base_type = base_node.get_node_type(); var override_type = override_node.get_node_type(); // If types differ or either is not an object, override wins if (base_type != NodeType.OBJECT || override_type != NodeType.OBJECT) { return copy_node(override_node); } // Both are objects: recursive deep merge var merged_object = deep_merge( base_node.get_object(), override_node.get_object() ); var result = new Json.Node(NodeType.OBJECT); result.set_object(merged_object); return result; } /// Creates a deep copy of a JSON node private static Json.Node copy_node(Json.Node source) { return source.copy(); } } } ``` ### 5. WebApplication Integration Modify [`WebApplication.vala`](src/Core/WebApplication.vala:12) to auto-register WebConfig: ```vala public class WebApplication { public Container container { get; private set; } public int port { get; private set; } public WebConfig config { get; private set; } private Server server; private Pipeline pipeline; public WebApplication(int? port = null) { // Load configuration first (supports layering via ASTRALIS_CONFIG_PATH) this.config = WebConfigLoader.load(); // Port priority: constructor arg > config file > env var > default int config_port = config.get_int("port", 0); string? env_port = Environment.get_variable("ASTRALIS_PORT"); if (port != null) { this.port = port; } else if (config_port > 0) { this.port = config_port; } else if (env_port != null) { this.port = int.parse(env_port); } else { this.port = 8080; } // Log loaded config files for debugging var loaded_files = config.get_loaded_files(); if (loaded_files.length > 0) { printerr(@"[Astralis] Configuration loaded from: $(string.joinv(" -> ", loaded_files))\n"); } printerr(@"[Astralis] Web application using port $(this.port)\n"); container = new Container(); // Register config as singleton container.register_singleton(() => config); pipeline = new Pipeline(container); server = new Server(this.port, pipeline); } // ... rest of the class unchanged } ``` ## Example Configuration Files ### Layered Configuration Example The power of configuration layering is best demonstrated with a multi-environment setup: #### Base Configuration - web-config.json ```json { "port": 8080, "host": "0.0.0.0", "debug": false, "database": { "host": "localhost", "port": 5432, "name": "myapp", "user": "appuser", "pool_size": 10 }, "logging": { "level": "info", "format": "json" }, "cors": { "allowed_origins": ["http://localhost:3000"], "allow_credentials": true } } ``` #### Staging Override - web-config.staging.json ```json { "debug": true, "database": { "host": "staging-db.internal.example.com", "pool_size": 20 }, "logging": { "level": "debug" }, "cors": { "allowed_origins": ["https://staging.example.com", "https://staging-admin.example.com"] } } ``` #### Local Development Override - web-config.local.json ```json { "debug": true, "database": { "host": "localhost", "user": "devuser" }, "logging": { "level": "debug", "format": "text" } } ``` #### Merged Result (Staging Environment) When running with `ASTRALIS_CONFIG_PATH="web-config.staging.json"`, the merged configuration is: ```json { "port": 8080, "host": "0.0.0.0", "debug": true, "database": { "host": "staging-db.internal.example.com", "port": 5432, "name": "myapp", "user": "appuser", "pool_size": 20 }, "logging": { "level": "debug", "format": "json" }, "cors": { "allowed_origins": ["https://staging.example.com", "https://staging-admin.example.com"], "allow_credentials": true } } ``` #### Merged Result (Local Development) When running with `ASTRALIS_CONFIG_PATH="web-config.staging.json;web-config.local.json"`: ```json { "port": 8080, "host": "0.0.0.0", "debug": true, "database": { "host": "localhost", "port": 5432, "name": "myapp", "user": "devuser", "pool_size": 20 }, "logging": { "level": "debug", "format": "text" }, "cors": { "allowed_origins": ["https://staging.example.com", "https://staging-admin.example.com"], "allow_credentials": true } } ``` ### Minimal Example ```json { "port": 3000 } ``` ## Usage Examples ### In an Endpoint ```vala using Astralis; class ConfigAwareEndpoint : Object, Endpoint { // Inject WebConfig via constructor injection private WebConfig config; public ConfigAwareEndpoint(WebConfig config) { this.config = config; } public async HttpResult handle_request(HttpContext ctx, RouteContext route) throws Error { // Access top-level config values string host = config.get_string("host", "localhost"); int port = config.get_int("port", 8080); bool debug = config.get_bool("debug", false); // Access nested sections var db_config = config.get_section("database"); if (db_config != null) { string db_host = db_config.get_string("host", "localhost"); int db_port = db_config.get_int("port", 5432); } // Access arrays var cors_config = config.get_section("cors"); string[] origins = {}; if (cors_config != null) { origins = cors_config.get_string_array("allowed_origins", {}); } return new HttpStringResult(@"Config loaded: host=$host, port=$port"); } } ``` ### Manual Registration Override ```vala void main() { var application = new WebApplication(); // Access config directly from application var config = application.config; string app_name = config.get_string("app_name", "My App"); // Register additional config-dependent services application.add_singleton(() => { var db_section = config.get_section("database"); return new DatabaseConnection( db_section.get_string("host", "localhost"), db_section.get_int("port", 5432) ); }); application.run(); } ``` ### Using Environment Variable ```bash # Single config file export ASTRALIS_CONFIG_PATH=/etc/myapp/web-config.json ./myapp # Multiple config files with layering (staging environment) export ASTRALIS_CONFIG_PATH="web-config.staging.json" ./myapp # Multiple config files with layering (local development) export ASTRALIS_CONFIG_PATH="web-config.staging.json;web-config.local.json" ./myapp # Absolute and relative paths mixed export ASTRALIS_CONFIG_PATH="/etc/myapp/base.json;./local-overrides.json" ./myapp # Production with secrets file export ASTRALIS_CONFIG_PATH="/etc/myapp/web-config.json;/run/secrets/config.json" ./myapp ``` ### Layered Configuration Scenarios #### Scenario 1: Development Environment ```bash # Developer wants to override database settings locally ASTRALIS_CONFIG_PATH="web-config.local.json" ./myapp ``` This loads: 1. `web-config.json` (base) 2. `web-config.local.json` (local overrides) #### Scenario 2: CI/CD Pipeline ```bash # CI uses staging config with test database override ASTRALIS_CONFIG_PATH="web-config.staging.json;web-config.test.json" ./run_tests ``` This loads: 1. `web-config.json` (base) 2. `web-config.staging.json` (staging settings) 3. `web-config.test.json` (test database, mock services) #### Scenario 3: Production with Secrets Injection ```bash # Production base config with secrets mounted by orchestration platform ASTRALIS_CONFIG_PATH="/run/secrets/db-credentials.json;/run/secrets/api-keys.json" ./myapp ``` Note: In production, there may be no `web-config.json` in CWD; the first override becomes the base. ## Error Handling Strategy ### Missing Configuration File - **Behavior**: Application continues with empty configuration - **Logging**: Warning message printed to stderr - **Rationale**: Allows applications to run without config file using all defaults ### Invalid JSON - **Behavior**: Throw `WebConfigError.PARSE_ERROR` - **Logging**: Error details printed to stderr - **Rationale**: Invalid JSON indicates a serious configuration problem ### Missing Keys - **Behavior**: Return default value provided by caller - **Logging**: None (this is expected behavior) - **Rationale**: Configuration should be optional; code should provide sensible defaults ### File Exists but Unreadable - **Behavior**: Throw `WebConfigError.PARSE_ERROR` - **Logging**: Error details printed to stderr - **Rationale**: Permission issues should be surfaced to the user ## Implementation Steps 1. **Create WebConfigSection.vala** - Nested section access class 2. **Create WebConfig.vala** - Main configuration class with source tracking 3. **Create ConfigMerger.vala** - Deep merge logic for layering 4. **Create WebConfigLoader.vala** - File discovery, layering, and loading 5. **Update WebApplication.vala** - Auto-register WebConfig singleton with layering support 6. **Update src/meson.build** - Add new source files 7. **Create example** - Demonstrate configuration usage with layering 8. **Add unit tests** - Test merge semantics and layering behavior ## Design Decisions and Rationale ### Why Singleton Registration? WebConfig is registered as a singleton because: - Configuration is read once at startup - All components should see the same configuration values - Consistent with how other cross-cutting concerns are handled ### Why Not Throw on Missing File? Following the existing pattern in [`WebApplication.vala:13`](src/Core/WebApplication.vala:13) where `ASTRALIS_PORT` defaults gracefully, the configuration system should allow applications to run without a config file. ### Why json-glib-1.0? - Already a project dependency (see [`meson.build:13`](meson.build:13)) - Well-maintained GNOME library - Good Vala bindings - Consistent with project's GLib-based architecture ### Why Separate Loader Class? - Single Responsibility Principle - Easier testing of file discovery logic - Allows custom loaders in the future - Keeps WebConfig focused on value access ### Why Semicolon as Path Separator? - Semicolon (`;`) is the standard path separator on Windows (e.g., `PATH` variable) - Colon (`:`) is used in Unix PATH but conflicts with absolute paths on Windows (`C:\...`) - Semicolon is unambiguous on all platforms - Familiar to developers from Java classpath and other tools ### Why Array Replacement Instead of Concatenation? - Predictable behavior: you know exactly what the final array contains - Concatenation could lead to unintended accumulation across environments - If you need to extend arrays, specify the complete array in the override - Future enhancement could add merge strategy hints if needed ### Why Deep Merge for Objects? - Allows partial overrides of nested configuration (e.g., database settings) - Avoids the need to duplicate unchanged nested values - Matches behavior of popular configuration systems (Spring Boot, ASP.NET Core) - Maintains backward compatibility while enabling layering ### Environment Variable Name `ASTRALIS_CONFIG_PATH` follows the existing convention established by `ASTRALIS_PORT`. ## Future Enhancements 1. **Hot Reload**: Watch config file for changes and reload 2. **Schema Validation**: Validate config against a JSON schema 3. **Environment Variable Substitution**: Support `${VAR}` syntax in config values 4. **Config Sections as DI Services**: Register individual sections as separate services 5. **Array Merge Strategies**: Allow configuration to specify array merge behavior (replace, concatenate, or merge-by-index) 6. **Config Diff Logging**: Log which values were overridden by which file for debugging ## Integration with Existing Patterns The WebConfig system follows established Astralis patterns: | Pattern | Example | WebConfig Usage | |---------|---------|-----------------| | Constructor injection | `Pipeline(Container container)` | `Endpoint(WebConfig config)` | | Singleton registration | `container.register_singleton` | Auto-registered in WebApplication | | Graceful defaults | `ASTRALIS_PORT ?? "8080"` | `get_string(key, default)` | | Internal constructors | `internal HttpContext(...)` | `internal WebConfig.from_json_object(...)` | | Public interfaces | `HttpResult`, `Endpoint` | `WebConfig`, `WebConfigSection` |