DEV Community

Cover image for Coding Principles for Spring Boot
Rajkumar Sony
Rajkumar Sony

Posted on

Coding Principles for Spring Boot

Here is the complete set that covers what teams actually use in real Spring Boot code (design, Spring patterns, testing, security, ops).


1) Prefer constructor injection (not field injection)

Principle: Dependencies should be explicit and testable.

✅ Good

@Service
class OrderService {
  private final OrderRepository repo;

  OrderService(OrderRepository repo) {
    this.repo = repo;
  }
}
Enter fullscreen mode Exit fullscreen mode

❌ Bad

@Service
class OrderService {
  @Autowired
  private OrderRepository repo; // hidden dependency
}
Enter fullscreen mode Exit fullscreen mode

2) Keep controllers thin, put logic in services

Principle: Controllers handle HTTP, services handle business rules.

✅ Good

@RestController
class OrderController {
  private final OrderService service;

  OrderController(OrderService service) { this.service = service; }

  @PostMapping("/orders")
  OrderResponse create(@RequestBody CreateOrderRequest req) {
    return service.create(req);
  }
}
Enter fullscreen mode Exit fullscreen mode

❌ Bad

@RestController
class OrderController {
  @Autowired OrderRepository repo;

  @PostMapping("/orders")
  Order save(@RequestBody Order o) {
    o.setStatus("NEW");
    o.setCreatedAt(Instant.now());
    return repo.save(o); // business logic in controller
  }
}
Enter fullscreen mode Exit fullscreen mode

3) Separate API DTOs from JPA entities

Principle: Entities are persistence models, not public API contracts.

✅ Good

record CreateUserRequest(String email, String name) {}
record UserResponse(Long id, String email, String name) {}
Enter fullscreen mode Exit fullscreen mode

❌ Bad

@PostMapping("/users")
public UserEntity create(@RequestBody UserEntity entity) { // leaking entity
  return repo.save(entity);
}
Enter fullscreen mode Exit fullscreen mode

4) Validate inputs with Bean Validation

Principle: Reject bad input early and consistently.

✅ Good

record SignupRequest(
  @jakarta.validation.constraints.Email String email,
  @jakarta.validation.constraints.NotBlank String password
) {}

@PostMapping("/signup")
UserResponse signup(@Valid @RequestBody SignupRequest req) { ... }
Enter fullscreen mode Exit fullscreen mode

❌ Bad

@PostMapping("/signup")
UserResponse signup(@RequestBody Map<String, String> req) {
  if (!req.get("email").contains("@")) throw new RuntimeException("bad");
  ...
}
Enter fullscreen mode Exit fullscreen mode

5) Centralize error handling with @ControllerAdvice

Principle: One place for consistent HTTP errors.

✅ Good

@RestControllerAdvice
class ApiErrors {
  @ExceptionHandler(NotFoundException.class)
  ResponseEntity<?> handle(NotFoundException e) {
    return ResponseEntity.status(404).body(Map.of("error", e.getMessage()));
  }
}
Enter fullscreen mode Exit fullscreen mode

❌ Bad

@GetMapping("/orders/{id}")
Order get(@PathVariable Long id) {
  try { return service.get(id); }
  catch (Exception e) { throw new ResponseStatusException(HttpStatus.NOT_FOUND); }
}
Enter fullscreen mode Exit fullscreen mode

6) Use correct HTTP status codes and responses

Principle: API should be predictable.

✅ Good

@PostMapping("/orders")
ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest req) {
  var created = service.create(req);
  return ResponseEntity.status(201).body(created);
}
Enter fullscreen mode Exit fullscreen mode

❌ Bad

@PostMapping("/orders")
OrderResponse create(@RequestBody CreateOrderRequest req) { // always 200
  return service.create(req);
}
Enter fullscreen mode Exit fullscreen mode

7) Use meaningful package structure (by feature is often best)

Principle: Code should be easy to find.

