Defining module dependencies

This section covers Across module dependencies, it shows how to:

  • Define dependencies between components and Across modules

  • Share components between modules by exposing them

  • Test exposed beans and module dependencies

This how-to is part of the Building a modular monolith series. It continues directly from the previous how-to on Application and modules.

Adding a second module

Create a second package modules.two and more or less copy the configuration of ModuleOne: create an equivalent module descriptor and internal component.

/src/main/java/com/example/demo/modules/two/ModuleTwo.java
package com.example.demo.modules.two;

public class ModuleTwo extends AcrossModule
{
	@Override
	public String getName() {
		return "ModuleTwo";
	}

	@Override
	protected void registerDefaultApplicationContextConfigurers( Set<ApplicationContextConfigurer> contextConfigurers ) {
		contextConfigurers.add( ComponentScanConfigurer.forAcrossModule( ModuleTwo.class ) );
	}
}
/src/main/java/com/example/demo/modules/two/InternalComponentTwo.java
package com.example.demo.modules.two;

@Component
@Slf4j
public class InternalComponentTwo
{
	public InternalComponentTwo() {
		LOG.info( "Component created: {}", getClass() );
	}
}

Your project structure now looks like:

com.example.demo/
  modules/
    one/
      ModuleOne
      InternalComponentOne
    two/
      ModuleTwo
      InternalComponentTwo
  DemoApplication

Add an integration test for the second module.

src/test/java/test/TestModuleBootstrapScenarios.java
@Test
public void moduleTwoShouldBootstrap() {
    try (AcrossTestContext ignore = AcrossTestBuilders.standard( false ).modules( new ModuleTwo() ).build()) {
        LOG.trace( "Bootstrap successful." );
    }
}

You should be able to run this test successfully.

Adding a component dependency

Let’s add a component dependency on InternalComponentTwo. Add a constructor injection dependency to component InternalComponentOne.

src/main/java/com/example/demo/modules/two/InternalComponentTwo.java
@Component
@Slf4j
public class InternalComponentTwo
{
    private final InternalComponentOne internalComponentOne;

    public InternalComponentTwo(InternalComponentOne internalComponentOne) {
        LOG.info("Component created: {} (using {})", getClass(), internalComponentOne);

        this.internalComponentOne = internalComponentOne;
    }
}

This is a regular Spring bean dependency. This code compiles as InternalComponentOne is a public class, but if you run the test, it fails with exception:

No qualifying bean of type 'com.example.demo.modules.one.InternalComponentOne' available

Which makes sense, as InternalComponentOne is a bean created in ModuleOne but our ModuleTwo does not have a dependency on ModuleOne.

Adding a module dependency

In a module approach, a module can explicitly define a dependency on another module. You can do so using @AcrossDepends on the module descriptor.

Change the ModuleTwo descriptor to add an explicit dependency on ModuleOne.

package com.example.demo.modules.two;

@AcrossDepends(required = "ModuleOne")
public class ModuleTwo extends AcrossModule
{
    ...
}

This dependency is required, meaning that the application must not start if the dependency is not met. We refer to the module we depend on by name, as a module name is expected to be unique.

If you re-run the moduleTwoShouldBootstrap() test, it now fails with another, clear exception:

com.foreach.across.core.context.bootstrap.ModuleDependencyMissingException: Unable to bootstrap AcrossContext as module ModuleTwo requires module ModuleOne. Module ModuleOne is not present in the context.

This is expected behaviour. We have stipulated a dependency on ModuleOne, but have not added ModuleOne to our test configuration yet.

Cleaning up the tests

Let’s split up our single test case into two separate tests:

  • one that verifies bootstrapping fails if ModuleOne is not present

  • one that verifies bootstrapping works if ModuleOne is present

src/test/java/test/TestModuleBootstrapScenarios.java
@Test(expected = ModuleDependencyMissingException.class)
public void moduleTwoRequiresModuleOne() {
    try (AcrossTestContext ignore = AcrossTestBuilders.standard( false )
                                                      .modules( new ModuleTwo() )
                                                      .build()) {
        fail( "Should not have bootstrapped." );
    }
}

@Test
public void moduleTwoBootstrapsIfOneIsPresent() {
    try (AcrossTestContext ignore = AcrossTestBuilders.standard( false )
                                                      .modules( new ModuleTwo(), new ModuleOne() )
                                                      .build()) {
        LOG.trace( "Bootstrap successful." );
    }
}

If we run the tests, moduleTwoRequiresModuleOne() succeeds, but moduleTwoBootstrapsIfOneIsPresent() fails again with the original exception:

No qualifying bean of type 'com.example.demo.modules.one.InternalComponentOne' available

Even though in the console log we can see that InternalComponentOne gets created:

: --- Starting module bootstrap
:
: 1 - ModuleOne [resources: ModuleOne]: class com.example.demo.modules.one.ModuleOne
: Across development mode active: false
: Component created: class com.example.demo.modules.one.InternalComponentOne
:
: 2 - ModuleTwo [resources: ModuleTwo]: class com.example.demo.modules.two.ModuleTwo
: Exception encountered during context initialization

