Dependency Inversion Principle

21 min read

Object-Oriented Design and SOLID Review — review Java services for direct dependencies on infrastructure that should sit behind stable business abstractions.

1. Introduction

The Dependency Inversion Principle (DIP) is one of the SOLID design principles used to reduce tight coupling between business logic and implementation details.

In practical Java and Spring Boot development, DIP means that high-level business code should not directly depend on low-level technical classes such as:

  • Database repositories
  • REST clients
  • Email libraries
  • File-system implementations
  • Payment SDKs
  • Message brokers
  • Cloud-storage clients
  • External vendor APIs

Instead, business logic should depend on stable abstractions.

The implementation details should then implement those abstractions.

A typical problem looks like this:

JAVA
public class OrderService {
    private final StripePaymentClient stripePaymentClient = new StripePaymentClient();
}

OrderService is now directly coupled to Stripe.

If the company later introduces another provider, unit testing, migration, or fallback logic becomes harder.

A better design is:

JAVA
public class OrderService {
    private final PaymentGateway paymentGateway;
    public OrderService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }
}

Now the service depends on a business abstraction rather than a specific implementation.

This is especially important in Spring Boot applications because dependency injection makes DIP practical and easy to enforce.

2. What This Topic Means

The Dependency Inversion Principle has two important ideas:

  1. High-level modules should not depend directly on low-level modules.
  2. Both should depend on abstractions.

It also implies that abstractions should represent stable business requirements rather than infrastructure details.

Consider an order-processing service.

The business workflow may need to charge a customer.

A tightly coupled implementation could depend directly on:

JAVA
StripeClient

But the business requirement is not:

Use Stripe SDK version X.

The business requirement is:

Charge the customer.

Therefore, the service should depend on something such as:

JAVA
PaymentGateway

The infrastructure layer can then provide:

JAVA
StripePaymentGateway

or:

JAVA
RazorpayPaymentGateway

or:

JAVA
PayPalPaymentGateway

The business service does not need to know how the payment provider performs the operation internally.

3. Why It Matters in Real Projects

Maintainability

Infrastructure changes are common.

Projects may change:

  • Database technology
  • Payment provider
  • Email provider
  • Cloud platform
  • Messaging system
  • Third-party APIs

If business classes depend directly on these technologies, every change can spread across many files.

With DIP, implementation changes are usually isolated behind an abstraction.

Testability

A business service depending on an abstraction can be tested with a mock or fake implementation.

For example:

JAVA
PaymentGateway paymentGateway = mock(PaymentGateway.class);

The test does not need a real payment provider.

Readability

A dependency such as:

JAVA
PaymentGateway

communicates business intent more clearly than:

JAVA
StripeSdkClient

inside core business logic.

Reliability

External integrations can be replaced, wrapped, or enhanced without rewriting business workflows.

Team Development

Different teams can work independently.

For example:

  • Business team defines PaymentGateway.
  • Integration team implements StripePaymentGateway.
  • Testing team uses a fake gateway.
  • Platform team introduces another provider later.

Scalability

As more implementations are added, the core business code can remain stable.

4. Core Concept

The most important practical idea behind DIP is:

Business policy should control the abstraction, not infrastructure details.

Consider:

JAVA
@Service
public class InvoiceService {
    private final S3Client s3Client;
    public InvoiceService(S3Client s3Client) {
        this.s3Client = s3Client;
    }
}

This service directly depends on AWS infrastructure.

If its real business need is storing invoices, a better dependency could be:

JAVA
public interface InvoiceStorage {
    void store(InvoiceDocument document);
}

Then AWS becomes one implementation:

JAVA
@Component
public class S3InvoiceStorage implements InvoiceStorage {
    private final S3Client s3Client;
    public S3InvoiceStorage(S3Client s3Client) {
        this.s3Client = s3Client;
    }
    @Override
    public void store(InvoiceDocument document) {
        // Store document in S3
    }
}

InvoiceService now depends on:

JAVA
InvoiceStorage

not:

JAVA
S3Client

The direction of dependency has effectively changed.

Infrastructure depends on a business-facing abstraction instead of business code depending directly on infrastructure.

5. Important Rules

