01 · Contexte & objectif
Écrire un mini-framework web réactif en TypeScript, en n’utilisant aucune bibliothèque externe — Vite servait uniquement au bundling. React, Vue, Svelte, jQuery, Lodash : tous interdits. Il ne restait que l’API DOM native.
L’énoncé demandait d’y faire vivre les patrons de conception vus en cours, et d’aller jusqu’aux Observables. Le barème, sur 20 points, était très inégalement réparti : Strategy pesait 5 points à lui seul, Observer 2, et chacun des autres patterns 1. Ce déséquilibre disait où était le vrai sujet — et ce n’était pas d’aligner des patterns pour la forme.
Dix jours, à trois. Je me suis chargé du cœur du framework : les cinq patterns, l’infrastructure Docker et les tests unitaires.
02 · Le point dur : Strategy sur trois backends de stockage
Le store devait pouvoir persister son état en mémoire, dans le localStorage,
ou dans IndexedDB — et changer de backend à l’exécution, sans que le store
lui-même ne soit modifié.
Le piège est dans la nature des trois API. Map et localStorage sont
synchrones : la valeur est là, tout de suite. IndexedDB est
asynchrone : ouverture de base, transaction, événements onsuccess /
onerror. Un contrat commun naïf — get(key): T — exclut donc IndexedDB
d’entrée. Et un contrat qui l’accepterait par un détour quelconque forcerait
l’appelant à savoir quel backend est branché, ce qui annule tout l’intérêt du
pattern.
La seule interface qui tienne les trois est celle qui s’aligne sur le plus
contraignant : tout retourne une Promise, y compris les backends synchrones,
qui se contentent d’un Promise.resolve(). Le code appelant écrit await sans
jamais savoir ce qu’il y a derrière, et setStrategy() peut permuter les
implémentations à chaud.
Détail ajouté au-delà de l’énoncé : LocalStorageAdapter préfixe ses clés. Sans
ça, son clear() viderait tout le localStorage du domaine, y compris ce qui ne
lui appartient pas — un effet de bord invisible en développement, et pénible en
production.
03 · Les autres patterns
Factory sans if ni switch. L’énoncé l’exigeait explicitement. Les huit
types d’éléments (button, div, image, hr, input, heading, span,
paragraph) sont enregistrés dans un Record<ElementType, TagConstructor> :
ajouter un type consiste à ajouter une entrée, jamais à rallonger un
branchement. Une classe abstraite partagée applique la configuration commune,
chaque tag concret ne fournit que son nom de balise.
Builder à interface fluide. Des méthodes with* qui retournent this, et
leurs symétriques without*. Il complète la Factory plutôt qu’il ne la double :
la Factory pour créer vite un élément simple, le Builder pour composer des
arbres imbriqués.
Observable. Le socle de toute la réactivité — le binding DOM, le store et le
routeur sont tous construits dessus. Deux choix qui ne sont pas dans l’énoncé
mais qui changent l’usage : subscribe() émet immédiatement la valeur
courante — sinon un composant qui s’abonne après une émission reste vide
jusqu’au prochain changement — et les abonnés sont stockés dans un Set, ce qui
rend l’abonnement idempotent. Le désabonnement est retourné par subscribe(),
pour éviter les fuites mémoire à la destruction d’un composant.
Singleton. AppConfig et AppStore, constructeur privé, getInstance().
AppStore reçoit une StorageStrategy : le Singleton porte l’état, la Strategy
décide où il est persisté.
04 · Ce que j’ai ajouté ensuite
Le client HTTP renvoie un Result<T, HttpError> plutôt que de lever une
exception : une erreur réseau devient une valeur qu’on traite, pas un flux de
contrôle parallèle qu’on peut oublier d’attraper.
J’y ai greffé des interceptors, encore par Strategy — un AuthInterceptor
qui ajoute un jeton porteur quand il y en a un, un LoggingInterceptor qui trace
requête et réponse. Ils étaient optionnels au barème ; c’était l’endroit le plus
naturel pour réutiliser le pattern hors du stockage.
05 · Un piège qui m’a coûté du temps
Les tests de LocalStorageAdapter passaient — mais ne testaient rien.
Node 22 embarque une implémentation expérimentale et native de
localStorage. Sous Vitest, elle prenait le pas sur celle de jsdom : la suite
exerçait le localStorage de Node, pas celui du navigateur qu’on prétendait
vérifier. Un test vert qui ne prouve rien est pire qu’un test rouge, parce qu’il
ferme la question.
Correctif : lancer Vitest avec NODE_OPTIONS=--no-experimental-webstorage, pour
que jsdom reprenne la main.
06 · Qualité et livraison
86 tests Vitest, un fichier par brique. strict: true, plus noUnusedLocals et
noUnusedParameters — et zéro any dans les sources. JSDoc sur les classes,
interfaces et méthodes publiques.
Livraison en image Docker multi-étages : Node compile, Nginx sert le dist/
statique avec un repli SPA pour que le routeur côté client fonctionne sur un
rechargement en profondeur.
07 · Ce que j’en retire
Un projet « appliquer les design patterns » invite à les plaquer : on écrit une Factory parce qu’il faut une Factory, et elle ne résout rien. Le barème d’ici protégeait contre ça en mettant cinq points sur le seul pattern qui posait une vraie question — faire cohabiter trois API dont l’une est asynchrone.
Ce que j’en garde n’est pas la mécanique des patterns, elle s’oublie et se
relit. C’est le réflexe de chercher la contrainte la plus forte avant de
définir une interface. En raisonnant depuis Map, on obtient un contrat
synchrone élégant qu’IndexedDB fait exploser une semaine plus tard. En
raisonnant depuis IndexedDB, on obtient un contrat que les trois honorent — un
peu plus lourd pour les cas simples, mais qui n’a jamais eu besoin d’être
retouché.
01 · Context & goal
Write a reactive web mini-framework in TypeScript using no external library — Vite was allowed for bundling only. React, Vue, Svelte, jQuery, Lodash: all banned. What remained was the native DOM API.
The brief asked us to put the course’s design patterns to work in it, all the way through to observables. The 20-point marking scheme was very unevenly weighted: Strategy alone was worth 5 points, Observer 2, and every other pattern 1. That imbalance said where the real subject was — and it was not about lining up patterns for show.
Ten days, in a team of three. I took the framework core: the five patterns, the Docker infrastructure and the unit tests.
02 · The hard part: Strategy across three storage backends
The store had to persist its state in memory, in localStorage or in
IndexedDB — and switch backend at runtime, without the store itself
changing.
The trap lies in the nature of the three APIs. Map and localStorage are
synchronous: the value is right there. IndexedDB is asynchronous:
opening a database, a transaction, onsuccess / onerror events. A naive
common contract — get(key): T — therefore rules IndexedDB out from the start.
And a contract that somehow worked around it would force callers to know which
backend is plugged in, which defeats the whole point of the pattern.
The only interface that holds all three is the one that aligns with the most
constrained: everything returns a Promise, including the synchronous
backends, which simply hand back Promise.resolve(). Calling code writes
await without ever knowing what sits behind it, and setStrategy() can swap
implementations live.
One detail added beyond the brief: LocalStorageAdapter prefixes its keys.
Without that, its clear() would wipe the domain’s entire localStorage,
including keys that are none of its business — a side effect that is invisible
in development and painful in production.
03 · The other patterns
Factory with no if and no switch. The brief required it explicitly. The
eight element types (button, div, image, hr, input, heading, span,
paragraph) are registered in a Record<ElementType, TagConstructor>: adding a
type means adding an entry, never extending a branch. A shared abstract class
applies the common configuration; each concrete tag only supplies its tag name.
Builder with a fluent interface. with* methods returning this, and their
without* counterparts. It complements the Factory rather than duplicating it:
the Factory to create a simple element quickly, the Builder to compose nested
trees.
Observable. The backbone of all reactivity — DOM binding, the store and the
router are all built on it. Two choices that are not in the brief but change how
it is used: subscribe() emits the current value immediately — otherwise a
component subscribing after an emission stays empty until the next change — and
subscribers live in a Set, which makes subscription idempotent. The
unsubscribe function is returned by subscribe(), to avoid memory leaks when a
component is destroyed.
Singleton. AppConfig and AppStore, private constructor, getInstance().
AppStore takes a StorageStrategy: the Singleton holds the state, the
Strategy decides where it is persisted.
04 · What I added afterwards
The HTTP client returns a Result<T, HttpError> rather than throwing: a network
error becomes a value you handle, not a parallel control flow you can forget to
catch.
Onto it I grafted interceptors, again through Strategy — an
AuthInterceptor that attaches a bearer token when there is one, a
LoggingInterceptor that traces request and response. They were optional in the
marking scheme; this was the most natural place to reuse the pattern outside
storage.
05 · A trap that cost me time
The LocalStorageAdapter tests passed — but tested nothing.
Node 22 ships an experimental native implementation of localStorage. Under
Vitest it took precedence over jsdom’s: the suite was exercising Node’s
localStorage, not the browser one we claimed to be verifying. A green test
that proves nothing is worse than a red one, because it closes the question.
Fix: run Vitest with NODE_OPTIONS=--no-experimental-webstorage, so jsdom takes
over again.
06 · Quality and delivery
86 Vitest tests, one file per building block. strict: true, plus
noUnusedLocals and noUnusedParameters — and zero any in the sources.
JSDoc on classes, interfaces and public methods.
Delivered as a multi-stage Docker image: Node builds, Nginx serves the static
dist/ with an SPA fallback so the client-side router survives a deep reload.
07 · What I take away
A project framed as “apply the design patterns” invites you to bolt them on: you write a Factory because a Factory is required, and it solves nothing. This marking scheme guarded against that by putting five points on the one pattern that raised a real question — making three APIs coexist when one of them is asynchronous.
What I keep from it is not the mechanics of the patterns; those are forgotten
and looked up again. It is the reflex of looking for the strongest constraint
before defining an interface. Reason from Map and you get an elegant
synchronous contract that IndexedDB blows up a week later. Reason from
IndexedDB and you get a contract all three can honour — slightly heavier for the
simple cases, but one that never needed touching again.