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:
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:
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:
- High-level modules should not depend directly on low-level modules.
- 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:
StripeClientBut 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:
PaymentGatewayThe infrastructure layer can then provide:
StripePaymentGatewayor:
RazorpayPaymentGatewayor:
PayPalPaymentGatewayThe 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:
PaymentGateway paymentGateway = mock(PaymentGateway.class);The test does not need a real payment provider.
Readability
A dependency such as:
PaymentGatewaycommunicates business intent more clearly than:
StripeSdkClientinside 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:
@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:
public interface InvoiceStorage {
void store(InvoiceDocument document);
}Then AWS becomes one implementation:
@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:
InvoiceStoragenot:
S3ClientThe 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
PaymentGatewayover infrastructure-shaped names such asStripeServiceInterface. - 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.
@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:
StripeClientdirectly.
The order workflow is now tightly coupled to one payment provider.
Direct Dependency on Email Infrastructure
The service also creates:
EmailClientdirectly.
Order business logic now controls SMTP infrastructure.
Hard-Coded Configuration
The code contains:
"secret-key"
"smtp.company.com"This is both a maintainability and security problem.
Difficult Unit Testing
A unit test cannot easily replace:
StripeClientor:
EmailClientbecause 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:
PaymentResponsefrom 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:
OrderServicecurrently createsStripeClientdirectly, which tightly couples the business workflow to one provider and makes unit testing difficult. Can we introduce aPaymentGatewayabstraction 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.
public interface PaymentGateway {
PaymentResult charge(PaymentRequest paymentRequest);
}Define a notification abstraction.
public interface OrderNotificationSender {
void sendOrderConfirmation(Order order, String email);
}Implement the payment integration.
@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.
@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.
@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:
PaymentGatewayEmail Infrastructure Is Isolated
The order service depends on:
OrderNotificationSenderrather 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:
StripePaymentGatewayThe business layer receives:
PaymentResultProvider 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
| Area | Bad Code | Improved Code |
|---|---|---|
| Dependency direction | Business service depends on vendor classes | Business service depends on abstractions |
| Testability | External clients created internally | Dependencies can be mocked |
| Provider migration | Requires service changes | Mostly isolated to adapter/configuration |
| Readability | Infrastructure details mixed with business logic | Business workflow is clearer |
| Security | Configuration hard-coded | Configuration can be externalized |
| Reliability | Provider behavior leaks into service | Adapter translates provider behavior |
| Maintainability | High coupling | Lower 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:
@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:
public interface TransferEventPublisher {
void publish(TransferCompletedEvent event);
}Kafka becomes one adapter:
@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:
TransferEventPublisherThis 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:
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:
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:
StripeClientInterfacePrefer:
PaymentGatewayThe abstraction should describe the business capability.
Leaking Vendor DTOs
Avoid exposing:
StripePaymentResponsefrom the payment abstraction.
Use application-owned result types.
Static Utility Dependencies
Example:
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:
PaymentGatewaymay 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:
database save
payment call
event publicationmay require transactional or eventual-consistency design.
17. Performance Considerations
DIP itself does not significantly change algorithmic complexity.
Calling:
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:
new HttpClient()inside every service method.
Better:
inject configured clientResource 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:
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:
@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:
StripePaymentGatewayagainst 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
RestTemplateWebClient- 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:
StripeClientBut:
PaymentGatewayStep 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:
new StripeClient()
new EmailClient()
new KafkaProducer()because the service owns infrastructure creation.
Vendor-Specific Dependencies in Core Services
Avoid:
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:
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:
CustomerServiceInterfacecreated 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:
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
newinside 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
- *OrderService creates StripeClient directly, which makes the business workflow tightly coupled and difficult to test. Please inject a payment abstraction instead.*
- *This service depends on the vendor-specific StripePaymentResponse type. Can we map this response inside the adapter and expose an application-owned PaymentResult?*
- *Using dependency injection here is good, but the service still depends directly on the concrete provider class. Could the dependency be PaymentGateway instead?*
- *The API URL and key should not be hard-coded in this class. Please move them to external configuration owned by the integration layer.*
- *This KafkaTemplate dependency leaks messaging infrastructure into the business service. Consider introducing an OrderEventPublisher abstraction.*
- *Please avoid creating HttpClient inside the method. Inject a configured reusable client through the adapter.*
- *The interface is named after the implementation. Could we rename it around the business capability so the contract remains stable if the provider changes?*
- *External API exceptions are reaching the service layer directly. Please translate them into an application-level exception in the adapter.*
- *This class cannot be unit tested without the real external dependency because it constructs the client internally. Constructor injection would make the dependency replaceable.*
- *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.
@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:
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:
AmazonS3ClientThe actual business need is:
Store patient report.
Issue 3: Hard-Coded Credentials
The service contains:
"access-key"
"secret-key"This is a serious security problem.
Issue 4: Direct SMTP Dependency
The service directly constructs:
SmtpEmailClientIssue 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.
public interface PatientReportGenerator {
byte[] generate(Patient patient);
}Define report storage.
public interface PatientReportStorage {
void store(Long patientId, byte[] report);
}Define notification capability.
public interface PatientNotificationSender {
void sendReportReadyNotification(Patient patient);
}Implement the business service.
@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:
@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:
@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:
- Load patient.
- Generate report.
- Store report.
- 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:
@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:
PaymentGatewayAt 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:
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:
PaymentGatewaycreates 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:
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:
DocumentStorage
InvoiceStorage
AttachmentStoragedepending 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:
newexternal 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:
public interface PaymentGateway {
PaymentResult charge(PaymentRequest request);
}Then implement:
StripePaymentGateway
RazorpayPaymentGatewayas 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:
payment provider
message broker
cloud storage
email service
external APIrequires rewriting the core business workflow.
A strong Java design keeps business policies stable while allowing technical implementations to change behind clear, focused abstractions.