CodeGym /Cours /Swift SELF /Couches d'erreurs : parse / domain / storage / network

Couches d'erreurs : parse / domain / storage / network

Swift SELF
Niveau 30 , Leçon 3
Disponible

1. Pourquoi un seul Error pour toute l'application est la voie vers la tristesse

Quand on commence a decouvrir throws, on a vraiment envie de faire comme ca : « Bon, d'accord, on aura un enum AppError: Error { case somethingWentWrong } — et c'est parti ». Cela ressemble a du minimalisme, mais en pratique cela se transforme en jeu de type « devine d'apres la stack trace » pourquoi le programme n'a pas fait ce que vous vouliez. Et la stack trace, comme par hasard, vous montre exactement l'endroit ou vous avez decouvert le probleme — et montre rarement ou vous avez cree le probleme.

Les erreurs dans une application ont une propriete importante : elles surgissent dans differentes parties du systeme et pour differentes raisons. Si vous melangez tout dans un seul tas, il devient difficile de faire deux choses : d'une part, reagir correctement (par exemple, afficher une aide pour une erreur de saisie et proposer de reessayer plus tard pour une erreur de stockage), et d'autre part, comprendre (en tant que developpeur), ou chercher exactement le bug. C'est pourquoi nous introduisons un modele de couches de responsabilite : les erreurs sont reparties selon la couche dans laquelle elles sont apparues.

Modele mental : quatre couches d'erreurs

Les couches ne sont pas une « architecture obligatoire » ni une loi de la nature. C'est une carte pratique que vous suivez quand vous avez une chaine d'actions. Pour une application CLI pedagogique, cette carte ressemble en general a ceci : parse domain storage network. Meme si vous n'avez pas encore de reseau ni de fichiers, le modele reste utile, parce qu'il vous habitue a penser : « cette erreur concerne la saisie », « cette erreur concerne les regles metier », « cette erreur concerne la persistence », « cette erreur concerne le monde exterieur ».

Voici un petit tableau pour « fixer les notions au sol » (comme un tapis dans une piece ou vit un chat qui fait semblant que le tapis est son ennemi personnel) :

Couche De quoi parle cette couche Question principale Exemple d'erreur
parse Analyse de l'entree : commande, arguments, format « Qu'a voulu dire l'utilisateur ? » commande inconnue, arguments manquants
domain Regles du domaine metier « Est-ce autorise par les regles ? » annee du livre hors plage, titre vide
storage Lecture/ecriture des donnees « Pouvons-nous sauvegarder/lire ? » sauvegarde impossible, enregistrement corrompu
network Systemes externes « Pouvons-nous parler au service ? » pas de reseau, mauvais statut HTTP, delai depasse

Idee cle : chaque couche doit avoir son propre lexique d'erreurs. Ainsi, meme sans journaux, vous comprenez : « ah, le probleme est bien dans le parsing », et non « quelque chose s'est mal passe, et nous sommes un peu tristes a cause de cela ».

2. Ou ces couches vivent dans LibraryCLI

Imaginons que nous continuions a faire evoluer notre application CLI LibraryCLI (une « bibliotheque a la maison » fictive), dans laquelle il existe des commandes comme add, list, remove. L'utilisateur saisit une ligne, nous la decoupons, la transformons en commande, appliquons la commande au modele de domaine, puis (plus tard) enregistrons les changements. Meme si, pour l'instant, nous ne stockons les donnees qu'en memoire, la structure de la chaine ressemble deja a celle d'une vraie application.

Schema simplifie :

flowchart TD
    A["Entree utilisateur (String)"] --> B["Parse (CommandParser)"]
    B -->|Command| C["Domain (LibraryService/Rules)"]
    C -->|Modifications| D["Storage (Repository)"]
    C -->|Requetes| D
    C --> E["Network (optionnel)"]

    B -->|ParseError| X["Erreur de parsing"]
    C -->|DomainError| Y["Erreur du domaine"]
    D -->|StorageError| Z["Erreur de stockage"]
    E -->|NetworkError| W["Erreur reseau"]

Important : la couche parse ne doit pas connaitre les regles du domaine (par exemple, « annee du livre de 1450...2100 »). Son role est de comprendre que l'utilisateur a ecrit add "Dune" 1965, et de transformer cela en commande structuree. La couche domain, elle, decide deja si de telles valeurs sont acceptables.

4. Erreurs par couche : ParseError, DomainError, StorageError, NetworkError

ParseError : erreurs de format et « l'utilisateur a ecrit n'importe quoi »

