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)
| Case | Status |
|---|---|
| Create | 201 CREATED |
| Read / Update | 200 OK |
| Delete | 204 NO CONTENT |
| Validation error | 400 BAD REQUEST |
| Not found | 404 NOT FOUND |
| Duplicate | 409 CONFLICT |
| Auth required | 401 |
| Forbidden | 403 |
| Server error | 500 |
📌 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
@Validonly hereNo
try/catchNo 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)
| Case | Spring |
|---|---|
| Create | @ResponseStatus(CREATED) |
| Delete | @ResponseStatus(NO_CONTENT) |
| Validation | MethodArgumentNotValidException |
| Not found | EntityNotFoundException |
| Conflict | ResponseStatusException(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→ controllerPlain JUnit → service
No DB in unit tests
No comments:
Post a Comment