Series MapLesson 37 / 112
Focus mode active/Press Alt+Shift+R to toggle/Esc to exit
Build CoreOrdered learning track

Dependency Injection Fundamentals

Dependency Injection Fundamentals and Scopes

Dependency Injection dari prinsip Java sampai runtime enterprise: constructor injection, provider/factory, singleton, request scope, HK2/CDI boundary, dan testability

9 min read1654 words
PrevNext
Lesson 37112 lesson track22–61 Build Core
#dependency-injection#scopes#hk2#cdi+4 more

Part 037 — Dependency Injection Fundamentals and Scopes

Fokus part ini: memahami Dependency Injection sebagai mekanisme composition dan lifecycle management, bukan sekadar cara menghindari new. Dalam JAX-RS enterprise service, DI menentukan siapa yang membuat object, kapan object hidup, kapan object mati, dan apakah object aman dipakai lintas request.

Dependency Injection sering terlihat sederhana:

public class QuoteResource {
    private final QuoteService quoteService;

    public QuoteResource(QuoteService quoteService) {
        this.quoteService = quoteService;
    }
}

Tetapi di production system, pertanyaannya bukan hanya "bagaimana object masuk ke constructor".

Pertanyaan senior engineer adalah:

- siapa owner lifecycle object ini?
- apakah object ini singleton, request-scoped, atau transient?
- apakah object ini thread-safe?
- apakah object ini membawa tenant/user/request state?
- apakah dependency graph bisa dibangun saat startup?
- apakah dependency ini mudah diganti di test?
- apakah konfigurasi dan secret masuk dengan cara aman?
- apakah ada hidden global state?

DI adalah bagian dari architecture boundary.


1. Mental Model: Object Graph, Not Magic

Aplikasi Java enterprise adalah object graph.

flowchart TD A[JAX-RS Resource] --> B[Application Service] B --> C[Repository] B --> D[HTTP Client] B --> E[Kafka Publisher] C --> F[DataSource / Connection Pool] D --> G[HTTP Client Pool] E --> H[KafkaProducer]

DI container membantu membangun graph ini.

Container responsibility:
- know which implementation satisfies which type
- create object in correct order
- inject dependencies
- manage lifecycle/scope
- fail startup if graph invalid

Tetapi DI container tidak otomatis membuat design menjadi baik.

Bad graph tetap bad graph, hanya saja dibuat otomatis.

Dependency Injection solves construction.
It does not solve coupling, state ownership, or bad boundaries.

2. Why DI Exists in Backend Services

Tanpa DI, resource sering membuat dependency sendiri.

@Path("/quotes")
public class QuoteResource {
    private final QuoteService quoteService = new QuoteService(
        new QuoteRepository(new DataSourceFactory().create()),
        new PricingClient()
    );
}

Masalahnya:

- resource tahu terlalu banyak detail construction
- sulit mengganti implementation untuk test
- sulit mengatur lifecycle connection pool/client
- setiap resource bisa membuat dependency mahal berulang
- konfigurasi tersebar
- shutdown/cleanup tidak jelas
- observability wrapper sulit dipasang konsisten

Dengan DI:

@Path("/quotes")
public class QuoteResource {
    private final QuoteService quoteService;

    @Inject
    public QuoteResource(QuoteService quoteService) {
        this.quoteService = quoteService;
    }
}

Resource hanya tahu behavior yang ia butuhkan.

Construction dipindahkan ke composition root.


3. Composition Root

Composition root adalah tempat dependency graph disusun.

Dalam Java/JAX-RS service, composition root bisa muncul sebagai:

- Jersey ResourceConfig
- HK2 AbstractBinder
- CDI bean discovery
- manual application bootstrap
- Servlet initializer
- main method embedded server
- framework-specific module/configuration class

Contoh conceptual HK2-style binding:

public class AppBinder extends AbstractBinder {
    @Override
    protected void configure() {
        bind(QuoteService.class).to(QuoteService.class).in(Singleton.class);
        bind(PostgresQuoteRepository.class).to(QuoteRepository.class).in(Singleton.class);
        bind(PricingHttpClient.class).to(PricingClient.class).in(Singleton.class);
    }
}

Contoh conceptual CDI-style bean discovery:

