Retour aux articles

Application de la Clean Architecture à une application Full Stack Spring Boot 3 et Angular 17

Application de la Clean Architecture à une application Full Stack Spring Boot 3 et Angular 17 | Laty Gueye Samba - Développeur Full Stack Dakar Sénégal, Expert Java Spring Boot Angular
Application de la Clean Architecture à une application Full Stack Spring Boot 3 et Angular 17

Application de la Clean Architecture à une application Full Stack Spring Boot 3 et Angular 17

Dans l'écosystème du développement logiciel moderne, la complexité des applications ne cesse de croître. Pour relever ce défi, des principes d'architecture robustes sont indispensables. La Clean Architecture, popularisée par Robert C. Martin (Uncle Bob), offre une approche structurée pour concevoir des systèmes maintenables, testables et indépendants des détails d'implémentation. Cette méthodologie garantit que la logique métier essentielle reste isolée des frameworks, des bases de données ou des interfaces utilisateur, permettant ainsi une plus grande flexibilité et une meilleure évolutivité.

Pour un Développeur Full Stack Dakar Sénégal comme Laty Gueye Samba, expert en Java Spring Boot et Angular, l'adoption de la Clean Architecture est une stratégie clé pour bâtir des applications performantes et durables. Cet article explore comment les principes de la Clean Architecture peuvent être appliqués concrètement à une application Full Stack Spring Boot 3 et Angular 17, en détaillant la structuration côté backend et frontend pour maximiser les bénéfices.

Principes Fondamentaux de la Clean Architecture

