Core path · 25 of 34
A practical developer and architect guide to OSGi services in AEM, including service contracts, implementations, dependency injection, component lifecycle, service boundaries, and common production design problems.
An AEM product endpoint can start with a simple job: accept a product ID and return product data. Then the same feature needs API access, repository updates, configuration, and failure handling. Putting all of that in the servlet makes the HTTP entry point own the whole feature.
A servlet should own the HTTP request: validate input, choose a status, and return a response. The product operation itself needs a boundary that can also be called by a job, workflow, or another component.
Consider what happens when the servlet needs to do this:
@Override
protected void doPost(
SlingHttpServletRequest request,
SlingHttpServletResponse response) throws IOException {
String productId = request.getParameter("productId");
// Read configuration
// Call SAP
// Transform product data
// Update repository content
// Apply business rules
// Handle integration failures
// Return HTTP response
}
Technically, all of that code could live inside the servlet.
That does not make the servlet the right place for it.
The same problem appears with Sling Models.
A model starts by preparing component data:
@Model(
adaptables = SlingHttpServletRequest.class,
resourceType = "myproject/components/product"
)
public class ProductModel {
// Component properties
}
Then an external API call gets added.
Then repository lookup.
Then configuration.
Then transformation logic.
Eventually the Sling Model is no longer preparing a component for rendering. It has become the implementation of the feature.
This is where OSGi services become an important architectural boundary in AEM.
Suppose a product feature needs to retrieve product information from an external commerce system.
The same operation may eventually be needed from:
If the integration code lives directly inside the servlet:
public class ProductServlet extends SlingAllMethodsServlet {
private ProductData loadProduct(String productId) {
// Read endpoint configuration
// Build authentication headers
// Call commerce API
// Parse response
// Apply mapping rules
// Handle integration errors
}
}
the implementation is tied to that HTTP endpoint. If the same operation is needed somewhere else, we either duplicate the logic or start trying to reuse a servlet as if it were an application service.
Neither is a good boundary.
Instead, the operation can have its own contract:
public interface ProductService {
ProductData getProduct(String productId)
throws ProductServiceException;
}
and an implementation:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
@Override
public ProductData getProduct(String productId)
throws ProductServiceException {
// Product retrieval and integration logic
}
}
Now the servlet can depend on the service:
@Reference
private ProductService productService;
and use it:
ProductData product =
productService.getProduct(productId);
The servlet still owns the HTTP request.
The service owns the product operation.
That separation is the starting point for understanding OSGi services in AEM.
But there is another part that matters just as much.
ProductServiceImpl is not simply a Java class that happens to have an annotation.
Once it is registered as an OSGi component and exposed as a service, its creation, dependencies, configuration, activation, availability, and lifecycle are managed by the OSGi runtime.
Understanding that runtime behavior is what turns @Component and @Reference from annotations we copy into code into architecture we can reason about in production.
Before going deeper into @Component and @Reference, it helps to separate two things that can look almost identical in code.
A normal Java class can provide reusable logic:
public class ProductMapper {
public ProductData map(ProductResponse response) {
// Map external response to application model
}
}
We create and manage that object ourselves:
ProductMapper mapper = new ProductMapper();
There is nothing wrong with this.
If ProductMapper is a stateless utility with no runtime configuration and no dependencies managed by AEM, making it an OSGi service may add complexity without giving us anything useful.
An OSGi service solves a different problem.
Consider the product integration again:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
@Reference
private CommerceClient commerceClient;
@Override
public ProductData getProduct(String productId) {
return commerceClient.getProduct(productId);
}
}
Here we are not creating ProductServiceImpl ourselves.
There is no:
ProductService productService =
new ProductServiceImpl();
The OSGi runtime manages the component.
When its required dependencies are available, the component can become active and its service can be registered for other components to use.
A consumer can then declare:
@Reference
private ProductService productService;
instead of deciding which implementation to instantiate.
That difference becomes important as an AEM application grows.
This distinction is easy to miss because we commonly write:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
}
and casually call the whole thing an "OSGi service."
There are actually two related concepts here.
ProductServiceImpl is an OSGi component implementation managed by Declarative Services.
ProductService is the service contract that the component registers into the OSGi service registry.
In this declaration:
@Component(service = ProductService.class)
we are telling Declarative Services that this component should be registered as a service under ProductService.
Another component can then depend on that contract:
@Reference
private ProductService productService;
The consumer does not need to know that the implementation class is:
ProductServiceImpl
It depends on:
ProductService
That gives us a useful boundary between the caller and the implementation.
For application-level services, I normally define the contract separately:
public interface ProductService {
ProductData getProduct(String productId)
throws ProductServiceException;
}
and implement it:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
@Override
public ProductData getProduct(String productId)
throws ProductServiceException {
// Implementation
}
}
Consumers depend on the interface:
@Reference
private ProductService productService;
This keeps them from depending directly on implementation details.
It also becomes useful when there are multiple implementations, when an implementation changes, or when the consumer needs to be tested independently.
But I would not create an interface mechanically for every class just because the project uses OSGi.
The interface should represent a meaningful service contract.
A small internal helper that is never consumed as an OSGi service does not automatically need:
Helper
HelperImpl
just to follow a naming pattern.
The service boundary should exist because there is a real responsibility worth exposing to other components.
When a servlet contains:
@Reference
private ProductService productService;
it is tempting to think of that as ordinary dependency injection:
Servlet needs ProductService
ProductServiceImpl is injected
That description is useful at a high level, but there is an important runtime detail behind it.
The consumer is asking OSGi for a service registered under the ProductService contract.
A component such as:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
}
registers that service when the component is able to become active.
The consumer's reference is then satisfied from the service registry.
This means the dependency is not just a compile-time relationship between two Java classes.
It also has runtime state.
If a required service is unavailable, the consuming component may not be able to activate.
That is why an AEM class can:
and still fail to become active at runtime.
The next part is understanding that lifecycle: how an OSGi component moves through activation, how @Reference affects that process, and why a missing dependency can leave a component unsatisfied instead of producing the behavior we expected.
An OSGi component does not become usable simply because its bundle was installed successfully.
Declarative Services first needs to determine whether the component can be satisfied.
Consider:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
@Reference
private CommerceClient commerceClient;
@Override
public ProductData getProduct(String productId) {
return commerceClient.getProduct(productId);
}
}
ProductServiceImpl has a dependency on CommerceClient.
Before this component can become active, OSGi needs a matching CommerceClient service for that required reference.
If one is available, the reference can be satisfied and the component can continue through activation.
If no matching service is available, the component cannot become active as expected.
This is one of the first places where OSGi problems look different from normal Java problems.
The code may compile.
The Maven build may succeed.
The bundle may even be installed and active.
But the component itself can still be unavailable.
This distinction matters when troubleshooting AEM.
A bundle being Active tells us that the bundle itself has started.
It does not guarantee that every Declarative Services component inside that bundle is active.
For example, our core bundle could be active while:
ProductServiceImpl
is still unable to activate because its required CommerceClient reference cannot be satisfied.
That is why checking only the bundle state can send us in the wrong direction.
When a service is unexpectedly unavailable, I want to know:
Those are different questions.
A component can define activation logic using @Activate.
For example:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
@Reference
private CommerceClient commerceClient;
private String endpoint;
@Activate
protected void activate() {
endpoint = "https://example.com/products";
}
@Override
public ProductData getProduct(String productId) {
return commerceClient.getProduct(productId);
}
}
The example is intentionally simple.
The important point is that @Activate belongs to the component lifecycle.
It is not a method we call manually from the servlet:
productService.activate();
Declarative Services invokes the activation method when the component is being activated.
In a real AEM service, activation is often where configuration becomes part of the component's runtime state.
We will look at configuration architecture separately later, but the lifecycle connection is important here.
A service may depend on:
Required service references
+
OSGi configuration
+
Successful activation
before it is ready to serve callers.
Now consider this chain:
ProductServlet
depends on ProductService
ProductServiceImpl
depends on CommerceClient
CommerceClientImpl
depends on another required service
If the last required dependency is unavailable, the effect can move up the chain.
CommerceClientImpl cannot provide the expected service.
Then ProductServiceImpl cannot satisfy its required CommerceClient reference.
Then a component requiring ProductService may also be unable to activate.
From the servlet side, the problem may initially look like:
ProductService is not available.
But ProductServiceImpl may not be the original failure.
The missing dependency can be further down the service graph.
This is why I do not immediately change @Reference code when an OSGi service is unavailable.
I trace the dependency chain.
A reference is not always required.
Suppose a feature can operate without an optional analytics integration.
The component could declare that dependency as optional rather than preventing the complete service from becoming available when analytics is missing.
Conceptually:
@Reference(
cardinality = ReferenceCardinality.OPTIONAL
)
private volatile AnalyticsService analyticsService;
That decision should come from the feature behavior.
If the service cannot perform its job without the dependency, making the reference optional just to get the component into an active state hides the real problem.
For example, this would be suspicious:
if (commerceClient != null) {
return commerceClient.getProduct(productId);
}
return null;
if retrieving products is the entire purpose of ProductService.
In that case, CommerceClient is not really optional.
The component should not pretend to be ready when its core dependency is missing.
Optional references make sense when the feature genuinely has valid behavior without that dependency.
Another pattern I try to avoid is putting too much work into @Activate.
For example:
@Activate
protected void activate() {
// Call external API
// Load thousands of repository resources
// Build application data
// Perform cleanup
}
Activation is part of bringing the component into a usable runtime state.
If activation performs expensive external or repository operations, failures in those operations can also become component-startup problems.
I keep activation focused on preparing the service itself:
@Activate
protected void activate(ProductServiceConfig config) {
this.endpoint = config.endpoint();
this.timeout = config.timeout();
}
The actual product operation remains in the service method:
@Override
public ProductData getProduct(String productId)
throws ProductServiceException {
return commerceClient.getProduct(
endpoint,
productId,
timeout
);
}
Now there is a cleaner distinction.
Activation prepares the component.
The service method performs the application operation.
When something using:
@Reference
private ProductService productService;
does not become available, I work backward from the service registry rather than starting with the Java call.
I check whether ProductService is registered.
If it is not, I check the component that should provide it.
If that component is unsatisfied, I check its required references.
If the references are satisfied but activation failed, I look at the activation failure and logs.
That troubleshooting path follows how Declarative Services actually builds the dependency graph at runtime.
Once that lifecycle is clear, @Reference becomes more than a convenient way to inject another class. Its cardinality, policy, target filters, and the number of matching service implementations can all change which dependency a component receives and how that component behaves when services appear or disappear.
@Reference Resolves a ServiceSo far, we have used a simple reference:
@Reference
private ProductService productService;
At runtime, Declarative Services looks for a service registered under the ProductService contract.
For example:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
}
If that is the matching service, the reference can be satisfied.
The interesting part starts when more than one implementation registers the same service interface.
Consider:
@Component(service = ProductService.class)
public class SapProductService implements ProductService {
}
and:
@Component(service = ProductService.class)
public class CommerceProductService implements ProductService {
}
Both components register:
ProductService
Now this reference:
@Reference
private ProductService productService;
has more than one possible service available.
That is where service ranking, target filters, cardinality, and reference policy start to matter.
OSGi services can publish a service.ranking property.
For example:
@Component(
service = ProductService.class,
property = {
Constants.SERVICE_RANKING + ":Integer=100"
}
)
public class SapProductService implements ProductService {
}
Another implementation could have a lower ranking:
@Component(
service = ProductService.class,
property = {
Constants.SERVICE_RANKING + ":Integer=10"
}
)
public class DefaultProductService implements ProductService {
}
For a unary reference where multiple matching services are available, ranking participates in deciding which service is selected.
This can be useful when multiple implementations intentionally satisfy the same contract.
But I would not use ranking as a substitute for understanding why multiple implementations exist.
If two services represent completely different business behaviors, silently selecting one through ranking can make the design difficult to understand.
Sometimes the consumer needs to state exactly which implementation it expects.
A service can expose additional properties.
For example:
@Component(
service = ProductService.class,
property = {
"source=sap"
}
)
public class SapProductService implements ProductService {
}
and:
@Component(
service = ProductService.class,
property = {
"source=commerce"
}
)
public class CommerceProductService implements ProductService {
}
A consumer can restrict its reference to a matching service:
@Reference(target = "(source=sap)")
private ProductService productService;
Now the consumer is not asking for any available ProductService.
It is asking for a ProductService whose service properties match:
(source=sap)
This can be useful when multiple implementations are intentionally registered under the same contract.
It also introduces another runtime dependency.
If the expected service is registered but its properties do not match the target filter, the reference is still not satisfied.
That can produce an interesting troubleshooting situation:
ProductService exists
but:
@Reference(target = "(source=sap)")
still cannot resolve it.
The interface alone is no longer enough. The service properties also have to match.
A reference also has cardinality.
At a practical level, I think about two questions:
Is the dependency required or optional?
and:
Does the consumer need one service or multiple services?
That gives us the common cardinalities:
1..1 mandatory, one service
0..1 optional, at most one service
1..n mandatory, multiple services
0..n optional, multiple services
A normal required reference such as:
@Reference
private ProductService productService;
represents the common 1..1 case.
The component needs one matching service.
For an optional dependency:
@Reference(
cardinality = ReferenceCardinality.OPTIONAL
)
private volatile AnalyticsService analyticsService;
the component can still have valid behavior when no AnalyticsService is available.
Multiple cardinality becomes useful when the application intentionally supports several implementations.
Imagine several processors:
public interface ProductProcessor {
void process(ProductData product);
}
Different components can register the same contract:
@Component(service = ProductProcessor.class)
public class ValidationProcessor implements ProductProcessor {
}
@Component(service = ProductProcessor.class)
public class EnrichmentProcessor implements ProductProcessor {
}
A coordinating component may need all registered processors rather than choosing one.
That is a different design from:
@Reference
private ProductService productService;
where the consumer expects a single implementation
The cardinality should describe the actual dependency model rather than being changed simply to make an unsatisfied component activate.
Another decision is what should happen if the referenced service changes while the application is running.
That is controlled by the reference policy.
The two policies are:
STATIC DYNAMIC
With a static reference, changes to the bound service can require Declarative Services to deactivate and reactivate the consuming component so the dependency can be rebound.
With a dynamic reference, the reference can be updated while the component remains active.
Dynamic references are useful when the application genuinely needs to react to services appearing or disappearing at runtime.
But most application services do not need to declare dynamic references just because OSGi supports them.
For a normal application dependency such as:
@Reference
private ProductService productService;
the default static behavior is usually easier to reason about.
I introduce dynamic behavior only when the runtime requirement actually needs it.
@Reference Is an Architectural Dependency
This is the part I care about most when reviewing service design.
Every time we add:
@Reference
private SomeService someService;
we are adding more than a Java field.
We are saying:
This component depends on another runtime service to perform its responsibility.
A service with one or two clear dependencies is usually easy to understand.
A service with a long list like:
@Reference
private ProductApiService productApiService;
@Reference
private PricingService pricingService;
@Reference
private InventoryService inventoryService;
@Reference
private CustomerService customerService;
@Reference
private NotificationService notificationService;
@Reference
private AuditService auditService;
@Reference
private WorkflowService workflowService;
may be telling us something about the responsibility of that service.
The number of references is not automatically a problem.
But when one component coordinates many unrelated systems, performs repository work, owns business rules, sends notifications, and triggers workflows, I check whether the service boundary has become too broad.
OSGi makes injecting another service easy.
That convenience should not turn one service into the place where the entire application is assembled.
The next question is therefore not how many services OSGi can inject.
It is how we decide what should actually belong inside an OSGi service in the first place.
Once we understand how services are registered and injected, the next design question is more important:
What responsibility should the service own?
It is easy to create an OSGi service whenever we want to move code out of a servlet or Sling Model.
That can leave us with classes such as:
CommonService
UtilityService
HelperService
AemService
ContentService
that slowly collect unrelated methods.
I prefer to define the service around a clear application responsibility.
For example:
public interface ProductService {
ProductData getProduct(String productId);
List<ProductData> searchProducts(ProductSearchCriteria criteria);
}
Both methods belong to the same product capability.
Compare that with:
public interface CommonService {
ProductData getProduct(String productId);
void sendEmail(String email);
Resource getResource(String path);
void activatePage(String path);
String getConfiguration(String key);
}
There is no useful boundary here.
The methods are reusable, but they are not part of the same responsibility.
That usually makes the service harder to change, test, configure, and troubleshoot.
The servlet chapter gave us a useful boundary.
A servlet may receive:
String productId = request.getParameter("productId");
validate the request and then call:
ProductData product =
productService.getProduct(productId);
I would not pass the complete Sling request into the service:
productService.getProduct(request);
unless the service genuinely needs to operate on a Sling request.
If ProductService accepts SlingHttpServletRequest, it now knows about the HTTP layer.
That makes the service harder to reuse from:
because those callers do not naturally have an HTTP request.
A cleaner contract is:
public interface ProductService {
ProductData getProduct(String productId);
}
The servlet extracts HTTP-specific input.
The service receives application-specific input.
The same rule applies to Sling Models.
A Sling Model may use a service:
@Model(
adaptables = SlingHttpServletRequest.class,
resourceType = "myproject/components/product"
)
public class ProductModel {
@OSGiService
private ProductService productService;
@ValueMapValue
private String productId;
public ProductData getProduct() {
return productService.getProduct(productId);
}
}
The model knows that productId came from an AEM component.
ProductService does not need to know that.
I would avoid service methods such as:
ProductData getProduct(Resource componentResource);
when the service only needs:
String productId
Passing the complete Resource can unnecessarily couple the service to the component's repository structure.
If the service genuinely performs repository operations on that resource, the design may be different. But I would make that dependency intentional rather than passing AEM objects through every layer by default.
External integrations are one of the clearest places where OSGi services help.
Suppose AEM needs product information from SAP.
A servlet should not contain:
String endpoint = "...";
String token = getAccessToken();
HttpRequest request = buildRequest(endpoint, productId, token);
HttpResponse response = client.execute(request);
ProductData product = parseResponse(response);
That integration can have its own service:
public interface ProductService {
ProductData getProduct(String productId)
throws ProductServiceException;
}
The implementation can own:
The servlet does not need to know how SAP is called.
The Sling Model does not need to know either.
They only depend on the product operation exposed by the service.
The cleanup servlet from the previous real-world scenario is another example.
The original servlet performed repository deletion directly:
resourceResolver.delete(child);
For a maintained implementation, I would move the cleanup operation behind a service:
public interface RepositoryCleanupService {
CleanupResult cleanup(String directoryPath);
}
The service can then own:
The servlet remains responsible for the HTTP boundary.
This also gives us one place to reason about how the repository operation obtains its ResourceResolver and which repository identity it should use.
That becomes especially important when the same repository operation can be triggered from more than one entry point.
Moving logic into an OSGi service does not mean creating a large service layer for every feature.
Sometimes the useful service is small:
public interface ExternalizerService {
String getPublishUrl(Resource resource);
}
Sometimes it represents a larger integration:
public interface CommerceService {
ProductData getProduct(String productId);
PriceData getPrice(String productId);
InventoryData getInventory(String productId);
}
The size is less important than whether the methods belong to the same responsibility.
When a service starts changing for unrelated reasons, I check whether it is doing too much.
For example, if CommerceService changes because:
then the class probably owns responsibilities that should not be together.
When I move code into a service, I check whether I can describe that service without referring to the caller.
For example:
ProductService retrieves product information.
NewsletterService manages newsletter subscriptions.
RepositoryCleanupService performs the controlled cleanup operation.
Those responsibilities still make sense whether the caller is a servlet, Sling Model, job, workflow, or another service.
This description is weaker:
ProductServletService contains the code used by ProductServlet.
Now the service is defined by where it is called from rather than what it does.
That usually means we moved methods into another class without creating a real service boundary.
An OSGi service should represent a capability of the application.
The servlet, Sling Model, workflow, job, or scheduler is simply one of the places that can use that capability.
At this point, the individual responsibilities are clearer.
The next problem is how they work together without pushing the same logic into multiple layers.
Consider a product feature that has both a component and a separate search endpoint.
The Sling Model may need product data while rendering:
@Model(
adaptables = SlingHttpServletRequest.class,
resourceType = "myproject/components/product"
)
public class ProductModel {
@OSGiService
private ProductService productService;
@ValueMapValue
private String productId;
private ProductData product;
@PostConstruct
protected void init() {
product = productService.getProduct(productId);
}
public ProductData getProduct() {
return product;
}
}
A servlet may use the same service for a separate HTTP request:
@Component(
service = Servlet.class,
property = {
ServletResolverConstants.SLING_SERVLET_RESOURCE_TYPES
+ "=myproject/components/productsearch",
ServletResolverConstants.SLING_SERVLET_SELECTORS
+ "=results",
ServletResolverConstants.SLING_SERVLET_EXTENSIONS
+ "=json",
ServletResolverConstants.SLING_SERVLET_METHODS
+ "=" + HttpConstants.METHOD_GET
}
)
public class ProductSearchServlet extends SlingSafeMethodsServlet {
@Reference
private ProductService productService;
@Override
protected void doGet(
SlingHttpServletRequest request,
SlingHttpServletResponse response) throws IOException {
String productId = request.getParameter("productId");
ProductData product =
productService.getProduct(productId);
// Build the HTTP response.
}
}
The callers are different.
The Sling Model participates in component rendering.
The servlet participates in an independent HTTP request.
But neither one needs to know how the product data is retrieved.
That responsibility remains in:
ProductService
This is where the service layer starts paying off. The implementation is not duplicated just because the same capability is needed from two different AEM entry points.
Separating layers does not mean every call needs another class in between.
For example:
public class ProductModel {
@OSGiService
private ProductModelService productModelService;
public String getTitle() {
return productModelService.getTitle(resource);
}
}
and:
@Component(service = ProductModelService.class)
public class ProductModelServiceImpl
implements ProductModelService {
public String getTitle(Resource resource) {
return resource.getValueMap()
.get("title", String.class);
}
}
does not give us much.
The service exists only to move a simple component property lookup into another class.
The Sling Model already owns the component-facing responsibility and can read that property directly.
A service layer becomes useful when there is an application capability worth separating:
productService.getProduct(productId);
or:
pricingService.getPrice(productId);
or:
repositoryCleanupService.cleanup(directoryPath);
The goal is not to create more layers.
The goal is to put a stable boundary around logic that should not belong to the caller.
Repository operations are common inside AEM services, but this is also where service design can become difficult to reason about.
Consider:
@Component(service = ContentService.class)
public class ContentServiceImpl implements ContentService {
@Override
public void updateContent(String path) {
// Obtain resolver
// Resolve resource
// Modify properties
// Commit
}
}
There are several decisions hidden inside that small method:
Those decisions should not be accidental.
If the service obtains its own service resolver, the service normally owns that resolver's lifecycle.
For example:
try (ResourceResolver resolver =
resourceResolverFactory.getServiceResourceResolver(
authenticationInfo)) {
Resource resource = resolver.getResource(path);
// Repository operation
resolver.commit();
}
A resolver obtained from:
request.getResourceResolver()
has a different lifecycle. The service must not treat a request-owned resolver as though it created and owns it.
This distinction becomes important when service methods start accepting ResourceResolver directly.
For example:
contentService.updateContent(
request.getResourceResolver(),
path
);
That can be a valid design when the operation is intentionally supposed to use the caller's repository identity.
But now the contract needs to be clear that the caller owns the resolver.
The service should not close it.
Repository access is important enough that we will cover ResourceResolver, the JCR API, service users, and repository permissions in the upcoming chapters rather than hiding those details inside this service chapter.
A similar question appears around commit().
Suppose a service method does this:
public void updateProduct(
ResourceResolver resolver,
ProductData product)
throws PersistenceException {
updateProductResource(resolver, product);
updateProductMetadata(resolver, product);
resolver.commit();
}
The service owns the complete repository operation, so committing there can be understandable.
Now imagine:
productService.updateProduct(resolver, product);
pricingService.updatePrice(resolver, price);
inventoryService.updateInventory(resolver, inventory);
and every service independently calls:
resolver.commit();
The caller no longer has a clear boundary around the complete operation.
A failure in the third call may happen after the first two services have already committed their changes.
There is no single rule saying every service must always commit or must never commit.
The important part is that ownership is deliberate.
The code should make it clear which layer owns the unit of repository work and where persistence happens.
A service method can make complex work look deceptively small:
productService.getProduct(productId);
Behind that method could be:
External API call
Authentication
Response transformation
Repository lookup
Fallback processing
That abstraction is useful, but callers still need to understand the behavior that affects them.
For example, calling an external API from a Sling Model means page rendering may now depend on that external system.
If the external API takes five seconds to respond, the fact that the call is hidden behind ProductService does not remove those five seconds from the page request.
The service boundary organizes responsibility.
It does not remove runtime cost.
When reviewing a service contract, I want to understand whether the method is:
That affects where it is safe to call the service.
A method used from a Sling Model during page rendering has different runtime expectations from a method triggered asynchronously by a Sling Job.
Suppose the same ProductService is used by:
Sling Model
Servlet
Sling Job
Reuse is useful only if the operation makes sense in all three contexts.
The Sling Model is running in the page-rendering request.
The servlet has its own HTTP lifecycle.
The Sling Job can execute outside the user's request and can tolerate different processing behavior.
If getProduct() is lightweight enough for all three, sharing the service is reasonable.
If the operation becomes expensive, the solution is not necessarily to create three copies of the logic.
It may mean the architecture around the service needs to change:
Request-time caller
|
v
Read cached/prepared data
Background processing
|
v
Call external system and update data
The OSGi service still owns the application capability, but the execution model is chosen based on where that capability is used.
That distinction becomes important later when we get into Sling Jobs and Schedulers.
One check I use when reviewing an OSGi service is to look at the calling code.
A servlet should become easier to understand:
ProductData product =
productService.getProduct(productId);
A Sling Model should become easier to understand:
product =
productService.getProduct(productId);
The caller should not need to know:
Which external endpoint is used
How authentication works
How the response is mapped
Which implementation provides the service
How integration-specific failures are handled internally
If those details are still leaking into every caller, the service boundary has not separated much.
At the same time, I do not want the abstraction to hide behavior that affects the caller, such as an operation being slow, state-changing, or capable of failing because of an external dependency.
A useful service contract hides implementation detail without hiding the operational behavior that callers need to design around.
One detail about OSGi services becomes especially important once the application starts receiving real traffic:
the service instance may be shared by many callers at the same time.
Consider:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
private String currentProductId;
@Override
public ProductData getProduct(String productId) {
currentProductId = productId;
// Process product using currentProductId
return loadProduct();
}
}
At first glance, this may look harmless.
But currentProductId is instance state.
If the same service instance is being used concurrently, two requests can interfere with each other.
Request A may execute:
currentProductId = "PRODUCT-A";
Before it finishes, Request B may execute:
currentProductId = "PRODUCT-B";
Request A can now continue with state that belongs to Request B.
This is not an OSGi-specific Java problem. It is normal concurrent access to shared mutable state.
OSGi simply makes it easy to encounter because application services are commonly shared across many consumers.
For request-specific values, I keep the data inside the method:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
@Override
public ProductData getProduct(String productId) {
String normalizedProductId =
normalizeProductId(productId);
return loadProduct(normalizedProductId);
}
private ProductData loadProduct(String productId) {
// Retrieve product
return null;
}
private String normalizeProductId(String productId) {
return productId.trim();
}
}
Now:
productId
and:
normalizedProductId
belong to that method invocation.
One request does not need to store its working data in fields shared with another request.
The same principle applies to values such as:
Current user
Current resource path
Request parameters
Temporary response data
Current product
Current locale
Current ResourceResolver
If the value belongs to one operation, it normally belongs to that operation's method scope.
This is a particularly dangerous variation:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
private SlingHttpServletRequest request;
public void setRequest(SlingHttpServletRequest request) {
this.request = request;
}
}
and somewhere else:
productService.setRequest(request);
productService.getProduct(productId);
Now the service contains request-specific state.
Under concurrent traffic, another request can replace that field before the first operation finishes.
It also creates an unnecessary dependency between the service and the HTTP request lifecycle.
If a service genuinely needs information from the request, pass the specific value required by the operation:
productService.getProduct(productId, locale);
rather than storing:
SlingHttpServletRequest
inside the service instance.
I also avoid storing a request-specific ResourceResolver in a service field.
For example:
private ResourceResolver resourceResolver;
followed by:
public void setResourceResolver(
ResourceResolver resourceResolver) {
this.resourceResolver = resourceResolver;
}
creates both ownership and concurrency problems.
Which request owns that resolver?
Who closes it?
Can another thread replace it?
Is the resolver still valid when another service method uses it?
A resolver should have a clear lifecycle.
If the caller owns it:
contentService.updateContent(
request.getResourceResolver(),
path
);
the service uses it for that operation and does not close it.
If the service creates its own service resolver:
try (ResourceResolver resolver =
resourceResolverFactory.getServiceResourceResolver(
authenticationInfo)) {
// Repository operation
}
then the service owns that resolver and closes it when the operation finishes.
Not every field inside an OSGi service is wrong.
A service may keep configuration-derived state:
private String endpoint;
private int timeout;
initialized during activation:
@Activate
protected void activate(ProductServiceConfig config) {
this.endpoint = config.endpoint();
this.timeout = config.timeout();
}
Those values describe the service configuration rather than one particular request.
The distinction I make is:
Does this field describe the service itself, or does it describe the current operation?
Configuration such as:
API endpoint
Connection timeout
Feature flag
Configured repository root
can reasonably belong to the component.
Values such as:
Current product ID
Current request
Current user
Current resource
Current response
Temporary processing result
usually should not.
If configuration can change while the component remains active, the implementation also needs to publish updated configuration state safely to concurrent callers. That becomes relevant when we introduce @Modified and runtime configuration changes.
Consider:
@Reference
private CommerceClient commerceClient;
We do not normally create a new CommerceClient for every call to:
getProduct(productId);
The referenced service may itself be shared.
That means thread-safety needs to continue through the dependency chain.
For example:
ProductServlet
uses
ProductService
uses
CommerceClient
Making ProductService stateless does not help if CommerceClient stores the current request data in mutable instance fields.
Each service should be designed according to its own concurrency model.
Sometimes mutable shared state is intentional.
A common example is caching:
private final Map<String, ProductData> cache =
new HashMap<>();
and:
public ProductData getProduct(String productId) {
if (cache.containsKey(productId)) {
return cache.get(productId);
}
ProductData product =
loadProduct(productId);
cache.put(productId, product);
return product;
}
Now the shared state is deliberate, but concurrency becomes part of the design.
A normal HashMap should not simply become a shared application cache without considering concurrent access.
There are also larger architectural questions:
For some cases a thread-safe local structure may be enough.
For others, caching belongs at another layer entirely.
The important point is that once an OSGi service stores mutable shared state, we need to design for that state explicitly.
If concurrency problems appear, it can be tempting to do this:
public synchronized ProductData getProduct(
String productId) {
// Processing
}
That may prevent concurrent execution of the method on that instance, but it can also serialize requests through the service.
If the method performs network or repository work, one slow request may now block other callers waiting for the same monitor.
Sometimes synchronization is required around a specific shared resource.
But if the only reason we need it is because request-specific data was stored in instance fields, the better fix is usually to remove that shared state.
For example:
public ProductData getProduct(String productId) {
ProductData product =
loadProduct(productId);
return product;
}
is easier to reason about than protecting:
private String currentProductId;
private ProductData currentProduct;
with synchronization.
OSGi supports different service scopes, but most application services we write in AEM are designed around the normal shared-service model.
I would not change service scope simply to work around unsafe mutable fields.
If a service contains request-specific state and only works correctly when every caller gets a separate instance, I first check whether that state belongs in the service at all.
For most business and integration services, a stateless or carefully managed shared implementation is easier to operate.
Thread-safety issues are difficult because they may never appear during local testing.
A developer sends one request:
PRODUCT-A
and gets the correct response.
The code looks fine.
Under concurrent production traffic, several operations can execute against the same service instance at nearly the same time.
Now the result depends on timing.
That can produce bugs such as:
These problems can be difficult to reproduce because adding logs or debugging can change the timing that caused the failure.
For OSGi services, I therefore review shared fields before waiting for a concurrency bug to appear.
A useful default is simple:
Keep operation-specific state local to the operation.
Use component fields for state that genuinely belongs to the component, and treat intentionally shared mutable state as a concurrency design problem rather than ordinary storage.
Configuration is one of the main reasons an OSGi service needs component-level state.
Consider a product integration service that needs:
We could hardcode those values:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
private static final String ENDPOINT =
"https://api.example.com/products";
private static final int CONNECTION_TIMEOUT = 5000;
}
That immediately creates a deployment problem.
The endpoint may be different between:
Timeout requirements may also change without requiring a Java code change.
These values belong to runtime configuration rather than the implementation itself.
With OSGi Metatype annotations, we can define the configuration expected by the service.
For example:
@ObjectClassDefinition(
name = "My Project - Product Service"
)
public @interface ProductServiceConfig {
@AttributeDefinition(
name = "API Endpoint",
description = "Base endpoint used to retrieve product information"
)
String endpoint();
@AttributeDefinition(
name = "Connection Timeout",
description = "Connection timeout in milliseconds"
)
int connectionTimeout() default 5000;
@AttributeDefinition(
name = "Read Timeout",
description = "Read timeout in milliseconds"
)
int readTimeout() default 10000;
@AttributeDefinition(
name = "Enabled",
description = "Controls whether the product integration is enabled"
)
boolean enabled() default true;
}
This interface describes the configuration available to the component.
It does not contain the product integration logic.
That remains in the service.
The component can be associated with the configuration using @Designate:
@Component(service = ProductService.class)
@Designate(ocd = ProductServiceConfig.class)
public class ProductServiceImpl implements ProductService {
private String endpoint;
private int connectionTimeout;
private int readTimeout;
private boolean enabled;
@Activate
protected void activate(ProductServiceConfig config) {
this.endpoint = config.endpoint();
this.connectionTimeout =
config.connectionTimeout();
this.readTimeout =
config.readTimeout();
this.enabled =
config.enabled();
}
}
Now the service implementation does not decide what the production endpoint should be.
It receives the configured value when the component is activated.
The same implementation can run in different environments with different configuration.
I try to keep configuration close to the responsibility of the component using it.
For example, these values make sense for ProductService:
endpoint
connectionTimeout
readTimeout
enabled
This is less useful:
productEndpoint
emailEndpoint
damCleanupRoot
workflowModel
newsletterEnabled
inside one generic configuration.
That usually indicates the configuration is collecting unrelated application settings rather than describing one service.
The same service-boundary rule applies to configuration.
If the settings change for unrelated reasons, they probably do not belong to the same component configuration.
Once configuration belongs to the service, the servlet should not need to retrieve it separately.
I would avoid a design where the servlet does this:
String endpoint = getProductEndpointFromConfiguration();
ProductData product =
productService.getProduct(
endpoint,
productId
);
Now the servlet knows configuration details that belong to the product integration.
A cleaner call remains:
ProductData product =
productService.getProduct(productId);
The service already knows which configured endpoint it should use.
This keeps configuration knowledge inside the component that owns the behavior.
This connects directly to the thread-safety discussion.
These fields:
private String endpoint;
private int connectionTimeout;
private int readTimeout;
describe the service.
They are different from fields such as:
private String currentProductId;
private SlingHttpServletRequest currentRequest;
private Resource currentResource;
which describe a particular operation.
Configuration is component-level state.
Request-specific values should remain operation-level state.
That distinction keeps the service much easier to reason about under concurrent use.
A component can also define a modification callback:
@Modified
protected void modified(ProductServiceConfig config) {
this.endpoint = config.endpoint();
this.connectionTimeout =
config.connectionTimeout();
this.readTimeout =
config.readTimeout();
this.enabled =
config.enabled();
}
This allows the implementation to react when its configuration changes without putting configuration lookup logic inside every business method.
Once we support runtime updates, however, we also need to think about concurrent callers observing that configuration state.
Instead of spreading configuration assignments across lifecycle methods, I prefer keeping the update in one place.
For example:
@Activate
@Modified
protected void configure(ProductServiceConfig config) {
this.endpoint = config.endpoint();
this.connectionTimeout =
config.connectionTimeout();
this.readTimeout =
config.readTimeout();
this.enabled =
config.enabled();
}
For a small set of simple configuration values this can be sufficient, but services with more complex mutable configuration need deliberate thread-safety around how updated state is published to callers.
Suppose the product service cannot work without an endpoint.
I would rather detect that during component configuration than wait until the first production request reaches:
productService.getProduct(productId);
For example:
@Activate
@Modified
protected void configure(ProductServiceConfig config) {
String configuredEndpoint =
config.endpoint();
if (configuredEndpoint == null
|| configuredEndpoint.trim().isEmpty()) {
throw new IllegalStateException(
"Product API endpoint is not configured"
);
}
this.endpoint = configuredEndpoint;
this.connectionTimeout =
config.connectionTimeout();
this.readTimeout =
config.readTimeout();
this.enabled =
config.enabled();
}
Now an invalid required configuration becomes visible as a component lifecycle problem instead of appearing later as an unrelated integration failure.
The same principle applies to values such as:
Required repository roots Required external endpoints Invalid timeout values Required feature-specific identifiers
Optional configuration is different. If a value genuinely has a valid default or the feature can operate without it, activation does not need to fail.
Credentials, tokens, passwords, and other sensitive values should not be hardcoded into the service implementation:
private static final String API_PASSWORD =
"my-production-password";
Apart from the security problem, this also ties credentials to the application artifact.
How secrets are supplied and managed depends on the AEM environment and deployment architecture. The service should consume the required value through the approved configuration or secret-management mechanism rather than embedding it in source code.
We will keep the detailed environment-specific configuration discussion for Chapter 28.
The implementation should ideally remain the same across environments.
What changes is configuration.
The service code still calls:
productService.getProduct(productId);
while the runtime configuration determines the endpoint and operational settings appropriate for that environment.
That separation is one of the practical benefits of combining OSGi services with OSGi configuration.
The Java implementation owns behavior.
The OSGi configuration supplies environment-dependent values.
With the service contract, dependencies, lifecycle, thread-safety, and configuration now separated, we can put those pieces together in one complete service implementation before moving into production troubleshooting.
We have looked at the individual parts separately:
@ReferenceNow we can put them together in one example.
Assume AEM needs to retrieve product information from an external commerce system.
The service should expose a product operation without making the caller understand the integration details.
Start with the capability:
public interface ProductService {
ProductData getProduct(String productId)
throws ProductServiceException;
}
There is nothing AEM-specific in this contract.
The caller does not need to know:
ProductServiceIt asks for one application operation:
productService.getProduct(productId);
The integration needs environment-specific settings:
@ObjectClassDefinition(
name = "My Project - Product Service"
)
public @interface ProductServiceConfig {
@AttributeDefinition(
name = "API Endpoint"
)
String endpoint();
@AttributeDefinition(
name = "Connection Timeout"
)
int connectionTimeout() default 5000;
@AttributeDefinition(
name = "Read Timeout"
)
int readTimeout() default 10000;
@AttributeDefinition(
name = "Enabled"
)
boolean enabled() default true;
}
The endpoint and timeout values are no longer part of the Java implementation.
They belong to the runtime configuration of the service.
Now the implementation can combine configuration with its required dependencies:
@Component(service = ProductService.class)
@Designate(ocd = ProductServiceConfig.class)
public class ProductServiceImpl implements ProductService {
@Reference
private CommerceClient commerceClient;
private volatile ServiceSettings settings;
@Activate
@Modified
protected void configure(ProductServiceConfig config) {
String endpoint = config.endpoint();
if (endpoint == null
|| endpoint.trim().isEmpty()) {
throw new IllegalStateException(
"Product API endpoint is not configured"
);
}
this.settings = new ServiceSettings(
endpoint,
config.connectionTimeout(),
config.readTimeout(),
config.enabled()
);
}
@Override
public ProductData getProduct(String productId)
throws ProductServiceException {
if (productId == null
|| productId.trim().isEmpty()) {
throw new IllegalArgumentException(
"Product ID must not be empty"
);
}
ServiceSettings currentSettings = settings;
if (!currentSettings.isEnabled()) {
throw new ProductServiceException(
"Product integration is disabled"
);
}
return commerceClient.getProduct(
currentSettings.getEndpoint(),
productId,
currentSettings.getConnectionTimeout(),
currentSettings.getReadTimeout()
);
}
}
The ServiceSettings object can be an immutable internal class:
final class ServiceSettings {
private final String endpoint;
private final int connectionTimeout;
private final int readTimeout;
private final boolean enabled;
ServiceSettings(
String endpoint,
int connectionTimeout,
int readTimeout,
boolean enabled) {
this.endpoint = endpoint;
this.connectionTimeout = connectionTimeout;
this.readTimeout = readTimeout;
this.enabled = enabled;
}
String getEndpoint() {
return endpoint;
}
int getConnectionTimeout() {
return connectionTimeout;
}
int getReadTimeout() {
return readTimeout;
}
boolean isEnabled() {
return enabled;
}
}
There is a deliberate reason for keeping the configuration together here.
Instead of updating several mutable fields independently when configuration changes, the service creates one immutable settings object and replaces the reference.
A caller sees one configuration snapshot for that operation.
The service still needs an appropriate concurrency strategy for its configuration lifecycle, but this is easier to reason about than request-specific mutable fields spread across the component.
ProductService does not need to contain every detail of making an HTTP request.
For example:
public interface CommerceClient {
ProductData getProduct(
String endpoint,
String productId,
int connectionTimeout,
int readTimeout)
throws ProductServiceException;
}
Its implementation can own the actual integration mechanics:
@Component(service = CommerceClient.class)
public class CommerceClientImpl
implements CommerceClient {
@Override
public ProductData getProduct(
String endpoint,
String productId,
int connectionTimeout,
int readTimeout)
throws ProductServiceException {
// Build the external request
// Apply authentication
// Execute the request
// Check the response
// Map the external response to ProductData
return null;
}
}
I am intentionally leaving the HTTP-client implementation out of this example.
The correct implementation depends on the HTTP library and integration standards already used by the project.
The important part here is the service boundary.
ProductServiceImpl owns the product operation.
CommerceClient owns communication with the external system.
The HTTP endpoint can now focus on HTTP concerns:
@Component(
service = Servlet.class,
property = {
ServletResolverConstants.SLING_SERVLET_RESOURCE_TYPES
+ "=myproject/components/product",
ServletResolverConstants.SLING_SERVLET_SELECTORS
+ "=details",
ServletResolverConstants.SLING_SERVLET_EXTENSIONS
+ "=json",
ServletResolverConstants.SLING_SERVLET_METHODS
+ "=" + HttpConstants.METHOD_GET
}
)
public class ProductServlet
extends SlingSafeMethodsServlet {
@Reference
private ProductService productService;
@Override
protected void doGet(
SlingHttpServletRequest request,
SlingHttpServletResponse response)
throws IOException {
String productId =
request.getParameter("productId");
if (productId == null
|| productId.trim().isEmpty()) {
response.sendError(
HttpServletResponse.SC_BAD_REQUEST,
"productId is required"
);
return;
}
try {
ProductData product =
productService.getProduct(productId);
response.setContentType(
"application/json"
);
// Serialize ProductData using the
// JSON library already used by the project.
} catch (ProductServiceException e) {
response.sendError(
HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"Unable to retrieve product information"
);
}
}
}
Notice what is missing from the servlet.
It does not contain:
Commerce endpoint configuration
Authentication handling
External request construction
Response mapping
Integration implementation details
Those responsibilities do not belong to the HTTP boundary.
The same service can also be consumed by a Sling Model:
@Model(
adaptables = SlingHttpServletRequest.class,
resourceType = "myproject/components/product"
)
public class ProductModel {
@ValueMapValue
private String productId;
@OSGiService
private ProductService productService;
private ProductData product;
@PostConstruct
protected void init() {
if (productId == null
|| productId.trim().isEmpty()) {
return;
}
try {
product =
productService.getProduct(productId);
} catch (ProductServiceException e) {
// Log according to the project's
// error-handling strategy.
}
}
public ProductData getProduct() {
return product;
}
}
The service did not need another implementation because the caller changed.
That is the boundary we were trying to create.
The servlet uses the capability from an HTTP endpoint.
The Sling Model uses it while preparing component data.
Both depend on:
ProductService
rather than implementing the product integration themselves.
One architectural concern still remains here.
Because this Sling Model calls the external integration during initialization, page rendering now depends on that external call.
That may be acceptable for some requirements and a poor design for others.
Moving the call into an OSGi service organizes the code, but it does not remove the latency or availability dependency from the page request.
That decision still has to be made at the application architecture level.
The responsibilities now have clearer boundaries.
| Layer | Responsibility |
|---|---|
| Sling Servlet | HTTP request validation and HTTP response |
| Sling Model | Component-facing data preparation |
ProductService | Product capability exposed to the application |
ProductServiceImpl | Product operation and coordination |
CommerceClient | External-system communication |
| OSGi Configuration | Environment-specific runtime settings |
This does not mean every AEM feature needs this many classes.
The structure should follow the actual complexity of the feature.
For a simple component, a Sling Model may be enough.
For a feature involving external integrations, multiple callers, configuration, and reusable business behavior, the service boundary becomes much more valuable.
The next practical question is what happens when one of these pieces does not come up correctly in AEM.
That takes us into the OSGi problems we actually see at runtime: unsatisfied references, activation failures, missing configuration, incorrect service registration, and dependency chains that prevent components from becoming available.
Once an OSGi service is deployed, a failure does not always appear as a normal Java exception from the service method.
Sometimes the service method is never reached because the component itself is not available.
That is why OSGi troubleshooting starts slightly differently from debugging ordinary application code.
Suppose a servlet contains:
@Reference
private ProductService productService;
and the expected endpoint is not working.
The first question should not automatically be:
What is wrong inside getProduct()?
The problem may exist before getProduct() can ever be called.
Several runtime conditions can prevent ProductService from becoming available.
Consider:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
@Reference
private CommerceClient commerceClient;
}
ProductServiceImpl depends on a CommerceClient service.
If no matching CommerceClient is available, that required reference cannot be satisfied.
The problem is not necessarily inside:
ProductServiceImpl
The dependency may be the actual failure.
For example:
@Component(service = CommerceClient.class)
public class CommerceClientImpl implements CommerceClient {
@Reference
private AuthenticationService authenticationService;
}
Now CommerceClientImpl has its own required dependency.
If AuthenticationService is unavailable, the failure can propagate through the dependency graph.
From the top of the feature we may see that ProductService is unavailable, while the original problem is further down the chain.
When I see an unsatisfied component, I check the unsatisfied reference first and then inspect the component that is supposed to provide that service.
This problem is easy to create during refactoring.
Suppose we have:
public interface ProductService {
ProductData getProduct(String productId);
}
and:
@Component(service = ProductServiceImpl.class)
public class ProductServiceImpl implements ProductService {
}
but the consumer expects:
@Reference
private ProductService productService;
The implementation class exists, but the consumer is looking for a service registered under the ProductService contract.
I prefer making the intended service registration explicit:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
}
When a reference cannot be resolved, checking the implementation class alone is not enough.
I also check which service type was actually registered.
Earlier we used:
@Reference(target = "(source=sap)")
private ProductService productService;
That reference requires more than a ProductService.
The matching service also needs the expected property.
For example:
@Component(
service = ProductService.class,
property = {
"source=sap"
}
)
public class SapProductService implements ProductService {
}
If the property changes to:
"source=commerce"
the service still exists.
But this reference:
@Reference(target = "(source=sap)")
no longer matches it.
This is why a statement such as:
ProductService is registered, so @Reference should work.
is not always enough.
When a target filter exists, I check the service properties as well as the interface.
A component may have all of its required service references available and still fail during activation.
For example:
@Activate
protected void activate(ProductServiceConfig config) {
String endpoint = config.endpoint();
if (endpoint == null
|| endpoint.trim().isEmpty()) {
throw new IllegalStateException(
"Product API endpoint is not configured"
);
}
this.endpoint = endpoint;
}
If the required endpoint is missing, activation fails.
From the caller's point of view, the result may still look like:
ProductService is unavailable
But the root cause is different from an unsatisfied @Reference.
The component reached its activation logic and failed there.
That distinction matters because the troubleshooting path changes.
For an unsatisfied reference, I inspect service dependencies.
For an activation failure, I inspect the activation error, configuration, and logs.
Configuration problems do not always mean the configuration object is completely absent.
The configuration may exist but contain an invalid value.
For example:
endpoint=
or:
connectionTimeout=-1
or a configured identifier may no longer match what the external system expects.
Some of these problems should be rejected during activation.
Others may only become visible when the service performs an operation.
For example, this is syntactically a valid endpoint string:
https://old-api.example.com/products
but the external system may no longer be available there.
The component can activate successfully and still fail during actual service calls.
That gives us two different categories:
Component configuration failure
The service cannot become correctly configured and should fail early.
Operational integration failure
The service is configured and active, but an external dependency fails while an operation is running.
Those should not be treated as the same problem.
Suppose both of these are registered:
@Component(service = ProductService.class)
public class SapProductService implements ProductService {
}
and:
@Component(service = ProductService.class)
public class CommerceProductService implements ProductService {
}
while the consumer only declares:
@Reference
private ProductService productService;
If multiple implementations are intentionally available, the application needs a deliberate selection strategy.
Depending on the design, that may involve:
What I would avoid is allowing multiple implementations to accumulate and assuming the intended one will always be selected.
The service registry should reflect an intentional architecture.
Service dependencies can also form a cycle.
For example:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
@Reference
private PricingService pricingService;
}
and:
@Component(service = PricingService.class)
public class PricingServiceImpl implements PricingService {
@Reference
private ProductService productService;
}
Now ProductService requires PricingService, while PricingService requires ProductService.
With required static dependencies, this kind of cycle can prevent the components from reaching a usable state.
The first reaction should not be to make one reference optional just to break the cycle.
The dependency structure itself deserves review.
Often the cycle tells us that responsibilities are mixed.
There may be a lower-level capability that both services should depend on instead.
For example, instead of:
ProductService depends on PricingService
PricingService depends on ProductService
the design may need a separate repository or integration capability that both can use without depending on each other.
Circular dependencies are often more useful as an architecture warning than as something to work around with annotations.
A component can be completely active and still behave incorrectly.
For example:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
private String currentProductId;
@Override
public ProductData getProduct(String productId) {
this.currentProductId = productId;
return loadProduct(currentProductId);
}
}
Nothing here prevents the component from activating.
There is no unsatisfied reference.
There is no configuration failure.
The problem appears when concurrent callers modify the same field.
This type of failure is different because the OSGi runtime may report the component as healthy.
The bug exists inside the service's concurrency design.
That is why component state alone cannot tell us whether the implementation itself is thread-safe.
This is worth repeating because it causes unnecessary debugging.
Seeing:
Bundle: Active
does not prove:
ProductServiceImpl: Active and available
The bundle and the Declarative Services component have related but different lifecycle states.
A bundle can be active while a component inside it has:
So when the application says a service is unavailable, checking only the bundle state is not enough.
I move from the bundle to the actual component and then to its references.
This is where configuration and runtime dependencies usually become more interesting.
The same code may work locally because:
and fail elsewhere because one of those conditions changes.
I do not start by assuming the Java implementation is different.
I compare the runtime environment around the service:
The same service implementation can behave differently when its runtime dependencies are different.
One useful distinction when troubleshooting OSGi services is whether the component itself is unavailable or whether an active component failed while executing an operation.
If this cannot be resolved:
@Reference
private ProductService productService;
we are dealing with component/service availability.
If this executes:
productService.getProduct(productId);
and then fails because SAP returned an error, the service was available.
The failure happened during the operation.
Those two situations lead to different debugging paths.
For component availability, I inspect OSGi state and dependencies.
For operation failures, I inspect the service implementation, external systems, repository operations, input, and application logs.
Keeping those two categories separate makes OSGi troubleshooting much faster.
When an OSGi service is unavailable, I try not to jump randomly between bundles, logs, configuration, and Java code.
I first determine which type of failure I am dealing with.
There are two very different starting points:
The service itself is unavailable
or:
The service is active, but the operation is failing
That distinction removes a lot of unnecessary debugging.
Suppose a servlet depends on:
@Reference
private ProductService productService;
and the servlet component is not becoming available.
I first check the Declarative Services component that should provide:
ProductService
I want to know whether ProductServiceImpl is actually available as a component and whether its required dependencies are satisfied.
I do not stop after confirming that the project bundle is active.
An active bundle tells me that the bundle started.
It does not prove that every component inside that bundle successfully satisfied its dependencies and activated.
This is usually the first place I separate a bundle problem from a component problem.
Consider:
@Component(service = ProductService.class)
public class ProductServiceImpl implements ProductService {
@Reference
private CommerceClient commerceClient;
}
If ProductServiceImpl is unsatisfied, I check its references.
If the missing reference is:
CommerceClient
I move to the component that should provide CommerceClient.
For example:
@Component(service = CommerceClient.class)
public class CommerceClientImpl
implements CommerceClient {
@Reference
private AuthenticationService authenticationService;
}
If CommerceClientImpl is also unsatisfied, I continue down the dependency chain.
The useful troubleshooting path is therefore not:
ProductService is broken
It is to find the first required dependency in the chain that cannot be satisfied.
That is often where the actual problem starts.
If the provider component exists, I check what it actually registers.
The consumer expects:
@Reference
private ProductService productService;
The provider should expose the corresponding contract:
@Component(service = ProductService.class)
public class ProductServiceImpl
implements ProductService {
}
During refactoring, it is possible to have the implementation class present while the expected service interface is not registered the way the consumer expects.
I therefore check three things together:
Consumer reference
Provider component
Registered service type
The Java class existing in the bundle is not enough.
If the reference contains:
@Reference(target = "(source=sap)") private ProductService productService;
then finding a registered ProductService is only half of the check.
The service must also have a matching property.
For example:
@Component(
service = ProductService.class,
property = {
"source=sap"
}
)
public class SapProductService
implements ProductService {
}
A small mismatch such as:
source=commerce
means the reference does not match even though the interface itself is registered.
Whenever a target filter is involved, I check the actual service properties rather than only the service interface.
If all required references are available, configuration is the next place I look.
For our product service:
@Designate(ocd = ProductServiceConfig.class)
I check whether the expected configuration exists and whether the values make sense for that environment.
For example:
endpoint
connectionTimeout
readTimeout
enabled
A configuration can exist and still be wrong.
The endpoint may be empty.
A feature may accidentally be disabled.
A timeout may contain an invalid value.
The configuration may also be correct syntactically but point to an old external endpoint.
I separate those cases from service-resolution problems.
If the component cannot activate because required configuration is invalid, that is a component lifecycle problem.
If the component is active but the configured external endpoint is unavailable, that is an operation-time integration problem.
Suppose the service contains:
@Activate
@Modified
protected void configure(
ProductServiceConfig config) {
String endpoint = config.endpoint();
if (endpoint == null
|| endpoint.trim().isEmpty()) {
throw new IllegalStateException(
"Product API endpoint is not configured"
);
}
// Apply configuration
}
All references may be satisfied, but the component can still fail while executing this lifecycle method.
At this point I look for the activation failure and its underlying exception.
I do not try to solve an activation exception by changing @Reference unless the error actually points to a reference problem.
This distinction matters:
UNSATISFIED REFERENCE
and:
ACTIVATION FAILURE
may both result in an unavailable service, but they have different causes.
Sometimes the expected provider component is not available at all.
Then I move down another level and check the bundle containing it.
At that point I look for problems such as:
This is different from a component whose bundle is healthy but whose required service reference is unsatisfied.
I normally reach bundle-level investigation after checking the component because it keeps the troubleshooting path focused.
Logs become much more useful once I know what I am looking for.
Instead of searching for every occurrence of:
ProductService
I look for the failure associated with the component lifecycle or operation I have already identified.
For example:
Component activation failure
Missing required dependency
Configuration validation failure
External API exception
Repository exception
The first meaningful failure in the dependency chain is usually more useful than later errors caused by that failure.
A downstream component may report that ProductService is unavailable.
That message tells me the effect.
The earlier error explaining why CommerceClientImpl could not activate may tell me the cause.
Suppose I confirm:
and this still fails:
productService.getProduct(productId);
At this point, I stop treating the problem primarily as OSGi service availability.
The failure is now inside the operation or one of the systems it uses.
I check:
This prevents a lot of wasted time inspecting component state when the component itself is already healthy.
If the service works correctly in normal testing but produces inconsistent results under concurrent traffic, I inspect shared component state.
Fields such as:
private String currentProductId;
private ProductData currentProduct;
private ResourceResolver currentResolver;
immediately deserve attention if they contain operation-specific values.
An OSGi component can appear completely healthy from the runtime's point of view while still containing a concurrency bug.
The component state tells us whether OSGi successfully manages the component.
It does not prove that our implementation is thread-safe.
For a service that is unavailable, my investigation usually follows this order:
If the service is already active, I switch the investigation:
That separation between component availability and operation execution is one of the most useful habits when debugging OSGi services in AEM.
Once the service is running correctly, there is one final design question for this chapter: how many services should an application have, where should those boundaries sit, and how do we avoid turning the OSGi service layer into another collection of oversized classes?
At the developer level, an OSGi service often starts with a simple requirement:
Move reusable logic out of the servlet or Sling Model.
At the architecture level, the question is different:
What capability should this service own, and which parts of the application should depend on it?
That decision matters because service boundaries tend to survive much longer than the first implementation.
A poorly defined service can slowly become the class through which every feature passes.
I prefer service names that describe what the application can do.
For example:
ProductService
PricingService
InventoryService
NewsletterService
RepositoryCleanupService
Each name suggests a responsibility.
Compare that with:
CommonService
UtilityService
HelperService
AemService
ApplicationService
Those names do not tell us what the service owns.
A CommonService may start with two methods:
String getPublishUrl(String path);
Resource getResource(String path);
Six months later it may contain:
sendEmail(...)
getProduct(...)
activatePage(...)
updateMetadata(...)
callExternalApi(...)
createWorkflow(...)
deleteResource(...)
The problem is not the number of methods alone.
The problem is that unrelated features now depend on the same service for unrelated reasons.
That makes the service difficult to change without affecting multiple parts of the application.
Consider:
@Component(service = ContentService.class)
public class ContentServiceImpl
implements ContentService {
@Reference
private ResourceResolverFactory resolverFactory;
@Reference
private ProductService productService;
@Reference
private EmailService emailService;
@Reference
private WorkflowService workflowService;
@Reference
private Externalizer externalizer;
@Reference
private Replicator replicator;
// Many unrelated operations
}
Having several @Reference fields does not automatically make a component badly designed.
But this structure makes me ask why one service needs all of these capabilities.
If the same class:
then the service boundary is probably too broad.
Splitting the responsibility may give us clearer capabilities:
ProductService
AssetMetadataService
NotificationService
ContentPublicationService
The exact split depends on the application.
The important part is that each service has a reason to change that matches its responsibility.
Suppose a servlet uses:
@Reference
private ProductService productService;
and ProductService uses:
@Reference
private CommerceClient commerceClient;
The dependency direction is understandable.
The HTTP layer depends on the application capability.
The application capability depends on the integration capability.
What I try to avoid is making lower-level services depend back on the caller.
For example, CommerceClient should not need:
@Reference
private ProductServlet productServlet;
The integration layer should not depend on the servlet that happens to use it.
The same applies to Sling Models.
A reusable application service should not need to know which component model called it.
Keeping dependency direction clear makes the service usable from other entry points later.
This is another decision worth making deliberately.
I use an OSGi service when the class benefits from OSGi runtime management.
Typical reasons include:
A plain Java class is often enough for internal implementation details.
For example:
final class ProductResponseMapper {
ProductData map(ProductApiResponse response) {
// Mapping
}
}
If this mapper only belongs to the product integration implementation, has no OSGi dependencies, and needs no runtime configuration, registering it as another service may not provide much value.
ProductServiceImpl can own or use that helper as a normal Java object.
Not every class needs:
@Component
and not every dependency needs:
@Reference
A smaller OSGi service graph is often easier to understand and troubleshoot.
A servlet owns HTTP concerns.
That includes things such as:
Request parameters
HTTP methods
Response status
Response content type
HTTP-specific validation
A Sling Model owns component-facing concerns:
Authored properties
Component resource
Component presentation data
HTL-facing getters
An OSGi service should not absorb those concerns simply because moving code into a service feels cleaner.
For example, I prefer:
productService.getProduct(productId);
over:
productService.processRequest(request, response);
for a product capability.
The second contract has effectively moved the servlet into another class.
The Java file changed.
The architectural boundary did not.
When a service performs repository operations, I want the security model to be understandable from the design.
There is an important difference between:
contentService.update(
request.getResourceResolver(),
path
);
and a service obtaining its own resolver through:
resourceResolverFactory
.getServiceResourceResolver(...);
The first operation uses a resolver supplied by the caller.
The second operates using a service identity configured for the backend capability.
Neither approach should be selected simply because it is convenient.
The correct choice depends on whose repository permissions are supposed to govern the operation.
The service boundary should not hide that decision.
For repository access, see Service Users and Repository Permissions for backend identity, resolver ownership, and ACL design.
A common reason for creating a service is:
We need this logic in more than one place.
That is valid.
But reuse does not mean all reusable code belongs in one central service.
For example, these operations may all be reusable:
Retrieve product
Calculate price
Send notification
Update asset metadata
Putting all four into:
ApplicationService
creates centralization, not useful service design.
Separate capabilities can still collaborate when required.
For example:
@Component(service = ProductImportService.class)
public class ProductImportServiceImpl
implements ProductImportService {
@Reference
private ProductService productService;
@Reference
private AssetMetadataService assetMetadataService;
@Override
public void importProduct(String productId) {
ProductData product =
productService.getProduct(productId);
assetMetadataService.updateFromProduct(product);
}
}
Here ProductImportService coordinates a specific application operation.
It does not need to absorb the implementations of both capabilities.
Individual @Reference declarations are easy to add.
The architecture created by hundreds of them is harder to see.
Over time, I watch for:
These are usually more important than whether a particular service has five methods or seven methods.
The service graph should help explain the application.
It should not become another layer that developers need to untangle before they can understand where a feature actually runs.
OSGi services give us a runtime-managed service layer for responsibilities that should not live directly inside Sling Models, Servlets, workflows, jobs, or other entry points.
The important part is not adding @Component to a Java class.
It is defining a useful service boundary.
In this chapter, we saw that an OSGi service can provide:
@ReferenceWe also saw the production side of that model.
A bundle can be active while one of its components is still unavailable.
A required reference can keep a component unsatisfied.
A component can satisfy all of its references and still fail during activation.
A service can be completely active and still contain concurrency problems because of shared mutable state.
That is why debugging OSGi services requires looking beyond the Java implementation.
The runtime state matters.
For service design, the boundary I try to maintain is straightforward:
Servlets handle HTTP concerns.
Sling Models prepare component-facing data.
OSGi services own reusable application capabilities.
Integration clients handle communication with external systems.
Plain Java classes remain useful for internal implementation details that do not need OSGi lifecycle, configuration, or service registration.
OSGi should help us separate responsibilities, not create another layer of classes for the sake of having a service layer.
A well-designed service should make its callers simpler while keeping its dependencies, configuration, repository access, concurrency behavior, and failure modes understandable.
Read Real-World Scenario — When an OSGi Service Is the Right Call (and When It's Just Ceremony) for practical guidance on when an OSGi service earns its lifecycle and configuration overhead, the reference and configuration problems that appear in production, and when a plain Java class is the simpler choice.
The next published chapter is Service Users and Repository Permissions (chapter 27). It applies service and resolver boundaries to backend repository access, including repository identity, resolver ownership, and ACL design.
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.