Le parsing est l'endroit ou vous rencontrez pour la premiere fois le « monde reel » (sous la forme de l'utilisateur). L'utilisateur peut ecrire n'importe quoi : une ligne vide, une commande avec une faute de frappe, oublier des arguments, inverser l'ordre. Une erreur de parsing n'est pas un « probleme interne du programme ». C'est un cas normal : il faut indiquer a la personne ce qui ne va pas dans la saisie.

Commencons par un simple enum :

import Foundation

enum ParseError: Error {
    case emptyInput
    case unknownCommand(String)
    case missingArgument(name: String)
    case invalidNumber(String)
}

Remarquez : ici, nous n'ecrivons pas de « message utilisateur », nous ne formatons pas le texte, nous ne decidons pas comment l'afficher. Nous donnons simplement des causes precises qui aideront le niveau superieur a reagir correctement.

Maintenant, ecrivons une micro-fonction qui analyse un entier (par exemple, une annee) :

import Foundation

func parseInt(_ text: String) throws -> Int {
    guard let value = Int(text) else {
        throw ParseError.invalidNumber(text)
    }
    return value
}

Et le parseur de commande add au niveau de l'idee (simplifie, sans guillemets ni regles complexes de tokenisation — nous en avons/deviendrons plus tard dans le sujet du parsing CLI, et ici la couche d'erreurs est l'essentiel) :

import Foundation

struct AddBookCommand {
    let title: String
    let year: Int
}

func parseAdd(tokens: [String]) throws -> AddBookCommand {
    guard tokens.count >= 3 else { throw ParseError.missingArgument(name: "title/year") }
    let title = tokens[1]
    let year = try parseInt(tokens[2])
    return AddBookCommand(title: title, year: year)
}

Oui, c'est encore un parseur « naif », mais il montre parfaitement l'idee : les erreurs de parse concernent la forme de l'entree. Si le nombre n'a pas pu etre analyse, c'est un ParseError.invalidNumber, et non un DomainError ni un StorageError.

DomainError : erreurs des regles du domaine metier

Quand vous arrivez au domaine, vous avez deja tout compris sur la forme des donnees. La commande est reconnue, les arguments sont presents, les nombres ont ete analyses. Une autre philosophie commence alors : « selon les regles de notre monde, est-ce possible ou non ? »

Pour LibraryCLI, definissons une entite livre et des regles. Nous ne compliquons pas encore les choses et n'allons pas vers les futurs sujets, donc faisons au minimum :

import Foundation

struct Book {
    let title: String
    let year: Int
}

Erreurs du domaine :

import Foundation

enum DomainError: Error {
    case emptyTitle
    case yearOutOfRange(Int)
}

Et un validateur (cela peut etre une fonction ou une methode — ici ce n'est pas essentiel) :

import Foundation

func validateBook(title: String, year: Int) throws {
    guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
        throw DomainError.emptyTitle
    }
    guard (1450...2100).contains(year) else {
        throw DomainError.yearOutOfRange(year)
    }
}

Notez un detail important : 1450...2100 est une regle du domaine. Le parseur ne doit absolument pas la connaitre. Le parseur doit seulement comprendre « annee = 3000 » comme un nombre. Et le domaine dira ensuite : « non, chez nous, cela ne se fait pas, nous n'acceptons pas les livres du futur (pour le moment) ».

StorageError : erreurs de stockage

Meme si vous ecrivez un domaine parfait et que vous parsez impeccablement les commandes, arrive un moment ou les donnees doivent etre stockees quelque part. Dans une application pedagogique, ce sera d'abord la memoire (Array), puis les fichiers/JSON, etc. Les erreurs de stockage n'apparaissent ni parce que l'utilisateur a « mal saisi », ni parce qu'il a « enfreint les regles ». Elles apparaissent parce que l'environnement d'execution ne vous a pas permis de faire l'I/O, ou parce que les donnees etaient corrompues.

Comme nous n'avons pas encore de fichiers dans le sujet actuel, nous pouvons modeliser la couche storage comme un protocole de repository et une implementation simple qui « casse parfois » (juste pour voir comment les types d'erreurs fonctionnent).

import Foundation

enum StorageError: Error {
    case cannotSave
    case cannotLoad
}

Repository le plus simple :

import Foundation

final class InMemoryBookRepository {
    private var books: [Book] = []

    func add(_ book: Book) {
        books.append(book)
    }

    func listAll() -> [Book] {
        books
    }
}

Imaginons maintenant une « sauvegarde » qui peut lancer une erreur (dans la vraie vie, ce sera un fichier) :

import Foundation

func saveSnapshot() throws {
    let ok = Bool.random()
    guard ok else { throw StorageError.cannotSave }
}

L'idee principale : StorageError vit a la frontiere « nous essayons d'interagir avec le stockage ». Il ne doit pas decrire un « title vide » — car ce n'est pas un probleme de stockage, mais un probleme de domaine.

