Real-World Scenario — When an OSGi Service Is the Right Call (and When It's Just Ceremony)
Where OSGi services are genuinely the right tool in real AEM projects, the recurring reference and configuration problems they cause in production, and honest criteria for when a plain Java class beats a service, with a full working example.
Part of the Real-World AEM Problems series. Background reading: OSGi Services in AEM — Lifecycle, Registration, and Real Service Design covers the lifecycle and registration concepts behind what's discussed here.
Third stop in the building-blocks pass: OSGi services. Servlets handle the HTTP interaction, components handle rendering — the actual business logic both of them delegate to is supposed to live in a service. Here's where that pattern genuinely earns its place, and where it's just extra ceremony around something that didn't need it.
Where It's Actually Used
A vendor integration that might get swapped. A shipping-rate calculator backed by a specific carrier's API, a tax-calculation service, a payment gateway — anything where "we might switch providers" is a realistic possibility (not a hypothetical one) belongs behind a service interface. When the carrier does eventually change, it's a new @Component implementing the same interface, not a rewrite of every call site.
Shared logic used by more than one caller. The pricing logic from earlier in this series is a service specifically because both a servlet (for the frontend widget) and a component's Sling Model (for server-rendered pricing) need the exact same calculation, cache, and fallback behavior — duplicating that logic in two places would have meant fixing bugs twice.
Anything needing OSGi-managed configuration. A service that needs an admin to set an API endpoint, a timeout, or a feature flag per environment (author vs. publish, per country) uses @ObjectClassDefinition-backed OSGi configuration specifically because that configuration needs to be settable without a redeploy — a plain Java class has no equivalent mechanism for this on its own.
Real Problems You'll Hit
Circular or overly-static references stalling activation. Two services that reference each other with mandatory, static references can deadlock — neither activates because each is waiting on the other. This tends to get introduced gradually as services accumulate cross-dependencies over a project's lifetime, and it's rarely caught in a small local test because it usually only manifests with the specific combination of bundles active in a full deployment.
A configuration factory instance silently not picking up a change. OSGi configuration changes made through the console sometimes don't trigger the reactivation a developer expects, particularly with factory configurations — the fix (usually deactivating/reactivating the specific PID, or checking whether the property is actually marked as one that triggers a restart) takes real debugging time the first several times it happens because the failure mode looks identical to "the config just isn't being read."
A service with too many responsibilities becoming untestable. A service that started scoped to one thing (fetch pricing) accumulates unrelated methods over time (also handles inventory, also handles promotions) until unit testing it means mocking a dozen unrelated collaborators just to test one method — the sign that it's grown past its actual interface boundary and needs to be split, which by that point is a real refactor, not a quick fix.
When You Actually Need It vs When It's Overkill
Build a service when: the logic is shared across multiple callers, when it genuinely might need a second implementation (a vendor swap, a multi-tenant variant), or when it needs OSGi-managed, environment-specific configuration.
Skip it when: the logic is used from exactly one place, will realistically never need a second implementation, and doesn't need runtime configuration — a plain Java utility class (or a static method, if it's genuinely stateless) does the same job with none of the component lifecycle, reference management, or activation-order complexity to reason about.
A genuinely common overkill pattern: wrapping a single, simple string-formatting or date-parsing helper in a full @Component with an interface, purely out of habit or "for consistency." That's lifecycle machinery with no actual benefit — nobody is ever going to register a second implementation of a date formatter, and the extra indirection just makes the code harder to trace for no real gain.
Concrete Example
A shared pricing-lookup service used by both a servlet and a component's Sling Model — the actual shared-logic case that justifies a service:
public interface PricingLookupService {
Optional<PriceInfo> getPrice(String sku);
}
@Component(service = PricingLookupService.class)
@Designate(ocd = PricingLookupConfig.class)
public class PricingLookupServiceImpl implements PricingLookupService {
private static final Logger LOG = LoggerFactory.getLogger(PricingLookupServiceImpl.class);
@Reference
private PricingCacheService cacheService;
@Reference
private ExternalPricingClient pricingClient;
private String apiTimeout;
@Activate
@Modified
protected void activate(PricingLookupConfig config) {
this.apiTimeout = config.api_timeout_ms() + "ms";
}
@Override
public Optional<PriceInfo> getPrice(String sku) {
Optional<PriceInfo> cached = cacheService.get(sku);
if (cached.isPresent()) {
return cached;
}
try {
PriceInfo fresh = pricingClient.fetch(sku);
cacheService.put(sku, fresh);
return Optional.of(fresh);
} catch (PricingClientException e) {
LOG.warn("Pricing lookup failed for SKU {}, no fallback available", sku, e);
return Optional.empty();
}
}
}
@ObjectClassDefinition(name = "Pricing Lookup Service Configuration")
public @interface PricingLookupConfig {
@AttributeDefinition(name = "API Timeout (ms)")
int api_timeout_ms() default 2000;
}
Both a servlet (PricingJsonServlet, serving the frontend widget) and a Sling Model (ProductPricingModel, backing server-rendered pages) inject PricingLookupService and call the same method — the caching, timeout, and fallback behavior lives in exactly one place, and changing the timeout is an OSGi config change, not a code deploy.
Summary
- Build a service when logic is genuinely shared across callers, might need a second implementation, or needs OSGi-managed configuration
- Circular static references and factory-configuration reactivation are the two most common real production headaches
- A service accumulating unrelated responsibilities is a sign it's outgrown its interface — split it before it becomes untestable
- A plain Java class is the right (and simpler) choice for logic used from exactly one place with no realistic need for a second implementation
- Don't wrap simple stateless helpers in
@Componentout of habit — that's lifecycle overhead with no actual payoff
What's Next
Next in this pass: workflows — where AEM's workflow engine is genuinely the right tool for a multi-step, human-involved process, versus where a simpler event listener or scheduled job would do the same job with far less operational overhead.
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.