N
Naveenr.dev
Chapter 22
20 min read2026-08-14

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:

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

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

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

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

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

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

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

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

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

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

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

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

text
Before

ProductService
      │
      ▼
Repository implementation

and later:

text
After

ProductService
      │
      ▼
Commerce API implementation

The consumer still depends on:

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

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

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

java
@Component

tells OSGi that the class is a component managed by the OSGi runtime.

Second:

java
service = ProductService.class

registers the component as an implementation of the ProductService service contract.

So conceptually:

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

java
ProductServiceImpl service = new ProductServiceImpl();

the model declares that it needs a ProductService.

With Declarative Services, that dependency can be expressed using @Reference:

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

java
@OSGiService
private ProductService productService;

The key idea remains the same:

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

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

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

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

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

text
ProductService
      │
      ├── PricingService
      └── ProductApi

The implementation can declare those dependencies using Declarative Services.

For example:

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

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

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

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

text
/system/console/components

and:

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

java
public interface ProductService {
    Product getProduct(String sku);
}

Then the implementation:

java
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {

    @Override
    public Product getProduct(String sku) {
        return null;
    }
}

After deployment, the runtime relationship is roughly:

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

text
ProductService
    │
    ├── API URL
    ├── Connection timeout
    ├── Retry count
    └── Feature flag

These values are not business logic.

They are configuration.

Instead of writing:

java
String apiUrl = "https://example.com/api";

inside the service, we can make the value configurable.

This gives us another important separation:

text
Service
   ↓
Business Logic

OSGi Configuration
   ↓
Configuration Values

For example:

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

text
API URL
Connection timeout
Retry count
Enable/disable integration

These values can change between environments.

For example:

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

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

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

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

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

text
ProductService
      ↓
Product API

A single configuration may be enough.

But consider a service that communicates with several external systems:

text
ProductService
     │
     ├── Commerce API
     ├── Pricing API
     └── Inventory API

The project may need separate configuration instances.

This is where a configuration factory can become useful.

Conceptually:

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

text
API URL
Timeout
Retry Count

are ordinary configuration values.

But:

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

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

java
ProductService
    ↓
getProduct()
getPrice()

while the configuration provides:

text
apiUrl
timeout
retryCount

Put another way:

text
             Product Integration
                    │
          ┌─────────┴─────────┐
          ↓                   ↓
       Service            Configuration
          │                   │
    Business Logic      Environment Values

Mixing these responsibilities creates unnecessary problems.

For example, this is usually a poor design:

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

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

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

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

text
ProductService
      │
      ├── Repository Access
      ├── Product API
      └── PricingService
              

OSGi Configuration
      │
      ├── API URL
      ├── Timeout
      └── Retries

The implementation might then look roughly like:

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

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

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

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

text
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

java
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

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

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

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

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

java
public String getTitle() {
    return resource.getValueMap().get("title", String.class);
}

That's a reasonable responsibility.

But consider this:

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

text
Sling Model
    ↓
ProductService
    ↓
Pricing / API / Repository Logic

The model can then expose the result:

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

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

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

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

java
public interface ProductService {
    Product getProduct(String sku);
}

and two implementations:

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

text
                 ProductService
                       │
              ┌────────┴────────┐
              ↓                 ↓
   CommerceProductService   MockProductService
        ranking: 100           ranking: 10
OSGi Service Ranking
OSGi Service Ranking

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:

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

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

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

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

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

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

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

java
public String getTitle() {
    return properties.get("title", String.class);
}

Later, more logic gets added:

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

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

Centralizing External API Integration
Centralizing External API Integration

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:

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

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

text
Sling Model
     │
     └── External API

If the integration sits behind a service:

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

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

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

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

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

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

text
Product Component
      ↓
Commerce API

As the project grows, other components need the same information.

The team introduces a service:

text
Product Component
      ↓
ProductService
      ↓
Commerce API

Later, the integration needs:

  • Different API URLs by environment
  • Authentication
  • Timeout configuration
  • Retry handling
  • Caching
  • Error handling
  • Monitoring
Production OSGi Service Architecture
Production OSGi Service Architecture

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:

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

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

OSGi Bundle Lifecycle
OSGi Bundle Lifecycle

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:

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

OSGi Bundle Dependencies
OSGi Bundle Dependencies

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.

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

text
ProductServiceImpl

We build the project:

text
Source Code
    ↓
Maven Build
    ↓
myproject.core.jar

The runtime process looks roughly like this:

OSGi Deployment to Runtime Flow
OSGi Deployment to Runtime Flow

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

Bundle → Component → Service
Bundle → Component → 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:

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

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

java
/system/console/bundles

This helps answer:

text
Is my bundle installed?
Is it resolved?
Is it active?
Are there unresolved dependencies?

Then:

