Skip to content

Neuron DI is a new approach for dependency injection in Java and Scala.

License

Notifications You must be signed in to change notification settings

christian-schlichtherle/neuron-di

Repository files navigation

Apache License 2.0 Test Workflow

Why Neuron DI?

As a Java developer, does Spring or Guice give you a headache? Maybe you are tired of annotation frenzy or slow startup times of your application and test code?

As a Scala developer, do you frown upon long parameter lists or the Cake pattern? Maybe you are looking for something simpler, yet scalable while retaining compile-time dependency injection?

Neuron DI is a tiny library for dependency injection (DI) in Java and Scala which helps you structure your application or library code with ease, whether it's small or large. In contrast to any JSR 330 based framework like Spring, Guice etc, it takes a complementary approach. In doing so, it has become a significant productivity booster with a shallow learning curve.

Features

Neuron DI provides the following features:

  • dependency injection into abstract methods without parameters, called synapse methods
  • synapse methods can be members of any class or interface, called neuron classes or neuron interfaces
  • dependency injection into third-party code like java.util.function.Supplier.get()
  • inherently lazy resolution of dependencies
  • selectable caching strategies for dependencies: thread-safe, not-thread-safe, thread-local or disabled
  • looking up dependencies in any object by delegation
  • dependency injection at compile-time (Scala only)
  • peaceful coexistence with any other DI framework or library

Neuron DI frees your code from the following code smells:

  • copy constructors for constructor injection
  • mutable classes for method injection
  • bad testability because of (private) field injection
  • qualifier annotations like @Named
  • scope annotations like @Singleton
  • specific application contexts or containers
  • builder classes for injecting dependencies
  • tight coupling with a DI framework or library

Benefits

Neuron DI is a hybrid - it supports both runtime and compile-time DI:

In Java, your code is limited to runtime DI, but Neuron DI frees it from the shortcomings and the ballast of JSR 330 - see next section. It also adds caching like you can do with a lazy val definition in Scala, but with more options like not-thread-safe caching or even thread-local caching, so you can say goodbye to the ThreadLocal class. With synapse methods and caching you can effectively inject dependencies into interfaces, which means you can compose your application mostly of mix-in interfaces like you can do in Scala.

In Scala, you can use all the features for Java plus compile-time DI: With compile-time DI, you can avoid the (albeit minimal) runtime overhead plus you get a compiler error if any dependency is missing or has the wrong type.

All in all, Neuron DI closes some gaps between Java and Scala, allowing you to more easily mix these languages in your projects. For example, you can write your main code in Java and your test code in Scala. Neuron DI then lets you take the same approach to DI with both languages.

Walk-Through

The sample code discussed in this chapter is derived from another GitHub repository, named Neuron DI Examples For Java. Please check out this repository for more options and all the glory details.

About Neurons And Synapses

Consider the following sample code:

import java.util.*;
import java.util.function.BiFunction;

public interface GreetingService extends BiFunction<List<Locale>, Optional<String>, String> {

    Locale defaultLocale();

    Map<Locale, List<String>> greetingMessages();

    default String apply(List<Locale> languageRanges, Optional<String> who) {
        // Some code calling `defaultLocale()` and `greetingMessages()`:
        [...]
    }
}

GreetingService has two abstract methods without parameters: defaultLocale() and greetingMessages(). These methods return dependencies of the code in the apply([...]) method (not shown). In Neuron DI, abstract methods without parameters are called synapse methods and their enclosing types are called neuron classes or neuron interfaces. This concept is entirely abstract: There is no dependency of the GreetingService interface on Neuron DI.

Tip

The body of the apply([...]) method and its dependencies defaultLocale() and greetingMessages() are actually implementation details! So, according to the interface segregation and dependency inversion principles these methods should be moved to another class or interface. However, for the sake of brevity, this is not shown here.

One of the benefits of this approach is that synapse methods not only give a dependency a location and a (return) type, but also a (method) name. This is why Neuron DI doesn't need any qualifier annotations such as @Named.

Another benefit is that dependency resolution is inherently lazy by design, but can also be eager by choice: When a neuron is constructed, you get to choose if you want to delegate each call to a synapse method to another method in another object (lazy dependency resolution) or if you simply want to return a pre-computed value (eager dependency resolution).

Following is an HTTP controller which depends on a GreetingService:

import example.web.framework.HttpController;

public interface GreetingController extends HttpController {

    GreetingService greetingService();

    default int get() throws Exception {
        var g = new Greeting();
        g.message = greetingService().apply(acceptLanguages(), requestParam("who"));
        applicationJson().encode(g);
        return 200;
    }
}

In this case, the body of the get() method depends on the synapse method greetingService(). Next, let's look into the wiring of these classes.

About Modules And The Incubator

In a large code base, you will have many neuron classes and interfaces and you will need to wire them into a dependency graph somehow. This is where the module pattern comes into play: A module bundles a group of factory methods for application components which may depend on each other into a single object.

In design pattern parlance, the module pattern is a blend of the factory pattern and the mediator pattern because a module not only creates (and optionally caches) application components, but typically delegates back to itself whenever any of their dependencies need to get resolved.

Consider the following sample code:

import global.namespace.neuron.di.java.Caching;
import global.namespace.neuron.di.java.Neuron;

import java.util.*;

import static global.namespace.neuron.di.java.Incubator.wire;

@Neuron
abstract class Module {

    private static final Locale defaultLocale = Locale.ENGLISH;

    private static final Map<Locale, List<String>> greetingMessages = Map.of(
            ENGLISH, List.of("Hello, %s!", "world"),
            GERMAN, List.of("Hallo, %s!", "Welt")
    );

    @Caching
    GreetingService greetingService() {
        return wire(GreetingService.class).using(this);
    }
}

In this case, the factory method is greetingService(): Its body calls the static method Incubator.wire([...]). The Incubator class is located in the package global.namespace.neuron.di.java, which provides the Java API of Neuron DI. The term wire(GreetingService.class).using(this) specifically tells the incubator to make a GreetingService and look up any of its dependencies in this module object. This technique is called dependency delegation. There are other options to wire a neuron, but dependency delegation is easy to use, yet very versatile.

When looking up dependencies, the using([...]) method accepts any method without parameters or any field with the same name as the synapse method and an assignment compatible return value - regardless of any modifiers. In this case, it finds the private, static fields defaultLocale and greetingMessages. Such fields or methods are called dependency provider fields or dependency provider methods.

A dependency provider method may be abstract, in which case it's also a synapse method and hence the module is a neuron class or interface. You can either use regular inheritance and implement the synapse method in a subclass or sub-interface or you can use another wire([...]).using([...]) term to wire the module by looking up its dependencies in some delegate object, ideally another module. This kind of module stacking enables the modules to have different scopes: For example, the delegate module may be application scoped while the delegating module may be request scoped. This is why Neuron DI doesn't need any scope annotations such as @Singleton.

In this case, the module is application scoped and the GreetingService is immutable, so it's a good idea to cache it for subsequent use. The @Caching annotation makes sure that the body of the method greetingService() is called at most once. For Scala developers, the effect is the same as a lazy val definition. The @Caching annotation requires the @Neuron annotation on the class or interface. So although this module class does not have any synapse methods, it's a neuron class by declaration.

Last, but not least, a module does not need to extend or implement a specific class or interface. Modules classes or interfaces are implementations of the module pattern, not of a particular type.

About Booting Applications

The Module class is abstract to prevent you from accidentally calling new Module() - this would ignore the @Caching annotation. Also, we haven't seen how a GreetingController is created. So how do these objects get created? The solution is in the Main class:

import example.web.framework.HttpServer;

import java.io.IOException;

import static global.namespace.neuron.di.java.Incubator.breed;

public abstract class Main extends Module implements HttpServer {

    public static void main(String... args) throws IOException {
        breed(Main.class)
                .with(GreetingController.class)
                    .route("/greeting")
                        .get(GreetingController::get)
                .start(args.length > 0 ? Integer.parseInt(args[0]) : 8080);
    }
}

The main class extends our module class, so it inherits the @Neuron and @Caching annotations and hence it should be abstract again to prevent you from accidentally calling new Main(). To create our application instance, the main method calls the breed([...]) method in the Incubator class. This method is designed for creating a neuron object which does not have any synapse methods. Obviously, a main class should never have any synapse methods, that is, it should never have any unsatisfied dependencies, so it's a perfect match.

The remaining method calls use the domain-specific language (DSL) inherited from the HttpServer interface to configure the routing of HTTP calls and start a web server. The DSL instructs the web server to create an instance of the GreetingController interface and call its get() method whenever it receives a GET request for the URI path /greeting.

