Back to Web Layer: Controllers & Request Mapping
Q3Question 3 of 3

Why should you not put DB logic in a controller?

Key Concepts
  • Controller handles HTTP parsing and response formatting, Service handles business logic, Repository handles data access
  • Putting database code in a controller violates separation of concerns and makes testing impossible without a running database
  • The DAO pattern abstracts persistence so you can swap the database without changing the service layer
  • Auto-configuration reads classpath and properties, then creates beans via @Conditional annotations
  • @ConditionalOnMissingBean ensures your custom @Bean always overrides auto-configured defaults
  • Debug auto-configuration with --debug flag or /actuator/conditions endpoint to see why beans were created or skipped

Definition

Controller handles HTTP, Service handles business logic, Repository handles data access, and auto-configuration creates beans automatically based on classpath and properties via @Conditional annotations.
  • Layered architecture – Spring Boot applications follow Controller to Service to Repository layering where each layer has a single responsibility.
  • Controller – receives HTTP requests, validates input, delegates to the Service layer, and formats the HTTP response.
  • Service – contains business logic such as interest calculation, validation rules, and transaction orchestration.
  • Repository (DAO) – abstracts all data access behind an interface so the database engine can be swapped without changing business logic.
  • Auto-configuration – Spring Boot scans the classpath and properties at startup, then uses @Conditional annotations to decide which beans to create automatically.
  • @ConditionalOnMissingBean – ensures that your custom @Bean definition always takes priority over the auto-configured default.

At National Bank of India, the receptionist (controller) greets customers and routes them to the right desk. The loan officer (service) calculates EMI amounts and applies business rules. The vault keeper (repository) stores and retrieves records from the safe. The receptionist never opens the vault directly. Auto-configuration is like the branch manager who walks in on the first day, sees vault equipment already in the building, and automatically sets up the vault without anyone filling out a checklist.

Understanding the Concepts

What is layered architecture in Spring Boot?

Layered architecture means splitting your application into distinct layers where each layer has one job. In Spring Boot the standard layers are Controller, Service, and Repository. The Controller handles HTTP concerns: parsing request bodies, extracting path variables, and returning the correct status code. The Service handles business logic: calculating interest, applying transfer limits, and orchestrating multiple operations. The Repository handles data access: reading and writing to the database.

The key rule is that each layer talks only to the layer directly below it. The Controller calls the Service. The Service calls the Repository. The Controller never talks to the Repository directly.

JAVA
package com.bank.controller;

import com.bank.service.AccountService;
import com.bank.model.AccountStatement;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;

@RestController
@RequestMapping("/api/v1/accounts")
public class AccountController {

    private final AccountService accountService;

    public AccountController(AccountService accountService) {
        this.accountService = accountService;
    }

    @GetMapping("/{accountId}/balance")
    public ResponseEntity<BigDecimal> getBalance(@PathVariable String accountId) {
        // Controller does NOT query the database directly.
        // It delegates to the Service layer.
        BigDecimal balance = accountService.getBalance(accountId);
        return ResponseEntity.ok(balance);
    }
}
TEXT
Expected output (HTTP response):
HTTP 200 OK
Content-Type: application/json

250000.00

Why is putting database logic in a controller bad practice?

When Rajesh puts a JDBC call or JPA query directly inside a controller method, several problems appear. First, testing becomes painful. You cannot test the controller without a running database because the database code is embedded in the HTTP handling code. With proper layering, Rajesh tests the controller using MockMvc and a mocked service, with no database needed at all.

Second, code reuse disappears. If another controller or a scheduled job needs the same query, the developer must copy the database code. With a Repository, both the controller and the scheduled job call the same Repository method.

Third, transaction management breaks. Spring's @Transactional annotation belongs on Service methods where business operations are coordinated. A controller should not manage transactions because a single business operation might involve multiple repository calls that must either all succeed or all roll back together.

JAVA
package com.bank.controller;

