OSGi Configuration Architecture in AEM — Designing Runtime Configuration Without Hard-Coding
A practical developer and architect guide to OSGi configuration in AEM, covering configuration contracts, @ObjectClassDefinition, @Designate, activation, AEM as a Cloud Service configuration, service boundaries, and runtime configuration design.
OSGi Configuration Architecture in AEM — Designing Runtime Configuration Without Hard-Coding
An OSGi service often starts with a few values that look harmless enough to place directly in Java.
For example:
private static final String API_URL =
"https://api.example.com/products";
private static final int CONNECTION_TIMEOUT =
5000;
The implementation works until the same service needs a different endpoint, timeout, feature switch, or operational value in another environment.
Now the value is no longer part of the Java implementation.
It is runtime configuration.
That distinction matters because changing Java behavior and changing deployment configuration are different operations.
A service should not need a code change simply because the endpoint for one environment is different from another.
At the same time, moving every constant into OSGi configuration is not good design either.
The real question is:
Which values belong to the service's runtime contract, and which values are actually part of the implementation?
That is where OSGi configuration architecture starts.
What OSGi Configuration Is Solving
Consider an integration service:
@Component(service = ProductApiService.class)
public class ProductApiServiceImpl
implements ProductApiService {
private static final String API_URL =
"https://api.example.com/products";
private static final int TIMEOUT =
5000;
}
The service contains two different kinds of information.
The Java code describes how the integration works.
The URL and timeout describe how that integration should run in a particular deployment.
Those concerns should not necessarily have the same lifecycle.
The implementation may remain unchanged while configuration varies between environments.
For example:
Development
https://dev-api.example.com/products
Stage
https://stage-api.example.com/products
Production
https://api.example.com/products
The Java implementation should not need three environment branches to handle that difference.
Configuration Is Part of the Service Contract
When a service requires configuration, I treat those values as part of the service's runtime contract.
Suppose ProductApiService cannot operate without:
- API endpoint
- Connection timeout
- Read timeout
Then those values are not random deployment properties.
They describe what the service needs in order to function.
A configuration interface makes that dependency visible.
For example:
@ObjectClassDefinition(
name = "My Project - Product API Configuration"
)
public @interface ProductApiConfiguration {
@AttributeDefinition(
name = "API URL"
)
String apiUrl();
@AttributeDefinition(
name = "Connection Timeout"
)
int connectionTimeout() default 5000;
@AttributeDefinition(
name = "Read Timeout"
)
int readTimeout() default 10000;
}
Now the configuration contract is explicit.
Someone reading the service can see which runtime values it depends on instead of discovering hidden constants throughout the implementation.
@ObjectClassDefinition Defines the Configuration Shape
The configuration interface describes the properties available to the component.
For example:
@ObjectClassDefinition(
name = "My Project - Product API Configuration",
description = "Runtime configuration for the product API integration"
)
public @interface ProductApiConfiguration {
@AttributeDefinition(
name = "API URL",
description = "Base URL used by the product API client"
)
String apiUrl();
@AttributeDefinition(
name = "Connection Timeout"
)
int connectionTimeout() default 5000;
@AttributeDefinition(
name = "Read Timeout"
)
int readTimeout() default 10000;
}
The interface gives the configuration a typed shape.
Instead of repeatedly retrieving raw string properties and converting them inside business logic, the component receives values through a configuration contract that reflects their intended types.
That makes configuration easier to understand and reduces parsing logic inside the service.
@Designate Connects the Component to Its Configuration
The service then declares which configuration type belongs to it.
For example:
@Component(service = ProductApiService.class)
@Designate(ocd = ProductApiConfiguration.class)
public class ProductApiServiceImpl
implements ProductApiService {
}
@ObjectClassDefinition describes the configuration.
@Designate associates that configuration with the component.
The next question is when those values become available to the implementation.
Reading Configuration During Activation
Declarative Services can provide the typed configuration during component activation.
For example:
@Component(service = ProductApiService.class)
@Designate(ocd = ProductApiConfiguration.class)
public class ProductApiServiceImpl
implements ProductApiService {
private String apiUrl;
private int connectionTimeout;
private int readTimeout;
@Activate
protected void activate(
ProductApiConfiguration config) {
this.apiUrl =
config.apiUrl();
this.connectionTimeout =
config.connectionTimeout();
this.readTimeout =
config.readTimeout();
}
}
At this point, the service has a clear initialization boundary.
The configuration values are read when the component is activated and stored in the form the implementation needs.
The service method does not need to repeatedly look up OSGi configuration while processing each request.
Configuration Updates and @Modified
Some runtime configuration may change while the component is already active.
If the component supports configuration updates, the update path should be explicit.
For example:
@Activate
@Modified
protected void activate(
ProductApiConfiguration config) {
this.apiUrl =
config.apiUrl();
this.connectionTimeout =
config.connectionTimeout();
this.readTimeout =
config.readTimeout();
}
Using the same method for activation and modification can work well when both operations apply the configuration in the same way.
The important part is not the annotation combination itself.
The implementation needs to remain valid when configuration changes.
If configuration values are stored in service fields and the component can be called concurrently, the update strategy needs to avoid exposing partially updated state.
We will return to that when we discuss runtime configuration design.
Defaults Are Part of the Design
A default value can make configuration easier to operate.
For example:
int connectionTimeout() default 5000;
means the service has a valid timeout even when the deployment does not explicitly configure one.
But defaults should represent safe, meaningful behavior.
This is more questionable:
String apiUrl()
default "https://api.example.com";
if using the production endpoint by default would be dangerous in development or testing.
A required value should not receive a convenient default merely to avoid configuration work.
For each property, I ask:
Can the service operate safely when this property is not explicitly configured?
If yes, a default may be useful.
If no, the service should treat the value as required and validate it.
Validate Configuration at the Boundary
Suppose the service requires a non-empty API endpoint.
I would rather discover a broken configuration when the component initializes than much later when a request reaches the integration.
For example:
@Activate
@Modified
protected void activate(
ProductApiConfiguration config) {
String configuredApiUrl =
config.apiUrl();
if (configuredApiUrl == null
|| configuredApiUrl.trim().isEmpty()) {
throw new IllegalArgumentException(
"Product API URL must be configured"
);
}
this.apiUrl = configuredApiUrl;
this.connectionTimeout =
config.connectionTimeout();
this.readTimeout =
config.readTimeout();
}
The same applies to invalid numeric values.
If a timeout must be positive, validate that requirement where the configuration enters the service.
Business methods should not repeatedly rediscover invalid deployment configuration.
Do Not Turn OSGi Configuration Into a Dumping Ground
Once a team starts using OSGi configuration, it is easy to move every value into it.
That creates a different problem.
For example, these may legitimately be configurable:
- External endpoint
- Timeout
- Retry limit
- Feature switch
- Operational batch size
But values such as these may simply be implementation constants:
- Internal helper names
- Fixed property names owned by the code
- Algorithm-specific constants that should change only with the implementation
- Repository structure that is intentionally part of the application model
A value should not become configurable simply because it is technically possible to expose it.
Every configurable property becomes part of the runtime surface that developers and operators need to understand and maintain.
Configuration Should Belong to the Component That Owns the Behavior
Suppose ProductApiService owns communication with the product API.
Then configuration such as:
API URL
connection timeout
read timeout
retry count
belongs naturally with that integration boundary.
It would be harder to reason about if unrelated components each read pieces of the same low-level configuration independently.
A useful rule is:
The component that owns the behavior should usually own the configuration required for that behavior.
Other services should depend on the capability rather than knowing how its runtime configuration is assembled.
Avoid Environment Checks in Business Logic
This is a warning sign:
if ("prod".equals(environment)) {
apiUrl = PROD_URL;
} else if ("stage".equals(environment)) {
apiUrl = STAGE_URL;
} else {
apiUrl = DEV_URL;
}
The service now knows about deployment topology.
Adding another environment requires changing Java logic.
The implementation should usually consume the endpoint it has been configured to use:
client.get(apiUrl);
Environment-specific deployment configuration decides the value.
That keeps environment differences outside the business path.
Environment-Specific Configuration in AEM
The same Java bundle can run in local development, stage, and production while requiring different runtime values.
In AEM as a Cloud Service, start with normal inline OSGi configuration stored in Git when possible. Supported run-mode folders can target Author/Publish and environment types.
For values that genuinely need external environment-specific substitution, custom OSGi configuration can use an environment placeholder such as:
{
"url": "$[env:PRODUCT_API_URL]"
}
Secrets use the secret placeholder mechanism instead of being stored in Git:
{
"api.key": "$[secret:PRODUCT_API_KEY]"
}
The service still consumes the resolved configuration value. It should
not contain Java branches for dev, stage, or prod.
Run Modes and Configuration Selection
In AEM as a Cloud Service, OSGi configuration is deployed as .cfg.json
files and can be scoped with the supported service and environment run
modes, such as author, publish, dev, stage, and prod.
AEM as a Cloud Service does not support arbitrary custom run modes. If several configurations for the same PID match, the configuration with the most matching run modes is selected for that PID.
That last point matters because configuration is not merged property by property across matching folders. A more specific configuration for the same PID needs to contain the effective property set required for that runtime.
The responsibility remains clear:
- Java defines the behavior.
- OSGi configuration defines runtime values.
- Supported run-mode folders select configuration for a service/environment combination.
- Environment and secret placeholders handle values that should not simply be hard-coded into Git.
Do Not Put Environment Names Into the Service API
A service method such as:
productApiService.fetchProducts();
has a clean business responsibility.
This version exposes deployment concerns:
productApiService.fetchProducts("prod");
Now callers need to understand environment selection.
That responsibility does not belong in the service API.
The component should already have been configured for the environment in which it is running.
Callers should use the capability, not select its deployment configuration.
Author and Publish May Need Different Configuration
Environment is not the only configuration dimension.
The same service may behave differently on Author and Publish because those runtimes have different responsibilities.
For example, an Author-side integration might need configuration for content synchronization or an authoring support API.
A Publish-side service might need a read-oriented external endpoint or a different operational timeout.
That does not mean every component needs separate Author and Publish configuration.
It means instance role is part of configuration design when the runtime responsibility actually differs.
The Java code should still avoid checks such as:
if (isAuthor()) {
// one configuration
} else {
// another configuration
}
when the difference can be represented through deployment configuration.
Keep Secrets Out of Ordinary Configuration Values
An endpoint or timeout can be an ordinary OSGi value. Passwords, private API keys, and similar secrets must not be committed to Git as plain configuration.
For AEM as a Cloud Service, secret values can be supplied through Cloud Manager and referenced from custom OSGi configuration with the secret placeholder syntax:
{
"api.key": "$[secret:PRODUCT_API_KEY]"
}
The service can consume the resolved property like other configuration, but source control contains the reference rather than the secret value itself.
Troubleshooting should confirm that the secret is available without logging its resolved value.
Configuration PID and Component Identity
OSGi Configuration Admin associates configuration with a PID. For a Declarative Services component, the component PID defaults to the component name, which normally defaults to the implementation class's fully qualified name unless the component metadata specifies otherwise.
@Designate(ocd = ProductApiConfiguration.class) associates the
component PID with the Object Class Definition used for its typed
configuration metadata.
From an operational perspective, the PID matters because the deployed
.cfg.json file must target the identity expected by the component.
A file can contain perfectly valid property names and values but still have no effect on the intended service if it targets the wrong PID.
Configuration File Names Are Part of Deployment Wiring
In AEM as a Cloud Service projects, custom OSGi configuration is
normally delivered from the project's ui.config package as .cfg.json
files under the appropriate configuration folders.
A file such as:
com.myproject.core.impl.ProductApiServiceImpl.cfg.json
targets that PID when the component uses its implementation class name as the default component PID.
This is deployment wiring, not just file naming. A typo or a PID that no longer matches the component can leave the intended service using different configuration or, when configuration is required, prevent it from becoming available.
Required Configuration vs Optional Configuration
Not every component should activate successfully with missing configuration.
Suppose an integration cannot work without an endpoint.
This definition:
String apiUrl();
expresses a required configuration property more clearly than providing a production-looking fallback.
The component can then validate the value during activation.
On the other hand:
int connectionTimeout() default 5000;
can be reasonable because the service has a safe operational default.
The difference is intentional.
A required value says:
The deployment must make this decision.
A default says:
The service already has a safe behavior unless the deployment chooses to override it.
Configuration Policy
Some components should exist only when configuration is present.
Others can operate correctly using defaults.
That distinction can be represented through component configuration policy.
For example:
@Component(
service = ProductApiService.class,
configurationPolicy =
ConfigurationPolicy.REQUIRE
)
@Designate(
ocd = ProductApiConfiguration.class
)
public class ProductApiServiceImpl
implements ProductApiService {
}
Here the component requires corresponding configuration before it can become active.
That can be useful for integrations where silently running with missing configuration would be misleading or unsafe.
It also changes how production failures appear.
Instead of a service becoming active and failing only when a method is called, the component may remain unavailable because its required configuration is missing.
That makes component state part of configuration troubleshooting.
Optional Configuration Needs Safe Defaults
If configuration is optional, the service needs a valid behavior when no explicit configuration is supplied.
For example:
@ObjectClassDefinition(
name = "My Project - Cache Configuration"
)
public @interface CacheConfiguration {
@AttributeDefinition(
name = "Cache Enabled"
)
boolean enabled() default true;
@AttributeDefinition(
name = "Maximum Entries"
)
int maxEntries() default 500;
}
Those defaults define the component's behavior when deployment-specific values are absent.
The defaults should therefore be reviewed with the same care as explicit configuration.
A default is still production behavior.
Factory Configuration — When One Component Needs Multiple Configured Instances
A single configuration works when one component instance represents one capability.
Sometimes the same implementation needs multiple independently configured instances.
For example, imagine one generic external-feed service that must connect to:
Product Feed
Inventory Feed
Pricing Feed
Each instance may need its own:
- Endpoint
- Timeout
- Feed identifier
- Enablement state
Hard-coding three components would duplicate implementation.
Putting all three feed configurations into one large configuration object would make the service responsible for manually managing multiple logical instances.
Factory configuration provides another model: multiple configured component instances from the same implementation.
A configuration definition can be marked as a factory:
@ObjectClassDefinition(
name = "My Project - External Feed"
)
public @interface ExternalFeedConfiguration {
@AttributeDefinition(
name = "Feed Name"
)
String feedName();
@AttributeDefinition(
name = "Endpoint"
)
String endpoint();
@AttributeDefinition(
name = "Enabled"
)
boolean enabled() default true;
}
The component can then use:
@Designate(
ocd = ExternalFeedConfiguration.class,
factory = true
)
Each configuration instance represents one configured instance of the capability.
Do Not Use Factory Configuration Just Because There Are Multiple Values
Factory configuration is useful when the application genuinely needs multiple independent instances of the same component.
It is not a replacement for an array property.
For example, if one service simply needs a list of allowed domains:
String[] allowedDomains();
that does not automatically justify creating one factory component per domain.
The decision depends on whether each configured item has its own lifecycle and behavior.
A feed integration with its own endpoint, state, and runtime instance is different from one service reading a list of values.
Configuration Should Not Become Business Content
Another boundary appears when teams start using OSGi configuration for data that business users need to manage.
Suppose marketing users need to maintain:
- Product categories
- Campaign mappings
- Regional labels
- Frequently changing business rules
Those values may not belong in OSGi configuration.
OSGi configuration is well suited to application and operational settings.
Author-managed business content has a different lifecycle, ownership model, and governance requirement.
A useful question is:
Who owns this value and how should it change?
If a deployment engineer changes it as part of application operation, OSGi configuration may be appropriate.
If a content author or business user owns it, the value probably belongs in an authorable content model instead.
Configuration Is Not a Substitute for Feature Modeling
Consider:
String[] productCategories();
Technically, OSGi can hold the values.
But if product categories are business data that changes regularly and drives authored experiences, putting them into OSGi configuration creates an operational dependency for a content change.
That is usually the wrong lifecycle.
The configuration system should configure the application.
It should not quietly become a content repository.
Keep Configuration Close to the Owning Service
Suppose an application has:
ProductApiService
InventoryApiService
PricingApiService
It may be tempting to create one large configuration:
CommerceIntegrationConfiguration
containing every endpoint, timeout, retry setting, and feature flag.
That centralizes the file but weakens ownership.
Now a change to inventory configuration shares a configuration contract with pricing and product behavior.
A cleaner design is usually for each integration boundary to own the configuration it requires.
For example:
ProductApiConfiguration
InventoryApiConfiguration
PricingApiConfiguration
Shared configuration should exist only when the value is genuinely shared as part of the architecture.
Avoid a Global Configuration Service
Another common pattern is a generic service such as:
configurationService.get("product.api.url");
used throughout the application.
It looks reusable.
But callers now depend on configuration keys rather than typed service contracts.
The product integration knows the key.
The inventory integration knows another key.
Business services start retrieving raw configuration directly.
The configuration boundary becomes spread across the application.
Typed component-owned configuration is usually easier to reason about:
ProductApiService
owns:
ProductApiConfiguration
and callers simply use the service.
Runtime Updates Need a Consistent State
If @Modified can replace configuration while the service is being
used, values that belong together should be published as one consistent
runtime state.
An immutable configuration snapshot referenced through a volatile
field is one practical approach. Build and validate the new snapshot
first, then replace the reference as one unit. The complete service
example below uses that pattern.
Do Not Perform Expensive Work Every Time a Method Needs Configuration
If configuration can be processed once during activation, do that work at the boundary.
For example, instead of parsing a configured URI on every request:
URI uri =
URI.create(configuredUrl);
inside every service call, the component can validate and prepare it during activation:
@Activate
@Modified
protected void activate(
ProductApiConfiguration config) {
this.apiUri =
URI.create(
config.apiUrl()
);
}
Now invalid configuration fails earlier and repeated service calls use already prepared runtime state.
This keeps configuration parsing out of the business path.
Where Configuration Ends and Service Logic Begins
A configuration value should influence behavior without becoming the behavior itself.
For example:
int retryCount();
can configure how many times an integration retries.
The retry algorithm still belongs in Java.
Similarly:
boolean enabled();
may control whether an integration is active.
The business logic that runs when enabled remains implementation code.
This boundary keeps OSGi configuration understandable.
If configuration begins encoding workflows, branching rules, or large amounts of business logic, the application is moving behavior out of code without gaining a proper domain model.
A Complete Configured Service
Putting the pieces together, a service might look like this:
@Component(
service = ProductApiService.class,
configurationPolicy =
ConfigurationPolicy.REQUIRE
)
@Designate(
ocd = ProductApiConfiguration.class
)
public class ProductApiServiceImpl
implements ProductApiService {
private volatile RuntimeConfig runtimeConfig;
@Activate
@Modified
protected void activate(
ProductApiConfiguration config) {
String apiUrl =
config.apiUrl();
if (apiUrl == null
|| apiUrl.trim().isEmpty()) {
throw new IllegalArgumentException(
"Product API URL must be configured"
);
}
if (config.connectionTimeout() <= 0) {
throw new IllegalArgumentException(
"Connection timeout must be greater than zero"
);
}
if (config.readTimeout() <= 0) {
throw new IllegalArgumentException(
"Read timeout must be greater than zero"
);
}
this.runtimeConfig =
new RuntimeConfig(
apiUrl,
config.connectionTimeout(),
config.readTimeout()
);
}
@Override
public ProductResponse getProducts() {
RuntimeConfig config =
this.runtimeConfig;
return productClient.getProducts(
config.getApiUrl(),
config.getConnectionTimeout(),
config.getReadTimeout()
);
}
}
The configuration interface defines what the service needs.
Activation validates and converts those values into runtime state.
The service method works with that prepared state.
Environment selection stays outside the Java implementation.
The next part of the chapter will focus on what happens when this wiring is wrong in production: missing configuration, incorrect PID targeting, inactive components, unexpected defaults, and configuration differences between environments.
Production Configuration Failures and Troubleshooting
Configuration failures are often confusing because the Java code may be completely correct.
A service can compile, deploy, and still fail because the runtime configuration is missing, targeting the wrong component, using an unexpected default, or differing between environments.
I troubleshoot these problems from the component state outward instead of immediately changing the implementation.
Missing Required Configuration
Consider a component declared with:
@Component(
service = ProductApiService.class,
configurationPolicy =
ConfigurationPolicy.REQUIRE
)
@Designate(
ocd = ProductApiConfiguration.class
)
public class ProductApiServiceImpl
implements ProductApiService {
}
If the required configuration is not available, the component should not be treated as a normal active service.
That failure may first appear somewhere else.
For example, another component may depend on:
@Reference
private ProductApiService productApiService;
and now that dependent component cannot become satisfied because
ProductApiService is unavailable.
The visible failure is in the consumer.
The root cause may be missing configuration in the provider.
This is why configuration troubleshooting and Declarative Services troubleshooting often meet at the same place.
The Configuration Exists but Targets the Wrong Identity
A configuration file can be present in the deployment and still not configure the component you expect.
When that happens, I verify the configuration identity before checking individual property values.
The questions are:
- Which component or configuration PID is this file targeting?
- Is that the identity expected by the deployed component?
- Is the configuration being selected for this runtime?
- Is another configuration instance taking precedence?
A correctly spelled apiUrl property is irrelevant if the configuration
is attached to the wrong PID.
Unexpected Defaults Can Hide Missing Configuration
Defaults are useful, but they can also hide deployment mistakes.
Suppose:
int connectionTimeout() default 5000;
The service can safely use 5000 when no explicit timeout is provided.
That is fine if the default is intentional.
Now consider a deployment where production was expected to use:
15000
but the environment-specific configuration was never applied.
The component still works.
It silently uses:
5000
This kind of problem is harder to detect than a component that refuses to activate.
For operationally important values, teams need to know whether using a default is acceptable or whether the environment must explicitly configure the property.
Local Works, Cloud Fails
This is one of the most common configuration patterns I investigate.
The service works locally.
The same code fails after deployment.
Before changing Java, compare the runtime configuration.
Local development instances often contain values that were:
- Entered manually
- Left over from previous testing
- Installed by an older package
- Added through local-only configuration
If those values are not represented in the deployable project configuration, the cloud environment will not reproduce the same behavior.
A working local instance proves that the code can work with the local state.
It does not prove that the application's configuration is fully deployable.
One Environment Uses a Different Property Value
Not every environment difference is a defect.
Development, stage, and production may intentionally use different:
- API endpoints
- Timeouts
- Feature switches
- Batch sizes
- Integration identifiers
The problem starts when nobody can tell whether the difference is intentional.
For configuration that materially changes behavior, the environment-specific value should be traceable to deployment configuration rather than remembered as a manual change.
That makes environment comparison much easier during incidents.
@Modified Is Not a Replacement for Validation
A component that supports runtime updates still needs to validate every new configuration.
For example:
@Activate
@Modified
protected void activate(
ProductApiConfiguration config) {
validate(config);
this.runtimeConfig =
createRuntimeConfig(config);
}
Validation should run for both initial activation and later modification.
Otherwise the service may start with valid configuration and later accept an invalid runtime update.
The configuration boundary remains the same regardless of when the value arrives.
A Troubleshooting Sequence I Use
When an OSGi-configured service behaves differently from what I expect, I check the problem in this order.
1. Is the component active?
If not, inspect why the component is unsatisfied or failed to activate.
Do not start by debugging a service method that cannot run.
2. Is required configuration present?
For a component using:
configurationPolicy =
ConfigurationPolicy.REQUIRE
missing configuration can prevent activation.
3. Does the configuration target the expected component or PID?
A deployed file is not enough.
It must target the correct configuration identity.
4. Is the expected configuration selected for this runtime?
Check the environment and instance role.
The right file in the wrong configuration scope does not solve the problem.
5. Are the property names and values correct?
Now inspect:
- Endpoint
- Timeout
- Boolean switches
- Numeric ranges
- Required strings
6. Is the service using defaults?
A default may explain why the component is active even though the expected environment value is missing.
7. Did activation or modification reject the value?
Validation code may prevent the new configuration from being applied.
8. Does another component depend on this service?
The first visible failure may be in a consumer whose required OSGi reference cannot be satisfied.
Following this sequence keeps the investigation focused on the runtime wiring before changing Java logic.
Common Configuration Design Problems
Some configuration problems are not deployment failures. They are design problems that make the application harder to operate.
Problem Why it becomes difficult
Hard-coded environment endpoint Requires code changes for deployment differences
One giant application Weak ownership and unrelated configuration settings change together
Generic configuration lookup Spreads raw keys and configuration service knowledge across callers
Production-looking default for a Can hide missing environment required endpoint configuration
Business content stored as OSGi Gives author-managed data a properties deployment lifecycle
Secrets stored like normal Mixes ordinary configuration with source-controlled values sensitive data
Broad factory configuration used Adds unnecessary for simple lists component-instance lifecycle
Expensive parsing inside every Repeats work that belongs at service method activation
Several mutable fields updated Can expose inconsistent runtime independently state during modification
These problems usually become visible later, when the application has more environments, more integrations, or more people operating it.
When Configuration Should Be Shared
Shared configuration is reasonable when several components genuinely depend on one architectural concept with one owner, such as a common integration gateway.
Do not share a configuration contract merely because multiple services currently happen to use the same value. If those services can evolve independently, component-owned configuration keeps that independence visible.
Configuration Changes Can Affect Availability
Changing configuration is not always a harmless property update.
A component may:
- Re-run activation logic
- Rebuild a client
- Replace runtime state
- Temporarily lose a dependency
- Reject invalid configuration
For critical services, configuration changes should therefore be treated as operational changes.
The component's activation and modification behavior should be understood before assuming a value can be changed without impact.
This is another reason to keep activation logic predictable and configuration validation explicit.
Architect Perspective — Configuration Is an Operational API
Java interfaces define how other code uses a service.
OSGi configuration defines how the runtime operates that service.
That makes configuration an operational API.
Once a property is deployed and used across environments, changing its meaning can affect operations even if the Java interface stays exactly the same.
For each configured component, I want the design to answer:
- Which values are genuinely environment-specific?
- Which values are required?
- Which defaults are safe?
- Which component owns the configuration?
- Can the configuration change at runtime?
- What happens when it changes?
- Does the component need one instance or factory instances?
- Are any values sensitive?
- Is the configuration reproducible through deployment?
- Can an operator understand a failure without reading the entire implementation?
That is a stronger design than simply adding annotations until values appear in an OSGi console.
A Practical Review Example
Suppose we find this service:
@Component(service = CustomerApiService.class)
public class CustomerApiServiceImpl
implements CustomerApiService {
private static final String API_URL =
"https://customer-api.example.com";
private static final int TIMEOUT =
5000;
@Override
public Customer getCustomer(
String customerId) {
return client.getCustomer(
API_URL,
customerId,
TIMEOUT
);
}
}
The first question is not:
How do we convert every constant into OSGi configuration?
Instead, review each value.
API URL
The endpoint can differ between environments.
That is a strong configuration candidate.
Timeout
The timeout may need operational tuning without changing the integration implementation.
That is also a reasonable configuration candidate, with a safe default if the service can define one.
Customer ID
The customer ID is request/business data.
It does not belong in the service's OSGi configuration.
Client Behavior
How the client builds the request, handles the response, and maps the customer remains Java behavior.
It should not be pushed into configuration simply to make the service appear flexible.
A possible configuration contract becomes:
@ObjectClassDefinition(
name = "My Project - Customer API Configuration"
)
public @interface CustomerApiConfiguration {
@AttributeDefinition(
name = "API URL"
)
String apiUrl();
@AttributeDefinition(
name = "Timeout"
)
int timeout() default 5000;
}
That is enough.
The configuration describes the runtime decisions.
The Java implementation still owns the integration behavior.
Summary
OSGi configuration separates runtime decisions from Java implementation.
@ObjectClassDefinition gives the configuration a typed contract,
@Designate associates it with the component, and
activation/modification establish the service's runtime state.
ConfigurationPolicy.REQUIRE is useful when a component must not become
available without configuration, while factory configuration fits cases
that genuinely require multiple independently configured component
instances.
In AEM as a Cloud Service, deployment details matter as much as the
annotations: .cfg.json files target PIDs, supported run modes select
the applicable configuration, and environment/secret placeholders cover
values that should be supplied outside ordinary inline configuration.
The design question remains simple: configure the application behavior that needs an operational lifecycle; do not turn OSGi configuration into business content or a dumping ground for arbitrary constants.
What's Next
Chapter 29 — Sling Jobs vs Schedulers
The next chapter moves into background processing.
We will separate two mechanisms that are often treated as interchangeable even though they solve different problems: scheduled execution and queued asynchronous work.
Enjoyed this chapter?
Get an email when I publish the next chapter. No spam — just new technical deep-dives.
Comments
Share feedback or questions about this blog post.
No comments yet. Be the first to share your thoughts.