Spring Boot Best Practices That Should Fail Your Build

Spring Boot best practices are useless if they live only in code reviews.

You probably know the story. The team agrees that field injection is bad. Everyone knows that @Transactional on a private method does not work with Spring proxies. Someone writes in Confluence that controllers should not call repositories directly. Then two months later, the same problems are back in the codebase.


That is why I created Spring Boot Code Guard. It converts Spring Boot best practices into Konsist checks that run as regular JUnit 5 tests. The test reads your Kotlin source code, checks annotations, package names, class names, and dependencies, then fails with a rule ID such as CodeGuard:noFieldInjection.

Let’s break down the rules that should fail your build before they reach code review.

1. Spring Boot Best Practices Should Be Executable

Spring Boot gives you a lot of freedom. The official documentation even says that Spring Boot does not require a specific code layout, but there are best practices that help, especially around root packages and component scanning (Spring Boot: Structuring Your Code).

The reviewer should think about behavior, edge cases, business logic, security, and production risks. Repeating “please move this class to the service package” for the hundredth time is painful to watch.

Code Guard’s position is simple: if a Spring Boot best practice can be described structurally, it can be tested structurally.

2. General Spring Boot Best Practices

General Spring Boot best practices catch the small details that look harmless in a pull request and become annoying when they spread across the project.

Constructor Injection Instead of Field Injection

Spring supports field injection with @Autowired, but it also supports constructor injection, and a single constructor is used even when it is not annotated (Spring Framework: Using @Autowired).

Code Guard chooses constructor injection as the project rule:

// Bad
@Service
class PaymentService {
    @Autowired
    lateinit var repository: PaymentRepository
}
// Better
@Service
class PaymentService(
    private val repository: PaymentRepository,
)

Rule: CodeGuard:noFieldInjection

Why fail the build? Dependencies should be visible from the constructor. If a class has too many dependencies, you see it immediately.

Proxy Annotations Must Not Be Placed on Private Methods

Spring AOP proxying has a hard limitation: private methods cannot be advised because they cannot be overridden (Spring Framework: Proxying Mechanisms). The transaction documentation also explains that proxy-based transactions intercept calls coming through the proxy, not internal calls hidden inside the same object (Spring Framework: Using @Transactional).

This code looks meaningful, but the annotation is lying to you:

// Bad
@Service
class InvoiceService {
    fun createInvoice() {
        persistInvoice()
    }
    @Transactional
    private fun persistInvoice() {
        // no transactional proxy interception here
    }
}
// Better
@Service
class InvoiceService {
    @Transactional
    fun createInvoice() {
        persistInvoice()
    }
    private fun persistInvoice() {
        // transaction is opened on the public service method
    }
}

Rule: CodeGuard:noProxyAnnotationsOnPrivateMethods

The rule covers @Transactional, @Cacheable, @CacheEvict, @CachePut, and @Async. Same family of problem: a proxy annotation on code that the proxy cannot intercept.

Proxy Methods Must Not Self-Invoke Other Proxy Methods

Same root cause as the private-method case, different symptom. When a public proxy-annotated method calls another proxy-annotated method on the same instance, the call goes through this, not through the Spring-generated proxy. The second annotation is silently ignored (Spring Framework: Understanding AOP Proxies).

The transactional reference makes the same point about self-invocation bypassing the proxy and skipping any configured advice (Spring Framework: Using @Transactional).

// Bad
@Service
class ReportService {
    @Transactional
    fun generate(id: Long) {
        archive(id) // self-invocation, @Async never fires
    }
    @Async
    fun archive(id: Long) {
        // expected to run on a separate thread, but does not
    }
}
// Better
@Service
class ReportService(
    private val archiveService: ArchiveService,
) {
    @Transactional
    fun generate(id: Long) {
        archiveService.archive(id)
    }
}
@Service
class ArchiveService {
    @Async
    fun archive(id: Long) {
        // proxied call from another bean, advice runs
    }
}

Rule: CodeGuard:noSelfInvocationOfProxyMethods

The rule also flags proxy method calls from constructors, init blocks, and property initializers – the proxy is not in place yet during object construction, so even an external-looking call would still bypass it.

