Normes de développement frontend et backend pour la mise en oeuvre de projet Angular/Fastapi
| Layer | Technology | Version | Features |
|---|---|---|---|
| Frontend | Angular | 21 | Signals, Signal Forms, Zoneless, Standalone |
| Backend | FastAPI | 0.135.1 | Async/Await, Pydantic v2 |
| UI Framework | Bootstrap | 5.3.8 | Responsive, Accessible |
| Bootstrap Icons |
| 1.13.1 |
| SVG Icons |
| Python Runtime | Python | 3.14 | Asynchronous |
| Node Runtime | Node.js | 18+ | ES2021 |
| Deployment | Docker | Multi-stage | Single Container SPA |
Angular 21 apporte le mode zoneless pour performances optimales:
Le mode zoneless désactive NgZone d'Angular et s'appuie sur les signaux pour la détection de changements.
// src/main.ts - Application bootstrap
import { bootstrapApplication } from "@angular/platform-browser";
import { AppComponent } from "./app/app.component";
import { appConfig } from "./app/app.config";
bootstrapApplication(AppComponent, appConfig);
// src/app/app.config.ts - Application configuration
import { ApplicationConfig, provideZonelessChangeDetection } from "@angular/core";
import { provideRouter } from "@angular/router";
import { provideHttpClient } from "@angular/common/http";
export const appConfig: ApplicationConfig = {
providers: [
// ✅ Enable zoneless mode for better performance
provideZonelessChangeDetection(),
provideRouter(routes),
provideHttpClient(),
],
};
Avantages du mode zoneless:
Angular 21 apporte des changements majeurs:
* (dépréciées: *ngIf, *ngFor, *ngSwitch)@if, @for, @switch)Tous les composants doivent avoir des fichiers séparés pour template et style:
app/features/user/
├── user-list/
│ ├── user-list.component.ts ← Code TypeScript
│ ├── user-list.component.html ← Template HTML
│ └── user-list.component.css ← Styles CSS
import { Component, input, output, OnInit, inject } from "@angular/core";
import { CommonModule } from "@angular/common";
import { signal, computed, effect } from "@angular/core";
import {
email,
form,
FormField,
required,
submit,
} from "@angular/forms/signals";
// Standalone component - no module needed
@Component({
selector: "app-user-profile",
imports: [CommonModule, FormField],
templateUrl: "./user-profile.component.html",
styleUrl: "./user-profile.component.css",
})
export class UserProfileComponent implements OnInit {
// Injected services
private userService = inject(UserService);
// Input signal (receives data from parent)
userId = input<number>(0);
// Output signal (sends data to parent)
userSaved = output<User>();
// Error signal
readonly error = signal("");
// Internal state using signals
user = signal<User | null>(null);
isLoading = signal(false);
errorMessage = signal<string | null>(null);
// Computed derived state (automatically updates)
displayName = computed(() => {
const usr = this.user();
return usr ? `${usr.firstName} ${usr.lastName}` : "Unknown";
});
// Signal Forms
private readonly userInit = { email: "", phone: "" };
userModel = signal({ ...this.userInit });
userForm = form(this.userModel, (schemaPath) => {
required(schemaPath.email);
required(schemaPath.phone);
email(schemaPath.email);
});
constructor() {
// Load user when userId input changes
effect(() => {
const id = this.userId();
if (id > 0) {
this.loadUser(id);
}
});
}
private async loadUser(id: number): Promise<void> {
this.isLoading.set(true);
try {
const userData = await this.userService.getUser(id).toPromise();
this.user.set(userData);
} catch (error) {
this.errorMessage.set("Failed to load user");
} finally {
this.isLoading.set(false);
}
}
onSubmit(event: Event): void {
event.preventDefault();
sumibt(this.userForm, async (f) => {
const formData = f().value();
try {
this.userSaved.emit(formData);
} catch (err: unknown) {
const httpErr = err as { error?: { detail?: string } };
this.error.set(httpErr.error?.detail ?? "Authentication failed");
}
});
}
}
❌ ANCIEN (Déprécié):
<!-- Old directive syntax - DO NOT USE -->
<div *ngIf="isVisible">Content</div>
<div *ngFor="let item of items">{{ item }}</div>
<div *ngSwitch="status">
<div *ngSwitchCase="'active'">Active</div>
</div>
✅ NOUVEAU (Angular 21):
<!-- New control flow syntax - USE THIS -->
<!-- If statement -->
@if (isVisible) {
<div>Content</div>
} @else if (isLoading) {
<p>Loading...</p>
} @else {
<p>Hidden</p>
}
<!-- For loop -->
@for (item of items; track item.id) {
<div>{{ item.name }}</div>
}
<!-- Alternative: trackBy with function -->
@for (item of items; track trackByUserId($index, item)) {
<div>{{ item.name }}</div>
}
<!-- Switch statement -->
@switch (status) { @case ('active') {
<span class="badge bg-success">Active</span>
} @case ('inactive') {
<span class="badge bg-secondary">Inactive</span>
} @default {
<span class="badge bg-warning">Unknown</span>
} }
<!-- Empty state handling -->
@if (items.length > 0) {
<ul>
@for (item of items; track item.id) {
<li>{{ item.name }}</li>
}
</ul>
} @else {
<p class="text-muted">No items found</p>
}
Les signaux remplacent le système de zones d'Angular pour la détection de changements:
import { signal, computed, effect } from "@angular/core";
export class ShoppingCartComponent {
// Basic signal
cartItems = signal<CartItem[]>([]);
quantity = signal(0);
discountPercent = signal(0);
// Computed signal (derived state)
subtotal = computed(() =>
this.cartItems().reduce((sum, item) => sum + item.price * item.qty, 0),
);
discountAmount = computed(
() => this.subtotal() * (this.discountPercent() / 100),
);
total = computed(() => this.subtotal() - this.discountAmount());
constructor() {
// Effect: runs whenever dependencies change
effect(() => {
const total = this.total();
console.log(`Total changed to: $${total.toFixed(2)}`);
this.logToAnalytics(total);
});
}
// Modifying signals
addItem(item: CartItem): void {
// Update by creating new array (immutable pattern)
this.cartItems.update((items) => [...items, item]);
this.quantity.update((q) => q + 1);
}
removeItem(itemId: number): void {
this.cartItems.update((items) => items.filter((i) => i.id !== itemId));
}
// Set values directly
applyDiscount(percent: number): void {
this.discountPercent.set(percent);
}
private logToAnalytics(total: number): void {
// Log to analytics
}
}
Les Signal Forms simplifient la gestion des formulaires. Le template doit être dans un fichier dédié, exception dans ce chaitre pour des raisons pratiques d'explications
import { Component } from "@angular/core";
import {
form,
email,
FormField,
required,
submit,
} from "@angular/forms/signals";
@Component({
selector: "app-registration-form",
standalone: true,
imports: [FormRoot, FormField],
template: `
<form [formRoot]="registrationForm">
<input [formField]="registrationForm.username" />
<input type="email" [formField]="registrationForm.email" />
<input type="password" [formField]="registrationForm.password" />
<input type="password" [formField]="registrationForm.confirmPassword" />
<input type="checkbox" [formField]="registrationForm.acceptTerms" />
<button type="submit">Register</button>
</form>
`,
})
export class RegistrationFormComponent {
private readonly registrationInit = {
username: "",
email: "",
password: "",
confirmPassword: "",
acceptTerms: false,
};
registrationModel = signal({ ...this.registrationInit });
registrationForm = form(
this.userModel,
(schemaPath) => {
required(schemaPath.username);
required(schemaPath.email);
required(schemaPath.password);
required(schemaPath.confirmPassword);
required(schemaPath.acceptTerms);
email(schemaPath.email);
validate(schemaPath.confirmPassword, ({ value, valueOf }) => {
const confirmPassword = value();
const password = valueOf(schemaPath.password);
if (confirmPassword !== password) {
return {
kind: "passwordMismatch",
message: "Passwords do not match",
};
}
});
},
{
submission: {
action: async (f) => this.submitToServer(f),
},
},
);
private submitToServer(f: form) {
const formData = this.registrationForm().value();
console.log("Form submitted:", formData);
this.registrationForm().reset({ ...this.registrationInit });
}
}
import { Injectable, inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable, signal } from "@angular/core";
import { environment } from "../../../environments/environment";
interface User {
id: number;
username: string;
email: string;
}
// Singleton service
@Injectable({
providedIn: "root",
})
export class UserService {
// Inject dependencies using inject()
private http = inject(HttpClient);
private apiUrl = `${environment.apiUrl}/users`;
// State management using signals
users = signal<User[]>([]);
selectedUser = signal<User | null>(null);
isLoading = signal(false);
getUsers(): Observable<User[]> {
this.isLoading.set(true);
return new Observable((observer) => {
this.http.get<User[]>(this.apiUrl).subscribe({
next: (users) => {
this.users.set(users);
observer.next(users);
observer.complete();
},
error: (error) => {
this.isLoading.set(false);
observer.error(error);
},
complete: () => this.isLoading.set(false),
});
});
}
getUser(id: number): Observable<User> {
return this.http.get<User>(`${this.apiUrl}/${id}`);
}
createUser(user: Omit<User, "id">): Observable<User> {
return this.http.post<User>(this.apiUrl, user);
}
updateUser(id: number, user: Partial<User>): Observable<User> {
return this.http.put<User>(`${this.apiUrl}/${id}`, user);
}
deleteUser(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}
✅ Format recommandé avec fichier séparé:
<!-- user-list.component.html -->
<div class="container mt-5">
<div class="row mb-4">
<div class="col-md-6">
<h1>
<i class="bi bi-person-lines-fill"></i>
Users
</h1>
</div>
<div class="col-md-6 text-end">
<button class="btn btn-primary" (click)="openCreateModal()">
<i class="bi bi-plus-circle"></i>
Add User
</button>
</div>
</div>
<!-- Search and filter -->
<div class="row mb-3">
<div class="col-md-6">
<input
type="text"
class="form-control"
placeholder="Search users..."
(input)="filterUsers($event)"
/>
</div>
</div>
<!-- Loading state -->
@if (userService.isLoading()) {
<div class="alert alert-info">
<i class="bi bi-hourglass-split"></i>
Loading users...
</div>
}
<!-- Error state -->
@if (errorMessage(); as error) {
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle"></i>
{{ error }}
<button type="button" class="btn-close" (click)="clearError()"></button>
</div>
}
<!-- Users table -->
@if (filteredUsers().length > 0) {
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (user of filteredUsers(); track user.id) {
<tr>
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>{{ user.email }}</td>
<td>
<button
class="btn btn-sm btn-info"
(click)="editUser(user)"
title="Edit"
>
<i class="bi bi-pencil"></i>
</button>
<button
class="btn btn-sm btn-danger"
(click)="deleteUser(user.id)"
title="Delete"
>
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
} @else {
<div class="alert alert-warning text-center">
<i class="bi bi-inbox"></i>
No users found
</div>
}
</div>
✅ Format avec fichier séparé:
/* user-list.component.css */
/* Container styling */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
/* Headings */
h1 {
font-size: 2rem;
margin-bottom: 1.5rem;
color: #212529;
display: flex;
align-items: center;
gap: 10px;
}
/* Button styling */
.btn {
transition: all 0.3s ease;
}
.btn-primary {
background-color: #0d6efd;
}
.btn-primary:hover {
background-color: #0b5ed7;
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(13, 110, 253, 0.3);
}
/* Table styling */
.table-responsive {
border-radius: 0.25rem;
overflow: hidden;
}
.table {
margin-bottom: 0;
}
.table thead {
background-color: #f8f9fa;
font-weight: 600;
text-transform: uppercase;
font-size: 0.875rem;
}
.table tbody tr:hover {
background-color: #f5f5f5;
}
/* Alerts */
.alert {
border-radius: 0.5rem;
display: flex;
align-items: center;
gap: 10px;
}
/* Input fields */
input[type="text"],
input[type="email"],
textarea,
select {
border: 1px solid #dee2e6;
border-radius: 0.25rem;
padding: 0.5rem 0.75rem;
}