Salta al contenuto principale

Use Case Pattern in Spring Boot Clean Architecture

Come spostare le regole dal controller a uno use case con port, DTO a record e transazioni, provandolo con fake in memoria senza avviare Spring.

Cosa ne pensi di questo articolo?

Use Case Pattern in Spring Boot Clean Architecture
Clean ArchitectureFoto:RDNE Stock projectPexels

In Breve

Come spostare le regole dal controller a uno use case con port, DTO a record e transazioni, provandolo con fake in memoria senza avviare Spring.

Il controller riceve una richiesta HTTP, e dopo sei mesi contiene la regola «un autore non può superare cento articoli», il calcolo della data di pubblicazione e l'invio di una notifica. Per provare una di queste regole devi avviare l'intera applicazione e un database. Il pattern Use Case sposta quelle regole in una classe che ha un solo verbo (crea un articolo, pubblica un articolo) e che dipende da interfacce invece che da JPA o da HTTP. Il risultato è un pezzo di codice che si esegue, e si prova, con un main o con un test da pochi millisecondi.

Che cosa c'è di sbagliato nel controller che fa tutto?

Prendi un caso concreto: una piattaforma di blog in cui ogni autore può pubblicare al massimo cento articoli. Nella versione ingenua, la regola vive dove capita, di solito nell'endpoint che crea l'articolo. Il blocco seguente è uno schema da adattare (richiede Spring MVC e JPA, quindi non è eseguibile in isolamento) ma è la forma che si incontra più spesso nei progetti reali.

@RestController
@RequestMapping("/api/articles")
public class ArticleController {

    private final ArticleJpaRepository articleRepository;   // dettaglio JPA
    private final UserJpaRepository userRepository;         // dettaglio JPA

    // costruttore omesso

    @PostMapping
    public ResponseEntity<?> create(@RequestBody CreateArticleRequest req) {
        User author = userRepository.findById(req.authorId()).orElseThrow();
        if (author.getArticlesPublished() >= 100) {
            return ResponseEntity.status(409).body("limite raggiunto");
        }
        ArticleEntity entity = new ArticleEntity(req.title(), req.content(), author);
        articleRepository.save(entity);
        return ResponseEntity.ok(entity);   // espone l'entità JPA
    }
}

I difetti sono tre e si leggono direttamente nel blocco. Il controller conosce ArticleJpaRepository e UserJpaRepository, cioè la tecnologia di persistenza: cambiare database o anche solo il modo di caricare l'autore significa modificare un file di presentation. La regola del limite è un if con un numero scritto a mano, che nessun altro punto del sistema può riusare, per esempio un import da riga di comando che crea articoli senza passare da HTTP. Infine l'ultima riga restituisce l'entità JPA al client, e con lei la struttura delle tabelle, comprese le relazioni lazy che prima o poi producono un errore di serializzazione. Per provare la regola del limite devi avviare il contesto Spring e un database, perché l'unico modo di raggiungere quell'if è passare dal controller.

Come si costruisce un use case: port, request, response

Un use case si compone di quattro cose: un oggetto di ingresso (request), un oggetto di uscita (response), le interfacce che dichiarano di che cosa ha bisogno (port) e la classe che coordina il tutto. Il domain, che resta senza framework, contiene le regole che appartengono ai dati stessi. Qui l'autore sa dire se può ancora pubblicare, e un articolo nasce come bozza senza identificativo, perché assegnarlo spetta a chi lo persiste.

public record AuthorId(long value) {
    public AuthorId {
        if (value <= 0) {
            throw new IllegalArgumentException("l'id autore deve essere positivo: " + value);
        }
    }
}

public record Author(AuthorId id, String name, int publishedCount) {

    public static final int PUBLISHING_LIMIT = 100;

    public boolean canPublish() {
        return publishedCount < PUBLISHING_LIMIT;
    }

    public Author withOneMorePublished() {
        return new Author(id, name, publishedCount + 1);
    }
}

public record Article(long id, String title, String content, AuthorId authorId) {

    /** Bozza non ancora salvata: l'id lo assegna chi persiste. */
    public static Article draft(String title, String content, AuthorId authorId) {
        return new Article(0, title, content, authorId);
    }

    public Article withId(long newId) {
        return new Article(newId, title, content, authorId);
    }
}

