How to Understand a Spring Boot Project: Beans to Profiles

Spring Boot hides wiring behind annotations and auto-configuration. This guide shows where to look and which Actuator endpoints and flags reveal what is really running.

By The OpenVisio Team · · 11 min read

Spring Boot projects are built to be productive, not to be readable by a newcomer. A class with @Service on it is created by the container without any new in sight. A controller method responds to HTTP because of an annotation. A DataSource exists because a starter dependency is on the classpath and auto-configuration decided you wanted one. A method is transactional because a proxy wraps it. None of that shows up as a call you can follow.

The good news is that Spring knows exactly what it did, and it will tell you if you ask. This guide gives you a reading order for an unfamiliar Spring Boot application: the build file, the main class, component scanning and beans, auto-configuration, the web layer, configuration and profiles, and the proxies that add behavior. At each step it points at the flag or Actuator endpoint that shows the running truth, so you spend less time inferring.

Key takeaways

  • Read the build file first. Starters tell you which auto-configurations are in play before you open any Java.
  • @SpringBootApplication combines @Configuration, @EnableAutoConfiguration and @ComponentScan. Its package sets the scan root.
  • Beans come from stereotype annotations (@Component, @Service, @Repository, @Controller) and from @Bean methods in @Configuration classes.
  • Auto-configuration is conditional. Run with --debug or use /actuator/conditions to see which auto-configurations matched and why.
  • /actuator/beans, /actuator/mappings and /actuator/env give you the bean graph, every endpoint and the resolved configuration of a running app.
  • @Transactional, @Async, @Cacheable and security annotations work through proxies. Self-invocation bypasses them.

Step 1: Read the build file

Open pom.xml (Maven) or build.gradle / build.gradle.kts (Gradle). Note the Spring Boot version from the parent POM or the org.springframework.boot plugin. Spring Boot 3 requires Java 17 and uses jakarta.* packages instead of javax.*, which matters when you read imports and search for answers online.

Then list the starters. Each one pulls in a set of libraries and the auto-configurations that set them up:

StarterWhat it brings
spring-boot-starter-webSpring MVC, embedded Tomcat, Jackson
spring-boot-starter-webfluxReactive web stack on Netty
spring-boot-starter-data-jpaHibernate, Spring Data repositories, a DataSource
spring-boot-starter-securitySpring Security with a default filter chain
spring-boot-starter-actuatorHealth, metrics and introspection endpoints
spring-boot-starter-validationBean Validation (@Valid, @NotNull)

Print the resolved dependency tree once so you know what is actually on the classpath:

./mvnw dependency:tree
./gradlew dependencies --configuration runtimeClasspath

If the project is multi-module, the root build file lists modules, and the module with the spring-boot-maven-plugin or org.springframework.boot plugin applied, plus a main class, is the application. Other modules are libraries.

Step 2: Find the main class and the scan root

Search for the entry point:

grep -rln "@SpringBootApplication" src/main/java
package com.acme.billing;

@SpringBootApplication
public class BillingApplication {
    public static void main(String[] args) {
        SpringApplication.run(BillingApplication.class, args);
    }
}

@SpringBootApplication is shorthand for three annotations. @ComponentScan scans the main class's package and every subpackage for components, so the package of this class is the root of the application. A component in com.acme.shared would not be picked up unless scanBasePackages or an explicit @Import brings it in. Also check for @EnableScheduling, @EnableAsync, @EnableCaching, @EnableJpaRepositories or @ConfigurationPropertiesScan on the main class or on configuration classes; each one turns on a feature you will need to look for later.

Look for other entry points too: CommandLineRunner and ApplicationRunner beans run after startup, and @EventListener(ApplicationReadyEvent.class) methods run when the app is ready. These often do seeding, cache warming or registration.

Step 3: Map the beans

A Spring application is a graph of beans, objects created and wired by the container. They come from two places.

Stereotype annotations on classes found by component scanning: @Component, @Service, @Repository, @Controller, @RestController and @Configuration. List them:

grep -rlE "@(Service|Component|Repository|RestController|Controller|Configuration)\b" src/main/java | sort

@Bean methods inside @Configuration classes, used for third-party types and anything that needs construction logic:

@Configuration
public class PaymentsConfig {
    @Bean
    PaymentGateway paymentGateway(StripeProperties props, RestClient.Builder builder) {
        return new StripePaymentGateway(builder.baseUrl(props.baseUrl()).build(), props.apiKey());
    }
}

