Skip to content

Cross-/multi-scope bean wiring #793

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
cbarlin opened this issue Apr 2, 2025 · 31 comments · Fixed by #800
Closed

Cross-/multi-scope bean wiring #793

cbarlin opened this issue Apr 2, 2025 · 31 comments · Fixed by #800
Assignees
Labels
enhancement New feature or request
Milestone

Comments

@cbarlin
Copy link
Contributor

cbarlin commented Apr 2, 2025

Hi there! I'm experimenting with avaje-inject version 11.4 and exploring ways to work with @Scopes in order to make a mono-repo with multiple deployments, which share a lot of common code by virtue of sharing a common core and because it was previously a monolith.

Ideally, there'd be an easy way to:

  • Annotate each component with a small number of annotations, which defines which deployments it's applicable to
  • Easily start the application with a small "main" method

I've tried a few things, but have gotten stuck on these paths (code examples+more detail in the expandables):

Declare scopes requiring other scopes, automatically including them

Using the following structure:

@Scope
@InjectModule(provides = {BeanInModA.class}, strictWiring = true)
public @interface ModAScope {}

@ModAScope
public class BeanInModA{}

@Scope
@InjectModule(provides = {BeanInModB.class}, strictWiring = true)
public @interface ModBScope {}

@ModBScope
public class BeanInModB{}

@Scope
@InjectModule(requires = {ModAScope.class, ModBScope.class}, strictWiring = true)
public @interface CrossCutScope {}

@CrossCutScope
public class BeanCross{
    public BeanCross(final BeanInModA beanInModA, final BeanInModB beanInModB) {

    }
}

The result means you cannot just refer to the CrossCutModule when starting an entire BeanScope - you have to declare parent scopes (which is documented), or start the BeanScope with all modules declared in order. You can't pass them to BeanScope.builder().modules(...) out of order (e.g. ModAScope, CrossCutScope, ModBScope) as you'll end up with exception at startup ("java.lang.IllegalStateException: Injecting null for com.example.BeanInModB name:!beanInModB ...").

This is fine if you have a small number of scopes with a clear parent/child hierarchy, but if you have a large number of scopes/modules, or there's not a clear relationship between them, it becomes quite unmanageable.


Meta-annotating defined Scopes

Using the following structure:

@Scope
@Target(TYPE)
@Retention(SOURCE)
@InjectModule(strictWiring = true)
public @interface ModAScope {}

@Scope
@Target(TYPE)
@Retention(SOURCE)
@InjectModule(strictWiring = true)
public @interface ModBScope {}

@ModAScope
@ModBScope
@Target(TYPE)
@Retention(SOURCE)
public @interface MetaScope {}

@MetaScope
public class MyBean {}

I hoped it would result in the same output as:

@ModAScope
@ModBScope
public class MyBean {}

So the bean exists in both ModAScope and ModBScope, but instead it's not picked up by Avaje at all.