// WRONG: database logic inside the controller
@RestController
public class BadAccountController {

    @Autowired
    private EntityManager entityManager;

    @GetMapping("/api/v1/accounts/{id}/balance")
    public BigDecimal getBalance(@PathVariable String id) {
        // This is untestable without a database.
        // Transaction management is missing.
        // This query cannot be reused by other classes.
        return (BigDecimal) entityManager
            .createQuery("SELECT a.balance FROM Account a WHERE a.id = :id")
            .setParameter("id", id)
            .getSingleResult();
    }
}
JAVA
package com.bank.controller;

import com.bank.service.AccountService;

// CORRECT: controller delegates to service
@RestController
public class GoodAccountController {

    private final AccountService accountService;

    public GoodAccountController(AccountService accountService) {
        this.accountService = accountService;
    }

    @GetMapping("/api/v1/accounts/{id}/balance")
    public ResponseEntity<BigDecimal> getBalance(@PathVariable String id) {
        BigDecimal balance = accountService.getBalance(id);
        return ResponseEntity.ok(balance);
    }
}

What are the benefits of the DAO (Repository) pattern?

The Data Access Object pattern, called Repository in Spring, abstracts persistence behind an interface. The Service layer depends on the interface, not on the implementation. This gives three concrete benefits.

First, you can swap the database engine without changing business logic. If National Bank of India migrates from Oracle to PostgreSQL, only the Repository implementation changes. The Service and Controller are untouched.

Second, each layer is independently testable. The Controller is tested with MockMvc and a mocked Service with no database running. The Service is tested with a mocked Repository and no database running. The Repository is tested with @DataJpaTest and a real test database.

Third, the DAO centralizes all SQL and query logic in one place, making it easy to optimize queries, add indexes, or switch from JPA to JDBC for a specific query without scattering changes across the codebase.

JAVA
package com.bank.repository;

import com.bank.model.Account;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.math.BigDecimal;
import java.util.Optional;

public interface AccountRepository extends JpaRepository<Account, String> {

    Optional<Account> findByAccountNumber(String accountNumber);

    @Query("SELECT a.balance FROM Account a WHERE a.accountNumber = :accountNumber")
    BigDecimal findBalanceByAccountNumber(String accountNumber);
}

How does Spring Boot auto-configuration work internally?

When you annotate your main class with @SpringBootApplication, it includes @EnableAutoConfiguration. At startup, Spring Boot reads the META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports files from every JAR on the classpath. Each listed class is a @Configuration class guarded by @Conditional annotations.

For example, when spring-boot-starter-data-jpa is on the classpath and spring.datasource.url is set in properties, the auto-configuration creates a HikariCP DataSource, an EntityManagerFactory, a TransactionManager, and a Flyway migration runner, all without any explicit @Bean definitions from the developer.

The @Conditional annotations are the gatekeepers:

  • @ConditionalOnClass(DataSource.class) activates when the DataSource class exists on the classpath.
  • @ConditionalOnProperty(name = "spring.datasource.url") activates when the property is set.
  • @ConditionalOnMissingBean(DataSource.class) backs off if you define your own DataSource bean.
JAVA
package com.bank.config;

import com.zaxxer.hikari.HikariDataSource;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;

@Configuration
public class CustomDataSourceConfig {

    // This @Bean overrides auto-configured DataSource
    // because @ConditionalOnMissingBean on the auto-config sees this bean and backs off.
    @Bean
    public DataSource dataSource() {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl("jdbc:postgresql://prod-db:5432/nbi_accounts");
        ds.setUsername("nbi_app");
        ds.setMaximumPoolSize(20);
        ds.setConnectionTimeout(5000);
        return ds;
    }
}

How do you debug auto-configuration?

When auto-configuration does not behave as expected, Rajesh has two tools. The first is the --debug flag. Running the application with java -jar app.jar --debug prints a Conditions Evaluation Report at startup showing every auto-configuration class and exactly why it matched (positive match) or was skipped (negative match).

The second tool is the Actuator endpoint /actuator/conditions, which provides the same information as a JSON response at runtime.

BASH
# Run with debug flag to see the Conditions Evaluation Report
java -jar national-bank-app.jar --debug
TEXT
Conditions Evaluation Report:
============================

Positive matches:
-----------------
   DataSourceAutoConfiguration matched:
      - @ConditionalOnClass found required classes 'javax.sql.DataSource',
        'org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType'

Negative matches:
-----------------
   RabbitAutoConfiguration:
      Did not match:
         - @ConditionalOnClass did not find required class
           'com.rabbitmq.client.Channel'

How do you exclude an auto-configuration class?

Sometimes auto-configuration creates beans you do not want. You can exclude specific classes using the exclude attribute on @SpringBootApplication or via a property in application.yml.

JAVA
package com.bank;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;

// Exclude security auto-config during early development
@SpringBootApplication(exclude = SecurityAutoConfiguration.class)
public class NationalBankApplication {
    public static void main(String[] args) {
        SpringApplication.run(NationalBankApplication.class, args);
    }
}
YAML
# Alternative: exclude via properties
spring:
  autoconfigure:
    exclude:
      - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration

What are common mistakes with layered architecture?

The most common mistake is injecting a Repository directly into a Controller, bypassing the Service layer. This looks harmless for simple reads but breaks down when business logic grows.

JAVA
// WRONG: Controller depends directly on Repository
@RestController
public class BadTransferController {

    @Autowired
    private AccountRepository accountRepository;

    @PostMapping("/api/v1/transfer")
    public ResponseEntity<String> transfer(@RequestBody TransferRequest req) {
        Account from = accountRepository.findById(req.fromId()).orElseThrow();
        Account to = accountRepository.findById(req.toId()).orElseThrow();
        // Business logic scattered in controller. No @Transactional.
        // If debit succeeds but credit fails, money disappears.
        from.setBalance(from.getBalance().subtract(req.amount()));
        to.setBalance(to.getBalance().add(req.amount()));
        accountRepository.save(from);
        accountRepository.save(to);
        return ResponseEntity.ok("Done");
    }
}
JAVA
// CORRECT: Controller delegates to Service with @Transactional
@RestController
public class GoodTransferController {

    private final TransferService transferService;

    public GoodTransferController(TransferService transferService) {
        this.transferService = transferService;
    }

    @PostMapping("/api/v1/transfer")
    public ResponseEntity<String> transfer(@RequestBody TransferRequest req) {
        transferService.execute(req.fromId(), req.toId(), req.amount());
        return ResponseEntity.ok("Transfer complete");
    }
}

How does each layer get tested independently?

The layering enables the test pyramid. Each layer uses a different Spring test slice.

| Layer | Test Annotation | What Is Real | What Is Mocked | Speed |
|---|---|---|---|---|
| Controller | @WebMvcTest | MockMvc, Jackson, Validators | Service (via @MockBean) | Fast |
| Service | No Spring (Mockito) | Service class | Repository (via @Mock) | Very fast |
| Repository | @DataJpaTest | JPA, EntityManager, DB | Nothing | Medium |
| Full stack | @SpringBootTest | Everything | Nothing | Slow |

JAVA
package com.bank.controller;

import com.bank.service.AccountService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.bean.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(AccountController.class)
class AccountControllerTest {

    @Autowired
    private MockMvc mvc;

    @MockBean
    private AccountService accountService;

    @Test
    void returnsBalanceForValidAccount() throws Exception {
        when(accountService.getBalance("SAV2001"))
            .thenReturn(new BigDecimal("250000.00"));

        mvc.perform(get("/api/v1/accounts/SAV2001/balance"))
           .andExpect(status().isOk())
           .andExpect(content().string("250000.00"));
    }
}
TEXT
Expected output:
Tests run: 1, Failures: 0, Errors: 0

What should a senior engineer know?

A senior engineer understands that auto-configuration order matters. Spring Boot processes auto-configuration classes in a defined order, and @AutoConfigureAfter and @AutoConfigureBefore control sequencing. If your custom DataSource bean must be available before Flyway auto-configuration runs, you use these annotations.

A senior also knows that @ConditionalOnMissingBean has a timing dependency: the condition is evaluated when the auto-configuration class is processed, so if your custom bean is registered later, the auto-configuration may not see it. Using @Configuration(proxyBeanMethods = false) in auto-configuration classes avoids CGLIB proxying overhead for configuration classes that do not need inter-bean references.

In production at National Bank of India, Pankaj designed the microservices so that each service has its own custom auto-configuration module. Shared concerns like audit logging, correlation ID propagation, and health checks are packaged into an internal starter JAR (nbi-spring-boot-starter-audit) that downstream services include as a dependency. The starter's auto-configuration activates automatically via @ConditionalOnClass checks.

What are common interview follow-up questions?

  • Can a Controller call another Controller directly? No, controllers should not call each other. Extract shared logic into a Service.
  • What happens if two @Bean methods create the same type? Spring throws a BeanDefinitionOverrideException unless you set spring.main.allow-bean-definition-overriding=true.
  • Why use constructor injection over field injection? Constructor injection makes dependencies explicit, supports immutability with final fields, and enables testing without Spring.
  • How does @Transactional interact with layering? It belongs on Service methods. Placing it on a Controller method works technically but violates separation of concerns because the controller now manages transaction boundaries.

Visual Overview

DIAGRAM
  HTTP Request
       │
       ▼
  ┌─────────────────────────────┐
  │     Controller Layer         │
  │  @RestController             │
  │  Parse request, validate,    │
  │  format response             │
  └──────────┬──────────────────┘
             │ delegates
             ▼
  ┌─────────────────────────────┐
  │     Service Layer            │
  │  @Service + @Transactional   │
  │  Business logic, rules,      │
  │  orchestration               │
  └──────────┬──────────────────┘
             │ delegates
             ▼
  ┌─────────────────────────────┐
  │     Repository Layer         │
  │  @Repository / JpaRepository │
  │  SQL, queries, data access   │
  └──────────┬──────────────────┘
             │
             ▼
        [ Database ]
DIAGRAM
  Auto-Configuration Decision Flow:

  classpath has DataSource.class?
       │
       ├── NO  → skip DataSourceAutoConfiguration
       │
       └── YES → spring.datasource.url set?
                    │
                    ├── NO  → skip (no property)
                    │
                    └── YES → user defined @Bean DataSource?
                                 │
                                 ├── YES → back off (@ConditionalOnMissingBean)
                                 │
                                 └── NO  → create HikariCP DataSource bean

Key Takeaway

Layered architecture separates concerns so that each part of the application has one job. The Controller handles HTTP: parsing request parameters, validating input shapes, and formatting JSON responses. The Service handles business logic: calculating interest, enforcing transfer limits, and coordinating multi-step operations inside a transaction. The Repository handles data access: running queries and mapping results to objects. When Rajesh puts a JPA query inside a Controller at National Bank of India, the code becomes untestable without a database, unreusable by other classes, and impossible to manage transactions correctly.

Spring Boot auto-configuration is the engine that creates beans based on what it finds on the classpath and in your properties. When you add spring-boot-starter-data-jpa to your dependencies and set spring.datasource.url, auto-configuration creates a HikariCP connection pool, an EntityManagerFactory, and a TransactionManager, all without a single explicit @Bean definition. The @ConditionalOnMissingBean annotation ensures that if you define your own DataSource, the auto-configured one steps aside. Debug with --debug to see exactly why each auto-configuration class matched or was skipped.

Remember one sentence: the Controller parses HTTP, the Service applies business rules, the Repository talks to the database, and auto-configuration wires it all together based on your classpath and properties.

---

Share
Use ← → arrow keys to navigate