Managing dependencies in large-scale Java applications can quickly become a complex challenge.
As codebases grow, so does the intricacy of object relationships, often leading to tightly coupled components that are difficult to test, maintain, and scale.
This is where the principle of Dependency Injection (DI) becomes invaluable. By externalizing the creation and management of an object’s dependencies, DI promotes cleaner, more modular, and highly testable code.
While several frameworks exist to implement dependency injection in Java, many come with the overhead of reflection or complex configurations. Avaje Inject offers a modern, lightweight alternative.
It leverages a compile-time annotation processor to generate the necessary wiring, completely avoiding the performance penalties associated with runtime reflection.
This approach not only simplifies your codebase but also enhances application startup speed and provides robust compile-time validation.
This guide explores the fundamentals of Java DI and demonstrates how Avaje Inject can help you build more efficient and maintainable applications.
We will cover its core features, from basic bean management to advanced conditional wiring, providing practical examples to help you integrate it into your projects.
By the end, you’ll have a clear understanding of how Avaje Inject streamlines development and helps you adhere to best practices in software design.
The Foundations of Avaje Inject
At its core, Avaje Inject is designed for simplicity and performance. It operates on the principle of “convention over configuration,” reducing the boilerplate code needed to set up dependency injection.
Compile-Time Generation
The standout feature of Avaje Inject is its reliance on compile-time code generation.
When you compile your project, Avaje Inject’s annotation processor scans your code for specific annotations, such as @Singleton and @Inject.
It then generates plain Java code that handles the creation and injection of dependencies. This process eliminates the need for runtime reflection, which is a common source of performance overhead in other DI frameworks.
Core Annotations
Getting started with Avaje Inject involves learning just a few key annotations:
- @Singleton: Marks a class as a singleton bean. Avaje Inject will create only one instance of this class and inject it wherever it is needed.
- @Component: Similar to @Singleton, this annotation registers a class as a managed bean.
- @Inject: Used on constructors, fields, or methods to request a dependency. Constructor injection is the recommended approach as it ensures that objects are fully initialized with all their dependencies.
Setting Up Your First Project
Integrating Avaje Inject into a Java project is straightforward. It requires adding the necessary dependencies to your build configuration file, whether you are using Maven or Gradle.
Maven Configuration
For a Maven project, you need to add two dependencies to your pom.xml file: the API and the annotation processor.
- avaje-inject: This artifact contains the core APIs and annotations like @Singleton and @Inject.
- avaje-inject-generator: This is the annotation processor that generates the DI code during compilation.
Here is an example snippet for your pom.xml:
<dependencies>
<dependency>
<groupId>io.avaje</groupId>
<artifactId>avaje-inject</artifactId>
<version>x.x.x</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>io.avaje</groupId>
<artifactId>avaje-inject-generator</artifactId>
<version>x.x.x</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
Creating and Injecting Beans
Once the setup is complete, you can start defining your components. Let’s create a simple service and inject it into another component.
- First, define a service interface and its implementation.
- Annotate the implementation with @Singleton to register it as a bean.
- Then, create a consumer class that uses constructor injection to receive an instance of the service.
public interface GreetingService {
String greet();
}
@Singleton
public class SimpleGreetingService implements GreetingService {
@Override
public String greet() {
return “Hello, World!”;
}
}
@Singleton
public class Application {
private final GreetingService greetingService;
@Inject
public Application(GreetingService greetingService) {
this.greetingService = greetingService;
}
public void run() {
System.out.println(greetingService.greet());
}
}
Advanced Wiring with Qualifiers
In many applications, you may have multiple implementations of the same interface.
For example, you might have different data access objects (DAOs) for different databases. In such cases, Avaje Inject allows you to use qualifiers to specify which implementation to inject.
Using @Named
The @Named annotation lets you assign a unique string identifier to a bean. You can then use this name to request a specific instance during injection.
- Annotate each implementation with @Named and provide a distinct name.
- When injecting the dependency, add the @Named annotation to the constructor parameter with the desired bean name.
public interface StorageService {
void save(String data);
}
@Singleton
@Named(“file”)
public class FileStorageService implements StorageService { … }
@Singleton
@Named(“database”)
public class DatabaseStorageService implements StorageService { … }
@Singleton
public class DataManager {
private final StorageService fileStorage;
@Inject
public DataManager(@Named(“file”) StorageService fileStorage) {
this.fileStorage = fileStorage;
}
//…
}
Creating Custom Qualifiers
For more type-safe and descriptive wiring, you can create your own custom qualifier annotations.
This approach is generally preferred over using string-based names because it allows the compiler to catch typos and provides better clarity.
- Define a new annotation and meta-annotate it with @Qualifier.
- Use your custom annotation on both the bean implementation and the injection point.
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
public @interface FileStorage {}
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
public @interface DatabaseStorage {}
@Singleton
@FileStorage
public class FileStorageService implements StorageService { … }
@Singleton
public class DataManager {
private final StorageService storageService;
@Inject
public DataManager(@FileStorage StorageService storageService) {
this.storageService = storageService;
}
}
Bean Scopes and Lifecycle
Avaje Inject provides clear support for bean lifecycle management. By default, beans are singletons, meaning only one instance is created and shared throughout the application’s lifecycle.
However, you can manage the startup and shutdown logic for your beans using specific annotations.
Post-Construct and Pre-Destroy
You can control bean initialization and destruction using the standard Jakarta annotations:
- @PostConstruct: Methods annotated with @PostConstruct are executed after a bean’s dependencies have been injected. This is useful for performing initialization tasks.
- @PreDestroy: Methods with this annotation are called just before the bean is removed from the container. This is the ideal place to release resources like database connections or file handles.
@Singleton
public class DatabaseConnectionManager {
@PostConstruct
public void initialize() {
// Code to establish database connections
System.out.println(“Database connections initialized.”);
}
@PreDestroy
public void cleanup() {
// Code to close database connections
System.out.println(“Database connections closed.”);
}
}
Conditional Wiring and Profiles
Real-world applications often require different configurations for different environments, such as development, testing, and production.
Avaje Inject supports this through profiles and conditional wiring, allowing you to include or exclude beans based on specific conditions.
Using @Profile
The @Profile annotation lets you associate a bean with one or more profiles. The bean will only be registered if one of its associated profiles is active.
- Add @Profile(“profileName”) to a bean class.
- You can activate profiles programmatically when building the BeanScope.
@Singleton
@Profile(“dev”)
public class DevDataSource implements DataSource { … }
@Singleton
@Profile(“prod”)
public class ProdDataSource implements DataSource { … }
Conditional Beans with @Requires
For more complex conditions, you can use the @Requires annotation. It allows you to register a bean only if a specific property is set, a bean is present, or a class is on the classpath.
- @Requires(property = “feature.enabled”): Registers the bean only if the specified property is set to “true”.
- @Requires(bean = SomeBean.class): Registers the bean only if SomeBean is also present in the context.
- @Requires(classes = AnotherClass.class): Registers the bean only if AnotherClass is available on the classpath.
Building for the Future with Avaje Inject
Adopting a compile-time dependency injection framework like Avaje Inject is a strategic decision that pays dividends in performance, maintainability, and developer productivity.
By shifting dependency resolution from runtime to compile-time, it catches configuration errors early, accelerates application startup, and produces highly optimized code.
Its intuitive, annotation-driven approach simplifies the development of complex Java DI systems while promoting clean, modular architecture.
If you are looking to build robust and efficient Java applications without the overhead of traditional DI frameworks, Avaje Inject offers a compelling solution.
We encourage you to explore its features further and consider integrating it into your next project.
