Module 3: Dependency Injection 2

Module Overview

Advanced dependency injection concepts and Dagger implementation. Build on your DI knowledge with more complex patterns and frameworks.

Learning Objectives

  • Use a dependency injection framework to provide an object with its dependencies
  • Analyze if an injected object should be scoped as a singleton or not for a provided scenario
  • Identify what types a given Java class depends on
  • Illustrate object dependency relationships as a dependency graph
  • Explain what dependency injection is
  • Explain why dependency injection is preferable to having objects instantiate their own dependencies

Video Content: Dagger @Binds Annotation

This video explains the @Binds annotation in Dagger, which is a more efficient way to bind an interface to its implementation compared to @Provides methods.

// Interface definition
public interface UserRepository {
    User findById(String id);
    void save(User user);
}

// Implementation
public class SQLUserRepository implements UserRepository {
    private final Database database;
    
    @Inject
    public SQLUserRepository(Database database) {
        this.database = database;
    }
    
    @Override
    public User findById(String id) {
        // Implementation using database
        return database.executeQuery("SELECT * FROM users WHERE id = ?", id);
    }
    
    @Override
    public void save(User user) {
        // Implementation using database
        database.executeUpdate("INSERT INTO users VALUES (?, ?, ?)",
            user.getId(), user.getName(), user.getEmail());
    }
}

// Module with @Binds - more efficient than @Provides
@Module
public abstract class RepositoryModule {
    // Bind UserRepository interface to SQLUserRepository implementation
    @Binds
    abstract UserRepository bindUserRepository(SQLUserRepository impl);
}

This example demonstrates the use of @Binds in a Dagger module to efficiently bind an interface (UserRepository) to its implementation (SQLUserRepository). The @Binds method is abstract, as Dagger only needs to know about the binding relationship, not how to construct the implementation.

When @Inject Isn't Enough

Toward the end of the last reading, we hinted at a case where annotating constructors with @Inject won't be an option. Here, we see Dagger's solution to that problem, which we also use to inject specific implementations for dependencies on interfaces.

We also learn how to tell Dagger when to reuse the same object instance across constructors that share a dependency object.

CAUTION

If you're following along in code, you may have trouble with the DaggerZooAppComponent not being found. This is because Dagger hasn't generated it yet. You must successfully build one time before you can import the DaggerZooAppComponent in IntelliJ, because Dagger creates this class at build time.

When @Inject is not enough

What about classes where we cannot annotate a constructor with @Inject, like classes we don't own such as DynamoDBMapper? We can still let Dagger manage it for us, but we will need some additional steps. Let's create a method whose functionality will be to provide a DynamoDBMapper. We'll move the code that used to be in the ZooAppHelper to create the DynamoDBMapper here:

public class MapperModule {
    public DynamoDBMapper provideDynamoDBMapper() {
        return new DynamoDBMapper(DynamoDbClientProvider.provideDynamoDBClient(getRegion()));
    }

    private Regions getRegion() {
        // some logic that might return appropriate region for THIS deployment
        // of the service
    }
}

This is almost identical to the relevant portion of ZooAppHelper. The primary difference is that we need to make the provideDynamoDBMapper method public (so that Dagger can call it for us).

Now we need to add annotations to let Dagger know that this method is how we want to provide a DynamoDBMapper, when a constructor with @Inject asks for one. To do this, we:

  • annotate the class with @Module
  • annotate the provider method with @Provides

At build time, Dagger compiles @Inject and @Provides annotations into a map describing how to instantiate objects. Dagger's documentation doesn't say what it will do if a class could be instantiated with both an @Inject constructor and an @Provides method. Avoid providing multiple ways to inject a dependency object.

In our example, Dagger compiles the @Provides annotation on the provideDynamoDBMapper method into its map, indicating that it can use that method to create a DynamoDBMapper.

At run time, when Dagger prepares to call a constructor that needs a DynamoDBMapper, it will check its map for a way to create a DynamoDBMapper, call the provideDynamoDBMapper method, then provide the resulting object to the constructor that required it.

