← Back to the section

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 Dockerfile and 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:

  • Clock and UuidProvider — production implementations of interfaces from core/;
  • ObjectMapper with custom modules;
  • RestClient beans, 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 @WebMvcTest would 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 on bootstrap/.
  • Spring beans from the adapters are picked up via component scan or an explicit @Import.
  • All profiles (local, production) are stored in bootstrap/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 in bootstrap/.

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.