Low Coupling

24 min read

Object-Oriented Design and SOLID Review — review Java dependency boundaries so business services stay replaceable, testable, and resilient to infrastructure changes.

1. Introduction

Low Coupling means that classes, modules, services, and components should depend on each other as little as reasonably possible.

In practical Java development, coupling describes how strongly one piece of code is connected to another.

A highly coupled class knows too much about:

  • Other classes
  • Their internal structure
  • Concrete implementations
  • Database details
  • External APIs
  • Framework-specific behavior
  • Shared mutable state
  • Method-call sequences

When one component changes, tightly coupled components may also need to change.

Low coupling reduces that dependency.

For example, an order-processing service should ideally know that it needs to:

  • Process payment
  • Save an order
  • Send confirmation

It should not need to know:

  • Which payment SDK is being used
  • Which SMTP library sends email
  • Which HTTP client calls a provider
  • How a repository builds SQL
  • Which concrete storage implementation is configured

Low coupling is therefore a practical design goal in Java and Spring Boot systems because it makes code easier to:

  • Change
  • Test
  • Review
  • Replace
  • Extend
  • Debug
  • Maintain

Low coupling commonly works together with:

  • High Cohesion
  • Dependency Inversion Principle
  • Interface Segregation Principle
  • Single Responsibility Principle
  • Encapsulation
  • Composition
  • Dependency Injection

2. What This Topic Means

Consider the following Spring Boot service:

JAVA
@Service
public class OrderService {
    private final StripePaymentService stripePaymentService;
    private final MySqlOrderRepository mySqlOrderRepository;
    private final SmtpEmailService smtpEmailService;
    public OrderService(
        StripePaymentService stripePaymentService,
        MySqlOrderRepository mySqlOrderRepository,
        SmtpEmailService smtpEmailService
    ) {
        this.stripePaymentService = stripePaymentService;
        this.mySqlOrderRepository = mySqlOrderRepository;
        this.smtpEmailService = smtpEmailService;
    }
}

This service is directly coupled to specific technical implementations:

JAVA
StripePaymentService
MySqlOrderRepository
SmtpEmailService

If the company changes:

  • Stripe to another provider
  • MySQL persistence implementation
  • SMTP to a cloud notification provider

the business service may also need modification.

A lower-coupled design could depend on stable abstractions:

JAVA
PaymentGateway
OrderRepository
OrderNotificationSender

For example:

JAVA
@Service
public class OrderService {
    private final PaymentGateway paymentGateway;
    private final OrderRepository orderRepository;
    private final OrderNotificationSender notificationSender;
    public OrderService(
        PaymentGateway paymentGateway,
        OrderRepository orderRepository,
        OrderNotificationSender notificationSender
    ) {
        this.paymentGateway = paymentGateway;
        this.orderRepository = orderRepository;
        this.notificationSender = notificationSender;
    }
}

Now OrderService depends on the capabilities it requires rather than unnecessary implementation details.

Low coupling does not mean:

Classes should never depend on other classes.

Dependencies are necessary.

The real goal is:

Keep dependencies clear, stable, minimal, and appropriate to the responsibility.

3. Why It Matters in Real Projects

Maintainability

When components are loosely coupled, changes remain localized.

For example, changing an SMS provider should ideally require changes only inside the notification integration layer.

Readability

Focused dependencies communicate what a class requires.

Compare:

JAVA
private final TwilioRestClient twilioRestClient;

with:

JAVA
private final SmsSender smsSender;

The second dependency communicates business intent more clearly.

Testability

Low coupling makes dependencies replaceable.

A unit test can mock:

JAVA
PaymentGateway

without connecting to a real payment provider.

Debugging

When responsibilities and dependency boundaries are clear, developers can isolate where a failure occurred.

For example:

JAVA
OrderService
    -> PaymentGateway
    -> StripePaymentGateway
    -> Stripe API

This is easier to reason about than provider-specific calls scattered across business classes.

Reliability

A change in one implementation is less likely to break unrelated components.

Team Development

Teams can work behind stable contracts.

For example:

  • Checkout team uses PaymentGateway.
  • Integration team maintains StripePaymentGateway.
  • Platform team configures external clients.

Scalability of Codebase

As systems grow, loose coupling allows components to evolve independently.

4. Core Concept

The practical goal of low coupling is:

A component should know only what it needs to perform its responsibility.

Consider:

JAVA
public class OrderService {
    public void placeOrder(Order order) {
        Customer customer = order.getCustomer();
        Address address = customer.getProfile().getAddress();
        String countryCode = address.getCountry().getCode();
    }
}

This code depends on a deep internal object structure:

JAVA
Order
    -> Customer
        -> Profile
            -> Address
                -> Country
                    -> Code

If the customer-profile structure changes, OrderService may break even though its business responsibility has not changed.

This is a form of tight structural coupling.

A better design may expose the information through a clearer domain method:

JAVA
String countryCode = order.getShippingCountryCode();

or delegate the decision to a suitable domain/service abstraction.

Common Forms of Coupling

In Java projects, coupling may appear through:

  • Concrete class dependencies
  • Static utility calls
  • Shared mutable state
  • Deep object navigation
  • Vendor SDK types
  • Database entities leaking everywhere
  • Direct framework dependencies
  • Bidirectional module dependencies
  • Large shared DTOs
  • Global constants
  • Service locator usage
  • Direct cross-module repository access