Sono tutti record Java: immutabili per costruzione, quindi withOneMorePublished() restituisce un nuovo Author invece di modificare quello ricevuto. Il limite di cento è una costante del domain, PUBLISHING_LIMIT, e il metodo canPublish() è l'unico posto in cui la regola esiste. Se domani il limite dipende dall'abbonamento dell'autore, cambia questo metodo e nient'altro. AuthorId è un value object minimo: rifiuta gli id non positivi già alla costruzione.

Il use case segnala i fallimenti di business con eccezioni di dominio. Sono tre classi piccole, che servono per compilare gli altri listati: una base comune e le due eccezioni lanciate dal use case.

public abstract class DomainException extends RuntimeException {
    protected DomainException(String message) {
        super(message);
    }
}

public class AuthorNotFoundException extends DomainException {
    public AuthorNotFoundException(AuthorId id) {
        super("autore " + id.value() + " inesistente");
    }
}

public class PublishingLimitExceededException extends DomainException {
    public PublishingLimitExceededException(Author author) {
        super("l'autore " + author.name() + " ha già " + author.publishedCount()
            + " articoli (limite " + Author.PUBLISHING_LIMIT + ")");
    }
}

Estendono RuntimeException tramite la base DomainException, così un handler può intercettarle tutte insieme (ci torniamo nella sezione sulle transazioni) e i messaggi dicono chi e che cosa non ha funzionato. Con questi tipi tutti i listati dell'articolo compilano insieme: i sorgenti completi sono quelli mostrati, nessun file è omesso.

I port dicono che cosa serve senza dire come si ottiene. Sono interfacce comuni, senza annotazioni di Spring, e vivono accanto al use case che le usa.

import java.util.Optional;

public interface AuthorRepository {
    Optional<Author> findById(AuthorId id);
    Author save(Author author);
}

public interface ArticleRepository {
    Article save(Article article);
}

public interface FollowerNotifier {
    void articlePublished(Author author, Article article);
}

Nota la scelta di Optional<Author> findById: il port non restituisce null e non lancia l'eccezione di JPA, perché il «non trovato» è una condizione che il use case deve gestire esplicitamente. L'interfaccia FollowerNotifier è il modo di parlare con il mondo esterno (posta, code, notifiche push) senza far entrare nel use case nessuna di queste tecnologie.

La request è il confine tra HTTP e applicazione. Qui un compact constructor (la forma di costruttore dei record, descritta nella JEP 395) rifiuta gli input malformati prima che il use case parta, e in questo modo la validazione sintattica non si disperde nel controller.

public record CreateArticleRequest(String title, String content, long authorId) {

    public CreateArticleRequest {
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("il titolo è obbligatorio");
        }
        if (content == null || content.length() < 20) {
            throw new IllegalArgumentException("il contenuto deve avere almeno 20 caratteri");
        }
    }
}

public record CreateArticleResponse(long articleId, String title, int authorArticleCount) { }

La validazione qui è volutamente solo sintattica: titolo non vuoto, contenuto non troppo corto. Che l'autore esista o che abbia raggiunto il limite non si può sapere dalla request da sola, e infatti sono controlli del use case. Distinguere i due livelli ti evita il vizio opposto, cioè riempire il record di chiamate a repository.

Il use case, riga per riga

Ecco la classe che orchestra. Ha quattro dipendenze, tutte interfacce, passate dal costruttore: nessun @Autowired sui campi, nessun new di una classe concreta.

import java.util.function.Consumer;

public class CreateArticleUseCase {

    private final AuthorRepository authors;
    private final ArticleRepository articles;
    private final FollowerNotifier notifier;
    private final Consumer<String> log;

    public CreateArticleUseCase(AuthorRepository authors, ArticleRepository articles,
                                FollowerNotifier notifier, Consumer<String> log) {
        this.authors = authors;
        this.articles = articles;
        this.notifier = notifier;
        this.log = log;
    }

