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.
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 ) );
}
}
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.
@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
.
@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
@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
.
package com.example.demo.modules.one;
@Component
@Exposed
public class ExposedComponentOne implements Supplier<String>
{
@Override
public String get() {
return "hello from module one";
}
}
@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:
@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:
@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:
: 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.
Continue to Using module components.