@ApplicationScoped
public class QuoteService {
    private final QuoteRepository repository;

    @Inject
    public QuoteService(QuoteRepository repository) {
        this.repository = repository;
    }
}

Senior-level rule:

Business code should not know how the whole application is assembled.

4. Manual Construction vs DI vs Service Locator

PatternExampleStrengthRisk
manual constructionnew QuoteService(...)explicit, simplerepeated wiring, poor lifecycle
DIconstructor injectiontestable, lifecycle-awarecontainer magic if uncontrolled
service locatorlocator.getService(...)dynamic lookuphides dependency, harder to test
static globalGlobal.get(...)convenientworst for tests/lifecycle/concurrency

DI is usually preferred for stable dependencies.

Service locator may be acceptable only near infrastructure boundary, for example:

- Jersey/HK2 internal integration
- plugin lookup
- dynamic provider lookup
- framework bridge

Avoid service locator in business logic.

Bad:

public Quote calculate(QuoteCommand command) {
    PricingClient client = ServiceLocator.current().get(PricingClient.class);
    return client.price(command);
}

Better:

public class QuoteService {
    private final PricingClient pricingClient;

    public QuoteService(PricingClient pricingClient) {
        this.pricingClient = pricingClient;
    }
}

Hidden dependencies become hidden failure modes.


5. Constructor Injection

Constructor injection is the default choice for mandatory dependencies.

public class QuoteService {
    private final QuoteRepository repository;
    private final PricingClient pricingClient;
    private final Clock clock;

    @Inject
    public QuoteService(
        QuoteRepository repository,
        PricingClient pricingClient,
        Clock clock
    ) {
        this.repository = repository;
        this.pricingClient = pricingClient;
        this.clock = clock;
    }
}

Why it is strong:

- dependencies are explicit
- object can be immutable after construction
- no partially initialized state
- easy to test without container
- impossible to forget mandatory dependency

Test example:

QuoteService service = new QuoteService(
    fakeRepository,
    fakePricingClient,
    fixedClock
);

PR review signal:

If a class cannot be constructed in a unit test without the DI container,
its dependency boundary may be too framework-coupled.

6. Field Injection

Field injection is common in framework examples.

@Inject
private QuoteService quoteService;

Problems:

- dependency is hidden from constructor
- object can exist in invalid partially injected state
- hard to instantiate in unit tests
- encourages mutable non-final fields
- harder to reason about lifecycle

Field injection may be tolerable for framework-owned classes where constructor injection is not supported or where the codebase standard uses it consistently, but it should not be the default for core business logic.

Senior recommendation:

Use constructor injection for application code.
Use field injection only when framework constraints or internal standard justify it.

Internal verification matters because legacy enterprise systems may already standardize field injection. Do not refactor blindly without understanding container behavior.


7. Setter Injection

Setter injection is useful for optional or late-bound dependencies.

public class QuoteService {
    private AuditPublisher auditPublisher = AuditPublisher.noop();

    @Inject
    public void setAuditPublisher(AuditPublisher auditPublisher) {
        this.auditPublisher = auditPublisher;
    }
}

Use carefully.

Good use cases:

- optional dependency
- backward-compatible extension
- framework lifecycle callback
- test-only override

Bad use cases:

- mandatory dependency
- security dependency
- repository/client dependency required for correct behavior

Mandatory dependencies should not be optional by accident.


8. Provider and Factory

Sometimes you do not want the dependency immediately.

You want a way to create or retrieve it when needed.

Conceptually:

public class ExportService {
    private final Provider<ExportJobContext> jobContextProvider;

    @Inject
    public ExportService(Provider<ExportJobContext> jobContextProvider) {
        this.jobContextProvider = jobContextProvider;
    }

    public void runExport() {
        ExportJobContext ctx = jobContextProvider.get();
        // use ctx
    }
}

Use provider/factory when:

- object is expensive and should be lazy
- object is request-scoped but injected into singleton
- object needs runtime parameter
- object lifecycle must be controlled explicitly
- object creation can fail and needs local handling

Do not use provider to hide poor design.

Bad smell:

Provider<X> used everywhere because dependency graph is circular or scopes are wrong.

Factory example:

public interface PricingRequestContextFactory {
    PricingRequestContext create(TenantId tenantId, CorrelationId correlationId);
}

