「〇〇Serviceが見つかりません」というNoSuchBeanDefinitionExceptionに遭遇して、「@Serviceは付けたのになぜ」と首をかしげたことはありませんか。多くの場合、原因はBeanの書き方ではなく置き場所にあります(@Service・@Repository・@Controllerの使い分けは こちらの記事 で解説しています)。

この記事では、別パッケージや別モジュールに置いたBeanがコンポーネントスキャンの対象から外れる仕組みと、scanBasePackages指定・明示的な@ComponentScan・マルチモジュール構成という3つの解決パターンを整理します。

原因は「メインクラスの配下しかスキャンしない」仕様

@SpringBootApplicationは内部で@ComponentScanを暗黙的に含んでいて、スキャン範囲はデフォルトでは付けたクラスのパッケージ以下だけです。

com.example.app
└── Application.java   ← @SpringBootApplicationはここ

com.example.lib
└── PaymentService.java  ← @Serviceを付けてもスキャン対象外

この状態でPaymentServiceをDIしようとすると、次のような例外が出ます。

org.springframework.beans.factory.NoSuchBeanDefinitionException:
No qualifying bean of type 'com.example.lib.PaymentService' available:
expected at least 1 bean which qualifies as autowire candidate.

クラスパス上にクラスがあっても、スキャン範囲に含まれていなければBean定義は作られません(@Componentの役割自体は @Componentって何? で解説しています)。ここで詰まるのは「付け方」ではなく「置き場所」です。

直し方は構成によって変わるので、まず自分がどれに当てはまるか確認しましょう。

  • 単一モジュールでBeanだけ別パッケージに置いている → パターン1かパターン2
  • 設定をメインクラスから切り離したい → パターン2
  • 依存しているライブラリモジュール(build.gradleやpom.xmlでmodule依存を宣言している別モジュール)にBeanがある → パターン3
  • 判断に迷ったら、パッケージ構成図とbuild.gradleのdependencies(またはpom.xmlのmodule依存)を見比べる

パターン1: scanBasePackagesでスキャン範囲を広げる

一番手軽なのは、@SpringBootApplicationにscanBasePackagesを指定する方法です。

@SpringBootApplication(scanBasePackages = {
    "com.example.app",
    "com.example.lib"
})
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

パッケージ名の文字列指定はリファクタリングに追従しないので、型安全にしたいならクラス参照で指定するscanBasePackageClassesも選択肢です。

@SpringBootApplication(scanBasePackageClasses = {
    Application.class,
    PaymentService.class
})
public class Application {
}

広げすぎには注意が必要です。もともと無関係だったBean同士がスキャン範囲に入ったことで互いを参照し合うようになり、循環参照エラーで起動しなくなることがあります。「必要な分だけ足す」感覚で指定しましょう。

パターン2: 明示的な@ComponentScanで制御する

設定をメインクラスに書きたくない場合は、専用の設定クラスに@ComponentScanを分離します(@Configurationの基本的な役割は @Configuration / @Beanって何? で解説しています)。

@Configuration
@ComponentScan(basePackages = "com.example.lib")
public class LibraryScanConfig {
}

@SpringBootApplicationと@ComponentScanは別の型なので、同じクラスに両方書いてもJavaのコンパイル自体は通ります。問題は起動時に起きます。@SpringBootApplicationは@ComponentScanをメタアノテーションとして内包しているため、自前の@ComponentScanを重ねると属性が競合し、起動時にアノテーション設定エラーになることがあります。独自のスキャン設定は別クラスに切り出しましょう。

basePackagesが重なっていても、同じクラスを二重にスキャンする分には実害はありません。注意すべきは、別パッケージの異なるクラスが同じデフォルトBean名を持つケースです。例えばcom.example.app.Configとcom.example.lib.Configのように単純クラス名が重複すると、ConflictingBeanDefinitionExceptionになります。

パターン3: マルチモジュールGradle/Maven構成でのBean登録

ライブラリモジュール側には@SpringBootApplicationが存在しないため、scanBasePackagesで解決できません。呼び出し側でスキャン範囲にライブラリのパッケージを含める必要があります。

// app/build.gradle
dependencies {
    implementation project(':payment-lib')
}
@SpringBootApplication(scanBasePackages = {
    "com.example.app",
    "com.example.paymentlib"
})
public class Application {
}

呼び出し側がライブラリの内部パッケージ名を知る必要があるのが難点です。これを避けたいなら、ライブラリ側に@Configurationクラスを用意して呼び出し側で@Importする方法もあります。

// payment-lib側
@Configuration
public class PaymentLibAutoConfig {
    @Bean
    public PaymentService paymentService() {
        return new PaymentService();
    }
}

呼び出し側は@Import(PaymentLibAutoConfig.class)を付けるだけで済みます。この仕組みをさらに自動化したのがspring.factoriesやAutoConfiguration.importsによる自動構成です。詳しくは Spring Bootの自動構成の仕組みを理解する で解説しています。

よくある落とし穴

@SpringBootTestは設定クラスを指定しない場合、テストクラスのパッケージから親方向に@SpringBootConfiguration(@SpringBootApplicationが内包)を探索します。テストの配置が浅かったり、モジュールごとに別の@SpringBootApplicationがある構成だと、mainとは違う設定を拾ってテストだけBeanが見つからないことがあります。@WebMvcTestなどのスライステストはコンポーネントスキャン自体を行わないため、ライブラリ側のBeanはそもそも対象外です。

また、スキャン範囲を広げた結果、同じ型のBeanが複数見つかりNoUniqueBeanDefinitionExceptionに変わることもあります。こちらは@Primaryや@Qualifierで解決しましょう。

起動時のエラー全般の切り分け方は Spring Bootアプリが起動しない時の原因切り分けと対処法 にまとめています。

まとめ

NoSuchBeanDefinitionExceptionの多くはBeanの書き方ではなくスキャン範囲の問題です。単純な構成ならscanBasePackages、設定を分離したいなら専用クラスでの@ComponentScan、マルチモジュールなら呼び出し側のスキャン範囲調整かライブラリ側の@Import提供を選びましょう。パッケージ構成を図に書き出せば、どのパターンに当てはまるかすぐ判断できます。