    public CreateArticleResponse execute(CreateArticleRequest request) {
        AuthorId authorId = new AuthorId(request.authorId());
        Author author = authors.findById(authorId)
            .orElseThrow(() -> new AuthorNotFoundException(authorId));

        if (!author.canPublish()) {
            throw new PublishingLimitExceededException(author);
        }

        Article saved = articles.save(
            Article.draft(request.title(), request.content(), author.id()));
        Author updated = authors.save(author.withOneMorePublished());

        try {
            notifier.articlePublished(updated, saved);
        } catch (RuntimeException e) {
            log.accept("notifica fallita (ignorata): " + e.getMessage());
        }

        return new CreateArticleResponse(saved.id(), saved.title(), updated.publishedCount());
    }
}

La sequenza ha un ordine preciso che conta. Prima si recupera l'autore (orElseThrow trasforma l'assenza in AuthorNotFoundException), poi si applica la regola di business tramite canPublish(), e solo dopo si scrive: se il limite è superato non viene salvato nulla. Il salvataggio dell'articolo e l'aggiornamento del contatore dell'autore sono due scritture che devono riuscire o fallire insieme, e quel «insieme» è affidato alla transazione, di cui parliamo più avanti. La notifica sta dentro un try proprio perché è un effetto collaterale: se il server di posta è fuori uso, non vogliamo perdere un articolo già salvato. Il costo è che l'errore viene registrato ma non rilanciato, quindi qualcuno deve guardare quel log.

Eseguirlo senza framework

Il vantaggio dei port si misura qui. Per provare il use case bastano due classi in memoria che implementano le interfacce, e nessun contenitore di dipendenze.

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class InMemoryAuthors implements AuthorRepository {
    private final Map<Long, Author> byId = new HashMap<>();

    public InMemoryAuthors with(Author author) {
        byId.put(author.id().value(), author);
        return this;
    }

    @Override public Optional<Author> findById(AuthorId id) {
        return Optional.ofNullable(byId.get(id.value()));
    }

    @Override public Author save(Author author) {
        byId.put(author.id().value(), author);
        return author;
    }
}

import java.util.ArrayList;
import java.util.List;

public class InMemoryArticles implements ArticleRepository {
    private final List<Article> saved = new ArrayList<>();

    @Override public Article save(Article article) {
        Article withId = article.withId(saved.size() + 1L);
        saved.add(withId);
        return withId;
    }

    public List<Article> all() {
        return List.copyOf(saved);
    }
}

Sono dei fake: implementazioni vere ma semplificate, che tengono i dati in una mappa e in una lista. InMemoryArticles.save assegna l'identificativo come farebbe il database con una sequenza, e per questo la bozza nasce con id = 0. A differenza di un mock, un fake ha comportamento, quindi puoi verificare lo stato finale (quanti articoli ci sono) invece di verificare quali metodi sono stati chiamati.

Il programma seguente costruisce un autore con tre articoli, uno già al limite, un notificatore che fallisce sempre, e prova quattro richieste. L'ho compilato con javac (JDK 21) in una cartella temporanea e l'uscita è quella che segue. Due esecuzioni consecutive producono lo stesso testo, perché il codice non usa orologio né numeri casuali.

import java.util.ArrayList;
import java.util.List;

public class Demo {

    public static void main(String[] args) {
        List<String> log = new ArrayList<>();
        var authors = new InMemoryAuthors()
            .with(new Author(new AuthorId(7), "Giulia", 3))
            .with(new Author(new AuthorId(8), "Marco", Author.PUBLISHING_LIMIT));
        var articles = new InMemoryArticles();
        FollowerNotifier brokenNotifier = (author, article) -> {
            throw new IllegalStateException("server di posta non raggiungibile");
        };
        var useCase = new CreateArticleUseCase(authors, articles, brokenNotifier, log::add);

        String body = "Un contenuto abbastanza lungo da superare il minimo.";
        run("autore 7, richiesta valida", () ->
            useCase.execute(new CreateArticleRequest("Use case senza framework", body, 7)));
        run("autore 99, inesistente", () ->
            useCase.execute(new CreateArticleRequest("Titolo", body, 99)));
        run("autore 8, al limite", () ->
            useCase.execute(new CreateArticleRequest("Titolo", body, 8)));
        run("titolo vuoto", () ->
            useCase.execute(new CreateArticleRequest("  ", body, 7)));

        System.out.println("log: " + log);
        System.out.println("articoli salvati: " + articles.all().size());
    }

    private static void run(String label, java.util.function.Supplier<Object> action) {
        try {
            System.out.println(label + " -> " + action.get());
        } catch (RuntimeException e) {
            System.out.println(label + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage());
        }
    }
}

// Output:
// autore 7, richiesta valida -> CreateArticleResponse[articleId=1, title=Use case senza framework, authorArticleCount=4]
// autore 99, inesistente -> AuthorNotFoundException: autore 99 inesistente
// autore 8, al limite -> PublishingLimitExceededException: l'autore Marco ha già 100 articoli (limite 100)
// titolo vuoto -> IllegalArgumentException: il titolo è obbligatorio
// log: [notifica fallita (ignorata): server di posta non raggiungibile]
// articoli salvati: 1

Le quattro righe mostrano quattro esiti diversi dello stesso use case. La prima va a buon fine, con il contatore dell'autore che passa da 3 a 4. La seconda e la terza falliscono con eccezioni di dominio diverse, e in entrambi i casi non resta traccia nel repository (l'ultima riga conferma che c'è un solo articolo salvato). La quarta non arriva nemmeno al use case, perché il compact constructor della request lancia prima. Nella riga log si vede infine la notifica fallita e ignorata della prima richiesta, mentre l'articolo risulta comunque salvato.

Gli stessi comportamenti diventano test. Questi tre girano con JUnit 5 (console launcher 1.11.4 scaricato in una cartella fuori dal repo) senza Spring: l'intera esecuzione, avvio del launcher compreso, è durata tra 113 e 535 ms in sette esecuzioni (AMD Ryzen 5 5600H, OpenJDK 21.0.13), e il valore dipende dal carico della macchina.

import static org.junit.jupiter.api.Assertions.*;

import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;

class CreateArticleUseCaseTest {

    private final List<String> log = new ArrayList<>();
    private final InMemoryAuthors authors = new InMemoryAuthors()
        .with(new Author(new AuthorId(1), "Giulia", 99))
        .with(new Author(new AuthorId(2), "Marco", 100));
    private final InMemoryArticles articles = new InMemoryArticles();
    private final CreateArticleUseCase useCase =
        new CreateArticleUseCase(authors, articles, (a, art) -> { }, log::add);

    private static final String BODY = "Un contenuto abbastanza lungo da passare.";

    @Test
    void l_ultimo_articolo_consentito_viene_salvato() {
        var response = useCase.execute(new CreateArticleRequest("Titolo", BODY, 1));

        assertEquals(100, response.authorArticleCount());
        assertEquals(1, articles.all().size());
    }

    @Test
    void oltre_il_limite_non_salva_nulla() {
        assertThrows(PublishingLimitExceededException.class,
            () -> useCase.execute(new CreateArticleRequest("Titolo", BODY, 2)));

        assertTrue(articles.all().isEmpty());
    }

    @Test
    void autore_inesistente() {
        assertThrows(AuthorNotFoundException.class,
            () -> useCase.execute(new CreateArticleRequest("Titolo", BODY, 5)));
    }
}

// Output:
// ├─ JUnit Jupiter ✔
// │  └─ CreateArticleUseCaseTest ✔
// │     ├─ l_ultimo_articolo_consentito_viene_salvato() ✔
// │     ├─ oltre_il_limite_non_salva_nulla() ✔
// │     └─ autore_inesistente() ✔
// 3 tests successful
// 0 tests failed

Il primo test fissa il caso limite, l'ultimo articolo consentito (l'autore ne ha 99), il secondo verifica che oltre il limite non venga salvato niente, il terzo l'autore inesistente. Sono i tre casi che nella versione con il controller richiedevano un database. Come si organizzano questi test insieme a quelli del domain e a quelli con database vero lo vediamo in Testing completo in Clean Architecture.

Dove metti @Transactional?

In Spring la transazione si dichiara con un'annotazione, e il punto giusto è il use case: è la classe che sa quali scritture formano una sola unità di lavoro. Nel nostro esempio sono due, l'articolo e il contatore dell'autore. Se la seconda fallisce, la prima deve essere annullata. Il blocco che segue è uno schema da adattare: richiede Spring e un PlatformTransactionManager, quindi non è stato eseguito.

@Service
@Transactional
public class CreateArticleUseCase {
    // campi, costruttore ed execute(...) identici alla versione senza framework:
    // l'unica differenza sono queste due annotazioni sulla classe.
}

@Service
@Transactional(readOnly = true)
public class GetArticleUseCase {
    // sola lettura: un suggerimento che il gestore di transazioni può sfruttare
}

Tre dettagli del comportamento di Spring cambiano il significato di questo codice, e li ho verificati nella documentazione ufficiale. Primo: per impostazione predefinita il rollback avviene solo per eccezioni unchecked (RuntimeException ed Error), quindi un'eccezione controllata lanciata dal use case non annulla niente se non lo chiedi con rollbackFor. Per questo le eccezioni di dominio dell'esempio estendono RuntimeException. Secondo: il proxy intercetta solo le chiamate che arrivano dall'esterno, quindi un metodo del use case che ne chiama un altro della stessa classe non apre una nuova transazione. Terzo: readOnly = true è un'ottimizzazione che il gestore di transazioni può sfruttare, non una garanzia che il database rifiuti le scritture.

Sul backend di questo sito ho misurato dove compare l'annotazione, contando i file che hanno @Transactional a inizio riga con grep -rlE "^\s*@Transactional" --include=*.java in ciascuna cartella di backend/src/main/java/dev/federicocalo/portfolio (2026-11-12):

  • application: 76 file
  • infrastructure: 32 file, di cui 22 sono *RepositoryAdapter
  • domain: 0 file
  • presentation: 0 file

Il quadro non è quello da manuale. Dominio e presentation sono puliti, e i use case che coordinano più scritture portano l'annotazione. Ma gli adapter di persistenza la portano a loro volta, in 22 casi. Non è un errore: con la propagazione REQUIRED, quella che la pagina di Spring sulla propagazione indica come buon valore di default, un metodo annotato che viene chiamato dentro una transazione già aperta ne entra a far parte invece di aprirne un'altra. L'adapter con @Transactional è quindi una rete di sicurezza per i chiamanti che non ne hanno aperta una. Il prezzo è che leggendo l'adapter non capisci più da solo dove inizia la transazione, e che un cambio di propagazione in uno dei due livelli può cambiare il comportamento dell'altro. Fuori da questo conteggio restano i moduli verticali (projecthub, partitaiva e altri) che hanno una propria struttura: in tutto, 367 file del backend hanno l'annotazione a inizio riga.

Il controller diventa sottile

Con il use case in mezzo, il controller ha un compito solo: tradurre HTTP in una request, chiamare execute, tradurre la response in HTTP. Anche la traduzione degli errori esce dal use case, che lancia eccezioni di dominio senza sapere cosa sia un codice 404. Lo schema seguente non è eseguito (Spring MVC).

@RestController
@RequestMapping("/api/articles")
public class ArticleController {

    private final CreateArticleUseCase createArticle;

    public ArticleController(CreateArticleUseCase createArticle) {
        this.createArticle = createArticle;
    }

    @PostMapping
    public ResponseEntity<CreateArticleResponse> create(@RequestBody CreateArticleRequest request) {
        return ResponseEntity.status(201).body(createArticle.execute(request));
    }
}

@RestControllerAdvice
class DomainExceptionAdvice {

    @ExceptionHandler(AuthorNotFoundException.class)
    ResponseEntity<String> notFound(AuthorNotFoundException e) {
        return ResponseEntity.status(404).body(e.getMessage());
    }

    @ExceptionHandler(PublishingLimitExceededException.class)
    ResponseEntity<String> conflict(PublishingLimitExceededException e) {
        return ResponseEntity.status(409).body(e.getMessage());
    }

    @ExceptionHandler(IllegalArgumentException.class)
    ResponseEntity<String> badRequest(IllegalArgumentException e) {
        return ResponseEntity.badRequest().body(e.getMessage());
    }
}

La classe DomainExceptionAdvice è il solo posto in cui un'eccezione di dominio diventa uno stato HTTP. La scelta di 409 per il limite superato è una convenzione che puoi cambiare: conta che il use case non la conosca. Se domani lo stesso use case è chiamato da un job notturno o da una coda di messaggi, riceve la stessa eccezione e decide da solo come reagire.

Cosa succede quando il use case parla con l'esterno

La notifica ai follower è il punto dove le cose si complicano. Nel codice sopra la chiamata avviene dopo i salvataggi, dentro lo stesso metodo che Spring avvolge in una transazione. Se la notifica venisse inviata subito e poi il commit fallisse, i follower riceverebbero l'avviso di un articolo che non esiste. L'ordine inverso (commit, poi notifica) ha il problema opposto: se il processo muore tra i due passi, l'articolo c'è e l'avviso non parte mai. Il pattern transactional outbox risolve il dilemma scrivendo il messaggio in una tabella nella stessa transazione dei dati, e lasciando a un processo separato il compito di inviarlo (è descritto nella pagina dedicata di microservices.io, tra le fonti). Costa una tabella in più e un processo da gestire. Per un avviso non critico, come in questo esempio, il try con registrazione dell'errore è una scelta accettabile, purché tu sappia che stai accettando di poter perdere qualche notifica.

Il caso reale: come lo fa il backend di questo sito

Nel backend (backend/src/main/java/dev/federicocalo/portfolio/application) i use case sono ovunque: find . -name "*UseCase*.java" | wc -l restituisce 987 file e find . -name "*UseCase.java" | wc -l ne restituisce 647 (misure del 2026-11-12). Il secondo numero conta i nomi che finiscono per UseCase, il primo include anche UseCaseImpl, i test e altre varianti, quindi non è «il numero di use case».

Due esempi letti nel codice mostrano le scelte concrete. ListUserPurchasesUseCase è un'interfaccia con un metodo execute(Long userId), e ListUserPurchasesUseCaseImpl la implementa con @Service e @Transactional(readOnly = true): qui il use case ha un'interfaccia propria, quindi il controller dipende dall'astrazione. Il Javadoc dell'interfaccia precisa che userId viene sempre dal JWT e mai da un parametro del client, una regola di sicurezza scritta nel contratto del use case. Il secondo esempio, GetBestMonthsUseCase, è una classe concreta senza interfaccia, con @Cacheable(value = "weather-best-months", key = "#city") direttamente sul metodo.

Le due forme convivono, e la differenza è un trade-off: l'interfaccia costa un file in più e rende banale sostituire il use case nei test del controller, la classe concreta è più corta e basta quando il test del controller può usare un mock della classe. Nessuna delle due è «più pulita»: quello che il progetto tiene davvero fermo è che il controller non tocca l'infrastructure, e lo verifica con ArchUnit come vedremo nel pilastro della serie.

Domande che si fanno spesso

Serve un use case anche per una lettura banale?

Conviene averlo se la lettura ha una regola (autorizzazione, filtri per ruolo, cache) o se è chiamata da più di un punto. Per un semplice «dammi l'utente con questo id» un use case che chiama un solo metodo del repository è un passaggio in più senza contenuto. Il compromesso ragionevole è partire dal use case solo quando c'è qualcosa da orchestrare, e mantenere comunque la regola che il controller non importa classi di infrastructure.

Uno use case per ogni endpoint?

Non necessariamente. La corrispondenza naturale è uno use case per ogni azione di business, e di solito un endpoint corrisponde a un'azione. Può capitare che due endpoint (per esempio uno JSON e uno per l'esportazione) chiamino lo stesso use case, oppure che un endpoint ne richiami due in sequenza. Il criterio è il verbo di business, non l'URL.

Dove va la logica condivisa fra due use case?

Se la logica riguarda i dati di un'entità, nel domain (come canPublish() nell'esempio). Se coinvolge più entità e non appartiene a nessuna, in un servizio di dominio, cioè una classe del domain senza dipendenze da framework. Non farla chiamare da un use case all'altro: crea una dipendenza nascosta fra azioni che dovrebbero restare indipendenti. Il ruolo del domain è approfondito in Domain layer in Clean Architecture, mentre gli adapter che implementano i port sono in Infrastructure layer: JPA e Redis.

Che cosa sai fare adesso

Sai riconoscere in un controller le tre cose che non gli appartengono (regola di business, accesso alla persistenza, entità esposta) e spostarle in un use case con port e DTO a record. Sai verificare quel use case con fake in memoria, senza avviare Spring, e sai dove dichiarare la transazione e quali sono i suoi limiti: rollback solo sulle eccezioni non controllate, nessuna transazione sulle chiamate interne alla stessa classe, e un dilemma aperto per gli effetti collaterali come le notifiche.

Fonti

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti