DEV Community

Dominik Kovács
Dominik Kovács

Posted on Originally published at solodev.sk on

Vertical slices in Spring Boot, and how to test them

Where did the feature go

Most Spring Boot projects start with the structure everyone recognises.

controller/
service/
repository/
dto/
entity/
Enter fullscreen mode Exit fullscreen mode

It reads well when there are four classes in it. The problem arrives later, when adding one feature
means editing a controller here, a method on a service there, two DTOs in a third package, and a
repository query in a fourth. Nothing about that change is complicated. It is just spread out.

The alternative is to make the feature the unit of organisation rather than the technical role.

order/
    OrderController.java
    OrderService.java
    OrderRepository.java
    OrderRequest.java
    OrderResponse.java
customer/
payment/
Enter fullscreen mode Exit fullscreen mode

Everything an order needs lives in order. That is a vertical slice, one business capability cut
through every technical layer it happens to use.

The feature owns its whole stack

The interesting consequence is not the folder. It is that the classes inside stop being public.

In a layered layout a service in service has to be visible to a controller in controller, so
every one of those types is public, which in Java means available to the entire codebase. You wanted
"the controller may call the service" and the language made you write "anyone may call the service".

Put them in the same package and the calls stop crossing a boundary at all.

OrderService.java

@Service
class OrderService {

    private final OrderRepository orders;
    private final PaymentGateway payments;

    OrderService(OrderRepository orders, PaymentGateway payments) {
        this.orders = orders;
        this.payments = payments;
    }

    @Transactional
    OrderResponse place(OrderRequest request) {
        var order = Order.from(request);
        payments.authorise(request.customerId());
        return OrderResponse.of(orders.save(order));
    }
}
Enter fullscreen mode Exit fullscreen mode

OrderController.java

@RestController
class OrderController {

    private final OrderService service;

    OrderController(OrderService service) {
        this.service = service;
    }

    @PostMapping("/orders")
    OrderResponse place(@RequestBody OrderRequest request) {
        return service.place(request);
    }
}
Enter fullscreen mode Exit fullscreen mode

No public on either. customer cannot import OrderService, and not as a matter of policy. The
compiler rejects it, on every machine, including the one belonging to whoever joins next year and
never read the team conventions.

That is the part a layered structure cannot give you. It has to expose every layer to the next, so
"no feature depends on another feature" stays a convention you check in review rather than something
the build enforces.

PaymentGateway is the one type in there that comes from outside the feature, which is a problem of
its own and gets a section further down.

What still has to be shared

Two things usually escape the feature package, and it is worth being deliberate about both.

The first is the domain model, when several features genuinely write to the same rows. Those
invariants belong to the domain, not to whichever feature touched them last, so they get a home of
their own.

order/
    domain/
        Order.java
        OrderItem.java
        OrderRepository.java
    OrderController.java
    OrderService.java
Enter fullscreen mode Exit fullscreen mode

Java package visibility does not nest, which surprises people. As far as the compiler is concerned
order.domain is a different package from order, with no privileged access to it, and there is no
modifier meaning "visible to my subpackages". Move Order down a level and it has to become
public. That is a real cost, and a reason not to introduce subpackages until something forces you.

The second is genuine infrastructure, meaning security configuration, error handling, or an
interceptor that adds a request identifier to the logging context. None of that is a feature, and
pretending otherwise helps nobody.

web/
    SecurityConfiguration.java
    ApiExceptionHandler.java
Enter fullscreen mode Exit fullscreen mode

What should not escape is a helper extracted because two features looked similar once. A shared
OrderUtils that both order and payment depend on has coupled them together in a way the
package structure now hides. Duplication between features is usually cheaper than a shared
abstraction serving two masters, and the third occurrence tells you far more about the right shape
than the second one does.

When one feature needs another

Placing an order has to authorise a payment. Payments are their own feature, so what does order
call?

The tempting answer is to inject payment's service directly. That works, and it quietly undoes the
thing you just built. payment now has to make that class public, order compiles against
payment's internals, and you have a dependency the package structure claims does not exist.

Two options keep the boundary intact.

The first is to publish a deliberately small interface from the feature being called, and treat that
as its contract rather than an accident of its implementation.

payment/
    PaymentGateway.java        <- public, the contract
    PaymentService.java        <- package-private, the implementation
    StripeClient.java          <- package-private
Enter fullscreen mode Exit fullscreen mode

order depends on PaymentGateway and nothing else. The one public type is the price of the
dependency, and because it is the only one, it is a decision somebody had to make on purpose rather
than a side effect of layering.

