02Database5 min

Ho riscritto lo stesso modello di contratti più di dieci volte

Un modello di dominio complicato non viene bene al primo colpo. Ci si converge riscrivendolo con i dati veri davanti, e questo include tornare indietro su decisioni che sembravano giuste.

Di Salva Castells

Immagine di copertina: Ernst Alban, Pubblico dominio.
In questo post

Il primo modello di contratti che ho scritto per uno strumento di BI di un'azienda grande aveva circa ottanta righe di SQL e mi sembrava a posto. Un contratto, una data di inizio, una data di fine, delle condizioni appese. Si leggeva pulito. Un anno dopo quello stesso modello l'avevo riscritto più di dieci volte, inclusa una in cui gli ho cambiato il nome perché il nome mentiva.

Non è stata cattiva pianificazione. È che il dominio si vede solo quando entrano i dati veri.

Perché il primo modello sembra sempre corretto

Perché lo scrivi contro il dominio che ti hanno raccontato, non contro quello che esiste. Ti dicono "un contratto ha una data di fine". Lo modelli. Funziona con le venti righe che guardi.

Il primo avviso è arrivato con il nome. Il modello si chiamava kpi_02_20_revenue_contracts e l'ho rinominato in kpi_02_20_rent_contracts. Non era revenue, era rent: contratti di affitto dove l'azienda paga, non dove incassa. Segno opposto. Il nome veniva dal ticket, non dalla tabella. Un mese a lavorare con un modello che si chiamava il contrario di quello che conteneva.

I nomi messi male non sono estetica. Qualcuno li legge e dà per scontato.

Quello che compare solo quando entrano i dati veri

La data di fine è stata il pozzo. La tabella aveva due colonne: DT_FIRST_CONTRACT_END e DT_CONTRACT_END_NEXT. La prima versione della data unificata era questa:

case
    when c.DT_FIRST_CONTRACT_END is null then c.DT_CONTRACT_END_NEXT
    when c.DT_CONTRACT_END_NEXT is null then c.DT_FIRST_CONTRACT_END
    when c.DT_FIRST_CONTRACT_END < CURRENT_DATE() then c.DT_CONTRACT_END_NEXT
    else c.DT_FIRST_CONTRACT_END
end as dt_end_merged

Sembra esaustivo. Non lo è. Manca il caso in cui sono entrambe nulle, e manca quello in cui la prima è nel passato e la seconda non esiste. Nei dati, entrambi i casi significavano la stessa cosa: contratto indeterminato. Il case li mandava a un null accidentale che poi veniva contato come "scaduto". La versione finale aggiunge quei due rami espliciti e un when dt_end_merged is null then True nel flag is_unlimited, perché lì il nullo è informazione, non assenza di informazione.

E quello stesso flag si portava già dietro un'altra perla del mondo reale:

when year_end_merged in (8909, 8899, 9999) then True

Tre anni sentinella diversi per dire "senza fine". Nessuno documentato. Tutti e tre in produzione.

Le tre volte in cui abbiamo annullato una decisione

La più illuminante è l'anno 99. Negli Excel di origine c'erano date di fine il cui anno arrivava come 99, e qualcuno nel team diceva che significava "indeterminato". Ho aggiunto la gestione:

case
    when {{ normalize_date('ew.WARUNEK_WA_NY_DO') }} like '%99%' then '2099-12-31'::date
    else {{ normalize_date('ew.WARUNEK_WA_NY_DO') }}::date
end

E il filtro corrispondente, un or ... like '%99%' perché quelle righe non cadessero fuori dalla finestra di date. L'ho tolto tre giorni dopo. Il like '%99%' combacia con 1999, con 2099 e con qualsiasi data che abbia un 99 nel giorno o nel mese. Stavo infilando contratti chiusi vent'anni prima nel calcolo del revenue attivo. La regola di business forse era vera; l'implementazione con like su una stringa di data no.

Due mesi dopo lo stesso problema è tornato da un'altra porta, nel modello delle rinegoziazioni: anni a due cifre. Stavolta l'ho fatto sull'anno già estratto, non sul testo:

case
  when year(normalized_next_possible_termination_old_contract) < 100
    then year(...) + 2000
  else year(...)
end

Nemmeno quello è sopravvissuto intatto: due settimane dopo è rimasto commentato nel file con un -- ! davanti. Terzo giro sulla stessa colonna.

L'altro rollback grosso è stato l'inner join tra contratti e condizioni. Un contratto senza condizione non compariva. Cambiarlo in left join ha cambiato i numeri della dashboard, ed è esattamente la conversazione da avere con chi chiede il KPI: se un contratto senza condizione conta o non conta. La prima volta l'ho deciso da solo, con un inner, senza accorgermi che lo stavo decidendo.

Come capisco quando un modello è stabile

Dal tipo di commit. Durante il primo anno i commit erano strutturali: join che cambiano, filtri che entrano ed escono, colonne che si rinominano. Alla fine del progetto i commit erano di un'altra specie: togliere un moltiplicatore per 100, fondere due case in uno, scrivere la documentazione delle colonne. Quello è un modello che non si muove più sotto, solo sopra.

L'altro segnale è che il .yml di documentazione passa dall'avere quattro bullet vaghi all'elencare i criteri di selezione esatti: entità, tipi di contratto, valuta, cosa si esclude e perché. Quando riesci a scrivere quella lista senza guardare l'SQL, il modello esiste davvero. Prima quello che hai è una query.

Cosa faccio oggi prima del primo CREATE TABLE

Prima scrivevo il modello e poi guardavo i dati. Adesso è il contrario, e in quest'ordine:

  • Contare i nulli di ogni colonna che entrerà in una condizione. Una colonna con il 40% di nulli non è una data, sono due concetti mescolati.
  • Cercare valori sentinella. select distinct year(...) order by 1 desc. Se escono 9999, 8899 o 2099, lì dentro ci sono regole di business non documentate.
  • Scrivere il filtro in prosa prima che in SQL. Se non so dire in una frase quali contratti entrano, il where finirà con sei condizioni contraddittorie e due commentate.
  • Normalizzare sui tipi, mai sulle stringhe. Ogni like su una data è una bomba a orologeria.

Un modello di dominio non si progetta: si negozia con i dati, e i dati non cedono. Ogni volta che ho annullato qualcosa è stato perché avevo deciso io quello che avrei dovuto chiedere.