Introduction
Configuration management is a critical aspect of any enterprise Angular application. Whether you're working with environment-specific settings, feature flags, or API endpoints, having a robust way to load and access configuration at runtime is essential. In this article, we'll build a properties configuration library specifically designed for Angular applications in Nx workspaces.
The Challenge
Traditional Angular configuration approaches using environment files have limitations:
- Configuration is bundled at build time, requiring rebuilds for config changes
- Different environments require separate builds
- Configuration can't be updated without redeploying the application
We need a solution that:
- Loads configuration at runtime from a JSON file
- Enforces strict property access (fails fast on missing properties)
- Works seamlessly in an Nx monorepo environment
- Uses modern Angular patterns (standalone components, functional providers)
Our Solution: A Properties Configuration Library
Let's create a library for loading and accessing configuration from a properties.json
file at runtime.
Core Features
- Runtime loading of configuration from a JSON file
- Strict property access with clear error messages
- Support for nested properties using dot notation
- Application initialization integration
- TypeScript type safety
The Implementation
1. The Properties Service
The heart of our solution is the PropertiesService
that handles loading and accessing configuration:
// File: libs/properties/src/lib/properties.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
export interface Properties {
[key: string]: any;
}
export class PropertyNotFoundError extends Error {
constructor(key: string) {
super(`Property '${key}' not found in configuration`);
this.name = 'PropertyNotFoundError';
}
}
@Injectable({
providedIn: 'root'
})
export class PropertiesService {
private http = inject(HttpClient);
private properties: Properties = {};
private propertiesLoaded = false;
private defaultPath = 'assets/properties.json';
async loadProperties(path?: string): Promise<Properties> {
if (this.propertiesLoaded) {
return this.properties;
}
const filePath = path || this.defaultPath;
try {
this.properties = await firstValueFrom(this.http.get<Properties>(filePath));
this.propertiesLoaded = true;
return this.properties;
} catch (error) {
console.error('Failed to load properties file', error);
throw new Error(`Failed to load properties file from ${filePath}`);
}
}
getProperty<T>(key: string): T {
if (!this.propertiesLoaded) {
throw new Error('Properties not loaded. Initialize the service first');
}
const value = this.getNestedProperty(this.properties, key);
if (value === undefined) {
throw new PropertyNotFoundError(key);
}
return value as T;
}
hasProperty(key: string): boolean {
if (!this.propertiesLoaded) {
throw new Error('Properties not loaded. Initialize the service first');
}
const value = this.getNestedProperty(this.properties, key);
return value !== undefined;
}
isInitialized(): boolean {
return this.propertiesLoaded;
}
getAllProperties(): Properties {
if (!this.propertiesLoaded) {
throw new Error('Properties not loaded. Initialize the service first');
}
return JSON.parse(JSON.stringify(this.properties));
}
private getNestedProperty(obj: any, path: string): any {
if (!path) return undefined;
const properties = path.split('.');
let current = obj;
for (const prop of properties) {
if (current === null || current === undefined) {
return undefined;
}
current = current[prop];
}
return current;
}
}
2. Application Initialization Provider
To ensure properties are loaded before the application starts, we'll use Angular's provideAppInitializer
(available in Angular 16+):
// File: libs/properties/src/lib/properties.provider.ts
import { EnvironmentProviders, makeEnvironmentProviders, inject } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideAppInitializer } from '@angular/core/appinit';
import { PropertiesService } from './properties.service';
export interface PropertiesConfig {
path?: string;
}
export function provideProperties(config?: PropertiesConfig): EnvironmentProviders {
return makeEnvironmentProviders([
provideHttpClient(),
PropertiesService,
provideAppInitializer(() => {
const propertiesService = inject(PropertiesService);
return propertiesService.loadProperties(config?.path);
})
]);
}
3. Library Exports
We'll keep our public API clean:
// File: libs/properties/src/index.ts
export * from './lib/properties.service';
export * from './lib/properties.provider';
How to Use the Library
1. Create a Properties JSON File
Place a properties.json
file in your application's assets folder:
{
"apiUrl": "https://api.example.com",
"environment": "production",
"features": {
"enableFeatureA": true,
"enableFeatureB": false
},
"logging": {
"level": "error"
}
}
2. Configure Your Application
In your application configuration, provide the properties service:
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideProperties } from '@myorg/properties';
export const appConfig: ApplicationConfig = {
providers: [
provideProperties({
path: 'assets/properties.json'
})
]
};
3. Use Properties in Components
Now you can use the properties in your components:
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PropertiesService, PropertyNotFoundError } from '@myorg/properties';
@Component({
selector: 'app-example',
standalone: true,
imports: [CommonModule],
template: `
API URL: {{ apiUrl }}
Log level: {{ logLevel }}
Feature A enabled: {{ featureAEnabled }}
`
})
export class ExampleComponent implements OnInit {
private propertiesService = inject(PropertiesService);
apiUrl!: string;
logLevel!: string;
featureAEnabled!: boolean;
ngOnInit() {
try {
this.apiUrl = this.propertiesService.getProperty<string>('apiUrl');
this.logLevel = this.propertiesService.getProperty<string>('logging.level');
this.featureAEnabled = this.propertiesService.getProperty<boolean>('features.enableFeatureA');
} catch (error) {
if (error instanceof PropertyNotFoundError) {
console.error('Missing required property:', error.message);
} else {
console.error('Error accessing properties:', error);
}
}
}
}
Key Design Decisions
1. Strict Property Access
Our library is designed to fail fast if a required property is missing. Instead of returning undefined or using default values that might mask configuration issues, we throw a specific PropertyNotFoundError
. This helps catch configuration problems early in development or deployment.
2. Support for Nested Properties
Configuration often has a hierarchical structure. Our library supports dot notation for accessing nested properties:
// Access nested properties with dot notation
const logLevel = propertiesService.getProperty<string>('logging.level');
const featureEnabled = propertiesService.getProperty<boolean>('features.enableFeatureA');
3. Application Initialization
We use provideAppInitializer
to ensure that properties are loaded before the application starts. This prevents components from accessing configuration before it's available.
4. TypeScript Type Safety
The library leverages TypeScript generics to provide type safety when accessing properties:
// Type-safe property access
const apiUrl = propertiesService.getProperty<string>('apiUrl');
const maxRetries = propertiesService.getProperty<number>('api.maxRetries');
const featureEnabled = propertiesService.getProperty<boolean>('features.newFeature');
Advanced Usage
Checking If a Property Exists
Sometimes you need to check if an optional property exists before using it:
if (propertiesService.hasProperty('features.experimentalFeature')) {
// Use the experimental feature
const experimentalConfig = propertiesService.getProperty<object>('features.experimentalFeature');
}
Getting All Properties
You might need to access all properties at once:
const allConfig = propertiesService.getAllProperties();
console.log('Current configuration:', allConfig);
Deployment Considerations
With this library, you can deploy the same build to different environments and simply change the properties.json
file. This simplifies your CI/CD pipeline and reduces the number of builds required.
For cloud deployments, you could:
- Include environment-specific
properties.json
files in your Docker images - Mount configuration files from environment-specific ConfigMaps in Kubernetes
- Use environment-specific S3 buckets or Azure Blob Storage to host configuration files
Conclusion
Our Properties Configuration Library provides a robust solution for runtime configuration management in Angular applications within Nx workspaces. By loading configuration at runtime, enforcing strict property access, and leveraging modern Angular patterns, we've created a flexible and maintainable approach to configuration management.
This library allows for:
- Changing configuration without rebuilding applications
- Catching missing configuration early
- Accessing nested properties with ease
- Type-safe property access
- Clean integration with Angular's initialization process
These features make it an excellent choice for enterprise Angular applications in Nx workspaces where configuration management is critical.