Sunday, February 1, 2026

Spring Boot API Design & Architecture

 

 API Design & Architecture

Outcome: design clean, predictable, boring-in-a-good-way APIs


1️⃣ Controller–Service–Repository Separation

Controller (HTTP layer)

Responsibilities

  • Request/response mapping

  • Validation trigger

  • HTTP status codes

Rules

  • No business logic

  • No try/catch

  • No repository access

@PostMapping("/orders")
@ResponseStatus(HttpStatus.CREATED)
public OrderResponse create(@Valid @RequestBody CreateOrderRequest req) {
    return orderService.create(req);
}

Service (Business layer)

Responsibilities

  • Business rules

  • Transactions

  • Orchestration

Rules

  • No HTTP concepts

  • Throws domain exceptions

@Transactional
public OrderResponse create(CreateOrderRequest req) {
    if (!productExists(req.productId())) {
        throw new ResourceNotFoundException("Product not found");
    }
    return mapper.toResponse(orderRepo.save(mapper.toEntity(req)));
}

Repository (Data layer)

Responsibilities

  • DB access only

Rules

  • No business logic

  • No DTOs

Optional<Order> findById(Long id);

📌 Lead rule:

If it depends on HTTP → Controller
If it depends on DB → Repository
Everything else → Service


2️⃣ DTO vs Entity (Strict boundary)

Never expose Entities

Reasons:

  • DB coupling

  • Lazy loading issues

  • Security leaks

  • Versioning pain

✅ Use DTOs

Request DTO

record CreateOrderRequest(
    @NotNull Long productId,
    @Min(1) int quantity
) {}

Response DTO

record OrderResponse(
    Long id,
    int quantity,
    BigDecimal total
) {}

📌 Lead rule:

Entities never cross the service boundary


3️⃣ HTTP Status Codes (Intentional, not random)

CaseStatus
Create201 CREATED
Read / Update200 OK
Delete204 NO CONTENT
Validation error400 BAD REQUEST
Not found404 NOT FOUND
Duplicate409 CONFLICT
Auth required401
Forbidden403
Server error500

📌 Lead rule:

Status code tells the story before the body


4️⃣ Error Model (Single, consistent shape)

✅ Standard error response

{
  "timestamp": "2026-02-02T10:15:30",
  "status": 400,
  "error": "VALIDATION_ERROR",
  "message": "Invalid request",
  "path": "/orders",
  "details": {
    "quantity": "must be greater than 0"
  }
}

📌 Lead rule:

Errors are for machines first, humans second


5️⃣ Global Exception Handling

✅ Centralized handling

@RestControllerAdvice
public class GlobalExceptionHandler {

  @ExceptionHandler(MethodArgumentNotValidException.class)
  public ResponseEntity<ApiError> validation(...) {}

  @ExceptionHandler(ResourceNotFoundException.class)
  public ResponseEntity<ApiError> notFound(...) {}

  @ExceptionHandler(Exception.class)
  public ResponseEntity<ApiError> fallback(...) {}
}

Rules

  • No try/catch in controllers

  • Domain exceptions map to HTTP here

📌 Lead rule:

Controllers must be tiny and boring


6️⃣ Validation (Fail fast)

Bean Validation

@NotBlank
@Email
private String email;

Rules

  • Validation at DTO level

  • Service assumes valid input

  • Custom validators only when needed

📌 Lead rule:

Invalid data never reaches business logic


7️⃣ PR Review Checklist (Week 1)

Reject if you see

  • Entity in controller response

  • try/catch in controller

  • 200 for every response

  • Validation inside service

  • Repository logic in controller

Approve when

  • Thin controllers

  • Clear DTOs

  • Centralized errors

  • Correct status codes

  • Consistent error model


 Spring Boot API Design & Architecture

1️⃣ Package Structure (Feature-based, not layer-dump)

❌ Bad (layer-based)

controller/
service/
repository/
dto/

Good (feature-based)