ModuleOne starts up fine and creates InternalComponentOne, but bootstrapping ModuleTwo fails when it tries to resolve the InternalComponentOne dependency.

So what is going on here?

Exposing beans

In a modular approach with Across, all beans are contained within their module unless otherwise exposed. This means that even though InternalComponentOne is a publicly accessible class, and there is a singleton bean created for it, that bean can only be accessed from within ModuleOne. For ModuleTwo there is no component InternalComponentOne available.

To fix this, we can expose InternalComponentOne by annotating it with @Exposed. Instead of exposing the internal component however, let’s create a separate exposed component and use that one as a dependency in InternalComponentTwo.

src/main/java/com/example/demo/modules/one/ExposedComponentOne.java
package com.example.demo.modules.one;

@Component
@Exposed
public class ExposedComponentOne implements Supplier<String>
{
	@Override
	public String get() {
		return "hello from module one";
	}
}
src/main/java/com/example/demo/modules/two/InternalComponentTwo.java
@Component
@Slf4j
public class InternalComponentTwo
{
	private final ExposedComponentOne exposedComponentOne;

	public InternalComponentTwo( ExposedComponentOne exposedComponentOne ) {
		LOG.info( "Component created: {} (using {})", getClass(), exposedComponentOne );

		this.exposedComponentOne = exposedComponentOne;
	}
}

Your project structure should look like:

com.example.demo/
  modules/
    one/
      ModuleOne
      InternalComponentOne
      ExposedComponentOne
    two/
      ModuleTwo
      InternalComponentTwo
  DemoApplication

All tests should be green.

Verifying exposed beans

Our integration test for ModuleTwo indirectly tests that ModuleOne exposes the correct component. Often you also want to test in the scope of your module which beans it exposed. Let’s update the module one test accordingly:

src/test/java/test/TestModuleBootstrapScenarios.java
@Test
public void moduleOneShouldBootstrapInIsolation() {
    try (AcrossTestContext context = AcrossTestBuilders.standard(false)
            .modules(new ModuleOne())
            .build()) {
        assertNotNull(context.getBeanOfType(ExposedComponentOne.class));
    }
}

We use the AcrossTestContext to retrieve the exposed bean. If you comment or remove @Exposed on ExposedComponentOne, this test will fail.

As we’ll see in another example below, there are other ways to expose beans.

Module ordering

In a regular Spring application, beans often know which other beans exist even before those other beans have been created. In an Across application this works differently: a bean can only know which beans another module provides once that other module has started. This means that even though ExposedComponentOne is exposed, it is required that ModuleOne is fully bootstrapped before ModuleTwo attempts to retrieve the exposed bean.

It is the correct use of @AcrossDepends that ensures this: ModuleTwo explicitly depends on ModuleOne, which means ModuleOne will be guaranteed to have been started before ModuleTwo. This also means that all components that make up ModuleOne will have been created. This type of ordering is fundamentally different from regular Spring applications, in which it is quite difficult to ensure the creation order of an entire group of beans, without depending on each one of them separately.

This type of reliable bootstrap ordering also means that cyclic dependencies are not possible: having a cyclic dependency between 2 modules (direct or indirect) will not allow your application to start.

Let’s put it to the test.

Non-deterministic bootstrap order

Put the @AcrossDepends(required = "ModuleOne") annotation on ModuleTwo in comments.

Now let’s look at our test method:

src/test/java/test/TestModuleBootstrapScenarios.java
@Test
public void moduleTwoBootstrapsIfOneIsPresent() {
    try (AcrossTestContext ignore = AcrossTestBuilders.standard(false)
            .modules(new ModuleTwo(), new ModuleOne())
            .build()) {
        LOG.trace("Bootstrap successful.");
    }
}

We no longer have defined a dependency between ModuleOne and ModuleTwo, this means they don’t care about each other. In our test configuration example, we register them with .modules(new ModuleTwo(), new ModuleOne()): ModuleTwo is registered before ModuleOne. Since there is no dependency based ordering, the registration order will be kept, causing the test to fail.

Play around with reversing the registration order, in the console output you can clearly see its impact on the bootstrap order:

Test console output excerpt
: AcrossContext: AcrossContext-1 (AcrossContext-1)
: Bootstrapping 3 modules in the following order:
: 1 - ModuleTwo [resources: ModuleTwo]: class com.example.demo.modules.two.ModuleTwo
: 2 - ModuleOne [resources: ModuleOne]: class com.example.demo.modules.one.ModuleOne
: 3 - AcrossContextPostProcessorModule [resources: AcrossContextPostProcessorModule]: class com.foreach.across.core.AcrossContextConfigurationModule
: ---

This illustrates the importance of clearly defining your module dependencies. The reliable implicit ordering resulting from it is one of the foremost features for building modular applications.

Let’s see how that ordering propagates throughout the application. Make sure you have uncommented the @AcrossDepends on ModuleTwo again before continuing.