With the expected annotations, our example becomes:

@Module
public class MapperModule {
    @Provides
    public DynamoDBMapper provideDynamoDBMapper() {
        return new DynamoDBMapper(DynamoDbClientProvider.provideDynamoDBClient(getRegion()));
    }

    private Regions getRegion() {
        // some logic that might return appropriate region for THIS deployment
        // of the service
    }
}

When we create a @Module class we need to do one piece of bookkeeping for Dagger: we need to tell the @Component interface to use this module class by adding an argument to the @Component annotation on the interface:

@Component(modules = { MapperModule.class })
public interface ZooAppComponent {
    ZooService provideZooService();
}

Now Dagger can find all the dependencies in the dependency graph for ZooService and eventually provide one to our ZooApplication. Dagger will continue "walking" the dependency graph, finding new objects to instantiate until it either:

  • reaches a class with a no-args constructor annotated with @Inject
  • finds an object provided by an @Provides method in a class annotated with @Module

Dagger Scope

Before we move on let's quickly talk about scope. Dagger keeps track of the "scope" for each of the dependency objects that it creates. By default, each object has no scope or "unscoped". This means that every time Dagger requires an instance of the object, it instantiates a new one (again, providing its dependencies).

This isn't always a good thing though. Going back to our ZooApplication example, if we added a new DynamoDbEmployeeDao that needs a DynamoDBMapper, it would be inefficient to create an entirely new instance of DynamoDBMapper, one for DynamoDbAnimalDao and one for DynamoDbEmployeeDao. Remember that DynamoDBMapper is able to identify which table we want to load data from based on the @DynamoDBTable annotation, and the table name we pass to it. There's no need to create a new instance of DynamoDBMapper for every DAO instance.

@Singleton objects

Dagger lets us override the default "unscoped" by telling it to only create a single instance of an object, called a singleton. We can declare an object in the dependency graph using the @Singleton annotation. The @Singleton annotation tells Dagger that whenever this object is requested, it should use the same instance every time.

This also isn't always a good thing, if you change a property after creation of a singleton object you must be aware that any update to it will be reflected everywhere the object is used! Singleton can be a tricky pattern to know when to implement, but very useful when needed. Controlling the scope of the Dagger object can not only help your functionality but will make it easier to read and distinguish what is happening with the code during debugging.

Let's choose to make our DynamoDBMapper a singleton. We just add @Singleton to the provide method in our module:

@Module
public class MapperModule {
    @Singleton
    @Provides
    public DynamoDBMapper provideDynamoDBMapper() {
        return new DynamoDBMapper(DynamoDbClientProvider.provideDynamoDBClient(getRegion()));
    }

    private Regions getRegion() {
        // some logic that might return appropriate region for THIS deployment
        // of the service
    }
}

We are also required to annotate our @Component as @Singleton. This makes sure that Dagger only creates one instance of the component class, with only one DynamoDBMapper object shared by all:

@Singleton
@Component(modules = { MapperModule.class })
public interface ZooAppComponent {
    ZooService provideZooService();
}

Remember that @Singleton refers to an object in the dependency graph. In our MapperModule @Module, we need to put the @Singleton annotation on the provide* method in our @Module. However, if we want a singleton instance of an object with an @Inject constructor, the @Singleton annotation belongs on the class, for example:

@Singleton
public class CoffeeMaker {
    @Inject
    public CoffeeMaker() { ... }
    //...
}

Polymorphism and injection

We mentioned before that we will find a way to inject concrete implementations of interfaces via dependency injection. Conveniently, these provide methods in @Module classes can serve this purpose as well. Let's take the idea we had earlier, and create a new AnimalDao interface that DynamoDbAnimalDao implements. We'll also want ZooService to have an AnimalDao interface member variable (rather than DynamoDbAnimalDao). We'll be moving to a @Provides method to provide the AnimalDao, so we can drop the @Inject from the DynamoDbAnimalDao constructor.

// DynamoDbAnimalDao.java
public class DynamoDbAnimalDao implements AnimalDao {
    private DynamoDBMapper dynamoDbMapper;

