← Back to list

Tight Coupling vs. Loose Coupling: Why Dependency Injection Matters in Architecture

Understanding the core difference that separates maintainable code from tangled messes

Ahmet Emre DEMİRŞEN in @Override · 2026-07-03 17:46 · 19 claps · 4.1 min read paywalled
#software-development #software-engineering #java #spring-boot #software-architecture
Open on Medium ↗
Wiki topics: 🏛️ · Architecture

Tight Coupling vs. Loose Coupling — Generated by AI

Tight Coupling vs. Loose Coupling — Generated by AI

Tight Coupling vs. Loose Coupling: Why Dependency Injection Matters in Architecture

Understanding the core difference that separates maintainable code from tangled messes

In this article, we’ll explore the fundamental difference between tight and loose coupling in software architecture, and why Dependency Injection is the tool that makes loose coupling practical. If you’ve ever inherited a codebase where changing one class breaks ten others, you’ve experienced the pain of tight coupling firsthand.

You can read this article for free by clicking ***here***.

Table of Contents

  1. The Problem with Tight Coupling
  2. What Loose Coupling Actually Means
  3. Dependency Injection: The Practical Solution
  4. Real-World Example: From Tight to Loose
  5. Common Mistakes and Best Practices

The Problem with Tight Coupling

Let’s start with a concrete example. Imagine we’re building a notification system for an e-commerce application. Here’s what tight coupling looks like:

public class OrderService {
    private EmailService emailService = new EmailService();

    public void processOrder(Order order) {
        // business logic
        emailService.sendConfirmation(order);
    }
}

This looks innocent enough, right? The problem is that OrderService is directly creating an instance of EmailService. This means:

  • We can’t test OrderService without also testing EmailService
  • If we want to switch to SMS notifications, we have to modify OrderService
  • Any change to EmailService's constructor breaks OrderService

This is tight coupling. The two classes are so intertwined that they can’t be separated. As the project grows, this pattern creates a domino effect where changes ripple through the entire codebase.

Let’s see what happens when we add a second notification channel:

public class OrderService {
    private EmailService emailService = new EmailService();
    private SMSService smsService = new SMSService();

    public void processOrder(Order order) {
        emailService.sendConfirmation(order);
        smsService.sendConfirmation(order);
    }
}

Now we have two concrete dependencies. Want to add push notifications? Modify OrderService again. Want to change the email provider? Hope you don’t break anything. This is the reality of tightly coupled code.

What Loose Coupling Actually Means

Loose coupling means our classes depend on abstractions, not concrete implementations. Instead of OrderService knowing about EmailService, it should know about a NotificationService interface.

Here’s the key insight: loose coupling isn’t about having fewer dependencies—it’s about having the right kind of dependencies. We want dependencies on contracts (interfaces), not on specific implementations.

public interface NotificationService {
    void sendConfirmation(Order order);
}

public class EmailService implements NotificationService {
    @Override
    public void sendConfirmation(Order order) {
        // send email
    }
}

public class SMSService implements NotificationService {
    @Override
    public void sendConfirmation(Order order) {
        // send SMS
    }
}

Now OrderService can depend on the interface:

public class OrderService {
    private NotificationService notificationService;

    public OrderService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    public void processOrder(Order order) {
        // business logic
        notificationService.sendConfirmation(order);
    }
}

See the difference? OrderService no longer cares about how the notification is sent. It just knows that something will handle it. This is the essence of loose coupling.

Dependency Injection: The Practical Solution

Now we have a problem: who creates the NotificationService instance? We can’t just do new EmailService() inside OrderService anymore—that would defeat the purpose. This is where Dependency Injection (DI) comes in.

Dependency Injection means we pass dependencies into a class from the outside, rather than having the class create them internally. The most common approach is constructor injection:

public class OrderService {
    private final NotificationService notificationService;

    // Dependency is injected through constructor
    public OrderService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }
}

In a Spring Boot application, this becomes:

@Service
public class OrderService {
    private final NotificationService notificationService;

    public OrderService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }
}

Spring’s IoC container handles creating the NotificationService instance and injecting it into OrderService. We just need to configure which implementation to use:

@Configuration
public class NotificationConfig {
    @Bean
    public NotificationService notificationService() {
        return new EmailService(); // or new SMSService()
    }
}

Or, if we’re using Spring Boot’s auto-configuration with @Component:

@Component
public class EmailService implements NotificationService {
    // implementation
}

Spring will automatically find the implementation and inject it wherever NotificationService is required.

Real-World Example: From Tight to Loose

Let’s walk through a complete refactoring. Here’s a tightly coupled payment processing system:

public class PaymentProcessor {
    private StripeAPI stripeAPI = new StripeAPI();
    private Logger logger = new FileLogger();

    public void processPayment(Payment payment) {
        logger.log("Processing payment: " + payment.getId());
        stripeAPI.charge(payment.getAmount(), payment.getCurrency());
    }
}

Problems: Can’t test without Stripe, can’t change payment provider, logging is hardcoded.

Here’s the loosely coupled version:

public interface PaymentGateway {
    PaymentResult charge(double amount, String currency);
}

public interface Logger {
    void log(String message);
}

public class PaymentProcessor {
    private final PaymentGateway paymentGateway;
    private final Logger logger;

    public PaymentProcessor(PaymentGateway paymentGateway, Logger logger) {
        this.paymentGateway = paymentGateway;
        this.logger = logger;
    }

    public void processPayment(Payment payment) {
        logger.log("Processing payment: " + payment.getId());
        paymentGateway.charge(payment.getAmount(), payment.getCurrency());
    }
}

Now testing is trivial:

@Test
void testPaymentProcessing() {
    PaymentGateway mockGateway = mock(PaymentGateway.class);
    Logger mockLogger = mock(Logger.class);
    PaymentProcessor processor = new PaymentProcessor(mockGateway, mockLogger);

    processor.processPayment(new Payment(100.0, "USD"));

    verify(mockGateway).charge(100.0, "USD");
}

We can swap implementations without changing PaymentProcessor at all. That’s the power of loose coupling with Dependency Injection.

Common Mistakes and Best Practices

Mistake 1: Using field injection instead of constructor injection

// Bad - hard to test, hides dependencies
@Service
public class OrderService {
    @Autowired
    private NotificationService notificationService;
}
// Good - explicit dependencies, easy to test
@Service
public class OrderService {
    private final NotificationService notificationService;

    public OrderService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }
}

Mistake 2: Over-engineering with too many interfaces

Not every class needs an interface. Only extract interfaces when you have multiple implementations or need to mock for testing. A class with a single concrete implementation and no testing needs doesn’t benefit from an interface.

Mistake 3: Circular dependencies

If ServiceA depends on ServiceB and ServiceB depends on ServiceA, you have a design problem. Break the cycle by extracting shared functionality or using an event-driven approach.

Best practice: Keep constructors simple

Your injected dependencies should only be used for what they’re designed for. If a class has more than 3–4 constructor parameters, it’s probably doing too much. Consider splitting it.

Tags: java spring spring-boot dependency-injection software-architecture coupling software-engineering software-development

References:

To support my work, please follow and clap.


메타데이터
post_id
829e4ef40ea0
slug
tight-coupling-vs-loose-coupling-why-dependency-injection-matters-in-architecture-829e4ef40ea0
url
https://medium.com/but-it-works-on-my-machine/tight-coupling-vs-loose-coupling-why-dependency-injection-matters-in-architecture-829e4ef40ea0
canonical_url
https://medium.com/but-it-works-on-my-machine/tight-coupling-vs-loose-coupling-why-dependency-injection-matters-in-architecture-829e4ef40ea0
author_url
https://medium.com/@aedemirsen
status
ok
fetched_at
2026-07-09 03:40:04