Factory is often better than injecting a mutable context.


9. Scope Mental Model

Scope answers one question:

How many instances exist, and how long do they live?

Common scopes:

ScopeLifetimeTypical useRisk
singleton/applicationwhole appstateless service, client, repositorymutable shared state
requestone HTTP requestrequest context, auth contextleaking outside request
per-lookup/transientevery injection/lookupshort-lived helperexpensive if overused
dependentowned by injected targetCDI dependent objectlifecycle tied to owner
sessionuser/sessionweb app staterarely appropriate for stateless APIs

Enterprise JAX-RS services usually prefer:

- stateless singleton services
- singleton repositories/clients backed by pools
- request-scoped context holders
- explicit factories for runtime-specific objects

10. Singleton Scope

Singleton means one instance per container/application context.

Good singleton candidates:

- stateless application services
- repository objects that use DataSource
- HTTP client wrapper with connection pool
- Kafka producer wrapper
- configuration snapshot
- object mapper if immutable/configured once
- metric registry wrapper

Bad singleton candidates:

- object storing current user
- object storing current tenant
- object storing current request body
- mutable workflow state
- non-thread-safe formatter/client
- per-request accumulator

Singleton rule:

Singleton object must be thread-safe or effectively immutable.

Dangerous singleton:

@Singleton
public class QuoteContext {
    private String currentTenantId;
    private String currentUserId;
}

This can leak tenant/user across concurrent requests.

Better:

- pass tenant/user explicitly as method argument
- use request-scoped context if framework-supported
- use immutable command object

11. Request Scope

Request scope means one instance per HTTP request.

Useful for:

- authenticated principal
- tenant context
- correlation context
- request metadata
- permission context
- request-local cache

Example conceptual request context:

public class RequestContext {
    private final TenantId tenantId;
    private final UserId userId;
    private final CorrelationId correlationId;

    public RequestContext(TenantId tenantId, UserId userId, CorrelationId correlationId) {
        this.tenantId = tenantId;
        this.userId = userId;
        this.correlationId = correlationId;
    }
}

Risk:

Request-scoped object is only valid during request lifecycle.

Do not store request-scoped object inside long-lived singleton.

Dangerous:

@Singleton
public class QuoteService {
    @Inject
    private RequestContext requestContext;
}

Depending on container proxy behavior, this may work via proxy or may break badly. But even if it works, it hides the request dependency.

Prefer explicit method argument for important request state:

quoteService.createQuote(command, requestContext);

12. Scope Mismatch

Scope mismatch is a major source of subtle production bugs.

Example:

Singleton service depends on request-scoped context.

Possible outcomes:

- container injects proxy and it works only inside active request
- request context unavailable in background thread
- context leaks if ThreadLocal not cleared
- unit tests miss issue
- async tasks fail after response returned

Scope mismatch matrix:

Long-lived object depends on short-lived objectRisk
singleton -> request contextcontext unavailable/leaked
singleton -> transaction objectinvalid outside transaction
singleton -> entity manager/sessionthread safety issue
async job -> request contextmissing tenant/security data
Kafka consumer -> HTTP request contextno active HTTP request

Senior pattern:

Convert short-lived runtime context into immutable command/context value at boundary.
Pass it explicitly.

13. Jakarta Inject Basics

Jakarta Inject provides basic annotations such as:

@Inject
@Named
@Qualifier
@Scope
@Singleton

Important distinction:

Jakarta Inject defines common injection annotations.
It does not itself provide the full container runtime.

Actual behavior depends on DI implementation:

- HK2 in Jersey environments
- CDI in Jakarta EE environments
- Spring if integrated separately
- custom/manual wiring

Do not assume @Inject means CDI. It may be HK2-managed depending on runtime.

Internal verification checklist must identify:

- which container processes @Inject
- which scopes are supported
- how resource classes are instantiated
- whether CDI integration is active

14. HK2 vs CDI: Practical Boundary

Conceptual comparison:

TopicHK2CDI
Common withJerseyJakarta EE / CDI runtime
Core modelservice locator + descriptorscontextual beans
BindingAbstractBinder, factoriesbean discovery, producers
Scope modelHK2 scopesCDI scopes
Qualifierssupportedrich qualifier model
Events/interceptorslimited compared to CDIricher CDI ecosystem
VerificationJersey config/dependencyCDI provider/beans.xml/runtime