java
/system/console/components

can help answer:

text
Is my component registered?
Is it active?
Is it unsatisfied?

And:

java
/system/console/services

helps answer:

text
Is the service registered?
Which component provides it?

These views give us different pieces of the same runtime picture.

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

java
@OSGiService
private ProductService productService;

Instead of immediately changing this code, check the runtime.

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

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

text
                     AEM Runtime
                          │
                     OSGi Framework
                          │
                 ┌────────┴────────┐
                 │                 │
              Bundles          Service Registry
                 │                 │
                 ▼                 │
          Project Bundle            │
                 │                 │
          ┌──────┴──────┐          │
          ↓             ↓          │
      Components     Services ─────┘
          │
          ├── References
          │
          └── Configuration

Now the pieces we've covered start fitting together:

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

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

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

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

java
@Component
public class ProductServiceImpl implements ProductService {
}

the class becomes an OSGi Declarative Services component.

If we also want to expose it as a service:

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

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

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

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

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

java
productService.activate();

The OSGi runtime manages that lifecycle.

@Deactivate

@Deactivate is the other side of the lifecycle.

For example:

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

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

OSGi Service Lifecycle
OSGi Service Lifecycle

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.

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

java
@Reference
private PricingService pricingService;

@Reference
private ProductApi productApi;

The component effectively depends on both:

text
ProductServiceImpl
       │
       ├── PricingService
       │
       └── ProductApi

For the component to become active, its mandatory dependencies must be satisfied.

OSGi Service Dependency Resolution
OSGi Service Dependency Resolution

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:

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

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

java
@Component(
    service = ProductService.class,
    property = {
        "product.type=commerce"
    }
)
public class CommerceProductService implements ProductService {
}

Another implementation could use:

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

java
@Reference(target = "(product.type=commerce)")
private ProductService productService;
text
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:

java
ProductServiceImpl
    State: Unsatisfied

Don't immediately change the Java code.

Look at the component's references.

For example:

text
ProductServiceImpl
    │
    ├── PricingService     ✓
    │
    └── ProductApi         ✗

Now the investigation has a direction.

OSGi Service Troubleshooting Flow
OSGi Service Troubleshooting Flow

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:

java
@Component(
    service = ProductService.class,
    property = {
        "service.ranking:Integer=100"
    }
)
public class CommerceProductService implements ProductService {
}

Another implementation might have:

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

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

java
@Component(
    service = ProductService.class,
    scope = ServiceScope.SINGLETON
)
public class ProductServiceImpl implements ProductService {
}

With singleton scope, the same component instance can serve multiple consumers.

One Shared OSGi Service - Multiple Consumers
One Shared OSGi Service - 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:

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

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

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

java
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {

    private String currentSku;

    public Product getProduct(String sku) {
        currentSku = sku;

        // processing

        return null;
    }
}

Instead:

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

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

text
ProductServiceImpl
PricingServiceImpl
ProductServlet

A simplified update flow is:

OSGi Bundle Update Flow
OSGi Bundle Update Flow

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:

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

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

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

Request Lifecycle vs OSGi Service Lifecycle
Request Lifecycle vs OSGi Service Lifecycle

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:

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

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

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

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

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

java
@ObjectClassDefinition(
    name = "Product API Configuration"
)
public @interface ProductApiConfig {

    String apiUrl();

    int timeout() default 5000;

    int retryCount() default 3;
}

This tells us:

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

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

text
ui.config
└── src
    └── main
        └── content
            └── jcr_root
                └── apps
                    └── myproject
                        └── osgi
                            └── config

You may then have configuration files for a service.

For example:

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

java
{
    "apiUrl": "https://api.example.com",
    "timeout": 5000,
    "retryCount": 3
}

The important relationship is:

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

text
Local AEM SDK
      ↓
Development
      ↓
Stage
      ↓
Production

The application code can remain the same:

ProductServiceImpl

while configuration values change:

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

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

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

text
Author
   ↓
Author-specific configuration

Publish
   ↓
Publish-specific configuration

That matters because Author and Publish often have different responsibilities.

For example:

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

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

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

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

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

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

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

text
ProductService
      │
      ▼
Configuration
      │
      ├── API URL ✓
      ├── Timeout ✓
      └── Retry Count ✓
      │
      ▼
Component Active

Compare that with:

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

text
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

java
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

java
public Product getProduct(String sku) {

    String endpoint = apiUrl + "/products";

    // call API
    // parse response
    // apply business rules

    return product;
}

Here:

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

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

text
Browser
   ↓
Sling
   ↓
Sling Model / Servlet
   ↓
OSGi Service
   ↓
Business Logic
   ├── JCR / Oak
   ├── Other OSGi Services
   └── External API
   ↓
Response

And behind the service:

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

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

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

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