Use Structured Logging Instead of println and printStackTrace()

Spring Boot has a logging system with Logback as the default when using starters, and it pre-configures log output with levels, logger names, process IDs, and optional file output (Spring Boot: Logging).

This code bypasses it:

// Bad
@Service
class ImportService {
    fun importFile(file: File) {
        try {
            println("Importing ${file.name}")
        } catch (ex: IOException) {
            ex.printStackTrace()
        }
    }
}
// Better
@Service
class ImportService {
    private val log = LoggerFactory.getLogger(javaClass)
    fun importFile(file: File) {
        try {
            log.info("Importing {}", file.name)
        } catch (ex: IOException) {
            log.error("Failed to import {}", file.name, ex)
        }
    }
}

Rules:

  1. CodeGuard:loggerInsteadOfPrint
  2. CodeGuard:noStackTracePrint

Good practice here is not “never debug locally.” Good practice is not letting local debugging habits become production behavior.

Configuration Classes Should Not Store Mutable State

Spring documents @Configuration classes as sources of bean definitions, with @Bean methods used to instantiate and configure Spring-managed objects (Spring Framework: @Bean and @Configuration).

Code Guard enforces a stricter project rule: @Configuration classes should define beans, not behave like mutable state holders.

// Bad
@Configuration
class MailConfiguration {
    var retries = 0
    @Bean
    fun mailClient(): MailClient = MailClient()
}
// Better
@Configuration
class MailConfiguration {
    @Bean
    fun mailClient(properties: MailProperties): MailClient =
        MailClient(properties.host, properties.port)
}

Rule: CodeGuard:statelessConfiguration

There are intentional exceptions for @Value and @ConfigurationProperties fields. The rule is not against configuration values. It is against turning configuration classes into stateful objects.

@Bean Methods Should Live in @Configuration Classes

Spring allows @Bean methods in non-@Configuration classes, but the @Bean javadoc calls that “lite mode.” In lite mode, inter-bean method calls are plain Java/Kotlin calls and are not intercepted through the CGLIB-enhanced configuration class semantics (Spring Framework @Bean javadoc).

Code Guard chooses the predictable rule:

// Bad
@Component
class ClientFactory {
    @Bean
    fun client(): HttpClient = HttpClient()
}
// Better
@Configuration
class ClientConfiguration {
    @Bean
    fun client(): HttpClient = HttpClient()
}

Rule: CodeGuard:beanMethodsInConfiguration

Yes, Spring can handle lite mode. No, I don’t want it used accidentally in the codebase.

Custom Exceptions Should Not Extend Raw Exception

Checked exceptions have their place, but many Spring Boot applications are cleaner when domain and application exceptions are unchecked and handled by controller advice. Code Guard enforces that classes ending with Exception extend RuntimeException, one of the well-known unchecked subtypes, ResponseStatusException, NestedRuntimeException, or another custom exception class.

// Bad
class PaymentFailedException(message: String) : Exception(message)
// Better
class PaymentFailedException(message: String) : RuntimeException(message)

Rule: CodeGuard:customExceptionStructure

This is a project convention, not a Spring Framework requirement. The point is consistency. If every service invents its own exception hierarchy, global error handling becomes a mess.

Kotlin Data Classes Should Not Be JPA Entities

Kotlin data classes automatically generate equals() and hashCode() from primary constructor properties, and data classes cannot be open (Kotlin: Data Classes). Hibernate explains that final entity classes prevent proxy creation for lazy loading unless you use special alternatives, such as proxying an interface (Hibernate ORM User Guide).

That is already enough to be paranoid.

// Bad
@Entity
data class UserEntity(
    @Id
    val id: Long,
    val email: String,
)
// Better
@Entity
class UserEntity(
    @Id
    var id: Long? = null,
    var email: String,
)

Rule: CodeGuard:noDataClassEntity

If you want deeper JPA entity design details, I wrote a separate guide: Spring Data JPA with Kotlin: Entities, Repositories, and Pitfalls.

JPA Entities Should Declare an Identifier

Hibernate’s user guide is direct here: every entity must define an identifier, and simple identifiers use the @Id annotation (Hibernate ORM User Guide: Identifiers).

Code Guard checks that an @Entity class, or one of its parents inside the same codebase, declares an @Id field.

// Bad
@Entity
class AuditLogEntity(
    var message: String,
)
// Better
@Entity
class AuditLogEntity(
    @Id
    var id: Long? = null,
    var message: String,
)

Rule: CodeGuard:entityId

Repository Methods Should Return Concrete Types

Spring Data JPA supports entity, interface, and DTO projections. It uses the declared return type to build the result (Spring Data JPA: Projections).

A repository method that returns Any or Object throws that contract away. Callers must guess the type or cast it themselves.

// Bad
interface AuditLogRepository : JpaRepository<AuditLogEntity, Long> {
    @Query("select a.message from AuditLogEntity a where a.id = :id")
    fun findMessage(@Param("id") id: Long): Any?
}
// Better
interface AuditLogRepository : JpaRepository<AuditLogEntity, Long> {
    @Query("select a.message from AuditLogEntity a where a.id = :id")
    fun findMessage(@Param("id") id: Long): String?
}

Rule: CodeGuard:repositoryReturnType

The rule also looks inside wrappers, so List<Any>, Optional<Any>, and Page<Any> fail too. Map<String, Any> and other multi-argument generic types remain allowed.

@Transactional Belongs in the Service Layer

Spring does not force you to put @Transactional only in services. It gives you the annotation and proxy infrastructure. Your architecture decides the boundary.

Code Guard’s rule is deliberately stricter: controllers should not own transaction boundaries. They should translate HTTP input into application calls and HTTP output back to the client.

// Bad
@RestController
class OrderController(
    private val orderService: OrderService,
) {
    @Transactional
    @PostMapping("/orders")
    fun create(@RequestBody request: CreateOrderRequest) =
        orderService.create(request)
}
// Better
@Service
class OrderService {
    @Transactional
    fun create(request: CreateOrderRequest): OrderResponse {
        // transactional business operation
    }
}

Rule: CodeGuard:transactionalPlacement

I covered more transaction details in Spring Data JPA Best Practices: Transactions and Manual Queries.

Domain and Entity Packages Should Not Depend on Spring

Code Guard also checks that classes in ..domain.. or ..entity.. packages do not import org.springframework.*.

Rule: CodeGuard:domainLayerIndependence

This one is not about pleasing a framework. It is about keeping the domain model portable and boring. Your entity should not know that the web layer exists. Your domain object should not need Spring to explain what it is.

3. Naming Rules

Naming rules are less exciting than transaction bugs, but they remove friction.

Code Guard checks:

  1. CodeGuard:serviceNaming: @Service classes end with Service.
  2. CodeGuard:repositoryNaming: @Repository classes and interfaces end with Repository.
  3. CodeGuard:controllerNaming: @Controller and @RestController classes end with Controller.
  4. CodeGuard:exceptionHandlerNaming: @ControllerAdvice and @RestControllerAdvice classes end with ExceptionHandler or Advice.
  5. CodeGuard:configurationPropertiesNaming: @ConfigurationProperties classes end with Properties.

Example:

// Bad
@Service
class PaymentProcessor
// Better
@Service
class PaymentService

This will not save your application on its own, but it saves attention. When a project has 50 services, 10 repositories, and 7 controllers, predictable naming matters.

4. Spring Boot Project Structure Best Practices

This is the strongest architecture section.

Spring Boot recommends a root package above the rest of the application so that component, entity, and configuration property scanning stay within your project, preventing accidental scanning of everything (Spring Boot: Structuring Your Code).

Java package names are conventionally lowercase, and Oracle’s Java tutorial explains the convention to avoid conflicts with class and interface names (Oracle Java Tutorial: Naming a Package).

Code Guard builds on top of these conventions with package rules.

Positive Placement Rules