NetworkError : erreurs du monde exterieur

Le reseau est la couche la plus honnete. Il dit tout de suite : « tout peut casser, et ce n'est pas votre faute ». Meme si vous n'ecrivez pas aujourd'hui la partie reseau, il est utile de garder un type d'erreurs reseau separé, car cela impose une discipline : les echecs reseau ne se corrigent pas avec les memes methodes que les erreurs de parsing ou de domaine.

Pour l'instant, sans URLSession (ce sera pour un autre jour), fixons simplement l'idee :

import Foundation

enum NetworkError: Error {
    case offline
    case timeout
    case badResponse
}

Et une requete « factice » :

import Foundation

func fetchSomething() throws {
    throw NetworkError.offline
}

Pourquoi est-il important de ne pas melanger network et storage ? Parce que la reaction est souvent differente. Une erreur de stockage signifie en general « reessayez plus tard, verifiez les droits/le chemin », tandis qu'une erreur reseau signifie « verifiez internet/reessayez la requete ». Quand vous separez les types, vous obtenez plus facilement un UX correct, meme en CLI.

5. Comment relier les couches : regles, translation et point de traitement

Regles des couches de responsabilite

Les couches d'erreurs ne sont pas seulement quatre noms. C'est un ensemble de regles qui vous aident a ne pas transformer le projet en chaos de catch { print("oops") }. La regle la plus importante sonne presque ennuyeuse : l'erreur doit etre « native » a sa couche. Si, dans la couche de parsing, vous commencez a valider des regles metier, vous obtenez un code ou la logique est etalee dans toute l'application.

La deuxieme regle est aussi simple, mais elle casse les habitudes des debutants : les couches basses n'ont pas besoin d'etre « jolies pour l'utilisateur ». La couche basse doit etre precise. La joliesse se fait en general plus haut, la ou vous connaissez le contexte du scenario et ou vous pouvez decide : « c'est une erreur de saisie, affichons l'aide » ou « c'est une erreur de stockage, donnons un conseil pour sauvegarder dans un autre dossier ».

La troisieme regle concerne la « conservation du sens » : si vous remontez une erreur, ne transformez pas tout en unknown. Parfois, vous n'avez vraiment pas besoin des details, mais les categories doivent etre preservees : parse vs domain vs storage vs network — c'est deja enormement utile.

Translation des erreurs entre couches

Quand vous avez plusieurs types d'erreurs, une question se pose : « que renvoyer vers le haut ? » Souvent, le niveau superieur (par exemple, runCLI()) ne veut pas connaitre tous les details des couches basses, mais veut comprendre la categorie. Pour cela, on cree une erreur d'application de niveau superieur qui encapsule la couche.

C'est ce qu'on appelle la translation : cause de bas niveau → dictionnaire d'erreurs de haut niveau. Important : la translation ne consiste pas a « cacher le probleme », mais a « l'emballer de facon a ce que la couche superieure puisse reagir correctement ».

Creons AppError, qui stocke la couche :

import Foundation

enum AppError: Error {
    case parse(ParseError)
    case domain(DomainError)
    case storage(StorageError)
    case network(NetworkError)
    case unexpected(Error)
}

Ecrivons maintenant une fonction qui traverse la chaine : parse → domain → storage, et qui traduit les erreurs. Ici, il est important que nous n'utilisions pas try?, parce que nous avons besoin de la cause pour faire la bonne translation.

import Foundation

func addBookFlow(input: String, repo: InMemoryBookRepository) throws {
    do {
        let tokens = input.split(separator: " ").map(String.init)
        guard let cmd = tokens.first else { throw ParseError.emptyInput }
        guard cmd == "add" else { throw ParseError.unknownCommand(cmd) }

        let add = try parseAdd(tokens: tokens)
        try validateBook(title: add.title, year: add.year)

        repo.add(Book(title: add.title, year: add.year))
        try saveSnapshot()
    } catch let e as ParseError {
        throw AppError.parse(e)
    } catch let e as DomainError {
        throw AppError.domain(e)
    } catch let e as StorageError {
        throw AppError.storage(e)
    } catch {
        throw AppError.unexpected(error)
    }
}

Remarquez que ce code parait plus long que le simple « try ». Mais c'est comme une ceinture de securite : au debut elle semble superflue, puis un jour vous attrapez un bug bizarre et vous commencez a aimer cette ceinture de tout votre coeur.

Ou intercepter les erreurs

