Przeglądaj źródła

feat(config): add WebConfig system for configuration management

Introduce a comprehensive configuration management system with:
- WebConfig class for type-safe configuration access
- WebConfigLoader for loading from JSON files
- ConfigMerger for environment-specific config overlays
- WebConfigSection for hierarchical config organization

WebApplication now integrates with WebConfig, loading configuration
from web-config.json with port resolution priority: constructor arg >
config file > env var > default (8080). Configuration is registered
as a singleton in the DI container for application-wide access.

Includes example implementation (WebConfigExample.vala) with sample
config files and architecture documentation.
Billy Barrow 3 godzin temu
rodzic
commit
1c420b8ed7

+ 296 - 0
examples/WebConfigExample.vala

@@ -0,0 +1,296 @@
+using Astralis;
+using Invercargill;
+using Invercargill.DataStructures;
+using Inversion;
+
+/**
+ * WebConfig Example
+ * 
+ * Demonstrates the WebConfig configuration system:
+ * - Accessing configuration values from DI-injected WebConfig
+ * - Using different typed getters (string, int, bool, arrays)
+ * - Accessing nested sections
+ * - Handling missing keys with defaults
+ * 
+ * To run with different environments:
+ *   ./web-config-example                    # Uses web-config.json
+ *   ASTRALIS_ENV=staging ./web-config-example  # Uses web-config.json + web-config.staging.json
+ */
+
+// Root endpoint - shows all configuration
+class ConfigRootEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext http_context, RouteContext route) throws Error {
+        var config = inject<WebConfig>();
+        
+        var json_parts = new Series<string>();
+        json_parts.add_start("{\n");
+        json_parts.add_start("  \"message\": \"WebConfig Example API\",\n");
+        json_parts.add_start("  \"endpoints\": {\n");
+        json_parts.add_start("    \"config\": \"/config\",\n");
+        json_parts.add_start("    \"database\": \"/config/database\",\n");
+        json_parts.add_start("    \"origins\": \"/config/origins\",\n");
+        json_parts.add_start("    \"debug\": \"/config/debug\",\n");
+        json_parts.add_start("    \"sources\": \"/config/sources\"\n");
+        json_parts.add_start("  }\n");
+        json_parts.add_start("}");
+        
+        var json_string = json_parts.to_immutable_buffer()
+            .aggregate<string>("", (acc, s) => acc + s);
+        
+        return new HttpStringResult(json_string)
+            .set_header("Content-Type", "application/json")
+            .set_header("Access-Control-Allow-Origin", "*");
+    }
+}
+
+// Shows full configuration details
+class ConfigEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext http_context, RouteContext route) throws Error {
+        var config = inject<WebConfig>();
+        
+        // Get values with defaults for missing keys
+        var port = config.get_int("port", 8080);
+        var app_name = config.get_string("app_name", "Unknown App");
+        var debug = config.get_bool("debug", false);
+        
+        // Build JSON response
+        var json_parts = new Series<string>();
+        json_parts.add_start("{\n");
+        json_parts.add_start(@"  \"port\": $port,\n");
+        json_parts.add_start(@"  \"app_name\": \"$app_name\",\n");
+        json_parts.add_start(@"  \"debug\": $debug\n");
+        json_parts.add_start("}");
+        
+        var json_string = json_parts.to_immutable_buffer()
+            .aggregate<string>("", (acc, s) => acc + s);
+        
+        return new HttpStringResult(json_string)
+            .set_header("Content-Type", "application/json")
+            .set_header("Access-Control-Allow-Origin", "*");
+    }
+}
+
+// Demonstrates accessing nested configuration sections
+class DatabaseConfigEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext http_context, RouteContext route) throws Error {
+        var config = inject<WebConfig>();
+        
+        // Get the database section
+        var db_section = config.get_section("database");
+        
+        string db_host;
+        int db_port;
+        string db_name;
+        
+        if (db_section != null) {
+            // Access values from the nested section
+            db_host = db_section.get_string("host", "localhost");
+            db_port = db_section.get_int("port", 5432);
+            db_name = db_section.get_string("name", "app_db");
+        } else {
+            // Section not configured - use defaults
+            db_host = "not configured";
+            db_port = 0;
+            db_name = "not configured";
+        }
+        
+        // Build JSON response
+        var json = @"{
+  \"database\": {
+    \"host\": \"$db_host\",
+    \"port\": $db_port,
+    \"name\": \"$db_name\"
+  }
+}";
+        
+        return new HttpStringResult(json)
+            .set_header("Content-Type", "application/json")
+            .set_header("Access-Control-Allow-Origin", "*");
+    }
+}
+
+// Demonstrates accessing string arrays
+class OriginsConfigEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext http_context, RouteContext route) throws Error {
+        var config = inject<WebConfig>();
+        
+        // Get string array with default empty array
+        var origins = config.get_string_array("allowed_origins", {});
+        
+        // Build JSON array
+        var origins_parts = new Series<string>();
+        origins_parts.add_start("[");
+        
+        bool first = true;
+        foreach (var origin in origins) {
+            if (!first) origins_parts.add_start(", ");
+            origins_parts.add_start(@"\"$origin\"");
+            first = false;
+        }
+        
+        origins_parts.add("]");
+        
+        var origins_json = origins_parts.to_immutable_buffer()
+            .aggregate<string>("", (acc, s) => acc + s);
+        
+        var json = @"{
+  \"allowed_origins\": $origins_json,
+  \"count\": $(origins.length)
+}";
+        
+        return new HttpStringResult(json)
+            .set_header("Content-Type", "application/json")
+            .set_header("Access-Control-Allow-Origin", "*");
+    }
+}
+
+// Demonstrates boolean access and has_key checking
+class DebugConfigEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext http_context, RouteContext route) throws Error {
+        var config = inject<WebConfig>();
+        
+        // Check if key exists before accessing
+        var has_debug = config.has_key("debug");
+        var debug = config.get_bool("debug", false);
+        
+        // Also check for other optional keys
+        var has_log_level = config.has_key("log_level");
+        var log_level = config.get_string("log_level", "info");
+        
+        var json = @"{
+  \"debug\": {
+    \"configured\": $has_debug,
+    \"value\": $debug
+  },
+  \"log_level\": {
+    \"configured\": $has_log_level,
+    \"value\": \"$log_level\"
+  }
+}";
+        
+        return new HttpStringResult(json)
+            .set_header("Content-Type", "application/json")
+            .set_header("Access-Control-Allow-Origin", "*");
+    }
+}
+
+// Shows which configuration files were loaded (demonstrates layering)
+class ConfigSourcesEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext http_context, RouteContext route) throws Error {
+        var config = inject<WebConfig>();
+        
+        // Get list of loaded files
+        var loaded_files = config.get_loaded_files();
+        var is_loaded = config.is_loaded();
+        
+        // Build JSON array of sources
+        var sources_parts = new Series<string>();
+        sources_parts.add_start("[");
+        
+        bool first = true;
+        foreach (var file in loaded_files) {
+            if (!first) sources_parts.add_start(", ");
+            sources_parts.add_start(@"\"$file\"");
+            first = false;
+        }
+        
+        sources_parts.add("]");
+        
+        var sources_json = sources_parts.to_immutable_buffer()
+            .aggregate<string>("", (acc, s) => acc + s);
+        
+        var json = @"{
+  \"loaded\": $is_loaded,
+  \"sources\": $sources_json,
+  \"to_string\": \"$(config.to_string().replace("\"", "\\\""))\"
+}";
+        
+        return new HttpStringResult(json)
+            .set_header("Content-Type", "application/json")
+            .set_header("Access-Control-Allow-Origin", "*");
+    }
+}
+
+// Demonstrates accessing all keys and iterating configuration
+class ConfigKeysEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext http_context, RouteContext route) throws Error {
+        var config = inject<WebConfig>();
+        
+        // Get all root-level keys
+        var keys = config.get_keys();
+        
+        // Build JSON array of keys
+        var keys_parts = new Series<string>();
+        keys_parts.add_start("[");
+        
+        bool first = true;
+        foreach (var key in keys) {
+            if (!first) keys_parts.add_start(", ");
+            keys_parts.add_start(@"\"$key\"");
+            first = false;
+        }
+        
+        keys_parts.add("]");
+        
+        var keys_json = keys_parts.to_immutable_buffer()
+            .aggregate<string>("", (acc, s) => acc + s);
+        
+        var json = @"{
+  \"keys\": $keys_json,
+  \"count\": $(keys.length())
+}";
+        
+        return new HttpStringResult(json)
+            .set_header("Content-Type", "application/json")
+            .set_header("Access-Control-Allow-Origin", "*");
+    }
+}
+
+void main() {
+    var application = new WebApplication();
+    
+    // Register endpoints
+    application.container.register_scoped<Endpoint>(() => new ConfigRootEndpoint())
+        .with_metadata<EndpointRoute>(new EndpointRoute("/"))
+        .as<Endpoint>();
+    
+    application.container.register_scoped<Endpoint>(() => new ConfigEndpoint())
+        .with_metadata<EndpointRoute>(new EndpointRoute("/config"))
+        .as<Endpoint>();
+    
+    application.container.register_scoped<Endpoint>(() => new DatabaseConfigEndpoint())
+        .with_metadata<EndpointRoute>(new EndpointRoute("/config/database"))
+        .as<Endpoint>();
+    
+    application.container.register_scoped<Endpoint>(() => new OriginsConfigEndpoint())
+        .with_metadata<EndpointRoute>(new EndpointRoute("/config/origins"))
+        .as<Endpoint>();
+    
+    application.container.register_scoped<Endpoint>(() => new DebugConfigEndpoint())
+        .with_metadata<EndpointRoute>(new EndpointRoute("/config/debug"))
+        .as<Endpoint>();
+    
+    application.container.register_scoped<Endpoint>(() => new ConfigSourcesEndpoint())
+        .with_metadata<EndpointRoute>(new EndpointRoute("/config/sources"))
+        .as<Endpoint>();
+    
+    application.container.register_scoped<Endpoint>(() => new ConfigKeysEndpoint())
+        .with_metadata<EndpointRoute>(new EndpointRoute("/config/keys"))
+        .as<Endpoint>();
+    
+    print("WebConfig Example Server starting...\n");
+    print("Configuration is loaded from web-config.json (and web-config.{ENV}.json if ASTRALIS_ENV is set)\n");
+    print("\nTry these endpoints:\n");
+    print("  - http://localhost:3000/\n");
+    print("  - http://localhost:3000/config\n");
+    print("  - http://localhost:3000/config/database\n");
+    print("  - http://localhost:3000/config/origins\n");
+    print("  - http://localhost:3000/config/debug\n");
+    print("  - http://localhost:3000/config/sources\n");
+    print("  - http://localhost:3000/config/keys\n");
+    print("\nTo test configuration layering:\n");
+    print("  ASTRALIS_ENV=staging ./web-config-example\n");
+    print("  Then check /config/sources to see both files loaded\n");
+    
+    application.run();
+}

+ 7 - 0
examples/meson.build

@@ -109,3 +109,10 @@ executable('sse-example',
     dependencies: [astralis_dep, invercargill_dep],
     install: false
 )
+
+# WebConfig Example - demonstrates configuration management with WebConfig
+executable('web-config-example',
+    'WebConfigExample.vala',
+    dependencies: [astralis_dep, invercargill_dep],
+    install: false
+)

+ 11 - 0
examples/web-config.json

@@ -0,0 +1,11 @@
+{
+    "port": 3000,
+    "app_name": "WebConfig Example",
+    "debug": true,
+    "database": {
+        "host": "localhost",
+        "port": 5432,
+        "name": "example_db"
+    },
+    "allowed_origins": ["http://localhost:3000", "http://example.com"]
+}

+ 6 - 0
examples/web-config.staging.json

@@ -0,0 +1,6 @@
+{
+    "debug": false,
+    "database": {
+        "host": "staging-db.example.com"
+    }
+}

+ 1078 - 0
plans/web-config-architecture.md

@@ -0,0 +1,1078 @@
+# 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<string>();
+
+            // 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<string>();
+
+            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<string>();
+
+            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<WebConfig>(() => 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<DatabaseConnection>(() => {
+        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<T>` | 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` |

+ 122 - 0
src/Core/ConfigMerger.vala

@@ -0,0 +1,122 @@
+using Json;
+
+namespace Astralis {
+
+    /// <summary>
+    /// Internal utility class for deep merging JSON configuration objects.
+    /// Provides static methods for merging JSON nodes with configurable semantics.
+    /// </summary>
+    internal class ConfigMerger : GLib.Object {
+
+        /// <summary>
+        /// Performs a deep merge of two JSON nodes.
+        /// 
+        /// Merge semantics:
+        /// - Scalar values (strings, numbers, booleans): Override replaces base entirely
+        /// - Arrays: Override replaces base entirely (no concatenation)
+        /// - Objects: Deep merge recursively
+        /// - Null values: Removes the key from result (when merging objects)
+        /// </summary>
+        /// <param name="base_node">The base node to merge into</param>
+        /// <param name="override_node">The override node whose values take precedence</param>
+        /// <returns>A new merged Json.Node</returns>
+        public static Json.Node deep_merge(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 both are objects, perform deep merge
+            if (base_type == NodeType.OBJECT && override_type == NodeType.OBJECT) {
+                var merged_object = merge_objects(
+                    base_node.get_object(),
+                    override_node.get_object()
+                );
+                var result = new Json.Node(NodeType.OBJECT);
+                result.set_object(merged_object);
+                return result;
+            }
+
+            // For all other cases (scalars, arrays, type mismatches), override wins
+            return copy_node(override_node);
+        }
+
+        /// <summary>
+        /// Performs a deep merge of two JSON objects.
+        /// The override object's values take precedence.
+        /// </summary>
+        /// <param name="base_obj">The base object to merge into</param>
+        /// <param name="override_obj">The override object whose values take precedence</param>
+        /// <returns>A new merged Json.Object</returns>
+        public static Json.Object merge_objects(Json.Object base_obj, Json.Object override_obj) {
+            var result = new Json.Object();
+
+            // Copy all members from base
+            foreach (string member_name in base_obj.get_members()) {
+                var node = copy_node(base_obj.get_member(member_name));
+                result.set_member(member_name, node);
+            }
+
+            // Merge override values
+            foreach (string member_name in override_obj.get_members()) {
+                var override_node = override_obj.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;
+        }
+
+        /// <summary>
+        /// Merges two JSON nodes based on their types.
+        /// </summary>
+        /// <param name="base_node">The base node</param>
+        /// <param name="override_node">The override node</param>
+        /// <returns>A new merged Json.Node</returns>
+        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 = merge_objects(
+                base_node.get_object(),
+                override_node.get_object()
+            );
+
+            var result = new Json.Node(NodeType.OBJECT);
+            result.set_object(merged_object);
+            return result;
+        }
+
+        /// <summary>
+        /// Creates a deep copy of a JSON node.
+        /// </summary>
+        /// <param name="source">The source node to copy</param>
+        /// <returns>A deep copy of the source node</returns>
+        public static Json.Node copy_node(Json.Node source) {
+            return source.copy();
+        }
+
+    }
+
+}

+ 36 - 4
src/Core/WebApplication.vala

@@ -5,20 +5,52 @@ namespace Astralis {
 
         public Container container { get; private set; }
         public int port { get; private set; }
+        
+        /**
+         * The application configuration loaded from web-config.json
+         */
+        public WebConfig config { get; private set; }
 
-        private Server server;
+        private Server? server;
         private Pipeline pipeline;
+        private bool _port_explicitly_set;
 
         public WebApplication(int? port = null) {
-            this.port = port ?? int.parse(Environment.get_variable ("ASTRALIS_PORT") ?? "8080");
-            printerr(@"[Astralis] Web application using port $(port)\n");
+            this._port_explicitly_set = (port != null);
+            this.port = port ?? 0;
 
             container = new Container();
             pipeline = new Pipeline(container);
-            server = new Server(this.port, pipeline);
+            // Server is created in run() after config is loaded
         }
 
         public void run() throws Error {
+            // Load configuration
+            config = WebConfigLoader.load();
+            
+            // Resolve port if not explicitly set in constructor
+            // Priority: constructor arg > config file > env var > default (8080)
+            if (!_port_explicitly_set) {
+                if (config.has_key("port")) {
+                    port = config.get_int("port", 8080);
+                } else {
+                    string? env_port = Environment.get_variable("ASTRALIS_PORT");
+                    if (env_port != null) {
+                        port = int.parse(env_port);
+                    } else {
+                        port = 8080;
+                    }
+                }
+            }
+            
+            printerr(@"[Astralis] Web application using port $port\n");
+            
+            // Create server with resolved port
+            server = new Server(port, pipeline);
+            
+            // Register config as singleton in DI container
+            add_singleton<WebConfig>(() => config);
+            
             // Ensure router is registered last so that it is the last component in the pipeline.
             add_component<EndpointRouter>();
 

+ 195 - 0
src/Core/WebConfig.vala

@@ -0,0 +1,195 @@
+using Json;
+
+namespace Astralis {
+
+    /// <summary>
+    /// Main configuration class that provides typed access to configuration values.
+    /// This class is registered as a singleton in the DI container.
+    /// Supports configuration layering where multiple files can be merged together.
+    /// </summary>
+    public class WebConfig : GLib.Object {
+
+        private Json.Object? root_object;
+        private bool loaded;
+        private string[] _loaded_files;
+
+        /// <summary>
+        /// Internal constructor. Creates a WebConfig from a Json.Object with optional source tracking.
+        /// </summary>
+        /// <param name="obj">The root Json.Object containing configuration</param>
+        /// <param name="sources">Array of file paths that contributed to this configuration</param>
+        internal WebConfig.from_json_object(Json.Object? obj, string[] sources = {}) {
+            this.root_object = obj;
+            this.loaded = obj != null;
+            this._loaded_files = sources;
+        }
+
+        /// <summary>
+        /// Creates an empty WebConfig with no values.
+        /// </summary>
+        public WebConfig.empty() {
+            this.root_object = null;
+            this.loaded = false;
+            this._loaded_files = {};
+        }
+
+        /// <summary>
+        /// Gets a string value from the configuration.
+        /// </summary>
+        /// <param name="key">The key to look up</param>
+        /// <param name="default">The default value if key is missing or not a string</param>
+        /// <returns>The string value or the default</returns>
+        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;
+        }
+
+        /// <summary>
+        /// Gets an integer value from the configuration.
+        /// </summary>
+        /// <param name="key">The key to look up</param>
+        /// <param name="default">The default value if key is missing or not an integer</param>
+        /// <returns>The integer value or the default</returns>
+        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();
+        }
+
+        /// <summary>
+        /// Gets a boolean value from the configuration.
+        /// </summary>
+        /// <param name="key">The key to look up</param>
+        /// <param name="default">The default value if key is missing or not a boolean</param>
+        /// <returns>The boolean value or the default</returns>
+        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();
+        }
+
+        /// <summary>
+        /// Gets a double value from the configuration.
+        /// </summary>
+        /// <param name="key">The key to look up</param>
+        /// <param name="default">The default value if key is missing or not a double</param>
+        /// <returns>The double value or the default</returns>
+        public double get_double(string key, double default = 0.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 node.get_double();
+        }
+
+        /// <summary>
+        /// Gets a string array value from the configuration.
+        /// </summary>
+        /// <param name="key">The key to look up</param>
+        /// <param name="default">The default value if key is missing or not an array</param>
+        /// <returns>The string array or the default</returns>
+        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;
+        }
+
+        /// <summary>
+        /// Checks if a key exists in the configuration.
+        /// </summary>
+        /// <param name="key">The key to check</param>
+        /// <returns>True if the key exists, false otherwise</returns>
+        public bool has_key(string key) {
+            return root_object != null && root_object.has_member(key);
+        }
+
+        /// <summary>
+        /// Gets all keys in the root configuration.
+        /// </summary>
+        /// <returns>A list of key names</returns>
+        public GLib.List<weak string> get_keys() {
+            if (root_object == null) {
+                return new GLib.List<weak string>();
+            }
+            return root_object.get_members();
+        }
+
+        /// <summary>
+        /// Gets a nested section from the configuration.
+        /// </summary>
+        /// <param name="key">The key to look up</param>
+        /// <returns>A WebConfigSection if the key exists and is an object, null otherwise</returns>
+        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());
+        }
+
+        /// <summary>
+        /// Checks if configuration was loaded from a file.
+        /// </summary>
+        /// <returns>True if configuration was loaded, false if using empty config</returns>
+        public bool is_loaded() {
+            return loaded;
+        }
+
+        /// <summary>
+        /// Gets the list of file paths that contributed to this configuration.
+        /// Useful for debugging configuration layering.
+        /// </summary>
+        /// <returns>Array of file paths</returns>
+        public string[] get_loaded_files() {
+            return _loaded_files;
+        }
+
+        /// <summary>
+        /// Returns a string representation for debugging purposes.
+        /// </summary>
+        /// <returns>A string showing loaded files and load status</returns>
+        public string to_string() {
+            if (!loaded) {
+                return "WebConfig(empty)";
+            }
+            if (_loaded_files.length == 0) {
+                return "WebConfig(loaded, no sources)";
+            }
+            return "WebConfig(loaded from: %s)".printf(string.joinv(", ", _loaded_files));
+        }
+
+    }
+
+}

+ 18 - 0
src/Core/WebConfigError.vala

@@ -0,0 +1,18 @@
+
+namespace Astralis {
+
+    /// <summary>
+    /// Error domain for WebConfig operations
+    /// </summary>
+    public errordomain WebConfigError {
+        /// Configuration file not found
+        FILE_NOT_FOUND,
+        /// Failed to parse JSON configuration
+        PARSE_ERROR,
+        /// Invalid configuration value or structure
+        INVALID_CONFIG,
+        /// Failed to merge configuration layers
+        MERGE_ERROR
+    }
+
+}

+ 214 - 0
src/Core/WebConfigLoader.vala

@@ -0,0 +1,214 @@
+using Json;
+
+namespace Astralis {
+
+    /// <summary>
+    /// Static utility class that handles configuration file discovery, loading, and merging.
+    /// Supports configuration layering where multiple files can be merged together.
+    /// </summary>
+    public class WebConfigLoader : GLib.Object {
+
+        /// <summary>
+        /// Default configuration filename to look for in the current working directory.
+        /// </summary>
+        public const string DEFAULT_CONFIG_FILENAME = "web-config.json";
+
+        /// <summary>
+        /// Environment variable name for specifying configuration path(s).
+        /// </summary>
+        public const string ENV_CONFIG_PATH = "ASTRALIS_CONFIG_PATH";
+
+        /// <summary>
+        /// Separator used between multiple paths in the environment variable.
+        /// </summary>
+        public const string PATH_SEPARATOR = ";";
+
+        /// <summary>
+        /// Main entry point for loading configuration.
+        /// Discovers config paths, loads and merges all layers, and returns a WebConfig instance.
+        /// </summary>
+        /// <returns>A WebConfig instance (may be empty if no config files found)</returns>
+        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();
+            }
+
+            try {
+                return load_and_merge_files(config_paths);
+            } catch (WebConfigError e) {
+                printerr(@"[Astralis] Error loading configuration: $(e.message)\n");
+                return new WebConfig.empty();
+            }
+        }
+
+        /// <summary>
+        /// Discovers configuration file paths.
+        /// 
+        /// Discovery order:
+        /// 1. If ASTRALIS_CONFIG_PATH env var is set, parse semicolon-delimited paths and return them
+        /// 2. If not set, check if web-config.json exists in CWD
+        /// </summary>
+        /// <returns>Array of paths to load (may be empty)</returns>
+        public static string[] discover_config_paths() throws WebConfigError {
+            // First, check if ASTRALIS_CONFIG_PATH env var is set
+            string? env_path = Environment.get_variable(ENV_CONFIG_PATH);
+            
+            if (env_path != null && env_path != "") {
+                // Parse semicolon-delimited paths and return them
+                return parse_override_paths(env_path);
+            }
+
+            // If not set, check if web-config.json exists in CWD
+            string cwd_path = GLib.Path.build_filename(
+                Environment.get_current_dir(),
+                DEFAULT_CONFIG_FILENAME
+            );
+
+            if (File.new_for_path(cwd_path).query_exists()) {
+                return { cwd_path };
+            }
+
+            // Return empty array if no config found
+            return {};
+        }
+
+        /// <summary>
+        /// Parses semicolon-delimited paths from the environment variable value.
+        /// Handles edge cases: empty segments, whitespace, relative vs absolute paths.
+        /// </summary>
+        /// <param name="env_value">The raw environment variable value</param>
+        /// <returns>Array of resolved paths</returns>
+        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<string>();
+
+            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 (GLib.Path.is_absolute(trimmed)) {
+                    resolved_path = trimmed;
+                } else {
+                    resolved_path = GLib.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;
+        }
+
+        /// <summary>
+        /// Loads a single JSON file and returns its root as a Json.Node.
+        /// </summary>
+        /// <param name="path">The path to the JSON file</param>
+        /// <returns>The root Json.Node of the parsed file</returns>
+        /// <throws WebConfigError.FILE_NOT_FOUND if the file doesn't exist</throws>
+        /// <throws WebConfigError.PARSE_ERROR if the file contains invalid JSON</throws>
+        /// <throws WebConfigError.INVALID_CONFIG if the root is not a JSON object</throws>
+        public static Json.Node load_file(string path) throws WebConfigError {
+            string contents;
+            
+            try {
+                FileUtils.get_contents(path, out contents);
+            } 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)"
+                );
+            }
+
+            var parser = new Json.Parser();
+            try {
+                if (!parser.load_from_data(contents)) {
+                    throw new WebConfigError.PARSE_ERROR(
+                        "Failed to parse JSON from config file"
+                    );
+                }
+            } catch (Error e) {
+                throw new WebConfigError.PARSE_ERROR(
+                    @"Error parsing config file: $(e.message)"
+                );
+            }
+
+            var root = parser.get_root();
+            
+            // Validate that the root is an object
+            if (root.get_node_type() != NodeType.OBJECT) {
+                throw new WebConfigError.INVALID_CONFIG(
+                    "Config file root must be a JSON object"
+                );
+            }
+
+            return root;
+        }
+
+        /// <summary>
+        /// Loads and merges multiple configuration files.
+        /// Files are processed in order; later files override earlier ones.
+        /// </summary>
+        /// <param name="paths">Array of file paths to load</param>
+        /// <returns>A tuple containing the merged Json.Object and the list of successfully loaded file paths</returns>
+        /// <throws WebConfigError if no files could be loaded successfully</throws>
+        public static WebConfig load_and_merge_files(string[] paths) throws WebConfigError {
+            if (paths.length == 0) {
+                return new WebConfig.empty();
+            }
+
+            Json.Object? merged_object = null;
+            var loaded_files = new GenericArray<string>();
+
+            foreach (string path in paths) {
+                try {
+                    Json.Node root = load_file(path);
+                    Json.Object config_object = root.get_object();
+                    loaded_files.add(path);
+
+                    if (merged_object == null) {
+                        merged_object = config_object;
+                    } else {
+                        merged_object = ConfigMerger.merge_objects(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) {
+                // No files were loaded successfully
+                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);
+        }
+
+    }
+
+}

+ 146 - 0
src/Core/WebConfigSection.vala

@@ -0,0 +1,146 @@
+using Json;
+
+namespace Astralis {
+
+    /// <summary>
+    /// Provides typed access to a nested configuration section.
+    /// Wraps a Json.Object and provides convenient getter methods with default values.
+    /// </summary>
+    public class WebConfigSection : GLib.Object {
+
+        private Json.Object section_object;
+
+        /// <summary>
+        /// Internal constructor. Creates a new WebConfigSection wrapping the given Json.Object.
+        /// </summary>
+        /// <param name="obj">The Json.Object to wrap</param>
+        internal WebConfigSection(Json.Object obj) {
+            this.section_object = obj;
+        }
+
+        /// <summary>
+        /// Gets a string value from the section.
+        /// </summary>
+        /// <param name="key">The key to look up</param>
+        /// <param name="default">The default value if key is missing or not a string</param>
+        /// <returns>The string value or the default</returns>
+        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;
+        }
+
+        /// <summary>
+        /// Gets an integer value from the section.
+        /// </summary>
+        /// <param name="key">The key to look up</param>
+        /// <param name="default">The default value if key is missing or not an integer</param>
+        /// <returns>The integer value or the default</returns>
+        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();
+        }
+
+        /// <summary>
+        /// Gets a boolean value from the section.
+        /// </summary>
+        /// <param name="key">The key to look up</param>
+        /// <param name="default">The default value if key is missing or not a boolean</param>
+        /// <returns>The boolean value or the default</returns>
+        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();
+        }
+
+        /// <summary>
+        /// Gets a double value from the section.
+        /// </summary>
+        /// <param name="key">The key to look up</param>
+        /// <param name="default">The default value if key is missing or not a double</param>
+        /// <returns>The double value or the default</returns>
+        public double get_double(string key, double default = 0.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 node.get_double();
+        }
+
+        /// <summary>
+        /// Gets a string array value from the section.
+        /// </summary>
+        /// <param name="key">The key to look up</param>
+        /// <param name="default">The default value if key is missing or not an array</param>
+        /// <returns>The string array or the default</returns>
+        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;
+        }
+
+        /// <summary>
+        /// Checks if a key exists in this section.
+        /// </summary>
+        /// <param name="key">The key to check</param>
+        /// <returns>True if the key exists, false otherwise</returns>
+        public bool has_key(string key) {
+            return section_object.has_member(key);
+        }
+
+        /// <summary>
+        /// Gets all keys in this section.
+        /// </summary>
+        /// <returns>A list of key names</returns>
+        public GLib.List<weak string> get_keys() {
+            return section_object.get_members();
+        }
+
+        /// <summary>
+        /// Gets a nested section from this section.
+        /// </summary>
+        /// <param name="key">The key to look up</param>
+        /// <returns>A WebConfigSection if the key exists and is an object, null otherwise</returns>
+        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());
+        }
+
+    }
+
+}

+ 5 - 0
src/meson.build

@@ -6,6 +6,11 @@ sources = files(
     'Core/AsyncOutput.vala',
     'Core/Pipeline.vala',
     'Core/WebApplication.vala',
+    'Core/WebConfigError.vala',
+    'Core/WebConfig.vala',
+    'Core/WebConfigSection.vala',
+    'Core/WebConfigLoader.vala',
+    'Core/ConfigMerger.vala',
     'Data/FormDataParser.vala',
     'Components/EndpointRouter.vala',
     'Components/Compressor.vala',