These rules say where a type should live:

  1. CodeGuard:packageNaming: package names must be lowercase.
  2. CodeGuard:servicePackage: @Service classes must live in a ..service.. package segment.
  3. CodeGuard:controllerPackage: controllers must live in ..controller.. or ..web...
  4. CodeGuard:configurationPackage: @Configuration classes must live in ..config.. or ..configuration...
  5. CodeGuard:propertiesValidation: @ConfigurationProperties classes must live in ..property...
  6. CodeGuard:entityPackage: @Entity classes must live in ..domain.. or ..entity...
  7. CodeGuard:repositoryPackage: repositories must live in ..repository...

Spring Boot also recommends kebab-case canonical property names for configuration binding, and its documentation says the @ConfigurationProperties prefix must be kebab case (Spring Boot: Externalized Configuration).

Code Guard enforces that too:

// Bad
@ConfigurationProperties(prefix = "mailClient")
class MailProperties
// Better
@ConfigurationProperties(prefix = "mail.client")
class MailProperties

Rule: CodeGuard:configurationPropertiesPrefixKebabCase

Exclusive Package Rules

If a service must be in a service package, then a repository should not quietly move into the same package.

Code Guard checks package exclusivity:

  1. CodeGuard:onlyServicesInServicePackage
  2. CodeGuard:onlyEntitiesInEntityPackage
  3. CodeGuard:onlyControllersInControllerPackage
  4. CodeGuard:onlyConfigurationsInConfigPackage
  5. CodeGuard:onlyPropertiesInPropertyPackage
  6. CodeGuard:onlyRepositoriesInRepositoryPackage

The rules include practical exceptions for file-level helpers, nested helper classes, and JPA @MappedSuperclass, JPA @Embeddable, and @IdClass support where relevant. The goal is not to punish normal Kotlin code organization. The goal is to stop architectural erosion.

5. Spring Boot REST API Best Practices

The web layer is where small shortcuts become public API problems.

Use Specific HTTP Method Annotations

Spring MVC documents @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, and @PatchMapping as shortcut variants of @RequestMapping, and says most controller methods should be mapped to a specific HTTP method (Spring Framework: Mapping Requests).

Code Guard turns that into a rule:

// Bad
@RestController
class UserController {
    @RequestMapping("/users")
    fun users(): List<UserDto> = emptyList()
}
// Better
@RestController
class UserController {
    @GetMapping("/users")
    fun users(): List<UserDto> = emptyList()
}

Rule: CodeGuard:httpMethodAnnotation

Do Not Add Trailing Slashes to Mapping Paths

Spring Framework 6 deprecated transparent trailing slash matching and favors explicit redirects through a proxy, servlet/web filter, or controller (Spring PathPatternParser javadoc).

Code Guard keeps URL mappings consistent:

// Bad
@GetMapping("/users/")
fun users(): List<UserDto> = emptyList()
// Better
@GetMapping("/users")
fun users(): List<UserDto> = emptyList()

Rule: CodeGuard:noTrailingSlash

GET Endpoints Should Return Something

A GET endpoint that returns Unit or void is suspicious. If the endpoint is a command, it probably should not be GET. If it is a query, it should return a meaningful response.

// Bad
@GetMapping("/reports/rebuild")
fun rebuildReport() {
    reportService.rebuild()
}
// Better
@PostMapping("/reports/rebuild")
fun rebuildReport(): RebuildReportResponse =
    reportService.rebuild()

Rule: CodeGuard:restControllerReturnType

Do Not Expose JPA Entities Through REST APIs

Returning entities from controllers couples your HTTP contract to your persistence model. It also makes it easier to accidentally expose fields that were added for database logic, not for clients.

// Bad
@GetMapping("/users/{id}")
fun user(@PathVariable id: Long): UserEntity =
    userService.getEntity(id)
// Better
@GetMapping("/users/{id}")
fun user(@PathVariable id: Long): UserResponse =
    userService.get(id)

Rule: CodeGuard:dtoSeparation

Related reading: Spring Data JPA Best Practices: Repositories Design Guide and Spring Data JPA with Kotlin.

Controllers Should Not Depend on Repositories

Controllers should call services. Services coordinate business logic and data access. Repositories should not appear as constructor parameters in controllers.

// Bad
@RestController
class UserController(
    private val userRepository: UserRepository,
)
// Better
@RestController
class UserController(
    private val userService: UserService,
)