While @Profile would probably do what I need (and I can meta-annotate those), that's less easily shaded (in the sense of dropping unreferenced classes) and doesn't give compile time errors. While neither of those cons are deal-breakers (Avaje is fast and light enough from my experiments you can just check the profiles work in tests, and there's negligible runtime overhead to non-constructed beans) I'm surprised that neither of my scope attempts worked (and at the BeanScope.builder().modules(...) being order-dependent). Another option would be to have many Gradle/Maven sub-projects and rely on the default scope, but that would also become quite unwieldy depending on the size of the code base and how much you wish to split it.

Is there any scope/intention to support something like this with @Scope/@InjectModule? Ideas on making something like this work without introducing potentially breaking changes:

  1. Add another value to the InjectModule called something like automaticallyImport which acts like requires for all validation checking, but when the generated module's build method is called the first operations are to create and build the listed modules/scopes
  2. Add an option on the BeanScopeBuilder to correct module order (default false - current behaviour) and/or to define our own ModuleOrdering with custom logic
  3. Allow Scope composite annotations. A bean annotated with a meta-scope would be treated as if it were directly annotated with those scopes, which currently is to be created in each scope.

Option 3 is probably the most elegant for both Avaje and consuming applications. A blend of Options 1 and 2 could also be done outside of Avaje as the details are available at run-time, so someone using Avaje could write a method that takes in a module and it returns an array of modules in the correct order (if an order is possible).

Thanks heaps for taking the time to read!

@SentryMan
Copy link
Collaborator

SentryMan commented Apr 2, 2025

@scopes

I added all the Profile and conditional annotations precisely because I found custom scopes unwieldy. @rbygrave is the guy to ask about custom scopes.

Another option would be to have many Gradle/Maven sub-projects and rely on the default scope

Other than conditional annotations, this is the option I see most people go with.

define our own ModuleOrdering with custom logic

You can do this today, though it is an SPI and not a direct argument on the builder.

@SentryMan
Copy link
Collaborator

One question I have is how custom scopes change the shading situation.

@cbarlin
Copy link
Contributor Author

cbarlin commented Apr 2, 2025

I added all the Profile and conditional annotations precisely because I found custom scopes unwieldy...

That's fair - they do appear to be more user friendly. The trade-offs I mentioned really are very minor given how trivial they are to overcome.

You can do this today, though it is an SPI and not a direct argument on the builder.

Oh? If so that's good, although from my quick poke around it did appear the SPI point was the BeanScopeBuilder instead. Now I think on it though that would work to - write a wrapper that delegates everything to the default, and just overload the modules method

One question I have is how custom scopes change the shading situation

I could give a proper example later when I'm not on mobile, but Profile still references the classes that won't be loaded as it wraps them in a conditional vs Scope whose module doesn't reference it at all. If you had a bean PullsInLargeDependencyBean that is only needed in an TalkToMachinaryApp then when shading Profile will (probably) result in the bean (and dependencies) being included vs Scope which wouldn't (assuming your "app" module only loads a relevant scope).

Again, that could be solved with having different gradle/maven projects, but working out where to draw those harder boundaries and how many can be hard on a medium/large project.

@rbygrave
Copy link
Contributor

rbygrave commented Apr 3, 2025

I'd like to understand the problem / context of the issue better.

with multiple deployments

How many different deployments are we talking about and can you give some examples. (All / Admin Only ... ? .. I'm wondering if we thinking the same thing here with "deployments" split by functionality?)

less easily shaded

Why do we care about shading so much? Are we building using graalvm native-image or is it just truely large or something else? [just wondering about the bang for buck and testing etc or am I completely missing something interesting here] [edit: shading isn't great for docker and shading isn't great for java module-info adoption so I'm wary when shading comes up as opposed to excluding optional dependencies but maybe there is a really good need for shading / tree shaking here]

This is fine if you have a small number of scopes with a clear parent/child hierarchy, but if you have a large number of scopes/modules, or there's not a clear relationship between them, it becomes quite unmanageable.

How many scopes do you have in mind? Can you put some examples of what you think these scopes are and how they are related?

An example of how bean scopes are used is in a build system which supports multiple platforms (Windows, Linux, Mac). So there are a few scopes for common build features and a scope for each platform specific one. Lets say 7 scopes with pretty well understood relationships. I'm getting a feeling you are talking a LOT more than 7 scopes? Like how many scopes are we talking about? [which might give me a sense of what we are trying to do. If you say 100 scopes then I'm not understanding the use case ... that is basically a big dependency tree of scopes etc]

it was previously a monolith.

So it sounds like its pretty big and maybe not broken into multiple maven modules yet, its still one src/main? No existing modularity in the code base? No intention to add modularity in the form of splitting it into maven modules yet?

@rbygrave
Copy link
Contributor

rbygrave commented Apr 3, 2025

@ModAScope
@ModBScope
public class MyBean {}

So the bean exists in both ModAScope and ModBScope ...

You are going to have to give a practical example of this so that I can get a sense of what you are trying to achieve here / why a bean "exists" in both ModAScope and ModBScope?

The issue is that "exists" is actually more "owned, created in" and a bean in this sense should be created and owned by only one scope. So what were we trying to achieve with a bean in multiple scopes? Can you put a real world example there so that I can understand what we were aiming for?

@rbygrave
Copy link
Contributor

rbygrave commented Apr 3, 2025

PullsInLargeDependencyBean

Just to say that for me it's more PullsInLargeDependencyBean attached to an expensive resource like additional db connection pool, queue, external resource, expensive UI canvas etc ... its really the expensive resource part we are looking to avoid initialisation on, and so Profile and optional dependencies can work well enough.

Scope has been used when the final deployment artifact literally only includes certain scopes - Linux, Windows, MacOS.

I think I'm keen to here if this application is a server app or desktop app or build system or ... so that might help understand the goals we are aiming for and better choose Scope vs Profile vs [optional] AvajeModule etc.

@cbarlin
Copy link
Contributor Author

cbarlin commented Apr 3, 2025

Can you put a real world example there so that I can understand what we were aiming for?

Yep! I'll walk through the journey and answer the other questions after that :D

So it sounds like its pretty big and maybe not broken into multiple maven modules yet, its still one src/main? No existing modularity in the code base? No intention to add modularity in the form of splitting it into maven modules yet?

A server app all in one src/main yes, no clear modularity in code, but the tasks it performs can be broken up semi-cleanly which hopefully will become k8s deployments with APIs, or jobs/cronjobs. The number of deployments is a little hazy as it depends on how things "fall out", but is around 12-15.

After getting a consistent DI into the project, plan is to try and move to a structure that looks like this:

project/
├─ app_warehouse_mgmt_sea/
│  ├─ src/main/java/.../App.java
├─ app_warehouse_mgmt_air/
│  ├─ src/main/java/.../App.java
├─ app_analytics_api/
│  ├─ src/main/java/.../App.java
├─ job_analytics_processor/
│  ├─ src/main/java/.../App.java
... repeat for the rest of the apps/jobs
├─ was_monolith/
│  ├─ src/main/java/
│  │  ├─ <all the code is here>
├─ components/
│  ├─ <nothing here ... yet!>

Each App.java starts up the bean scope, and either exposes the API or does its job. The API ones will all start out being the same (i.e. the monolith minus jobs deployed a bunch of times). After that would then be to start trying to break apart the was_monolith so that each app only loads beans it needs either going top-down or bottom-up [edit: or both, although probably only surface level on one side]. Once that's done, start trying to group the beans up and lift them out into their own projects under components or into the app/job project itself (and maybe eventually start breaking it into independent projects). The goal of doing it in passes is to de-risk the overall process.

You can kinda see it above, but the warehousing items above would be typical candidates for Profile (warehouses near the sea vs near airports), whereas the warehouse vs analytics would probably be more akin to Scope. Both could be used, and probably will be at the end, but I'd like to keep the number of metaphorical balls in the air down (where reasonable). A Scope (being a module) could also be used as a prospective component boundary as it isolates the beans within it from those outside of it (while the code lives in the same compilation unit).

So the journey I went on with exploration is what I (aimed, a little poorly) to list in my original post:

  1. Try a top-down approach - use Profile as a meta-annotation that denotes which deployment a bean should end up in. Think @WarehouseMgmtSea = @Profile(all = {"warehouse_mgmt", "sea"}). Pros are it's pretty flexible and easy to mix or rework, cons are it's possibly too flexible and prone to simple mistakes [edit: at scale it'd be prone, small behavioural differences would be fine], and it doesn't convey potential module boundaries
  2. Try a bottom-up approach - can I create InjectModules items that would be candidates to be components? No, end up with errors about multiple modules in the default scope, which makes sense to me.
  3. Try a bottom-up approach - can I create Scope items that would be candidates to be components (e.g. database init, validating incoming inventory manifests)? First define the scope and tag the beans wherever they are, then try and refactor the beans into a single package so when we start lifing it should be easy. Pros are its concrete in that it doesn't rely on string matching, it makes it clear what could be an actual module boundary, and compile checks over test checks, cons are loading all the needed scopes in the App.java could be frustrating
  4. Try a top-down approach with Scope instead - Scope generates a module that lists all the beans right there, so have each app only load the one Scope (i.e. Scope = deployment). Pros are it's definitive and very clear what is or isn't in an app, cons are that you end up with lots of annotations on a single bean and it doesn't easily convey potential module boundaries

I'm still OK with the trade-off of Profile being less "concrete" than Scope because tests will pick up if a bean isn't enabled in a profile it should be, and beans being enabled in profiles they don't need to be isn't as much of an issue if nothing external can interact with it/it doesn't produce external effects. If the outcome of this issue is that no change will be made to Avaje that's totally fine - there is a totally workable path with Profile and I'm more exploring if there is a better path. The point about potentially using the SPI to load in a different ModuleOrdering (or BeanScopeBuilder) also gives a path forward there.

Addressing some of the other questions:

Why do we care about shading so much? Are we building using graalvm native-image or...

Shading would be useful for two purposes:

  • Working out what dependencies are actually used and where
  • Vulnerabilities in dependencies only flag on the apps they are actually used in, making reasoning with them (patch, assert that there's no path to exploit, etc) much easier to do

I agree that modules (both gradle/maven and Java) being the better solution for dependency tree management overall, or tools like jib for docker containers if you'd rather avoid modules. Re graalvm: Doesn't it do tree-shaking automatically?

shading isn't great for java module-info adoption

Huh, never thought of that but I guess it makes sense. Why should e.g. guava modularise when shading can do the job, I suppose (although if java had multi-module jar files...)

How many scopes do you have in mind? Can you put some examples of what you think these scopes are and how they are related?

That depends on if a top-down (Scope = deployed artifact) or bottom-up (Scope = eventual component) approach is taken. The latter will end up with a lot more Scopes than the former, although it comes down to how aggressively it's broken up and if a path of break a few scopes out then lift them out, break a few more then lift, rinse/repeat.

The issue is that "exists" is actually more "owned, created in" and a bean in this sense should be created and owned by only one scope

For this example I was talking about the Scope = deployed artifact path I was exploring. At runtime, the bean is only owned/created in by only one scope because only one scope is loaded. I noticed at compile time though, the bean creation/ownership is written into multiple Scopes (and it sounds like that maybe shouldn't be the case?). The meta-annotation idea was extending the existing current behaviour (hopefully causing the least impact).

Just to say that for me it's more PullsInLargeDependencyBean attached to an expensive resource...

Completely reasonable - why load an expensive resource when it doesn't need to be? Profile definitely fills this role nicely.

@cbarlin
Copy link
Contributor Author

cbarlin commented Apr 3, 2025

For this example I was talking about the Scope = deployed artifact path I was exploring. At runtime, the bean is only owned...

To clarify this a bit more: In both the Scope = deployed artifact path and the Scope = potential component path, a bean at runtime would/should be created by and owned/managed by only one scope (or not loaded at all). For the deployment path, this is because only one scope is loaded, and for the component path this is because it'd be in one scope then requires (or automaticallyImport) to the others.

The difference was in the source code (and by extension at compile time) how many Scopes a bean belongs to.

@rbygrave
Copy link
Contributor

rbygrave commented Apr 3, 2025

Ok, so if I understand it right then, say in app_warehouse_mgmt_sea/ there is the App but there isn't much else at all. This is really just code to bootstrap the "warehouse_mgmt_sea App", and 99% of the code is in ... was_monolith / src/main/java.

Rob background thinking ...

  • Naturally a [default scope] AvajeModule maps 1 to 1 to a maven module - a src/main/java
  • We like to use custom Scope when we have a lot of components inside a single src/main/java and we wish to bring some DI modularity there

deployments ... "fall out", but is around 12-15.

Right. So at a very minimum we are talking 15 odd Scope ... and it's just basically pretty darn big. Its pretty likely that each of those 15 deployments could well desire a bunch of sub-scopes to vertically or horizontally slice their components.

Right, I think I'm getting it. My gut is saying that custom Scope could well be pretty close to a good fit conceptually. Problems with it like say ordering are related to the shear scale we've got here (to date it's only been used at much smaller scale, say less that 10 custom scopes).

@rbygrave
Copy link
Contributor

rbygrave commented Apr 3, 2025

  1. Try a bottom-up approach ... cons are loading all the needed scopes in the App.java could be frustrating

The result means you cannot just refer to the CrossCutModule when starting an entire BeanScope ... You can't pass them to BeanScope.builder().modules(...) out of order

Right, at scale yes this isn't practical to being done manually. This seems like something that is fixable. We have the custom scopes, and their requires/provides so it could be that ordering for the custom scopes could be generated at compile time or determined at runtime.

e.g. We want to build a BeanScope with CrossCutScope. It's dependencies and their ordering can be determined by avaje-inject at runtime or compile time.

A failing test here could be, using BeanScope Builder pass the CrossCutScope module only, and the builder automatically determines the dependencies (ModAScope, ModBScope) and the ordering based on requires/provides and that "just works".

  1. Try a top-down approach with ... cons are that you end up with lots of annotations on a single bean

I'm not sure here and need to look at what's going on. The single bean in multiple scopes wasn't aligned to my thinking of a bean only being in a single Scope. Not sure here and need to check this.

@cbarlin
Copy link
Contributor Author

cbarlin commented Apr 3, 2025

Ok, so if I understand it right then, say in app_warehouse_mgmt_sea/ there is the App but there isn't much else at all.

That's the goal for now, yes. After all the slicing is done, it's possible that there will instead be an app_warehouse_mgmt with a sea and air Profile, but that's long-term. Modularity is what we want for now.

My gut is saying that custom Scope could well be pretty close to a good fit conceptually.

It did start to seem like that while I was experimenting, so I'm glad that you also think so 😄

Right, at scale yes this isn't practical to being done manually. This seems like something that is fixable. We have the custom scopes, and their requires/provides so it could be that ordering for the custom scopes could be generated at compile time or determined at runtime.

Yes, with a slight preference for compile time ordering as it has all the context and can probably better handle situations in which the dependency graph isn't strictly linear. For example, a manifest-validator Scope which requires both a manifest-database and core-database, and manifest-database also requires core-database, compile-time analysis would be better suited to ensuring that core-database is loaded before the other two (or fail the compilation if the graph isn't complete).

To avoid breaking existing behaviour while keeping resolution done at compile time I proposed the automaticallyImport, so:

  • requires = beans provided in that scope/module are available in this one, but I will do the wiring (current behaviour)
  • automaticallyImport = beans provided in that scope/module are available in this one, and I want the wiring done for me

And if compile-time isn't the place to do this, to avoid breaking existing behaviour for runtime I proposed a BeanScopeBuilder option to turn on the resolver.

That's my outside looking in perspective - you guys might have better ideas knowing other projects/Avaje better than I do.

A failing test here could be, using BeanScope Builder pass the CrossCutScope module only, and the builder automatically determines the dependencies (ModAScope, ModBScope) and the ordering based on requires/provides and that "just works".

Yep, although I think that's the passing case - the failure would be that it didn't work it out and errors out at runtime/in tests instead.

The single bean in multiple scopes wasn't aligned to my thinking of a bean only being in a single Scope

Which is fair - a bean being in multiple scopes is a bit odd. At runtime it would only exist in one, but the compiler has no way of knowing that. It does seem like the bottom-up approach where Scope = component is more promising for this case though.

Thanks heaps for the help so far to both of you!

@SentryMan
Copy link
Collaborator

Profile still references the classes that won't be loaded

Perchance can you elaborate on this?

@cbarlin
Copy link
Contributor Author

cbarlin commented Apr 5, 2025

Perchance can you elaborate on this?

Sure! Using version 11.4 still for these examples.

Let's say that the following is in the was_monolith module:

public interface IncomingContainerPlacementPlanner {}

@Singleton
@Profile(all = {"warehouse", "air"})
public class AirInConPlanner implements IncomingContainerPlacementPlanner {}

@Singleton
@Profile(all = {"warehouse", "sea"})
public class SeaInConPlanner implements IncomingContainerPlacementPlanner {}

// In package-info.java
@io.avaje.inject.InjectModule(provides = com.example.profile.IncomingContainerPlacementPlanner.class)
package com.example.profile;

The following is generated (in collapsible):

Generated code

AirInConPlanner$DI.java

@Generated("io.avaje.inject.generator")
public final class AirInConPlanner$DI  {

  public static void build(Builder builder) {
    if (!builder.containsAllProfiles(List.of("air","warehouse"))) {
      return;
    }

    if (builder.isBeanAbsent(AirInConPlanner.class, IncomingContainerPlacementPlanner.class)) {
      var bean = new AirInConPlanner();
      builder.register(bean);
    }
  }

}

SeaInConPlanner$DI.java

@Generated("io.avaje.inject.generator")
public final class SeaInConPlanner$DI  {

  public static void build(Builder builder) {
    if (!builder.containsAllProfiles(List.of("warehouse","sea"))) {
      return;
    }

    if (builder.isBeanAbsent(SeaInConPlanner.class, IncomingContainerPlacementPlanner.class)) {
      var bean = new SeaInConPlanner();
      builder.register(bean);
    }
  }

}

ProfileModule.java

@Generated("io.avaje.inject.generator")
@InjectModule(provides = {com.example.profile.IncomingContainerPlacementPlanner.class})
public final class ProfileModule implements AvajeModule {

  @Override
  public Type[] provides() {
    return new Type[] {
      com.example.profile.IncomingContainerPlacementPlanner.class,
    };
  }

  @Override
  public Type[] autoProvides() {
    return new Type[] {
      com.example.profile.AirInConPlanner.class,
      com.example.profile.SeaInConPlanner.class,
    };
  }

  @Override
  public Class<?>[] classes() {
    return new Class<?>[] {
      com.example.profile.AirInConPlanner.class,
      com.example.profile.SeaInConPlanner.class,
    };
  }

  /**
   * Creates all the beans in order based on constructor dependencies.
   * The beans are registered into the builder along with callbacks for
   * field/method injection, and lifecycle support.
   */
  @Override
  public void build(Builder builder) {
    // create beans in order based on constructor dependencies
    // i.e. "provides" followed by "dependsOn"
    build_profile_AirInConPlanner(builder);
    build_profile_SeaInConPlanner(builder);
  }

  @DependencyMeta(
      type = "com.example.profile.AirInConPlanner",
      provides = {"com.example.profile.IncomingContainerPlacementPlanner"},
      autoProvides = {
        "com.example.profile.IncomingContainerPlacementPlanner",
        "com.example.profile.AirInConPlanner"
      })
  private void build_profile_AirInConPlanner(Builder builder) {
    AirInConPlanner$DI.build(builder);
  }

  @DependencyMeta(
      type = "com.example.profile.SeaInConPlanner",
      provides = {"com.example.profile.IncomingContainerPlacementPlanner"},
      autoProvides = {
        "com.example.profile.IncomingContainerPlacementPlanner",
        "com.example.profile.SeaInConPlanner"
      })
  private void build_profile_SeaInConPlanner(Builder builder) {
    SeaInConPlanner$DI.build(builder);
  }

}

If we then have an App.java in our app_warehouse_sea:

public class App 
{
    public static void main( String[] args )
    {
        try(
            BeanScope cross = BeanScope.builder()
                .profiles("warehouse", "sea")
                .build();
        ) {
            // Do all the things
        }
    }
}

When you shade the output with an Service Loader aware plugin, it will:

  • BeanScopeBuilder is referenced - include that
  • We have the Service Loader plugin enabled, so it detects the app uses SPI to load all AvajeModule, include all those
  • ProfileModule references both SeaInConnPlanner and AirInConnPlanner and their $DI classes, so include those and those they reference

Both beans (and their dependencies) will be included in the output unless the shader has some way of knowing that the result of !builder.containsAllProfiles(List.of("air","warehouse")) is always false. There may be a way to do that, I'm not sure, but as far as I know that's not possible.

Compare that to the following layout in the was_monolith module:

@Scope
public @interface AirAppScope {}

@Scope
public @interface SeaAppScope {}

public interface IncomingContainerPlacementPlanner {}

@AirAppScope
public class AirInConPlanner implements IncomingContainerPlacementPlanner {}

@SeaAppScope
public class SeaInConPlanner implements IncomingContainerPlacementPlanner {}

The following is the generated output:

Generated code

AirInConPlanner$DI.java

@Generated("io.avaje.inject.generator")
public final class AirInConPlanner$DI  {

  public static void build(Builder builder) {
    if (builder.isBeanAbsent(AirInConPlanner.class, IncomingContainerPlacementPlanner.class)) {
      var bean = new AirInConPlanner();
      builder.register(bean);
    }
  }

}

SeaInConPlanner$DI.java

@Generated("io.avaje.inject.generator")
public final class SeaInConPlanner$DI  {

  public static void build(Builder builder) {
    if (builder.isBeanAbsent(SeaInConPlanner.class, IncomingContainerPlacementPlanner.class)) {
      var bean = new SeaInConPlanner();
      builder.register(bean);
    }
  }

}

AirAppModule.java

@Generated("io.avaje.inject.generator")
@InjectModule(customScopeType = "com.example.profile.AirAppScope")
public final class AirAppModule implements AvajeModule.Custom {

  @Override
  public Type[] autoProvides() {
    return new Type[] {
      com.example.profile.AirInConPlanner.class,
      com.example.profile.IncomingContainerPlacementPlanner.class,
    };
  }

  @Override
  public Class<?>[] classes() {
    return new Class<?>[] {
      com.example.profile.AirInConPlanner.class,
    };
  }

  /**
   * Creates all the beans in order based on constructor dependencies.
   * The beans are registered into the builder along with callbacks for
   * field/method injection, and lifecycle support.
   */
  @Override
  public void build(Builder builder) {
    // create beans in order based on constructor dependencies
    // i.e. "provides" followed by "dependsOn"
    build_profile_AirInConPlanner(builder);
  }

  @DependencyMeta(
      type = "com.example.profile.AirInConPlanner",
      provides = {"com.example.profile.IncomingContainerPlacementPlanner"},
      autoProvides = {
        "com.example.profile.IncomingContainerPlacementPlanner",
        "com.example.profile.AirInConPlanner"
      })
  private void build_profile_AirInConPlanner(Builder builder) {
    AirInConPlanner$DI.build(builder);
  }

}

SeaAppModule.java

@Generated("io.avaje.inject.generator")
@InjectModule(customScopeType = "com.example.profile.SeaAppScope")
public final class SeaAppModule implements AvajeModule.Custom {

  @Override
  public Type[] autoProvides() {
    return new Type[] {
      com.example.profile.IncomingContainerPlacementPlanner.class,
      com.example.profile.SeaInConPlanner.class,
    };
  }

  @Override
  public Class<?>[] classes() {
    return new Class<?>[] {
      com.example.profile.SeaInConPlanner.class,
    };
  }

  /**
   * Creates all the beans in order based on constructor dependencies.
   * The beans are registered into the builder along with callbacks for
   * field/method injection, and lifecycle support.
   */
  @Override
  public void build(Builder builder) {
    // create beans in order based on constructor dependencies
    // i.e. "provides" followed by "dependsOn"
    build_profile_SeaInConPlanner(builder);
  }

  @DependencyMeta(
      type = "com.example.profile.SeaInConPlanner",
      provides = {"com.example.profile.IncomingContainerPlacementPlanner"},
      autoProvides = {
        "com.example.profile.IncomingContainerPlacementPlanner",
        "com.example.profile.SeaInConPlanner"
      })
  private void build_profile_SeaInConPlanner(Builder builder) {
    SeaInConPlanner$DI.build(builder);
  }

}

If we then have an App.java in our app_warehouse_sea:

public class App 
{
    public static void main( String[] args )
    {
        try(
            BeanScope cross = BeanScope.builder()
                .modules(new SeaAppModule())
                .build();
        ) {
            // Do all the things
        }
    }
}

When you attempt to shade the output, it will:

  • BeanScopeBuilder is referenced - include that
  • SeaAppModule is referenced - include that
  • SeaAppModule references SeaInConnPlanner and its $DI generated class, include them

The main difference is the way the Module files are generated - Profile creates a single module that is picked up by the Service Loader that references both and uses a conditional to pick beans, vs Scope which creates two modules and then we explicitly reference one class. Profile definitely does work to prevent starting the beans (and thus anything they connect to like databases or message queues or machine bus interfaces), so the runtime/startup cost is still successfully massively reduced, which is the primary concern. It also works for cases where we need to specify behaviour for certain situations. It's just that shading (probably) won't remove the unneeded classes/dependencies.

I haven't yet put enough together to ascertain if shading still needs to be SPI aware in this case (it might do - a lot of things use Service Loaders, not just avaje-inject). But because AirAppModule isn't referenced, it's not include and neither is AirInConPlanner and its dependencies etc...

You could probably exclude the class AirInConnPlanner manually in the shading configuration of the warehouse_sea module in this example. If you did need/want Service loader support though I imagine that manually excluding AirAppModule would be easier than listing all the specific classes to exclude (because it could be a lot of classes, at least in my case).

Does that help?

@SentryMan
Copy link
Collaborator

SentryMan commented Apr 5, 2025

If you use shading to remove the unused third-party dependencies but keep your own classes, is there an issue? As the execution paths are gated behind a conditional check I would imagine you wouldn't get any NoClassDefFound errors.

@cbarlin
Copy link
Contributor Author

cbarlin commented Apr 5, 2025

Oh you mean like exclude the transitive dependencies a given app doesn't need? That should work yes - thank you for that idea! It'd be manual, but that probably forces thinking about how those dependencies are used and if they are even needed to begin with. Number of dependencies might still pose an issue, but it's a workable way forward for that path.

It wouldn't work if you wanted to shade your own classes though - not sure there's too many cases where that would be a strongly desired goal though.

@cbarlin
Copy link
Contributor Author

cbarlin commented Apr 10, 2025

I did have some thoughts about a way to potentially implement the automaticallyImport idea too. I'm not 100% certain it's the best approach - I've only poked about the avaje-inject code, not worked with it. Seeing the new test module did prompt me to write those thoughts down though, in case they end up being helpful during design (if the idea is implemented, that is).

Implementation idea

Add the automaticallyImport value to the InjectModule annotation. Valid targets are AvajeModule classes, or interfaces annotated with @Scope. Target modules/imports cannot have external requires unless the current module can provide them (via automaticallyImporting the required item, or by requiring the item itself). Circular dependencies are prohibited at compile time.

Add two more extensions to the AvajeModule interface - a ModuleTreeAware which directly extends the AvajeModule, and a CompleteModuleTree which extends ModuleTreeAware.

ModuleTreeAware adds an additional method automaticallyImportedModules which returns an ordered AvajeModule[] (default empty).

When generating an AvajeModule:

  • If the InjectModule has any automaticallyImport items, it is eligble to become a ModuleTreeAware module. Scopes are translated to their modules, and then they get written into the generated AvajeModule[] array.
  • If ModuleA says to auto import ModuleB and ModuleC, and ModuleB says to auto import ModuleC, then ModuleA only needs to include ModuleB in the output.
  • If the InjectModule has no requires itself and has strictWiring set to true, then it becomes a CompleteModuleTree module
    • This is even if it lists automaticallyImport as it knows how to wire them
    • If the module would require items from other modules that aren't listed, then it's not a ModuleTreeAware module (as in, automatically created AvajeModules created as they are right now are unchanged)

When loading AvajeModules if a module is ModuleTreeAware, extract its automaticallyImportedModules and insert those modules into the load order before the current one. Repeat for those discovered modules. If a module is attempted to be loaded again, ignore the duplicate (although ensure that load-order is correct).

Once all modules to be loaded are found, if all the modules in the list to be built are CompleteModuleTree modules, then we can skip loading the default scope/re-sorting/etc as the module tree is already complete.

This covers the request to have scopes depend on other scopes, and also has the nice benefit that if the entire module tree was known at compile time each step of the way then less work needs to be done at runtime (although the wiring is already very very fast, and it's probably not the slow point at startup!).

Also: is the expectation here that I would implement this feature, or am I fine to leave it for someone else to assign themselves to it? Considering it's making some not-so-small changes I'm not sure a "first contribution" is entirely appropriate...

@SentryMan
Copy link
Collaborator

If you think it's taking too long you can try to implement. That's how I got started. I'm not really a scopes guy so I was assuming Rob was working on it.

If you create a PR with a failing test I could try and take a look.

@cbarlin
Copy link
Contributor Author

cbarlin commented Apr 11, 2025

If you think it's taking too long you can ...

Wasn't aiming to be impatient sorry! More just checking expectations

... was assuming Rob was working on it.

I'm happy to do that too (or you, if you wanted to work on it)

If you create a PR with a failing test

If that would be helpful, I can do that! I might make two - one building off of Rob's existing test (testing module ordering when they are all defined at runtime manually), and one with the automaticallyImport idea. I'll get started on those shortly

@rbygrave
Copy link
Contributor

implement the automaticallyImport idea too

That might be a thing but for me right now its actually just down to determining the module ordering of the required modules and generating code based on that.

So the guts is that to use the CrossCutModule ... we actually need the required modules AND ordering of those so:

      .modules(new ModAModule(), new ModCModule(), new ModBModule(),  new CrossCutModule())

This could be just done by adding a helper method onto the generated CrossCutModule like:

@Generated("io.avaje.inject.generator")
@InjectModule(requires = {org.multi.scope.ModAScope.class,org.multi.scope.ModBScope.class}, customScopeType = "org.multi.scope.CrossCutScope")
public final class CrossCutModule implements AvajeModule.Custom {

  // new method ... return all required modules in correct order
  public static List<AvajeModule> allRequiredModules() {
    return List.of(new ModAModule(), new ModCModule(), new ModBModule(), new CrossCutModule());
  }
  ...

And then we could use it like:

return BeanScope.builder()
      .modules(CrossCutModule.allRequiredModules())
      .build();

Is that not what we need here? Or have I missed something and there is more required?

If thats the case then I think this becomes:
We already have the FactoryOrder class that can take a list of ModuleData. I think this is a case of starting from CrossCutScope, get its required list, continue recursively getting the required scopes ... create a ModuleData for each custom module with the required properties, give those all to FactoryOrder to order them, take that ordered list and generate the new allRequiredModules() method.

@SentryMan
Copy link
Collaborator

public final class CrossCutModule implements AvajeModule.Custom {

// new method ... return all required modules in correct order
public static List allRequiredModules() {
return List.of(new ModAModule(), new ModCModule(), new ModBModule(), new CrossCutModule());
}

@cbarlin do you want this too?

@cbarlin
Copy link
Contributor Author

cbarlin commented Apr 11, 2025

This could be just done by adding a helper method onto the generated CrossCutModule like:
...
And then we could use it like:

return BeanScope.builder()
      .modules(CrossCutModule.allRequiredModules())
      .build();

Is that not what we need here? Or have I missed something and there is more required?

Yes! If you are happy for it to be done that way, then that definitely works for me. I was only suggesting automaticallyImport to avoid changing existing behaviour, but this also does that without adding a new setting in the annotation.

do you want this too?

Yes please!

@SentryMan
Copy link
Collaborator

SentryMan commented Apr 11, 2025

adding a helper method

sounds cool, but I feel like this might complicate things.

  1. How can we determine the module class names from just the annotation?
  2. The same as above, but for custom scopes that require scopes from external dependencies/maven modules

@SentryMan
Copy link
Collaborator

SentryMan commented Apr 11, 2025

I can figure out the first item as long as it's in the current compilation unit, but the second stumps me.

@SentryMan SentryMan self-assigned this Apr 11, 2025
@SentryMan SentryMan added the enhancement New feature or request label Apr 11, 2025
@SentryMan SentryMan added this to the 11.5 milestone Apr 11, 2025
@SentryMan
Copy link
Collaborator

Or perhaps I'm thinking about it wrong, do we want to have the module itself added as a requires instead of the scope annotation?

@SentryMan
Copy link
Collaborator

SentryMan commented Apr 12, 2025

  // new method ... return all required modules in correct order
  public static List<AvajeModule> allRequiredModules() {
    return List.of(new ModAModule(), new ModCModule(), new ModBModule(), new CrossCutModule());
  }

@rbygrave it seems this doesn't work as there are cases where the generated module can have constructor parameters.

tbh, I think what we have in that PR should be good for now

@cbarlin
Copy link
Contributor Author

cbarlin commented Apr 12, 2025

What if it the method took in all the params that were needed for the modules constructor's?

@SentryMan
Copy link
Collaborator

It seems that this is harder than it looks. This is what I got so far.

@cbarlin
Copy link
Contributor Author

cbarlin commented Apr 12, 2025

Sorry! Only made that suggestion since it sounded like a hard requirement for the idea to be implemented.

I don't have a requirement to pass in external dependencies, especially given the implementation above makes things very flexible as you can use scopes and factories to manage difficult things instead. Could you instead not generate the method if there are externally required items, and deal with generating a requiring version if/when someone puts forth a use case for such a thing?

Also thank you so much for doing the digging into making it happen - I very much appreciate it!

@SentryMan
Copy link
Collaborator

SentryMan commented Apr 12, 2025

I'm trying that, but it fails for a different reason: something about the dependencies not being satisfied. Doesn't happen with the annotations approach though.

@rbygrave
Copy link
Contributor

Releasing 11.5-RC2 that contains this change to maven central now. Should be available in central soon to try out etc.

Awesome work @SentryMan

@cbarlin
Copy link
Contributor Author

cbarlin commented Apr 14, 2025

Awesome! Thanks heaps, especially @SentryMan!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants