Packages should explain the system before a developer opens a class
A good structure makes business capabilities, dependency direction, and integration boundaries visible without requiring a separate framework inside the framework.
Spring Boot does not require one project layout, which is useful until a growing application accumulates controllers, services, repositories, configuration, clients, and data types in broad technical folders. The code still compiles, but each change requires navigating the entire application.
A stronger structure follows the application’s capabilities while preserving a small number of clear responsibilities inside each capability.
Put the application class in a root package
The class annotated with @SpringBootApplication should live in a named root
package above the rest of the application. Spring Boot uses that location as a
base search package for component scanning and several auto-configuration
features.
com.alconite.orders
├── OrdersApplication.java
├── customer
├── fulfillment
├── ordering
└── shared
Avoid Java’s default package. It gives Spring an excessively broad search area and makes package ownership unclear. A reverse-domain package name provides a stable application namespace.
package com.alconite.orders;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class OrdersApplication {
public static void main(String[] args) {
SpringApplication.run(OrdersApplication.class, args);
}
}
The main class should remain boring. Business workflows, client configuration,
and startup side effects belong in focused types rather than in main.
Organize top-level packages by capability
A package-by-layer layout looks tidy when an application is small:
controller/
service/
repository/
model/
As the application grows, those packages become catalogs of unrelated types. Changing ordering behavior requires moving among four large folders, and package visibility cannot protect one capability from another.
Feature-oriented packages keep related work together:
com.alconite.orders
├── ordering
│ ├── Order.java
│ ├── OrderController.java
│ ├── OrderRepository.java
│ ├── OrderRequest.java
│ ├── OrderResponse.java
│ └── OrderService.java
├── fulfillment
│ ├── Fulfillment.java
│ ├── FulfillmentController.java
│ ├── FulfillmentRepository.java
│ └── FulfillmentService.java
└── customer
├── Customer.java
├── CustomerRepository.java
└── CustomerService.java
This structure is a good default for straightforward CRUD and workflow-oriented applications. It follows Spring Boot’s own feature-package example and avoids adding architectural ceremony before the domain needs it.
Add internal layers only where they clarify a real boundary
A complex capability may benefit from internal packages that separate HTTP, application workflows, domain rules, persistence, and external providers:
ordering/
├── web/
│ ├── OrderController.java
│ ├── CreateOrderRequest.java
│ └── OrderResponse.java
├── application/
│ ├── CreateOrder.java
│ └── OrderService.java
├── domain/
│ ├── Order.java
│ ├── OrderLine.java
│ └── OrderPolicy.java
└── infrastructure/
├── JdbcOrderRepository.java
└── PaymentClient.java
Do not repeat this hierarchy mechanically for every feature. A package that contains one class and no meaningful boundary adds navigation without adding clarity. Start cohesive and introduce subpackages when the capability earns them.
Keep controllers focused on HTTP
Controllers should translate the transport contract into an application call: deserialize input, validate it, enforce the HTTP security boundary, and map the result to a response.
@RestController
@RequestMapping("/api/orders")
final class OrderController {
private final OrderService orders;
OrderController(OrderService orders) {
this.orders = orders;
}
@PostMapping
ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) {
Order order = orders.create(request.customerId(), request.lines());
return ResponseEntity
.created(URI.create("/api/orders/" + order.id()))
.body(OrderResponse.from(order));
}
}
Transactions, provider orchestration, retry decisions, and business rules do not belong in this class. Keeping the controller thin makes the application workflow testable without HTTP and the HTTP mapping testable without the full system.
Keep application workflows explicit
An application service coordinates a use case. It loads state, invokes domain behavior, persists results, and calls external boundaries where appropriate.
@Service
final class OrderService {
private final OrderRepository orders;
OrderService(OrderRepository orders) {
this.orders = orders;
}
@Transactional
Order create(CustomerId customerId, List<RequestedLine> lines) {
return orders.save(Order.create(customerId, lines));
}
}
Transaction boundaries generally belong around application operations rather than controllers. Take care not to hold a database transaction open across a slow network call unless the consistency design explicitly requires it.
Treat external systems as package boundaries
Provider details should stay behind focused integration packages or capability infrastructure. The integration owns authentication, request mapping, timeout behavior, response validation, and provider-specific error translation.
Application services should receive application-owned results rather than SDK types. That keeps vendor changes from spreading through controllers and domain logic.
Shared configuration deserves the same discipline:
configuration/
├── JacksonConfiguration.java
├── SecurityConfiguration.java
└── WebConfiguration.java
Do not turn configuration or shared into a place for anything that lacks an
obvious home. A shared type should have multiple real consumers and stable
meaning.
Mirror the structure in tests
Tests are easier to find when src/test/java mirrors production packages:
src/test/java/com/alconite/orders
├── ordering
│ ├── OrderControllerTest.java
│ ├── OrderServiceTest.java
│ └── JdbcOrderRepositoryTest.java
└── fulfillment
└── FulfillmentServiceTest.java
Use the narrowest test that proves the behavior: plain unit tests for domain and application logic, MVC slices for controllers, persistence slices for mappings, client-focused tests for integrations, and full application tests only when the complete context matters.
Enforce important boundaries
Package structure communicates intent, but Java allows public types to be used from anywhere. Prefer package-private visibility for implementation types that do not form part of another capability’s contract.
If the application becomes large enough that boundaries are repeatedly broken, architecture tests or Spring Modulith can make those rules executable. That is most useful after the desired modules and their allowed dependencies are clear. It should not be the first step in organizing a small codebase.
Closing view
A typical Spring Boot project does not need separate modules, ports and adapters for every class, or a directory tree copied from a reference architecture. It needs a root package, cohesive business capabilities, narrow transport and integration boundaries, and tests that follow the production design.
Start with package by feature. Add internal layering when complexity makes the boundary valuable. The resulting structure can grow from a small service into a modular application without forcing the team to redesign the whole repository at each stage.