In a Jersey-based service, HK2 may be enough. In a Jakarta EE server, CDI may be the primary DI system. In mixed environments, integration must be verified carefully.

Risk areas:

- object created by HK2 cannot see CDI bean
- object created manually cannot receive injection
- duplicate binding for same interface
- ambiguous injection with multiple implementations
- scope annotations interpreted differently

15. Binding Interfaces to Implementations

Application code should depend on interfaces at important boundaries.

public interface QuoteRepository {
    QuoteRecord findById(QuoteId id);
    void save(QuoteRecord quote);
}

Implementation:

public class PostgresQuoteRepository implements QuoteRepository {
    private final DataSource dataSource;

    @Inject
    public PostgresQuoteRepository(DataSource dataSource) {
        this.dataSource = dataSource;
    }
}

Binding:

QuoteRepository -> PostgresQuoteRepository

Good interface boundaries:

- database repository
- external HTTP client
- Kafka publisher
- clock/time provider
- feature flag provider
- secret/config provider
- audit publisher

Avoid interface inflation for trivial classes.

Bad:

IQuoteService, QuoteServiceImpl, QuoteServiceFactory, QuoteServiceProvider

Use interfaces where substitution is real.


16. Qualifiers and Multiple Implementations

When there are multiple implementations, type alone is not enough.

PricingClient can mean:
- internal pricing engine client
- mock pricing client
- legacy pricing client
- tenant-specific pricing client

A qualifier disambiguates.

Conceptual CDI example:

@Qualifier
@Retention(RUNTIME)
@Target({ FIELD, PARAMETER, METHOD, TYPE })
public @interface LegacyPricing {}

Usage:

@Inject
public QuoteService(@LegacyPricing PricingClient pricingClient) {
    this.pricingClient = pricingClient;
}

PR review question:

Does the qualifier describe a stable architectural role,
or is it hiding environment-specific branching?

Qualifiers should not become random labels.


17. Configuration Injection

Configuration often enters the dependency graph.

Bad:

String timeout = System.getenv("PRICING_TIMEOUT_MS");

Better:

public class PricingClientConfig {
    private final Duration connectTimeout;
    private final Duration readTimeout;
    private final URI baseUri;
}

Then inject config object:

public class PricingHttpClient {
    private final PricingClientConfig config;

    @Inject
    public PricingHttpClient(PricingClientConfig config) {
        this.config = config;
    }
}

Advantages:

- config validated at startup
- defaults are explicit
- test can supply config
- no scattered env lookup
- config can be documented

Sensitive config should use secret handling rules, not normal property injection.


18. Startup Validation

A strong DI setup fails fast at startup when graph is invalid.

Good startup failures:

- missing required binding
- invalid config value
- duplicate ambiguous binding
- secret unavailable
- malformed URI
- invalid timeout

Bad production behavior:

Service starts successfully but first request fails because dependency is missing.

For critical dependencies, prefer startup validation.

But be careful with optional external systems.

Not every dependency should be hard-required at startup.

Example:

DependencyStartup required?Reason
config schemayesinvalid config means bad deployment
DataSource object creationyesapp cannot function without DB config
DB connectivitydependsstrict for monolith-like app, risky for rolling deploy if DB transient
optional notification clientnodegraded mode may be acceptable
feature flag providerdependssafe defaults may be enough

19. Circular Dependencies

Circular dependency:

QuoteService -> PricingService -> QuoteService

This is usually a design smell.

Possible causes:

- service boundary too broad
- orchestration mixed with domain operation
- event publishing mixed with command handling
- helper class became god service
- bidirectional module dependency

Bad workaround:

Use Provider everywhere to break cycle.

Better options:

- extract shared domain policy
- split orchestration service
- introduce event boundary
- pass data instead of service
- invert dependency through interface owned by lower-level module

DI container error is often a design diagnostic.


20. Mutable Shared State

The most dangerous DI bug is accidentally shared mutable state.

Example:

@Singleton
public class QuoteAccumulator {
    private final List<QuoteLine> lines = new ArrayList<>();

    public void add(QuoteLine line) {
        lines.add(line);
    }
}

In a concurrent JAX-RS service, this can corrupt data across requests.

Better:

public class QuoteDraft {
    private final List<QuoteLine> lines;
}

or:

Keep mutable aggregation as local variable within request method/service method.

Rule:

Mutable request data should not live in singleton beans.

21. Thread Safety Review for Injected Objects

Injected singleton dependencies are shared across request threads.

Review each singleton:

- Does it have mutable fields?
- Are mutable fields thread-safe?
- Does it use non-thread-safe Java types?
- Does it cache per-request data?
- Does it wrap a thread-safe client/pool?
- Does it mutate config after startup?

Common thread-safe singleton examples:

- DataSource/connection pool wrapper
- immutable config object
- stateless service
- ObjectMapper if configured once before use
- HTTP client designed for concurrent use

Common unsafe examples:

- SimpleDateFormat
- mutable ArrayList/HashMap without protection
- per-request context holder
- non-thread-safe SDK client if not documented as thread-safe

22. DI and Testing

Good DI makes tests simpler.

class QuoteServiceTest {
    @Test
    void rejectsExpiredCatalogVersion() {
        QuoteRepository repo = new InMemoryQuoteRepository();
        PricingClient pricing = new FakePricingClient();
        Clock clock = Clock.fixed(...);

        QuoteService service = new QuoteService(repo, pricing, clock);

        // assert behavior
    }
}

Test seam candidates:

- Clock
- UUID generator / ID generator
- repository interface
- external client interface
- feature flag provider
- event publisher
- audit publisher

Avoid requiring full DI container for every unit test.

Container test is useful for:

- binding correctness
- resource wiring
- provider registration
- scope behavior
- integration with Jersey/CDI/HK2

But business behavior tests should usually instantiate classes directly.


23. DI and Observability Wrapping

DI is where cross-cutting wrappers can be attached consistently.

Example:

PricingClient interface
  -> ObservedPricingClient
      -> ResilientPricingClient
          -> SigningPricingClient
              -> RawHttpPricingClient

This layering makes outbound behavior explicit:

- metrics/tracing
- retry/circuit breaker
- request signing
- error mapping
- raw HTTP call

Bad pattern:

Every call site manually adds retry/logging/signing.

Better:

Compose decorated dependency once at application boundary.

PR review question:

Is resilience/telemetry/security applied at the dependency boundary,
or scattered across business logic?

24. DI and Tenant-Aware Behavior

Tenant-aware systems often need tenant-specific configuration.

Bad:

@Singleton
public class PricingRules {
    private TenantId currentTenant;
    private RuleSet currentRules;
}

Better:

public interface PricingRuleProvider {
    RuleSet rulesFor(TenantId tenantId, Instant effectiveAt);
}

Usage:

RuleSet rules = pricingRuleProvider.rulesFor(command.tenantId(), command.effectiveAt());

DI injects provider/service. Runtime request supplies tenant.

Rule:

DI supplies capabilities.
Request supplies context.

This prevents singleton tenant leaks.


25. DI and Configuration per Environment

Environment-specific behavior should not be hidden in arbitrary branches.

Bad:

if (System.getenv("ENV").equals("prod")) {
    return new RealPricingClient();
} else {
    return new MockPricingClient();
}

Better:

Binding layer chooses implementation based on validated profile/config.
Application code depends on PricingClient.

But environment branching should be minimized.

Production-like behavior should exist in lower environments whenever possible.

Internal verification:

- How are profiles selected?
- Which bindings differ by environment?
- Are non-prod mocks accidentally deployable to prod?
- Are fail-safe defaults explicit?

26. Common Failure Modes

FailureSymptomLikely cause
missing bindingstartup failure or 500interface has no implementation registered
ambiguous bindingstartup failuremultiple implementations no qualifier
null dependencyNPEobject manually constructed, field injection not run
tenant leakwrong tenant datamutable singleton/request context misuse
context unavailableexception in async/jobrequest scope used outside request
circular dependencystartup failureservice boundary cycle
resource leakconnection/thread leaklifecycle owner unclear
slow startuplong constructor workexpensive I/O during graph creation
test painrequires container everywherepoor constructor boundaries

27. Debugging DI Problems

Start with questions:

1. Who created this object?
2. Was it created manually or by container?
3. Which container owns it: HK2, CDI, Servlet, custom bootstrap?
4. What scope does it have?
5. Is the dependency mandatory or optional?
6. Is there more than one implementation?
7. Is there a qualifier/name involved?
8. Is this running in HTTP request, background job, Kafka consumer, or startup?

Evidence to inspect:

- constructor annotations
- field/setter annotations
- HK2 binders
- CDI annotations
- ResourceConfig registration
- package scanning configuration
- startup logs
- dependency graph error
- test bootstrap

For production-only issues, inspect concurrency and scope first.

A DI bug that appears only under load is often a scope/thread-safety bug.

28. PR Review Checklist

When reviewing DI changes:

[ ] Does the class use constructor injection for mandatory dependencies?
[ ] Are dependencies explicit and minimal?
[ ] Is the scope correct?
[ ] Is every singleton thread-safe or immutable?
[ ] Is request/user/tenant state kept out of singleton objects?
[ ] Are multiple implementations disambiguated with stable qualifiers?
[ ] Are config objects validated at startup?
[ ] Are secrets handled through secure secret mechanism?
[ ] Are external clients wrapped consistently with timeout/resilience/telemetry/security?
[ ] Does business logic avoid service locator/static globals?
[ ] Can core behavior be unit-tested without DI container?
[ ] Are circular dependencies avoided?
[ ] Is shutdown/cleanup ownership clear?

29. Internal Verification Checklist

For CSG/internal codebase, verify rather than assume:

[ ] Which DI mechanism is actually used: HK2, CDI, Spring, manual, or mixed?
[ ] Are JAX-RS resources constructed by Jersey/HK2, CDI, Servlet, or manually?
[ ] Is `@Inject` processed by HK2 or CDI?
[ ] Which scope annotations are valid in the runtime?
[ ] Are resources per-request or singleton?
[ ] Are providers singleton by default?
[ ] Where are HK2 binders registered?
[ ] Is CDI integration enabled?
[ ] Is `beans.xml` present and relevant?
[ ] Are there custom factories/producers?
[ ] Are config/secret objects injected or accessed directly?
[ ] Are tenant/user/request contexts request-scoped, ThreadLocal, explicit argument, or header-only?
[ ] Are there known patterns for mocking dependencies in tests?
[ ] Are external clients decorated with resilience/telemetry/security wrappers?
[ ] Are there internal architecture guidelines for dependency scope?

30. Senior Engineer Heuristics

Use these heuristics in real reviews:

1. DI should make dependency graph clearer, not more mysterious.
2. Singleton is safe only when state is immutable or thread-safe.
3. Request context should be explicit at domain/integration boundary.
4. Configuration should be typed, validated, and owned.
5. Secret handling is not normal config handling.
6. Service locator belongs near framework boundary, not domain logic.
7. Provider/factory is for lifecycle/runtime variability, not hiding cycles.
8. Tests should not require container unless testing container behavior.
9. DI errors are often architecture signals.
10. The composition root is production-critical code.

31. Minimal Vocabulary

TermMeaning
dependencyobject required by another object
injectionsupplying dependency from outside
containerruntime that creates/injects/manages objects
scopelifetime/instance rule of object
bindingmapping from abstraction to implementation
qualifierlabel to disambiguate multiple implementations
providerlazy accessor/creator for dependency
factoryobject that creates another object, often with runtime parameter
composition rootplace where dependency graph is assembled
service locatorobject registry queried manually at runtime

32. Practical Exercise

Pick one real resource class and answer:

- Who constructs this resource?
- What dependencies does it use?
- Are they constructor, field, or setter injected?
- What scope does each dependency have?
- Which dependencies are singleton?
- Which carry request/tenant/user state?
- Can the resource logic be unit-tested without container?
- Where is the binding/producer/factory registered?
- What would fail at startup vs first request?

Then draw the dependency graph.

flowchart TD Resource[JAX-RS Resource] --> Service[Application Service] Service --> Repo[Repository] Service --> Client[External Client] Repo --> DS[DataSource] Client --> Pool[HTTP Client Pool]

The goal is not to make the graph pretty. The goal is to know whether it is true.

Lesson Recap

You just completed lesson 37 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.