    public DynamoDbAnimalDao(DynamoDBMapper dynamoDbMapper) {
        this.dynamoDbMapper = dynamoDbMapper;
    }

    @Override
    public void save(Animal animal) {
        this.dynamoDbMapper.save(animal);
    }
}

// ZooService.java
public class ZooService {
    private AnimalDao animalDao;

    @Inject
    public ZooService(AnimalDao animalDao) {
        this.animalDao = animalDao;
    }

    public void addNewAnimal(String animalType) {
        //  Create and save new animal with the type passed into the method
        Animal animal = new Animal(animalType);
        animalDao.save(animal);
    }
}

We can specify DynamoDbAnimalDao as our default AnimalDao for dependency injection purposes by implementing a method with the @Provides annotation. Because we need to pass in a DynamoDBMapper object, we need our provides method to accept a DynamoDBMapper object. Because Dagger already knows how to provide a DynamoDBMapper instance, it can pass that in to our provide method. We'll create a new @Module class in this example, but we could choose instead to create one module that provides both classes.

@Module
public class DaoModule {
    @Provides
    public AnimalDao provideAnimalDao(DynamoDBMapper dynamoDbMapper) {
        return new DynamoDbAnimalDao(dynamoDbMapper);
    }
}

And we'll need to tell our @Component about this module as well:

@Singleton
@Component(modules = { MapperModule.class, DaoModule.class })
public interface ZooAppComponent {
    ZooService provideZooService();
}

When to use @Provides instead of @Inject

We should use @Inject whenever we can to tell Dagger how to construct objects. There are some situations when we can't use @Inject, and in these cases we must use @Provides:

  • We don't own the class, so we can't add @Inject to its constructor.
  • The dependency object's type is an interface. We use interfaces in for polymorphism; the @Provides method decides which concrete implementation to use.

Some less common cases occur when:

  • The class has no public constructor. (Note that the default constructor is public. You should define it and annotate it with @Inject.) The @Provides method can instantiate classes using the Builder or Factory pattern, which Dagger does not support.
  • Instantiating the object requires some configuration specific to the environment. The @Provides method can call methods that determine regional endpoints, user preferences, and other configuration so the logic class doesn't have to.

Our Zoo app, Dagger-ified

Phew!

It looks like a lot of changes, but it really wasn't. We've reused several of the methods from our ZooAppHelper, thrown some away in favor of @Inject annotations, and made our app more flexible by accommodating polymorphism of the AnimalDao class.

We've kept our app's functionality while simplifying ZooService and DynamoDbAnimalDao quite a bit. They no longer have to worry about how to create their dependencies. Further, their test code can very easily mock dependencies to isolate the class under test, and create specific test conditions directly.

Let's take a look at the final production code:

public class ZooApplication {
    public static void main(String[] args) {
        ZooAppComponent dagger = DaggerZooAppComponent.create();
        ZooService zooService = dagger.provideZooService();

        zooService.addNewAnimal("Ring-Tailed Lemur");
        //...
    }       
}

// ZooZervice.java
public class ZooService {
    private AnimalDao animalDao;

    @Inject
    public ZooService(AnimalDao animalDao) {
        this.animalDao = animalDao;    
    }   

    public void addNewAnimal(String animalType) {
        //  Create and save new animal with the type passed into the method
        Animal animal = new Animal(animalType);
        animalDao.save(animal);
    }
}

// AnimalDao.java
public interface AnimalDao {
    void save(Animal animal);
}

// DynamoDbAnimalDao.java
public class DynamoDbAnimalDao implements AnimalDao {
    private DynamoDBMapper dynamoDbMapper;

    public DynamoDbAnimalDao(DynamoDBMapper dynamoDbMapper) {
       this.dynamoDbMapper = dynamoDbMapper;
    }

    @Override
    public void save(Animal animal) {
        this.dynamoDbMapper.save(animal);
    }
}

And the Dagger-specific code that accompanies it:

// ZooAppComponent.java
@Singleton
@Component(modules = { MapperModule.class, DaoModule.class })
public interface ZooAppComponent {
    ZooService provideZooService();
}

// MapperModule.java
@Module
public class MapperModule {
    @Singleton
    @Provides
    public DynamoDBMapper provideDynamoDBMapper() {
        return new DynamoDBMapper(DynamoDbClientProvider.provideDynamoDBClient(getRegion()));
    }

    private Regions getRegion() {
        // some logic that might return appropriate region for THIS deployment
        // of the service
    }
}

// DaoModule.java
@Module
public class DaoModule {
    @Provides
    public AnimalDao provideAnimalDao(DynamoDBMapper dynamoDbMapper) {
        return new DynamoDbAnimalDao(dynamoDbMapper);
    }
}

We've omitted the code that Dagger auto-generated. Dagger generates significantly more code than the App class it replaces. This is something you'll see whenever you deal with generated code. For the most part, it is longer than code we would write ourselves.

Think about trying to write code that handles every possible edge case for the input you could provide. When you know that some of those edge cases don't apply, you can choose not to write that code. Generated code doesn't have that luxury. It has to treat every version as potentially the most complicated version.

On the other hand, Dagger's auto-generated code is reliable and prevents us from writing a lot of code by hand. Whenever we write code by hand, we potentially introduce bugs. Dagger's auto-generated code doesn't need to be tested and prevents us from writing bugs.

Adopting Dagger, Step By Step

The steps to adopt Dagger in a project:

  1. Identify the dependency graph (look at your classes and their member variables)
  2. Determine which objects are the root object(s). What are the roots of your dependency graph? (here we will use RootObject as a placeholder)
  3. Create a Dagger @Component interface (here we will use MyComponent as a placeholder)
  4. Add a provide<ObjectType> method to the interface for each root object you have.
  5. For each root object, and all of their dependencies, mark the constructor that Dagger should use with @Inject.
    • If the object should be a singleton, mark the class as @Singleton
  6. If any objects can't be provided via @Inject, create a @Module class, and implement a provide<ObjectType> method to provide that type. This can allow you to instantiate objects from a library you don't own, or to provide a concrete class for an interface type.
    • Mark each of these methods as @Provides
    • If any should be singleton objects, mark the method as @Singleton
  7. Add the module class names to your @Component annotation.
  8. If any of your objects are singletons, mark your component class as @Singleton.
  9. To allow your code to build, reference a null component where you want to fetch your Dagger root object:
    MyComponent dagger = null;
    RootObject obj = dagger.provideRootObject();
  10. Build [1]
  11. (You may need to sync your workspace in IntelliJ to pick up the new classes generated by Dagger)
  12. To fetch your Dagger root object, change the null reference to the Dagger-generated concrete implementation:
    MyComponent dagger = DaggerMyComponent.create();
    RootObject obj = dagger.provideRootObject();

[1] You must build your project after implementing the Dagger @Component interface and before referencing its DaggerMyComponent concrete class. Only when that build succeeds will you have the Dagger-generated class that you need in order to call the provide-root-object method on the Dagger-generated component class.

Guided Project: Advanced Dependency Injection

This video brings together the advanced dependency injection concepts covered in this module, presenting a comprehensive view of advanced Dagger usage.

// Complete advanced DI example
// Multibinding with a map - useful for plugins or feature modules
@Module
public abstract class ProcessorsModule {
    // Bind multiple implementations to a map
    @Binds
    @IntoMap
    @StringKey("json")
    abstract DataProcessor bindJsonProcessor(JsonProcessor impl);
    
    @Binds
    @IntoMap
    @StringKey("xml")
    abstract DataProcessor bindXmlProcessor(XmlProcessor impl);
    
    @Binds
    @IntoMap
    @StringKey("csv")
    abstract DataProcessor bindCsvProcessor(CsvProcessor impl);
}

// Service using map injection
public class DataProcessingService {
    private final Map<String, DataProcessor> processors;
    
    @Inject
    public DataProcessingService(Map<String, DataProcessor> processors) {
        this.processors = processors;
    }
    