✅ Good

com.app.orders/
  OrderController, OrderService, OrderRepository, dto/
Enter fullscreen mode Exit fullscreen mode

❌ Bad

com.app.controller/, com.app.service/, com.app.repo/
(100+ files per folder)
Enter fullscreen mode Exit fullscreen mode

8) Keep transaction boundaries in service layer

Principle: Transactions should wrap business operations, not controllers.

✅ Good

@Service
class TransferService {
  @Transactional
  void transfer(long from, long to, BigDecimal amount) { ... }
}
Enter fullscreen mode Exit fullscreen mode

❌ Bad

@RestController
class TransferController {
  @Transactional
  @PostMapping("/transfer")
  void transfer(...) { ... } // transaction tied to web layer
}
Enter fullscreen mode Exit fullscreen mode

9) Don’t swallow exceptions; wrap with context

Principle: Errors should keep meaning and traceability.

✅ Good

try {
  client.call();
} catch (IOException e) {
  throw new ExternalCallException("payment-provider failed", e);
}
Enter fullscreen mode Exit fullscreen mode

❌ Bad

try {
  client.call();
} catch (Exception e) {
  return null; // silent failure
}
Enter fullscreen mode Exit fullscreen mode

10) Use logging wisely (no System.out)

Principle: Use structured logs, correct levels.

✅ Good

private static final Logger log = LoggerFactory.getLogger(getClass());
log.info("Created order id={}", orderId);
Enter fullscreen mode Exit fullscreen mode

❌ Bad

System.out.println("Created order " + orderId);
Enter fullscreen mode Exit fullscreen mode

11) Never log secrets (tokens, passwords, full cards)

Principle: Logs are not a safe place.

✅ Good

log.info("Login attempt email={}", email);
Enter fullscreen mode Exit fullscreen mode

❌ Bad

log.info("Login payload={}", req); // might contain password
Enter fullscreen mode Exit fullscreen mode

12) Use configuration properties instead of sprinkling @Value

Principle: Strong typing + one place for config.

✅ Good

@ConfigurationProperties(prefix="app.storage")
record StorageProps(String bucket, int timeoutSeconds) {}

@Bean
StorageClient client(StorageProps p) { ... }
Enter fullscreen mode Exit fullscreen mode

❌ Bad

@Value("${app.storage.bucket}") String bucket;
@Value("${app.storage.timeoutSeconds}") int timeout; // scattered
Enter fullscreen mode Exit fullscreen mode

13) Use profiles for environment differences

Principle: No “if prod do X” in code.

✅ Good

@Profile("dev")
@Bean DataSource devDs() { ... }

@Profile("prod")
@Bean DataSource prodDs() { ... }
Enter fullscreen mode Exit fullscreen mode

❌ Bad

if ("prod".equals(System.getenv("ENV"))) { ... } // brittle
Enter fullscreen mode Exit fullscreen mode

14) Prefer immutability for DTOs and configs

Principle: Fewer bugs, easier reasoning.

✅ Good

record Money(BigDecimal amount, String currency) {}
Enter fullscreen mode Exit fullscreen mode

❌ Bad

class Money {
  public BigDecimal amount;
  public String currency; // mutable and public
}
Enter fullscreen mode Exit fullscreen mode

15) Avoid “God services”; keep single responsibility

Principle: One class should do one job.

✅ Good

class PricingService { ... }
class InventoryService { ... }
class CheckoutService { ... } // composes them
Enter fullscreen mode Exit fullscreen mode

❌ Bad

class AppService { // does pricing + inventory + email + auth + reports
}
Enter fullscreen mode Exit fullscreen mode

16) Use repository methods that match intent

Principle: Queries should be readable.

✅ Good

interface UserRepository extends JpaRepository<User, Long> {
  Optional<User> findByEmail(String email);
}
Enter fullscreen mode Exit fullscreen mode

❌ Bad