When writing or reviewing Java code related to DIP:

  • Keep core business logic independent of specific infrastructure libraries where practical.
  • Depend on interfaces representing business capabilities.
  • Prefer constructor injection over creating dependencies with new.
  • Do not instantiate repositories, API clients, or SDK wrappers inside business services.
  • Keep external-provider code behind adapters.
  • Avoid leaking vendor-specific types into domain or service layers.
  • Define abstractions at meaningful architectural boundaries.
  • Do not create interfaces for every class automatically.
  • Add an abstraction when it reduces meaningful coupling or represents a real boundary.
  • Keep interfaces focused on business needs.
  • Prefer interfaces such as PaymentGateway over infrastructure-shaped names such as StripeServiceInterface.
  • Keep Spring configuration responsible for wiring implementations.
  • Avoid static global dependencies for external infrastructure.
  • Make implementations replaceable without rewriting high-level business logic.
  • Keep tests independent of real external providers whenever possible.

6. Bad Code Example

Consider an order service in a Spring Boot e-commerce application.

JAVA
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }
    public Order placeOrder(CreateOrderRequest request) {
        StripeClient stripeClient = new StripeClient("secret-key");
        EmailClient emailClient = new EmailClient("smtp.company.com");
        PaymentResponse paymentResponse = stripeClient.charge(
            request.getCardToken(),
            request.getAmount()
        );
        if (!paymentResponse.isSuccessful()) {
            throw new PaymentFailedException();
        }
        Order order = new Order();
        order.setCustomerId(request.getCustomerId());
        order.setAmount(request.getAmount());
        order.setPaymentId(paymentResponse.getPaymentId());
        Order savedOrder = orderRepository.save(order);
        emailClient.send(
            request.getEmail(),
            "Order confirmed: " + savedOrder.getId()
        );
        return savedOrder;
    }
}

At first glance, the code may appear straightforward.

However, the service contains several architectural problems.

7. Problems in the Bad Code

Direct Dependency on Stripe

The business service creates:

JAVA
StripeClient

directly.

The order workflow is now tightly coupled to one payment provider.

Direct Dependency on Email Infrastructure

The service also creates:

JAVA
EmailClient

directly.

Order business logic now controls SMTP infrastructure.

Hard-Coded Configuration

The code contains:

JAVA
"secret-key"
"smtp.company.com"

This is both a maintainability and security problem.

Difficult Unit Testing

A unit test cannot easily replace:

JAVA
StripeClient

or:

JAVA
EmailClient

because the service creates them internally.

Multiple Infrastructure Concerns

The service knows:

  • Payment SDK details
  • Email infrastructure
  • Repository persistence
  • Order business workflow

Vendor Types Leak Into Business Logic

The service depends on:

JAVA
PaymentResponse

from a specific provider design.

Difficult Provider Migration

Moving from Stripe to another provider would require modifying the core order-processing service.

Production Risk

External calls are embedded directly inside the business workflow without clear boundaries for:

  • Retry
  • Timeout handling
  • Error mapping
  • Observability
  • Circuit breaking

8. Code Review Findings

A senior reviewer should notice the following.

Finding 1

OrderService constructs external clients internally.

This prevents proper dependency injection and makes the service tightly coupled.

Finding 2

The high-level order workflow directly depends on Stripe-specific behavior.

Payment processing should be exposed through a business abstraction.

Finding 3

Email delivery is another infrastructure responsibility embedded directly inside the service.

Finding 4

Secrets and infrastructure configuration are hard-coded.

Finding 5

The implementation is difficult to unit test because dependencies cannot be replaced easily.

Finding 6

Provider-specific response types have entered business logic.

Finding 7

External integration failures are not translated into application-level exceptions consistently.

9. Reviewer Comment Example

A practical Pull Request comment could be:

OrderService currently creates StripeClient directly, which tightly couples the business workflow to one provider and makes unit testing difficult. Can we introduce a PaymentGateway abstraction and inject the implementation through the constructor?

Another:

Email delivery is infrastructure behavior and is currently instantiated inside the order service. Please inject a notification abstraction so the order workflow remains independent of the SMTP implementation.

Another:

The payment secret should not be hard-coded in application code. Move provider configuration to external configuration and inject it into the infrastructure adapter.

10. Improved Code

Define a payment abstraction.

JAVA
public interface PaymentGateway {
    PaymentResult charge(PaymentRequest paymentRequest);
}

Define a notification abstraction.

JAVA
public interface OrderNotificationSender {
    void sendOrderConfirmation(Order order, String email);
}

Implement the payment integration.