user/
 ├─ UserController
 ├─ UserService
 ├─ UserRepository
 ├─ dto/
 │   ├─ CreateUserRequest
 │   ├─ UserResponse
 └─ exception/
     └─ UserNotFoundException

📌 Lead rule:

Features scale, layers rot.


2️⃣ Controller–Service–Repository (Spring way)

Controller

@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {

  private final UserService service;

  @PostMapping
  @ResponseStatus(HttpStatus.CREATED)
  public UserResponse create(@Valid @RequestBody CreateUserRequest req) {
    return service.create(req);
  }
}

Rules

  • @Valid only here

  • No try/catch

  • No entities


Service

@Service
@RequiredArgsConstructor
@Transactional
public class UserService {

  private final UserRepository repo;

  public UserResponse create(CreateUserRequest req) {
    if (repo.existsByEmail(req.email())) {
      throw new DuplicateResourceException("Email exists");
    }
    return UserMapper.toResponse(repo.save(UserMapper.toEntity(req)));
  }
}

Repository

public interface UserRepository extends JpaRepository<User, Long> {
  boolean existsByEmail(String email);
}

3️⃣ DTO vs Entity (Spring JPA safe)

❌ Entity

@Entity
class User {
  @Id @GeneratedValue
  Long id;
}

🚫 Never return this

DTO

public record UserResponse(
  Long id,
  String name,
  String email
) {}

📌 Rule:

Entity never leaves service layer.


4️⃣ Validation (Hibernate Validator)

public record CreateUserRequest(
  @NotBlank String name,
  @Email String email,
  @Size(min = 8) String password
) {}
  • Runs before the controller method

  • Fails fast with 400


5️⃣ Global Exception Handling (@RestControllerAdvice)

@RestControllerAdvice
public class GlobalExceptionHandler {

  @ExceptionHandler(MethodArgumentNotValidException.class)
  ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException ex) {
    return ResponseEntity.badRequest().body(ApiError.from(ex));
  }

  @ExceptionHandler(EntityNotFoundException.class)
  ResponseEntity<ApiError> handleNotFound(EntityNotFoundException ex) {
    return ResponseEntity.status(404).body(ApiError.notFound(ex));
  }
}

6️⃣ Standard Error Model

public record ApiError(
  Instant timestamp,
  int status,
  String error,
  String message,
  String path,
  Map<String, String> details
) {}

📌 Rule:

One error shape for the entire API.


7️⃣ HTTP Status Codes (Spring defaults)

CaseSpring
Create@ResponseStatus(CREATED)
Delete@ResponseStatus(NO_CONTENT)
ValidationMethodArgumentNotValidException
Not foundEntityNotFoundException
ConflictResponseStatusException(409)

8️⃣ Pagination (Spring Data)

@GetMapping
public Page<UserResponse> list(Pageable pageable) {
  return service.list(pageable);
}

Frontend-friendly, zero effort.


9️⃣ API Versioning

@RequestMapping("/api/v1/users")

📌 Never skip this.


🔟 Logging & Correlation ID (Must-have)

@Component
public class CorrelationIdFilter extends OncePerRequestFilter {
  protected void doFilterInternal(...) {
    MDC.put("correlationId", UUID.randomUUID().toString());
    filterChain.doFilter(req, res);
    MDC.clear();
  }
}

1️⃣1️⃣ Time & Serialization

spring:
  jackson:
    time-zone: UTC
    serialization:
      write-dates-as-timestamps: false

Always ISO-8601.


1️⃣2️⃣ OpenAPI / Swagger

springdoc-openapi-starter-webmvc-ui

Auto-docs your DTOs + validation.

📌 Rule:

Swagger is part of the API, not optional.


1️⃣3️⃣ Tests (Week-1 scope)

  • @WebMvcTest → controller

  • Plain JUnit → service

  • No DB in unit tests


 

No comments:

Post a Comment

LeetCode C++ Cheat Sheet June

🎯 Core Patterns & Representative Questions 1. Arrays & Hashing Two Sum – hash map → O(n) Contains Duplicate , Product of A...