Recognizing Tight Coupling

23 min read

Object-Oriented Design and SOLID Review — identify tight coupling signals in Java code and apply meaningful abstractions to reduce unnecessary dependencies.

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:

JAVA
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:

JAVA
@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:

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

The business service then depends on that capability:

JAVA
@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:

JAVA
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:

JAVA
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:

JAVA
OrderService -> OrderRepository

is 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

JAVA
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }
}

OrderService needs persistence. That dependency is explicit.

Tight Coupling

JAVA
@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 ApplicationContext as 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.

JAVA
@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

JAVA
MySqlOrderRepository orderRepository =
        new MySqlOrderRepository();

The service decides which database implementation to use.

If persistence changes, business logic must change.

Concrete Payment Provider

JAVA
StripePaymentClient paymentClient =
        new StripePaymentClient(...);

The service knows the exact payment vendor.

Changing payment providers requires changing OrderService.

Configuration Knowledge

The business service retrieves:

JAVA
System.getenv("PAYMENT_API_KEY")

Credential and environment configuration are infrastructure concerns.

Vendor-Specific Response Model

JAVA
StripePaymentResponse

Vendor-specific DTOs have entered the business workflow.

If several services use this type, changing the payment provider becomes expensive.

Direct Notification Implementation

JAVA
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

OrderService is 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:

StripePaymentResponse is 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

JAVA
public interface PaymentGateway {
    PaymentResult process(
            Long orderId,
            BigDecimal amount);
}

Application-Level Result

JAVA
public record PaymentResult(
        boolean successful,
        String transactionId) {
}

Stripe Adapter

JAVA
@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

JAVA
public interface OrderNotificationService {
    void sendOrderConfirmation(
            String customerEmail,
            Long orderId);
}

Order Service

JAVA
@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.

JAVA
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

AreaTightly Coupled CodeImproved Design
Dependency creationBusiness service constructs dependenciesDependencies are injected
Payment vendorHardcoded Stripe dependencyPayment capability exposed through boundary
Vendor DTOUsed directly by business serviceConverted to application result
ConfigurationRead inside business methodHandled externally
TestabilityDifficult to mockDependencies easily replaced
MaintainabilityProvider change affects business serviceProvider change remains near adapter
ReliabilityMay bypass configured clientsManaged infrastructure dependencies can be used
ReadabilityConstruction and business logic mixedWorkflow remains focused
Change impactPotentially broadMore localized

13. Real Project Scenario

Consider a healthcare application that verifies patient insurance eligibility.

Initially:

JAVA
EligibilityService

directly uses:

JAVA
VendorAInsuranceClient

Vendor 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:

JAVA
InsuranceEligibilityGateway

with an application-level result:

JAVA
EligibilityResult

Vendor-specific adapters can then translate:

JAVA
VendorAResponse -> EligibilityResult

and:

JAVA
VendorBResponse -> EligibilityResult

The 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

JAVA
PaymentGateway gateway =
        new StripePaymentGateway();

This tightly binds the caller to one implementation.

Static Utility Services

JAVA
EmailUtil.sendEmail(...);

Static access hides dependencies and makes substitution difficult.

Service Locator Pattern

JAVA
PaymentService paymentService =
        ApplicationContextProvider.getBean(
                PaymentService.class);

The class now has a hidden runtime dependency on the Spring container.

Depending on Vendor DTOs Everywhere

JAVA
StripePaymentResponse

should not automatically become the business model for the complete application.

Creating Interfaces Without Purpose

Developers sometimes attempt to reduce coupling by creating:

JAVA
UserService
UserServiceImpl

even 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:

JAVA
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

JAVA
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:

JAVA
TaxCalculator

may 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:

JAVA
OrderService -> PaymentService
PaymentService -> OrderService

This 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:

JAVA
StripeApiException

may be better translated into:

JAVA
PaymentProcessingException

where appropriate.

17. Performance Considerations

Tight coupling itself does not have a specific Big-O complexity.

The performance concern is indirect.

Repeated Object Creation

Bad:

JAVA
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:

JAVA
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:

JAVA
@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.

JAVA
@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.

JAVA
@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:

JAVA
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:

JAVA
new StripePaymentClient(...)

out of business logic.

Use dependency injection.

Step 3: Define a Boundary Only If Useful

If provider independence matters, create:

JAVA
PaymentGateway

Do not create abstractions mechanically.

Step 4: Isolate Vendor Models

Convert:

JAVA
StripePaymentResponse

to:

JAVA
PaymentResult

at 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:

JAVA
processPayment()

rather than:

JAVA
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

JAVA
new MySqlRepository()

Avoid because business logic becomes responsible for infrastructure creation.

Static Business Services

JAVA
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

JAVA
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

  1. OrderService is creating the payment client directly. Could we inject the dependency so the service is not responsible for client construction and configuration?
  1. This business flow currently depends on StripePaymentResponse. Could we map the provider response at the integration boundary and expose an application-level result instead?
  1. The new code reads the API key directly from the environment inside the service. Please keep credential/configuration handling outside the business workflow.
  1. Could we avoid resolving PaymentService through ApplicationContext here? Constructor Injection would make this dependency explicit and easier to test.
  1. 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.
  1. The payment implementation is selected using repeated if/else conditions across several services. Could provider selection be centralized so callers depend only on the required capability?
  1. 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.
  1. 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?
  1. 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?
  1. 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.

JAVA
@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:

  • MySqlCustomerRepository
  • ExperianCreditClient
  • SmsVendorClient
  • ExperianCreditResponse
  • Environment configuration

Problem 1: Database Implementation Coupling

JAVA
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

JAVA
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

JAVA
ExperianCreditResponse

The loan rule only needs a credit score.

Using the vendor model directly creates unnecessary dependency on the external API contract.

Problem 4: Configuration Leakage

JAVA
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:

JAVA
public interface CreditScoreProvider {
    CreditScore getCreditScore(String panNumber);
}

Application model:

JAVA
public record CreditScore(int value) {
}

Notification capability:

JAVA
public interface LoanNotificationService {
    void sendApprovalNotification(Customer customer);
}

Improved service:

JAVA
@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:

JAVA
creditScore >= 750

It 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:

JAVA
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:

JAVA
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:

JAVA
PaymentGateway

with a simple operation:

JAVA
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?

JAVA
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:

JAVA
ProviderCustomerResponse

When 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:

JAVA
CustomerProfile

the 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.