Dans une application CLI, il y a presque toujours un point ou vous decidez finalement : « que montrer a l'utilisateur et avec quel code de sortie terminer ». Dans le cadre de cette lecon, nous n'introduisons pas les codes de sortie ni la politique des messages (c'est un sujet voisin du jour), mais le principe reste utile : intercepter les erreurs au plus pres du point ou vous pouvez reellement faire quelque chose.

Creons un mini-runner qui appelle le flow et affiche ce qui s'est passe. Ici, nous affichons encore « techniquement », pour voir les categories :

import Foundation

let repo = InMemoryBookRepository()

do {
    try addBookFlow(input: "add Dune 1965", repo: repo)
    print("OK: book added") // OK: book added
} catch let e as AppError {
    print("APP ERROR:", e)  // APP ERROR: ...
} catch {
    print("UNEXPECTED:", error)
}

Oui, print(e) a l'air encore un peu brut. Mais c'est normal : aujourd'hui, notre objectif est la structure, pas le texte final.

Mini-scenarios : comment la saisie casse differentes couches

Il est tres utile de voir qu'une meme commande peut « tomber » a differents endroits. Imaginez que l'utilisateur saisit :

  • "" (vide) — c'est parse (pas de commande).
  • "add 1965" (des espaces a la place du titre) — formellement, le parseur peut extraire quelque chose, mais le domaine dira « titre vide ».
  • "add Dune 9999" — parse reussi, le domaine se plaint de la plage.
  • "add Dune 1965" — tout va bien, mais le storage n'a « par hasard » pas sauvegarde.

Verifions deux cas avec de courts appels :

import Foundation

do {
    try addBookFlow(input: "add Dune 9999", repo: repo)
} catch {
    print(error) // AppError.domain(...)
}
import Foundation

do {
    try addBookFlow(input: "add Dune 1965", repo: repo)
} catch {
    print(error) // Peut etre AppError.storage(...) a cause de saveSnapshot()
}

Le sens est precisement que, par le type d'erreur, vous comprenez deja ou regarder. Cela economise du temps et des neurones. Les neurones, au passage, seront utiles, parce que la programmation ne les laisse generalement pas rentrer chez eux le vendredi soir.

6. Erreurs typiques

Erreur n°1 : melanger parse et domain « parce que c'est plus simple ».
Cas frequent : dans le parseur de commande, vous verifiez directement les plages, l'unicite, « peut-on supprimer ce livre » et ainsi de suite. Le parseur devient alors un monstre, et le domaine se transforme en coquille vide. Mieux vaut separer patiemment : parse s'occupe de la forme, domain — des regles.

Erreur n°2 : faire un seul catch { throw AppError.unexpected(error) } et penser que « tout est gere ».
Un tel code se compile et fonctionne meme, mais vous perdez tout le sens des couches. L'utilisateur obtiendra le meme comportement pour une faute de frappe dans la commande et pour un probleme de sauvegarde. Et vous, en tant que developpeur, vous passerez longtemps a vous demander ou ca a casse.

Erreur n°3 : avaler les erreurs avec try? avant de les avoir traduites.
Si vous faites let x = try? something() au milieu de la chaine, vous transformez la « cause » en nil. Apres cela, il n'y a plus rien a traduire : vous ne savez deja plus s'il s'agissait d'un ParseError, d'un StorageError ou d'autre chose. try? est un outil utile, mais il doit apparaitre seulement la ou la cause n'a vraiment pas d'importance. Pour la translation, la cause est presque toujours importante.

Erreur n°4 : stocker dans les couches basses des chaines deja pretes pour l'utilisateur.
Si DomainError renvoie « Veuillez saisir une annee entre 1450 et 2100 (merci) », il semble que vous ayez fait quelque chose de pratique. Mais dans une semaine, vous voudrez afficher d'autres textes, ajouter une aide help, changer la langue des messages ou le format. C'est pourquoi, dans les couches basses, nous gardons des donnees d'erreur, et le texte est forme plus haut (dans le cadre de cette journee, ce sera un sujet a part).

Erreur n°5 : fabriquer une « erreur bouche-trou » au lieu de cas concrets.
enum StorageError { case failed } — c'est comme un mot « je suis occupe » sans signature ni heure : formellement, quelque chose a ete dit, mais rien d'utile. Meme si vous ne connaissez pas encore tous les cas, commencez au moins par deux ou trois cas significatifs : cannotLoad, cannotSave, corruptedData. La precision, c'est du temps gagne en debogage.

1
Mission
Swift SELF, niveau 30, leçon 3
Bloqué
Parseur de nombre
Parseur de nombre
1
Mission
Swift SELF, niveau 30, leçon 3
Bloqué
Profil de domaine
Profil de domaine
1
Mission
Swift SELF, niveau 30, leçon 3
Bloqué
Enregistrement des couches
Enregistrement des couches
1
Mission
Swift SELF, niveau 30, leçon 3
Bloqué
Synchronisation des couches
Synchronisation des couches
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION