Dependency Injection, maar dan zonder framework!

Sinds jaar en dag maken de meeste ontwikkelaars gebruik van frameworks om dependency injection voor hen te verzorgen. Denk hierbij aan bijvoorbeeld het Spring Framework, Google Guice of een applicatieserver die Java/Jakarta EE implementeert. Wat nou als ik je zou vertellen dat je helemaal geen framework en/of container nodig hebt om gebruik te maken van dependency injection? In dit artikel laat ik je zien hoe je gebruik kan maken van dependency injection, maar dan zonder framework! [Mark Hendriks]

Als voorbeeldproject heb ik een restaurant gebouwd in Java. Binnen dit project worden een aantal branches gebruikt. De master branch betreft een werkende implementatie, maar dan volledig zonder dependency injection. In Listing 1 staat een fragment uit DinerRestaurantService.java. Deze class heeft een tweetal dependencies, namelijk een implementatie van Chef en van OrderRepository. Beide dependencies worden gedeclareerd en geïnstantieerd. Het werkt, maar echt flexibel is het niet. Op deze manier kun je tijdens je tests bijvoorbeeld geen gebruikmaken van een mock implementatie van de Chef interface. Laten we eens kijken hoe we hier verandering in kunnen brengen.

public class DinerRestaurantService implements RestaurantService {

    private Chef chef = new DinerChef();

    private OrderRepository orderRepository = new InMemoryOrderRepository();


    @Override

    public void takeOrder(String order) {

        chef.prepareOrder(order);

        orderRepository.save(order);

    }

}

Listing 1.

 

Het voorbeeldproject behorende bij dit artikel is te vinden op mijn github. [1]

Inversion of Control

Voordat we met dependency injection aan de slag gaan, is het goed om te weten welk principe hieraan ten grondslag ligt. In het geval van dependency injection is dat inversion of control. In conventionele programmatuur bepaalt jouw code de flow. Dit kunnen we ook omdraaien. In plaats van dat jouw programmatuur externe functies aanroept, zal externe code jouw code aanroepen. Jij levert dus enkel de functionaliteit, zonder dat je weet wanneer deze code aangeroepen zal worden. Jij bent nu dus niet meer “in control” van de flow van het programma.

Een voorbeeld hiervan vinden wij in de Jakarta Persistence API (JPA). Zo kan je methoden in je class annoteren met bijvoorbeeld @PrePersist of @PostUpdate. Het framework of container die de JPA spec implementeert, zal de aanroep van deze methoden verzorgen. In dit geval geef je de controle dus uit handen.

Een simpeler voorbeeld vinden wij in het design pattern “template method”. De superclass definieert de flow, subclasses overerven deze class en overschrijven daarbij methoden of implementeren abstracte methoden.

Dependency Injection

Wat opvalt is dat veel ontwikkelaars dependency injection zien als synoniem van DI-frameworks, terwijl het een pattern is dat ondersteund kan worden door frameworks, maar ook gewoon op zichzelf staat.

 

Enkele voordelen van dependency injection zijn:

– Code decoupling: gebruik contracten/interfaces en vermijd implementaties.

– Verbeterde testbaarheid: dependencies zijn makkelijk te mocken

– Configurability: implementatie van dependency makkelijk te veranderen.

Constructor injection

Om dependency injection toe te passen, heb je geen complexe code nodig. Je kan simpelweg gebruikmaken van een constructor met parameters. Door gebruik te maken van een zogenoemde geparametriseerde constructor leg je de verantwoordelijkheid voor het aanleveren van de afhankelijkheden neer bij de code die deze class nodig heeft. Op deze manier wordt hier dus Inversion of Control toegepast.

Niet iedereen zal dit een mooie oplossing vinden, omdat er gebruik gemaakt wordt van het “new” keyword. Het hele idee van dependency injection is dat het “new” keyword niet gebruikt hoeft te worden bij het aanmaken van afhankelijkheden. Echter, als dit op een slimme manier wordt toegepast, hoeft dit maar in één enkele class te gebeuren. Let er echter wel op dat je dan ook echt alleen maar in één class doet. Of desnoods in de classes van één enkele package, want als je app erg complex is wil je misschien dingen een beetje opsplitsen. Je zou er dan voor kunnen kiezen een “main” package te maken waarin alle classes staan die op verschillende manieren dingen aan elkaar knopen. En het goede nieuws: het mag van Uncle Bob! [2]

Het mooie aan deze oplossing is dat er al tijdens compile-time gecontroleerd wordt of alle dependencies aanwezig zijn, daar waar Guice en Spring dit pas tijdens runtime doen. Ook is het heel makkelijk om via je IDE na te gaan waar dependencies vandaan komen. Listing 2 laat zien hoe we de constructor hebben geïmplementeerd in de DinnerRestaurantService class.

 

public class DinerRestaurantService implements RestaurantService {


    private final Chef chef;

    private final OrderRepository orderRepository;


    public DinerRestaurantService(Chef chef, OrderRepository orderRepository) {

        this.chef = chef;

        this.orderRepository = orderRepository;

    }


    public void takeOrder(String order) {

        chef.prepareOrder(order);

        orderRepository.save(order);

    }

}

Listing 2.

Als wij vervolgens een andere implementatie voor bijvoorbeeld tests zouden willen injecteren, dan is dat nu mogelijk. In Listing 3 zie je hoe er voor het testen van DinnerRestaurantService mock implementaties geïnjecteerd worden. Dit voorbeeld is terug te vinden in de “constructor” branch.

class DinerRestaurantServiceTest {


    private Chef chefMock;

    private OrderRepository orderRepositoryMock;

    private DinerRestaurantService sut;



    @BeforeEach

    void setUp() {

        chefMock = Mockito.mock(Chef.class);

        orderRepositoryMock = Mockito.mock(OrderRepository.class);

        sut = new DinerRestaurantService(chefMock, orderRepositoryMock);

    }



    @Test

    void whenTakingOrderChefAndRepoAreCalled() {

        sut.takeOrder("Fries");

        Mockito.verify(chefMock, Mockito.times(1)).prepareOrder("Fries");

        Mockito.verify(orderRepositoryMock, Mockito.times(1)).save("Fries");

    }

}

Listing 3.

Setter injection

Een andere simpele manier is door gebruik te maken van setter injection. Bij deze variant maak je gebruik van de setter methods om de benodigde dependencies te injecteren. Hier schuilt wel een gevaar. Als je een setter vergeet aan te roepen, dan eindig je met een incomplete instantie van jouw object. De compiler zal dit tijdens compile time niet merken. Hier kom je pas achter tijdens runtime. Dit zal dan een NullPointerException tot gevolg hebben. Dit is natuurlijk alles behalve gewenst. Constructor injection heeft daarom ook de voorkeur, omdat de instance na het aanroepen van de constructor compleet is (het bewust instantiëren met null references daargelaten).

Factory method

Een andere benadering is het gebruikmaken van design patterns. Zo is het “factory method” pattern bij uitstek geschikt. Bij deze oplossing is er nog steeds een constructor met parameters beschikbaar, maar zal je niet zelf het object creëren middels het “new” keyword. In plaats daarvan wordt dit afgehandeld door een zogeheten factory method. Zo wordt er in Listing 4 een static getInstance() method aangeboden, die het werk van je overneemt. De volledige code is terug te vinden onder branch “factory-method”.


public class DinerRestaurantService implements RestaurantService {




    private final Chef chef;

    private final OrderRepository orderRepository;




    DinerRestaurantService(Chef chef, OrderRepository orderRepository) {

        this.chef = chef;

        this.orderRepository = orderRepository;

    }



    public static DinerRestaurantService getInstance() {

        return new DinerRestaurantService(new DinerChef(),

new InMemoryOrderRepository());

    }



    public void takeOrder(String order) {

        chef.prepareOrder(order);

        orderRepository.save(order);

    }

}

Listing 4.

Factory injection

Je zou gebruik kunnen maken van het “Factory Pattern”. Het idee achter een factory is dat deze factory weet hoe elk object in jouw project gecreëerd kan worden. Het voordeel wat je hierbij hebt is dat alle logica voor het creëren van nieuwe objecten maar op één enkele plek terug te vinden is. Het mogelijke nadeel van deze aanpak is dat je de code goed moet bestuderen als je wilt achterhalen welke afhankelijkheden er onderling bestaan. Ander bijkomend nadeel is dat je in de code een afhankelijkheid hebt naar deze factory. In Listing 5 zie je hoe z’n factory geïmplementeerd wordt in ons voorbeeldproject onder de branch “factory”.

public class DinerRestaurantFactory {




    public static RestaurantService createRestaurantService() {

        return new DinerRestaurantService(getDinerChef(), createOrderRepository());

    }




    public static OrderRepository createOrderRepository() {

        return new InMemoryOrderRepository();

    }




    public static Chef createChef() {

        return new DinerChef();

    }

}

Listing 5.

Je zou een stap verder kunnen gaan en het “Abstract Factory Pattern” [3] kunnen gebruiken. Om dit voor elkaar te krijgen, hebben we een abstracte RestaurantFactory nodig. Dit hoeft niet per se een abstracte class te zijn, dit is ook mogelijk met een interface. In Listing 6 zie je hoe de abstract factory gebruikt wordt om te bepalen welke implementatie er terug gegeven moet worden. In Listing 7 zie je vervolgens hoe je gebruik kan maken van deze abstracte factory. Een class diagram is terug te vinden in afbeelding 1.

public abstract class RestaurantFactory {


    public static RestaurantFactory getFactory(String name) {

        if ("fancy".equalsIgnoreCase(name)) {

            return new FancyRestaurantFactory();

        }

        return new DinerRestaurantFactory();

    }


    public abstract RestaurantService createRestaurantService();


    public abstract Chef createChef();


    public abstract OrderRepository createOrderRepository();

}

Listing 6.

 

public class App {




    public static void main(String[] args) {

        RestaurantFactory factory = RestaurantFactory.getFactory("fancy");

        RestaurantService restaurantService = factory.createRestaurantService();

        restaurantService.takeOrder("Steak");

    }

}

Listing 7.

 

Afbeelding 1.

Tot slot

In dit artikel heb ik laten zien hoe je gebruik kan maken van dependency injection, zonder dat je hier een framework voor nodig hebt. Wat ik vooral niet wil bereiken met dit artikel is dat je geen framework meer zou willen gebruiken, deze nemen namelijk veel werk uit handen. Wel hoop ik je meer inzicht gegeven te hebben in het concept genaamd Dependency Injection.

Ben je door dit artikel geïnspireerd om zelf meer met dependency injection te gaan doen? Zelf een framework schrijven om zo beter te begrijpen hoe dat werkt, is natuurlijk ook een leuk en leerzaam idee. Zelf heb ik dat ook gedaan. Deze implementatie is terug te vinden in referentie [4].

 

Referenties

[1] https://github.com/TheCheerfulDev/javamag-no-framework-di

[2] https://twitter.com/jqno/status/1111574888580562944

[3] https://refactoring.guru/design-patterns/abstract-factory

[4] https://github.com/TheCheerfulDev/injectinator

 

Bio Mark Hendriks

Mark Hendriks is een gepassioneerde software architect en Elastic Stack enthousiasteling bij Ordina JTech.