02 - Backend Patterns (Spring Boot, Services, Repositories)¶
Layered Architecture¶
Backend follows a strict three-layer architecture:
REST Controllers (HTTP handling)
↓
Service Layer (business logic)
↓
Repository Layer (data access)
↓
Database (PostgreSQL)
Rule: Each layer has a single responsibility. No skipping layers.
Controllers (REST Endpoints)¶
Controller Design¶
Controllers are thin HTTP adapters. They delegate to services:
package fr.tiogars.data.dev.docs.brick.controllers;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@RestController
@Tag(name = "brick", description = "Gestion de la collection de briques et des liens externes associes.")
public class BrickController {
private final BrickListService brickListService;
private final BrickCreationService brickCreationService;
private final BrickDeleteAllService brickDeleteAllService;
public BrickController(
BrickListService brickListService,
BrickCreationService brickCreationService,
BrickDeleteAllService brickDeleteAllService
) {
this.brickListService = brickListService;
this.brickCreationService = brickCreationService;
this.brickDeleteAllService = brickDeleteAllService;
}
@GetMapping(\"/brick\")
@Operation(summary = \"Lister les briques\", description = \"Retourne la collection complete de briques.\")
public ResponseEntity<BrickListResponse> listBricks() {
return ResponseEntity.ok(brickListService.listBricks());
}
@PostMapping(\"/brick\")
@Operation(summary = \"Creer une brique\", description = \"Ajoute une nouvelle brique a la collection.\")
public ResponseEntity<Brick> createBrick(@RequestBody BrickCreationForm form) {
return ResponseEntity.ok(brickCreationService.createBrick(form));
}
@DeleteMapping(\"/brick\")
@Operation(summary = \"Supprimer toutes les briques\", description = \"Supprime l'ensemble des briques de la collection.\")
public ResponseEntity<Void> deleteAllBricks() {
brickDeleteAllService.deleteAllBricks();
return ResponseEntity.noContent().build();
}
}
Rules:
- Inject services via constructor dependency injection
- One service per operation (keep controllers thin)
- Use OpenAPI annotations (@Tag, @Operation) on all endpoints
- Return appropriate HTTP status codes (200, 201, 204, 400, 404, 500)
- No business logic in controllers
List Printing Support (Required)¶
For domains exposing list screens, backend must support printing both full and filtered data.
Contract and behavior rules:
- Reuse list filtering/sorting parameters for print requests
- Support two print modes: filtered and all
- Return stable ordering matching UI sort order
- Include print metadata (generatedAt, total) in the response
- Protect performance with bounded queries and validation on filter inputs
Suggested endpoint style:
@PostMapping("/brick/print")
@Operation(summary = "Imprimer la liste des briques", description = "Retourne les donnees imprimees en mode filtre ou complet.")
public ResponseEntity<BrickPrintResponse> printBricks(@RequestBody BrickPrintRequest request) {
return ResponseEntity.ok(brickPrintService.printBricks(request));
}
Separation of Concerns: One Service Per Operation¶
Instead of a single BrickService:
// GOOD: Separate services for each operation
- BrickListService
- BrickCreationService
- BrickUpdateService
- BrickDeleteOneService
- BrickDeleteAllService
- BrickGetOneService
Why? Single Responsibility Principle makes testing and maintenance easier.
Service Layer (Business Logic)¶
Services contain all business logic, validation, and error handling.
Service Structure¶
package fr.tiogars.data.dev.docs.brick.services;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class BrickCreationService {
private final BrickRepository brickRepository;
// Constructor injection (required for testing)
public BrickCreationService(BrickRepository brickRepository) {
this.brickRepository = brickRepository;
}
@Transactional
public Brick createBrick(BrickCreationForm form) {
// 1. Validate input
validateUniqueNumber(form.getNumber(), null);
// 2. Create entity
BrickEntity entity = new BrickEntity();
applyValues(entity, form.getNumber(), form.getTitle(), form.getTags(), form.getImageBase64());
// 3. Persist
return BrickModelMapper.toModel(brickRepository.save(entity));
}
private void validateUniqueNumber(String number, String excludeId) {
Optional<BrickEntity> existing = brickRepository.findByNumber(number);
if (existing.isPresent() && !existing.get().getId().equals(excludeId)) {
throw new IllegalArgumentException(\"Le numero de brique est deja utilise: \" + number);
}
}
static void applyValues(BrickEntity entity, String number, String title, List<String> tags, String imageBase64) {
entity.setNumber(requireText(number, \"Le numero de brique est obligatoire.\"));
entity.setTitle(requireText(title, \"Le titre de la brique est obligatoire.\"));
entity.setTags(BrickModelMapper.tagsToCsv(normalizeTags(tags)));
entity.setImageBase64(normalizeNullableText(imageBase64));
}
private static String requireText(String value, String message) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(message);
}
return value.trim();
}
}
Rules:
- Use @Transactional for methods that modify data
- Validate inputs early (fail fast)
- Throw IllegalArgumentException for validation errors (caught by @RestControllerAdvice)
- Inject repositories, not entities
- Keep methods focused (do one thing)
- Use helper static methods for repeated logic
Validation Pattern¶
Validation happens in the Service layer, not in controllers or entities:
public void validateBrick(BrickCreationForm form) {
if (form.getNumber() == null || form.getNumber().trim().isEmpty()) {
throw new IllegalArgumentException(\"Le numero est obligatoire.\");
}
if (form.getTitle() == null || form.getTitle().trim().isEmpty()) {
throw new IllegalArgumentException(\"Le titre est obligatoire.\");
}
// Check uniqueness
brickRepository.findByNumber(form.getNumber()).ifPresent(existing -> {
throw new IllegalArgumentException(\"Le numero est deja utilise.\");
});
}
Repository Layer (Data Access)¶
Repositories are Spring Data JpaRepository interfaces:
package fr.tiogars.data.dev.docs.brick.repositories;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BrickRepository extends JpaRepository<BrickEntity, String> {
Optional<BrickEntity> findByNumber(String number);
// Custom queries can be added, but keep them simple
// List<BrickEntity> findByTagsContaining(String tag);
}
Rules:
- Extend JpaRepository<Entity, IdType>
- Declare as @Repository if not extending JpaRepository
- Use Spring Data query methods (avoid custom @Query)
- Let services handle complex query logic
Entity & DTO Patterns¶
Entities (JPA)¶
Entities represent database tables:
package fr.tiogars.data.dev.docs.brick.entities;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = \"brick\")
public class BrickEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = \"id\", nullable = false, updatable = false)
private String id;
@Column(name = \"number\", nullable = false, unique = true)
private String number;
@Column(name = \"title\", nullable = false)
private String title;
@Column(name = \"tags\", nullable = false, length = 4000)
private String tags; // CSV format
@Column(name = \"image_base64\", columnDefinition = \"TEXT\")
private String imageBase64;
@Column(name = \"created_at\", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = \"updated_at\", nullable = false)
private Instant updatedAt;
// Lifecycle hooks
@PrePersist
void onCreate() {
Instant now = Instant.now();
if (createdAt == null) {
createdAt = now;
}
updatedAt = now;
}
@PreUpdate
void onUpdate() {
updatedAt = Instant.now();
}
// Getters and setters (generated by IDE)
public String getId() { return id; }
public void setId(String id) { this.id = id; }
// ... etc
}
Rules:
- Use @Entity and @Table annotations
- Use @Id with @GeneratedValue(strategy = GenerationType.UUID)
- Database column names are snake_case
- Use @PrePersist and @PreUpdate for timestamps
- No business logic in entities
DTOs (Data Transfer Objects)¶
Response Models (exported to API)¶
package fr.tiogars.data.dev.docs.brick.models;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.List;
public class Brick {
@Schema(description = \"Identifiant unique de la brique.\", example = \"123e4567-e89b-12d3-a456-426614174000\")
private String id;
@Schema(description = \"Numero de reference de la brique.\", example = \"60284\")
private String number;
@Schema(description = \"Titre de la brique.\", example = \"Le camion de chantier\")
private String title;
@Schema(description = \"Tags de classification de la brique.\", example = \"[\\\"city\\\",\\\"truck\\\"]\")
private List<String> tags;
@Schema(description = \"Image en data URL base64.\")
private String imageBase64;
@Schema(description = \"Date de creation.\", example = \"2025-12-22T12:14:59.569Z\")
private Instant createdAt;
@Schema(description = \"Date de derniere modification.\")
private Instant updatedAt;
// Getters and setters
}
Request Forms (input validation)¶
package fr.tiogars.data.dev.docs.brick.forms;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
public class BrickCreationForm {
@Schema(description = \"Numero de reference.\", example = \"60284\")
private String number;
@Schema(description = \"Titre.\", example = \"Le camion de chantier\")
private String title;
@Schema(description = \"Tags de classification.\")
private List<String> tags;
@Schema(description = \"Image en data URL base64 (optional).\")
private String imageBase64;
// Getters and setters
}
Rules:
- Response models: immutable from client perspective, all fields with @Schema
- Request forms: mutable, only fields needed for input
- All public fields have @Schema documentation (French)
- Use descriptive examples in schemas
Model Mappers¶
Mappers convert entities ↔ DTOs:
package fr.tiogars.data.dev.docs.brick.services;
import fr.tiogars.data.dev.docs.brick.entities.BrickEntity;
import fr.tiogars.data.dev.docs.brick.models.Brick;
import java.util.List;
final class BrickModelMapper {
private BrickModelMapper() {
}
static Brick toModel(BrickEntity entity) {
Brick model = new Brick();
model.setId(entity.getId());
model.setNumber(entity.getNumber());
model.setTitle(entity.getTitle());
model.setTags(csvToTags(entity.getTags()));
model.setImageBase64(entity.getImageBase64());
model.setCreatedAt(entity.getCreatedAt());
model.setUpdatedAt(entity.getUpdatedAt());
return model;
}
static BrickEntity toEntity(Brick model) {
BrickEntity entity = new BrickEntity();
entity.setNumber(model.getNumber());
entity.setTitle(model.getTitle());
entity.setTags(tagsToCsv(model.getTags()));
entity.setImageBase64(model.getImageBase64());
return entity;
}
private static List<String> csvToTags(String csv) {
if (csv == null || csv.isEmpty()) return List.of();
return Arrays.asList(csv.split(\",\"));
}
static String tagsToCsv(List<String> tags) {
if (tags == null || tags.isEmpty()) return \"\";
return String.join(\",\", tags);
}
}
Rules: - Keep mappers simple (no business logic) - Use static methods - Package-private (only used within domain)
Error Handling (Global Exception Handler)¶
Exceptions are caught by a centralized @RestControllerAdvice:
package fr.tiogars.data.common.controllers.advices;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import fr.tiogars.data.common.exceptions.DataNotFoundException;
@RestControllerAdvice
public class GlobalControllerExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<String> handleIllegalArgument(IllegalArgumentException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
}
@ExceptionHandler(DataNotFoundException.class)
public ResponseEntity<String> handleNotFound(DataNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleGenericException(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(\"Une erreur inattendue s'est produite.\");
}
}
Rules:
- Throw IllegalArgumentException for validation errors → 400 Bad Request
- Throw DataNotFoundException for missing resources → 404 Not Found
- Centralize error responses in @RestControllerAdvice
- Log unexpected errors
Transactions¶
Use @Transactional on service methods that modify data:
@Service
public class BrickUpdateService {
@Transactional
public Brick updateBrick(String id, BrickUpdateForm form) {
BrickEntity entity = brickRepository.findById(id)
.orElseThrow(() -> new DataNotFoundException(\"Brick not found: \" + id));
entity.setTitle(form.getTitle());
entity.setTags(tagsTocsv(form.getTags()));
return BrickModelMapper.toModel(brickRepository.save(entity));
}
}
Rules:
- Use @Transactional on update/delete operations
- Omit on read-only queries (optional)
- Rollback automatically on exception
Dependency Injection & Testing¶
Always use constructor injection for testability:
// GOOD: Constructor injection
@Service
public class BrickCreationService {
private final BrickRepository repo;
public BrickCreationService(BrickRepository repo) {
this.repo = repo;
}
}
// AVOID: Field injection
@Service
public class BrickCreationService {
@Autowired
private BrickRepository repo;
}
Constructor injection makes mocking easier in tests.
OpenAPI Documentation¶
All endpoints and models must have OpenAPI annotations:
@RestController
@Tag(name = \"brick\", description = \"Gestion des briques.\")
public class BrickController {
@GetMapping(\"/brick/{id}\")
@Operation(summary = \"Obtenir une brique\", description = \"Retourne une brique par son identifiant.\")
public ResponseEntity<Brick> getBrickById(@PathVariable String id) {
// Implementation
}
}
In models:
public class Brick {
@Schema(description = \"Identifiant unique.\", example = \"uuid-123\")
private String id;
}
Testing Services¶
See Testing Conventions for detailed testing patterns.
Quick example:
@SpringBootTest
class BrickCreationServiceTest {
@Autowired
private BrickCreationService service;
@MockBean
private BrickRepository repository;
@Test
void shouldCreateBrick() {
BrickCreationForm form = new BrickCreationForm();
form.setNumber(\"60284\");
form.setTitle(\"Test\");
service.createBrick(form);
verify(repository).save(any(BrickEntity.class));
}
}