Rule: CodeGuard:controllerRepository

Services Should Not Depend on Web Types

An @Service should model application work, not HTTP transport. Returning ResponseEntity, reading HttpServletRequest, or depending on a controller binds the service to the web layer.

// Bad
@Service
class InvoiceService {
    fun find(id: Long): ResponseEntity<InvoiceResponse> =
        ResponseEntity.ok(loadInvoice(id))
}
// Better
@Service
class InvoiceService {
    fun find(id: Long): InvoiceResponse =
        loadInvoice(id)
}
@RestController
class InvoiceController(
    private val invoiceService: InvoiceService,
) {
    @GetMapping("/invoices/{id}")
    fun find(@PathVariable id: Long): ResponseEntity<InvoiceResponse> =
        ResponseEntity.ok(invoiceService.find(id))
}

Rule: CodeGuard:serviceWebDependency

The rule covers Spring Web and HTTP types, the Jakarta and Javax Servlet APIs, and dependencies on controllers from the same project.

Services Should Return DTOs, Not Entities

CodeGuard:dtoSeparation stops REST controllers from accepting or returning JPA entities. This rule catches the leak one layer earlier: public @Service methods must not return entities.

// Bad
@Service
class UserService(
    private val userRepository: UserRepository,
) {
    fun find(id: Long): UserEntity =
        userRepository.getReferenceById(id)
}
// Better
@Service
class UserService(
    private val userRepository: UserRepository,
) {
    fun find(id: Long): UserResponse =
        userRepository.getReferenceById(id).toResponse()
}

Rule: CodeGuard:serviceEntityReturn

The rule follows common wrappers, so List<UserEntity>, Optional<UserEntity>, and Page<UserEntity> fail too.

6. How to Add Code Guard

The library is published to Maven Central, and the README shows this dependency:

implementation("dev.protsenko:spring-boot-code-guard:1.0.11")

Then add a JUnit 5 test, for example src/test/kotlin/SpringCodeGuardTest.kt:

import dev.protsenko.codeguard.spring.springBootRules
import org.junit.jupiter.api.Test
class SpringCodeGuardTest {
    @Test
    fun `spring boot rules`() {
        springBootRules {
            all()
        }.verify()
    }
}

That is the aggressive mode.

For a legacy project, start smaller:

import dev.protsenko.codeguard.spring.springBootRules
import org.junit.jupiter.api.Test
class SpringCodeGuardTest {
    @Test
    fun `spring boot rules`() {
        springBootRules {
            general {
                noFieldInjection()
            }
            jpa {
                entitiesHaveIdField()
            }
            naming {
                serviceNamingConvention()
            }
            packages {
                packageNamingConvention()
            }
            web {
                properHttpMethodAnnotations()
            }
        }.verify()
    }
}

If a rule is right but one old class needs time, suppress it explicitly:

@Suppress("CodeGuard:noFieldInjection")
@Service
class LegacyPaymentService {
    @Autowired
    lateinit var repository: PaymentRepository
}

If a rule does not fit your project, exclude it:

springBootRules {
    all()
    exclude(
        "CodeGuard:noFieldInjection",
        "CodeGuard:transactionalPlacement",
    )
}.verify()

The point is not to turn every team into my team. The point is to make Spring Boot best practices visible, executable, and impossible to forget silently.

7. What This Does Not Replace

Code Guard is not a replacement for code review. It is not a full architecture test suite. It will not tell you if your business rule is wrong, your SQL is slow, your transaction boundary is too wide, or your API design is confusing.

It catches repeatable mistakes before review:

  1. Wrong annotation placement.
  2. Wrong package boundaries.
  3. Wrong naming conventions.
  4. Entity leaks through REST.
  5. Repository usage from controllers.
  6. Spring proxy annotations cannot work.

That is enough to be useful. Humans should review code like humans. Tests should reject mechanical mistakes like tests. That is the real value of executable Spring Boot best practices.

Found this useful? Follow me on social media to stay updated.

Avatar photo
Dmitry Protsenko

Senior Software Engineer
Specialized on Java / Kotlin and CyberSecurity
Author of this blog

Articles: 34