List<User> users = repo.findAll();
return users.stream().filter(u -> u.getEmail().equals(email)).findFirst();
Enter fullscreen mode Exit fullscreen mode

17) Avoid N+1 queries, fetch intentionally

Principle: Performance should not be accidental.

✅ Good

@Query("select o from Order o join fetch o.items where o.id = :id")
Optional<Order> findWithItems(@Param("id") Long id);
Enter fullscreen mode Exit fullscreen mode

❌ Bad

Order o = repo.findById(id).orElseThrow();
// later: o.getItems().size(); // triggers lazy loads per item chain
Enter fullscreen mode Exit fullscreen mode

18) Use pagination for list endpoints

Principle: Protect DB and API.

✅ Good

@GetMapping("/users")
Page<UserResponse> list(Pageable pageable) { ... }
Enter fullscreen mode Exit fullscreen mode

❌ Bad

@GetMapping("/users")
List<UserResponse> list() { return service.getAllUsers(); } // can explode
Enter fullscreen mode Exit fullscreen mode

19) Make endpoints idempotent where it matters

Principle: Retries should be safe.

✅ Good

@PutMapping("/users/{id}")
UserResponse update(@PathVariable Long id, @RequestBody UpdateUserRequest req) { ... }
Enter fullscreen mode Exit fullscreen mode

❌ Bad

@PostMapping("/users/{id}/update") // action-style, unclear semantics
Enter fullscreen mode Exit fullscreen mode

20) Prefer explicit response shapes (don’t return Map everywhere)

Principle: Strong contracts help clients.

✅ Good

record HealthResponse(String status, Instant time) {}
Enter fullscreen mode Exit fullscreen mode

❌ Bad

return Map.of("status", "ok", "t", System.currentTimeMillis());
Enter fullscreen mode Exit fullscreen mode

21) Use @Transactional(readOnly = true) for read paths

Principle: Helps performance and intent.

✅ Good

@Transactional(readOnly = true)
UserResponse get(long id) { ... }
Enter fullscreen mode Exit fullscreen mode

❌ Bad

@Transactional
UserResponse get(long id) { ... } // writes not needed
Enter fullscreen mode Exit fullscreen mode

22) Do not put external calls inside DB transactions

Principle: Transactions should be short.

✅ Good

var paymentId = paymentClient.charge(req);
@Transactional
void saveOrder(...) { ... }
Enter fullscreen mode Exit fullscreen mode

❌ Bad

@Transactional
void checkout(...) {
  paymentClient.charge(req); // slow network inside transaction
  repo.save(order);
}
Enter fullscreen mode Exit fullscreen mode

23) Use time abstractions for testability (Clock)

Principle: Don’t hardcode “now”.

✅ Good

@Service
class TokenService {
  private final Clock clock;
  TokenService(Clock clock) { this.clock = clock; }
  Instant now() { return Instant.now(clock); }
}
Enter fullscreen mode Exit fullscreen mode

❌ Bad

Instant issuedAt = Instant.now(); // hard to test
Enter fullscreen mode Exit fullscreen mode

24) Avoid static state and singletons

Principle: Spring already manages lifecycles.

✅ Good

@Component
class IdGenerator { String next() { ... } }
Enter fullscreen mode Exit fullscreen mode

❌ Bad

class IdGenerator {
  static final IdGenerator INSTANCE = new IdGenerator();
}
Enter fullscreen mode Exit fullscreen mode

25) Prefer interfaces at boundaries, not everywhere

Principle: Use interfaces where you have multiple impls or mocking needs.

✅ Good

interface EmailSender { void send(...); }
@Service class SesEmailSender implements EmailSender { ... }
Enter fullscreen mode Exit fullscreen mode

❌ Bad

interface UserService { ... } // only one impl forever, adds noise
class UserServiceImpl implements UserService { ... }
Enter fullscreen mode Exit fullscreen mode

26) Keep mapping logic in one place (mapper methods/classes)

Principle: Avoid scattered conversion code.

✅ Good

class UserMapper {
  static UserResponse toResponse(User u) { return new UserResponse(u.getId(), u.getEmail(), u.getName()); }
}
Enter fullscreen mode Exit fullscreen mode

❌ Bad

// mapping repeated in 7 controllers with slight differences
Enter fullscreen mode Exit fullscreen mode

27) Use OpenAPI/Swagger annotations (or docs) for public APIs

Principle: Docs should not be tribal knowledge.

✅ Good

@Operation(summary = "Create order")
@PostMapping("/orders")
...
Enter fullscreen mode Exit fullscreen mode

❌ Bad

// no docs, clients guess fields from examples
Enter fullscreen mode Exit fullscreen mode

28) Write tests at multiple levels (unit + slice + integration)

Principle: Catch bugs early and reliably.

✅ Good

@WebMvcTest(OrderController.class)
class OrderControllerTest { ... }
Enter fullscreen mode Exit fullscreen mode

❌ Bad

// only manual testing, no automated tests
Enter fullscreen mode Exit fullscreen mode

29) Use Testcontainers (or real dependencies) for DB integration tests

Principle: H2-only tests can lie.

✅ Good

@SpringBootTest
@Testcontainers
class OrderRepoIT { ... }
Enter fullscreen mode Exit fullscreen mode

❌ Bad

@SpringBootTest
class RepoTestUsingH2Only { ... } // passes but fails on Postgres
Enter fullscreen mode Exit fullscreen mode

30) Secure endpoints with Spring Security, not custom “if role”

Principle: Central policy, less risk.

✅ Good

@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/users/{id}")
void delete(@PathVariable long id) { ... }
Enter fullscreen mode Exit fullscreen mode

❌ Bad

if (!currentUser.isAdmin()) throw new RuntimeException("no"); // easy to miss
Enter fullscreen mode Exit fullscreen mode

31) Don’t expose internal exception messages directly to clients

Principle: Avoid leaking system details.

✅ Good

return ResponseEntity.status(500).body(Map.of("error", "Internal error"));
Enter fullscreen mode Exit fullscreen mode

❌ Bad

return ResponseEntity.status(500).body(Map.of("error", e.toString()));
Enter fullscreen mode Exit fullscreen mode

32) Use correlation IDs (trace IDs) for request tracking

Principle: Debugging needs linking logs across services.

✅ Good

// log pattern includes %X{traceId}; set it via filter once
Enter fullscreen mode Exit fullscreen mode

❌ Bad

// no request id; logs can’t be tied to a single call
Enter fullscreen mode Exit fullscreen mode

33) Use graceful shutdown and timeouts for clients

Principle: Prevent stuck threads and broken deploys.

✅ Good

// WebClient with timeouts; server.tomcat.threads tuned; graceful shutdown enabled
Enter fullscreen mode Exit fullscreen mode

❌ Bad

new RestTemplate(); // defaults, no timeouts, can hang
Enter fullscreen mode Exit fullscreen mode

34) Keep database schema changes managed (Flyway/Liquibase)

Principle: No “run this SQL manually” steps.

✅ Good

-- V3__add_status_to_orders.sql
ALTER TABLE orders ADD COLUMN status varchar(20) NOT NULL;
Enter fullscreen mode Exit fullscreen mode

❌ Bad

// app fails in prod because a column is missing and nobody ran the SQL
Enter fullscreen mode Exit fullscreen mode

35) Make code readable: names, small methods, no clever tricks

Principle: Maintainability wins.

✅ Good

boolean isEligible(User u) {
  return u.isActive() && u.getAge() >= 18;
}
Enter fullscreen mode Exit fullscreen mode

❌ Bad

boolean x(User u){return u.a()&&u.b()>17;} // cryptic
Enter fullscreen mode Exit fullscreen mode

Top comments (0)