Dependencies are injected through constructors (preferred, and the norm in modern code, often with Lombok's @RequiredArgsConstructor), through @Autowired fields in older code, or as @Bean method parameters. When an interface has several implementations, look for @Primary and @Qualifier to see which one wins at each injection point.

Spring Data adds another source of beans: interfaces extending JpaRepository or CrudRepository get implementations generated at runtime. Method names like findByCustomerIdAndStatusOrderByCreatedAtDesc are parsed into queries, and @Query annotations hold explicit JPQL or SQL.

To see the complete bean graph of a running app, use Actuator:

# application-local.yml
management:
  endpoints:
    web:
      exposure:
        include: health,info,beans,conditions,mappings,env,configprops
curl -s localhost:8080/actuator/beans | jq '.contexts[].beans | to_entries[] | select(.key | startswith("com.acme") or (.value.type | startswith("com.acme"))) | {name: .key, deps: .value.dependencies}'

Only expose these endpoints locally or behind authentication. env and configprops can show sensitive values, although Spring Boot 3 masks them by default.

Step 4: Decode auto-configuration

Auto-configuration is the part of Spring Boot that most often surprises people. Starters ship configuration classes that create beans only when certain conditions hold: a class is on the classpath (@ConditionalOnClass), a property is set (@ConditionalOnProperty), or the application has not defined its own bean of that type (@ConditionalOnMissingBean). The last condition is how you override defaults: define your own ObjectMapper and Boot's backs off.

In Spring Boot 3, auto-configuration classes are listed in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports inside each jar (Boot 2 used spring.factories). The project may have its own, if it ships an internal starter.

You do not need to read them. Ask the app:

./mvnw spring-boot:run -Dspring-boot.run.arguments=--debug
./gradlew bootRun --args='--debug'

With --debug, startup logs a conditions evaluation report listing positive matches (auto-configurations that applied), negative matches (those that did not, with the reason) and exclusions. The same data is available at /actuator/conditions. When you are wondering "where does this bean come from" or "why is there no connection pool", this report answers it.

Also check for @SpringBootApplication(exclude = ...) or spring.autoconfigure.exclude, which disable specific auto-configurations.

Step 5: Map the web layer

List every endpoint from the running app:

curl -s localhost:8080/actuator/mappings | jq -r '.contexts[].mappings.dispatcherServlets[][] | .predicate' | sort

Or search the code for @RequestMapping, @GetMapping, @PostMapping, @PutMapping, @DeleteMapping and @PatchMapping. Class-level @RequestMapping("/api/invoices") prefixes every method in the controller.

Then find the code that runs around controllers:

  • Filters: Filter or OncePerRequestFilter beans, and the Spring Security filter chain defined by a SecurityFilterChain bean. Read the security config carefully: which paths are permitAll(), which require roles, how authentication works (JWT resource server, session, OAuth2 login).
  • Interceptors: HandlerInterceptor implementations registered in a WebMvcConfigurer.
  • Exception handling: @ControllerAdvice or @RestControllerAdvice classes with @ExceptionHandler methods, which translate exceptions into responses for the whole app.
  • Validation: @Valid on request bodies plus constraint annotations on DTOs.
  • Method security: @PreAuthorize("hasRole('ADMIN')") on service or controller methods, enabled by @EnableMethodSecurity.

Step 6: Read configuration and profiles

Configuration lives in src/main/resources/application.yml or application.properties, with profile-specific files like application-dev.yml and application-prod.yml layered on top. The active profiles come from spring.profiles.active, set by an environment variable (SPRING_PROFILES_ACTIVE), a command-line argument or, in some projects, the build. Find out which profiles are used in each environment by checking Dockerfiles, Helm charts and CI.

Spring Boot resolves properties from many sources in a defined order, with later sources overriding earlier ones. Command-line arguments and environment variables beat application.yml, so production values often do not appear in the repo at all. Environment variables map to properties by relaxed binding: SPRING_DATASOURCE_URL sets spring.datasource.url.

Typed configuration uses @ConfigurationProperties:

@ConfigurationProperties(prefix = "acme.stripe")
public record StripeProperties(String baseUrl, String apiKey, Duration timeout) {}

Find these classes to learn every custom setting the app supports. Beans can also be conditional on profiles with @Profile("dev") or on properties with @ConditionalOnProperty, which means some code only exists in some environments. Use /actuator/env to see the final value of any property and which source it came from.

Step 7: Know which behavior comes from proxies

Several annotations work by wrapping beans in proxies: @Transactional, @Async, @Cacheable, @Retryable, @PreAuthorize, and custom aspects defined with @Aspect. This has two consequences when reading code.

First, the behavior is not in the method body. A @Transactional method commits or rolls back when it returns or throws an unchecked exception (checked exceptions do not trigger rollback by default unless rollbackFor says so). An @Async method returns immediately and runs on a task executor.

Second, self-invocation bypasses the proxy. If a method in a service calls another @Transactional method on this, the inner annotation has no effect, because the call never goes through the proxy. This is one of the most common Spring bugs, and knowing it changes how you read service classes.

Find the aspects too:

grep -rn "@Aspect\|@Around\|@Before(\|@AfterReturning" src/main/java

Custom aspects for logging, auditing, metrics or multi-tenancy can affect every method matching a pointcut expression.

Step 8: Trace one request

Pick an important endpoint and trace it: security filter chain, controller method, validation, service method (and its transaction boundary), repository calls, events published with ApplicationEventPublisher and their @EventListener or @TransactionalEventListener handlers, and the response. Turn on SQL logging with logging.level.org.hibernate.SQL=DEBUG to see every query and catch lazy-loading surprises. The general method is in tracing a request through a web app.

A first-day checklist

  1. Read the build file and list starters and the Boot version.
  2. Find @SpringBootApplication and note the scan root and @Enable* annotations.
  3. List stereotype classes and @Configuration classes.
  4. Expose Actuator locally and read beans, mappings and conditions.
  5. Run once with --debug and skim the conditions report.
  6. Read the SecurityFilterChain and @ControllerAdvice classes.
  7. List profiles and @ConfigurationProperties classes.
  8. Find proxy-based annotations and custom aspects.
  9. Trace one request with SQL logging on.

When a Spring app is one of many services, understanding a microservices architecture covers how to map the system around it. Codebase Knowledge by OpenVisio can index a Spring Boot repository in your browser and answer questions like "where are payments retried" in Ask, with the relevant code shown next to the explanation, which helps when the answer is spread across annotations.

Spring BootJavadependency injectioncode reading

See it on your own repo

Paste a GitHub URL and get the day-one walkthrough — free, in your browser.

Index a repo

Keep reading