In a hexagonal application the code is split into several modules: core/ with the business logic, persistence/ with the database, adapters for HTTP, Kafka and other systems. Someone has to assemble all of this and start it up — that is the job of bootstrap/.
Why you need a separate bootstrap module
Without a dedicated bootstrap module, the entry point ends up living wherever it happens to fit — in core/ or in one of the adapters. That breaks the structure quickly: the module with the business logic starts pulling in Spring Boot, configuration gets smeared across several places, and it becomes unclear what depends on what.
bootstrap/ is the composition root: the place where the application is assembled. Its role is strictly limited:
- declare the entry point (
main); - assemble the Spring context from all modules;
- own the configuration files (
application.yml); - hold the
Dockerfileand infrastructure scripts.
No business logic, no controllers — assembly only.
What lives in bootstrap/
A typical structure:
bootstrap/
├── src/main/java/<pkg>/bootstrap/
│ ├── <App>Application.java # @SpringBootApplication + main()
│ └── config/ # @Configuration classes for wiring
│ ├── ClockConfig.java
│ ├── ObjectMapperConfig.java
│ └── SecurityConfig.java
├── src/main/resources/
│ ├── application.yml # shared config
│ ├── application-local.yml # local profile
│ ├── application-production.yml
│ └── logback-spring.xml
├── Dockerfile
└── docker-compose.yml
A minimal entry point:
@SpringBootApplication
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
The @Configuration classes hold only the beans that need explicit setup:
ClockandUuidProvider— production implementations of interfaces fromcore/;ObjectMapperwith custom modules;RestClientbeans, if they are not assembled inside the adapters.
Most beans come up automatically via component scan — Spring finds the @Component classes in the adapters on its own.
bootstrap/ depends on every module
bootstrap/build.gradle.kts lists all the other modules:
dependencies {
implementation(project(":core"))
implementation(project(":persistence"))
implementation(project(":user-api-in-adapter"))
implementation(project(":admin-api-in-adapter"))
implementation(project(":kafka-in-adapter"))
implementation(project(":sber-out-adapter"))
implementation(project(":sms-out-adapter"))
implementation(project(":kafka-out-adapter"))
// Spring Boot starters — only here
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-jooq")
implementation("org.springframework.boot:spring-boot-starter-security")
runtimeOnly("org.postgresql:postgresql")
}
And at the same time nothing depends on bootstrap/ — core/, persistence/ and the adapters have no project(":bootstrap"). The dependency arrows all point in one direction: bootstrap → core ← adapters. bootstrap/ is the closing node where all the connections terminate.
If core/ or an adapter starts depending on bootstrap/, you get a circular dependency — Gradle will refuse to build the project.
How Spring finds beans from all modules
@SpringBootApplication starts package scanning from the package of its own class. There are three options.
Option 1 — a shared root package. If all modules live under one root, Spring will find every @Component class on its own:
package ru.example.order; // root package
@SpringBootApplication
public class OrderServiceApplication { /* ... */ }
This works as long as all modules use packages under ru.example.order.*.
Option 2 — an explicit list of packages. When the package structure does not allow a shared root:
@SpringBootApplication(scanBasePackages = {
"ru.example.order.core",
"ru.example.order.persistence",
"ru.example.order.userapi",
"ru.example.order.sberout",
})
public class OrderServiceApplication { /* ... */ }
Option 3 — explicit configuration import. Each adapter exports its own @Configuration class, and bootstrap imports it:
@SpringBootApplication
@Import({PersistenceConfig.class, SberOutAdapterConfig.class, UserApiInAdapterConfig.class})
public class OrderServiceApplication { /* ... */ }
This option is cleaner in terms of explicit contracts between modules, but it takes more code. In practice option 1 or 2 is used more often.
Profiles and application.yml
All configuration profiles live in bootstrap/src/main/resources/. core/ and the adapters neither see nor control them.
# application.yml — shared across all profiles
spring:
application:
name: order-service
# application-local.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/orders
username: orders
password: orders
sber:
api-url: https://sber-sandbox.example.com
# application-production.yml
spring:
datasource:
url: ${DB_URL}
username: ${DB_USER}
password: ${DB_PASSWORD}
sber:
api-url: ${SBER_API_URL}
Splitting configuration into profiles lets you run the application locally without touching the main config — just pass -Dspring.profiles.active=local.
Common mistakes
Controllers and logic in bootstrap
The temptation to quickly add a handler right in bootstrap is understandable, but it wrecks the architecture:
// Wrong — a controller in bootstrap
package ru.example.order.bootstrap;
@RestController
public class OrderController {
@PostMapping("/orders")
public OrderJson createOrder(@RequestBody CreateOrderRequest req) { ... }
}
The problem is that bootstrap/ should stay thin — assembly only. As soon as logic shows up there:
- it is hard to move into the right module without refactoring;
- a test for such a controller is forced to spin up the whole context, when a lightweight
@WebMvcTestwould have been enough.
The rule is simple: controllers go in *-in-adapter/, business logic in core/, and bootstrap/ does assembly only.
@SpringBootApplication in the wrong place
If @SpringBootApplication ends up in core/ or in an adapter, concrete problems arise:
// Wrong — @SpringBootApplication in core
package ru.example.order.core;
@SpringBootApplication
public class CoreApplication { /* ... */ }
First, core/ starts pulling in the entire Spring Boot infrastructure — you lose the ability to use the core without the framework. Second, when the project has two @SpringBootApplication classes (in core/ and in bootstrap/), it is unclear which one to run. Third, both classes start package scanning — they can conflict.
There should be exactly one @SpringBootApplication, strictly in bootstrap/.
In short
bootstrap/is the composition root: entry point, Spring context,application.yml,Dockerfile.bootstrap/depends on every other module; nothing depends onbootstrap/.- Spring beans from the adapters are picked up via component scan or an explicit
@Import. - All profiles (
local,production) are stored inbootstrap/src/main/resources/. - Controllers and business logic in
bootstrap/are a mistake: the module stops being thin and becomes hard to test. - There is exactly one
@SpringBootApplication, only inbootstrap/.
Further reading
- Module structure in Hexagonal — how core, adapters and bootstrap relate to each other.
- Architecture tests — how to verify the correctness of dependencies between modules automatically.
- Spring DI/IoC and the bean lifecycle — how Spring creates and wires objects inside the context.