Back-end angularforall.com

- CRUD Springboot & Angular : REST API complète

Springboot Angular Crud Rest-Api Java Jpa Mysql Httpclient Full-Stack
CRUD Springboot & Angular : REST API complète

Construisez une app CRUD Spring Boot, JPA, MySQL et Angular : API REST, service HttpClient, Reactive Forms, validations et déploiement Docker.

1. Introduction et architecture

Construire une application CRUD (Create, Read, Update, Delete) avec Spring Boot et Angular est l'une des combinaisons les plus populaires en développement full-stack professionnel. Spring Boot gère toute la logique serveur — base de données, API REST, sécurité — tandis qu'Angular construit l'interface utilisateur dans le navigateur.

Dans ce guide, nous allons construire une application de gestion de tâches (Todo) complète. Chaque partie est clairement séparée pour que vous compreniez le rôle de chaque technologie.

🧠 Comment lire ce guide ?
  • 🟦 Sections bleues = code Java/Spring Boot (côté serveur)
  • 🟧 Sections orangées = code TypeScript/Angular (côté navigateur)
  • Les deux parties communiquent via des requêtes HTTP au format JSON

Architecture générale


┌─────────────────────────────────────────────────────────┐
│                    NAVIGATEUR (Angular)                   │
│   Composants → Service HttpClient → Requêtes HTTP/JSON   │
└───────────────────────┬─────────────────────────────────┘
                        │ HTTP (GET, POST, PUT, DELETE)
                        │ JSON
┌───────────────────────▼─────────────────────────────────┐
│                 SERVEUR (Spring Boot)                     │
│  Controller → Service → Repository → Base de données     │
│                         (JPA)         (MySQL/H2)         │
└─────────────────────────────────────────────────────────┘

Flux d'une requête CRUD

Action utilisateur Angular (front) HTTP Spring Boot (back)
Voir la liste getTodos() GET /api/todos TodoController.getAll()
Créer une tâche createTodo(data) POST /api/todos TodoController.create()
Modifier updateTodo(id, data) PUT /api/todos/{id} TodoController.update()
Supprimer deleteTodo(id) DELETE /api/todos/{id} TodoController.delete()

2. Prérequis et installation

Installez ces outils avant de commencer :

🟦 Côté back-end (Java/Spring Boot)

  • Java 17+ — télécharger depuis adoptium.net (Eclipse Temurin recommandé)
  • Maven 3.8+ ou utiliser le wrapper ./mvnw inclus dans le projet
  • MySQL 8+ ou H2 (base en mémoire pour les tests)
  • IntelliJ IDEA Community (recommandé) ou VS Code avec l'extension Java

🟧 Côté front-end (Angular)

  • Node.js 18+ — télécharger depuis nodejs.org
  • Angular CLI 17+
  • VS Code avec les extensions Angular Language Service et ESLint

# Vérifier Java
java --version
# → java 17.0.x ...

# Vérifier Maven
mvn --version
# → Apache Maven 3.8.x ...

# Installer Angular CLI
npm install -g @angular/cli@17

# Vérifier Angular CLI
ng version
# → Angular CLI: 17.x.x

Générer le projet Spring Boot

Utilisez start.spring.io pour générer le projet avec ces dépendances :

  • Spring Web — pour les contrôleurs REST
  • Spring Data JPA — pour accéder à la base de données
  • MySQL Driver (ou H2 pour les tests)
  • Validation — pour valider les données
  • Lombok — pour éviter le code boilerplate (getters/setters)

3. 🟦 Back-end : structure du projet Spring Boot

Arborescence recommandée


todo-api/
├── src/
│   └── main/
│       ├── java/com/example/todo/
│       │   ├── TodoApiApplication.java   ← Point d'entrée
│       │   ├── controller/
│       │   │   └── TodoController.java   ← Endpoints REST
│       │   ├── service/
│       │   │   ├── TodoService.java      ← Interface
│       │   │   └── TodoServiceImpl.java  ← Implémentation
│       │   ├── repository/
│       │   │   └── TodoRepository.java   ← Accès BDD (JPA)
│       │   ├── model/
│       │   │   └── Todo.java             ← Entité JPA
│       │   └── dto/
│       │       ├── CreateTodoRequest.java
│       │       └── UpdateTodoRequest.java
│       └── resources/
│           └── application.properties    ← Configuration
└── pom.xml                               ← Dépendances Maven

Configuration application.properties


# Connexion MySQL
spring.datasource.url=jdbc:mysql://localhost:3306/tododb?createDatabaseIfNotExist=true
spring.datasource.username=root
spring.datasource.password=votre_mot_de_passe
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# JPA et Hibernate
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect

# Port du serveur (par défaut 8080)
server.port=8080
💡 Astuce débutant : ddl-auto=update crée automatiquement les tables en base de données à partir de vos entités Java. En production, utilisez validate et gérez les migrations manuellement avec Flyway.

4. 🟦 Back-end : entité JPA et repository

Une entité JPA est une classe Java qui correspond directement à une table dans la base de données. Spring Data JPA se charge de créer la table et de générer les requêtes SQL.

Entité Todo.java


package com.example.todo.model;

import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.time.LocalDateTime;

// @Entity indique que cette classe correspond à une table en base de données
@Entity
// @Table permet de nommer la table (optionnel, sinon le nom de la classe est utilisé)
@Table(name = "todos")
// @Data de Lombok génère automatiquement getters, setters, equals, hashCode, toString
@Data
public class Todo {

    // Clé primaire auto-incrémentée
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Titre obligatoire entre 3 et 200 caractères
    @NotBlank(message = "Le titre ne peut pas être vide")
    @Size(min = 3, max = 200, message = "Le titre doit contenir entre 3 et 200 caractères")
    @Column(nullable = false, length = 200)
    private String title;

    // Description optionnelle
    @Size(max = 1000, message = "La description ne peut pas dépasser 1000 caractères")
    @Column(length = 1000)
    private String description;

    // Statut de complétion (false par défaut)
    @Column(nullable = false)
    private boolean completed = false;

    // Priorité : 1=Basse, 2=Moyenne, 3=Haute
    @Min(value = 1, message = "La priorité minimale est 1")
    @Max(value = 3, message = "La priorité maximale est 3")
    @Column(nullable = false)
    private int priority = 2;

    // Dates de création et modification (gérées automatiquement)
    @Column(updatable = false)
    private LocalDateTime createdAt;

    private LocalDateTime updatedAt;

    // Méthodes de callback JPA pour gérer les dates automatiquement
    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
        updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        updatedAt = LocalDateTime.now();
    }
}

Repository TodoRepository.java

Le repository est l'interface qui permet d'accéder à la base de données. Spring Data JPA génère automatiquement toutes les requêtes SQL.


package com.example.todo.repository;

import com.example.todo.model.Todo;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;

// JpaRepository fournit déjà findAll(), findById(), save(), delete()...
// Pas besoin d'écrire du SQL pour les opérations de base !
@Repository
public interface TodoRepository extends JpaRepository {

    // Spring Data JPA génère automatiquement la requête SQL depuis le nom de la méthode
    List findByCompletedOrderByCreatedAtDesc(boolean completed);

    // Requête JPQL personnalisée pour filtrer par priorité
    @Query("SELECT t FROM Todo t WHERE t.priority = :priority ORDER BY t.createdAt DESC")
    List findByPriority(int priority);

    // Recherche par mot-clé dans le titre (insensible à la casse)
    List findByTitleContainingIgnoreCaseOrderByCreatedAtDesc(String keyword);
}
🎯 Pourquoi une interface et pas une classe ? Spring Data JPA implémente automatiquement l'interface à votre place. Vous déclarez juste les signatures des méthodes, Spring génère le SQL correspondant. C'est la magie de JPA !

5. 🟦 Back-end : service métier

Le service contient toute la logique métier de l'application. Il fait le lien entre le contrôleur (qui reçoit les requêtes HTTP) et le repository (qui accède à la base de données).

Interface TodoService.java


package com.example.todo.service;

import com.example.todo.model.Todo;
import java.util.List;
import java.util.Optional;

// L'interface définit le contrat de service
// Les contrôleurs dépendent de l'interface, pas de l'implémentation → couplage faible
public interface TodoService {
    List<Todo> findAll();
    Optional<Todo> findById(Long id);
    Todo create(Todo todo);
    Optional<Todo> update(Long id, Todo todo);
    boolean delete(Long id);
    List<Todo> findByCompleted(boolean completed);
    List<Todo> search(String keyword);
}

Implémentation TodoServiceImpl.java


package com.example.todo.service;

import com.example.todo.model.Todo;
import com.example.todo.repository.TodoRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;

// @Service indique à Spring que cette classe est un service à injecter
@Service
// @RequiredArgsConstructor génère automatiquement le constructeur avec injection de dépendances
@RequiredArgsConstructor
// @Slf4j active la journalisation (log.info, log.error, etc.)
@Slf4j
public class TodoServiceImpl implements TodoService {

    // Injection par constructeur (recommandé par Spring)
    private final TodoRepository todoRepository;

    @Override
    public List<Todo> findAll() {
        log.info("Récupération de tous les todos");
        return todoRepository.findAll();
    }

    @Override
    public Optional<Todo> findById(Long id) {
        log.info("Recherche du todo avec l'id : {}", id);
        return todoRepository.findById(id);
    }

    @Override
    // @Transactional garantit que l'opération est atomique (tout ou rien)
    @Transactional
    public Todo create(Todo todo) {
        log.info("Création d'un nouveau todo : {}", todo.getTitle());
        return todoRepository.save(todo);
    }

    @Override
    @Transactional
    public Optional<Todo> update(Long id, Todo updatedTodo) {
        return todoRepository.findById(id).map(existing -> {
            // Mise à jour uniquement des champs fournis
            if (updatedTodo.getTitle() != null) {
                existing.setTitle(updatedTodo.getTitle());
            }
            if (updatedTodo.getDescription() != null) {
                existing.setDescription(updatedTodo.getDescription());
            }
            // Mise à jour du statut et de la priorité
            existing.setCompleted(updatedTodo.isCompleted());
            existing.setPriority(updatedTodo.getPriority());

            log.info("Todo {} mis à jour", id);
            return todoRepository.save(existing);
        });
    }

    @Override
    @Transactional
    public boolean delete(Long id) {
        if (todoRepository.existsById(id)) {
            todoRepository.deleteById(id);
            log.info("Todo {} supprimé", id);
            return true;
        }
        log.warn("Todo {} introuvable pour suppression", id);
        return false;
    }

    @Override
    public List<Todo> findByCompleted(boolean completed) {
        return todoRepository.findByCompletedOrderByCreatedAtDesc(completed);
    }

    @Override
    public List<Todo> search(String keyword) {
        return todoRepository.findByTitleContainingIgnoreCaseOrderByCreatedAtDesc(keyword);
    }
}

6. 🟦 Back-end : contrôleur REST

Le contrôleur reçoit les requêtes HTTP d'Angular et retourne des réponses JSON. C'est le point d'entrée de l'API.

DTOs (Data Transfer Objects)

Les DTOs séparent les données de l'API de la structure interne de la base de données :


// CreateTodoRequest.java — données reçues lors de la création
package com.example.todo.dto;

import jakarta.validation.constraints.*;
import lombok.Data;

@Data
public class CreateTodoRequest {
    @NotBlank(message = "Le titre est obligatoire")
    @Size(min = 3, max = 200)
    private String title;

    @Size(max = 1000)
    private String description;

    @Min(1) @Max(3)
    private int priority = 2;
}

// UpdateTodoRequest.java — données reçues lors d'une mise à jour
@Data
public class UpdateTodoRequest {
    @Size(min = 3, max = 200)
    private String title;

    @Size(max = 1000)
    private String description;

    private Boolean completed;

    @Min(1) @Max(3)
    private Integer priority;
}

TodoController.java — API REST complète


package com.example.todo.controller;

import com.example.todo.dto.CreateTodoRequest;
import com.example.todo.dto.UpdateTodoRequest;
import com.example.todo.model.Todo;
import com.example.todo.service.TodoService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;

// @RestController = @Controller + @ResponseBody (retourne du JSON automatiquement)
@RestController
// Toutes les routes de ce contrôleur commencent par /api/todos
@RequestMapping("/api/todos")
// Permet les requêtes cross-origin depuis Angular (localhost:4200)
@CrossOrigin(origins = "http://localhost:4200")
@RequiredArgsConstructor
public class TodoController {

    private final TodoService todoService;

    // GET /api/todos — liste tous les todos
    // Paramètre optionnel : ?completed=true pour filtrer
    @GetMapping
    public ResponseEntity<List<Todo>> getAll(
            @RequestParam(required = false) Boolean completed,
            @RequestParam(required = false) String search) {

        List<Todo> todos;
        if (completed != null) {
            todos = todoService.findByCompleted(completed);
        } else if (search != null && !search.isBlank()) {
            todos = todoService.search(search);
        } else {
            todos = todoService.findAll();
        }
        // ResponseEntity.ok() retourne HTTP 200 avec le corps JSON
        return ResponseEntity.ok(todos);
    }

    // GET /api/todos/{id} — récupère un todo par son id
    @GetMapping("/{id}")
    public ResponseEntity<Todo> getById(@PathVariable Long id) {
        return todoService.findById(id)
                // HTTP 200 si trouvé
                .map(ResponseEntity::ok)
                // HTTP 404 si non trouvé
                .orElse(ResponseEntity.notFound().build());
    }

    // POST /api/todos — crée un nouveau todo
    // @Valid déclenche la validation des annotations sur CreateTodoRequest
    @PostMapping
    public ResponseEntity<Todo> create(@Valid @RequestBody CreateTodoRequest request) {
        // Convertir le DTO en entité
        Todo todo = new Todo();
        todo.setTitle(request.getTitle());
        todo.setDescription(request.getDescription());
        todo.setPriority(request.getPriority());

        Todo created = todoService.create(todo);
        // HTTP 201 Created avec le todo créé dans le corps
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    // PUT /api/todos/{id} — met à jour un todo
    @PutMapping("/{id}")
    public ResponseEntity<Todo> update(
            @PathVariable Long id,
            @Valid @RequestBody UpdateTodoRequest request) {

        Todo todo = new Todo();
        todo.setTitle(request.getTitle());
        todo.setDescription(request.getDescription());
        if (request.getCompleted() != null) todo.setCompleted(request.getCompleted());
        if (request.getPriority() != null) todo.setPriority(request.getPriority());

        return todoService.update(id, todo)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    // DELETE /api/todos/{id} — supprime un todo
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        if (todoService.delete(id)) {
            // HTTP 204 No Content — succès sans corps de réponse
            return ResponseEntity.noContent().build();
        }
        return ResponseEntity.notFound().build();
    }
}

Tester l'API avec curl


# Démarrer Spring Boot
./mvnw spring-boot:run
# → API disponible sur http://localhost:8080

# Lister tous les todos
curl http://localhost:8080/api/todos

# Créer un todo
curl -X POST http://localhost:8080/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Apprendre Spring Boot", "priority": 3}'

# Mettre à jour le todo avec l'id 1
curl -X PUT http://localhost:8080/api/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"completed": true}'

# Supprimer le todo avec l'id 1
curl -X DELETE http://localhost:8080/api/todos/1

7. 🟧 Front-end : structure du projet Angular

Créer le projet Angular


# Créer un nouveau projet Angular
ng new todo-angular --routing=true --style=scss

# Se déplacer dans le projet
cd todo-angular

# Installer Bootstrap pour le design
npm install bootstrap

# Ajouter Bootstrap dans angular.json (section styles)
# "styles": ["node_modules/bootstrap/dist/css/bootstrap.min.css", "src/styles.scss"]

# Générer les composants et services nécessaires
ng generate service services/todo
ng generate component components/todo-list
ng generate component components/todo-form
ng generate component components/todo-item

Arborescence Angular


todo-angular/
├── src/
│   └── app/
│       ├── models/
│       │   └── todo.model.ts          ← Interface TypeScript
│       ├── services/
│       │   └── todo.service.ts        ← Appels API REST
│       └── components/
│           ├── todo-list/             ← Affiche la liste
│           │   ├── todo-list.component.ts
│           │   └── todo-list.component.html
│           ├── todo-item/             ← Affiche un todo
│           │   ├── todo-item.component.ts
│           │   └── todo-item.component.html
│           └── todo-form/             ← Formulaire création/édition
│               ├── todo-form.component.ts
│               └── todo-form.component.html
├── src/
│   └── environments/
│       ├── environment.ts             ← Config développement
│       └── environment.prod.ts        ← Config production
└── angular.json                       ← Configuration Angular CLI

Interface TypeScript (models/todo.model.ts)

En TypeScript, on définit une interface pour typer les données reçues de l'API :


// Interface qui correspond exactement au JSON retourné par Spring Boot
export interface Todo {
  id: number;
  title: string;
  description?: string;  // ? = champ optionnel
  completed: boolean;
  priority: 1 | 2 | 3;  // Littéral de type : uniquement 1, 2 ou 3
  createdAt: string;     // ISO 8601 (ex: "2026-05-05T10:30:00")
  updatedAt: string;
}

// Interface pour la création (sans id ni dates — générés par le serveur)
export interface CreateTodoDto {
  title: string;
  description?: string;
  priority?: 1 | 2 | 3;
}

// Interface pour la mise à jour (tous les champs sont optionnels)
export interface UpdateTodoDto {
  title?: string;
  description?: string;
  completed?: boolean;
  priority?: 1 | 2 | 3;
}

// Enum lisible pour les priorités
export enum Priority {
  LOW    = 1,
  MEDIUM = 2,
  HIGH   = 3
}

export const PRIORITY_LABELS: Record<number, string> = {
  1: '🟢 Basse',
  2: '🟡 Moyenne',
  3: '🔴 Haute'
};

Configuration de l'environnement


// src/environments/environment.ts (développement)
export const environment = {
  production: false,
  // URL de l'API Spring Boot en développement
  apiUrl: 'http://localhost:8080/api'
};

// src/environments/environment.prod.ts (production)
export const environment = {
  production: true,
  // URL de l'API en production
  apiUrl: 'https://votre-domaine.com/api'
};

8. 🟧 Front-end : service HttpClient

Le service Angular centralise tous les appels HTTP vers l'API Spring Boot. Les composants utilisent ce service au lieu d'appeler l'API directement.

Configuration de HttpClient (app.module.ts)


import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
// HttpClientModule active les appels HTTP dans toute l'application
import { HttpClientModule } from '@angular/common/http';
import { ReactiveFormsModule } from '@angular/forms';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';

@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    AppRoutingModule,
    HttpClientModule,    // ← OBLIGATOIRE pour utiliser HttpClient
    ReactiveFormsModule  // ← Pour les formulaires réactifs
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

TodoService complet (services/todo.service.ts)


import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { Todo, CreateTodoDto, UpdateTodoDto } from '../models/todo.model';
import { environment } from '../../environments/environment';

// @Injectable({providedIn: 'root'}) rend le service disponible dans toute l'application
@Injectable({
  providedIn: 'root'
})
export class TodoService {

