Assistente indipendente per Pegaso, Mercatorum e San Raffaele
Snapshot di riferimento del capitolo
Questo capitolo chiude il libro sul candidato PlumePilot 2.35.2, branchfeat/ui-ux-2.35.2, commit
f6fcb4c29c83ba9150df0be31da04fc91dffb634.
Arrivati alla fine di un progetto reale è molto facile guardare indietro e dire:
questo file è troppo grande
questa API interna è troppo implicita
qui sarebbe servito un modulo
lì avremmo dovuto usare un framework
Il problema è che questa valutazione conosce già il futuro.
Quando PlumePilot era più piccolo non sapevamo ancora che avrebbe avuto:
tre università
Chrome + Edge + Firefox
popup + floating menu
PDF + HTML + EPUB
editor rich-text offline
autoplay con più policy
commission monitor
achievement e Gaming UI
release build specifiche per browser
Un’architettura ottima per un progetto di tre file può essere una pessima architettura per un progetto di cinquanta feature.
Ma vale anche il contrario:
Progettare fin dal primo giorno come se il progetto fosse già enorme può essere un’altra forma di errore.
Questo capitolo non vuole quindi rispondere:
“qual era l'architettura perfetta?”
Vuole rispondere a una domanda più utile:
Ora che conosciamo davvero la forma del problema, quali decisioni prenderemmo diversamente se dovessimo continuare a far crescere PlumePilot?
Quando riguardiamo una decisione passata possiamo classificarla come:
errore
trade-off
scaling debt
decisione ancora corretta
Sono categorie molto diverse.
Una decisione era sbagliata già rispetto alle informazioni disponibili.
Esempio ipotetico:
salvare un bearer token persistente senza necessità
Sarebbe difficile giustificarlo anche in una versione piccola.
Abbiamo scelto consapevolmente semplicità in cambio di un limite.
Esempio:
vanilla JavaScript
senza bundler
ha ridotto toolchain, build complexity e review surface.
Non è un errore solo perché il progetto è poi cresciuto.
Una decisione semplice era corretta quando il sistema era piccolo, ma oggi costa più di quanto faccia risparmiare.
Esempio:
molta logica concentrata in content.js
All’inizio:
un file
facile da caricare
nessun module graph
Oggi:
più domini
più stato
più test che estraggono source slice
più rischio di coupling
Questa è una categoria particolarmente importante.
Una scelta può sembrare “semplice” ma continuare a essere appropriata.
Per esempio:
nessun backend proprietario necessario
PlumePilot opera localmente sulle piattaforme a cui l’utente è già autenticato.
Aggiungere un server solo perché “le app serie hanno un backend” aumenterebbe:
privacy surface
operational cost
security obligations
failure modes
senza un beneficio automatico.
Dopo sedici capitoli potremmo disegnare una nuova struttura elegantissima e concludere:
riscriviamo tutto
È una tentazione pericolosa.
Il codice attuale contiene conoscenza accumulata attraverso:
bug reali
layout reali
race reali
store rejection reali
fallback reali
Molti dettagli apparentemente strani esistono perché una condizione del mondo reale li ha resi necessari.
Una rewrite tende a ricordare:
feature visibili
e dimenticare:
edge case invisibili
Una strategia più sana è:
sistema esistente
↓
identifica boundary
↓
introduci modulo nuovo
↓
sposta responsabilità gradualmente
↓
rimuovi vecchio path quando non serve più
È simile al pattern spesso chiamato Strangler Fig.
Non sostituiamo tutto in una volta.
Facciamo crescere una struttura migliore attorno alle parti che hanno davvero bisogno di cambiare.
content.jscontent.js oggi contiene responsabilità molto diverse:
DOM discovery
autoplay
API coordination
cache
material collection
first-incomplete discovery
progress tracking
recovery
Non è necessariamente un “God object” classico, perché molte funzioni sono ben delimitate.
Ma il file è diventato un convergence point.
Non divideremmo semplicemente il file in:
content1.js
content2.js
content3.js
La separazione deve seguire responsabilità.
Una possibile struttura:
playback/
identity.mjs
discovery.mjs
autoplay-policy.mjs
recovery.mjs
platform/
dom-adapter.mjs
api-adapter.mjs
cache/
lesson-cache.mjs
material-cache.mjs
content.js
orchestration + browser effects
Estrarre prima ciò che ha:
input chiari
output chiari
pochi side effect
Non partire dalle funzioni più grandi.
Partire dalle funzioni con boundary migliori.
Possiamo organizzare parte del sistema così:
raw state
↓
pure/quasi-pure decision
↓
command
↓
browser effect
Oggi molte fasi sono già concettualmente separabili.
Potremmo avere:
const plan = chooseDiscoveryPlan({
outline,
masterIndex,
verifiedComplete,
mode,
});
che produce:
{
candidates: [...],
requiresFreshData: true,
fallbackPolicy: "visual"
}
Poi la shell esegue:
API request
DOM open
click
Possiamo testare:
master stale
candidate order
ambiguous identity
senza caricare metà content.js in node:vm.
Dobbiamo:
cliccare
osservare DOM
mandare messaggi
aprire tab
scrivere storage
creare Blob
Provare a nascondere tutto dietro un’astrazione puramente funzionale produrrebbe probabilmente codice più difficile da leggere.
decision logic → pura quando conviene
browser interaction → esplicita
Non:
zero side effects
Nel tempo abbiamo introdotto:
Pegaso
Mercatorum
UTSR / San Raffaele
Molto codice è comune.
Alcune differenze sono:
DOM shape
platform ID
storage scoping
copy
URL patterns
Per esempio:
const platform = {
id,
courseCodeFromUrl(),
sections(),
chapters(section),
currentLesson(),
commissionLabel(...),
};
Non necessariamente una classe.
Potrebbe essere un semplice oggetto di funzioni.
Il core dovrebbe chiedere:
“qual è il capitolo corrente?”
non:
“se siamo Pegaso cerca div.border-t,
se siamo Mercatorum usa questo fallback...”
Abbiamo normalizzatori per:
master API
lesson API
commission data
DOM identities
Questa è già una forma di anti-corruption layer.
Raggrupperemmo i contratti esterni in moduli dedicati:
external/multiversity-api.mjs
external/course-dom.mjs
external/browser-runtime.mjs
Il resto del sistema consumerebbe strutture normalizzate.
I messaggi hanno shape come:
{
type: "PEGASO_START_EXPORT",
...
}
Il Capitolo 2 ha mostrato che, di fatto, questi messaggi sono API interne.
Una struttura possibile:
protocol/
messages.mjs
validators.mjs
version.mjs
con costanti:
export const MessageType = Object.freeze({
START_EXPORT: "PEGASO_START_EXPORT",
CANCEL_EXPORT: "PEGASO_CANCEL_EXPORT",
...
});
Per messaggi critici potremmo validare:
type
required fields
ID format
payload bounds
al boundary.
Potrebbe essere utile.
Ma per un protocollo interno piccolo può bastare:
function parseStartExportMessage(value) {
...
}
L’obiettivo è rendere il contract esplicito, non aggiungere tecnologia per principio.
Content script e background vengono aggiornati normalmente insieme.
Quindi non abbiamo un distributed system con server vecchi e client nuovi per mesi.
Durante un update possono convivere temporaneamente:
page vecchia
extension runtime nuovo
oppure una pagina può restare aperta mentre l’estensione si aggiorna.
Per messaggi critici, un semplice:
protocolVersion
potrebbe rendere failure più chiari in futuro.
Abbiamo visto che autoplay è già una state machine.
Ma lo stato è distribuito fra:
booleans
context
stored settings
recovery markers
DOM state
Non necessariamente XState.
Potrebbe bastare:
function transition(state, event) {
switch (state.phase) {
...
}
}
{
phase: "watching",
playback: {...},
policy: {...},
pendingBoundary: null,
generation: 12,
}
VIDEO_PLAY
VIDEO_ENDED
COMPLETION_CONFIRMED
TEST_FOUND
USER_NAVIGATED
POLICY_CHANGED
CANCEL
TAB_HIDDEN
La precedenza fra:
70%
chapter limit
test stop
auto-complete
manual selection
sarebbe leggibile in un unico luogo.
PlumePilot ha contesti separati.
Un unico store globale “magico” richiederebbe comunque:
messaging
serialization
lifecycle handling
Un buon principio è:
state vicino al suo owner
con:
protocollo esplicito quando attraversa un boundary
Abbiamo wrapper:
storageGet
storageSet
storageRemove
ma molte chiavi vengono ancora conosciute da moduli differenti.
Potremmo avere:
storage/
preferences-repository.mjs
operation-repository.mjs
commission-repository.mjs
course-state-repository.mjs
migrations.mjs
await courseState.get(courseIdentity);
anziché conoscere direttamente:
chiave
legacy key
platform prefix
migration fallback
È utile salvare:
{
version: 3,
...
}
come già facciamo in alcuni snapshot.
v1
↓ migrate
v2
↓ migrate
v3
La normalizzazione legacy non resta sparsa indefinitamente nel codice di lettura.
Abbiamo più Map con policy differenti:
lessonApiCache
materialLinkCache
pageLessonSnapshots
pending requests
La differenza è spesso giustificata.
Ma alcune regole ricorrono.
Per ogni cache documenteremmo:
key
entry schema
freshness
max size
invalidation
monotonic fields
negative caching
owner
Una cache di lesson detail e una pending request registry hanno semantiche diverse.
Creare una mega-classe generica rischia di nascondere proprio ciò che ci interessa.
Potremmo condividere helper per:
bounded insertion
TTL check
in-flight coalescing
mantenendo policy di dominio separate.
Se due caller chiedono contemporaneamente:
same course + lesson detail
potremmo avere:
Map<cacheKey, Promise<Response>>
first caller
↓ miss
create request
store Promise
second caller
↓ hit in-flight
reuse Promise
Poi:
finally → remove in-flight
Evita cache stampede senza trasformare ogni fetch in serializzazione globale.
Entrambi ragionano su:
course order
master hints
lesson details
identity
completion evidence
ma con policy differenti.
DiscoveryEngine
che restituisce target logici.
bookmark-first-incomplete
forward-incomplete-video
sequential-next
{
status: "target",
chapterIdentity,
activityIdentity,
evidence,
requiresVisualVerification
}
Solo dopo aver scelto logicamente il target:
open section
open chapter
resolve row
click
Questo ridurrebbe il coupling fra:
search algorithm
UI navigation
Ma condividono alcuni input:
course title
section/chapter order
assets
questions
metadata
Potremmo normalizzare prima in:
{
course,
sections: [
{
title,
chapters: [...]
}
],
assets: [...]
}
Poi renderer distinti:
IR → PDF
IR → EPUB
IR → HTML
I test export e le dispense hanno domini diversi.
Quindi potremmo avere:
CourseMaterialIR
QuizIR
anziché un oggetto universale enorme.
Alcune fasi EPUB/PDF consumano:
CPU
canvas
compression
hash
Spostare parte del lavoro in un Worker potrebbe ridurre il main-thread blocking.
Worker introduce:
message passing
structured clone / transfer
worker lifecycle
library compatibility
memory duplication se trasferiamo male
Dal Capitolo 12:
profiling first
Se il problema reale è:
peak memory
un Worker potrebbe persino peggiorarlo se duplica buffer.
Se usassimo Worker, dovremmo progettare ownership esplicita di:
ArrayBuffer
tramite transferable quando appropriato.
L’editor usa pragmaticamente:
execCommand
DOM manipulation
Selection
Range
Funziona e conserva bene alcune undo semantics.
execCommand() è deprecato.
Se l’editor continuasse a crescere, investiremmo in un command model esplicito.
ToggleBold
ApplyHighlight
InsertChecklist
WrapCallout
ExitBlock
ClearFormat
Ogni command definisce:
precondition
DOM transform
selection restoration
undo semantics
Studieremmo più a fondo:
beforeinput
inputType
Selection API
per ridurre dipendenza da comandi legacy.
L’editor serve per:
appunti di studio offline
Non per collaborazione realtime, track changes o layout editoriale complesso.
Non adotteremmo automaticamente:
ProseMirror
Slate
Lexical
solo perché sono potenti.
Il costo deve essere giustificato dallo scope.
menu-ux.js e menu-ux.css condividono:
presentation model
style roles
layout rules
Potremmo formalizzare primitive come:
Disclosure
SettingsGroup
ActionRow
StatusBanner
OperationControl
TabController
Vanilla DOM factory:
function createSettingsGroup({...}) {
...
}
può essere sufficiente.
Con UI più grande potremmo chiedere:
“non sarebbe meglio React?”
La risposta corretta non è sì o no in assoluto.
Potrebbe aiutare con:
component composition
render derivato dallo state
shared UI primitives
conditional rendering
bundler
runtime/library weight
build transforms
source maps
review complexity
migration cost
oltre a non risolvere automaticamente:
extension messaging
DOM host integration
API reverse engineering
browser packaging
Per la scala attuale, probabilmente non riscriveremmo la UI in React.
Estrarremmo prima:
view models
DOM helpers
shared controllers
Se poi la UI diventasse molto più complessa, rivaluteremmo con dati reali.
Molti bug potenziali riguardano shape:
message
API response
cache entry
operation
layout config
Type information avrebbe valore.
Potremmo partire da:
JSDoc
// @ts-check
su moduli nuovi.
/**
* @typedef {Object} PlaybackTarget
* @property {"target"|"end"|"fallback"} status
* @property {ChapterIdentity=} chapter
*/
L’editor/TypeScript language service può trovare molte inconsistenze senza cambiare runtime.
Prima tipizzeremmo:
message protocol
normalized API models
storage records
operation state
perché sono boundary dove una shape errata ha effetto ampio.
Il valore di un type system è maggiore dove:
il dominio è strutturato
non dove aggiunge solo annotazioni meccaniche.
Oggi i test sono molti file .mjs indipendenti.
Funziona.
Ma è facile dimenticarne uno.
scripts/test-all.mjs
che:
enumera suite
esegue in ordine stabile
riassume fallimenti
test:fast
test:browser
test:release
anche senza aggiungere dipendenze npm.
PlumePilot ha ormai abbastanza invarianti da giustificare una CI minima.
Per ogni PR:
Node regression suite
static/source invariants
basic release consistency
Potrebbe girare:
su PR UI
nightly
pre-release
se il costo è maggiore.
Su tag/candidate:
build 3 target
validate
publish checksums/artifacts
potrebbe essere automatizzato.
Per un progetto piccolo, un gate umano prima di:
submit/publish
resta utile.
Specialmente quando cambiano:
permissions
review notes
screenshots
UX
Aggiungeremmo alcuni E2E ad alto valore:
popup ↔ storage sync
floating ↔ storage sync
service worker restart
operation recovery
builder handoff
Firefox target package load
Gli E2E sono costosi.
Devono proteggere boundary che i test Node non possono rappresentare bene.
È ottimo per property test.
Ma potremmo anche mantenere alcune response reali sanitizzate:
master
lesson detail
commission
Quando aggiorniamo normalizzatori possiamo verificare:
shape reale storica
oltre a input sintetici.
Le fixture devono rimuovere:
token
utente
email
ID sensibili
contenuti non necessari
Abbiamo log e alcuni diagnostics object.
{
event: "playback.discovery.target",
courseCode,
lessonNumber,
source: "master+detail",
elapsedMs,
}
Structured diagnostics può restare:
locale
opt-in/debug-only
Non serve creare un analytics backend.
Più facile:
filtrare
confrontare timeline
scrivere test
rispetto a stringhe libere.
Oggi esiste già un modello:
kind
phase
operationId
sourceTabId
builderTabId
È uno dei nuclei architetturali migliori del progetto.
Come un piccolo state machine/repository:
idle
acquired
collecting
building
completed
cancelled
failed
PDF materials, EPUB e tests possono condividere:
ownership
cancellation
lifecycle
ma non per forza la stessa pipeline di contenuto.
Ma introdurremmo un helper coerente:
function createOperationContext({ signal, operationId, report }) {
...
}
signal
progress reporter
identity
senza rileggere global state se non necessario.
Questo è corretto.
Ma alcune operazioni ripetono:
start time
loop
sleep
abort
hidden-tab compensation
waitUntil
withDeadline
retryWithBackoff
con policy passata dal caller.
sleep() generico come soluzione universaleL’helper non deve incoraggiare:
attendi 2s e spera
Deve favorire:
attendi condizione entro deadline
Molte decisioni non sono ovvie dal codice:
perché no backend?
perché Firefox vendor transform?
perché normal autoplay non salta completati?
perché master è solo hint?
perché popup e floating restano controller separati?
Un file:
docs/adr/0007-master-index-is-a-hint.md
potrebbe contenere:
context
decision
consequences
alternatives
ifADR va usato per decisioni:
costose da cambiare
non ovvie
cross-cutting
Non useremmo il libro stesso come unica architettura documentation operativa.
ARCHITECTURE.md
con:
contexts
message flows
state ownership
external boundaries
release build
sarebbe sufficiente per orientarsi.
È ancora una scelta forte.
I dati necessari restano principalmente:
nel browser
nella piattaforma universitaria
nei file generati localmente
privacy
zero account PlumePilot
zero server uptime
zero database migration
zero central data retention
Solo se una feature richiedesse davvero:
sync cross-device
collaboration
server-side processing
con valore sufficiente a giustificare il costo.
La combinazione:
API evidence
+
DOM interaction
è una delle architetture più importanti del progetto.
Quindi:
normalizer
fallback
validation
restano necessari.
cheap coarse signal
→ narrow search
→ detailed verification
È un pattern generale efficiente.
La refactor futura dovrebbe preservare questa semantica anche se cambia il codice.
non saltare
non produrre artifact
non dichiarare tutto completo
Questo principio deve sopravvivere a qualsiasi refactor.
Preferenze come:
stop test
skip completed
70%
chapter limit
rendono l’automazione una policy scelta dall’utente.
Una selezione manuale dell’utente deve continuare ad avere precedenza su timer/recovery automatici.
È una regola UX e di concurrency insieme.
video.play()PlumePilot orchestra la navigazione.
Non cerca di sostituire il playback engine dell’LMS.
PlumePilot → seleziona attività
LMS → gestisce playback/sessione
Questo limita il coupling.
Oggi sappiamo che:
Firefox manifest
Edge locales
Chromium offscreen
AMO review transforms
sono differenze reali.
Possibilmente con test/CI migliori.
Non solo per gli store.
Anche per:
provenance
debug
supply-chain awareness
Ma la sua logica deve rimanere:
nel package
Questo semplifica:
security
review
reproducibility
È:
Usa la complessità minima che risolve il problema corrente.
Vanilla JavaScript era una scelta eccellente.
Oggi il progetto può introdurre più struttura senza dover cambiare paradigma completamente.
Ma contiene conoscenza preziosa.
Un refactor deve:
migliorare la test architecture
senza perdere:
regression knowledge
Classifichiamo per:
beneficio
rischio
costo
test runner unico
CI fast suite
ARCHITECTURE.md
ADR per decisioni principali
JSDoc/@ts-check sui nuovi boundary
message type constants/validators
estrazione di pure helpers
Discovery Engine
Platform/DOM Adapter
storage repositories
explicit autoplay state transitions
operation state model
export IR
worker experimentation
structured diagnostics
fixture API sanitizzate
browser E2E più ampi
full TypeScript migration
framework UI
bundler generale
backend
Solo se nuove esigenze lo giustificano.
Prima di un refactor grande:
run all regression tests
aggiungi test mancanti sul boundary
Per esempio:
identity
candidate ordering
presentation model
storage key normalization
platform DOM
API
storage
browser runtime
content.jsLasciandogli principalmente:
orchestration
event listeners
side effects
Dopo aver creato boundary più stabili.
flowchart TD
UI[Popup / Floating UI] --> VM[Presentation Models]
UI --> MSG[Message Protocol]
MSG --> BG[Background Coordinator]
MSG --> CS[Content Orchestrator]
CS --> FSM[Autoplay State Machine]
CS --> DISC[Discovery Engine]
CS --> DOM[Platform DOM Adapter]
CS --> API[Multiversity API Adapter]
DISC --> CACHE[Cache Policies]
DISC --> API
BG --> REPO[Storage Repositories]
CS --> REPO
BG --> JOB[Operation Manager]
JOB --> EXPORT[Export Builders]
EXPORT --> IR[Document IR]
IR --> PDF[PDF Renderer]
IR --> EPUB[EPUB Renderer]
IR --> HTML[HTML Renderer]
BUILD[Release Compiler] --> CH[Chrome Artifact]
BUILD --> FF[Firefox Artifact]
BUILD --> ED[Edge Artifact]
Un bug di route mapping vive in:
Discovery Engine / identity
non in un file da migliaia di righe.
Possiamo importare moduli direttamente.
Ogni stato ha un owner più evidente.
Se Mercatorum cambia DOM:
adapter
non discovery core.
La navigazione del codebase diventa più complessa.
Dobbiamo gestire ordine/caricamento/module support.
Un nuovo contributor può dover capire:
adapter
repository
state machine
prima di cambiare una riga.
La modularità non è gratis.
La adottiamo perché oggi la complessità del dominio la giustifica.
Se introduciamo:
IPlaybackDiscoveryProviderFactory
AbstractCourseRepositoryAdapter
per chiamare una funzione che ha due implementazioni, potremmo essere andati troppo oltre.
function
plain object
Map
module export
finché bastano.
Non sappiamo ancora cosa è accidentale e cosa è comune.
Possiamo vedere il pattern.
La 2.35.2 menu-ux è nata dopo che popup e floating menu avevano davvero mostrato drift.
È un buon esempio di astrazione introdotta al momento giusto.
Se il rischio è alto:
security
storage corruption
message protocol
può valere la pena astrarre prima.
Anche con:
interface LessonResponse { ... }
l’API può restituire:
qualunque JSON
static type
+
runtime normalizer
Il primo protegge il nostro codice.
Il secondo protegge il boundary esterno.
Possiamo avere:
React spaghetti
come:
vanilla spaghetti
Non:
“quale framework?”
ma:
“quali responsabilità e state ownership?”
browser message → internal RPC
chrome.storage → local persistence
DOM adapter → external integration adapter
master API → index
content script → edge worker/client agent
release ZIP → deployment artifact
Nel browser:
lifetime
DOM volatility
user interaction
render timing
sono molto più centrali.
PlumePilot mostra:
state consistency
async orchestration
protocol design
resource management
recovery
esattamente come backend systems.
Dobbiamo convivere con:
focus
selection
layout
user timing
visibility
DOM lifecycle
che aggiungono nuove dimensioni.
Abbiamo:
più contexts
messaggi
persistenza
retry
timeout
leases
idempotence
senza un server nostro.
Questo cambia tutto.
Non controlliamo:
DOM
API
release LMS
timing
session
Un’estensione sana assume che il boundary esterno possa cambiare.
Capire:
request
response
identity
session
prima di scrivere automazione.
Normalizzare, verificare, fallback.
Nel browser il costo reale include:
network
render
canvas
GC
main-thread responsiveness
Spesso il miglior guadagno è:
non aprire capitolo
non rifare fetch
non rasterizzare pagina
non duplicare asset
closed accordion
→ logical identity survives DOM reconstruction
Questo è molto più prezioso del fix locale.
Il caso 2.35.2:
same tree
new parent
new commit
lo dimostra in modo quasi didatticamente perfetto.
È:
artifact
+
store metadata
+
review
+
update path
Chiediamo:
chi possiede lo stato?
qual è l'identità stabile?
qual è la source of truth?
quali boundary attraversiamo?
cosa succede se fallisce?
come cancelliamo?
come testiamo?
Non significa che dobbiamo bloccare lo sviluppo.
Ma sappiamo dove probabilmente nascerà il prossimo debito.
Possiamo usare una scorecard:
| Domanda | Risposta desiderata |
|---|---|
| Owner dello stato? | Uno chiaro |
| Identity? | Stabile e composita quando serve |
| Authority? | Esplicita |
| Lifetime? | Coerente con storage |
| Unknown? | Rappresentabile |
| Side effect? | Protetto da guard/revalidation |
| Cancellation? | Definita |
| Failure? | Classificata |
| Fallback? | Sicuro |
| Test? | Property verificabile |
| Boundary? | Normalizzato |
| Observability? | Sufficiente |
Fin dal primo giorno prepareremmo:
src/
tests/
scripts/
docs/
ma senza creare decine di directory vuote.
background/
content/
popup/
shared/
prima divisione naturale.
Un file shared per tipi/validator.
Wrapper + chiavi/schema in un punto.
Un runner dal primo regression bug, non necessariamente dal primo commit.
Browser-specific appena il secondo browser rende reale la differenza.
microfrontend
state management framework
plugin architecture interna
dependency injection container
ORM per chrome.storage
generic event bus
senza un problema concreto.
messaging
storage boundary
release build
Solo dopo aver incontrato:
closed accordion
stale master
multi-platform identity
store review
abbiamo capito alcune proprietà fondamentali.
È normale nei sistemi che integrano ambienti esterni non controllati.
La qualità sta nella velocità con cui trasformiamo esperienza in struttura.
Technical debt è costo futuro creato da una scelta corrente.
Può essere consapevole o accidentale.
source slicing nei test
ha permesso di aggiungere regression test rapidamente.
Oggi sappiamo che alcuni moduli meritano export espliciti.
Quella scelta ha generato valore prima di generare costo.
Quando vediamo ripetutamente:
stesso bug class
stesso workaround
stessa difficoltà test
stessa modifica in più superfici
L’interesse è diventato visibile.
Abbiamo introdotto:
menu-ux.js
menu-ux.css
non perché duplicazione sia sempre male.
Perché il costo delle divergenze era diventato reale.
Ma costava:
DOM transitions
latenza
fragilità
Quando la scala del corso ha reso il costo visibile, abbiamo introdotto l’indice.
Poi le differenze di review/runtime sono diventate concrete.
La build specifica è arrivata quando esisteva un problema reale da risolvere.
Ogni abstraction:
va imparata
testata
mantenuta
migrata
Per questo:
preferire duplication over wrong abstraction
può essere una buona scelta temporanea.
Chiediamo:
quanto costa cambiarla più avanti?
helper locale
CSS token
Possiamo sperimentare.
backend centralizzato
framework UI completo
storage schema irreversibile
remote account system
Richiede più evidenza.
Facile tornare indietro.
Costo alto per invertire.
Prima di una one-way-ish decision chiediamo più dati.
Questo aiuta a evitare architettura prematura.
Discovery Engine + Platform Adapter.
Perché riduce coupling nella parte più delicata:
API + DOM + identity + autoplay
Message contracts + operation/state models espliciti.
Perché i contesti sono l’architettura fondamentale dell’estensione.
Test runner + CI + moduli importabili.
Perché rende più sicuri tutti i refactor successivi.
Non la farei.
PlumePilot oggi ha:
feature funzionanti
store approval history
regression knowledge
multi-platform support
Una rewrite rimetterebbe tutto contemporaneamente in stato non provato.
Hai una funzione di 400 righe.
Quando è meglio:
split meccanico
e quando:
estrazione per responsibility
?
Disegna un PlaybackTarget normalizzato che possa rappresentare:
video
obiettivo
test
unknown visual fallback
senza esporre direttamente un DOM node.
Quali campi metteresti in un OperationState comune a:
PDF
EPUB
quiz export
?
Quali invece lasceresti specifici del job?
Immagina di migrare content.js a moduli.
Quale funzione estrarresti per prima?
Scegline una con:
input/output chiari
pochi global
alta copertura test
Vale la pena introdurre React se:
popup ha 5 tab
floating menu simile
ma gran parte della complessità è messaging/DOM host?
Scrivi pro/contro prima di rispondere.
Una API host cambia field name.
Dove dovrebbe rompersi idealmente?
UI
business logic
normalizer boundary
Perché?
Hai tre cache con TTL diversi.
Conviene una classe generica Cache?
Elenca quali semantiche devono essere realmente condivise prima di astrarre.
Vuoi spostare EPUB conversion in Worker.
Quali metriche raccogli prima?
main-thread blocking
peak memory
transfer bytes
wall time
cancellation latency
Valutare una decisione passata usando informazioni disponibili solo successivamente.
Scelta inizialmente adeguata che diventa costosa quando il sistema cresce.
Sostituzione incrementale di parti del sistema anziché rewrite totale.
Decision logic con pochi side effect circondata da un layer che interagisce con il mondo esterno.
Modulo che traduce differenze della piattaforma in un’interfaccia interna stabile.
Boundary che centralizza accesso e schema della persistenza.
Modello intermedio normalizzato usato fra acquisizione dati e renderer/trasformazioni.
Riutilizzo della stessa Promise/request per richieste concorrenti equivalenti.
Eventi diagnostici rappresentati come dati strutturati invece di sole stringhe libere.
Architecture Decision Record: breve documento che conserva contesto, decisione e conseguenze di una scelta architetturale.
Costo necessario per invertire una decisione tecnica dopo che il sistema vi ha costruito sopra.
Astrazione introdotta prima di comprendere correttamente ciò che è realmente comune.
AWS Prescriptive Guidance:
https://docs.aws.amazon.com/prescriptive-guidance/latest/architectural-decision-records/adr-process.html
Martin Fowler:
https://martinfowler.com/bliki/StranglerFigApplication.html
TypeScript Handbook:
https://www.typescriptlang.org/docs/handbook/type-checking-javascript-files.html
W3C:
https://www.w3.org/TR/input-events-2/
Se oggi iniziassimo PlumePilot sapendo tutto quello che sappiamo ora, costruiremmo una struttura più modulare.
Probabilmente avremmo prima:
message contracts
platform adapter
storage repositories
discovery engine
explicit state transitions
E avremmo test importabili più facilmente.
Ma questa non è la parte più importante della risposta.
La parte importante è capire perché non avevamo quella struttura all’inizio.
Non conoscevamo ancora abbastanza bene il problema.
PlumePilot non è passato da:
cattiva architettura
→ buona architettura
in un solo momento.
È passato attraverso una sequenza più realistica:
problema concreto
↓
soluzione semplice
↓
uso reale
↓
caso limite
↓
nuova informazione
↓
refactor
↓
nuova invariante
Poi il ciclo ricomincia.
È l’insieme di decisioni che permette al sistema di continuare a cambiare senza perdere le proprietà importanti.
Lo rende:
locale
comprensibile
verificabile
reversibile quando possibile
Nel Capitolo 0 avevamo posto una domanda:
Dove vive la verità?
Dopo tutto il percorso possiamo dare una risposta più matura.
La verità non vive sempre in un unico posto.
Abbiamo:
server authority
API snapshots
DOM observations
cache
storage
user intent
operation state
La qualità dell’architettura dipende dalla capacità di sapere:
quale fonte usare
per quale decisione
con quale lifetime
con quale livello di fiducia
Avevamo anche imparato che una browser extension non è un singolo programma.
Ora possiamo dirlo meglio:
PlumePilot
=
insieme di bounded contexts locali
coordinati da protocolli
attorno a una piattaforma esterna non controllata
Non è una definizione formale.
Ma descrive bene il sistema.
Molti dei bug più difficili sono stati bug di identità:
DOM node ≠ lesson
label ≠ global identity
exam_id ≠ cross-platform identity
branch history ≠ tree content
version ≠ artifact bytes
Una volta separata l’identità dalla rappresentazione temporanea, molti problemi diventano più semplici.
Altri bug erano bug temporali:
await
render delay
hidden tab
service worker lifetime
retry
release review
Il tempo è parte dello stato.
Non un dettaglio esterno.
Le parti più fragili sono quasi sempre ai confini:
page ↔ extension
DOM ↔ logical model
API ↔ normalized model
storage ↔ state
source ↔ artifact
artifact ↔ store
E le architetture più riuscite del progetto rendono questi confini più espliciti.
Abbiamo iniziato questo libro con una browser extension che poteva sembrare semplicemente:
JavaScript sopra una piattaforma universitaria
Abbiamo finito per incontrare:
process isolation
RPC
state machines
cache algorithms
optimistic revalidation
anti-corruption layers
resource ownership
binary pipelines
rich-text editing
cross-browser compilers
artifact provenance
regression engineering
Git DAG
release state machines
Non perché volessimo rendere il progetto complicato.
Perché un problema reale, seguito abbastanza a lungo, porta naturalmente a concetti profondi.
Non serve progettare fin dall’inizio l’architettura finale. Serve costruire il sistema in modo che ogni nuova informazione possa diventare una decisione più esplicita, un boundary più chiaro o un’invariante verificabile.
È questo che trasforma uno script utile in software mantenibile.
Ed è questo, più di qualsiasi libreria o pattern specifico, che vale la pena portarsi dietro da PlumePilot nel prossimo progetto.
Inside PlumePilot — Anatomia di una browser extension reale
JavaScript, WebExtensions, DOM, API, asincronia e software engineering attraverso un progetto reale.