JAVA
@Component
public class StripePaymentGateway implements PaymentGateway {
    private final StripeClient stripeClient;
    public StripePaymentGateway(StripeClient stripeClient) {
        this.stripeClient = stripeClient;
    }
    @Override
    public PaymentResult charge(PaymentRequest paymentRequest) {
        StripePaymentResponse response = stripeClient.charge(
            paymentRequest.cardToken(),
            paymentRequest.amount()
        );
        if (!response.isSuccessful()) {
            return PaymentResult.failed(response.getFailureReason());
        }
        return PaymentResult.success(response.getPaymentId());
    }
}

Implement the notification integration.

JAVA
@Component
public class EmailOrderNotificationSender implements OrderNotificationSender {
    private final EmailClient emailClient;
    public EmailOrderNotificationSender(EmailClient emailClient) {
        this.emailClient = emailClient;
    }
    @Override
    public void sendOrderConfirmation(Order order, String email) {
        emailClient.send(
            email,
            "Order confirmed: " + order.getId()
        );
    }
}

Now the business service depends on abstractions.

JAVA
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final PaymentGateway paymentGateway;
    private final OrderNotificationSender notificationSender;
    public OrderService(
        OrderRepository orderRepository,
        PaymentGateway paymentGateway,
        OrderNotificationSender notificationSender
    ) {
        this.orderRepository = orderRepository;
        this.paymentGateway = paymentGateway;
        this.notificationSender = notificationSender;
    }
    public Order placeOrder(CreateOrderRequest request) {
        PaymentRequest paymentRequest = new PaymentRequest(
            request.getCardToken(),
            request.getAmount()
        );
        PaymentResult paymentResult = paymentGateway.charge(paymentRequest);
        if (!paymentResult.successful()) {
            throw new PaymentFailedException(paymentResult.failureReason());
        }
        Order order = new Order();
        order.setCustomerId(request.getCustomerId());
        order.setAmount(request.getAmount());
        order.setPaymentId(paymentResult.paymentId());
        Order savedOrder = orderRepository.save(order);
        notificationSender.sendOrderConfirmation(
            savedOrder,
            request.getEmail()
        );
        return savedOrder;
    }
}

11. Improved Code Explanation

Payment Provider Is Hidden Behind an Abstraction

OrderService no longer knows whether payment is processed by:

  • Stripe
  • Razorpay
  • PayPal
  • Internal banking gateway

It only knows:

JAVA
PaymentGateway

Email Infrastructure Is Isolated

The order service depends on:

JAVA
OrderNotificationSender

rather than an SMTP-specific implementation.

Dependencies Are Constructor-Injected

Dependencies are supplied externally.

This improves:

  • Testability
  • Readability
  • Immutability
  • Dependency visibility

Vendor-Specific Types Are Contained

Stripe response types remain inside:

JAVA
StripePaymentGateway

The business layer receives:

JAVA
PaymentResult

Provider Changes Are Localized

Changing payment provider requires creating or switching an implementation rather than rewriting OrderService.

Configuration Is Externalized

Infrastructure credentials should be managed through configuration rather than hard-coded inside the business class.

12. Bad Code vs Improved Code

AreaBad CodeImproved Code
Dependency directionBusiness service depends on vendor classesBusiness service depends on abstractions
TestabilityExternal clients created internallyDependencies can be mocked
Provider migrationRequires service changesMostly isolated to adapter/configuration
ReadabilityInfrastructure details mixed with business logicBusiness workflow is clearer
SecurityConfiguration hard-codedConfiguration can be externalized
ReliabilityProvider behavior leaks into serviceAdapter translates provider behavior
MaintainabilityHigh couplingLower coupling

13. Real Project Scenario

Consider a banking application that sends transaction events to a message broker.

The first implementation uses Kafka.

A developer creates:

JAVA
@Service
public class MoneyTransferService {
    private final KafkaTemplate<String, TransferEvent> kafkaTemplate;
    public MoneyTransferService(
        KafkaTemplate<String, TransferEvent> kafkaTemplate
    ) {
        this.kafkaTemplate = kafkaTemplate;
    }
    public void transfer(TransferRequest request) {
        // Transfer money
        kafkaTemplate.send(
            "transfer-events",
            new TransferEvent(request.getAccountId())
        );
    }
}

The business service now depends directly on Kafka.

Later, another deployment environment requires:

  • AWS SNS
  • RabbitMQ
  • An internal event platform

Changing infrastructure means modifying the business service.

A better design is:

JAVA
public interface TransferEventPublisher {
    void publish(TransferCompletedEvent event);
}

Kafka becomes one adapter:

JAVA
@Component
public class KafkaTransferEventPublisher implements TransferEventPublisher {
    private final KafkaTemplate<String, TransferCompletedEvent> kafkaTemplate;
    public KafkaTransferEventPublisher(
        KafkaTemplate<String, TransferCompletedEvent> kafkaTemplate
    ) {
        this.kafkaTemplate = kafkaTemplate;
    }
    @Override
    public void publish(TransferCompletedEvent event) {
        kafkaTemplate.send("transfer-events", event);
    }
}

The business service depends only on:

JAVA
TransferEventPublisher

This protects core banking logic from messaging infrastructure changes.

14. Production Impact

Violating DIP can create several production problems.

Difficult Provider Migration

A provider outage or business decision may require quickly switching integrations.

Direct coupling makes migration expensive and risky.

Testing Gaps

Classes that create their own dependencies are difficult to test without calling real infrastructure.

This may lead to weak unit-test coverage.

Configuration Problems

Directly constructed dependencies often encourage hard-coded:

  • URLs
  • Credentials
  • Timeouts
  • API keys

Inconsistent Failure Handling

Each business service may handle external exceptions differently.

Wider Change Impact

Changing one infrastructure SDK may require modifying many business classes.

Difficult Incident Response

Provider-specific logic scattered through services makes production debugging harder.

Vendor Lock-In

Infrastructure details become deeply embedded in the application architecture.

15. Common Developer Mistakes

Creating Dependencies With new

Example:

JAVA
PaymentClient client = new PaymentClient();

inside a service makes replacement difficult.

Confusing Dependency Injection With DIP

Using Spring injection does not automatically mean DIP is followed.

This:

JAVA
private final StripeClient stripeClient;

is injected, but the business service still depends directly on a concrete infrastructure type.

Creating Interfaces for Every Class

DIP does not mean:

Every Java class must have an interface.

Interfaces should represent meaningful boundaries.

Naming Abstractions After Implementations

Avoid:

JAVA
StripeClientInterface

Prefer:

JAVA
PaymentGateway

The abstraction should describe the business capability.

Leaking Vendor DTOs

Avoid exposing:

JAVA
StripePaymentResponse

from the payment abstraction.

Use application-owned result types.

Static Utility Dependencies

Example:

JAVA
EmailUtils.send(...);

can create hidden coupling and make testing difficult.

Service Locator Pattern

Avoid retrieving dependencies dynamically from a global context when normal dependency injection is sufficient.

Injecting Framework Types Into Domain Objects

Domain objects should generally not require Spring infrastructure classes.

16. Edge Cases

Only One Implementation Exists

Having only one current implementation does not automatically mean an abstraction is unnecessary.

Ask whether the dependency represents a meaningful external boundary.

For example:

JAVA
PaymentGateway

may still be valuable even with one provider because payment infrastructure is external, failure-prone, and independently testable.

Simple Internal Helper Class

A stateless internal formatter may not need an interface.

Creating abstractions everywhere can produce unnecessary complexity.

Multiple Implementations

When several Spring beans implement one interface, bean selection must be explicit using:

  • @Qualifier
  • @Primary
  • Configuration
  • Strategy selection logic

Optional Integrations

If an external integration is optional, application startup and fallback behavior should be designed intentionally.

External API Failures

Adapters should translate provider-specific exceptions into application-level exceptions where useful.

Transaction Boundaries

Separating infrastructure behind interfaces does not automatically solve transaction consistency.

For example:

JAVA
database save
payment call
event publication

may require transactional or eventual-consistency design.

17. Performance Considerations

DIP itself does not significantly change algorithmic complexity.

Calling:

JAVA
paymentGateway.charge(...)

through an interface has negligible overhead compared with network or database operations.

The real performance considerations are architectural.

Remote Calls

An abstraction may hide an expensive external API.

Reviewers should ensure that callers understand whether the operation is:

  • Local
  • Database-backed
  • Remote
  • Blocking
  • Potentially slow

Retry Behavior

Adapters should avoid uncontrolled retries that increase latency.

Connection Reuse

External clients should generally be managed as reusable beans rather than recreated per request.

Bad:

JAVA
new HttpClient()

inside every service method.

Better:

JAVA
inject configured client

Resource Management

Infrastructure adapters should properly manage:

  • HTTP connections
  • Database connections
  • Threads
  • Streams
  • SDK resources

DIP mainly improves architecture, not raw runtime performance.

18. Security Considerations

DIP becomes security-relevant when dealing with infrastructure configuration and external services.

Hard-Coded Secrets

Avoid:

JAVA
new StripeClient("sk_live_...");

Credentials should be externalized.

Vendor SDK Isolation

Keeping external SDK code inside adapters reduces the number of classes that can access sensitive configuration.

Authorization Boundary

Do not assume that an abstraction automatically enforces authorization.

Security-sensitive business operations should still perform appropriate checks.

Sensitive Logging

Adapters should avoid logging:

  • Access tokens
  • Credit-card data
  • Passwords
  • Secrets
  • Full external API payloads containing PII

Mock Implementations

Ensure test or fake implementations are not accidentally enabled in production.

Spring profiles and configuration should be reviewed carefully.

19. Testing Considerations

DIP strongly improves testability.

Unit Test

OrderService can be tested without Stripe or SMTP.

Example:

JAVA
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    @Mock
    private OrderRepository orderRepository;
    @Mock
    private PaymentGateway paymentGateway;
    @Mock
    private OrderNotificationSender notificationSender;
    @InjectMocks
    private OrderService orderService;
    @Test
    void shouldPlaceOrderWhenPaymentSucceeds() {
        CreateOrderRequest request = createRequest();
        PaymentResult result = PaymentResult.success("PAY-101");
        when(paymentGateway.charge(any())).thenReturn(result);
        when(orderRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
        Order order = orderService.placeOrder(request);
        assertEquals("PAY-101", order.getPaymentId());
        verify(notificationSender).sendOrderConfirmation(
            any(Order.class),
            eq(request.getEmail())
        );
    }
}

Payment Failure Test

Verify that order persistence does not occur when payment fails.

Notification Failure Test

Define expected behavior if confirmation delivery fails.

Should order creation fail?

Should notification be retried asynchronously?

The test should reflect the actual business requirement.

Adapter Integration Tests

Test:

JAVA
StripePaymentGateway

against a mock server, sandbox, or controlled integration environment.

Contract Tests

If several implementations implement PaymentGateway, shared tests can verify contract consistency.

20. Refactoring Guidelines

Refactor DIP violations incrementally.

Step 1: Identify Direct Infrastructure Dependencies

Look for business classes depending directly on:

  • SDK clients
  • RestTemplate
  • WebClient
  • Kafka templates
  • SMTP clients
  • Cloud SDKs
  • File-system APIs

Step 2: Identify the Business Capability

Ask:

What does the business service actually need?

Example:

Not:

JAVA
StripeClient

But:

JAVA
PaymentGateway

Step 3: Introduce a Focused Interface

Create the abstraction close to the business requirement.

Step 4: Move Vendor Logic Into an Adapter

Move:

  • API request mapping
  • Provider response mapping
  • Provider exceptions
  • Authentication details

into the infrastructure implementation.

Step 5: Inject the Abstraction

Replace direct construction or concrete dependencies with constructor injection.

Step 6: Add Tests Before Changing Behavior

Protect existing business workflows with characterization or unit tests.

Step 7: Move Configuration Out of Business Code

Externalize:

  • URLs
  • Keys
  • Credentials
  • Timeouts

Step 8: Remove Old Direct Dependencies

Ensure core services no longer import provider SDK types unnecessarily.

21. Best Practices

  • Depend on business-facing abstractions at important architectural boundaries.
  • Use constructor injection for mandatory dependencies.
  • Keep infrastructure implementations behind adapters.
  • Keep provider-specific DTOs out of core business services.
  • Map provider failures to meaningful application errors.
  • Externalize configuration and secrets.
  • Use Spring configuration to wire implementations.
  • Design abstractions around application needs rather than vendor APIs.
  • Keep interfaces focused and stable.
  • Use mocks or fakes for business-service unit tests.
  • Add integration tests for infrastructure adapters.
  • Prefer one-way dependency from infrastructure toward application contracts.
  • Avoid unnecessary abstractions for trivial internal classes.
  • Keep business logic executable without real external infrastructure where practical.

22. Practices to Avoid

Direct Construction in Business Services

Avoid:

JAVA
new StripeClient()
new EmailClient()
new KafkaProducer()

because the service owns infrastructure creation.

Vendor-Specific Dependencies in Core Services

Avoid:

JAVA
private final StripeClient stripeClient;

when the business need is payment processing.

Hard-Coded Configuration

Avoid credentials and URLs directly in source code.

Static External Calls

Avoid:

JAVA
PaymentUtils.charge(...);

for infrastructure dependencies that need replacement or testing.

Framework Leakage

Avoid making domain logic depend unnecessarily on Spring or vendor framework classes.

Meaningless Interfaces

Avoid:

JAVA
CustomerServiceInterface

created only because "SOLID says use interfaces."

The abstraction should have a clear architectural purpose.

Massive Generic Abstractions

Avoid interfaces so generic that they lose business meaning.

Example:

JAVA
ExternalService {
    Object execute(Object input);
}

Provider Logic in Controllers

Controllers should not manually select and invoke SDKs.

23. Code Review Checklist

Ask these questions during Pull Request review:

  • Does this business service depend directly on a vendor SDK?
  • Is any external client instantiated using new inside business logic?
  • Could this dependency be expressed as a business capability?
  • Is the abstraction owned by the application rather than shaped entirely by the vendor API?
  • Are provider-specific DTOs leaking into the service layer?
  • Are provider-specific exceptions leaking into business logic?
  • Are external URLs or credentials hard-coded?
  • Is constructor injection used for required dependencies?
  • Can this business service be unit tested without real infrastructure?
  • Can the external implementation be replaced without rewriting the core workflow?
  • Is this interface meaningful, or was it created only for abstraction's sake?
  • Is infrastructure configuration separated from business logic?
  • Are multiple implementations wired explicitly when necessary?
  • Are external failures mapped consistently?
  • Is provider-selection logic located in the appropriate layer?
  • Does the abstraction expose only capabilities the business actually needs?
  • Are security-sensitive credentials isolated from core logic?
  • Are adapters covered by integration tests?
  • Are retry and timeout policies handled at the integration boundary?
  • Has unnecessary framework coupling entered the domain layer?

24. Common Pull Request Review Comments

  1. *OrderService creates StripeClient directly, which makes the business workflow tightly coupled and difficult to test. Please inject a payment abstraction instead.*
  1. *This service depends on the vendor-specific StripePaymentResponse type. Can we map this response inside the adapter and expose an application-owned PaymentResult?*
  1. *Using dependency injection here is good, but the service still depends directly on the concrete provider class. Could the dependency be PaymentGateway instead?*
  1. *The API URL and key should not be hard-coded in this class. Please move them to external configuration owned by the integration layer.*
  1. *This KafkaTemplate dependency leaks messaging infrastructure into the business service. Consider introducing an OrderEventPublisher abstraction.*
  1. *Please avoid creating HttpClient inside the method. Inject a configured reusable client through the adapter.*
  1. *The interface is named after the implementation. Could we rename it around the business capability so the contract remains stable if the provider changes?*
  1. *External API exceptions are reaching the service layer directly. Please translate them into an application-level exception in the adapter.*
  1. *This class cannot be unit tested without the real external dependency because it constructs the client internally. Constructor injection would make the dependency replaceable.*
  1. *Before adding another abstraction, please confirm that it represents a meaningful architectural boundary rather than simply wrapping a trivial internal class.*

25. Code Review Exercise

Review the following Spring Boot code.

JAVA
@Service
public class PatientReportService {
    private final PatientRepository patientRepository;
    public PatientReportService(PatientRepository patientRepository) {
        this.patientRepository = patientRepository;
    }
    public void generateAndUploadReport(Long patientId) {
        Patient patient = patientRepository.findById(patientId)
            .orElseThrow(() -> new PatientNotFoundException(patientId));
        PdfGenerator pdfGenerator = new PdfGenerator();
        byte[] report = pdfGenerator.generate(patient);
        AmazonS3Client s3Client = new AmazonS3Client(
            "access-key",
            "secret-key"
        );
        s3Client.upload(
            "patient-reports",
            patientId + ".pdf",
            report
        );
        SmtpEmailClient emailClient = new SmtpEmailClient(
            "smtp.company.com"
        );
        emailClient.send(
            patient.getEmail(),
            "Your report is available"
        );
    }
}

Identify:

  • Dependency problems
  • Tight coupling
  • Testability problems
  • Security problems
  • Infrastructure leakage
  • Maintainability risks
  • Production risks
  • Better abstraction boundaries

Do not reveal the answer until you complete your own review.

26. Exercise Solution

The implementation contains several DIP-related issues.

Issue 1: PdfGenerator Is Created Internally

The service directly creates:

JAVA
new PdfGenerator()

This makes the implementation harder to replace and test.

Whether an abstraction is needed depends on how complex and variable report generation is, but direct construction should at least be reviewed.

Issue 2: Direct AWS Dependency

The business service knows:

JAVA
AmazonS3Client

The actual business need is:

Store patient report.

Issue 3: Hard-Coded Credentials

The service contains:

JAVA
"access-key"
"secret-key"

This is a serious security problem.

Issue 4: Direct SMTP Dependency

The service directly constructs:

JAVA
SmtpEmailClient

Issue 5: Infrastructure Details Mixed With Business Workflow

The service knows:

  • PDF generation implementation
  • AWS bucket details
  • AWS credentials
  • SMTP hostname
  • Email sending implementation

Issue 6: Difficult Unit Testing

Tests cannot easily replace AWS or SMTP clients.

Improved Abstractions

Define report generation.

JAVA
public interface PatientReportGenerator {
    byte[] generate(Patient patient);
}

Define report storage.

JAVA
public interface PatientReportStorage {
    void store(Long patientId, byte[] report);
}

Define notification capability.

JAVA
public interface PatientNotificationSender {
    void sendReportReadyNotification(Patient patient);
}

Implement the business service.

JAVA
@Service
public class PatientReportService {
    private final PatientRepository patientRepository;
    private final PatientReportGenerator reportGenerator;
    private final PatientReportStorage reportStorage;
    private final PatientNotificationSender notificationSender;
    public PatientReportService(
        PatientRepository patientRepository,
        PatientReportGenerator reportGenerator,
        PatientReportStorage reportStorage,
        PatientNotificationSender notificationSender
    ) {
        this.patientRepository = patientRepository;
        this.reportGenerator = reportGenerator;
        this.reportStorage = reportStorage;
        this.notificationSender = notificationSender;
    }
    public void generateAndUploadReport(Long patientId) {
        Patient patient = patientRepository.findById(patientId)
            .orElseThrow(() -> new PatientNotFoundException(patientId));
        byte[] report = reportGenerator.generate(patient);
        reportStorage.store(patientId, report);
        notificationSender.sendReportReadyNotification(patient);
    }
}

AWS implementation:

JAVA
@Component
public class S3PatientReportStorage implements PatientReportStorage {
    private final AmazonS3Client s3Client;
    private final ReportStorageProperties properties;
    public S3PatientReportStorage(
        AmazonS3Client s3Client,
        ReportStorageProperties properties
    ) {
        this.s3Client = s3Client;
        this.properties = properties;
    }
    @Override
    public void store(Long patientId, byte[] report) {
        s3Client.upload(
            properties.getBucketName(),
            patientId + ".pdf",
            report
        );
    }
}

Email implementation:

JAVA
@Component
public class EmailPatientNotificationSender implements PatientNotificationSender {
    private final SmtpEmailClient emailClient;
    public EmailPatientNotificationSender(SmtpEmailClient emailClient) {
        this.emailClient = emailClient;
    }
    @Override
    public void sendReportReadyNotification(Patient patient) {
        emailClient.send(
            patient.getEmail(),
            "Your report is available"
        );
    }
}

Why This Is Better

The business service expresses its workflow clearly:

  1. Load patient.
  2. Generate report.
  3. Store report.
  4. Notify patient.

It does not know:

  • Whether storage is AWS S3.
  • Which S3 bucket is used.
  • Which SMTP server is used.
  • How infrastructure credentials are managed.

The service can now be tested using mocks.

Infrastructure-specific configuration remains in the infrastructure layer.

Sensitive credentials can be loaded securely from configuration or secret-management systems.

27. Interview Perspective

DIP often appears in Java and Spring Boot interviews through architecture scenarios.

An interviewer may ask:

A service directly creates RestTemplate and calls a third-party payment API. What problems do you see?

A strong answer should discuss:

  • Tight coupling
  • Poor testability
  • Infrastructure leakage
  • Provider-specific behavior
  • Dependency injection
  • Business-facing abstractions
  • Adapter implementations

Another common question is:

If Spring already provides dependency injection, does that automatically mean the application follows DIP?

The answer is no.

For example:

JAVA
@Service
public class PaymentService {
    private final StripeClient stripeClient;
}

The dependency is injected, but high-level business logic still depends directly on a low-level provider-specific class.

A better dependency might be:

JAVA
PaymentGateway

At senior level, candidates should also explain when not to create an interface.

DIP should reduce meaningful architectural coupling, not create abstraction layers around every trivial Java class.

28. Interview Questions and Answers

Basic Question

Question: What is the Dependency Inversion Principle?

Answer:

DIP states that high-level business modules should not depend directly on low-level implementation modules.

Both should depend on abstractions.

In practical Java development, business services should depend on capabilities such as PaymentGateway rather than provider-specific classes such as StripeClient.

Intermediate Question

Question: What is the difference between dependency injection and dependency inversion?

Answer:

Dependency injection is a technique for supplying dependencies from outside a class.

Dependency inversion is an architectural principle about the direction of dependencies.

This class uses dependency injection:

JAVA
public OrderService(StripeClient stripeClient) {
    this.stripeClient = stripeClient;
}

But it may still violate DIP because the business service depends directly on a low-level provider.

Using:

JAVA
PaymentGateway

creates a more appropriate dependency direction.

Spring's dependency injection mechanism helps implement DIP, but the two concepts are not identical.

Advanced Question

Question: Where should the abstraction be defined?

Answer:

The abstraction should normally be driven by the needs of the high-level application or business logic.

For example, instead of copying every method from a vendor SDK into an interface, define only the capability the application requires:

JAVA
PaymentResult charge(PaymentRequest request);

The infrastructure adapter then translates between the application's abstraction and the vendor API.

This keeps the abstraction stable even when the vendor implementation changes.

Scenario-Based Question

Question: An application uses AWS S3 directly in ten service classes. The company wants the option to move some workloads to Azure Blob Storage. What would you do?

Answer:

I would identify the storage capabilities required by the business and introduce application-owned abstractions such as:

JAVA
DocumentStorage
InvoiceStorage
AttachmentStorage

depending on the actual domain requirements.

AWS-specific operations would move into adapters implementing those interfaces.

Business services would depend on the abstractions.

Azure implementations could then be introduced without rewriting core workflows.

I would avoid creating one overly generic storage abstraction if different domains have significantly different behavior.

Code-Review Question

Question: What code smells indicate a DIP problem?

Answer:

Common signals include:

  • new external client inside a service
  • Vendor SDK imports in business classes
  • Static infrastructure utilities
  • Hard-coded provider configuration
  • Provider-specific DTOs in service methods
  • Difficult-to-mock dependencies
  • Multiple services duplicating API integration code
  • Direct Kafka, SMTP, S3, or HTTP client usage throughout the business layer
  • Service locator usage

These should be reviewed as possible dependency-boundary problems.

Real-Project Question

Question: How would you implement DIP in a Spring Boot payment module?

Answer:

I would define an application-facing contract:

JAVA
public interface PaymentGateway {
    PaymentResult charge(PaymentRequest request);
}

Then implement:

JAVA
StripePaymentGateway
RazorpayPaymentGateway

as Spring beans.

The business service would inject PaymentGateway.

Provider credentials, request mapping, SDK exceptions, timeout policies, and vendor response handling would remain inside the provider adapter or infrastructure configuration.

Unit tests would mock PaymentGateway, while integration tests would test each provider adapter separately.

29. Quick Rule to Remember

Business logic should describe what it needs, not which technical product must provide it.

30. Final Takeaway

The Dependency Inversion Principle protects important business logic from unnecessary dependency on infrastructure details.

Developers should remember:

  • Business services should depend on meaningful abstractions.
  • Infrastructure implementations should satisfy those abstractions.
  • Constructor injection helps make dependencies explicit.
  • Dependency injection alone does not guarantee DIP.
  • Vendor-specific SDKs should normally stay near the integration boundary.
  • Application-owned request, response, and exception types reduce vendor leakage.
  • External configuration and secrets should not be hard-coded.
  • Abstractions should be created where they solve real coupling problems, not mechanically around every class.

During Pull Request review, reviewers should check:

  • Whether business code imports infrastructure-specific classes.
  • Whether external clients are created inside service methods.
  • Whether concrete dependencies make unit testing difficult.
  • Whether vendor-specific DTOs or exceptions leak into core logic.
  • Whether configuration and credentials are embedded in source code.
  • Whether a meaningful business abstraction would isolate the dependency.
  • Whether the proposed interface actually represents a stable application capability.

Production code should avoid architectures where changing:

JAVA
payment provider
message broker
cloud storage
email service
external API

requires rewriting the core business workflow.

A strong Java design keeps business policies stable while allowing technical implementations to change behind clear, focused abstractions.