    public void processData(String format, String data) {
        DataProcessor processor = processors.get(format);
        if (processor == null) {
            throw new IllegalArgumentException("No processor found for format: " + format);
        }
        processor.process(data);
    }
}

This advanced example shows Dagger's multibinding feature for injecting a map of processors indexed by a key. This pattern is powerful for plugin architectures where different implementations can be selected at runtime. The @IntoMap annotation with key annotations helps Dagger build the map automatically from all the bound implementations.

Mastery Task 3: Implement the Dagger framework

Milestone 1: Plan our Dagger

One of the action items in the design was to replace our initial dependency injection implementation using the App class with the Dagger framework.

Hint: You can find a class in IntelliJ by hitting Command + O and typing the class name (e.g. App).

Update the template file so you can commit it along with the implementation in milestone 2.

Milestone 2: Implementing our Plan

Implement DaoModule & ServiceComponent

Implement your design from milestone 1 by creating a Module class named DaoModule and a Dagger Component class named ServiceComponent. Remember that ServiceComponent must be annotated with @Singleton and @Component, and we must pass in our module to the component annotation for Dagger to discover it.

All Dagger classes should live in the com.amazon.ata.music.playlist.service.dependency package.

Build

Note that the Dagger framework will generate code to implement dependency injection for us when we compile our project, so we must annotate our classes, create our ServiceComponent interface, and create the DaoModule class first, then click 'build' to compile our code and let Dagger generate new classes based on our existing @Component, @Module, and @Inject annotated classes.

(Note we are using an IntelliJ plugin to make this work. The Dagger annotation processor is included in build.gradle as the plugin net.ltgt.apt-idea' version "0.15" and the dependency annotationProcessor"com.google.dagger:dagger-compiler:2.15".

After building, you can navigate to the build directory and find the generated Dagger classes under the classes.java.main.com.amazon.ata.music.playlist.service.dependency package.

When we build our code, and because we annotated the interface with @Component, the Dagger framework has enough information to generate a class that implements our ServiceComponent interface. It can also fulfill all of the dependencies based on what classes we declare as return types in our component interface. The class that Dagger creates has Dagger in front of the name to indicate it is created by the framework. Our code can request an instance of this generated class from Dagger to instantiate our dependency objects. This replaces our hand-written App class!

Replace use of App (in each of the Activity provider classes)

After verifying that our Dagger classes are generated, let's take a look at the GetPlaylistActivtyProvider class. GetPlaylistActivtyProvider is the entry point to our Lambda and is what handles the request from API Gateway.

For example, when we want to use the GetPlaylistActivty handler, we first go through the GetPlaylistActivityProvider class which instantiates a GetPlaylistAcivity along with a playlistDAO and DynamoDBMapper

    @Override
    public GetPlaylistResult handleRequest(final GetPlaylistRequest getPlaylistRequest, Context context) {
        return getApp().provideGetPlaylistActivity().handleRequest(getPlaylistRequest, context);
    }

Notice that getApp() implements the singleton pattern to return an App instance once where we can get our Activity objects.

    private App getApp() {
        if (app == null) {
            app = new App();
        }

        return app;
    }

Rename and update the getApp method to instead return the generated DaggerServiceComponent, following this same singleton pattern. You'll use the DaggerServiceComponent.create() method instead of a constructor.

Next, update the GetPlaylistResult methods to use your updated method so that we remove the interaction with the App class and replace it with the DaggerServiceComponent.

You will need to do this for each Provider class in the lambda package!

Delete App!

After verifying that your service works and previous TCTs pass, you can go ahead and delete the App class! We've completely replaced our old hand-managed dependency class with a much simpler and extendable implementation with Dagger!

You can verify your work by running the MT3IntrospectionTests and by uploading your code to your Lambda functions and making sure they work properly.

Doneness Checklist

  • You've implemented dependency injection with Dagger and removed the App class.
  • MT3IntrospectionTests tests pass.
  • Your Lambda functions still behave properly with the new code.

Resources