Hai ereditato una classe UserService di seicento righe: valida, salva, manda email, scrive l'audit. Ogni modifica richiede di leggerla tutta, ogni test richiede di preparare cinque collaboratori, e nessuno è certo di cosa si rompe se tocca la riga sbagliata. SOLID è l'insieme di cinque regole di progettazione che servono a evitare questa situazione, e la risposta breve è che si riducono a due domande da farsi leggendo il codice: «per quante ragioni distinte potrebbe cambiare questa classe?» e «da chi dipende, da un'astrazione o da un'implementazione?». In questo articolo ogni principio ha un caso con il codice sbagliato, il codice corretto e un'uscita eseguita: i listati Java girano su OpenJDK 21.0.13 senza Spring, quello TypeScript è compilato con tsc 5.9.3 in modalità strict ed eseguito con Node 24. Prerequisito: sapere cos'è un'interfaccia; la parte di Clean Architecture è nei capitoli collegati.
I cinque principi, con le parole di chi li ha formulati
SOLID è un acronimo coniato da Robert C. Martin per cinque principi di progettazione di classi. Sulla sua pagina The Principles of OOD li riassume in una riga ciascuno, ed è bene tenerle sotto gli occhi prima di vedere gli esempi, perché le versioni circolanti sono spesso diluite.
| Principio | Formulazione di Martin (traduzione) |
|---|---|
| SRP, responsabilità singola | Una classe dovrebbe avere una sola ragione per cambiare. |
| OCP, aperto/chiuso | Dovresti poter estendere il comportamento di una classe senza modificarla. |
| LSP, sostituzione di Liskov | Le classi derivate devono essere sostituibili alle loro classi base. |
| ISP, segregazione delle interfacce | Interfacce a grana fine, specifiche del client. |
| DIP, inversione delle dipendenze | Dipendere da astrazioni, non da implementazioni concrete. |
Le cinque righe non sono comandamenti da applicare ovunque, sono criteri di giudizio, e il giudizio dipende da che cosa cambia davvero nel tuo sistema. Un metodo di dieci righe che non cambierà mai non beneficia di un'interfaccia; una classe che cambia ogni sprint per ragioni diverse sì. Lo stesso Martin, nel post The Single Responsibility Principle (2014), riconosce che la formula «una ragione per cambiare» solleva subito la domanda su che cosa sia una ragione, e la lega al concetto di responsabilità.
SRP: una classe, una ragione per cambiare
Pensa a una registrazione utente. Le ragioni di cambiamento sono distinte: le regole sull'email le decide il prodotto, la persistenza dipende dalla base dati, il testo della mail di benvenuto lo cambia chi cura la comunicazione, il formato dell'audit lo chiede chi fa compliance. Se stanno in un unico metodo, ogni richiesta di uno di questi gruppi rischia di rompere gli altri. Nel listato la versione monolitica (UserServiceGod) e quella separata (RegisterUser) convivono. Nella seconda, il caso d'uso registra l'utente e annuncia un evento; l'invio dell'email e l'audit sono due reazioni indipendenti, collegate al momento del cablaggio.
import java.util.*;
import java.util.function.Consumer;
interface UserRepository { boolean existsByEmail(String email); void save(String email); }
interface EmailSender { void send(String to, String subject); }
class InMemoryUsers implements UserRepository {
private final Set<String> emails = new LinkedHashSet<>();
public boolean existsByEmail(String email) { return emails.contains(email); }
public void save(String email) { emails.add(email); }
}
// SBAGLIATO: validazione, persistenza, email, audit e notifica nello stesso metodo.
class UserServiceGod {
private final UserRepository users; private final EmailSender mail; private final List<String> audit;
UserServiceGod(UserRepository users, EmailSender mail, List<String> audit) {
this.users = users; this.mail = mail; this.audit = audit;
}
void register(String email) {
if (!email.contains("@")) throw new IllegalArgumentException("Email non valida");
if (users.existsByEmail(email)) throw new IllegalStateException("Utente esistente");
users.save(email);
mail.send(email, "Benvenuto");
audit.add("User registered: " + email);
mail.send("admin@example.com", "Nuovo utente " + email);
}
}
// CORRETTO: il caso d'uso registra e annuncia; chi reagisce e' un altro oggetto.
record UserRegistered(String email) {}
class RegisterUser {
private final UserRepository users; private final Consumer<UserRegistered> publish;
RegisterUser(UserRepository users, Consumer<UserRegistered> publish) { this.users = users; this.publish = publish; }
void execute(String email) {
if (!email.contains("@")) throw new IllegalArgumentException("Email non valida");
if (users.existsByEmail(email)) throw new IllegalStateException("Utente esistente");
users.save(email);
publish.accept(new UserRegistered(email));
}
}
public class Srp {
public static void main(String[] args) {
// Test della registrazione "pura": nessuna email, nessun audit da preparare.
List<UserRegistered> events = new ArrayList<>();
new RegisterUser(new InMemoryUsers(), events::add).execute("a@example.com");
System.out.println("eventi pubblicati: " + events);
// Cablaggio reale: due reazioni indipendenti allo stesso evento.
List<String> sent = new ArrayList<>(), audit = new ArrayList<>();
List<Consumer<UserRegistered>> handlers = List.of(
e -> sent.add(e.email() + ": Benvenuto"), // handler email
e -> audit.add("User registered: " + e.email())); // handler audit
RegisterUser useCase = new RegisterUser(new InMemoryUsers(), e -> handlers.forEach(h -> h.accept(e)));
useCase.execute("b@example.com");
System.out.println("email: " + sent + " | audit: " + audit);
// La versione monolitica non si prova senza costruire anche mail e audit.
List<String> mails = new ArrayList<>();
new UserServiceGod(new InMemoryUsers(), (to, s) -> mails.add(to + ": " + s), new ArrayList<>()).register("c@example.com");
System.out.println("monolite, email inviate: " + mails);
}
}
// eventi pubblicati: [UserRegistered[email=a@example.com]]
// email: [b@example.com: Benvenuto] | audit: [User registered: b@example.com]
// monolite, email inviate: [c@example.com: Benvenuto, admin@example.com: Nuovo utente c@example.com]
L'uscita contiene tre fatti. Il primo test crea il caso d'uso passando solo un repository e un modo di raccogliere gli eventi: non ha dovuto preparare né un mittente di email né un registro d'audit, e controlla l'unica cosa che il caso d'uso deve garantire, cioè che l'evento sia stato pubblicato. Il secondo mostra due reazioni indipendenti allo stesso evento: se domani l'audit passa a un altro sistema, cambia un handler e il caso d'uso non viene ricompilato. Il terzo mostra il costo del monolite: per provare una registrazione si è dovuto costruire anche il mittente di email, e la mail all'amministratore parte comunque.
Il prezzo è reale: l'evento introduce un'indirezione, e chi legge il caso d'uso non vede più direttamente che cosa succede dopo la registrazione. Con due soli effetti collaterali, un'unica classe di cinquanta righe è spesso più chiara; l'evento paga quando le reazioni crescono o appartengono a persone diverse. Nel codice con Spring i due handler sarebbero metodi annotati con @EventListener, e l'attenzione va ai confini transazionali: per impostazione predefinita gli ascoltatori ricevono l'evento in modo sincrono e, se c'è una transazione, operano nel suo contesto (documentazione di Spring Framework). Vedi Use Case Pattern in Spring Boot per la forma del caso d'uso.
OCP: aggiungere senza modificare
Hai un PaymentProcessor con una catena di if sul metodo di pagamento. Ogni nuovo metodo ti obbliga a riaprire quel file, e la regressione colpisce i metodi che funzionavano. La soluzione è il pattern Strategy: un'interfaccia PaymentGateway con un metodo supports() che dichiara quale metodo gestisce, e una classe per ogni gateway. Il caso d'uso riceve la lista dei gateway e ne costruisce una mappa indicizzata per metodo. È il punto che molti esempi saltano: una mappa dichiarata ma mai popolata compila e non funziona; qui la costruzione è visibile nel costruttore.
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
enum PaymentMethod { CREDIT_CARD, PAYPAL, BITCOIN }
record Payment(PaymentMethod method, long cents) {}
interface PaymentGateway {
PaymentMethod supports();
String process(Payment payment);
}
class StripeGateway implements PaymentGateway {
public PaymentMethod supports() { return PaymentMethod.CREDIT_CARD; }
public String process(Payment p) { return "stripe: " + p.cents(); }
}
class PayPalGateway implements PaymentGateway {
public PaymentMethod supports() { return PaymentMethod.PAYPAL; }
public String process(Payment p) { return "paypal: " + p.cents(); }
}
class BitcoinGateway implements PaymentGateway { // il "nuovo requisito": una classe nuova
public PaymentMethod supports() { return PaymentMethod.BITCOIN; }
public String process(Payment p) { return "bitcoin: " + p.cents(); }
}
// Questa classe NON cambia quando aggiungi un gateway.
class ProcessPayment {
private final Map<PaymentMethod, PaymentGateway> gateways;
ProcessPayment(List<PaymentGateway> all) {
this.gateways = all.stream().collect(Collectors.toMap(PaymentGateway::supports, Function.identity()));
}
String execute(Payment payment) {
PaymentGateway g = gateways.get(payment.method());
if (g == null) throw new IllegalArgumentException("Metodo non supportato: " + payment.method());
return g.process(payment);
}
}
public class Ocp {
public static void main(String[] args) {
ProcessPayment before = new ProcessPayment(List.of(new StripeGateway(), new PayPalGateway()));
System.out.println(before.execute(new Payment(PaymentMethod.PAYPAL, 1990)));
try { before.execute(new Payment(PaymentMethod.BITCOIN, 1990)); }
catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }
ProcessPayment after = new ProcessPayment(List.of(new StripeGateway(), new PayPalGateway(), new BitcoinGateway()));
System.out.println(after.execute(new Payment(PaymentMethod.BITCOIN, 1990)));
try { new ProcessPayment(List.of(new StripeGateway(), new StripeGateway())); }
catch (IllegalStateException e) { System.out.println("duplicato: " + e.getMessage().split(" \\(")[0]); }
}
}
// paypal: 1990
// Metodo non supportato: BITCOIN
// bitcoin: 1990
// duplicato: Duplicate key CREDIT_CARD
Il programma costruisce prima il caso d'uso con due gateway e ne mostra il limite: un pagamento Bitcoin viene rifiutato con un messaggio chiaro. Poi aggiunge una terza classe, BitcoinGateway, e la passa alla stessa classe ProcessPayment, che non è stata modificata: il pagamento funziona. L'ultima riga prova il difetto che questo pattern porta con sé: due gateway che dichiarano lo stesso metodo non convivono, e Collectors.toMap lo segnala subito con un'eccezione invece di lasciare che uno dei due vinca in silenzio. In Spring la lista List<PaymentGateway> viene iniettata con tutti i bean che implementano l'interfaccia, e un gateway nuovo con @Component entra da solo.
Il costo da conoscere: «chiuso alla modifica» vale per il punto di estensione che hai previsto, non per qualsiasi cambiamento. Se domani il pagamento deve produrre anche una ricevuta, l'interfaccia cambia e tutte le implementazioni con essa. Per questo conviene introdurre l'astrazione dopo il secondo o terzo caso reale, e non al primo.
LSP: il sottotipo mantiene il contratto
Il principio dice che un oggetto del sottotipo deve potersi usare dove si usa quello della base senza sorprese. La formulazione formale è di Barbara Liskov e Jeannette Wing (A Behavioral Notion of Subtyping, ACM TOPLAS, 1994): non basta che le firme coincidano, conta il comportamento promesso. Qui il contratto di DiscountPolicy.percentage() è «restituisce un numero tra 0 e 100 e non lancia». La classe LooseTieredPolicy lo rompe: riceve il livello come stringa non controllata e, per i valori che non conosce, lancia dentro il metodo. Il cliente applyDiscount non può difendersi, perché non sa che la sua DiscountPolicy è di quel tipo.
// Contratto di DiscountPolicy: percentage() restituisce un numero tra 0 e 100 e non lancia.
class DiscountPolicy {
percentage(): number { return 0; }
}
// SBAGLIATO: il tier arriva da fuori come stringa e percentage() lancia per i valori sconosciuti.
class LooseTieredPolicy extends DiscountPolicy {
constructor(private readonly tier: string) { super(); }
override percentage(): number {
if (this.tier === 'gold') return 20;
if (this.tier === 'silver') return 10;
throw new Error(`Tier non configurato: ${this.tier}`); // il contratto della base non lo prevede
}
}
// CORRETTO: il tipo e il costruttore rendono impossibile uno stato non valido.
type Tier = 'gold' | 'silver';
class TieredPolicy extends DiscountPolicy {
private constructor(readonly tier: Tier, private readonly pct: number) { super(); }
static fromUntrusted(tier: string): TieredPolicy { // l'errore avviene qui, alla creazione
if (tier === 'gold') return new TieredPolicy('gold', 20);
if (tier === 'silver') return new TieredPolicy('silver', 10);
throw new Error(`Tier non valido: ${tier}`);
}
override percentage(): number { return this.pct; } // non lancia mai
}
function applyDiscount(policy: DiscountPolicy, price: number): number {
return price * (1 - policy.percentage() / 100);
}
const fromJson = (JSON.parse('{"tier":"platinum"}') as { tier: string }).tier;
try {
console.log(applyDiscount(new LooseTieredPolicy('gold'), 100));
console.log(applyDiscount(new LooseTieredPolicy(fromJson), 100));
} catch (e) { console.log('cliente: ' + (e as Error).message); }
console.log(applyDiscount(TieredPolicy.fromUntrusted('gold'), 100));
try { TieredPolicy.fromUntrusted(fromJson); } catch (e) { console.log('creazione: ' + (e as Error).message); }
// 80
// cliente: Tier non configurato: platinum
// 80
// creazione: Tier non valido: platinum
L'uscita mostra che lo stesso input difettoso (un livello platinum arrivato da JSON, dove TypeScript non può controllare il tipo a runtime) fa esplodere il cliente nel primo caso, e viene rifiutato alla creazione nel secondo. La versione corretta usa un costruttore privato e un metodo statico che valida: un TieredPolicy non esiste in stato non valido, quindi percentage() non ha motivo di lanciare. Questo è il modo pratico di rispettare LSP: spostare gli errori dal momento dell'uso al momento della costruzione. Un dettaglio sul codice di partenza: in modalità strict, tsc rifiuta già una proprietà tier non inizializzata (errore TS2564), quindi il difetto classico compare quando i dati arrivano da fuori, non da un campo dimenticato.
La libreria standard di Java ha un caso reale di questa tensione. Le specifiche di Collection dichiarano certe operazioni «opzionali», e l'implementazione può lanciare UnsupportedOperationException: List.of(1, 2, 3).add(4) compila e lancia a runtime, come ho verificato eseguendolo. Il contratto lo dichiara apertamente (Javadoc di Collection), ma un cliente che riceve una List non sa se può modificarla. Quando scrivi tu l'interfaccia, evita le operazioni opzionali: sono LSP sacrificato a favore della comodità, e si paga a runtime.
ISP: interfacce piccole, per chi le usa
Un'interfaccia Worker con work(), eat() e manage() sembra comoda, finché non devi implementarla per un robot. L'unica scorciatoia è lasciare i metodi inutili a lanciare eccezioni, che è proprio il difetto di LSP appena visto. Il listato mostra il problema nel modo in cui lo incontri: il compilatore accetta lunchBreak(robot), e l'errore arriva solo quando il codice gira.
interface Worker {
void work();
void eat();
void manage();
}
class RobotWorker implements Worker {
public void work() { System.out.println("robot: eseguo il task"); }
public void eat() { throw new UnsupportedOperationException("un robot non mangia"); }
public void manage() { throw new UnsupportedOperationException("un robot non gestisce"); }
}
public class IspBad {
static void lunchBreak(Worker w) { w.eat(); } // il compilatore accetta qualunque Worker
public static void main(String[] args) {
Worker robot = new RobotWorker();
robot.work();
try { lunchBreak(robot); }
catch (UnsupportedOperationException e) { System.out.println("a runtime: " + e.getMessage()); }
}
}
// robot: eseguo il task
// a runtime: un robot non mangia
La versione corretta spezza l'interfaccia secondo ciò che i client chiedono: chi assegna lavoro vuole un Workable, chi gestisce le pause vuole un Eatable. Il robot implementa solo la prima, l'umano tutte e tre. Il vantaggio non è estetico: l'errore si sposta dal runtime alla compilazione.
interface Workable { void work(); }
interface Eatable { void eat(); }
interface Manageable { void manage(); }
class RobotWorker implements Workable {
public void work() { System.out.println("robot: eseguo il task"); }
}
class HumanWorker implements Workable, Eatable, Manageable {
public void work() { System.out.println("umano: lavoro"); }
public void eat() { System.out.println("umano: pausa pranzo"); }
public void manage() { System.out.println("umano: coordino"); }
}
public class IspGood {
static void assign(Workable w) { w.work(); }
static void lunchBreak(Eatable e) { e.eat(); }
public static void main(String[] args) {
assign(new RobotWorker());
assign(new HumanWorker());
lunchBreak(new HumanWorker());
// lunchBreak(new RobotWorker()); // non compila: RobotWorker non e' Eatable
}
}
// robot: eseguo il task
// umano: lavoro
// umano: pausa pranzo
Se provi a passare il robot al metodo che si aspetta qualcosa da mangiare, il codice non compila più. Questo è il messaggio reale di javac per quella riga:
// IspBroken.java
public class IspBroken {
public static void main(String[] args) {
IspGood.lunchBreak(new RobotWorker());
}
}
// IspBroken.java:3: error: incompatible types: RobotWorker cannot be converted to Eatable
// IspGood.lunchBreak(new RobotWorker());
// ^
// 1 error
Un avvertimento sul rischio opposto: spezzare in cento interfacce da un metodo non è ISP, è frammentazione. Il criterio è il client: raggruppa i metodi che gli stessi client usano insieme. In una Clean Architecture questo principio è alla base della scelta di avere port piccoli e specifici per caso d'uso, invece di un unico repository con trenta metodi.
DIP: il dettaglio dipende dalla regola
Il caso d'uso FindUserTight costruisce da sé la classe concreta che accede al database. Il risultato è doppio: non si può provare senza un database, e cambiare database significa modificare il caso d'uso. L'inversione sta nel punto in cui è dichiarata l'interfaccia: UserRepository appartiene al livello alto (la regola applicativa), mentre l'implementazione concreta sta in basso e dipende da essa, non viceversa. È questa l'inversione del nome: la freccia di dipendenza dell'implementazione punta verso l'astrazione, contro il flusso delle chiamate.
import java.util.*;
record User(String email) {}
// Port: dichiarato dal livello alto (application), non dal database.
interface UserRepository {
Optional<User> findByEmail(String email);
}
// SBAGLIATO: il caso d'uso costruisce da solo la classe concreta.
class PostgresUserRepository implements UserRepository {
PostgresUserRepository() { throw new IllegalStateException("connessione al database non disponibile"); }
public Optional<User> findByEmail(String email) { return Optional.empty(); }
}
class FindUserTight {
private final UserRepository repository = new PostgresUserRepository(); // dipendenza concreta
Optional<User> execute(String email) { return repository.findByEmail(email); }
}
// CORRETTO: riceve l'astrazione dal costruttore.
class FindUser {
private final UserRepository repository;
FindUser(UserRepository repository) { this.repository = repository; }
String execute(String email) {
return repository.findByEmail(email).map(u -> "trovato " + u.email()).orElse("non trovato");
}
}
public class Dip {
public static void main(String[] args) {
try { new FindUserTight(); }
catch (IllegalStateException e) { System.out.println("senza database non si costruisce: " + e.getMessage()); }
UserRepository fake = email -> email.equals("a@example.com") ? Optional.of(new User(email)) : Optional.empty();
FindUser useCase = new FindUser(fake);
System.out.println(useCase.execute("a@example.com"));
System.out.println(useCase.execute("x@example.com"));
}
}
// senza database non si costruisce: connessione al database non disponibile
// trovato a@example.com
// non trovato
Il programma mostra le due strade. La versione stretta fallisce già nel costruttore, perché la classe concreta (simulata qui con un'eccezione) richiede un database che il test non ha. La versione corretta riceve l'astrazione dal costruttore, e il test la riempie con una funzione di una riga, senza librerie di mock: l'interfaccia ha un solo metodo, quindi una lambda basta. Con Spring l'iniezione dal costruttore è già il modo normale di lavorare; il principio dice dove deve stare l'interfaccia, non solo che c'è. Per il lato adapter e il mapping, vedi JPA e Flyway in Clean Architecture.
Nel backend di questo sito la regola è applicata a livello di package e si può verificare nel codice: il port ArticleRepository sta in domain.port e la sua implementazione ArticleRepositoryAdapter in infrastructure.persistence.adapter; il primo non importa nulla di JPA (lo dichiara il suo Javadoc, e le dichiarazioni di import del file lo confermano). Una Clean Architecture è, in buona misura, questo principio applicato a ogni confine fra layer.
Come si usano insieme, e come si verificano
I cinque principi si rinforzano a vicenda. Una classe con una sola responsabilità (SRP) ha di solito un'interfaccia piccola (ISP); un'interfaccia piccola è più facile da rispettare per i sottotipi (LSP); un sistema fatto di piccole parti dietro interfacce è estendibile aggiungendo parti (OCP) e verificabile sostituendo le parti con doppioni (DIP). Per usarli in una revisione del codice, cinque domande bastano.
- SRP: sai descrivere la classe in una frase senza «e»? Se la frase ha tre «e», le ragioni di cambiamento sono tre.
- OCP: per aggiungere il prossimo caso, devi riaprire codice che funziona o puoi scrivere una classe nuova?
- LSP: esiste un sottotipo che lancia dove la base non lo fa, o che ignora un parametro?
- ISP: ci sono metodi che alcune implementazioni lasciano vuoti o lanciano
UnsupportedOperationException? - DIP: nel costruttore c'è un
newdi una classe concreta che parla con il mondo esterno?
Le cinque domande non sono un punteggio: una risposta «sì» a una sola, in un punto che cambia spesso, vale più di cinque «sì» in codice che non cambierà mai. Cominciane l'applicazione dai punti dove senti già dolore, cioè i file che ogni modifica tocca e i test che richiedono più preparazione del codice che provano. Un refactoring totale in un colpo solo raramente funziona e quasi mai si giustifica.
Domande frequenti
SOLID è eccessivo per uno script piccolo?
Per uno script che farai girare una volta, sì. Il tempo speso per rispettare i principi si ripaga quando il codice cambia: se non cambierà, non c'è nulla da ripagare. Un criterio realistico: due principi, SRP e DIP, tengono il costo basso e il testing facile anche in un servizio piccolo.
Come introduco SOLID in un codice legacy?
A piccoli passi e partendo dal dolore. Scegli un punto che cambia spesso, isolalo dietro un'interfaccia (DIP), scrivi i test che prima non potevi scrivere, e solo poi separa le responsabilità. Mostrare ai colleghi un test che prima richiedeva un database e ora gira in un'istante convince più di un documento di principi.
SOLID vale anche nella programmazione funzionale?
Le idee sottostanti sì, la forma cambia. Una funzione pura che riceve le sue dipendenze come parametri realizza DIP senza interfacce; una funzione piccola e focalizzata è SRP. Cambia il vocabolario, non l'obiettivo: confini piccoli, dipendenze esplicite.
Che cosa sai fare adesso
Sai riconoscere in un codice reale le cinque violazioni tipiche, sai dire per ciascuna quale costo concreto paghi (test impossibili, regressioni, errori a runtime, accoppiamento al database) e sai dove il rimedio ha un prezzo: l'evento di SRP, la lista di OCP, il costruttore privato di LSP. Il passo successivo è vedere i principi dentro un'architettura intera, nel domain layer e nel quadro d'insieme di Clean Architecture in Spring Boot 3.4.
Fonti
- Robert C. Martin - The Principles of OOD - le formulazioni dei cinque principi; consultata il 2026-11-12
- Robert C. Martin - The Single Responsibility Principle (2014) - il significato di «ragione per cambiare»; consultata il 2026-11-12
- Spring Framework Reference - Standard and Custom Events - ascoltatori sincroni e contesto transazionale del publisher; consultata il 2026-11-12
- Java SE 21 API - java.util.Collection - operazioni opzionali e
UnsupportedOperationException; consultata il 2026-11-12 - Barbara Liskov, Jeannette Wing, A Behavioral Notion of Subtyping, ACM Transactions on Programming Languages and Systems 16(6), 1994 (DOI 10.1145/197320.197383) - riferimento bibliografico per la nozione di sottotipo comportamentale; riferimento bibliografico: il testo dell'articolo non è stato riletto per questo lavoro
- Prove eseguite: OpenJDK 21.0.13 (Java),
tsc5.9.3 e Node 24.11.0 (TypeScript), cartella temporanea fuori dal repository

Commenti
Caricamento commenti...
Accedi per commentare