Skip to main content

Use Case Pattern in Spring Boot Clean Architecture

How to move the rules from the controller into a use case with ports, record DTOs and transactions, testing it with in-memory fakes without starting Spring.

What do you think about this article?

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

At a Glance

How to move the rules from the controller into a use case with ports, record DTOs and transactions, testing it with in-memory fakes without starting Spring.

The controller receives an HTTP request, and six months later it contains the rule "an author cannot exceed one hundred articles", the computation of the publication date and the sending of a notification. To test one of these rules you have to start the whole application and a database. The Use Case pattern moves those rules into a class that has a single verb (create an article, publish an article) and that depends on interfaces instead of JPA or HTTP. The result is a piece of code that you can run, and test, with a main or with a test that takes a few milliseconds.

What is wrong with the controller that does everything?

Take a concrete case: a blog platform where each author can publish at most one hundred articles. In the naive version the rule lives wherever it happens to land, usually in the endpoint that creates the article. The block below is a schema to adapt (it needs Spring MVC and JPA, so it cannot run in isolation) but it is the shape you meet most often in real projects.

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

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

    // constructor omitted

    @PostMapping
    public ResponseEntity<?> create(@RequestBody CreateArticleRequest req) {
        User author = userRepository.findById(req.authorId()).orElseThrow();
        if (author.getArticlesPublished() >= 100) {
            return ResponseEntity.status(409).body("limit reached");
        }
        ArticleEntity entity = new ArticleEntity(req.title(), req.content(), author);
        articleRepository.save(entity);
        return ResponseEntity.ok(entity);   // exposes the JPA entity
    }
}

There are three defects and you can read them directly in the block. The controller knows ArticleJpaRepository and UserJpaRepository, that is the persistence technology: changing the database, or even just the way the author is loaded, means editing a presentation file. The limit rule is an if with a hand-written number that no other point of the system can reuse, for example a command-line import that creates articles without going through HTTP. Finally the last line returns the JPA entity to the client, and with it the structure of the tables, including the lazy relations that sooner or later produce a serialization error. To test the limit rule you have to start the Spring context and a database, because the only way to reach that if is to go through the controller.

How to build a use case: port, request, response

A use case is made of four things: an input object (request), an output object (response), the interfaces that declare what it needs (ports) and the class that coordinates everything. The domain, which stays free of frameworks, contains the rules that belong to the data itself. Here the author knows whether they can still publish, and an article is born as a draft without an identifier, because assigning it is the job of whoever persists it.

public record AuthorId(long value) {
    public AuthorId {
        if (value <= 0) {
            throw new IllegalArgumentException("the author id must be positive: " + 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) {

    /** Draft not saved yet: whoever persists it assigns the id. */
    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);
    }
}

All of them are Java records: immutable by construction, so withOneMorePublished() returns a new Author instead of modifying the one it received. The limit of one hundred is a constant of the domain, PUBLISHING_LIMIT, and the canPublish() method is the only place where the rule exists. If tomorrow the limit depends on the author's subscription, you change this method and nothing else. AuthorId is a minimal value object: it rejects non-positive ids at construction.

The use case reports business failures with domain exceptions. They are three small classes, needed to compile the other listings: a common base and the two exceptions the use case throws.

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

public class AuthorNotFoundException extends DomainException {
    public AuthorNotFoundException(AuthorId id) {
        super("author " + id.value() + " does not exist");
    }
}

public class PublishingLimitExceededException extends DomainException {
    public PublishingLimitExceededException(Author author) {
        super("author " + author.name() + " already has " + author.publishedCount()
            + " articles (limit " + Author.PUBLISHING_LIMIT + ")");
    }
}

They extend RuntimeException through the DomainException base, so a single handler can catch them all (we come back to this in the section on transactions) and the messages say who failed and what failed. With these types all the listings in the article compile together: the full sources are the ones shown, no file is omitted.

The ports say what is needed without saying how it is obtained. They are plain interfaces, with no Spring annotations, and they live next to the use case that uses them.

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);
}

Note the choice of Optional<Author> findById: the port does not return null and does not throw the JPA exception, because "not found" is a condition the use case must handle explicitly. The FollowerNotifier interface is the way to talk to the outside world (mail, queues, push notifications) without letting any of these technologies into the use case.

The request is the boundary between HTTP and the application. Here a compact constructor (the constructor form of records, described in JEP 395) rejects malformed input before the use case starts, so syntactic validation does not get scattered across the controller.

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

    public CreateArticleRequest {
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("the title is required");
        }
        if (content == null || content.length() < 20) {
            throw new IllegalArgumentException("the content must have at least 20 characters");
        }
    }
}

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

Validation here is deliberately only syntactic: non-empty title, content that is not too short. Whether the author exists or has reached the limit cannot be known from the request alone, and in fact those are checks for the use case. Telling the two levels apart saves you from the opposite vice, that is stuffing the record with repository calls.

The use case, line by line

Here is the class that orchestrates. It has four dependencies, all interfaces, passed through the constructor: no @Autowired on fields, no new of a concrete class.

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("notification failed (ignored): " + e.getMessage());
        }

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

The sequence has a precise order, and the order matters. First the author is retrieved (orElseThrow turns absence into AuthorNotFoundException), then the business rule is applied through canPublish(), and only afterwards do we write: if the limit is exceeded nothing is saved. Saving the article and updating the author's counter are two writes that must succeed or fail together, and that "together" is entrusted to the transaction, which we discuss further on. The notification sits inside a try precisely because it is a side effect: if the mail server is down, we do not want to lose an article that has already been saved. The cost is that the error is logged but not rethrown, so someone has to watch that log.

Running it without a framework

The advantage of ports is measured here. To test the use case you only need two in-memory classes that implement the interfaces, and no dependency-injection container.

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);
    }
}

These are fakes: real but simplified implementations that keep the data in a map and in a list. InMemoryArticles.save assigns the identifier as the database would with a sequence, and that is why the draft is born with id = 0. Unlike a mock, a fake has behavior, so you can verify the final state (how many articles there are) instead of verifying which methods were called.

The following program builds an author with three articles, one already at the limit, a notifier that always fails, and tries four requests. I compiled it with javac (JDK 21) in a temporary folder and the output is the one below. Two consecutive runs produce the same text, because the code uses neither a clock nor random numbers.

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("mail server unreachable");
        };
        var useCase = new CreateArticleUseCase(authors, articles, brokenNotifier, log::add);

        String body = "A content long enough to pass the minimum.";
        run("author 7, valid request", () ->
            useCase.execute(new CreateArticleRequest("Use case without a framework", body, 7)));
        run("author 99, does not exist", () ->
            useCase.execute(new CreateArticleRequest("Title", body, 99)));
        run("author 8, at the limit", () ->
            useCase.execute(new CreateArticleRequest("Title", body, 8)));
        run("empty title", () ->
            useCase.execute(new CreateArticleRequest("  ", body, 7)));

        System.out.println("log: " + log);
        System.out.println("saved articles: " + 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:
// author 7, valid request -> CreateArticleResponse[articleId=1, title=Use case without a framework, authorArticleCount=4]
// author 99, does not exist -> AuthorNotFoundException: author 99 does not exist
// author 8, at the limit -> PublishingLimitExceededException: author Marco already has 100 articles (limit 100)
// empty title -> IllegalArgumentException: the title is required
// log: [notification failed (ignored): mail server unreachable]
// saved articles: 1

The four lines show four different outcomes of the same use case. The first succeeds, with the author's counter going from 3 to 4. The second and the third fail with different domain exceptions, and in both cases no trace is left in the repository (the last line confirms that exactly one article was saved). The fourth does not even reach the use case, because the request's compact constructor throws first. In the log line you finally see the failed and ignored notification of the first request, while the article is saved all the same.

The same behaviors become tests. These three run with JUnit 5 (console launcher 1.11.4 downloaded to a folder outside the repository) without Spring: the whole execution, launcher startup included, took between 113 and 535 ms over seven runs (AMD Ryzen 5 5600H, OpenJDK 21.0.13), and the value depends on the machine load. The order in which JUnit lists the methods is not guaranteed, and the output below is the one actually printed.

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 = "A content long enough to pass.";

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

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

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

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

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

// Output:
// ├─ JUnit Jupiter ✔
// │  └─ CreateArticleUseCaseTest ✔
// │     ├─ the_last_allowed_article_is_saved() ✔
// │     ├─ author_does_not_exist() ✔
// │     └─ beyond_the_limit_nothing_is_saved() ✔
// 3 tests successful
// 0 tests failed

The first test pins down the edge case, the last allowed article (the author has 99), the second verifies that beyond the limit nothing gets saved, the third covers the nonexistent author. These are the three cases that, in the controller version, required a database. How to organize these tests together with the domain ones and those with a real database is covered in Complete Testing in Clean Architecture.

Where do you put @Transactional?

In Spring the transaction is declared with an annotation, and the right place is the use case: it is the class that knows which writes form a single unit of work. In our example there are two, the article and the author's counter. If the second fails, the first must be undone. The block that follows is a schema to adapt: it requires Spring and a PlatformTransactionManager, so it was not executed.

@Service
@Transactional
public class CreateArticleUseCase {
    // fields, constructor and execute(...) identical to the version without a framework:
    // the only difference is these two annotations on the class.
}

@Service
@Transactional(readOnly = true)
public class GetArticleUseCase {
    // read-only: a hint that the transaction manager can take advantage of
}

Three details of Spring's behavior change the meaning of this code, and I checked them in the official documentation. First: by default rollback happens only for unchecked exceptions (RuntimeException and Error), so a checked exception thrown by the use case rolls nothing back unless you ask for it with rollbackFor. That is why the domain exceptions in the example extend RuntimeException. Second: the proxy intercepts only calls coming from outside, so a method of the use case that calls another method of the same class does not open a new transaction. Third: readOnly = true is an optimization the transaction manager can take advantage of, not a guarantee that the database will refuse writes.

On this site's backend I measured where the annotation appears, counting the files that have @Transactional at the start of a line with grep -rlE "^\s*@Transactional" --include=*.java in each folder of backend/src/main/java/dev/federicocalo/portfolio (2026-11-12):

  • application: 76 files
  • infrastructure: 32 files, of which 22 are *RepositoryAdapter
  • domain: 0 files
  • presentation: 0 files

The picture is not the textbook one. Domain and presentation are clean, and the use cases that coordinate several writes carry the annotation. But the persistence adapters carry it too, in 22 cases. This is not a mistake: with the REQUIRED propagation, the one that Spring's propagation page indicates as a good default, an annotated method called inside an already open transaction joins it instead of opening another. The adapter with @Transactional is therefore a safety net for callers that have not opened one. The price is that by reading the adapter you can no longer tell on your own where the transaction begins, and that a change of propagation at one of the two levels can change the behavior of the other. Outside this count are the vertical modules (projecthub, partitaiva and others) that have a structure of their own: overall, 367 backend files have the annotation at the start of a line.

The controller becomes thin

With the use case in the middle, the controller has a single task: translate HTTP into a request, call execute, translate the response into HTTP. The translation of errors also leaves the use case, which throws domain exceptions without knowing what a 404 code is. The following schema is not executed (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());
    }
}

The DomainExceptionAdvice class is the only place where a domain exception becomes an HTTP status. The choice of 409 for the exceeded limit is a convention you can change: what matters is that the use case does not know it. If tomorrow the same use case is called by a nightly job or by a message queue, it receives the same exception and decides on its own how to react.

What happens when the use case talks to the outside

The notification to followers is the point where things get complicated. In the code above the call happens after the saves, inside the same method that Spring wraps in a transaction. If the notification were sent right away and then the commit failed, the followers would receive the announcement of an article that does not exist. The reverse order (commit, then notify) has the opposite problem: if the process dies between the two steps, the article exists and the announcement is never sent. The transactional outbox pattern solves the dilemma by writing the message to a table in the same transaction as the data, and leaving a separate process the job of sending it (it is described on the dedicated microservices.io page, among the sources). It costs an extra table and a process to manage. For a non-critical notice, as in this example, the try with error logging is an acceptable choice, as long as you know you are accepting that you may lose a few notifications.

The real case: how this site's backend does it

In the backend (backend/src/main/java/dev/federicocalo/portfolio/application) use cases are everywhere: find . -name "*UseCase*.java" | wc -l returns 987 files and find . -name "*UseCase.java" | wc -l returns 647 (measurements of 2026-11-12). The second number counts the names ending in UseCase, the first also includes UseCaseImpl, the tests and other variants, so it is not "the number of use cases".

Two examples read in the code show the concrete choices. ListUserPurchasesUseCase is an interface with a method execute(Long userId), and ListUserPurchasesUseCaseImpl implements it with @Service and @Transactional(readOnly = true): here the use case has an interface of its own, so the controller depends on the abstraction. The interface's Javadoc specifies that userId always comes from the JWT and never from a client parameter, a security rule written into the use case's contract. The second example, GetBestMonthsUseCase, is a concrete class without an interface, with @Cacheable(value = "weather-best-months", key = "#city") directly on the method.

The two forms coexist, and the difference is a trade-off: the interface costs one more file and makes it trivial to replace the use case in controller tests, while the concrete class is shorter and is enough when the controller test can use a mock of the class. Neither is "cleaner": what the project really holds fixed is that the controller does not touch the infrastructure, and it verifies this with ArchUnit as we will see in the pillar article of the series.

Frequently asked questions

Do you need a use case even for a trivial read?

It is worth having one if the read has a rule (authorization, filters by role, cache) or if it is called from more than one place. For a plain "give me the user with this id", a use case that calls a single repository method is a step with no content. The reasonable compromise is to start from the use case only when there is something to orchestrate, and still keep the rule that the controller does not import infrastructure classes.

One use case per endpoint?

Not necessarily. The natural correspondence is one use case per business action, and usually an endpoint corresponds to one action. It can happen that two endpoints (for example a JSON one and one for export) call the same use case, or that an endpoint calls two in sequence. The criterion is the business verb, not the URL.

Where does logic shared between two use cases go?

If the logic concerns the data of one entity, in the domain (like canPublish() in the example). If it involves several entities and belongs to none, in a domain service, that is a domain class with no framework dependencies. Do not make one use case call the other: it creates a hidden dependency between actions that should remain independent. The role of the domain is explored in Domain Layer in Clean Architecture, while the adapters that implement the ports are in Infrastructure Layer: JPA and Redis.

What you can do now

You can recognize in a controller the three things that do not belong to it (business rule, access to persistence, exposed entity) and move them into a use case with ports and record DTOs. You can verify that use case with in-memory fakes, without starting Spring, and you know where to declare the transaction and what its limits are: rollback only on unchecked exceptions, no transaction on calls internal to the same class, and an open dilemma for side effects such as notifications.

Sources

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects