This document covers advanced customization and architecture details for Otto Config.
flowchart TD
A[📱 <b>Your App</b>] --> B[⚙️ <b>Otto Config</b>]
B --> C[☁️ <b>AWS AppConfig</b>]
B --> D[🔐 <b>AWS Secrets Manager</b>]
B --> E[📝 <b>AWS Parameter Store</b>]
B --> F[📝 <b>Hashicorp Vault</b>]
B --> G[📄 <b>Local Files</b>]
classDef appStyle fill:#e3f2fd,stroke:#1976d2,stroke-width:2px,color:#000
classDef ottoConfigStyle fill:#e8f5e8,stroke:#388e3c,stroke-width:2px,color:#000
classDef awsStyle fill:#ffebee,stroke:#d32f2f,stroke-width:2px,color:#000
class A appStyle
class B ottoConfigStyle
class C,D,E,F,G awsStyle
flowchart TD
%% Applications
subgraph A[📱 Applications]
A1[🌱 <b>Spring Boot App</b>] -->
A2[☀️ <b>Helidon App</b>]
end
%% Framework Integration
subgraph B[🔗 Framework Integration]
B1[<b>SpringPropertySource</b><br/><small>Integrates Otto Config with Spring's config system</small>] -->
B2[<b>SpringSchedulerConfiguration</b><br/><small>Triggers periodic config refresh in Spring</small>] -->
B3[<b>HelidonPropertySource</b><br/><small>Integrates Otto Config with Helidon's config system</small>] -->
B4[<b>HelidonSchedulerConfiguration</b><br/><small>Triggers periodic config refresh in Helidon</small>]
end
%% Core Components
subgraph C[⚙️ Core Components & Services]
direction TB
C1[<b>ConfigurationProvider</b><br/><small>Configuration provider for properties with caching</small>]
C3[<b>Context</b><br/><small>Framework-agnostic configuration</small>]
C4[<b>ConfigurationCache</b><br/><small>Cache for all combined and normalized values</small>]
C5[<b>SourceAggregator</b><br/><small>Combines and normalizes source data</small>]
C6[<b>ClientRegistry</b><br/><small>Registry for re-usable clients</small>]
C7[<b>SourceRegistry</b><br/><small>Registry for sources</small>]
C8[<b>ProviderRegistry</b><br/><small>Registry for providers</small>]
end
%% Sources
subgraph D[📡 Sources]
D1[<b>AppConfigSource</b><br/><small>AWS AppConfig</small>] --> D2[<b>SecretsManagerSource</b><br/><small>AWS Secrets Manager</small>] --> D3[<b>SsmSource</b><br/><small>AWS Parameter Store</small>] --> D4[<b>VaultSource</b><br/><small>Hashicorp Vault</small>] --> D5[<b>FileSource</b><br/><small>Local JSON properties.json file</small>]
end
%% Internal connections (simplified)
A -.-> B
B -.-> C
C -.-> D
C1 -.-> C3
C1 -.-> C4
C1 -.-> C5
C3 -.-> C6
C3 -.-> C7
C3 -.-> C8
%% Styling
classDef subgraphStyle fill:#f9f9f9,stroke:#333,stroke-width:2px,color:#000
classDef appStyle fill:#e3f2fd,stroke:#1976d2,stroke-width:2px,color:#000
classDef frameworkStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#000
classDef coreStyle fill:#e8f5e8,stroke:#388e3c,stroke-width:2px,color:#000
classDef sourceStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px,color:#000
classDef externalStyle fill:#ffebee,stroke:#d32f2f,stroke-width:2px,color:#000
class A1,A2 appStyle
class B1,B2,B3,B4 frameworkStyle
class C1,C3,C4,C5,C6,C7,C8,C8 coreStyle
class D1,D2,D3,D4,D5 sourceStyle
%% Style invisible links
linkStyle 0 stroke:transparent,stroke-width:0px
linkStyle 1 stroke:transparent,stroke-width:0px
linkStyle 2 stroke:transparent,stroke-width:0px
linkStyle 3 stroke:transparent,stroke-width:0px
linkStyle 4 stroke:transparent,stroke-width:0px
linkStyle 5 stroke:transparent,stroke-width:0px
linkStyle 6 stroke:transparent,stroke-width:0px
flowchart LR
A[👤 <b>User Request:</b><br/>configurationProvider.getValue] --> B[🔧 <b>ConfigurationProvider</b><br/>delegates to ConfigurationCache]
B --> C[📊 <b>ConfigurationCache</b><br/>looks up cached values]
C --> I[✅ <b>Return cached value</b>]
D[🚀 <b>Service Startup</b>] --> E[📋 <b>ConfigurationProvider</b><br/>loads from sources]
E --> F[🏭 <b>SourceRegistry</b><br/>Load from AWS/Local]
F --> G[⚙️ <b>SourceAggregator</b><br/>combine + normalize]
G --> H[💾 <b>Cache values in</b><br/>ConfigurationCache]
J[⏰ <b>Every 5 minutes</b><br/>Auto refresh] -.-> K[🔄 <b>ConfigurationProvider</b><br/>refresh method]
K -.-> F
F -.-> G
H -.-> C
style A fill:#e3f2fd,stroke:#1976d2,stroke-width:2px,color:#000
style B fill:#e8f5e8,stroke:#388e3c,stroke-width:2px,color:#000
style C fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#000
style D fill:#e1f5fe,stroke:#0277bd,stroke-width:2px,color:#000
style E fill:#e8f5e8,stroke:#388e3c,stroke-width:2px,color:#000
style F fill:#fff8e1,stroke:#f57c00,stroke-width:2px,color:#000
style G fill:#ffebee,stroke:#d32f2f,stroke-width:2px,color:#000
style H fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#000
style I fill:#e8f5e8,stroke:#388e3c,stroke-width:2px,color:#000
style J fill:#e1f5fe,stroke:#0277bd,stroke-width:2px,color:#000
style K fill:#e8f5e8,stroke:#388e3c,stroke-width:2px,color:#000
Configuration properties are resolved in this order (highest to lowest priority):
- System properties and environment variables
- Otto Config sources (AWS AppConfig, Secrets Manager, Parameter Store)
- Local application config (application.properties, application.yml)
This enables local overrides via environment variables for development and testing while maintaining production configurations.
Otto Config can be extended to support additional configuration sources beyond those provided out of the box. This section describes how to implement and register a custom source—such as one backed by DynamoDB—so that its configuration data is seamlessly integrated into Otto Config's unified configuration system.
Create a class that extends the PropertySource abstract class. For example, here's how you might create a custom source that loads properties from DynamoDB:
package com.mycompany.source;
import de.otto.config.domain.Properties;
import de.otto.config.source.PropertySource;
@Slf4j
@Builder
public class DynamoDbSource extends PropertySource {
private final DynamoDbClient dynamoDbClient;
private final String tableName;
private final String environment;
@Override
public Properties load() throws SourceException {
// Load configuration from DynamoDB
Map<String, String> properties = new HashMap<>();
try {
// ... implementation details
return new Properties(properties);
} catch (Exception e) {
throw new SourceException("Unable to load DynamoDB data.", e);
}
}
}You can register your custom source in two ways:
-
Implement a Factory Class
Create a class that implements
SourceFactoryand provides a static factory method annotated with@SourceCreator. The annotation value is the unique name for your source (e.g.,"dynamodb"):package com.mycompany.config; import de.otto.config.core.Context; import de.otto.config.source.SourceFactory; @Slf4j public class MySourceFactory implements SourceFactory { @SourceCreator("aws.dynamodb") public static Source<Properties> createDynamoDbSource(Context context) { return DynamoDbSource.builder() .dynamoDbClient(DynamoDbClient.builder().build()) .tableName("table_name") .environment(context.getApplicationConfiguration().getValue("environment")) .build(); } }
Tip: To share a client instance (like
DynamoDbClient) across sources, use the context's client registryregisterIfAbsentmethod:DynamoDbClient dynamoDbClient = context.getClientRegistry().registerIfAbsent(DynamoDbClient.class, () -> DynamoDbClient.builder().build());
Tip: To make your source more flexible for local development and testing, consider checking for a local profile and falling back to a file source (such as
properties.json) when appropriate using theCoreSourceFactoryclass:if (CoreSourceFactory.isLocalProfile(context.getProfile())) { return CoreSourceFactory.createFileSource(context); }
-
Register the Factory with SPI
Create a file at:
resources/META-INF/services/source.core.de.otto.config.SourceFactoryAdd your factory class's fully qualified name:
com.mycompany.config.MySourceFactory -
Enable Your Source in Configuration
Add your source's name to the
otto.config.sources.enabledproperty:otto.config.sources.enabled=aws.appconfig.properties,aws.appconfig.toggles,aws.secrets,aws.ssm,aws.dynamodb
If your source needs runtime parameters or should only be added conditionally, register it dynamically:
// Example: Registering a custom source at runtime
import de.otto.config.core.Context;
import de.otto.config.provider.ConfigurationProvider;
// Inject Context for use in building your source
@Autowired // or @Inject
private Context context;
// Inject ConfigurationProvider
@Autowired // or @Inject
private ConfigurationProvider configurationProvider;
// Register the source with ConfigurationProvider
DynamoDbSource dynamoDbSource = DynamoDbSource.builder()
.dynamoDbClient(DynamoDbClient.builder().build())
.tableName("my-table")
.environment(context.getApplicationConfiguration().getValue("environment"))
.build();
configurationProvider.addSource(dynamoDbSource);With either approach, Otto Config will merge your custom source's data into the unified configuration, making it available alongside all other sources.
In some cases, you may want to implement your own Provider<T> to handle custom data types or specialized configuration needs within your application or service. For instance, you might need to load configuration for internal analytics, auditing, or integration with external systems.
To achieve this, define your own custom type (e.g., MyCustomType) and create a new source for it. Instead of implementing PropertySource, use the generic Source<T> interface with your custom type. For detailed steps on registering your source, refer to Register Your Source with Otto Config.
After defining your custom type (e.g., MyCustomType), implement a custom source that loads and provides instances of this type:
package com.mycompany.sources;
import de.otto.config.core.Context;
import de.otto.config.source.Source;
import com.mycompany.domain.MyCustomType;
import lombok.Builder;
import java.util.Map;
@Builder
public class MyCustomTypeSource implements Source<MyCustomType> {
private final Context context;
@Override
public Map<String, MyCustomType> load() throws SourceException {
// Load your custom data here (e.g., from a database, API, or file)
// Example:
Map<String, MyCustomType> data = Map.of(
"exampleKey", new MyCustomType(/* ... */)
);
return data;
}
}Then register the source with Otto Config as normal (see Register Your Source with Otto Config).
Implement your custom provider by extending the Provider<T> abstract class, parameterized with your custom type:
package com.mycompany.providers;
import java.util.Map;
import de.otto.config.core.Context;
import de.otto.config.provider.Provider;
import com.mycompany.domain.MyCustomType;
import lombok.Builder;
public class MyCustomProvider extends Provider<MyCustomType> {
@Builder
public MyCustomProvider(Context context) {
// Pass a transformer function and a list of supported types to the base Provider constructor.
// This enables your provider to handle values of MyCustomType and expose them via the unified API.
super(context, List.of(MyCustomType.class), MyCustomType.class::cast, false);
}
}To use your custom provider in a dependency injection framework like Spring or Helidon, define it as a bean and inject the Context into its constructor.
import com.mycompany.providers.MyCustomProvider;
import de.otto.config.core.Context;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MyCustomProviderConfig {
@Bean
public MyCustomProvider myCustomProvider(Context context) {
return MyCustomProvider.builder()
.context(context)
.build();
}
}import com.mycompany.providers.MyCustomProvider;
import de.otto.config.core.Context;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
@ApplicationScoped
public class MyCustomProviderProducer {
@Produces
public MyCustomProvider myCustomProvider(Context context) {
return MyCustomProvider.builder()
.context(context)
.build();
}
}You can now inject MyCustomProvider wherever needed in your application and use it to access your custom configuration values. For example, to retrieve a value of type MyCustomType under the exampleKey key:
MyCustomType value = myCustomProvider.getValue("exampleKey");This approach allows you to seamlessly access your custom configuration data throughout your application using your custom provider.