Structuring a Boot application
Package by feature, thin controllers, the service layer that earns its keep, and the mapping layer between JPA entities and API models.
Boot will run anything: one class with a controller, a repository and a main method starts fine. The structure question is about the second year, when there are forty features and someone needs to change one without reading the other thirty-nine. The layout that survives is packages by feature, layers that each do one thing, DTOs at the edge, and tests that load exactly the slice they test.
Package by feature, not by layer
The layer-first layout (controllers/, services/, repositories/) puts every unrelated thing together and every related thing apart. Changing "orders" means touching four packages, and nothing stops PaymentController from calling OrderRepository directly. Feature-first turns the package into the boundary:
com.acme.shop
├── ShopApplication.java ← @SpringBootApplication, nothing else
├── orders
│ ├── OrderController.java ← public: the HTTP surface
│ ├── OrderService.java ← package-private
│ ├── OrderRepository.java ← package-private
│ ├── Order.java ← the entity, package-private
│ ├── OrderDto.java ← public: what other features and clients see
│ └── OrderPlaced.java ← public: an event other features may listen to
├── payments
└── inventoryJava's package-private visibility does the enforcement for free. OrderRepository without public cannot be injected from payments; if payments needs an order, it goes through OrderService's public methods or listens for OrderPlaced. A feature's public surface is deliberately small, and a pull request that adds public to a repository is a design conversation. Spring Modulith formalises the same idea and can verify at test time that no package reaches into another's internals; the plain-Java version gets most of the benefit.
The main class stays boring on purpose. @SpringBootApplication scans downwards from its package, so it sits at the root and contains SpringApplication.run and nothing else. Beans that need configuration go in a @Configuration inside the feature that owns them.
Layers that earn their keep
Three layers, each with a job it does not share:
| Layer | Owns | Must not |
|---|---|---|
| Controller | HTTP: parsing, validation of the request shape, mapping to and from DTOs, status codes | contain business rules, touch a repository |
| Service | the use case: transactions, orchestration, domain rules, publishing events | know about HTTP (HttpServletRequest, ResponseEntity), return entities to the controller |
| Repository | persistence | contain business rules in queries nobody can find |
A "thin controller" is one you could delete and replace with a Kafka listener that calls the same service. A "service that earns its keep" is one whose methods read like the product's verbs: placeOrder, cancelOrder, not saveOrder. If a service method is one line that calls the repository, the question is whether the method is misnamed or whether the layer is doing nothing yet; both are fine, as long as the transaction boundary is there, because @Transactional belongs on the service method and nowhere else:
@Service
class OrderService {
private final OrderRepository orders;
private final ApplicationEventPublisher events;
@Transactional
OrderDto place(PlaceOrder cmd) {
var order = Order.place(cmd.customerId(), cmd.lines()); // the domain enforces its invariants
orders.save(order);
events.publishEvent(new OrderPlaced(order.id()));
return OrderDto.from(order);
}
}DTOs and mappers
The JPA entity does not leave the service. Returning it from a controller couples the API to the database schema, serialises lazy collections outside the transaction (the LazyInitializationException, or worse, the query storm when open-in-view hides it), and leaks fields that were never meant to be public. @JsonIgnore on an entity is the smell that says the entity is being used as a DTO.
Records make DTOs cheap:
public record OrderDto(UUID id, String status, List<LineDto> lines, Instant placedAt) {
static OrderDto from(Order o) {
return new OrderDto(o.getId(), o.getStatus().name(),
o.getLines().stream().map(LineDto::from).toList(), o.getPlacedAt());
}
public record LineDto(String sku, int quantity) {
static LineDto from(OrderLine l) { return new LineDto(l.getSku(), l.getQuantity()); }
}
}A static from per DTO is enough for most services and is greppable. MapStruct generates the same code from an interface when there are dozens of them; it is worth it when mapping is the bulk of the boilerplate and not before. Inbound requests get their own record (PlaceOrderRequest), separate from the outbound OrderDto, because the two change for different reasons.
Where validation lives
Three places, three kinds of rule:
- The request shape, at the controller, with Bean Validation:
@Valid @RequestBody PlaceOrderRequest req, constraints on the record's components. Fails with 400 before any code of yours runs. - Domain invariants, in the domain: an order cannot be placed with zero lines, a cancellation cannot follow shipment. These live in
Order.place(...)andorder.cancel(), throwing a domain exception, because they must hold no matter which entry point (HTTP, Kafka, a batch job) reaches the domain. - Data integrity, in the database: unique constraints, foreign keys, not-null. The last line, and the only one that holds under concurrent writers.
A rule enforced only in the controller is a rule the message listener will break. Errors get one shape: a @RestControllerAdvice maps domain exceptions to ProblemDetail responses (spring.mvc.problemdetails.enabled=true does the same for the framework's own errors), so a client sees RFC 9457 problem JSON with a type, a title and a status, never a stack trace.
Testing slices
@SpringBootTest loads everything, connects to everything, and takes ten seconds before the first assertion. Boot's slices load one layer:
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockitoBean OrderService service;
@Test void rejectsAnEmptyOrder() throws Exception {
mvc.perform(post("/orders").contentType(APPLICATION_JSON).content("""
{"customerId":"c1","lines":[]}"""))
.andExpect(status().isBadRequest());
}
}| Slice | Loads | For |
|---|---|---|
@WebMvcTest | controllers, advice, converters, validation; services mocked | request shape, status codes, JSON |
@DataJpaTest | JPA, repositories, a datasource; with Testcontainers @ServiceConnection, the real database | queries and mappings |
@JsonTest | Jackson | DTO serialisation |
@SpringBootTest | everything | a handful of end-to-end paths |
The service layer needs no slice: it is plain Java with constructor injection, so a unit test constructs it with a fake repository and runs in milliseconds. A test pyramid for a Boot service is mostly those, then slices, then a few @SpringBootTest runs that prove the wiring, and the whole suite finishes before the coffee does.
Under the hood: what a slice test actually loads
@SpringBootTest walks up from the test's package to find @SpringBootConfiguration (your main class), builds a context from it, and runs every auto-configuration the classpath allows: a datasource, JPA, Kafka listeners, security, Tomcat if webEnvironment says so. A slice annotation is the same bootstrapper with two changes. It carries @ImportAutoConfiguration with a curated list of auto-configurations for that layer, and it installs a TypeExcludeFilter so component scanning from the main class keeps only the beans of that layer: @WebMvcTest keeps @Controller, @ControllerAdvice, @JsonComponent, converters, filters and WebMvcConfigurers and drops @Service, @Repository and @Component; @DataJpaTest keeps @Repository and entities, adds an embedded database (or the Testcontainers one through @ServiceConnection), and wraps every test in a rolled-back transaction. That is why a controller test that "cannot find bean OrderService" is behaving correctly: the service was filtered out, and @MockitoBean is how you say what it should return.
The filter is what the package layout feeds. @SpringBootApplication on the main class means @ComponentScan from that package, and a slice test scans the same tree through the exclude filter; a main class moved into com.acme.shop.app scans only app, and a @Configuration in com.acme.shop.orders silently vanishes from every test and from production alike. Package-private visibility works with the same scanner: Spring instantiates beans reflectively and does not care whether the class is public, so class OrderService (no modifier) is a perfectly good bean that no other package can name, and injection across the boundary fails at compile time rather than at wiring time.
Contexts are cached across tests keyed by their configuration (the annotations, properties, mocks and profiles), so ten @WebMvcTests with the same shape share one context, and one test with a different properties value forces a second. A suite that takes minutes usually has many distinct keys; @MockitoBean declarations are part of the key, so mocking a different set of beans in each test class is the common cause of "every test starts a context".
Open-in-view is a filter too: OpenEntityManagerInViewInterceptor, registered by JpaWebConfiguration when spring.jpa.open-in-view is true, binds an EntityManager to the request thread before the controller runs and closes it after the response is written. A lazy collection touched during JSON serialisation therefore finds a live persistence context and loads, one query per element, on a connection held for the whole request, including the time spent writing the response to a slow client. With it off, the same access throws LazyInitializationException, which is the error you want, because it points at the DTO that should have been built inside the service's transaction.
Walkthrough: the module that leaked into three others
A retail platform kept everything in one Boot application and grew to fifty packages. A change to the Order entity broke the build in payments, shipping and reporting.
public class Order { ... } // public entity
public interface OrderRepository extends JpaRepository<Order, UUID> { ... } // public
// in shipping
@Service class ShippingService { ShippingService(OrderRepository orders) { ... } } // reaches into orders- Every entity and repository was
publicbecause the first generator made them so.shippinginjectedOrderRepositorydirectly, ran its own queries againstOrder, and mapped its fields. So didpaymentsandreporting. Theordersteam renamed a column and moved a status enum; three other teams' code stopped compiling and one team's report silently returned zero rows. - The build had no boundary check. The only signal that
shippingdepended on the internals oforderswas the import statement, which no review had flagged in two years. - The fix was mechanical and slow:
OrderandOrderRepositorywent package-private;ordersexposedOrderService.find(UUID)returning anOrderDtoand publishedOrderPlaced/OrderShippedevents; each of the three consumers was moved onto that surface, feature by feature, behind a Spring ModulithApplicationModules.verify()test that failed on any cross-package access to a non-exposed type. reportingturned out to need read access to eight modules' data. Rather than eight public surfaces, it got its own read model, filled from the events, which is also what it would have been as a separate service.- Two quarters later,
orderswas extracted into its own deployable in a week, because the boundary already existed. The monolith had become modular before it became distributed, which is the only order in which that works.
The compiler enforces package-private for free; a verify test enforces it against reflection-happy frameworks. Neither is a substitute for deciding what each feature's surface is, but both make the decision stick.
Try it yourself
Why does the test not find the bean?
@WebMvcTest(OrderController.class)
class OrderControllerTest { @Autowired OrderService service; ... }fails with NoSuchBeanDefinitionException: OrderService. OrderService is a @Service in the same package as the controller. What happened, and what are the two fixes?
Answer
@WebMvcTest installs a TypeExcludeFilter that keeps only web-layer beans; @Service is excluded regardless of package. Fix one, the intended one: @MockitoBean OrderService service and stub what the controller needs. Fix two, when the test genuinely needs the real service: @Import(OrderService.class) (which also pulls in its dependencies, so usually a repository mock too), or move to @SpringBootTest with MockMvc and accept the cost. The exception is the slice doing its job.
Which layer, which rule?
Classify each rule and say where it is enforced: (a) an order must have at least one line; (b) the customerId in the request must be a UUID; (c) two orders cannot share an idempotency key; (d) a cancelled order cannot be shipped.
Answer
(b) is request shape: a constraint on the request record, checked by @Valid at the controller, 400 before any code runs. (a) and (d) are domain invariants: Order.place refuses zero lines, order.ship() refuses a cancelled state, throwing a domain exception mapped to 409/422 by the advice, and enforced no matter whether HTTP, Kafka or a batch job called. (c) is integrity under concurrency: a unique constraint in the database, because two requests checking "does this key exist" in parallel both see no, and only the constraint holds.
Ten seconds per test class
A suite has 40 @WebMvcTest classes and takes eight minutes; each class logs "Starting OrderControllerTest" and a full context start. Why is the context not cached, and what would make it?
Answer
The cache key includes the set of @MockitoBeans, the @Imports, the properties, and the controllers named in the annotation; forty classes with forty different mock sets are forty keys. Make the shape uniform: one abstract base @WebMvcTest (no controller filter, or all controllers) declaring the mocks every test needs, subclasses adding none; keep properties overrides out of individual classes; then one context serves all forty and the suite takes the time of one start plus the tests.
Misconceptions
- "Package-private beans do not work with Spring." Spring instantiates reflectively and ignores visibility; package-private is the cheapest module boundary Java has.
- "
@WebMvcTestis a fast@SpringBootTest." It is a different context: the web layer only, services filtered out, mocks required. That is the feature. - "Open-in-view is a performance optimisation." It holds a connection for the whole request and turns lazy access into N+1 queries during serialisation. It hides the missing DTO; off is the fix.
- "Returning the entity is fine if I add
@JsonIgnorewhere needed." The annotation is the symptom: the API is coupled to the schema, and every new field is public by default. - "Feature packages mean no shared code." Shared infrastructure (a
RestClientconfig, an error advice, aClockbean) lives in acommonorplatformpackage; what does not cross is domain internals.
Going deeper
- Spring Boot reference, "Testing → Auto-configured Tests" for every slice and what each imports;
TypeExcludeFilterandWebMvcTypeExcludeFiltersource. - Spring Framework reference, "Context Caching" in the TestContext framework.
- Spring Modulith reference, "Fundamentals" and "Verifying Application Module Structure".
- Vlad Mihalcea, "The Open Session In View anti-pattern", and Boot's
JpaWebConfigurationsource. - Oliver Drotbohm, "Architecturally evident Java applications" (talk), on package-by-feature and the events between features.