La Clean Architecture se base sur l'idée que le code doit être organisé en couches concentriques, où les dépendances pointent toujours vers l'intérieur. Au cœur se trouve la logique métier pure (Entités et Cas d'Utilisation), indépendante de toute technologie externe. Les couches externes sont des adaptateurs et des frameworks, qui interagissent avec les couches internes via des interfaces.

Les quatre couches principales sont généralement identifiées comme suit :

  • Entités (Entities) : Contiennent la logique métier de l'entreprise. Ce sont les règles les plus générales et de plus haut niveau.
  • Cas d'Utilisation (Use Cases) : Orchestrent le flux de données vers et depuis les entités et encapsulent les règles spécifiques à l'application.
  • Adaptateurs d'Interface (Interface Adapters) : Convertissent les données du format des cas d'utilisation et des entités vers celui des frameworks et des bases de données, et vice versa. Cette couche inclut les contrôleurs, les présentateurs et les passerelles.
  • Frameworks & Drivers : La couche la plus externe, contenant les détails concrets tels que les frameworks web (Spring Boot, Angular), les bases de données (JPA, JDBC), etc.

La règle d'or est la Règle de Dépendance : les dépendances doivent toujours pointer de l'extérieur vers l'intérieur. Aucune entité ou cas d'utilisation ne devrait connaître l'existence d'un contrôleur ou d'une base de données.

Application de la Clean Architecture au Backend avec Spring Boot 3

L'implémentation de la Clean Architecture Full Stack côté backend avec Spring Boot 3 peut être structurée en plusieurs modules ou packages distincts, reflétant les couches architecturales.

1. Couche Domaine (Entities)

Cette couche contient les entités métier pures et les règles de l'entreprise. Elle ne doit avoir aucune dépendance envers Spring ou toute autre technologie d'infrastructure.


// src/main/java/com/latysamba/domain/model/Product.java
package com.latysamba.domain.model;

import java.math.BigDecimal;

public class Product {
    private Long id;
    private String name;
    private BigDecimal price;
    private int quantity;

    public Product(Long id, String name, BigDecimal price, int quantity) {
        this.id = id;
        this.name = name;
        this.price = price;
        this.quantity = quantity;
    }

    // Getters and Setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public int getQuantity() { return quantity; }
    public void setQuantity(int quantity) { this.quantity = quantity; }

    public void updateQuantity(int change) {
        if (this.quantity + change < 0) {
            throw new IllegalArgumentException("Quantity cannot be negative.");
        }
        this.quantity += change;
    }
}

2. Couche Application (Use Cases / Ports)

Cette couche définit les interfaces pour les opérations métier (ports d'entrée) et les interfaces pour les services d'infrastructure (ports de sortie). Elle orchestre les entités pour réaliser les cas d'utilisation.


// src/main/java/com/latysamba/application/port/in/ManageProductUseCase.java
package com.latysamba.application.port.in;

import com.latysamba.domain.model.Product;
import java.util.List;
import java.util.Optional;

public interface ManageProductUseCase {
    Product createProduct(Product product);
    Optional<Product> getProductById(Long id);
    List<Product> getAllProducts();
    Product updateProduct(Product product);
    void deleteProduct(Long id);
    void updateProductQuantity(Long id, int change);
}

// src/main/java/com/latysamba/application/port/out/ProductRepositoryPort.java
package com.latysamba.application.port.out;

import com.latysamba.domain.model.Product;
import java.util.List;
import java.util.Optional;

public interface ProductRepositoryPort {
    Product save(Product product);
    Optional<Product> findById(Long id);
    List<Product> findAll();
    void deleteById(Long id);
}

3. Couche Infrastructure (Adapters)

Cette couche contient les implémentations concrètes des ports de sortie (adaptateurs de persistance) et les adaptateurs d'entrée (contrôleurs REST). C'est ici que Spring Boot joue son rôle.


// src/main/java/com/latysamba/infrastructure/adapter/out/persistence/ProductJpaEntity.java
package com.latysamba.infrastructure.adapter.out.persistence;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;

@Entity
@Table(name = "products")
public class ProductJpaEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private BigDecimal price;
    private int quantity;

    // Default constructor for JPA
    public ProductJpaEntity() {}

    public ProductJpaEntity(Long id, String name, BigDecimal price, int quantity) {
        this.id = id;
        this.name = name;
        this.price = price;
        this.quantity = quantity;
    }

    // Getters and Setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public int getQuantity() { return quantity; }
    public void setQuantity(int quantity) { this.quantity = quantity; }
}

// src/main/java/com/latysamba/infrastructure/adapter/out/persistence/ProductJpaRepository.java
package com.latysamba.infrastructure.adapter.out.persistence;

import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductJpaRepository extends JpaRepository<ProductJpaEntity, Long> {
}

// src/main/java/com/latysamba/infrastructure/adapter/out/ProductPersistenceAdapter.java
package com.latysamba.infrastructure.adapter.out;

import com.latysamba.application.port.out.ProductRepositoryPort;
import com.latysamba.domain.model.Product;
import com.latysamba.infrastructure.adapter.out.persistence.ProductJpaEntity;
import com.latysamba.infrastructure.adapter.out.persistence.ProductJpaRepository;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

@Component
public class ProductPersistenceAdapter implements ProductRepositoryPort {

    private final ProductJpaRepository productJpaRepository;

    public ProductPersistenceAdapter(ProductJpaRepository productJpaRepository) {
        this.productJpaRepository = productJpaRepository;
    }

    @Override
    public Product save(Product product) {
        ProductJpaEntity entity = mapToJpaEntity(product);
        ProductJpaEntity savedEntity = productJpaRepository.save(entity);
        return mapToDomainModel(savedEntity);
    }

    @Override
    public Optional<Product> findById(Long id) {
        return productJpaRepository.findById(id)
                .map(this::mapToDomainModel);
    }

    @Override
    public List<Product> findAll() {
        return productJpaRepository.findAll().stream()
                .map(this::mapToDomainModel)
                .collect(Collectors.toList());
    }

    @Override
    public void deleteById(Long id) {
        productJpaRepository.deleteById(id);
    }

    private ProductJpaEntity mapToJpaEntity(Product product) {
        return new ProductJpaEntity(product.getId(), product.getName(), product.getPrice(), product.getQuantity());
    }

    private Product mapToDomainModel(ProductJpaEntity entity) {
        return new Product(entity.getId(), entity.getName(), entity.getPrice(), entity.getQuantity());
    }
}

// src/main/java/com/latysamba/infrastructure/adapter/in/web/ProductController.java
package com.latysamba.infrastructure.adapter.in.web;

import com.latysamba.application.port.in.ManageProductUseCase;
import com.latysamba.domain.model.Product;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ManageProductUseCase manageProductUseCase;

    public ProductController(ManageProductUseCase manageProductUseCase) {
        this.manageProductUseCase = manageProductUseCase;
    }

    @PostMapping
    public ResponseEntity<Product> createProduct(@RequestBody Product product) {
        Product createdProduct = manageProductUseCase.createProduct(product);
        return ResponseEntity.ok(createdProduct);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Product> getProductById(@PathVariable Long id) {
        return manageProductUseCase.getProductById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @GetMapping
    public ResponseEntity<List<Product>> getAllProducts() {
        List<Product> products = manageProductUseCase.getAllProducts();
        return ResponseEntity.ok(products);
    }

    // Other endpoints for update, delete, quantity adjustment
}

Cette structure permet de maintenir la logique métier découplée de l'infrastructure de persistance et du framework web, facilitant les tests unitaires et l'évolution.

Intégration de la Clean Architecture au Frontend avec Angular 17

Bien que la Clean Architecture soit souvent associée au backend, ses principes sont tout aussi bénéfiques pour une application Angular 17. L'objectif est de séparer la logique de présentation (composants), la logique métier (services d'application) et l'accès aux données (services d'infrastructure).

1. Couche Domaine (Models/Entities)

Définit les interfaces pour les objets métier, indépendamment de la façon dont ils sont obtenus ou affichés.


// src/app/domain/models/product.model.ts
export interface Product {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

2. Couche Application (Use Cases / Services)

Contient la logique d'application spécifique, manipulant les modèles du domaine et interagissant avec les services d'infrastructure via des interfaces. Ces services représentent les "cas d'utilisation" frontend.


// src/app/application/ports/product.repository.ts
import { Observable } from 'rxjs';
import { Product } from '../../domain/models/product.model';

export abstract class ProductRepository {
  abstract getAllProducts(): Observable<Product[]>;
  abstract getProductById(id: number): Observable<Product>;
  abstract createProduct(product: Product): Observable<Product>;
  abstract updateProduct(product: Product): Observable<Product>;
  abstract deleteProduct(id: number): Observable<void>;
}

// src/app/application/usecases/manage-product.service.ts
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { Product } from '../../domain/models/product.model';
import { ProductRepository } from '../ports/product.repository';

@Injectable({
  providedIn: 'root'
})
export class ManageProductService { // This acts as a Use Case
  constructor(private productRepository: ProductRepository) {}

  getAllProducts(): Observable<Product[]> {
    return this.productRepository.getAllProducts();
  }

  getProductDetails(id: number): Observable<Product> {
    // Potentially add specific business logic before fetching
    return this.productRepository.getProductById(id);
  }

  addProduct(product: Product): Observable<Product> {
    // Business rule: ensure product name is not empty
    if (!product.name || product.name.trim() === '') {
      throw new Error('Product name cannot be empty.');
    }
    return this.productRepository.createProduct(product);
  }

  // Other methods for update, delete
}

3. Couche Infrastructure (API Services / Adapters)

Cette couche contient les implémentations concrètes des ports de sortie (ProductRepository) qui communiquent avec le backend, par exemple via HttpClient.


// src/app/infrastructure/services/product-api.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Product } from '../../domain/models/product.model';
import { ProductRepository } from '../../application/ports/product.repository';
import { environment } from '../../../environments/environment';

@Injectable({
  providedIn: 'root'
})
export class ProductApiService extends ProductRepository {
  private apiUrl = `${environment.apiUrl}/products`;

  constructor(private http: HttpClient) {
    super();
  }

  getAllProducts(): Observable<Product[]> {
    return this.http.get<Product[]>(this.apiUrl);
  }

  getProductById(id: number): Observable<Product> {
    return this.http.get<Product>(`${this.apiUrl}/${id}`);
  }

