OSGi Services in AEM — The Service Layer Behind AEM
Understand how OSGi services work inside AEM, how services are registered and consumed, and how the OSGi runtime manages dependencies between AEM backend components.
The Backend Problem We Usually Don't See
Most AEM development starts from something visible.
An author creates a page.
A component is added to the page.
A Sling Model reads the content.
The component renders through HTL.
That flow is easy to see.
The part that is less visible is what happens when the component needs to do something more than simply read a property.
Suppose a product component needs to calculate a price.
Another component needs the same pricing logic.
A servlet also needs to call the pricing logic.
Later, the pricing information starts coming from an external system.
If each component implements its own logic, the code quickly becomes difficult to maintain.
You might end up with something like:
Product Component
└── Pricing Logic
Cart Component
└── Pricing Logic
API Servlet
└── Pricing Logic
Search Component
└── Pricing Logic
The same business logic is now spread across different parts of the application.
A change in the pricing rules means finding every place where that logic was implemented.
A better design is to move the shared logic behind a service:
PricingService
│
┌────────────┼────────────┐
↓ ↓ ↓
Sling Model Servlet Another Service
Now the consumers don't need to know how the pricing is calculated.
They only need to know that a PricingService is available.
This is one of the places where OSGi becomes important in AEM.
Instead of backend components directly creating and managing each other's dependencies, the OSGi runtime can manage component lifecycles and service dependencies.
The Architecture We Are Trying to Build
Let's take a simple AEM application.
We have a product page with a product component.
The component needs information that isn't stored directly on the page.
For example:
Product Component
│
│ needs product information
↓
ProductService
│
├── reads AEM repository data
├── applies business rules
└── may call an external system
The Sling Model should not need to know how all of that works.
It should simply depend on the service.
At runtime, the Sling Model depends on the ProductService.
The ProductServiceImpl implementation is registered with the OSGi service registry. The OSGi runtime resolves the dependency and provides the available service to the consumer.
The important part of this diagram is not the annotations.
It is the relationship.
The Sling Model asks for a service.
The implementation is registered with the OSGi runtime.
OSGi connects the consumer to the available service.
The consumer doesn't have to create the implementation itself.
That separation is one of the foundations of AEM backend development.
What OSGi Is Doing Inside AEM
AEM is built on top of an OSGi runtime.
At a high level, the runtime manages backend modules and components that are loaded into AEM.
When the project bundle is deployed, OSGi doesn't simply treat every Java class as an available service.
The runtime evaluates the components declared by the application and manages them according to their OSGi metadata.
This is why understanding the difference between a Java class, an OSGi component, and an OSGi service is important.
They are related concepts, but they are not the same thing.
Java Class, OSGi Component, and OSGi Service Are Not the Same Thing
These three terms are often used together in AEM discussions, which can make OSGi harder to understand than it needs to be.
Start with a normal Java class:
public class ProductServiceImpl implements ProductService {
@Override
public Product getProduct(String sku) {
// business logic
return null;
}
}
At this point, Java knows about the class.
But AEM does not automatically know that this class should be created, managed, or made available to other components.
That's where OSGi metadata comes in.
We can turn the class into an OSGi component:
@Component
public class ProductServiceImpl implements ProductService {
@Override
public Product getProduct(String sku) {
// business logic
return null;
}
}
Now the OSGi runtime knows that this class represents a component that it can manage.
We can also register the component as a service:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
@Override
public Product getProduct(String sku) {
// business logic
return null;
}
}
The three levels can be thought of like this:
Java Class
↓
OSGi Component
↓
OSGi Service
But these are not simply three names for the same thing.
A Java class is a Java construct.
An OSGi component is something the OSGi runtime can manage.
An OSGi service is a capability that has been registered in the OSGi service registry and can be consumed by other components.
That distinction becomes important when we start looking at dependency injection.
The Service Interface Defines the Contract
Let's keep the example simple.
Suppose several parts of our AEM application need product information.
Instead of exposing the implementation directly, we define an interface:
public interface ProductService {
Product getProduct(String sku);
double getPrice(String sku, String country);
}
The interface defines what the service can do.
It doesn't tell the consumer how the work is performed.
The implementation can then provide that behavior:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
@Override
public Product getProduct(String sku) {
// repository or external system lookup
return null;
}
@Override
public double getPrice(String sku, String country) {
// pricing logic
return 0.0;
}
}
Now we have a clean separation:
ProductService
│
│ contract
▼
ProductServiceImpl
│
│ implementation
▼
Actual business logic
A consumer should normally depend on the interface rather than the implementation.
For example, a Sling Model should not need to know that the implementation class is called ProductServiceImpl.
It only needs to depend on:
ProductService
That gives us a useful boundary between the consumer and the implementation.
Why This Separation Helps in AEM
Let's say the product information initially comes from the AEM repository.
The service might contain:
ProductServiceImpl
│
└── Repository lookup
A few months later, the project moves product information to an external commerce platform.
The consumer should ideally not need to change.
Instead, the implementation can change:
Before
ProductService
│
▼
Repository implementation
and later:
After
ProductService
│
▼
Commerce API implementation
The consumer still depends on:
ProductService
This is one reason service boundaries are useful in AEM applications.
The component that needs the business capability doesn't need to own the details of where that data comes from.
The OSGi Service Registry
Now we reach the part that makes OSGi different from simply creating Java objects ourselves.
In a traditional Java application, one class can directly create another:
ProductServiceImpl service = new ProductServiceImpl();
In an AEM OSGi application, that is usually not what we want for an OSGi-managed service.
Instead, the implementation is registered with the OSGi service registry.
The registry acts as the runtime directory for available OSGi services.
A consumer doesn't need to know how the service object was created.
It asks the OSGi runtime for a service matching the dependency.
This is the part that often gets lost when OSGi is explained only through annotations.
The annotation is not the architecture.
The runtime relationship is the architecture.
Registering a Service
For a modern AEM project, a typical service implementation can look like this:
package com.mycompany.core.services.impl;
import com.mycompany.core.services.ProductService;
import org.osgi.service.component.annotations.Component;
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
@Override
public Product getProduct(String sku) {
// implementation
return null;
}
@Override
public double getPrice(String sku, String country) {
// implementation
return 0.0;
}
}
There are two things worth noticing here. First:
@Component
tells OSGi that the class is a component managed by the OSGi runtime.
Second:
service = ProductService.class
registers the component as an implementation of the ProductService service contract.
So conceptually:
ProductServiceImpl
│
│ implements
▼
ProductService
│
│ exposed as an OSGi service
▼
OSGi Service Registry
This is why consumers can depend on ProductService instead of directly depending on ProductServiceImpl.
Consuming the Service
Now let's return to our Sling Model.
The model needs the product service.
Instead of creating the service itself:
ProductServiceImpl service = new ProductServiceImpl();
the model declares that it needs a ProductService.
With Declarative Services, that dependency can be expressed using @Reference:
@Model(
adaptables = Resource.class
)
public class ProductModel {
@Reference
private ProductService productService;
public Product getProduct(String sku) {
return productService.getProduct(sku);
}
}
There is an important AEM-specific detail here.
A Sling Model is not automatically an OSGi component simply because it uses @Model.
So in real AEM code, the way a service is injected into a Sling Model depends on the model's adaptation context and the injection mechanism being used.
In AEM, service injection into a Sling Model depends on the injection mechanism being used.
For example, many AEM projects use Sling Models with OSGi service injection through the Sling Models injector:
@OSGiService
private ProductService productService;
The key idea remains the same:
Sling Model
│
│ depends on
▼
ProductService
│
│ provided by
▼
OSGi Runtime
The exact annotation should therefore be chosen based on the component type and injection mechanism being used, rather than assuming that @Reference can be placed on every AEM class.
That distinction is important in real projects because OSGi Declarative Services injection and Sling Model injection are related, but they are not the same mechanism.
What Happens When AEM Starts?
So far, we've looked at the service from the code side.
Now let's look at what happens at runtime.
Suppose we deploy this implementation:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
}
The class doesn't simply become an available service when the source code is compiled.
The application bundle has to be deployed and processed by the OSGi runtime.
This is one reason an OSGi service can feel different from a normal Java object.
The lifecycle is managed by the runtime.
Your code defines the component and its dependencies. OSGi decides when the component can be created, started, stopped, or recreated.
Component Lifecycle
A component can have a lifecycle.
At a simplified level:
Component Not Active
↓
Dependencies Satisfied
↓
Component Activated
↓
Component Active
↓
Dependency Becomes Unavailable
↓
Component Deactivated
This becomes important when a service depends on another service.
If PricingClient isn't available, the ProductService component may not be able to become active, depending on how the dependency is configured.
That means a production problem can sometimes look like:
Sling Model
↓
ProductService unavailable
↓
Why?
↓
ProductService dependency not satisfied
↓
Check referenced service
The problem may not be inside the method that the developer is debugging.
The component may never have become active in the first place.
Activation and Deactivation
OSGi Declarative Services provides lifecycle methods that can be used when a component needs initialization or cleanup.
For example:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
@Activate
protected void activate() {
// initialization
}
@Deactivate
protected void deactivate() {
// cleanup
}
@Override
public Product getProduct(String sku) {
return null;
}
}
Use lifecycle methods for work that genuinely belongs to the lifecycle of the component.
For example:
- Initializing an expensive client
- Reading configuration
- Preparing resources
- Cleaning up resources when the component stops
Don't use @Activate as a place to execute normal business logic.
The method is tied to the component lifecycle, not to an individual request.
A Service Can Depend on Another Service
Real AEM applications rarely contain isolated services.
A service may depend on several other services.
For example:
ProductService
│
├── PricingService
└── ProductApi
The implementation can declare those dependencies using Declarative Services.
For example:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
@Reference
private PricingService pricingService;
@Reference
private ProductApi productApi;
@Override
public Product getProduct(String sku) {
return productApi.getProduct(sku);
}
@Override
public double getPrice(String sku, String country) {
return pricingService.getPrice(sku, country);
}
}
The service doesn't create these dependencies itself.
Instead, it declares:
"I need a PricingService and a ProductApi."
OSGi then resolves those dependencies from the services available in the runtime.
The relationship becomes:
ProductServiceImpl
│
├── @Reference → PricingService
│
└── @Reference → ProductApi
This is dependency injection at the OSGi component level.
What If a Dependency Is Not Available?
This is where understanding the runtime becomes useful during production debugging.
Suppose ProductService requires PricingService, but PricingService is not available.
Depending on the dependency configuration, the ProductService component may remain unsatisfied instead of becoming active.
Conceptually:
ProductServiceImpl
│
│ requires
↓
PricingService
X
Not Available
↓
ProductService
Not Active
A developer working only from the Sling Model might see:
ProductService is null / unavailable
and start looking at the Sling Model.
The better troubleshooting path is to move one level down:
Consumer
↓
Service
↓
Service Status
↓
Dependencies
↓
OSGi Configuration
↓
Bundle
This is one of the reasons the AEM Web Console is useful during backend troubleshooting.
Checking an OSGi Service in AEM
When an OSGi service isn't behaving as expected, the first thing to establish is whether the component is actually active.
In a local AEM SDK or an AEM environment where the relevant Web Console is available, developers commonly inspect:
/system/console/components
and:
/system/console/services
The exact access and permissions depend on the AEM environment.
The Components view helps answer:
- Is my component registered?
- Is it active?
- Is it unsatisfied?
- Are dependencies preventing activation?
The Services view helps answer:
- Has my service been registered?
- Which implementation provides it?
- Are there multiple implementations?
This is much more useful than simply looking at the Java source code.
A class can compile successfully and still not be available as an active OSGi service at runtime.
From Source Code to Runtime
Let's connect everything we've covered so far.
We start with the interface:
public interface ProductService {
Product getProduct(String sku);
}
Then the implementation:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
@Override
public Product getProduct(String sku) {
return null;
}
}
After deployment, the runtime relationship is roughly:
AEM Application Bundle
↓
OSGi Runtime
↓
ProductServiceImpl
↓
ProductService
↓
Consumer
This is the part worth remembering.
The annotation is only the entry point.
Behind it, AEM is running an OSGi runtime that manages components, dependencies, service registration, and lifecycle.
Once that mental model is clear, the annotations become much easier to understand.
OSGi Configuration Is a Different Problem
We've talked about services and dependencies.
There is another piece that frequently appears beside them in AEM projects:
OSGi configuration.
A service may need values that should not be hardcoded.
For example:
ProductService
│
├── API URL
├── Connection timeout
├── Retry count
└── Feature flag
These values are not business logic.
They are configuration.
Instead of writing:
String apiUrl = "https://example.com/api";
inside the service, we can make the value configurable.
This gives us another important separation:
Service
↓
Business Logic
OSGi Configuration
↓
Configuration Values
For example:
Development → Development API
Stage → Stage API
Production → Production API
The Java implementation can remain the same while the configuration changes between environments.
We'll look at this in detail next because OSGi Services and OSGi Configurations are closely related in AEM, but they solve different problems.
OSGi Configuration: Keeping Environment-Specific Values Out of Code
Let's continue with the ProductService example.
Suppose the service calls an external product API.
The implementation needs values such as:
API URL
Connection timeout
Retry count
Enable/disable integration
These values can change between environments.
For example:
Local → http://localhost:4503/api
Development → https://dev-api.example.com
Production → https://api.example.com
We don't want to create separate Java implementations just because the API URL changes.
The service should contain the logic.
The environment should provide the configuration.
That gives us a cleaner separation:
ProductService
│
├── Business Logic
│
└── OSGi Configuration
│
├── Local
├── Dev
└── Prod
The implementation stays the same.
Only the configuration changes.
Defining an OSGi Configuration
Modern AEM projects commonly use an OSGi configuration interface.
For example:
@ObjectClassDefinition(
name = "Product API Configuration"
)
public @interface ProductApiConfig {
String apiUrl();
int timeout() default 5000;
int retryCount() default 3;
}
The interface describes the configuration that the service expects.
The service can then receive that configuration through its activation method:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
private String apiUrl;
private int timeout;
private int retryCount;
@Activate
protected void activate(ProductApiConfig config) {
this.apiUrl = config.apiUrl();
this.timeout = config.timeout();
this.retryCount = config.retryCount();
}
@Override
public Product getProduct(String sku) {
// use configured values
return null;
}
}
There are several things happening here.
The configuration definition describes the available properties.
AEM provides the actual configuration.
When the component is activated, the configuration is passed to the service.
The service stores the values it needs and uses them during its normal operation.
The relationship looks like this:
OSGi Configuration
│
│ provides values
↓
ProductServiceImpl
│
│ uses configuration
↓
External Product API
The service doesn't need to contain environment-specific values.
It simply receives its configuration from the OSGi runtime.
Configuration Factory vs Single Configuration
Not every service needs multiple configuration instances.
Suppose there is only one product API:
ProductService
↓
Product API
A single configuration may be enough.
But consider a service that communicates with several external systems:
ProductService
│
├── Commerce API
├── Pricing API
└── Inventory API
The project may need separate configuration instances.
This is where a configuration factory can become useful.
Conceptually:
Product API Configuration
│
├── US
├── UK
└── Germany
Whether a factory configuration is appropriate depends on the application's design.
The important architectural decision is to avoid turning one configuration into a large collection of unrelated properties simply because several services happen to need configuration.
Configuration should follow a clear responsibility.
Configuration and Secrets Are Not the Same Thing
One common mistake is treating every configuration value as equally safe to store as normal OSGi configuration.
For example:
API URL
Timeout
Retry Count
are ordinary configuration values.
But:
Client Secret
API Password
Private Credential
are sensitive values.
Those should not be treated like ordinary source-controlled OSGi configuration.
The exact approach depends on the AEM deployment model and the project's secret-management strategy.
The architectural boundary remains:
Normal Configuration
↓
OSGi Configuration
Sensitive Credentials
↓
Approved Secret Management
This becomes particularly important in AEM as a Cloud Service, where deployment and configuration management follow Cloud Service-specific practices.
Service vs Configuration
At this point, the distinction should be clear.
A service answers:
- What does the application do?
A configuration answers:
- What values should the application use in this environment?
For example:
ProductService
↓
getProduct()
getPrice()
while the configuration provides:
apiUrl
timeout
retryCount
Put another way:
Product Integration
│
┌─────────┴─────────┐
↓ ↓
Service Configuration
│ │
Business Logic Environment Values
Mixing these responsibilities creates unnecessary problems.
For example, this is usually a poor design:
public Product getProduct(String sku) {
String apiUrl = "https://production-api.example.com";
// business logic
}
Now the environment is embedded inside the implementation.
A configuration-based approach keeps that decision outside the service:
public Product getProduct(String sku) {
// use configured apiUrl
// business logic
}
The code can then move between environments without changing the implementation simply because the endpoint changed.
What Happens When Configuration Changes?
The service lifecycle becomes important again.
An administrator or deployment changes the configuration.
The OSGi runtime can update the component according to its configuration and lifecycle.
Conceptually:
Configuration Changed
↓
OSGi Updates the Component
↓
@Modified Handles the Change
↓
Service Uses the New Values
For components that need to react explicitly to configuration changes, a modified lifecycle method can be used.
For example:
@Activate
@Modified
protected void configure(ProductApiConfig config) {
this.apiUrl = config.apiUrl();
this.timeout = config.timeout();
}
The exact lifecycle behavior depends on the component and configuration setup.
The useful thing to remember is that configuration is part of the component lifecycle.
It isn't simply a Java variable that gets read once and forgotten.
A More Realistic AEM Service
Let's bring the pieces together.
Suppose our product service needs:
- A repository lookup
- An external API
- A configurable endpoint
- A pricing service
The architecture could look like this:
ProductService
│
├── Repository Access
├── Product API
└── PricingService
OSGi Configuration
│
├── API URL
├── Timeout
└── Retries
The implementation might then look roughly like:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
@Reference
private PricingService pricingService;
private String apiUrl;
private int timeout;
@Activate
@Modified
protected void activate(ProductApiConfig config) {
this.apiUrl = config.apiUrl();
this.timeout = config.timeout();
}
@Override
public Product getProduct(String sku) {
// repository or API lookup
return null;
}
@Override
public double getPrice(String sku, String country) {
return pricingService.getPrice(sku, country);
}
}
The code itself isn't the main thing to remember.
The architecture is:
Consumer
↓
ProductService
↓
ProductServiceImpl
├── Other OSGi Services
├── Repository / AEM APIs
└── OSGi Configuration
This is the service layer we commonly build around AEM backend logic.
Where Developers Usually Get Confused
There are several concepts sitting very close together:
They are connected, but each has a different responsibility.
A simple mental model is:
Component
→ Something the OSGi runtime manages
Service
→ A capability exposed to other components
Configuration
→ Values that control how the component behaves
Consumer
→ Something that uses the service
Once these responsibilities are separated, the AEM backend becomes much easier to reason about.
And this is also where architecture decisions start becoming important.
We don't want every Sling Model talking directly to the repository, every servlet calling external APIs, and every component carrying its own configuration.
We want clear boundaries between the layers.
AEM Request Flow With an OSGi Service
Now let's connect the service layer back to something the reader already knows: an AEM request.
Suppose a browser requests a product page.
A simplified flow could look like this:
Browser
↓
AEM Request
↓
Sling / Component
↓
Sling Model
↓
ProductService
↓
Repository / External API
↓
Product Data
↓
HTL
↓
HTML Response
The Sling Model is responsible for adapting the content for the component.
The service owns the reusable business logic.
The repository or external API provides the underlying data.
That separation keeps the rendering layer from becoming responsible for everything.
This is the kind of boundary that becomes increasingly important as an AEM application grows.
A Real AEM Service Example
Let's move from the simplified ProductService example to something closer to what you might actually see in an AEM project.
Suppose we have a product component.
The component needs product information, but the information can come from more than one place:
Product Component
↓
ProductModel
↓
ProductService
├── Repository
├── Product API
└── PricingService
The component shouldn't need to know which source is being used.
Its responsibility is to prepare the data needed by the HTL template.
The service owns the business logic.
Service Interface
package com.mycompany.core.services;
import com.mycompany.core.models.Product;
public interface ProductService {
Product getProduct(String sku);
}
The interface is intentionally small.
The consumer doesn't need methods for every internal operation performed by the service.
It only exposes what the application actually needs.
Service Implementation
package com.mycompany.core.services.impl;
import com.mycompany.core.models.Product;
import com.mycompany.core.services.ProductService;
import org.apache.sling.api.resource.Resource;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.component.annotations.Modified;
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
@Reference
private PricingService pricingService;
private String apiUrl;
@Activate
protected void activate(ProductApiConfig config) {
this.apiUrl = config.apiUrl();
}
@Override
public Product getProduct(String sku) {
// Retrieve product information
// Apply business rules
// Use pricingService when required
return null;
}
}
There are several responsibilities represented here:
ProductServiceImpl
│
├── Implements ProductService
│
├── Receives PricingService
│
├── Receives OSGi configuration
│
└── Implements product business logic
The implementation doesn't need to know which Sling Model or servlet will call it.
That is deliberate.
Consuming the Service From a Sling Model
Now the service needs to be used by the component.
A Sling Model can inject the OSGi service through the Sling Models OSGi service injector.
For example:
import org.apache.sling.api.resource.Resource;
import org.apache.sling.models.annotations.Model;
import org.apache.sling.models.annotations.injectorspecific.OSGiService;
@Model(
adaptables = Resource.class
)
public class ProductModel {
@OSGiService
private ProductService productService;
public Product getProduct() {
return productService.getProduct("ABC-123");
}
}
@OSGiService tells the Sling Models injector to obtain the matching OSGi service from the service registry.
The important relationship is:
ProductModel
│
│ @OSGiService
▼
ProductService
│
▼
ProductServiceImpl
The model doesn't create ProductServiceImpl.
It doesn't know how the implementation is built.
It only depends on the service contract.
This keeps the Sling Model focused on preparing component data instead of becoming the place where all business logic lives.
What Should Stay in the Sling Model?
This is an important design decision in AEM projects.
A Sling Model is typically responsible for adapting repository content and exposing component data that HTL can consume.
For example:
public String getTitle() {
return resource.getValueMap().get("title", String.class);
}
That's a reasonable responsibility.
But consider this:
public double getPrice() {
// Call external API
// Read pricing rules
// Check country
// Apply discount
// Calculate tax
// Handle retries
// Build response
}
That is a different problem.
The model is now becoming a business-logic layer.
A better separation would be:
Sling Model
↓
ProductService
↓
Pricing / API / Repository Logic
The model can then expose the result:
public double getPrice() {
return productService.getPrice(productSku);
}
The exact boundary depends on the project, but the general rule is useful:
Keep presentation-specific adaptation in the Sling Model and move reusable business logic into services.
This becomes especially useful when the same logic is needed by a servlet, scheduler, workflow step, or another service.
The Same Service Can Have Multiple Consumers
Suppose the product service is used by more than one part of the application.
ProductService
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
ProductModel ProductServlet Scheduler
│ │ │
▼ ▼ ▼
HTL Page JSON API Background Job
The service becomes the common boundary.
Without that boundary, each consumer might implement its own version of the product logic.
That is where duplication starts to appear.
The logic has one place to maintain.
This is one of the strongest reasons to introduce a service in an AEM application.
Service Boundaries Matter More Than the Number of Services
There is another problem that appears as a project grows.
Once developers understand OSGi services, it can be tempting to create a service for almost everything.
For example:
ProductService
ProductTitleService
ProductDescriptionService
ProductImageService
ProductPriceService
ProductButtonService
Now the application has many small services, but the boundaries don't represent meaningful responsibilities.
The result can be harder to understand than the original code.
A better question is:
What responsibility does this service own?
For example:
ProductService
│
├── Product lookup
├── Product availability
└── Product-related business rules
PricingService
│
├── Price calculation
└── Regional pricing rules
The exact boundaries depend on the application.
The service should represent a meaningful capability, not simply a Java class that happens to have Service in its name.
What Happens When Multiple Services Implement the Same Interface?
This is another OSGi concept that becomes important in larger applications.
Suppose we have:
public interface ProductService {
Product getProduct(String sku);
}
and two implementations:
ProductService
│
├── CommerceProductService
│
└── MockProductService
Both implementations can potentially be registered as OSGi services.
Now the runtime needs a way to determine which service should satisfy a consumer's dependency.
This is where OSGi service properties and service ranking can become relevant.
Conceptually:
ProductService
│
┌────────┴────────┐
↓ ↓
CommerceProductService MockProductService
ranking: 100 ranking: 10