The second is to invert it. Publish an event from order and let payment subscribe.

OrderService.java

@Transactional
OrderResponse place(OrderRequest request) {
    var order = orders.save(Order.from(request));
    events.publishEvent(new OrderPlaced(order.id(), order.total()));
    return OrderResponse.of(order);
}
Enter fullscreen mode Exit fullscreen mode

Now order knows nothing about payments at all, and payment decides for itself what to do when an
order appears. That is a stronger boundary, and it costs you the ability to reason about the whole
operation in one place. Use it when the reaction is genuinely optional or asynchronous, and the
interface when the caller needs the result before it can continue.

Either way, a feature calling another feature should be visible, deliberate and narrow. What you are
avoiding is not the dependency itself, it is the dependency nobody chose.

Tests that mirror the structure

Here is the part that changes day to day. Because the feature is a package, its tests are the same
package, and a failing test names the broken capability rather than a layer.

src/test/java/com/example/shop/
    order/
        OrderSliceTest.java
    customer/
        CustomerSliceTest.java
Enter fullscreen mode Exit fullscreen mode

Tests living in the same package is also what makes package-private classes testable without
loosening anything. Maven and Gradle both put src/test/java on the same package namespace, so the
test sees the class while the rest of the application still cannot.

The test that follows from this packaging is one per feature. Name the classes of the feature and
run the whole thing, from the HTTP request to the rows in the database.

OrderSliceTest.java

@Testcontainers
@SpringBootTest(classes = {OrderController.class, OrderService.class})
@EntityScan(basePackageClasses = Order.class)
@EnableJpaRepositories(basePackageClasses = OrderRepository.class)
@ImportAutoConfiguration({
        DataSourceAutoConfiguration.class,
        HibernateJpaAutoConfiguration.class,
        TransactionAutoConfiguration.class,
        WebMvcAutoConfiguration.class})
@AutoConfigureMockMvc
@Transactional
class OrderSliceTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");

    @Autowired
    MockMvcTester mvc;

    @Autowired
    OrderRepository orders;

    @MockitoBean
    PaymentGateway payments;

    @Test
    void placesOrder() {
        assertThat(mvc.post().uri("/orders")
                .contentType(APPLICATION_JSON)
                .content(orderWith(2)))
                .hasStatus(HttpStatus.OK);

        assertThat(orders.findByCustomerId(42L)).hasSize(1);
    }

    @Test
    void rejectsOrderWithMoreThanTenItems() {
        assertThat(mvc.post().uri("/orders")
                .contentType(APPLICATION_JSON)
                .content(orderWith(11)))
                .hasStatus(HttpStatus.BAD_REQUEST);

        assertThat(orders.findByCustomerId(42L)).isEmpty();
        verifyNoInteractions(payments);
    }
}
Enter fullscreen mode Exit fullscreen mode

1. Naming the classes is what keeps this a slice. The context holds the controller and the
service of one feature, and nothing else in the application is loaded.

2. Naming them also turns off component scanning, so the entity has to be pointed at by hand.
Leave this out and Hibernate starts with no mapped types.

3. Same for the repository, which is an interface that something has to generate an
implementation for.

The four autoconfigurations below are the rest of what this slice needs. Listing them is the price
of not booting the application, and the list ends up being a fair description of the feature's real
dependencies.

4. The one stub in the test, because PaymentGateway is the one dependency that leaves the
feature. Everything else in the slice runs for real.

One test class, and the request travels the entire slice. JSON deserialisation, validation, the
controller, the service, the transaction, Hibernate, and a real Postgres. The assertion afterwards
reads the rows back, so nothing along that path is assumed.

Stub at the edges of the slice, never inside it. A stubbed repository would have left the SQL and
the mapping untested, which is most of what actually goes wrong, and running the real thing is what
lets the second test assert that a rejected order leaves the database untouched. That is a claim
about the feature end to end rather than about any class inside it.

Nothing from customer or payment is in the context, so a change in either cannot break this
test. That containment is also why one test per feature stays practical as the application grows.
The context is a function of the slice, not of the codebase, so the tenth feature does not slow down
the first feature's test.

When layers are fine

If every feature in the application has the same shape, layers cost nothing and give each kind of
file one obvious home. A CRUD service over a dozen tables does not need any of this.

Slices start paying when features stop resembling each other, and when parts of the system change
at different rates. The signal is practical rather than architectural. If implementing one feature
means opening ten packages, the structure is organised around the framework. If everything you need
to understand it sits under order/, it is organised around the work.

Top comments (0)