  createProduct(product: Product): Observable<Product> {
    return this.http.post<Product>(this.apiUrl, product);
  }

  updateProduct(product: Product): Observable<Product> {
    return this.http.put<Product>(`${this.apiUrl}/${product.id}`, product);
  }

  deleteProduct(id: number): Observable<void> {
    return this.http.delete<void>(`${this.apiUrl}/${id}`);
  }
}

Pour s'assurer que l'ManageProductService utilise ProductApiService, on peut configurer l'injection de dépendances dans un module Angular :


// src/app/app.module.ts ou un module feature
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';

import { AppComponent } from './app.component';
import { ProductRepository } from './application/ports/product.repository';
import { ProductApiService } from './infrastructure/services/product-api.service';

@NgModule({
  declarations: [
    AppComponent
    // ... other components
  ],
  imports: [
    BrowserModule,
    HttpClientModule
  ],
  providers: [
    { provide: ProductRepository, useClass: ProductApiService }
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

4. Couche Présentation (Components)

Les composants Angular interagissent uniquement avec les services de la couche Application (Use Cases). Ils sont responsables de l'affichage et de la gestion des interactions utilisateur, mais ne contiennent aucune logique métier directe.


// src/app/presentation/product-list/product-list.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { Product } from '../../domain/models/product.model';
import { ManageProductService } from '../../application/usecases/manage-product.service';

@Component({
  selector: 'app-product-list',
  templateUrl: './product-list.component.html',
  styleUrls: ['./product-list.component.css']
})
export class ProductListComponent implements OnInit {
  products$: Observable<Product[]>;

  constructor(private manageProductService: ManageProductService) { }

  ngOnInit(): void {
    this.products$ = this.manageProductService.getAllProducts();
  }

  onAddProduct(): void {
    // Example: navigate to add product form or open modal
    console.log('Navigate to add product');
  }

  onEditProduct(product: Product): void {
    // Example: navigate to edit product form
    console.log('Edit product:', product.id);
  }

  onDeleteProduct(id: number): void {
    this.manageProductService.deleteProduct(id).subscribe(() => {
      console.log('Product deleted');
      this.products$ = this.manageProductService.getAllProducts(); // Refresh list
    });
  }
}

Point de vue : développeur full stack à Dakar

Pour un développeur Full Stack travaillant sur des systèmes comme des applications de gestion hospitalière ou des applications métier complexes au Sénégal, la maîtrise de la Clean Architecture Full Stack, notamment avec Spring Boot Angular, représente un avantage concurrentiel réel sur le marché technologique africain, en pleine expansion. Cette approche permet de livrer des solutions robustes et facilement adaptables aux besoins spécifiques des entreprises locales et internationales, positionnant des profils comme Laty Gueye Samba comme des experts incontournables.

Conclusion

L'application de la Clean Architecture à une application Full Stack Spring Boot 3 et Angular 17 permet de construire des systèmes résilients, évolutifs et agréables à maintenir. En séparant clairement les préoccupations, elle isole la logique métier des détails techniques, rendant l'application plus facile à tester, à modifier et à comprendre.

Pour les Développeurs Full Stack à Dakar, et plus particulièrement pour les experts en Java Spring Boot Angular comme Laty Gueye Samba, l'adoption de cette architecture n'est pas seulement une bonne pratique technique, c'est une stratégie qui assure la qualité et la longévité des projets, qu'il s'agisse de systèmes ERP ou d'applications de gestion des risques. Cette approche favorise également la collaboration au sein des équipes de développement, chaque membre pouvant se concentrer sur sa couche de responsabilité sans impacter le cœur de l'application.

Pour approfondir vos connaissances sur la Clean Architecture, il est recommandé de consulter les ressources officielles :

À propos de l'auteur

Laty Gueye Samba est développeur Full Stack basé à Dakar, Sénégal. Spécialiste des écosystèmes Java / Spring Boot et Angular.

Contact : latygueyesamba@gmail.com  |  Dakar, Sénégal