The actual creation of the controller instance is happening in the web framework and looks similar to this:

package example.web.framework;

import com.sun.net.httpserver.HttpExchange;

import java.util.Map;

import static global.namespace.neuron.di.java.Incubator.wire;

interface HttpHandler<C extends HttpController> {

    Class<C> controller();

    HttpServer server();

    default void apply(HttpExchange exchange) throws Exception {
        try (OutputStream responseBody = [...]) {
            C controller = wire(controller())
                    .bind(HttpController::exchange).to(exchange)
                    .bind(HttpController::responseBody).to(responseBody)
                    .using(server())
            [...]
        }
    }
}

The apply([...]) method calls the wire([...]) method again, but this time the term is a bit more complex:

  • The controller class to instantiate is not provided as a class literal, but returned by the controller() method.
  • The synapse method HttpController.exchange() is bound to the method parameter exchange.
  • The synapse method HttpController.responseBody is bound to the local variable responseBody.
  • Any other synapse methods of the controller class or interface will be bound to dependency provider methods or fields in the server object, which is an instance of the application class Main, thereby effectively delegating any dependencies of controller classes or interfaces to the application class.

Note that any controller instances are request scoped, so they may even be mutable, while the main class (with its module superclass) is application scoped, so it should be immutable.

About Unit Testing

Writing test code using Neuron DI is no different than writing main code: Just use the Incubator class to wire or breed a test neuron.

The following unit test for the GreetingService interface is written in Scala using ScalaTest and shows an exclusive feature of the Scala API of Neuron DI: Compile-time dependency injection using the wire macro.

import java.util.Locale._

import global.namespace.neuron.di.scala._
import org.scalatest.WordSpec

import scala.jdk.CollectionConverters._

class GreetingServiceSpec extends WordSpec {

  "A GreetingService" should {
    "compute the expected message" in {
      // Some test code calling `greetingService.apply([...])`:
      [...]
    }
  }

  private lazy val greetingService = wire[GreetingService]

  private lazy val greetingMessages = Map(
    ENGLISH -> List("Hello, %s!", "world"),
    GERMAN -> List("Hallo, %s!", "Welt"),
  ).view.mapValues(_.asJava).toMap.asJava

  private lazy val defaultLocale = ENGLISH
}

The wire macro is located in the package global.namespace.neuron.di.scala, which provides the Scala API of Neuron DI.

In this unit test, it's used to create the test neuron greetingService. The macro figures that the GreetingService interface has two synapse methods, defaultLocale() and greetingMessages(), and looks up corresponding methods or fields in the current scope. If no such methods or fields are available or if their (return) types are not assignment-compatible, then the macro emits an error message.

The Scala API also has its own variant of the Incubator class with some syntax sugar added, so you could write this instead:

private lazy val greetingService = Incubator.wire[GreetingService] using this 

However, the Incubator class strictly uses runtime DI, no matter if you use its variant in the Scala API or the Java API, so the wire macro is generally preferable.

About Application Performance

Neuron DI uses reflection to analyze neuron and dependency delegate classes or interfaces at runtime. It saves its findings in class-loader sensitive caches to speed up subsequent calls. For proxy class generation, it uses ASM. The actual dispatching of synapse methods to dependency provider methods or fields is done using method handles. Tests have shown that the per-call overhead of synapse methods in comparison to hand-written implementations is below the level of noise typically induced by the standard garbage collection.

About Illegal Reflective Access

Illegal reflective access is avoided wherever possible:

  • No illegal reflective access whatsoever is required for dynamic loading of generated proxy classes.
  • If a dependency provider method in a non-public subclass or sub-interface overrides or implements a method in a public superclass or interface, then the public superclass or interface is used.

More Documentation

For more documentation, please consult the following resources:

Release Notes are available on GitHub:

  • Release Notes

Release artifacts are deployed to Maven Central:

  • Neuron DI for Java or Neuron DI for Java
  • Neuron DI for Scala 2.11
  • Neuron DI for Scala 2.12
  • Neuron DI for Scala 2.13
  • Neuron DI @ Guice for Java or Neuron DI @ Guice for Java
  • Neuron DI @ Guice for Scala 2.11
  • Neuron DI @ Guice for Scala 2.12
  • Neuron DI @ Guice for Scala 2.13

Neuron DI is covered by the Apache License, Version 2.0.