  // URL de base de l'API, chargée depuis environment.ts
  private readonly apiUrl = `${environment.apiUrl}/todos`;

  // HttpClient est injecté automatiquement par Angular (Dependency Injection)
  constructor(private http: HttpClient) { }

  // Récupère tous les todos (avec filtres optionnels)
  getAll(completed?: boolean, search?: string): Observable<Todo[]> {
    let params = new HttpParams();
    if (completed !== undefined) params = params.set('completed', completed.toString());
    if (search) params = params.set('search', search);

    return this.http.get<Todo[]>(this.apiUrl, { params })
      .pipe(
        // tap() permet de logger sans modifier les données
        tap(todos => console.log(`[TodoService] ${todos.length} todos chargés`)),
        // catchError() intercepte les erreurs HTTP
        catchError(this.handleError)
      );
  }

  // Récupère un todo par son id
  getById(id: number): Observable<Todo> {
    return this.http.get<Todo>(`${this.apiUrl}/${id}`)
      .pipe(catchError(this.handleError));
  }

  // Crée un nouveau todo (POST)
  create(dto: CreateTodoDto): Observable<Todo> {
    return this.http.post<Todo>(this.apiUrl, dto)
      .pipe(
        tap(created => console.log(`[TodoService] Todo créé : ${created.id}`)),
        catchError(this.handleError)
      );
  }

  // Met à jour un todo (PUT)
  update(id: number, dto: UpdateTodoDto): Observable<Todo> {
    return this.http.put<Todo>(`${this.apiUrl}/${id}`, dto)
      .pipe(catchError(this.handleError));
  }

  // Supprime un todo (DELETE)
  delete(id: number): Observable<void> {
    return this.http.delete<void>(`${this.apiUrl}/${id}`)
      .pipe(
        tap(() => console.log(`[TodoService] Todo ${id} supprimé`)),
        catchError(this.handleError)
      );
  }

  // Marque un todo comme complété (raccourci utile)
  complete(id: number): Observable<Todo> {
    return this.update(id, { completed: true });
  }

  // Gestion centralisée des erreurs HTTP
  private handleError(error: HttpErrorResponse): Observable<never> {
    let message = 'Une erreur est survenue';

    if (error.status === 0) {
      // Erreur réseau (API inaccessible)
      message = 'Impossible de contacter le serveur. Vérifiez que Spring Boot est démarré.';
    } else if (error.status === 404) {
      message = 'Todo introuvable (404)';
    } else if (error.status === 400) {
      // Erreur de validation Spring Boot
      message = error.error?.message || 'Données invalides (400)';
    } else if (error.status === 500) {
      message = 'Erreur serveur (500)';
    }

    console.error('[TodoService] Erreur:', error);
    return throwError(() => new Error(message));
  }
}

9. 🟧 Front-end : composant liste

TodoListComponent — TypeScript


import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { TodoService } from '../../services/todo.service';
import { Todo, PRIORITY_LABELS } from '../../models/todo.model';

@Component({
  selector: 'app-todo-list',
  templateUrl: './todo-list.component.html'
})
export class TodoListComponent implements OnInit, OnDestroy {

  todos: Todo[] = [];
  loading = false;
  error: string | null = null;
  searchQuery = '';
  priorityLabels = PRIORITY_LABELS;

  // Subject utilisé pour couper les subscriptions lors de la destruction du composant
  // Évite les fuites mémoire (memory leaks)
  private destroy$ = new Subject<void>();

  constructor(private todoService: TodoService) { }

  ngOnInit(): void {
    // Charger les todos au démarrage du composant
    this.loadTodos();
  }

  ngOnDestroy(): void {
    // Annuler toutes les subscriptions actives
    this.destroy$.next();
    this.destroy$.complete();
  }

  loadTodos(search?: string): void {
    this.loading = true;
    this.error = null;

    this.todoService.getAll(undefined, search)
      .pipe(takeUntil(this.destroy$))
      .subscribe({
        next: (data) => {
          this.todos = data;
          this.loading = false;
        },
        error: (err: Error) => {
          this.error = err.message;
          this.loading = false;
        }
      });
  }

  onSearch(): void {
    this.loadTodos(this.searchQuery || undefined);
  }

  toggleComplete(todo: Todo): void {
    this.todoService.update(todo.id, { completed: !todo.completed })
      .pipe(takeUntil(this.destroy$))
      .subscribe({
        next: (updated) => {
          // Mise à jour locale pour réactivité immédiate (sans recharger)
          const index = this.todos.findIndex(t => t.id === todo.id);
          if (index !== -1) this.todos[index] = updated;
        },
        error: (err: Error) => this.error = err.message
      });
  }

  deleteTodo(id: number): void {
    if (!confirm('Confirmer la suppression de ce todo ?')) return;

    this.todoService.delete(id)
      .pipe(takeUntil(this.destroy$))
      .subscribe({
        next: () => {
          // Retirer le todo de la liste localement
          this.todos = this.todos.filter(t => t.id !== id);
        },
        error: (err: Error) => this.error = err.message
      });
  }
}

TodoListComponent — Template HTML


<div class="container mt-4">
  <div class="d-flex justify-content-between align-items-center mb-4">
    <h1 class="h3 fw-bold">Mes tâches</h1>
    <a routerLink="/todo/new" class="btn btn-primary">+ Nouvelle tâche</a>
  </div>

  <!-- Barre de recherche -->
  <div class="input-group mb-3">
    <input type="text" class="form-control"
           placeholder="Rechercher une tâche..."
           [(ngModel)]="searchQuery"
           (keyup.enter)="onSearch()">
    <button class="btn btn-outline-secondary" (click)="onSearch()">Rechercher</button>
  </div>

  <!-- Affichage des erreurs -->
  <div *ngIf="error" class="alert alert-danger alert-dismissible" role="alert">
    {{ error }}
    <button type="button" class="btn-close" (click)="error = null"></button>
  </div>

  <!-- Spinner de chargement -->
  <div *ngIf="loading" class="text-center py-4">
    <div class="spinner-border text-primary" role="status">
      <span class="visually-hidden">Chargement...</span>
    </div>
  </div>

  <!-- Liste des todos -->
  <div *ngIf="!loading" class="list-group">
    <div *ngFor="let todo of todos" class="list-group-item list-group-item-action"
         [class.list-group-item-secondary]="todo.completed">
      <div class="d-flex align-items-center gap-3">

        <!-- Checkbox pour marquer comme complété -->
        <input type="checkbox" class="form-check-input"
               [checked]="todo.completed"
               (change)="toggleComplete(todo)">

        <!-- Titre avec rayure si complété -->
        <span [class.text-decoration-line-through]="todo.completed"
              [class.text-muted]="todo.completed" class="flex-grow-1 fw-semibold">
          {{ todo.title }}
        </span>

        <!-- Badge de priorité -->
        <span class="badge bg-secondary">{{ priorityLabels[todo.priority] }}</span>

        <!-- Boutons d'action -->
        <a [routerLink]="['/todo/edit', todo.id]" class="btn btn-sm btn-outline-primary">
          Modifier
        </a>
        <button class="btn btn-sm btn-outline-danger" (click)="deleteTodo(todo.id)">
          Supprimer
        </button>
      </div>

      <!-- Description (si présente) -->
      <small *ngIf="todo.description" class="text-muted d-block mt-1 ms-4">
        {{ todo.description }}
      </small>
    </div>

    <!-- Message si liste vide -->
    <div *ngIf="todos.length === 0" class="text-center py-4 text-muted">
      Aucune tâche trouvée. Créez votre première tâche !
    </div>
  </div>
</div>

10. 🟧 Front-end : composant formulaire

TodoFormComponent — TypeScript (Reactive Forms)


import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { TodoService } from '../../services/todo.service';

@Component({
  selector: 'app-todo-form',
  templateUrl: './todo-form.component.html'
})
export class TodoFormComponent implements OnInit {

  form: FormGroup;
  isEditMode = false;   // true si on modifie, false si on crée
  todoId: number | null = null;
  loading = false;
  submitting = false;
  error: string | null = null;

  constructor(
    private fb: FormBuilder,         // FormBuilder simplifie la création du formulaire
    private todoService: TodoService,
    private route: ActivatedRoute,   // Pour lire l'id dans l'URL
    private router: Router           // Pour naviguer après soumission
  ) {
    // Définir la structure du formulaire avec ses validateurs
    this.form = this.fb.group({
      title: ['', [
        Validators.required,
        Validators.minLength(3),
        Validators.maxLength(200)
      ]],
      description: ['', Validators.maxLength(1000)],
      priority: [2, [
        Validators.required,
        Validators.min(1),
        Validators.max(3)
      ]],
      completed: [false]
    });
  }

  ngOnInit(): void {
    // Récupérer l'id dans l'URL (si route /todo/edit/:id)
    const id = this.route.snapshot.paramMap.get('id');

    if (id) {
      this.isEditMode = true;
      this.todoId = parseInt(id, 10);
      this.loadTodo(this.todoId);
    }
  }

  loadTodo(id: number): void {
    this.loading = true;
    this.todoService.getById(id).subscribe({
      next: (todo) => {
        // Pré-remplir le formulaire avec les données existantes
        this.form.patchValue({
          title:       todo.title,
          description: todo.description || '',
          priority:    todo.priority,
          completed:   todo.completed
        });
        this.loading = false;
      },
      error: (err) => {
        this.error = err.message;
        this.loading = false;
      }
    });
  }

  onSubmit(): void {
    // Ne pas soumettre si le formulaire est invalide
    if (this.form.invalid) {
      this.form.markAllAsTouched(); // Affiche les erreurs de validation
      return;
    }

    this.submitting = true;
    const formValue = this.form.value;

    const operation = this.isEditMode && this.todoId
      ? this.todoService.update(this.todoId, formValue)
      : this.todoService.create(formValue);

    operation.subscribe({
      next: () => {
        // Rediriger vers la liste après succès
        this.router.navigate(['/todos']);
      },
      error: (err) => {
        this.error = err.message;
        this.submitting = false;
      }
    });
  }

  // Raccourci pour accéder aux contrôles dans le template
  get f() { return this.form.controls; }
}

TodoFormComponent — Template HTML


<div class="container mt-4">
  <div class="row justify-content-center">
    <div class="col-md-8 col-lg-6">
      <div class="card shadow-sm">
        <div class="card-header">
          <h2 class="h5 mb-0 fw-bold">
            {{ isEditMode ? 'Modifier la tâche' : 'Nouvelle tâche' }}
          </h2>
        </div>

        <div class="card-body">
          <div *ngIf="error" class="alert alert-danger">{{ error }}</div>
          <div *ngIf="loading" class="text-center py-3">
            <div class="spinner-border"></div>
          </div>

          <form [formGroup]="form" (ngSubmit)="onSubmit()" *ngIf="!loading">

            <!-- Champ Titre -->
            <div class="mb-3">
              <label class="form-label fw-semibold">Titre *</label>
              <input type="text" class="form-control"
                     formControlName="title"
                     [class.is-invalid]="f['title'].invalid && f['title'].touched"
                     placeholder="Titre de la tâche">
              <div class="invalid-feedback">
                <span *ngIf="f['title'].errors?.['required']">Le titre est obligatoire.</span>
                <span *ngIf="f['title'].errors?.['minlength']">Minimum 3 caractères.</span>
                <span *ngIf="f['title'].errors?.['maxlength']">Maximum 200 caractères.</span>
              </div>
            </div>

            <!-- Champ Description -->
            <div class="mb-3">
              <label class="form-label fw-semibold">Description</label>
              <textarea class="form-control" rows="3"
                        formControlName="description"
                        [class.is-invalid]="f['description'].invalid && f['description'].touched"
                        placeholder="Description optionnelle"></textarea>
              <div class="invalid-feedback">Maximum 1000 caractères.</div>
            </div>

            <!-- Champ Priorité -->
            <div class="mb-3">
              <label class="form-label fw-semibold">Priorité</label>
              <select class="form-select" formControlName="priority">
                <option value="1">🟢 Basse</option>
                <option value="2">🟡 Moyenne</option>
                <option value="3">🔴 Haute</option>
              </select>
            </div>

            <!-- Boutons -->
            <div class="d-flex gap-2">
              <button type="submit" class="btn btn-primary" [disabled]="submitting">
                <span *ngIf="submitting" class="spinner-border spinner-border-sm me-1"></span>
                {{ isEditMode ? 'Mettre à jour' : 'Créer la tâche' }}
              </button>
              <a routerLink="/todos" class="btn btn-outline-secondary">Annuler</a>
            </div>
          </form>
        </div>
      </div>
    </div>
  </div>
</div>

11. Communication front ↔ back (CORS)

CORS (Cross-Origin Resource Sharing) est une sécurité du navigateur qui bloque les requêtes vers un domaine différent. Comme Angular (port 4200) appelle Spring Boot (port 8080), vous devez configurer CORS côté serveur.

Option 1 : @CrossOrigin sur le contrôleur (simple)


// Ajouter sur la classe contrôleur pour autoriser Angular en développement
@CrossOrigin(origins = "http://localhost:4200")
@RestController
@RequestMapping("/api/todos")
public class TodoController { ... }

Option 2 : Configuration globale CORS (recommandé)


package com.example.todo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                // Autoriser uniquement l'origine Angular (dev + prod)
                .allowedOrigins("http://localhost:4200", "https://votre-domaine.com")
                // Méthodes HTTP autorisées
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                // Headers autorisés
                .allowedHeaders("*")
                // Autoriser les cookies et headers d'authentification
                .allowCredentials(true);
    }
}

Option 3 : Proxy Angular (développement uniquement)

Créez un fichier proxy.conf.json à la racine Angular :


{
  "/api": {
    "target": "http://localhost:8080",
    "secure": false,
    "changeOrigin": true,
    "logLevel": "debug"
  }
}

# Démarrer Angular avec le proxy (toutes les requêtes /api sont redirigées vers Spring Boot)
ng serve --proxy-config proxy.conf.json

# Ou ajouter dans package.json :
# "start": "ng serve --proxy-config proxy.conf.json"

12. Validation des données

La validation doit être faite des deux côtés : côté Angular (UX rapide) et côté Spring Boot (sécurité absolue).

Pourquoi valider des deux côtés ?

  • Angular : retour instantané à l'utilisateur, pas de requête inutile
  • Spring Boot : protection contre les requêtes directes (curl, Postman, scripts malveillants)
  • Ne faites jamais confiance aux données venant du client côté serveur

Gestion des erreurs de validation Spring Boot


// Gestionnaire global d'erreurs pour retourner un JSON propre
@RestControllerAdvice
public class GlobalExceptionHandler {

    // Capture les erreurs de validation (@Valid)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, Object> handleValidationErrors(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        // Extraire tous les messages d'erreur de chaque champ
        ex.getBindingResult().getFieldErrors().forEach(error ->
            errors.put(error.getField(), error.getDefaultMessage())
        );

        return Map.of(
            "status", 400,
            "message", "Validation échouée",
            "errors", errors
        );
    }
}

13. Déploiement Docker

Dockerfile Spring Boot


# Étape 1 : build Maven
FROM eclipse-temurin:17-jdk-alpine AS builder
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN ./mvnw package -DskipTests

# Étape 2 : image légère pour l'exécution
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Docker Compose (back + front + base de données)


version: '3.8'

services:
  # Base de données MySQL
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_DATABASE: tododb
      MYSQL_ROOT_PASSWORD: rootpassword
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql

  # API Spring Boot
  api:
    build: ./todo-api
    ports:
      - "8080:8080"
    environment:
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/tododb
      SPRING_DATASOURCE_USERNAME: root
      SPRING_DATASOURCE_PASSWORD: rootpassword
    depends_on:
      - mysql

  # Application Angular (build statique servi par Nginx)
  web:
    build: ./todo-angular
    ports:
      - "80:80"
    depends_on:
      - api

volumes:
  mysql-data:

# Build et démarrage de tous les services
docker-compose up --build

# L'application est accessible sur http://localhost
# L'API est disponible sur http://localhost:8080/api/todos

14. Conclusion et checklist

Vous avez construit une application CRUD complète avec Spring Boot et Angular. La séparation claire entre back-end et front-end vous permet de faire évoluer chaque partie indépendamment.

✅ Checklist back-end Spring Boot

  • Entité JPA avec annotations de validation
  • Repository JPA avec méthodes métier
  • Service avec logique métier séparée
  • Contrôleur REST avec DTOs
  • CORS configuré pour Angular
  • Gestion globale des erreurs (@RestControllerAdvice)
  • Logs avec @Slf4j
  • Variables de connexion dans application.properties

✅ Checklist front-end Angular

  • Interface TypeScript pour typer l'API
  • TodoService avec HttpClient centralisé
  • Gestion d'erreurs dans handleError()
  • Composant liste avec chargement et erreurs
  • Formulaire Reactive avec validateurs
  • takeUntil(destroy$) pour éviter les memory leaks
  • Environnements dev/prod séparés
  • Routing Angular configuré
🚀 Étapes suivantes :
  • Ajouter l'authentification JWT → voir l'article dédié Spring Boot/Angular
  • Implémenter la pagination côté API et Angular
  • Ajouter des tests unitaires JUnit et Jasmine
  • Configurer CI/CD avec GitHub Actions

Partager