Reviewers should consider both visible and hidden dependencies.

5. Important Rules

When reviewing Java code for coupling:

  • Depend on stable abstractions at meaningful boundaries.
  • Prefer constructor injection for required dependencies.
  • Avoid direct creation of external infrastructure clients inside business code.
  • Keep vendor-specific types inside integration adapters.
  • Avoid deep knowledge of another object's internal structure.
  • Keep modules from accessing each other's internal repositories directly.
  • Minimize shared mutable global state.
  • Avoid static dependencies for important replaceable behavior.
  • Prefer composition over inappropriate inheritance.
  • Expose only required methods through interfaces.
  • Avoid passing huge objects when only one or two values are required.
  • Keep cross-module calls explicit.
  • Avoid circular dependencies between services or packages.
  • Keep domain logic independent from unnecessary framework details.
  • Limit public APIs of classes and modules.
  • Avoid direct database access from unrelated layers.
  • Use events only when they genuinely reduce undesirable direct dependencies.
  • Do not introduce interfaces everywhere without a real need.
  • Keep coupling low without making architecture unnecessarily complex.

6. Bad Code Example

Consider a Spring Boot order-processing service.

JAVA
@Service
public class OrderService {
    private final CustomerRepository customerRepository;
    private final ProductRepository productRepository;
    private final InventoryRepository inventoryRepository;
    private final OrderRepository orderRepository;
    private final StripeClient stripeClient;
    private final JavaMailSender javaMailSender;
    public OrderService(
        CustomerRepository customerRepository,
        ProductRepository productRepository,
        InventoryRepository inventoryRepository,
        OrderRepository orderRepository,
        StripeClient stripeClient,
        JavaMailSender javaMailSender
    ) {
        this.customerRepository = customerRepository;
        this.productRepository = productRepository;
        this.inventoryRepository = inventoryRepository;
        this.orderRepository = orderRepository;
        this.stripeClient = stripeClient;
        this.javaMailSender = javaMailSender;
    }
    public Order placeOrder(CreateOrderRequest request) {
        Customer customer = customerRepository.findById(request.getCustomerId())
            .orElseThrow(() -> new CustomerNotFoundException(request.getCustomerId()));
        Product product = productRepository.findById(request.getProductId())
            .orElseThrow(() -> new ProductNotFoundException(request.getProductId()));
        Inventory inventory = inventoryRepository.findByProductId(product.getId())
            .orElseThrow(() -> new InventoryNotFoundException(product.getId()));
        if (inventory.getAvailableQuantity() < request.getQuantity()) {
            throw new InsufficientInventoryException(product.getId());
        }
        StripeChargeResponse response = stripeClient.charge(
            request.getCardToken(),
            product.getPrice().multiply(BigDecimal.valueOf(request.getQuantity()))
        );
        if (!response.isSuccessful()) {
            throw new PaymentFailedException(response.getFailureReason());
        }
        inventory.setAvailableQuantity(
            inventory.getAvailableQuantity() - request.getQuantity()
        );
        inventoryRepository.save(inventory);
        Order order = new Order();
        order.setCustomer(customer);
        order.setProduct(product);
        order.setQuantity(request.getQuantity());
        order.setPaymentTransactionId(response.getTransactionId());
        Order savedOrder = orderRepository.save(order);
        SimpleMailMessage mailMessage = new SimpleMailMessage();
        mailMessage.setTo(customer.getEmail());
        mailMessage.setSubject("Order Confirmation");
        mailMessage.setText("Order confirmed: " + savedOrder.getId());
        javaMailSender.send(mailMessage);
        return savedOrder;
    }
}

The method works, but OrderService is tightly coupled to multiple internal and infrastructure details.

7. Problems in the Bad Code

Direct Payment Provider Coupling

The service directly knows:

JAVA
StripeClient
StripeChargeResponse

Changing the provider affects business code.

Direct Email Framework Coupling

The business service knows:

JAVA
JavaMailSender
SimpleMailMessage

Email infrastructure details are mixed into the order workflow.

Direct Access to Multiple Repositories

OrderService directly coordinates:

  • Customer persistence
  • Product persistence
  • Inventory persistence
  • Order persistence

Some coordination may be valid, but accessing every module's repository can create strong cross-domain coupling.

Data-Structure Coupling

The service knows detailed entity structure and modifies inventory directly.

Difficult Testing

A unit test needs mocks for:

  • CustomerRepository
  • ProductRepository
  • InventoryRepository
  • OrderRepository
  • StripeClient
  • JavaMailSender

This creates large test setup.

Vendor Type Leakage

StripeChargeResponse enters core business code.

Wider Change Blast Radius

Changes to payment, email, or inventory implementation may require changing OrderService.

Infrastructure Logic Mixed With Business Logic

The service contains:

  • Payment-provider call
  • Mail-message construction
  • Repository operations
  • Inventory mutation
  • Order creation

This makes the dependency graph harder to maintain.

8. Code Review Findings

A senior reviewer should notice several coupling issues.

Finding 1

The core order workflow depends directly on Stripe-specific classes.

Finding 2

Email framework classes are used directly inside business logic.

Finding 3

The service reaches directly into inventory persistence rather than depending on an inventory capability.

Finding 4

The constructor contains several infrastructure-specific dependencies.

The dependency count alone is not necessarily a problem, but the types reveal strong coupling.

Finding 5

Vendor-specific response types leak into the application service.

Finding 6

The order service will likely change whenever the payment provider or email implementation changes.

Finding 7

Unit testing requires too much knowledge of lower-level dependencies.

Finding 8

The method coordinates several low-level operations directly rather than delegating focused responsibilities.

9. Reviewer Comment Example

A practical PR comment could be:

OrderService currently depends directly on StripeClient, so changing payment providers would require changes to the core order workflow. Could we inject a business-level PaymentGateway instead?

Another:

Email construction with JavaMailSender is infrastructure-specific and increases coupling in the order service. Consider moving it behind an OrderNotificationSender.

Another:

The service is directly manipulating inventory persistence. Could the order flow depend on an InventoryService or reservation abstraction rather than knowing repository details?

10. Improved Code

Define payment behavior.

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

Define inventory behavior.

JAVA
public interface InventoryService {
    void reserve(Long productId, int quantity);
}

Define notification behavior.

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

Keep the order service focused on orchestration.

JAVA
@Service
public class OrderService {
    private final CustomerRepository customerRepository;
    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;
    private final InventoryService inventoryService;
    private final PaymentGateway paymentGateway;
    private final OrderNotificationSender notificationSender;
    public OrderService(
        CustomerRepository customerRepository,
        ProductRepository productRepository,
        OrderRepository orderRepository,
        InventoryService inventoryService,
        PaymentGateway paymentGateway,
        OrderNotificationSender notificationSender
    ) {
        this.customerRepository = customerRepository;
        this.productRepository = productRepository;
        this.orderRepository = orderRepository;
        this.inventoryService = inventoryService;
        this.paymentGateway = paymentGateway;
        this.notificationSender = notificationSender;
    }
    public Order placeOrder(CreateOrderRequest request) {
        Customer customer = getCustomer(request.getCustomerId());
        Product product = getProduct(request.getProductId());
        inventoryService.reserve(
            product.getId(),
            request.getQuantity()
        );
        BigDecimal totalAmount = product.getPrice()
            .multiply(BigDecimal.valueOf(request.getQuantity()));
        PaymentResult paymentResult = paymentGateway.charge(
            new PaymentRequest(
                request.getCardToken(),
                totalAmount
            )
        );
        if (!paymentResult.successful()) {
            throw new PaymentFailedException(paymentResult.failureReason());
        }
        Order order = Order.create(
            customer,
            product,
            request.getQuantity(),
            paymentResult.transactionId()
        );
        Order savedOrder = orderRepository.save(order);
        notificationSender.sendOrderConfirmation(savedOrder);
        return savedOrder;
    }
    private Customer getCustomer(Long customerId) {
        return customerRepository.findById(customerId)
            .orElseThrow(() -> new CustomerNotFoundException(customerId));
    }
    private Product getProduct(Long productId) {
        return productRepository.findById(productId)
            .orElseThrow(() -> new ProductNotFoundException(productId));
    }
}

Stripe becomes an implementation detail.

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

Inventory logic moves behind its own capability.

JAVA
@Service
public class DefaultInventoryService implements InventoryService {
    private final InventoryRepository inventoryRepository;
    public DefaultInventoryService(InventoryRepository inventoryRepository) {
        this.inventoryRepository = inventoryRepository;
    }
    @Override
    public void reserve(Long productId, int quantity) {
        Inventory inventory = inventoryRepository.findByProductId(productId)
            .orElseThrow(() -> new InventoryNotFoundException(productId));
        if (inventory.getAvailableQuantity() < quantity) {
            throw new InsufficientInventoryException(productId);
        }
        inventory.setAvailableQuantity(
            inventory.getAvailableQuantity() - quantity
        );
        inventoryRepository.save(inventory);
    }
}

Email infrastructure is isolated.

JAVA
@Component
public class EmailOrderNotificationSender implements OrderNotificationSender {
    private final JavaMailSender javaMailSender;
    public EmailOrderNotificationSender(JavaMailSender javaMailSender) {
        this.javaMailSender = javaMailSender;
    }
    @Override
    public void sendOrderConfirmation(Order order) {
        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(order.getCustomer().getEmail());
        message.setSubject("Order Confirmation");
        message.setText("Order confirmed: " + order.getId());
        javaMailSender.send(message);
    }
}

11. Improved Code Explanation

Payment Coupling Is Reduced

OrderService depends on:

JAVA
PaymentGateway

not:

JAVA
StripeClient

The payment provider can change without changing the core workflow.

Provider Types Are Isolated

StripeChargeResponse remains inside:

JAVA
StripePaymentGateway

The application uses:

JAVA
PaymentResult

Inventory Logic Is Encapsulated

The order service no longer knows how inventory is loaded, validated, or updated.

It asks:

JAVA
inventoryService.reserve(...)

Email Infrastructure Is Hidden

The business service no longer knows:

  • JavaMailSender
  • SimpleMailMessage
  • SMTP details

Dependencies Communicate Capabilities

The service dependency list now describes business needs:

  • Inventory
  • Payment
  • Notification
  • Persistence

rather than low-level implementation technology.

Testing Becomes Easier

Tests can mock:

JAVA
InventoryService
PaymentGateway
OrderNotificationSender

without reproducing their implementation details.

12. Bad Code vs Improved Code

AreaBad CodeImproved Code
Payment dependencyStripe-specificPayment abstraction
Inventory dependencyRepository internalsInventory capability
NotificationJavaMailSender directlyNotification abstraction
Vendor DTOsLeak into serviceContained in adapter
TestabilityMany low-level mocksBusiness-level mocks
MaintainabilityInfrastructure changes affect serviceChanges localized
ReadabilityTechnical details mixed with workflowBusiness workflow clearer
ReplaceabilityDifficultEasier

13. Real Project Scenario

Consider a healthcare platform where a patient-registration module directly calls several other modules.

For example:

JAVA
PatientRegistrationService

may directly use:

JAVA
InsuranceRepository
AppointmentRepository
BillingRepository
EmailRepository
SmsRepository
AuditRepository

The registration service begins performing:

  • Patient creation
  • Insurance lookup
  • Initial appointment booking
  • Billing-account creation
  • Email notification
  • SMS notification
  • Audit persistence

Each module changes independently.

The registration class becomes tightly coupled to their persistence models.

Later, the billing team changes how billing accounts are created.

The registration service must change.

Then the appointment team introduces a new scheduling model.

The registration service changes again.

Then notifications move to another service.

The same registration class changes again.

A lower-coupled architecture might expose capabilities such as:

JAVA
InsuranceEligibilityService
AppointmentScheduler
BillingAccountService
PatientNotificationService
AuditPublisher

The patient-registration workflow still coordinates these capabilities, but it no longer depends on their internal persistence details.

This allows each module to evolve more independently.

14. Production Impact

Tight coupling often creates long-term production and maintenance problems rather than immediate syntax errors.

Large Change Blast Radius

A small infrastructure change may affect many classes.

Regression Risk

When one implementation changes, tightly connected callers may break.

Difficult Provider Migration

Changing:

  • Payment provider
  • Notification provider
  • Storage technology
  • Database implementation

becomes expensive.

Testing Difficulty

Developers may avoid unit tests because classes require too many low-level dependencies.

Slow Incident Resolution

Infrastructure and business concerns become mixed, making root-cause analysis harder.

Shared Failure Propagation

Strong synchronous coupling between services can cause one downstream outage to affect multiple upstream workflows.

Deployment Coordination

Tightly coupled modules may need to be deployed together even when business ownership is separate.

Maintenance Cost

Developers become afraid to modify central classes because they do not know what else may break.

15. Common Developer Mistakes

Depending Directly on Concrete Classes

Example:

JAVA
StripePaymentService

instead of:

JAVA
PaymentGateway

when provider replacement is a real concern.

Creating Dependencies With new

Example:

JAVA
HttpClient client = new HttpClient();

inside business code.

Static Utility Coupling

Example:

JAVA
PaymentUtils.charge(...);

Static calls can make replacement and testing difficult.

Deep Object Navigation

Example:

JAVA
order.getCustomer().getProfile().getAddress().getCountry().getCode();

The caller becomes coupled to internal structure.

Repository Sharing Across Modules

One module directly accessing another module's repository creates strong persistence coupling.

Shared DTO Everywhere

A large DTO used across unrelated layers can cause widespread changes when one field changes.

Circular Service Dependencies

Example:

JAVA
OrderService -> PaymentService
PaymentService -> OrderService

This creates fragile architecture.

Framework Types in Domain Logic

Business classes should not depend unnecessarily on HTTP, persistence, or messaging framework types.

Passing Large Objects Unnecessarily

Passing an entire Customer object when a component only needs customerId or email can increase coupling.

Using Events for Everything

Events reduce direct coupling in some scenarios but can create debugging complexity if used unnecessarily.

16. Edge Cases

Direct Dependency Is Sometimes Fine

Not every concrete dependency is a problem.

For example:

JAVA
private final MoneyCalculator moneyCalculator;

may be acceptable if it is a stable internal class with no meaningful alternative implementation.

Low coupling should not be applied mechanically.

Repository Dependency

A Spring service depending on its own domain repository is usually reasonable.

The problem is when services reach across unrelated modules and depend on internal repositories they do not own.

Orchestrator Services

A checkout orchestrator may depend on multiple services.

That does not automatically mean excessive coupling if the dependencies represent the capabilities required for one use case.

Shared Domain Objects

Passing domain entities inside one bounded context can be reasonable.

Using the same entity as an API DTO, persistence DTO, event payload, and cross-service contract may create excessive coupling.

Events

Asynchronous events can reduce direct dependency but introduce:

  • Eventual consistency
  • Retry concerns
  • Ordering issues
  • Observability challenges

Use them only when appropriate.

Multiple Implementations

Abstractions become especially valuable when providers or implementations can vary.

However, an interface should still represent a meaningful boundary.

17. Performance Considerations

Low coupling is primarily a design quality, not a performance optimization.

However, architecture decisions used to reduce coupling can affect performance.

Extra Network Boundaries

Splitting a monolith into multiple remote services purely to reduce coupling can increase:

  • Network latency
  • Serialization cost
  • Failure points

Low coupling does not require turning every module into a microservice.

Event-Based Communication

Asynchronous messaging can reduce direct coupling but introduces queue and broker overhead.

Excessive Indirection

Too many abstraction layers can make debugging harder without providing meaningful design value.

The runtime overhead of normal Java interface calls is generally insignificant compared with database or network operations.

Lazy Initialization

Loosely coupled infrastructure components can sometimes be initialized only where needed.

Database Access

Proper module boundaries can prevent unrelated components from issuing duplicated or uncontrolled database queries.

Do not claim low coupling automatically improves performance.

Its main benefit is maintainability and change isolation.

18. Security Considerations

Low coupling can improve security boundaries when components expose only the capabilities required by consumers.

Privileged Operations

A reporting service should not depend on a broad administration service exposing:

JAVA
deleteUser()
assignRole()
resetPassword()

when it only needs:

JAVA
findUser()

Vendor Credentials

Provider credentials should remain inside infrastructure configuration rather than leak into core services.

Data Exposure

Passing large domain objects across boundaries may expose more sensitive data than needed.

Prefer narrow contracts when appropriate.

Tenant Isolation

Cross-module direct repository access can bypass business-layer tenant or authorization checks.

Logging

Adapters and boundaries can centralize safe logging and prevent vendor payloads containing secrets from being logged broadly.

Low coupling does not replace:

  • Authentication
  • Authorization
  • Validation
  • Secret management

but it can help create clearer places to enforce them.

19. Testing Considerations

Low-coupled components are generally easier to test independently.

Unit Tests

For OrderService, mock:

JAVA
PaymentGateway
InventoryService
OrderNotificationSender

rather than real provider or framework classes.

Positive Test

Verify:

  • Inventory is reserved
  • Payment is processed
  • Order is saved
  • Confirmation is sent

Payment Failure

Verify that failure behavior matches the business rule.

For example:

  • Order should not be persisted.
  • Notification should not be sent.

Inventory Failure

Verify that payment is not attempted if inventory cannot be reserved, depending on the defined workflow.

Notification Failure

Define whether notification failure:

  • Fails the order
  • Is retried
  • Is logged asynchronously

Adapter Tests

Test:

JAVA
StripePaymentGateway

separately from OrderService.

Integration Tests

Verify Spring wiring between abstraction and implementation.

Test Smell

If a unit test must understand provider DTOs, SMTP types, database implementation classes, and several unrelated internals, the production class may be too tightly coupled.

20. Refactoring Guidelines

Refactor tightly coupled code gradually.

Step 1: Identify Unstable Dependencies

Look for dependencies likely to change:

  • External providers
  • SDKs
  • Messaging systems
  • Storage technologies
  • Cross-module repositories

Step 2: Identify Business Capability

Ask what the caller actually needs.

Example:

Not:

JAVA
StripeClient

But:

JAVA
PaymentGateway

Step 3: Introduce a Focused Boundary

Create a small abstraction around the required capability.

Step 4: Move Implementation Details

Move:

  • Vendor request mapping
  • Provider response mapping
  • Framework-specific code
  • Low-level exceptions

behind the boundary.

Step 5: Replace Deep Structural Access

Where appropriate, introduce domain methods rather than exposing long object-navigation chains.

Step 6: Remove Cross-Module Repository Access

Expose business operations through the owning module where practical.

Step 7: Add Tests

Protect behavior before changing dependencies.

Step 8: Remove Old Dependencies

After migration, delete unused concrete dependencies and imports.

Step 9: Avoid Big-Bang Refactoring

Extract one dependency boundary at a time.

21. Best Practices

  • Keep dependencies minimal and explicit.
  • Depend on meaningful abstractions at unstable boundaries.
  • Use constructor injection.
  • Isolate external providers behind adapters.
  • Keep vendor DTOs inside integration layers.
  • Avoid circular dependencies.
  • Keep modules from directly accessing each other's internal persistence.
  • Use narrow interfaces for consumers.
  • Keep domain logic free from unnecessary framework details.
  • Prefer composition over fragile inheritance.
  • Encapsulate internal data structures.
  • Avoid shared mutable global state.
  • Keep public APIs small.
  • Use events when asynchronous decoupling has clear value.
  • Test modules through their public contracts.
  • Keep abstractions business-oriented.
  • Refactor incrementally.
  • Balance low coupling with simplicity.

22. Practices to Avoid

Direct Vendor Dependencies Everywhere

Avoid spreading:

JAVA
StripeClient
AmazonS3Client
KafkaTemplate
JavaMailSender

through core business classes.

Concrete Object Creation Inside Services

Avoid:

JAVA
new PaymentClient()

when the dependency is infrastructure or replaceable.

Global Static Services

Avoid:

JAVA
NotificationManager.send(...);

if the behavior needs configuration, replacement, or testing.

Cross-Module Repository Access

Avoid letting every service call every repository.

Circular Dependencies

Avoid architectures such as:

JAVA
OrderService -> CustomerService
CustomerService -> OrderService

Deep Object Graph Access

Avoid unnecessary chains such as:

JAVA
a.getB().getC().getD().getValue();

Shared Everything DTO

Avoid one huge DTO being reused across controllers, persistence, messaging, and integrations.

Abstraction for Every Class

Do not create interfaces mechanically.

A stable internal implementation may not need an abstraction.

Excessive Eventing

Do not replace simple method calls with asynchronous events solely to claim lower coupling.

23. Code Review Checklist

Ask these questions during Pull Request review:

  • Does this class depend directly on an external vendor SDK?
  • Is a concrete infrastructure class used where a business abstraction would be clearer?
  • Is any external client created with new inside business logic?
  • Are vendor DTOs leaking into application services?
  • Does this class know too much about another object's internal structure?
  • Is this module accessing another module's repository directly?
  • Are dependencies minimal for the responsibility?
  • Does the constructor expose several unrelated implementation details?
  • Can this dependency be replaced without changing core business logic?
  • Are there circular dependencies between services or modules?
  • Does this class depend on static global utilities?
  • Are framework-specific types leaking into domain logic?
  • Is a large shared DTO coupling unrelated parts of the system?
  • Are public methods exposing internal implementation details?
  • Could a smaller interface reduce dependency surface?
  • Is an event being used for a valid decoupling reason?
  • Is asynchronous communication introducing unnecessary complexity?
  • Can this class be unit tested without real external infrastructure?
  • Is this abstraction meaningful or unnecessary?
  • Does this change increase the number of modules that must change together?

24. Common Pull Request Review Comments

  1. This service depends directly on StripeClient, which couples the order workflow to one provider. Could we use a PaymentGateway abstraction here?
  1. The controller is accessing the repository directly. Please consider routing this through the owning service so persistence details remain encapsulated.
  1. This method navigates through several internal objects to obtain the country code. Could the domain expose the required value without coupling the caller to the entire object graph?
  1. The new dependency introduces a vendor-specific response type into the business layer. Please map it to an application-owned result inside the adapter.
  1. This module now depends directly on another module's repository. That creates persistence-level coupling between modules; can we expose a business capability instead?
  1. Please avoid constructing HttpClient inside this service. Inject a configured client through an integration adapter.
  1. OrderService and PaymentService now depend on each other. We should remove this circular dependency and clarify ownership of the workflow.
  1. The shared DTO is now used by API, database, Kafka, and reporting code. This will make future changes affect unrelated areas. Please consider boundary-specific models.
  1. Using an event here may reduce direct coupling, but the workflow requires an immediate response. A normal service call may be simpler and easier to reason about.
  1. This abstraction adds another layer but does not isolate an unstable dependency or meaningful business boundary. Please confirm that the extra indirection provides real value.

25. Code Review Exercise

Review the following Spring Boot service.

JAVA
@Service
public class LoanApprovalService {
    private final CustomerRepository customerRepository;
    private final CreditScoreRepository creditScoreRepository;
    private final BankAccountRepository bankAccountRepository;
    private final ExperianClient experianClient;
    private final JavaMailSender javaMailSender;
    private final KafkaTemplate<String, LoanEvent> kafkaTemplate;
    public LoanApprovalService(
        CustomerRepository customerRepository,
        CreditScoreRepository creditScoreRepository,
        BankAccountRepository bankAccountRepository,
        ExperianClient experianClient,
        JavaMailSender javaMailSender,
        KafkaTemplate<String, LoanEvent> kafkaTemplate
    ) {
        this.customerRepository = customerRepository;
        this.creditScoreRepository = creditScoreRepository;
        this.bankAccountRepository = bankAccountRepository;
        this.experianClient = experianClient;
        this.javaMailSender = javaMailSender;
        this.kafkaTemplate = kafkaTemplate;
    }
    public LoanDecision approve(Long customerId, BigDecimal requestedAmount) {
        Customer customer = customerRepository.findById(customerId)
            .orElseThrow(() -> new CustomerNotFoundException(customerId));
        BankAccount bankAccount = bankAccountRepository.findPrimaryByCustomerId(customerId)
            .orElseThrow(() -> new BankAccountNotFoundException(customerId));
        ExperianScoreResponse externalScore = experianClient.getCreditScore(
            customer.getPanNumber()
        );
        CreditScore creditScore = new CreditScore();
        creditScore.setCustomerId(customerId);
        creditScore.setScore(externalScore.getScore());
        creditScoreRepository.save(creditScore);
        boolean approved =
            externalScore.getScore() >= 750
            && bankAccount.getAverageBalance().compareTo(requestedAmount.divide(BigDecimal.TEN)) >= 0;
        LoanDecision decision = new LoanDecision(
            customerId,
            requestedAmount,
            approved
        );
        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(customer.getEmail());
        message.setSubject("Loan Decision");
        message.setText(approved ? "Loan approved" : "Loan rejected");
        javaMailSender.send(message);
        kafkaTemplate.send(
            "loan-decisions",
            new LoanEvent(customerId, approved)
        );
        return decision;
    }
}

Identify:

  • Tight-coupling problems
  • Infrastructure leakage
  • Vendor dependency problems
  • Cross-module persistence coupling
  • Testing difficulties
  • Possible circular-dependency risks
  • Better abstraction boundaries
  • Dependencies that may reasonably remain

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

26. Exercise Solution

The class contains several important coupling problems.

Issue 1: Direct Experian Dependency

The business service depends on:

JAVA
ExperianClient
ExperianScoreResponse

The business requirement is:

Obtain the customer's credit score.

The provider is an implementation detail.

Issue 2: Direct Email Framework Dependency

The service uses:

JAVA
JavaMailSender
SimpleMailMessage

Loan-decision logic should not need SMTP/framework details.

Issue 3: Direct Kafka Dependency

The service knows:

JAVA
KafkaTemplate

The business requirement is:

Publish a loan-decision event.

Issue 4: Direct Repository Access Across Several Domains

The service directly accesses:

  • Customer persistence
  • Credit-score persistence
  • Bank-account persistence

Some direct access may be acceptable within one cohesive module, but in a larger banking system these may belong to separate modules.

Issue 5: Vendor DTO Leakage

ExperianScoreResponse is used directly in the business workflow.

Issue 6: Difficult Testing

Unit tests require mocks for multiple low-level technologies.

Better Abstractions

Define credit-score capability.

JAVA
public interface CreditScoreProvider {
    int getCreditScore(Customer customer);
}

Define account capability.

JAVA
public interface CustomerAccountService {
    BankAccount getPrimaryAccount(Long customerId);
}

Define notification capability.

JAVA
public interface LoanDecisionNotifier {
    void notify(Customer customer, LoanDecision decision);
}

Define event publishing.

JAVA
public interface LoanEventPublisher {
    void publishDecision(LoanDecision decision);
}

Provider implementation:

JAVA
@Component
public class ExperianCreditScoreProvider implements CreditScoreProvider {
    private final ExperianClient experianClient;
    public ExperianCreditScoreProvider(ExperianClient experianClient) {
        this.experianClient = experianClient;
    }
    @Override
    public int getCreditScore(Customer customer) {
        ExperianScoreResponse response = experianClient.getCreditScore(
            customer.getPanNumber()
        );
        return response.getScore();
    }
}

Notification implementation:

JAVA
@Component
public class EmailLoanDecisionNotifier implements LoanDecisionNotifier {
    private final JavaMailSender javaMailSender;
    public EmailLoanDecisionNotifier(JavaMailSender javaMailSender) {
        this.javaMailSender = javaMailSender;
    }
    @Override
    public void notify(Customer customer, LoanDecision decision) {
        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(customer.getEmail());
        message.setSubject("Loan Decision");
        message.setText(
            decision.approved()
                ? "Loan approved"
                : "Loan rejected"
        );
        javaMailSender.send(message);
    }
}

Event implementation:

JAVA
@Component
public class KafkaLoanEventPublisher implements LoanEventPublisher {
    private final KafkaTemplate<String, LoanEvent> kafkaTemplate;
    public KafkaLoanEventPublisher(
        KafkaTemplate<String, LoanEvent> kafkaTemplate
    ) {
        this.kafkaTemplate = kafkaTemplate;
    }
    @Override
    public void publishDecision(LoanDecision decision) {
        kafkaTemplate.send(
            "loan-decisions",
            new LoanEvent(
                decision.customerId(),
                decision.approved()
            )
        );
    }
}

Improved business service:

JAVA
@Service
public class LoanApprovalService {
    private final CustomerRepository customerRepository;
    private final CreditScoreRepository creditScoreRepository;
    private final CustomerAccountService customerAccountService;
    private final CreditScoreProvider creditScoreProvider;
    private final LoanDecisionNotifier loanDecisionNotifier;
    private final LoanEventPublisher loanEventPublisher;
    public LoanApprovalService(
        CustomerRepository customerRepository,
        CreditScoreRepository creditScoreRepository,
        CustomerAccountService customerAccountService,
        CreditScoreProvider creditScoreProvider,
        LoanDecisionNotifier loanDecisionNotifier,
        LoanEventPublisher loanEventPublisher
    ) {
        this.customerRepository = customerRepository;
        this.creditScoreRepository = creditScoreRepository;
        this.customerAccountService = customerAccountService;
        this.creditScoreProvider = creditScoreProvider;
        this.loanDecisionNotifier = loanDecisionNotifier;
        this.loanEventPublisher = loanEventPublisher;
    }
    public LoanDecision approve(Long customerId, BigDecimal requestedAmount) {
        Customer customer = customerRepository.findById(customerId)
            .orElseThrow(() -> new CustomerNotFoundException(customerId));
        BankAccount bankAccount =
            customerAccountService.getPrimaryAccount(customerId);
        int score = creditScoreProvider.getCreditScore(customer);
        saveCreditScore(customerId, score);
        boolean approved = isEligible(
            score,
            bankAccount,
            requestedAmount
        );
        LoanDecision decision = new LoanDecision(
            customerId,
            requestedAmount,
            approved
        );
        loanDecisionNotifier.notify(customer, decision);
        loanEventPublisher.publishDecision(decision);
        return decision;
    }
    private void saveCreditScore(Long customerId, int score) {
        CreditScore creditScore = new CreditScore();
        creditScore.setCustomerId(customerId);
        creditScore.setScore(score);
        creditScoreRepository.save(creditScore);
    }
    private boolean isEligible(
        int score,
        BankAccount bankAccount,
        BigDecimal requestedAmount
    ) {
        BigDecimal minimumBalance =
            requestedAmount.divide(BigDecimal.TEN);
        return score >= 750
            && bankAccount.getAverageBalance()
                .compareTo(minimumBalance) >= 0;
    }
}

Why This Is Better

The business service no longer knows:

  • Experian response types
  • Kafka APIs
  • Email framework details

The dependencies now describe business capabilities.

Provider changes remain localized.

Unit tests can mock:

JAVA
CreditScoreProvider
CustomerAccountService
LoanDecisionNotifier
LoanEventPublisher

The service still keeps direct dependencies on repositories that belong to its own business responsibility where that is appropriate.

Low coupling does not require removing every direct dependency.

It requires removing unnecessary or unstable coupling.

27. Interview Perspective

Low coupling is commonly tested through code-review and architecture scenarios.

An interviewer may ask:

An OrderService directly depends on StripeClient, JavaMailSender, AmazonS3Client, and KafkaTemplate. What problems do you see?

A strong answer should discuss:

  • Infrastructure coupling
  • Vendor lock-in
  • Testability
  • Change blast radius
  • Meaningful abstractions
  • Dependency injection
  • Adapters
  • Module boundaries

Another question may be:

Is every direct concrete dependency bad?

The correct answer is no.

A stable internal class may be perfectly reasonable.

The reviewer should focus on dependencies that are:

  • Unstable
  • External
  • Replaceable
  • Cross-module
  • Difficult to test
  • Exposing internal details

Senior interviews may also ask:

Can low coupling become over-engineering?

Yes.

Too many unnecessary interfaces, events, adapters, and remote boundaries can make a system harder to understand.

The goal is balanced design.

28. Interview Questions and Answers

Basic Question

Question: What is low coupling in Java?

Answer:

Low coupling means minimizing unnecessary dependencies between classes or modules.

A component should know only what it needs to perform its responsibility and should avoid depending directly on unstable implementation details.

Intermediate Question

Question: How does dependency injection help reduce coupling?

Answer:

Dependency injection allows dependencies to be supplied from outside the class.

Instead of:

JAVA
PaymentService service = new StripePaymentService();

the class can receive:

JAVA
PaymentGateway

through its constructor.

This improves replaceability and testability.

However, dependency injection alone does not guarantee low coupling.

Injecting a concrete provider class can still create tight coupling.

Advanced Question

Question: What is the relationship between low coupling and high cohesion?

Answer:

High cohesion means related responsibilities stay together.

Low coupling means components have minimal unnecessary dependencies on each other.

Good designs usually aim for both.

A service should internally contain behavior that belongs together while externally depending on the smallest useful set of other components.

Scenario-Based Question

Question: A user-reporting module directly uses UserRepository from another module. Why might this be a problem?

Answer:

The reporting module becomes coupled to the other module's persistence implementation and entity model.

Changes to database structure or repository behavior can affect reporting even if the user module's public business capability has not changed.

A better option may be a UserReader or reporting-specific query contract owned by the appropriate boundary.

Code-Review Question

Question: What code smells indicate tight coupling?

Answer:

Common indicators include:

  • Concrete vendor dependencies
  • Direct construction with new
  • Static service utilities
  • Cross-module repository access
  • Vendor DTO leakage
  • Deep object navigation
  • Circular dependencies
  • Shared global mutable state
  • Large shared DTOs
  • Framework types inside domain logic
  • Tests requiring detailed knowledge of many internals

These are signals that dependency boundaries should be reviewed.

Real-Project Question

Question: How would you reduce coupling in a Spring Boot payment workflow?

Answer:

I would define a business-facing interface such as:

JAVA
PaymentGateway

Then implement provider adapters such as:

JAVA
StripePaymentGateway
RazorpayPaymentGateway

The business service would depend on PaymentGateway.

Provider DTOs, SDK calls, credentials, timeout handling, and external exceptions would remain inside the adapter layer.

Unit tests would mock the abstraction, while provider adapters would be tested separately.

29. Quick Rule to Remember

A class should depend on what another component does, not unnecessarily on how that component does it.

30. Final Takeaway

Low coupling is about keeping dependencies between Java components controlled, minimal, and meaningful.

Developers should remember:

  • Dependencies are necessary, but unnecessary implementation knowledge is not.
  • Business services should avoid direct dependency on unstable external technologies.
  • Vendor-specific types should remain near integration boundaries.
  • Cross-module persistence access should be reviewed carefully.
  • Deep structural knowledge creates hidden coupling.
  • Constructor injection makes dependencies explicit.
  • Interfaces are useful when they represent meaningful boundaries.
  • Not every class requires an abstraction.
  • Events can reduce direct coupling but may introduce operational complexity.
  • Low coupling should not become over-engineering.

During Pull Request review, reviewers should check:

  • Whether the class depends on concrete vendor implementations.
  • Whether it knows too much about another module's internals.
  • Whether provider DTOs or framework types leak into business logic.
  • Whether the same dependency could be represented through a smaller business contract.
  • Whether a change in one module will unnecessarily force changes elsewhere.
  • Whether the code can be unit tested without real infrastructure.
  • Whether circular or cross-layer dependencies are appearing.
  • Whether new abstractions actually provide value.

Production code should avoid architectures where a small technical change causes a chain reaction across unrelated business components.

A strong Java design keeps components cohesive internally and loosely coupled externally, so each part of the system can evolve with minimal impact on the rest.