If a consumer has a unary service reference and multiple matching services are available, OSGi can use service ranking to determine which service is selected.
If two implementations represent genuinely different behaviors, explicit service properties or a clearer selection strategy may be easier to understand than relying only on ranking.
The architect should first ask:
Why do we have multiple implementations?
The ranking mechanism comes after that decision.
A Common Production Problem: Service Is Not Available
Let's say the Sling Model contains:
@OSGiService
private ProductService productService;
but the expected product data isn't being returned.
The first assumption is often:
"The Sling Model injection is broken."
That may not be the actual problem.
Work backward through the runtime:
HTL
↓
Sling Model
↓
ProductService
↓
ProductServiceImpl
↓
Dependencies
↓
Configuration
↓
Bundle
Check each layer.
Step 1: Is the bundle active?
If the bundle isn't active, its components won't be available.
Step 2: Is the service registered?
Check whether ProductService is actually available in the OSGi runtime.
Step 3: Is the component satisfied?
A component can exist but remain unsatisfied because one of its required dependencies isn't available.
For example:
ProductServiceImpl
│
└── @Reference
↓
PricingService
X
Not Available
In that situation, fixing the Sling Model won't solve the problem.
Step 4: Is the configuration available?
The service may depend on an OSGi configuration.
For example:
ProductServiceImpl
│
└── ProductApiConfig
X
Missing / Invalid
The component may therefore fail to activate or behave incorrectly.
Step 5: Check the logs
If activation or dependency resolution fails, the AEM logs can provide the next clue.
The exact error depends on the component and dependency that failed.
The useful troubleshooting habit is to follow the dependency chain rather than immediately changing the consumer.
AEM Backend Debugging Mental Model
When an OSGi service doesn't work.
Ask:
-
Is the bundle active?
-
Is the component active?
-
Is the service registered?
-
Are its references satisfied?
-
Is the required configuration present?
-
Does the service method itself work?
This order saves time because it separates runtime wiring problems from business-logic problems.
Where OSGi Services Fit in the AEM Architecture
At this point, we can place the service layer into the larger AEM request flow:
Browser
↓
AEM Request
↓
Sling / Component
↓
Sling Model
↓
OSGi Service
├── JCR / Oak
└── External API
↓
Business Result
↓
Sling Model
↓
HTL
↓
HTML Response
This isn't the only possible AEM request flow.
A servlet, workflow step, scheduler, or another OSGi service can consume the same service without going through a Sling Model.
That's the reason the service layer is useful:
OSGi Service
│
┌──────────────┼──────────────┐
▼ ▼ ▼
Sling Model Servlet Scheduler
The service becomes a reusable backend capability rather than logic tied to one rendering path.
That is the architectural role we want OSGi services to play in an AEM application.
Common OSGi Service Design Mistakes
Understanding how OSGi works is only part of the job.
The next question is how we use services in a real AEM project.
A service can be technically correct and still create problems if its responsibility isn't clear.
Here are some patterns worth watching for.
Putting Everything Into One Service
A project can start with a service like:
ApplicationService
├── Product logic
├── Search logic
├── User logic
├── API calls
├── Notification logic
└── Reporting logic
It may look convenient initially.
Over time, the service becomes difficult to understand and changes in one area can affect unrelated functionality.
A better approach is to separate responsibilities:
- ProductService
- SearchService
- UserService
- NotificationService
The exact boundaries depend on the application.
The point isn't to create as many services as possible.
The point is to give each service a responsibility that makes sense.
Putting Business Logic Directly Into Sling Models
This is another common pattern.
A Sling Model starts small:
public String getTitle() {
return properties.get("title", String.class);
}
Later, more logic gets added:
public Product getProduct() {
// Read repository data
// Call external API
// Apply pricing rules
// Check availability
// Handle errors
// Transform response
return product;
}
The model is now doing much more than preparing data for the component.
If another consumer needs the same product logic, the team may end up duplicating it.
A cleaner boundary is:
Sling Model
↓
ProductService
↓
Business Logic
The Sling Model can still expose the result to HTL, but the reusable logic lives in the service.
Calling External APIs Directly From Every Consumer
A common problem in AEM projects is allowing every consumer to call an external API directly.

