1. Introduction
Tight coupling occurs when one class depends too heavily on the internal implementation, concrete classes, construction details, configuration, or behavior of another class.
In real Java projects, some coupling between classes is unavoidable. An OrderService must communicate with repositories, payment systems, inventory systems, notification components, or other services.
The problem begins when those dependencies become difficult to replace, test, configure, or modify independently.
Typical examples include:
PaymentGateway gateway = new StripePaymentGateway();
EmailSender.sendEmail(customerEmail, message);
ApplicationContextProvider.getBean(PaymentService.class);
order.getCustomer().getAddress().getCountry().getCode();These implementations make one class know more about another component than it should.
During Pull Request review, reviewers should recognize tight coupling because it often leads to:
- Difficult unit testing
- Large change impact
- Hard-to-replace implementations
- Fragile integrations
- Complicated maintenance
- Hidden dependencies
- Reduced modularity
- Increased regression risk
The objective is not to eliminate every dependency. The objective is to make dependencies intentional, explicit, and replaceable where the project genuinely needs flexibility.
2. What This Topic Means
A class is tightly coupled when changing one component frequently forces changes in another component that should ideally remain independent.
Consider:
@Service
public class PaymentService {
private final StripePaymentClient paymentClient =
new StripePaymentClient(
"https://payments.example.com",
"secret-key");
public PaymentResult process(PaymentRequest request) {
return paymentClient.charge(request);
}
}PaymentService is coupled to:
StripePaymentClient- The client's constructor
- The payment service URL
- Credential handling
- The Stripe-specific implementation
- The client's lifecycle
If the company moves from Stripe to another payment provider, the business service itself must change.
A less tightly coupled design would make the required capability explicit:
public interface PaymentGateway {
PaymentResult charge(PaymentRequest request);
}The business service then depends on that capability:
@Service
public class PaymentService {
private final PaymentGateway paymentGateway;
public PaymentService(PaymentGateway paymentGateway) {
this.paymentGateway = paymentGateway;
}
public PaymentResult process(PaymentRequest request) {
return paymentGateway.charge(request);
}
}The business logic now cares about processing payments rather than how a particular vendor performs that operation.
3. Why It Matters in Real Projects
Maintainability
Tightly coupled classes increase the number of files that must change when requirements evolve.
If changing a payment provider requires modifications across:
- Controllers
- Services
- Schedulers
- Batch jobs
- Utility classes
the dependency boundary is likely weak.
Testability
A tightly coupled service may create real dependencies internally.
Example:
PaymentClient client = new PaymentClient();Unit tests cannot easily replace the client with a mock.
Tests may accidentally:
- Call real external APIs
- Require databases
- Depend on environment variables
- Require Spring startup
- Become slow and unreliable
Readability
Hidden dependencies make it difficult to understand what a class requires.
A constructor such as:
public OrderService(
OrderRepository orderRepository,
PaymentGateway paymentGateway) {
}is easier to understand than dependencies created throughout individual methods.
Debugging
Tightly coupled workflows can produce failures far away from the actual source of the problem.
Changing one implementation may unexpectedly affect several unrelated components.
Reliability
When implementation details leak between layers, small internal changes can create production regressions.
Team Development
Different teams often own different modules or microservices.
Strong boundaries allow teams to modify implementations independently.
Tight coupling creates cross-team coordination for changes that should have remained local.
4. Core Concept
Coupling describes the dependency relationship between software components.
Some coupling is necessary.
For example:
OrderService -> OrderRepositoryis a legitimate relationship.
The review question is not:
Does this class depend on another class?
The better questions are:
How much does this class know about the dependency?
and:
How difficult would it be to replace or change that dependency?
Reasonable Coupling
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
}OrderService needs persistence. That dependency is explicit.
Tight Coupling
@Service
public class OrderService {
private final JdbcOrderRepository orderRepository =
new JdbcOrderRepository(
DriverManager.getConnection(
"jdbc:mysql://localhost/orders",
"root",
"password"));
}Now the service knows:
- Repository implementation
- JDBC details
- Database connection creation
- Database URL
- Credentials
- Object lifecycle
That is much stronger coupling than the business service requires.
Recognizing Tight Coupling
Common signals include:
- Concrete dependencies created using
new - Static service access
- Global service locators
- Business logic depending on infrastructure details
- Long chains of method calls across object structures
- Direct dependency on vendor-specific APIs throughout business code
- Changes requiring modifications across unrelated classes
- Classes that cannot be tested without real infrastructure
- Classes importing implementation packages instead of stable abstractions
- Repeated conditional logic selecting implementations
5. Important Rules
- Do not create infrastructure dependencies directly inside business services unless the object is truly local and implementation-independent.
- Prefer dependency injection for external collaborators.
- Depend on a meaningful abstraction when multiple implementations or substitution are realistic requirements.
- Do not introduce interfaces mechanically for every class.
- Keep vendor-specific code near the integration boundary.
- Avoid global static access to services.
- Avoid using Spring's
ApplicationContextas a service locator inside business logic. - Keep database implementation details outside business services.
- Do not expose unnecessary internal object structures between modules.
- Avoid leaking external API DTOs throughout the domain or service layer.
- Use adapters or mappers where vendor-specific models would otherwise spread across the application.
- Review how many files must change when one implementation changes.
- Keep dependencies explicit through constructors.
- Treat difficult unit testing as a possible coupling signal.
- Do not solve every coupling problem with a design pattern.
- Introduce abstraction only where it creates a useful boundary.
6. Bad Code Example
Consider a Spring Boot order-processing service.
@Service
public class OrderService {
public OrderResult placeOrder(OrderRequest request) {
MySqlOrderRepository orderRepository =
new MySqlOrderRepository();
StripePaymentClient paymentClient =
new StripePaymentClient(
"https://api.payment-provider.com",
System.getenv("PAYMENT_API_KEY"));
EmailNotificationClient emailClient =
new EmailNotificationClient();
Order order = new Order(
request.getCustomerId(),
request.getAmount());
Order savedOrder = orderRepository.save(order);
StripePaymentResponse paymentResponse =
paymentClient.createPayment(
savedOrder.getId(),
savedOrder.getAmount());
if ("SUCCESS".equals(paymentResponse.getStatus())) {
emailClient.send(
request.getCustomerEmail(),
"Your order was successfully placed");
}
return new OrderResult(
savedOrder.getId(),
paymentResponse.getStatus());
}
}At first glance, the method performs the required workflow.
However, the class is strongly coupled to several implementation details.
7. Problems in the Bad Code
Concrete Repository Construction
MySqlOrderRepository orderRepository =
new MySqlOrderRepository();The service decides which database implementation to use.
If persistence changes, business logic must change.
Concrete Payment Provider
StripePaymentClient paymentClient =
new StripePaymentClient(...);The service knows the exact payment vendor.
Changing payment providers requires changing OrderService.
Configuration Knowledge
The business service retrieves:
System.getenv("PAYMENT_API_KEY")Credential and environment configuration are infrastructure concerns.
Vendor-Specific Response Model
StripePaymentResponseVendor-specific DTOs have entered the business workflow.
If several services use this type, changing the payment provider becomes expensive.
Direct Notification Implementation
EmailNotificationClient emailClient =
new EmailNotificationClient();The service controls notification construction and channel.
Difficult Unit Testing
A unit test cannot easily substitute:
- Payment client
- Repository
- Email client
The test may require real infrastructure or invasive techniques.
Multiple Reasons to Change
OrderService changes when:
- Database implementation changes
- Payment provider changes
- Payment configuration changes
- Notification technology changes
- Order workflow changes
This is a strong maintainability smell.
Production Risk
External clients are constructed directly inside a business method.
This may bypass production configuration such as:
- Timeouts
- Connection pooling
- Retry policy
- Authentication configuration
- Metrics
- Tracing
8. Code Review Findings
During Pull Request review, a senior Java developer should notice:
- The service creates infrastructure dependencies itself.
- The business layer depends directly on a payment-vendor implementation.
- Vendor-specific response types are leaking into application logic.
- Configuration values are being accessed from inside business processing.
- The notification implementation is hardcoded.
- Dependencies cannot easily be mocked.
- Changing implementation technology will require editing business logic.
- Client construction may bypass Spring configuration.
- The method combines orchestration with dependency construction.
- Production features such as configured timeouts or interceptors may be skipped.
The reviewer should determine whether stable boundaries already exist in the codebase before introducing new abstractions.
The goal is not to add unnecessary interfaces. The goal is to separate business requirements from infrastructure implementation details.
9. Reviewer Comment Example
OrderServiceis currently constructing the repository and external clients directly, which couples the order workflow to specific implementations. Could we inject these collaborators and keep the vendor-specific payment details behind a payment gateway boundary?
Another useful comment:
StripePaymentResponseis leaking into the order business flow. Consider mapping the provider response to an application-level result so changing the payment provider does not require changes throughout the service layer.
10. Improved Code
Payment Abstraction
public interface PaymentGateway {
PaymentResult process(
Long orderId,
BigDecimal amount);
}Application-Level Result
public record PaymentResult(
boolean successful,
String transactionId) {
}Stripe Adapter
@Component
public class StripePaymentGateway
implements PaymentGateway {
private final StripePaymentClient stripePaymentClient;
public StripePaymentGateway(
StripePaymentClient stripePaymentClient) {
this.stripePaymentClient = stripePaymentClient;
}
@Override
public PaymentResult process(
Long orderId,
BigDecimal amount) {
StripePaymentResponse response =
stripePaymentClient.createPayment(
orderId,
amount);
return new PaymentResult(
"SUCCESS".equals(response.getStatus()),
response.getTransactionId());
}
}Notification Abstraction
public interface OrderNotificationService {
void sendOrderConfirmation(
String customerEmail,
Long orderId);
}Order Service
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
private final OrderNotificationService notificationService;
public OrderService(
OrderRepository orderRepository,
PaymentGateway paymentGateway,
OrderNotificationService notificationService) {
this.orderRepository = orderRepository;
this.paymentGateway = paymentGateway;
this.notificationService = notificationService;
}
public OrderResult placeOrder(OrderRequest request) {
Order order = new Order(
request.getCustomerId(),
request.getAmount());
Order savedOrder = orderRepository.save(order);
PaymentResult paymentResult =
paymentGateway.process(
savedOrder.getId(),
savedOrder.getAmount());
if (paymentResult.successful()) {
notificationService.sendOrderConfirmation(
request.getCustomerEmail(),
savedOrder.getId());
}
return new OrderResult(
savedOrder.getId(),
paymentResult.successful());
}
}11. Improved Code Explanation
Dependency Construction Removed
OrderService no longer creates infrastructure objects.
Spring provides the required collaborators.
Vendor Details Isolated
Stripe-specific classes remain inside StripePaymentGateway.
OrderService does not need to know:
- Stripe client classes
- Stripe status formats
- Stripe response DTOs
Stable Application Contract
PaymentResult represents what the application needs from payment processing.
The application is no longer built around a vendor response.
Improved Testability
Tests can provide mocked dependencies.
PaymentGateway paymentGateway =
mock(PaymentGateway.class);No real payment provider is required.
Configuration Separation
Payment URL, API keys, timeouts, authentication, and HTTP client configuration can be handled in infrastructure configuration rather than inside order processing.
Smaller Change Impact
Moving to another payment provider can often be handled by creating another PaymentGateway implementation without rewriting the main order workflow.
12. Bad Code vs Improved Code
| Area | Tightly Coupled Code | Improved Design |
|---|---|---|
| Dependency creation | Business service constructs dependencies | Dependencies are injected |
| Payment vendor | Hardcoded Stripe dependency | Payment capability exposed through boundary |
| Vendor DTO | Used directly by business service | Converted to application result |
| Configuration | Read inside business method | Handled externally |
| Testability | Difficult to mock | Dependencies easily replaced |
| Maintainability | Provider change affects business service | Provider change remains near adapter |
| Reliability | May bypass configured clients | Managed infrastructure dependencies can be used |
| Readability | Construction and business logic mixed | Workflow remains focused |
| Change impact | Potentially broad | More localized |
13. Real Project Scenario
Consider a healthcare application that verifies patient insurance eligibility.
Initially:
EligibilityServicedirectly uses:
VendorAInsuranceClientVendor A's request and response DTOs are used throughout:
- Patient service
- Billing service
- Appointment service
- Claims service
Later, the company needs to integrate Vendor B for another hospital network.
The development team discovers that the entire application assumes Vendor A's data model.
Changing the integration requires modifications across many modules.
A better boundary would be:
InsuranceEligibilityGatewaywith an application-level result:
EligibilityResultVendor-specific adapters can then translate:
VendorAResponse -> EligibilityResultand:
VendorBResponse -> EligibilityResultThe healthcare business logic remains focused on questions such as:
- Is coverage active?
- What is the coverage type?
- What is the effective date?
instead of vendor-specific transport formats.
This is a practical example of why recognizing coupling early during code review matters.
14. Production Impact
Tight coupling does not automatically cause runtime failure, but it significantly increases change risk.
Larger Regression Surface
Changing one integration may require updates across many services.
Each additional modification introduces another opportunity for regression.
Difficult Provider Migration
A business may need to change:
- Payment gateway
- SMS provider
- Cloud storage
- Database technology
- External healthcare provider
- Identity provider
Vendor-specific logic spread throughout the application makes migration expensive.
Configuration Problems
Manually constructed clients may bypass:
- Connection pooling
- Timeouts
- Authentication interceptors
- Retry configuration
- Circuit breakers
- Monitoring
Difficult Incident Response
When concerns are mixed, production engineers must investigate business logic and infrastructure behavior in the same code path.
Reduced Test Coverage
If classes cannot be tested without infrastructure, developers may avoid writing focused tests.
This increases production risk.
15. Common Developer Mistakes
Creating Collaborators with new
PaymentGateway gateway =
new StripePaymentGateway();This tightly binds the caller to one implementation.
Static Utility Services
EmailUtil.sendEmail(...);Static access hides dependencies and makes substitution difficult.
Service Locator Pattern
PaymentService paymentService =
ApplicationContextProvider.getBean(
PaymentService.class);The class now has a hidden runtime dependency on the Spring container.
Depending on Vendor DTOs Everywhere
StripePaymentResponseshould not automatically become the business model for the complete application.
Creating Interfaces Without Purpose
Developers sometimes attempt to reduce coupling by creating:
UserService
UserServiceImpleven though:
- There is only one implementation
- No meaningful boundary exists
- No substitution is expected
- The interface exactly duplicates the implementation API
Interfaces are useful when they represent a meaningful abstraction, not merely because an implementation exists.
Exposing Internal Object Graphs
Example:
order.getCustomer()
.getProfile()
.getAddress()
.getCountry()
.getCode();The calling code depends on several internal object relationships.
A structural change can break many callers.
Direct Cross-Layer Access
A controller directly using repository implementation details may bypass service-level business rules.
Hardcoded Implementation Selection
if ("STRIPE".equals(provider)) {
return new StripePaymentGateway();
} else {
return new PayPalPaymentGateway();
}When scattered across the application, this creates coupling to construction logic.
16. Edge Cases
Only One Implementation Exists
An interface is not automatically required simply because one class depends on another.
Example:
TaxCalculatormay be a stable domain service with one implementation.
Direct constructor dependency may be perfectly acceptable.
The review should consider whether abstraction provides real value.
Framework Components
Depending on a Spring repository interface is usually reasonable.
Do not create another wrapper merely to satisfy an abstract rule unless the boundary is useful.
DTO Mapping
Not every DTO needs a separate internal representation.
Mapping is most valuable when:
- External schemas are unstable
- Vendor-specific details would spread
- Business needs differ from transport representation
Circular Dependencies
Tight coupling may become visible as:
OrderService -> PaymentService
PaymentService -> OrderServiceThis should trigger architectural review.
Optional Integrations
If notification is optional, the dependency design should clearly represent that behavior rather than scattering null checks.
Multiple Implementations
When multiple implementations exist, ensure bean resolution is explicit using appropriate Spring configuration, @Qualifier, or @Primary.
Exception Types
External exceptions should not necessarily propagate through the complete business layer.
Example:
StripeApiExceptionmay be better translated into:
PaymentProcessingExceptionwhere appropriate.
17. Performance Considerations
Tight coupling itself does not have a specific Big-O complexity.
The performance concern is indirect.
Repeated Object Creation
Bad:
public void process(Order order) {
PaymentClient client =
new PaymentClient();
}If a heavyweight client is constructed for every request, the application may waste resources.
Lost Connection Pooling
Manually creating HTTP or database clients may bypass pooling.
This can increase:
- Connection creation
- Latency
- CPU usage
- Resource consumption
Missing Caching
Coupled code may directly call external systems wherever data is required instead of using a centrally managed strategy.
Duplicated Network Calls
If integration logic is scattered across services, multiple components may independently request the same external data.
Important Review Point
Do not claim that lower coupling automatically makes code faster.
Its primary benefits are maintainability, replaceability, testability, and architectural control.
Performance improves only when better dependency management also improves resource handling or call behavior.
18. Security Considerations
Coupling can affect security when sensitive infrastructure details spread into business code.
Hardcoded Credentials
Bad:
new PaymentClient(
"https://api.example.com",
"production-secret-key");Secrets should be supplied through secure configuration rather than embedded in business code.
Vendor Security Logic Spread Across Application
Authentication headers or tokens should normally be managed by the integration layer.
Authorization Bypass
If controllers or other layers bypass the intended service layer and directly access repositories, business authorization rules may accidentally be skipped.
Sensitive External Responses
Vendor DTOs may contain more information than the business layer needs.
Mapping to a smaller application model can reduce unnecessary data exposure.
Logging
Do not log complete external request or response objects without checking whether they contain:
- Access tokens
- Customer data
- Account numbers
- Personal data
- Payment information
Tight coupling is not itself a security vulnerability, but poorly separated responsibilities can make security controls inconsistent.
19. Testing Considerations
Testing is one of the easiest places to detect problematic coupling.
Unit Test
Given:
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
public OrderService(
OrderRepository orderRepository,
PaymentGateway paymentGateway) {
this.orderRepository = orderRepository;
this.paymentGateway = paymentGateway;
}
}the class can be instantiated using mocks.
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private PaymentGateway paymentGateway;
private OrderService orderService;
@BeforeEach
void setUp() {
orderService = new OrderService(
orderRepository,
paymentGateway);
}
}Positive Test
Verify successful dependency interaction.
@Test
void shouldCompleteOrderWhenPaymentSucceeds() {
Order savedOrder =
new Order(101L, BigDecimal.valueOf(500));
when(orderRepository.save(any(Order.class)))
.thenReturn(savedOrder);
when(paymentGateway.process(
savedOrder.getId(),
savedOrder.getAmount()))
.thenReturn(
new PaymentResult(
true,
"TXN-1001"));
OrderResult result =
orderService.placeOrder(
new OrderRequest(
101L,
BigDecimal.valueOf(500)));
assertTrue(result.successful());
}Failure Test
Mock payment failure without calling a real payment system.
Exception Test
Verify behavior when:
paymentGateway.process(...)throws a payment exception.
Integration Test
Test the real payment adapter separately when verifying:
- HTTP serialization
- Authentication
- Vendor response mapping
- Retry behavior
- Timeouts
Architecture Testing
For large systems, architectural tests can also prevent forbidden dependencies between packages or layers.
For example, an organization may enforce that domain packages cannot depend directly on infrastructure packages.
20. Refactoring Guidelines
Refactoring tightly coupled code should be incremental.
Step 1: Identify the Actual Dependency
Find what capability the class needs.
Example:
The business service does not need "Stripe."
It needs:
Process a payment.
Step 2: Separate Construction from Usage
Move:
new StripePaymentClient(...)out of business logic.
Use dependency injection.
Step 3: Define a Boundary Only If Useful
If provider independence matters, create:
PaymentGatewayDo not create abstractions mechanically.
Step 4: Isolate Vendor Models
Convert:
StripePaymentResponseto:
PaymentResultat the integration boundary.
Step 5: Preserve Existing Behavior
Do not modify payment rules while performing architectural refactoring unless required.
Step 6: Add Characterization Tests
Before changing risky legacy code, add tests that capture current behavior.
Step 7: Move Configuration
Extract:
- URL
- API keys
- Timeouts
- Retry configuration
from business classes.
Step 8: Replace Callers Gradually
For large legacy applications, migrate one dependency path at a time.
Step 9: Remove Dead Coupling
After migration, remove:
- Old utility access
- Direct client construction
- Unused provider DTOs
- Temporary adapters
21. Best Practices
Make Collaborators Explicit
Use constructor parameters for required dependencies.
Keep Business Logic Vendor-Neutral Where Valuable
Business code should normally express concepts such as:
processPayment()rather than:
callStripeV3ChargeEndpoint()Localize External API Knowledge
Keep:
- Vendor request objects
- Vendor responses
- HTTP details
- Authentication
- Vendor status codes
inside the integration layer where practical.
Use Application-Level Models at Boundaries
Translate external concepts into models that reflect what the application actually needs.
Prefer Composition
Give a class the collaborators it needs instead of allowing it to locate or construct them globally.
Review Change Impact
Ask:
If we replace this implementation, how many application classes need to change?
The answer is a useful coupling indicator.
Use Abstraction Selectively
Abstraction should protect a useful boundary.
Do not create unnecessary layers for simple, stable local code.
22. Practices to Avoid
Direct Infrastructure Construction in Services
new MySqlRepository()Avoid because business logic becomes responsible for infrastructure creation.
Static Business Services
PaymentUtil.processPayment(...);Avoid when the operation depends on configurable collaborators or requires substitution.
ApplicationContext.getBean() in Business Logic
Avoid because it hides dependencies and creates container coupling.
Vendor DTOs Across Multiple Layers
Avoid because one vendor schema becomes an application-wide contract.
Repository Access from Every Layer
Avoid when it bypasses domain or application rules.
Deep Object Navigation
claim.getPatient()
.getInsurance()
.getProvider()
.getConfiguration()
.getCode();Question whether the caller should know that complete object structure.
Interface for Every Class
Avoid artificial abstractions that add no meaningful decoupling.
Dependency Wrapper Created Only to Hide Constructor Size
If a service has twelve unrelated dependencies, putting all twelve into a single ServiceDependencies object does not solve excessive responsibility.
23. Code Review Checklist
- Is this class constructing a dependency that should be injected?
- Does business logic depend directly on a vendor-specific implementation?
- Are external API DTOs leaking into unrelated application layers?
- Would replacing this implementation require changes in multiple business services?
- Can this class be unit-tested without real infrastructure?
- Are dependencies explicit in the constructor?
- Is the class using static access to another service?
- Is the class retrieving dependencies through the Spring application context?
- Does this class know configuration details that belong to infrastructure?
- Are database implementation details leaking into business logic?
- Are external API status codes interpreted throughout the application?
- Should provider-specific exceptions be translated at the integration boundary?
- Is this abstraction protecting a genuine architectural boundary?
- Has an unnecessary interface been introduced without a real requirement?
- Is a long object navigation chain exposing internal structure?
- Does a small implementation change require modifications in unrelated modules?
- Are HTTP clients configured centrally rather than created per request?
- Are credentials or endpoint URLs embedded inside business classes?
- Is a circular dependency exposing excessive coupling?
- Can one component be replaced with a test double easily?
- Is the service doing both business orchestration and infrastructure construction?
- Are cross-layer dependencies respecting the intended architecture?
- Has the code introduced another wrapper without actually reducing coupling?
- Would this design remain manageable if the external provider changed?
24. Common Pull Request Review Comments
OrderServiceis creating the payment client directly. Could we inject the dependency so the service is not responsible for client construction and configuration?
- This business flow currently depends on
StripePaymentResponse. Could we map the provider response at the integration boundary and expose an application-level result instead?
- The new code reads the API key directly from the environment inside the service. Please keep credential/configuration handling outside the business workflow.
- Could we avoid resolving
PaymentServicethroughApplicationContexthere? Constructor Injection would make this dependency explicit and easier to test.
- This change introduces direct calls to the repository from the controller. Please check whether this bypasses validation or authorization currently handled by the service layer.
- The payment implementation is selected using repeated
if/elseconditions across several services. Could provider selection be centralized so callers depend only on the required capability?
- This class is tightly coupled to the external provider's exception type. Consider translating it at the adapter boundary if the rest of the application does not need provider-specific information.
- This new interface has exactly the same API as the implementation and no clear substitution boundary. Could we confirm what coupling problem the abstraction is intended to solve?
- The call chain reaches through several internal objects to obtain the country code. Could the owning object expose the business information the caller actually needs instead?
- This HTTP client is instantiated for every request. Please use the configured managed client so we retain connection pooling, timeouts, tracing, and authentication configuration.
25. Code Review Exercise
Review the following code.
Identify:
- Tight coupling
- Hidden dependencies
- Infrastructure leakage
- Testing problems
- Maintenance risks
- Production risks
- Appropriate refactoring opportunities
Do not rewrite the code before identifying the problems.
@Service
public class LoanApprovalService {
public LoanDecision approve(
LoanApplication application) {
MySqlCustomerRepository customerRepository =
new MySqlCustomerRepository();
ExperianCreditClient creditClient =
new ExperianCreditClient(
System.getenv("EXPERIAN_URL"),
System.getenv("EXPERIAN_API_KEY"));
SmsVendorClient smsVendorClient =
new SmsVendorClient();
Customer customer =
customerRepository.findById(
application.getCustomerId());
ExperianCreditResponse creditResponse =
creditClient.getCreditScore(
customer.getPanNumber());
boolean approved =
creditResponse.getExperianScore() >= 750;
if (approved) {
smsVendorClient.sendSms(
customer.getPhoneNumber(),
"Loan approved");
}
return new LoanDecision(
approved,
creditResponse.getExperianScore());
}
}Learner Task
Find:
- Which concrete implementations create tight coupling
- Which configuration details are in the wrong layer
- Which external DTO leaks into business logic
- Why unit testing is difficult
- What abstractions would provide useful boundaries
- What should remain simple rather than over-engineered
26. Exercise Solution
The service directly depends on:
MySqlCustomerRepositoryExperianCreditClientSmsVendorClientExperianCreditResponse- Environment configuration
Problem 1: Database Implementation Coupling
MySqlCustomerRepository customerRepository =
new MySqlCustomerRepository();The loan business workflow should not normally decide which database technology stores customer data.
Use an injected repository dependency.
Problem 2: Credit Vendor Coupling
ExperianCreditClient creditClient =
new ExperianCreditClient(...);The business requirement is obtaining credit information.
It should not require the service to understand client construction.
Problem 3: Vendor DTO Leakage
ExperianCreditResponseThe loan rule only needs a credit score.
Using the vendor model directly creates unnecessary dependency on the external API contract.
Problem 4: Configuration Leakage
System.getenv("EXPERIAN_URL")
System.getenv("EXPERIAN_API_KEY")Configuration belongs to infrastructure setup.
Problem 5: Notification Coupling
The service directly constructs the SMS vendor client.
Problem 6: Difficult Testing
A unit test cannot easily simulate:
- High credit score
- Low credit score
- Credit provider timeout
- Repository failure
- SMS failure
without interacting with concrete infrastructure.
Improved Boundaries
Credit capability:
public interface CreditScoreProvider {
CreditScore getCreditScore(String panNumber);
}Application model:
public record CreditScore(int value) {
}Notification capability:
public interface LoanNotificationService {
void sendApprovalNotification(Customer customer);
}Improved service:
@Service
public class LoanApprovalService {
private final CustomerRepository customerRepository;
private final CreditScoreProvider creditScoreProvider;
private final LoanNotificationService notificationService;
public LoanApprovalService(
CustomerRepository customerRepository,
CreditScoreProvider creditScoreProvider,
LoanNotificationService notificationService) {
this.customerRepository = customerRepository;
this.creditScoreProvider = creditScoreProvider;
this.notificationService = notificationService;
}
public LoanDecision approve(
LoanApplication application) {
Customer customer =
customerRepository.findById(
application.getCustomerId());
CreditScore creditScore =
creditScoreProvider.getCreditScore(
customer.getPanNumber());
boolean approved =
creditScore.value() >= 750;
if (approved) {
notificationService
.sendApprovalNotification(customer);
}
return new LoanDecision(
approved,
creditScore.value());
}
}Why This Is Better
The loan service now focuses on the business rule:
creditScore >= 750It no longer knows:
- Database implementation
- Credit provider constructor
- Credit API credentials
- Experian response schema
- SMS vendor details
Those concerns can evolve independently.
Important Design Note
Do not introduce ten extra interfaces merely because coupling exists.
The useful boundaries here are the infrastructure capabilities that the loan decision genuinely depends on:
- Customer retrieval
- Credit score retrieval
- Notification
The business rule itself can remain simple.
27. Interview Perspective
Tight coupling commonly appears in senior Java and Spring Boot interviews as a code-review scenario.
An interviewer may show:
public class PaymentService {
private StripeClient client =
new StripeClient();
}and ask:
What problems do you see?
A strong answer should go beyond saying:
Use an interface.
A stronger discussion includes:
- The class controls dependency construction.
- Testing requires a real or difficult-to-replace client.
- Vendor-specific implementation details enter business logic.
- Configuration becomes harder to manage.
- Provider migration requires application changes.
- Constructor Injection would make the dependency explicit.
- An abstraction is useful if it represents a meaningful payment capability.
- Provider DTOs may need translation at the integration boundary.
Senior-Level Interview Expectation
Senior developers should recognize that "low coupling" does not mean:
Create an interface for every class.
Good architecture balances:
- Simplicity
- Change likelihood
- Testability
- Ownership boundaries
- External integration risk
- Maintenance cost
28. Interview Questions and Answers
Basic Question
Question: What is tight coupling in Java?
Answer:
Tight coupling occurs when one class depends heavily on the implementation details of another component.
For example:
public class OrderService {
private final StripeClient client =
new StripeClient();
}OrderService directly controls and depends on StripeClient.
This makes substitution, testing, and independent change more difficult.
Intermediate Question
Question: How can you recognize tight coupling during a Pull Request review?
Answer:
Common indicators include:
- Dependencies created directly using
new - Static access to services
- Service locator usage
- Vendor-specific DTOs throughout business logic
- Hardcoded infrastructure configuration
- Difficult unit testing
- Deep object navigation
- Changes to one implementation requiring edits across many classes
- Circular service dependencies
- Business code importing infrastructure implementation classes
The reviewer should evaluate whether those dependencies are necessary or whether a clearer boundary would reduce change impact.
Advanced Question
Question: Does depending on a concrete class always mean the code is tightly coupled?
Answer:
No.
Depending on a concrete class can be perfectly reasonable when:
- The class is stable
- There is no meaningful alternative implementation
- The dependency is local
- No architectural boundary needs protection
- Testing does not require substitution
Creating an interface purely to avoid concrete dependencies can produce unnecessary abstraction.
The key question is whether the dependency makes future changes, testing, or module independence unnecessarily difficult.
Scenario-Based Question
Question: Your application uses one payment provider today. Should you immediately create a generic payment framework supporting ten providers?
Answer:
No.
That would likely be over-engineering.
A useful approach is to create only the boundary the business already needs, such as:
PaymentGatewaywith a simple operation:
processPayment(...)The current provider can implement that boundary.
There is no need to design for every theoretical provider or feature before the requirement exists.
Code-Review Question
Question: What would you review in this code?
public InvoiceService() {
this.repository =
new OracleInvoiceRepository();
this.emailClient =
new SendGridClient();
}Answer:
The service is responsible for constructing infrastructure dependencies.
Potential review findings:
- Hardcoded database implementation
- Hardcoded notification vendor
- Difficult unit testing
- Configuration likely mixed with business code
- Changing infrastructure requires editing the service
Injecting the required collaborators would make dependencies explicit and improve testability.
Real-Project Question
Question: Give a real example where tight coupling creates maintenance problems.
Answer:
A common example is an external API response model being used throughout the application.
Suppose dozens of classes directly use:
ProviderCustomerResponseWhen the external provider changes its API or the organization replaces the provider, all those classes may require modification.
If the provider response had been converted at the integration boundary into an application-level model such as:
CustomerProfilethe external change could remain localized to the adapter or mapper.
This reduces regression risk and implementation-specific knowledge across the system.
29. Quick Rule to Remember
If changing one implementation forces unrelated business classes to change, review the dependency boundary for unnecessary coupling.
30. Final Takeaway
Tight coupling is not simply "one class using another class."
Java applications naturally contain dependencies.
The real problem is unnecessary dependency on:
- Concrete implementation details
- Object construction
- Vendor APIs
- External DTOs
- Infrastructure configuration
- Global state
- Hidden framework access
- Internal object structures
A developer should remember:
- Keep dependencies explicit.
- Inject external collaborators instead of constructing them inside business logic.
- Keep vendor-specific details near integration boundaries.
- Introduce abstractions only when they solve a real change or testing problem.
- Avoid spreading external models throughout the application.
- Keep configuration separate from business processing.
During Pull Request review, ask:
- Can this component be tested independently?
- Can the implementation change without rewriting business logic?
- Is the dependency visible?
- Does the class know infrastructure details it should not know?
- Is vendor-specific code spreading across layers?
- Is the proposed abstraction genuinely useful?
- How many files will change if this dependency changes?
Avoid production designs where business logic becomes permanently tied to:
- Specific vendors
- Database implementations
- Static services
- Service locators
- Hardcoded configuration
- External transport models
Good design does not attempt to eliminate coupling.
It keeps coupling intentional, limited, visible, and located at the correct architectural boundary.