A fast and lightweight dependency injection framework for Java, with focus on simplicity, testability and ease of use. Requires Java 11, AspectJ and JUnit 5.
I've written this small lib to have faster TDD cycles in my personal Java backend project. I'm running it in production for over two years now, without looking back at Spring/Guice/Dagger or doing it all by hand.
First off, you should NOT use this lib, if you:
- Can't or don't want to use AspectJ
- Have more than one application context in a process (enterprise java)
- Want to have circular dependencies (well, this might actually be a benefit)
Here is why you may want to check it out:
- Create components naturally with
new Foo()
, and injection happens automatically - First class support for unit testing
- Prepared for parallel unit test execution
- No static, hard to test loggers
- Small footprint, no dependencies except AspectJ (the JAR is about 14KB)
But see for yourself. Here's a small component:
import org.jusecase.inject.Component;
import javax.inject.Inject;
@Component
public class CoffeeMachine {
@Inject
private BeansRepository beansRepository;
@Inject
private WaterRepository waterRepository;
}
At some place at startup, we init the dependencies:
Injector.getInstance().add(new BeansRepository());
Injector.getInstance().add(new WaterRepository());
Here is how you create the CoffeeMachine:
CoffeeMachine coffeeMachine = new CoffeeMachine();
All dependencies are injected. If dependencies are missing you will get an exception telling you what's exactly missing.
JUsecase Inject is available on maven central repository:
<dependency>
<groupId>org.jusecase</groupId>
<artifactId>inject</artifactId>
<version>0.3.1</version>
</dependency>
<dependency>
<groupId>org.jusecase</groupId>
<artifactId>inject</artifactId>
<version>0.3.1</version>
<type>test-jar</type>
<scope>test</scope>
</dependency>
You should add JUnit 5 testing dependencies if you haven't already.
And AspectJ (for Java 11 we unfortunately can't use the official plugin):
<plugin>
<groupId>com.nickwongdev</groupId>
<artifactId>aspectj-maven-plugin</artifactId>
<version>1.12.6</version>
<configuration>
<complianceLevel>${maven.compiler.release}</complianceLevel>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
<encoding>UTF-8 </encoding>
<aspectLibraries>
<aspectLibrary>
<groupId>org.jusecase</groupId>
<artifactId>inject</artifactId>
</aspectLibrary>
</aspectLibraries>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>test-compile</goal>
</goals>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjtools</artifactId>
<version>1.9.5</version>
</dependency>
</dependencies>
</plugin>
To see if everything works as expected, we can create a quick hello world class.
You find the code for this example in the test source package org.jusecase.inject.classes.example1
import org.jusecase.inject.Component;
import javax.inject.Inject;
import javax.inject.Named;
@Component
public class HelloWorld {
@Inject
@Named("hello")
private String hello;
@Inject
@Named("world")
private String world;
public HelloWorld() {
System.out.println(hello + " " + world);
}
}
Let's create a unit test to see if everything is working:
import org.junit.jupiter.api.Test;
class HelloWorldTest {
@Test
void test() {
new HelloWorld();
}
}
Run the test. It fails with this error message:
org.jusecase.inject.InjectorException: No implementation found. Failed to inject java.lang.String hello in org.jusecase.inject.classes.HelloWorld
Well, that makes sense. We haven't told Inject, what dependencies to use. There is a ComponentTest
interface that helps with writing unit tests. By implementing it, we get some BDD style default methods, to setup test dependencies:
import org.junit.jupiter.api.Test;
import org.jusecase.inject.ComponentTest;
class HelloWorldTest implements ComponentTest {
@Test
void test() {
givenDependency("hello", "Hello");
givenDependency("world", "World");
new HelloWorld();
}
}
You should now see this output: "Hello World"
Let's have a look at a more interesting case than hello world. We want to write a small registration service.
You find the code for this example in the test source package org.jusecase.inject.classes.example2
@Component
public class RegisterNewsletter {
@Inject
private NewsletterGateway newsletterGateway;
@Inject
private EmailValidator emailValidator;
public void register(String email) {
emailValidator.validate(email);
try {
newsletterGateway.addRecipient(email);
} catch (DuplicateKeyException e) {
throw new BadRequest("This email address is already registered.");
}
}
}
We also have a entity gateway for newsletter recipients (only emails for the sake of this example).
public interface NewsletterGateway {
void addRecipient(String email) throws DuplicateKeyException;
boolean isRecipient(String email);
}
It's an interface, because in our unit test we don't want to use the real thing. Let's write a custom mock for it.
public class NewsletterGatewayTrainer implements NewsletterGateway {
private final Set<String> emails = new HashSet<>();
@Override
public void addRecipient(String email) throws DuplicateKeyException {
if (isRecipient(email)) {
throw new DuplicateKeyException("E-Mail already registered.");
}
emails.add(email);
}
@Override
public boolean isRecipient(String email) {
return emails.contains(email);
}
}
Let's have a look how we can test this class. We really would like to tell Inject that we want to use the NewsletterGatewayTrainer
as implementation for NewsletterGateway
. We can do this by hand, like we did it in the Hello World example:
class RegisterNewsletterTest implements ComponentTest {
RegisterNewsletter registerNewsletter;
@BeforeEach
void setUp() {
givenDependency(new NewsletterGatewayTrainer());
registerNewsletter = new RegisterNewsletter();
}
}
Usually you want to do stuff with your custom mocks in your unit tests. So there is a shorthand, to inject custom mocks in unit tests, by using the @Trainer
annotation:
class RegisterNewsletterTest implements ComponentTest {
@Trainer // ComponentTest will instantiate this field and provide it as a dependency.
NewsletterGatewayTrainer newsletterGatewayTrainer;
RegisterNewsletter registerNewsletter;
@BeforeEach
void setUp() {
registerNewsletter = new RegisterNewsletter();
}
}
There is another dependency in this class, the EmailValidator
. This is something we don't want to mock, thus we can simply provide it to the test by saying givenDependency(new EmailValidator());
. The final test then may look something like this:
class RegisterNewsletterTest implements ComponentTest {
@Trainer
NewsletterGatewayTrainer newsletterGatewayTrainer;
RegisterNewsletter registerNewsletter;
@BeforeEach
void setUp() {
givenDependency(new EmailValidator());
registerNewsletter = new RegisterNewsletter();
}
@Test
void success() {
whenEmailIsRegistered("test@example.com");
assertThat(newsletterGatewayTrainer.isRecipient("test@example.com")).isTrue();
}
@Test
void alreadyRegistered() {
newsletterGatewayTrainer.addRecipient("test@example.com");
Throwable throwable = catchThrowable(() -> whenEmailIsRegistered("test@example.com"));
assertThat(throwable).isInstanceOf(BadRequest.class).hasMessage("This email address is already registered.");
}
@Test
void emptyMail() {
Throwable throwable = catchThrowable(() -> whenEmailIsRegistered(""));
assertThat(throwable).isInstanceOf(BadRequest.class).hasMessage("Please enter an email address");
}
@Test
void nullEmail() {
Throwable throwable = catchThrowable(() -> whenEmailIsRegistered(null));
assertThat(throwable).isInstanceOf(BadRequest.class).hasMessage("Please enter an email address");
}
@Test
void invalidEmail() {
Throwable throwable = catchThrowable(() -> whenEmailIsRegistered("email"));
assertThat(throwable).isInstanceOf(BadRequest.class).hasMessage("email is not a valid email address");
}
private void whenEmailIsRegistered(String email) {
registerNewsletter.register(email);
}
}
In order to obtain a logger one usually does something like this:
private static final Logger LOGGER = Logger.getLogger(MyService.class.getName());
With Inject, you can register per class providers. They will provide a new instance for every class they are injected into. This is exactly what we need to inject loggers.
public class LoggerProvider implements PerClassProvider<Logger> {
@Override
public Logger get(Class<?> classToInject) {
return Logger.getLogger(classToInject.getName());
}
}
You need to register the provider like this, at the place you configure your production dependencies:
injector.addProvider(new LoggerProvider());
In all your components, you can now simply inject a logger that will generate logs for this class:
@Component
public class MyService {
@Inject
private Logger logger;
public MyService() {
logger.info("This service got created.");
}
}
For your tests you can now write a LoggerTrainer
, that does not log at all. This has the benefit, that your CI build logs look very clean and are not cluttered with exceptions. And finally, you can use it to verify that a certain important message was logged. For instance, your test could look like this:
public class MyServiceTest {
@Trainer
LoggerTrainer loggerTrainer;
@Test
void logging() {
new MyService();
loggerTrainer.thenInfoWasLogged("This service got created.");
}
}