A better approach is to centralize the external integration behind an OSGi service. This gives the application one place to handle API communication, configuration, error handling, and related integration logic.
Using new for OSGi-Managed Services
One of the most important rules is simple:
ProductServiceImpl service = new ProductServiceImpl();
is not how we normally consume an OSGi service.
The problem isn't the Java syntax itself.
The problem is that manually creating the object bypasses the OSGi runtime.
That means the manually created object does not participate in OSGi's managed lifecycle and dependency injection.
- OSGi-managed service references
- OSGi configuration
- Lifecycle management
- Other runtime-managed dependencies
The runtime manages the service instance.
Conceptually:
Don't:
Consumer
↓
new ProductServiceImpl()
Do:
Consumer
↓
ProductService
↓
OSGi Service Registry
↓
ProductServiceImpl
Service Design and Testability
A service boundary can also make backend code easier to test.
Suppose the Sling Model directly calls an external API.
Testing the model now requires dealing with the external integration.
Sling Model
│
└── External API
If the integration sits behind a service:
Sling Model
│
▼
ProductService
│
▼
External API
the model can be tested with a mocked ProductService.
The service itself can then be tested separately.
This gives us two smaller testing problems instead of one large one:
Sling Model Test
↓
Mock ProductService
ProductService Test
↓
Mock External API
The service boundary therefore isn't only about code organization.
It can also make the application easier to test and change.
Service Interfaces Are Useful, But Don't Create Them Automatically
You'll often see AEM projects using:
ProductService
ProductServiceImpl
for almost every piece of code.
An interface can be useful when:
- Multiple consumers use the capability
- The implementation may change
- Multiple implementations are possible
- The service represents a meaningful application boundary
- The dependency needs to be mocked or replaced in tests
But creating an interface for every small piece of logic doesn't automatically make the architecture better.
For example, if a private calculation is only used inside one service:
private double calculateDiscount(...) {
...
}
there may be no reason to turn it into another OSGi service.
The abstraction should follow a real responsibility.
Keep Configuration Out of Business Decisions
Another mistake is using configuration as a replacement for application logic.
For example:
enableProductLogic=true
useNewLogic=false
skipValidation=true
allowSpecialCase=true
useLegacyMode=false
A few configuration switches can be useful.
But if a service accumulates dozens of flags controlling every possible behavior, the configuration itself becomes difficult to understand.
The service starts looking like:
ProductService
│
├── 15 configuration flags
├── 8 environment values
├── 5 feature switches
└── 20 conditional branches
Configuration should represent values or behavior that genuinely needs to vary by environment or deployment.
It shouldn't become a substitute for a clear application design.
A Production Example
Consider an AEM application that integrates with a commerce platform.
The initial implementation might look simple:
Product Component
↓
Commerce API
As the project grows, other components need the same information.
The team introduces a service:
Product Component
↓
ProductService
↓
Commerce API
Later, the integration needs:
- Different API URLs by environment
- Authentication
- Timeout configuration
- Retry handling
- Caching
- Error handling
- Monitoring

