Skip to Content
Course content

159: Dependency Injection Principles

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I want to show you something that usually happens in a codebase over time. We start with a design that seems perfectly logical because it's simple, and then we hit a wall where that simplicity becomes a liability. Let's look at a notification system I'm sketching out.

The Hard-Coded Trap

Imagine we have a UserRegistrationService. Its job is to save a user to the database and then send a welcome email. In my first pass, I'd probably write it like this:

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

    public void registerUser(String email) {
        System.out.println("Saving user to DB...");
        emailService.sendWelcomeEmail(email);
    }
}

public class EmailService {
    public void sendWelcomeEmail(String email) {
        System.out.println("Sending email to " + email);
    }
}

On the surface, this works. I run it, the email sends, and I'm happy. But here's where the problem starts: my boss comes to me and says, "We need to support SMS notifications for users who don't have email addresses," or "We need to use a Mock service for our unit tests so we don't actually spam real customers every time the build runs."

If I want to change EmailService to SmsService, I have to open UserRegistrationService.java and manually change the code. That feels wrong. The registration service should care that a notification is sent, not how it's sent.

Introducing an Abstraction

Okay, so my first instinct is to create an interface. That way, I'm not tied to a specific class. Let's try that.

public interface MessageService {
    void sendMessage(String destination);
}

public class EmailService implements MessageService {
    public void sendMessage(String destination) {
        System.out.println("Sending email to " + destination);
    }
}

public class SmsService implements MessageService {
    public void sendMessage(String destination) {
        System.out.println("Sending SMS to " + destination);
    }
}

Now I'll update my registration service to use the interface:

public class UserRegistrationService {
    private MessageService messageService = new EmailService(); // Still hard-coded!

    public void registerUser(String contact) {
        messageService.sendMessage(contact);
    }
}

Wait. I just realized I didn't actually solve the problem. Even though I'm using an interface, I'm still calling new EmailService() inside the constructor. If I want to switch to SMS, I still have to modify the UserRegistrationService class. I've added an abstraction, but I'm still controlling the creation of the dependency inside the class that uses it.

Handing Over the Keys

This is the "aha!" moment. The problem isn't the interface; it's the new keyword. As long as UserRegistrationService is responsible for creating its own tools, it's stuck with whatever tool I hard-coded.

So, let's try something different. What if I just stop creating the service inside the class entirely? What if I tell the class: "You need a MessageService to function, but I'm not going to tell you which one. Someone else will just hand it to you when you're created."

public class UserRegistrationService {
    private final MessageService messageService;

    // I'm "injecting" the dependency via the constructor
    public UserRegistrationService(MessageService messageService) {
        this.messageService = messageService;
    }

    public void registerUser(String contact) {
        messageService.sendMessage(contact);
    }
}

Now, look at how this changes the way I actually run the program. The decision of which service to use has moved up the chain to the main entry point of the application:

public class Main {
    public static void main(String[] args) {
        // I can choose Email...
        MessageService email = new EmailService();
        UserRegistrationService regEmail = new UserRegistrationService(email);
        regEmail.registerUser("test@example.com");

        // ...or I can choose SMS without touching the UserRegistrationService code!
        MessageService sms = new SmsService();
        UserRegistrationService regSms = new UserRegistrationService(sms);
        regSms.registerUser("555-0123");
    }
}

This is Dependency Injection. It sounds like a fancy academic term, but all we did was move the new keyword. Instead of the class reaching out to grab a dependency, we "inject" the dependency into it. The UserRegistrationService is now completely decoupled from the specific implementation of the message service. It's cleaner, it's easier to test, and I don't have to rewrite my core logic every time the business decides to change vendors.




📋 Practical Task

Refactoring the Payment Gateway for Multi-Provider Support

You have been handed a legacy OrderProcessor class that is currently hard-coded to use a PayPalPaymentProvider. Your goal is to refactor this code using Dependency Injection so that the OrderProcessor can work with any payment provider (like Stripe or Square) without needing to be modified.

Requirements:

  • Create a PaymentProvider interface with a method processPayment(double amount).
  • Implement two classes, PayPalProvider and StripeProvider, that implement this interface.
  • Refactor the OrderProcessor class to remove the new PayPalProvider() call from its internals and instead receive the PaymentProvider through its constructor.
  • In your main method, demonstrate that you can create an OrderProcessor that uses PayPal and another that uses Stripe.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.