N
Naveenr.dev
Chapter 28
18 min read•2026-10-03

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:

java
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:

java
@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:

text
Development
https://dev-api.example.com/products
text
Stage
https://stage-api.example.com/products
text
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:

java
@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:

java
@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:

java
@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:

java
@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:

java
@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:

java
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:

java
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:

java
@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:

text
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:

java
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:

java
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:

json
{
  "url": "$[env:PRODUCT_API_URL]"
}

Secrets use the secret placeholder mechanism instead of being stored in Git:

json
{
  "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:

java
productApiService.fetchProducts();

has a clean business responsibility.

This version exposes deployment concerns:

java
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:

java
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:

json
{
  "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:

text
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:

java
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:

java
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:

java
@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:

java
@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:

text
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:

java
@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:

java
@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:

java
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:

java
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:

text
ProductApiService
InventoryApiService
PricingApiService

It may be tempting to create one large configuration:

text
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:

text
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:

java
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:

java
ProductApiService

owns:

java
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:

java
URI uri =
        URI.create(configuredUrl);

inside every service call, the component can validate and prepare it during activation:

java
@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:

java
int retryCount();

can configure how many times an integration retries.

The retry algorithm still belongs in Java.

Similarly:

java
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:

java
@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:

java
@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:

java
@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:

java
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:

text
15000

but the environment-specific configuration was never applied.

The component still works.

It silently uses:

text
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:

java
@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:

java
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:

java
@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:

java
@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.