The important architectural decision was made much earlier:
The component shouldn't own the commerce integration.
Once that boundary existed, the implementation could evolve without moving the integration logic into every consumer.
How to Think About OSGi Services as an Architect
When designing an AEM backend, don't start with:
"Where can I use an OSGi service?"
Start with the responsibility.
Ask:
-
What does this logic do?
-
Who needs it?
-
Is it reusable?
-
Does it depend on other runtime services?
-
Does it need environment-specific configuration?
-
Could another implementation replace it?
-
How will we test it?
-
What happens if one of its dependencies is unavailable?
-
Those questions lead to the service boundary.
OSGi then provides the runtime mechanism for managing that boundary.
That is a better way to think about OSGi than treating annotations as the architecture.
The Mental Model to Keep
You don't need to memorize the annotations to understand the architecture.
The important relationships are:
Component
↓
managed by OSGi
Service
↓
registered in OSGi
Reference
↓
declares a dependency
Configuration
↓
provides environment-specific values
OSGi Runtime
↓
connects and manages them
Once those relationships are clear, the AEM backend starts to make much more sense.
The next step is to look at what happens when these components are actually packaged and deployed as OSGi bundles.
OSGi Bundles: How AEM Deploys Backend Code
We've talked about services, components, dependencies, and configuration.
But there is still a practical question:
Where does all this Java code actually run inside AEM?
When we build an AEM project, our Java code is packaged into an OSGi bundle.
For a typical AEM project, the backend module produces a JAR that is packaged as an OSGi bundle, containing the project's Java classes along with the metadata required by the OSGi runtime.
The bundle is therefore the deployment unit for a large part of the AEM backend.
What Is Inside a Bundle?
A bundle is more than a normal JAR with compiled Java classes.
It also contains metadata that tells the OSGi runtime how the code should be treated.
A simplified view looks like:
myproject.core.jar
│
├── Java Classes
│ ├── Sling Models
│ ├── Services
│ ├── Servlets
│ └── Other Components
│
├── OSGi Metadata
│
└── Dependencies / Import Information
The OSGi runtime uses this metadata when the bundle is installed.
This is how AEM can discover the components inside the bundle and understand their relationships.
Without the OSGi metadata, the runtime would not have the same information about how the class should be managed.
Bundle Lifecycle
Bundles also have a lifecycle inside the OSGi framework.

The exact runtime behavior can be more detailed, but the useful idea is that a bundle must be successfully resolved before the components inside it can operate normally.
If that required dependency cannot be resolved, the bundle may not become active.
This is different from a service dependency problem.
Understanding which layer has failed is important when debugging.
Bundle Dependencies
AEM applications rarely operate in isolation.
Your project bundle uses APIs provided by AEM, Sling, OSGi, and other libraries.
For example:
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
Those classes are not normally packaged into your project bundle as your own application code.
The bundle declares the packages it needs, and the OSGi framework resolves those imports against packages provided by available bundles.

This modular structure is one of the reasons OSGi can support large applications.
Different bundles can provide different capabilities without putting everything into one huge application JAR.
A bundle dependency and an OSGi service dependency are not the same thing. Bundle dependencies are about whether the required Java packages are available and the bundle can be resolved. Service dependencies are about whether a required OSGi service is available for a component to become satisfied.
Bundle Dependency
↓
Can the bundle resolve its required packages?
Service Dependency
↓
Can the component obtain its required OSGi service?
What Happens During Deployment?
Let's connect the build process to the runtime.
Suppose we change:
ProductServiceImpl
We build the project:
Source Code
↓
Maven Build
↓
myproject.core.jar
The runtime process looks roughly like this:

The code may have compiled successfully, but the application can still have runtime problems.
Compilation proves that the Java code can be built.
It does not prove that:
- The bundle can be resolved
- Components can activate
- Service dependencies are available
- Configuration exists
- External dependencies are reachable
Those are runtime concerns.
Bundle vs Component vs Service

The bundle packages the implementation and its OSGi metadata.
The component tells OSGi how that implementation should be managed.
The service makes the capability available to other components.
These layers work together, but they solve different problems.
Why This Matters During Deployment
Suppose a developer says:
"The service is not working after deployment."
There are several possible causes.
It could be:
Bundle didn't start
↓
Component didn't activate
↓
Service wasn't registered
↓
Reference wasn't satisfied
↓
Configuration wasn't available
↓
Service logic failed
Those are very different problems.
A developer who understands the OSGi layers can narrow the issue much faster.
For example:
Is the bundle active?
↓
Yes
↓
Is the component active?
↓
Yes
↓
Is the service registered?
↓
Yes
↓
Are dependencies satisfied?
↓
Yes
↓
Check configuration / business logic
That is much more useful than immediately changing Java code.
Reading the Bundle From the AEM Runtime
When debugging an AEM deployment, the Web Console can help identify where the problem is.
The exact URLs and available console access depend on the AEM environment, but on environments where the OSGi Web Console is available, developers commonly inspect:
/system/console/bundles
This helps answer:
Is my bundle installed?
Is it resolved?
Is it active?
Are there unresolved dependencies?
Then:
/system/console/components
can help answer:
Is my component registered?
Is it active?
Is it unsatisfied?
And:
/system/console/services
helps answer:
Is the service registered?
Which component provides it?
These views give us different pieces of the same runtime picture.
Bundle
↓
Component
↓
Service
As a first troubleshooting pass, checking them in this order can help narrow down the failing layer.
A Simple Production Debugging Example
Suppose a deployment was completed and the product component suddenly stops displaying product information.
The Sling Model contains:
@OSGiService
private ProductService productService;
Instead of immediately changing this code, check the runtime.
Product Information Missing
↓
Is my bundle active?
↓
Is ProductServiceImpl active?
↓
Is ProductService registered?
↓
Are service references satisfied?
↓
Is OSGi configuration available?
↓
Does ProductService call succeed?
This sequence moves from the infrastructure layer toward the application logic.
It also helps separate deployment problems from code problems.
For example:
Bundle inactive
↓
Deployment / dependency issue
Bundle active
↓
Component unsatisfied
↓
Reference / configuration issue
Service active
↓
Method fails
↓
Application logic issue
The faster we identify the layer, the faster we can focus the investigation.
How This Fits Into the Architecture
We can now extend our earlier runtime model.
AEM Runtime
│
OSGi Framework
│
┌────────┴────────┐
│ │
Bundles Service Registry
│ │
▼ │
Project Bundle │
│ │
┌──────┴──────┐ │
↓ ↓ │
Components Services ─────┘
│
├── References
│
└── Configuration
Now the pieces we've covered start fitting together:
Bundle
↓
contains application code
Component
↓
managed by OSGi
Service
↓
exposed through the service registry
Reference
↓
connects one component to another service
Configuration
↓
controls environment-specific behavior
This is the foundation underneath a large part of AEM's backend runtime.
The next step is to look more closely at Declarative Services annotations and understand what @Component, @Reference, @Activate, @Deactivate, and related annotations actually tell the OSGi runtime.
Declarative Services: How the Annotations Fit Together
We've seen @Component, @Reference, @Activate, and @Deactivate in different examples.
It's worth slowing down here because these annotations are not just syntax added to make the code shorter.
They describe how a component should be managed by the OSGi runtime.
Consider this service:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
@Reference
private PricingService pricingService;
@Activate
protected void activate(ProductApiConfig config) {
// initialization
}
@Deactivate
protected void deactivate() {
// cleanup
}
}
There are several instructions being given to OSGi:
@Component
↓
Manage this class as an OSGi component
service = ProductService.class
↓
Expose it as ProductService
@Reference
↓
This component needs another OSGi service
@Activate
↓
Run this method when the component becomes active
@Deactivate
↓
Run this method when the component is deactivated
Once we look at the annotations this way, the code becomes much easier to understand.
@Component
@Component is the starting point.
Without it, a normal Java class is just a Java class.
For example:
public class ProductServiceImpl implements ProductService {
}
The class exists in the bundle, but we haven't told Declarative Services that the OSGi runtime should manage it as a component.
With:
@Component
public class ProductServiceImpl implements ProductService {
}
the class becomes an OSGi Declarative Services component.
If we also want to expose it as a service:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
}
we are defining both the component and the service it provides.
A useful way to read this code is:
@Component
↓
OSGi manages the component
service = ProductService.class
↓
Other components can consume ProductService
This is why you will commonly see service implementations written this way in AEM projects.
@Reference
Now suppose ProductServiceImpl needs another service.
@Reference
private PricingService pricingService;
This doesn't mean:
"Create a new PricingService."
It means:
"This component has a dependency on an available PricingService."
OSGi resolves that dependency from the services available in the runtime.
The relationship looks like:
ProductServiceImpl
│
│ @Reference
▼
PricingService
If the required service is available, the dependency can be satisfied.
If it isn't available, the component may not become active, depending on the reference configuration.
That distinction is important during troubleshooting.
A missing service reference can prevent the component from activating even though the Java code itself compiles successfully.
@Activate
A component may need to initialize something when it becomes active.
For example:
@Activate
protected void activate(ProductApiConfig config) {
this.apiUrl = config.apiUrl();
}
The method is invoked by Declarative Services when the component is activated.
This makes it a good place for things such as:
- Reading OSGi configuration
- Initializing a client
- Preparing reusable resources
- Performing startup initialization
The important part is that activation happens because of the component lifecycle.
It is not a method that should be called manually by application code.
You should not write:
productService.activate();
The OSGi runtime manages that lifecycle.
@Deactivate
@Deactivate is the other side of the lifecycle.
For example:
@Deactivate
protected void deactivate() {
// cleanup
}
This can be useful when the component needs to release resources that it created during activation.
A simple service that only contains stateless business logic may not need a @Deactivate method at all.
Don't add lifecycle methods simply because the annotation exists.
Use them when the component actually has lifecycle-related work to perform.
@Modified
Configuration can change while the application is running.
If the component needs to react to those changes, a modified lifecycle method can be used.
For example:
@Activate
@Modified
protected void activate(ProductApiConfig config) {
this.apiUrl = config.apiUrl();
this.timeout = config.timeout();
}
Here the same method is used for both initial activation and subsequent configuration modifications.

Whether a component needs @Modified depends on whether it needs to react to configuration changes while it is running.
If the component doesn't need to react to configuration changes explicitly, there is no reason to add it just for completeness.
Putting the Annotations Together
Let's look at a more complete service.
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
@Reference
private PricingService pricingService;
private String apiUrl;
private int timeout;
@Activate
@Modified
protected void activate(ProductApiConfig config) {
this.apiUrl = config.apiUrl();
this.timeout = config.timeout();
}
@Override
public Product getProduct(String sku) {
// business logic
return null;
}
@Deactivate
protected void deactivate() {
// cleanup if required
}
}
Instead of memorizing the annotations individually, read the class as a runtime definition.
The class is managed by OSGi, exposed as ProductService, depends on PricingService, and can react to activation, configuration changes, and deactivation.
This is a much better mental model than remembering:
@Component does X, @Reference does Y, @Activate does Z.
The annotations describe the component's relationship with the runtime.
What Happens When Dependencies Change?
Now consider a service with two dependencies:
@Reference
private PricingService pricingService;
@Reference
private ProductApi productApi;
The component effectively depends on both:
ProductServiceImpl
│
├── PricingService
│
└── ProductApi
For the component to become active, its mandatory dependencies must be satisfied.

This is why checking component status is often more useful than immediately debugging the method body.
The method may not even be reachable because the component never became active.
Mandatory and Optional References
Not every dependency has the same importance.
A service may have a dependency that is required for its core functionality:
OSGi Declarative Services allows reference cardinality and policy to be configured when the application requires more control over the dependency.
For example:
@Reference(
cardinality = ReferenceCardinality.OPTIONAL
)
private AnalyticsService analyticsService;
Now the component does not have the same mandatory dependency on AnalyticsService.
This can be useful when the service can still operate without that dependency.
But optional references should be used carefully.
Making every dependency optional just to prevent activation failures can hide real problems.
For example:
ProductService
│
└── PricingService
↓
Optional
If pricing is actually required for correct product behavior, making it optional doesn't solve the architecture problem.
It only moves the failure somewhere else.
The better question is:
Can this service genuinely operate without the dependency?
If the answer is no, keep the dependency required.
One Service, Multiple Implementations
Earlier we saw that multiple implementations of the same service can exist.
This becomes more interesting when we combine it with service properties.
For example, suppose the application has different implementations for different environments or product types.
Instead of relying only on service ranking, a consumer can use service properties to distinguish implementations when the architecture requires it.
For example:
@Component(
service = ProductService.class,
property = {
"product.type=commerce"
}
)
public class CommerceProductService implements ProductService {
}
Another implementation could use:
property = {
"product.type=mock"
}
A consumer can use a target filter on its service reference when it needs to select an implementation based on a service property.
@Reference(target = "(product.type=commerce)")
private ProductService productService;
ProductService
│
├── product.type=commerce
│
└── product.type=mock
│
▼
Target Filter
│
▼
Selected ProductService
This is an advanced use case.
Don't introduce service properties and filters simply because OSGi supports them.
Use them when there is a real requirement for multiple implementations.
The Runtime Picture
The annotations are the declarations.
The OSGi runtime is what actually manages the relationships.
That distinction is worth keeping in mind whenever we debug an AEM backend issue.
A Practical Debugging Example
Suppose this component is showing as unsatisfied:
ProductServiceImpl
State: Unsatisfied
Don't immediately change the Java code.
Look at the component's references.
For example:
ProductServiceImpl
│
├── PricingService ✓
│
└── ProductApi ✗
Now the investigation has a direction.

You may discover that the original problem was actually several layers away from ProductServiceImpl.
This is one of the useful habits that comes with understanding OSGi:
Follow the dependency chain instead of guessing from the class where the error first appears.
What We Should Remember
The annotations themselves are small.
The runtime behavior behind them is what matters.
Once that is clear, we can start looking at some of the less obvious OSGi behavior that matters in real AEM projects, including service ranking, component scope, configuration handling, and what happens when services are activated or replaced at runtime.
Service Ranking: When More Than One Implementation Exists
Most AEM services have one implementation.
In some applications, however, more than one implementation of the same service interface can be registered.
Now a consumer asking for ProductService has more than one possible service.
OSGi needs a way to determine which service should be preferred when the reference doesn't otherwise distinguish between them.
This is where service ranking comes into play.
A service can provide a ranking through a service property:
@Component(
service = ProductService.class,
property = {
"service.ranking:Integer=100"
}
)
public class CommerceProductService implements ProductService {
}
Another implementation might have:
@Component(
service = ProductService.class,
property = {
"service.ranking:Integer=10"
}
)
public class MockProductService implements ProductService {
}
When both services match a normal unary reference, the higher-ranked service is preferred.
That doesn't mean ranking should become the normal way of designing application behavior.
If the application genuinely needs two different implementations, it is worth asking why.
For example:
Different implementation by environment
↓
Configuration may be more appropriate
Different implementation by business type
↓
Service property / target filter may be appropriate
Temporary test implementation
↓
A test-specific setup may be simpler
Service ranking is a runtime selection mechanism.
It shouldn't be used to hide an unclear service architecture.
Component Scope: Does AEM Use One Instance or More?
Another OSGi concept that matters when designing services is component scope.
A service isn't necessarily created in exactly the same way in every situation.
For many backend services, the default and common choice is a singleton component.
That works well for stateless services.
For example:
@Component(
service = ProductService.class,
scope = ServiceScope.SINGLETON
)
public class ProductServiceImpl implements ProductService {
}
With singleton scope, the same component instance can serve multiple consumers.

This is useful when the service contains reusable, stateless behavior.
The service should not store request-specific information in instance fields.
For example, this would be a bad design:
private String currentUser;
private Resource currentResource;
inside a shared service.
Multiple requests can use the same service instance.
Request-specific state belongs to the request or the appropriate request-scoped object, not a shared service instance.
Stateless Services Are Easier to Reason About
A good AEM service will often look like this
The service doesn't remember what happened in the previous request.
For example:
public Product getProduct(String sku) {
// read data
// calculate result
// return result
}
This makes the service easier to use concurrently and easier to test.
Compare that with:
private Product lastProduct;
private String lastSku;
Now the service has internal state that can be shared between requests.
That creates unnecessary concurrency and correctness concerns.
For backend services that process requests, keeping the implementation stateless is usually the safer design unless there is a specific reason to maintain state.
Service Scope and Request Data Are Different Concerns
The model may represent data associated with one resource or request.
The service may be shared.
That means the service should not assume that its fields belong to one request.
For example, avoid:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
private String currentSku;
public Product getProduct(String sku) {
currentSku = sku;
// processing
return null;
}
}
Instead:
public Product getProduct(String sku) {
String currentSku = sku;
// processing
return null;
}
The second version keeps the request-specific value inside the method.
That distinction becomes important when the service is used concurrently.
Why Architects Care About Service Scope
This may look like an implementation detail, but it affects application behavior.
Suppose a service is used by:
100 requests
│
▼
ProductService
If the service stores mutable request-specific state in shared fields, one request can potentially interfere with another.
The problem may not appear during development.
It can become visible under production traffic.
That's why service design should consider:
- Is the service stateless?
- Does it store mutable state?
- Is that state shared?
- Does the state belong to the request?
- Does the service create expensive resources?
- Should those resources be reused?
The answer determines whether the default singleton-style behavior is appropriate or whether another design is required.
A Service Does Not Automatically Mean "One Global Object"
There is a subtle point here.
Developers sometimes hear:
- "OSGi service"
and think:
- "There is one global Java object."
That's not the right mental model.
The OSGi runtime manages component instances according to the component's declared scope and lifecycle.
The service registry exposes service references to consumers.
The runtime owns the lifecycle.
Application code should not assume that it owns the service instance.
What Happens During a Bundle Update?
Now consider a production deployment.
We deploy a new version of our core bundle.
The bundle contains:
ProductServiceImpl
PricingServiceImpl
ProductServlet
A simplified update flow is:

This is another reason lifecycle methods matter.
During a bundle update, components may be deactivated and then activated again as the updated bundle is processed.
If the service creates resources during activation, it should also handle their cleanup appropriately.
For example:
@Activate
protected void activate(ProductApiConfig config) {
// initialize client
}
@Deactivate
protected void deactivate() {
// close client / release resources
}
The exact lifecycle behavior depends on the component and how the bundle is updated, but the architectural point is simple:
Don't assume that a service instance will live forever.
A Common Mistake With External Clients
Suppose a service creates an HTTP client:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
private HttpClient client;
@Activate
protected void activate() {
client = createClient();
}
}
If that client owns resources that need to be released, the service should have a corresponding cleanup strategy.
For example:
@Deactivate
protected void deactivate() {
client.close();
}
The exact API depends on the HTTP client being used.
This is much safer than creating expensive resources repeatedly inside every service method.
Don't Confuse Service Lifecycle With Request Lifecycle
One of the easiest mistakes to make is assuming that a service instance exists only for the duration of a request.
It does not.
A request may come in, use a service, and finish.
The OSGi service can remain active long after that request is gone.

This distinction matters when designing services that are used by many requests.
A service may be shared across multiple requests, so request-specific data should not be stored in service instance fields.
For example, this is dangerous:
private String currentUser;
private Resource currentResource;
When a Service Should Not Exist
There is one more architectural question worth asking.
Sometimes developers introduce an OSGi service simply because the project uses OSGi.
For example:
- StringFormatterService
- DateHelperService
- TitleHelperService
- BooleanConverterService
If the logic is:
- private to one class
- simple
- stateless
- not reused
- not configuration-driven
- not an application capability
then an OSGi service may add unnecessary runtime complexity.
A normal private method or Java utility may be enough.
For example:
private String formatTitle(String title) {
return title.trim();
}
The service boundary should exist because it represents a meaningful responsibility, not because we want more OSGi components.
The Architecture Decision
At this point, the question isn't:
- "Should I use an OSGi service?"
A better question is:
- "Is this a capability that should be managed and shared by the AEM runtime?"
If the answer is yes, a service can provide a clean boundary.
If the logic is local to one class, keeping it local may be better.
That distinction helps prevent two opposite problems:
Too little abstraction
↓
Duplicated business logic
Too much abstraction
↓
Unnecessary services and dependencies
Good AEM architecture sits somewhere between those two extremes.
The OSGi Mental Model So Far
We've now moved from the basic service definition into how the runtime behaves.
With that foundation in place, the next area we should examine is how OSGi configuration is actually represented in an AEM project and how configuration differs between local development, AEM as a Cloud Service, and deployment environments.
That is where OSGi starts connecting directly to the way we build and deploy real AEM projects.
OSGi Configuration in a Real AEM Project
We've seen what configuration does at runtime.
Now let's look at where configuration actually lives in an AEM project.
A common AEM project structure separates application code from OSGi configuration:
AEM Project
│
├── core
│ └── Java / OSGi Components
│
├── ui.apps
│ └── AEM Application Content
│
└── ui.config
└── OSGi Configuration
The exact project structure can vary, but keeping configuration separate from Java code is an important part of a maintainable AEM project.
For example:
core
↓
ProductServiceImpl.java
ui.config
↓
Product API configuration
The service defines what configuration it expects.
The configuration project supplies the actual values.
Configuration Definition vs Configuration Value
There are two different things here that are easy to mix up.
The Java configuration definition describes the properties:
@ObjectClassDefinition(
name = "Product API Configuration"
)
public @interface ProductApiConfig {
String apiUrl();
int timeout() default 5000;
int retryCount() default 3;
}
This tells us:
apiUrl
timeout
retryCount
But it doesn't mean that these are the actual production values.
Those values come from the deployed configuration.
Think of it as:
Configuration Definition
│
│ describes
▼
Available Properties
│
│ supplied by
▼
Environment Configuration
│
▼
OSGi Component
The definition is part of the application code.
The values can be environment-specific.
Where Configuration Files Fit
In a typical AEM project, OSGi configuration can be maintained in the project configuration module.
For example:
ui.config
└── src
└── main
└── content
└── jcr_root
└── apps
└── myproject
└── osgi
└── config
You may then have configuration files for a service.
For example:
/apps/myproject/osgi/config/
<configuration-pid>.cfg.json
The exact PID and project structure depend on how the component and configuration are defined.
A configuration file might contain:
{
"apiUrl": "https://api.example.com",
"timeout": 5000,
"retryCount": 3
}
The important relationship is:
Java Service
│
│ defines expected configuration
▼
Configuration Definition
│
│ matched to configuration PID
▼
OSGi Configuration
│
▼
Running Component
The Java class doesn't need to contain the environment-specific endpoint.
Local, Development, and Production Configuration
This becomes more important when the same AEM application runs in different environments.
For example:
Local AEM SDK
↓
Development
↓
Stage
↓
Production
The application code can remain the same:
ProductServiceImpl
while configuration values change:
Local
apiUrl = local endpoint
Development
apiUrl = development endpoint
Production
apiUrl = production endpoint
This is one of the main reasons configuration belongs outside the Java implementation.
We don't want code changes such as:
if (environment.equals("prod")) {
apiUrl = "...";
} else {
apiUrl = "...";
}
The deployment environment should determine the configuration.
The service should simply consume it.
Run Modes and Environment-Specific Configuration
AEM also provides run modes that can be used to organize configuration for different environments.
A project might have configuration folders such as:
/apps/myproject/osgi/config
/apps/myproject/osgi/config.author
/apps/myproject/osgi/config.publish
A project can use folders such as config.author and config.publish, along with other run-mode combinations depending on the deployment model.
This allows configuration to be associated with the runtime role for which it is intended.
For example:
Author
↓
Author-specific configuration
Publish
↓
Publish-specific configuration
That matters because Author and Publish often have different responsibilities.
For example:
Author
├── Authoring-related configuration
└── Internal integrations
Publish
├── Delivery-related configuration
└── Public-facing integrations
The important part is not the folder name itself.
It is the architectural decision about which configuration belongs to which runtime.
AEM as a Cloud Service Changes the Deployment Model
The same configuration concepts exist in AEM as a Cloud Service, but the deployment model is different from a traditional on-premise or AMS-style AEM environment.
You don't manage the underlying AEM instances in the same way.
Configuration is packaged as part of the application and deployed through the Cloud Manager pipeline.
A simplified flow is:
Developer
↓
Git Repository
↓
Cloud Manager Pipeline
↓
Build
↓
Deploy
↓
AEM as a Cloud Service
↓
OSGi Configuration
This changes how we should think about configuration.
Configuration should be treated as part of the application's deployment architecture rather than something an administrator manually changes on a production server and expects to remain permanently outside source control.
Configuration Should Not Be Hardcoded
Consider this:
private static final String API_URL =
"https://production-api.example.com";
This creates several problems.
The value is now:
- tied to one environment
- part of the compiled application
- harder to change safely
- harder to manage across environments
Instead:
ProductService
│
▼
OSGi Configuration
│
├── Local URL
├── Dev URL
└── Production URL
The Java implementation remains unchanged.
This also makes the application's deployment and environment strategy easier to manage.
Configuration Is Code, Values Are Environment-Specific
Something like:
Application Code
│
├── ProductServiceImpl
└── ProductApiConfig
│
▼
Defines what the service needs
Deployment Configuration
│
┌───────┼────────┐
▼ ▼ ▼
Local Dev Prod
│ │ │
▼ ▼ ▼
Values Values Values
The configuration definition belongs to the application, while the actual values can vary by environment.
Configuration Should Not Become a Secret Store
There is another boundary we need to maintain.
An API endpoint is configuration.
An API password or private credential is sensitive information.
We should not treat both in exactly the same way.
For example:
Normal configuration
↓
API URL
Timeout
Retry count
Feature settings
Sensitive values
↓
Passwords
Private keys
Client secrets
Access tokens
Sensitive values should be managed through the appropriate secret-management mechanism for the deployment environment.
The service can still consume the resulting value, but the secret should not simply be committed as plain text into the application's source repository.
This becomes particularly important in cloud deployments.
What Happens If Configuration Is Missing?
Suppose the service requires:
String apiUrl();
but the deployed configuration doesn't provide a valid value.
The result depends on how the configuration and component have been defined.
The component may fail to activate, or the application may fail later when it tries to use an invalid value.
This is why critical configuration should be validated and its failure mode should be clear.
For example:
ProductService
│
▼
Configuration
│
├── API URL ✓
├── Timeout ✓
└── Retry Count ✓
│
▼
Component Active
Compare that with:
ProductService
│
▼
Configuration
│
└── API URL ✗
│
▼
Component / Integration Problem
A configuration problem can therefore look like a service problem from the outside.
When debugging, always check whether the expected configuration is actually present.
Configuration Validation
For configuration that is critical to the service, validation is worth considering.
For example, an API integration may require:
API URL
Timeout
Connection settings
If the URL is missing, allowing the component to start and fail only when the first request arrives can make troubleshooting harder.
A better design can fail early or clearly report that the configuration is incomplete.
The exact validation approach depends on the service and configuration API being used.
The principle is simple:
- A required configuration value should fail clearly rather than becoming a mysterious runtime error later.
Configuration and Business Logic Should Stay Separate
Let's compare two implementations.
Configuration mixed into business logic
public Product getProduct(String sku) {
String endpoint =
"https://production-api.example.com/products";
// call API
// parse response
// apply business rules
return product;
}
The endpoint and business logic are now mixed together.
Configuration separated from business logic
public Product getProduct(String sku) {
String endpoint = apiUrl + "/products";
// call API
// parse response
// apply business rules
return product;
}
Here:
apiUrl
↓
Configuration
getProduct()
↓
Business Logic
That separation makes the implementation easier to move between environments and easier to reason about.
A More Complete AEM Runtime Picture
We've now covered most of the pieces individually.
Let's put them together.
AEM Application
│
▼
OSGi Framework
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
Bundle Components Configurations
│ │ │
│ ▼ │
│ Services │
│ │ │
│ ▼ │
│ Service Registry │
│ │ │
└────────────┬───┴────────────────┘
│
▼
Dependencies
│
▼
Business Logic
│
┌────────┴────────┐
▼ ▼
JCR/Oak External APIs
A request can then travel through the application like this:
Browser
↓
Sling
↓
Sling Model / Servlet
↓
OSGi Service
↓
Business Logic
├── JCR / Oak
├── Other OSGi Services
└── External API
↓
Response
And behind the service:
OSGi Service
│
├── Component
├── References
├── Configuration
└── Lifecycle
This is the backend architecture we've been building throughout the chapter.
What Actually Happens When a Service Is Deployed?
Let's walk through the complete sequence one more time, this time from source code to request.
We start with:
@Component(service = ProductService.class)
public class ProductServiceImpl
implements ProductService {
}
The code is packaged into an OSGi bundle, deployed to AEM, resolved by the OSGi runtime, and processed by Declarative Services.
Once the component's dependencies and configuration are satisfied, OSGi activates it and registers the exposed service.
At request time, a consumer such as a Sling Model can obtain that service through @OSGiService.
That is the complete path from the Java class we write to the service that eventually handles application logic.
How to Debug the Whole Chain
When a service doesn't work, don't treat the error as a single problem.
For example:
Service unavailable
↓
Is the bundle active?
↓
Is the component active?
↓
Are references satisfied?
↓
Is configuration available/valid?
↓
Is the service registered?
↓
Can the consumer obtain it?
↓
Does the service method work?
This approach is much more effective than changing multiple classes at the same time.
It also gives the team a common troubleshooting process.
Why Architects Care About OSGi Services
At this point, OSGi services are no longer just an AEM development technique.
They affect architecture decisions.
A good service boundary can give us:
- Reusable backend capabilities
- Clear separation between consumers and implementations
- Centralized business logic
- Environment-specific configuration
- Easier testing
- Controlled dependencies
- Better separation between presentation and business logic
But OSGi also gives us more things to manage:
- Component lifecycle
- Service dependencies
- Configuration
- Bundle dependencies
- Runtime activation
- Service selection
That's why adding a service isn't automatically an architectural improvement.
The question should always be:
Does this service boundary make the application easier to understand, maintain, test, and evolve?
If it does, the abstraction is probably justified.
If it doesn't, we may simply be adding another layer.
Practical Rule for AEM Developers
When you're deciding where a piece of backend logic belongs, start with the responsibility.
Is it presentation-specific?
↓
Sling Model may be appropriate
Is it reusable business logic?
↓
Consider an OSGi Service
Does it vary by environment?
↓
Use OSGi Configuration
Does it need another backend capability?
↓
Use an OSGi service reference
Does it interact directly with repository or external systems?
↓
Keep that responsibility behind an appropriate backend boundary
This isn't a rigid rule.
Real projects will have exceptions.
But it gives us a useful starting point when designing an AEM backend.
Summary
OSGi services are one of the foundations of AEM backend development.
The important part isn't memorizing annotations.
Once these pieces are understood, many AEM backend problems become easier to reason about.
A service that isn't available may be a bundle problem.
A service that isn't active may be a dependency or configuration problem.
A service that is active but returns the wrong result may be an application logic problem.
The runtime gives us the pieces. Good architecture is deciding where those pieces belong.
What's Next
We've looked at OSGi services from the runtime and architecture side.
The next step is to go deeper into one of the areas developers encounter constantly in AEM:
Sling Models.
We'll look at what happens between an AEM resource and a Sling Model, how adaptation works, how injectors work, where @OSGiService fits into the picture, and where the boundary between a Sling Model and an OSGi service should be drawn.
That will connect the OSGi service layer we've built here back to the AEM components developers work with every day.
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.