How I Structure Every Spring Boot Application as a Senior Developer
Six months ago I reviewed a Spring Boot codebase that had been running in production for two years. 40,000 lines of code. Eight developers. And every single package looked like this:
com.company.app
├── controller/
├── service/
├── repository/
└── model/
The team knew it was wrong. They'd talked about refactoring it "eventually." But after two years of features piled on top of each other inside those four folders, nobody dared touch it.
That's what bad project structure does. It doesn't break you on day one — it breaks you slowly, until the codebase becomes something you maintain instead of something you build.
This is the structure I use instead. And the reasoning behind every decision.
The Problem With Package by Layer
Package by layer is what every tutorial shows you. It's also the first thing you should move away from once your project grows past three endpoints.
The issue isn't the folders themselves — it's what they represent. Your packages describe the technology stack. They say nothing about what the system actually does. If I want to understand the User feature, I open four packages. If I want to delete the Order module, I hunt through every layer to find its pieces.
Package by feature is better. Everything about Users lives in one place. But it still mixes things that shouldn't be mixed — your JPA entity sits next to your domain object, your @Repository annotation lives in the same package as your business logic. When you want to swap JPA for JDBC, you're touching code you shouldn't have to touch.
The approach I settled on makes the dependency rule explicit in the folder structure itself.
The Structure
com.company.app
├── user/
│ ├── api/ ← HTTP: controllers, request/response DTOs
│ ├── domain/ ← business logic: entities, services, repository interfaces
│ └── infrastructure/ ← adapters: JPA, REST clients, mappers
├── order/
│ ├── api/
│ ├── domain/
│ └── infrastructure/
└── shared/ ← cross-cutting: exceptions, config
Three sub-packages inside each feature. One rule that governs all of them:
domain/ never imports from api/ or infrastructure/. Ever.
Your domain package has zero Spring annotations. No @Component, no @Autowired, no @Entity. It defines interfaces — what it needs — and infrastructure implements them. The domain is pure Java.
Why does this matter? Because when you want to swap your persistence layer, add a message queue, or test your business logic without booting Spring, you can. The domain doesn't know what surrounds it.
What This Looks Like in Code
The UserRepository in your domain package is an interface:
// user/domain/UserRepository.java
public interface UserRepository {
Optional<User> findById(UserId id);
User save(User user);
}
No JPA. No Spring. This is a port — it describes what the domain needs in its own language.
The JPA implementation lives in infrastructure and implements that interface:
// user/infrastructure/UserRepositoryAdapter.java
@Repository
public class UserRepositoryAdapter implements UserRepository {
private final UserJpaRepository jpaRepository;
private final UserMapper mapper;
@Override
public Optional<User> findById(UserId id) {
return jpaRepository.findById(id.value())
.map(mapper::toDomain);
}
}
And your domain service test needs no Spring context at all:
class UserServiceTest {
UserRepository repository = mock(UserRepository.class);
UserService service = new UserService(repository);
@Test
void shouldReturnUser() {
var user = new User(new UserId(UUID.randomUUID()), new Email("jan@example.com"));
when(repository.findById(user.id())).thenReturn(Optional.of(user));
assertThat(service.getUser(user.id()).email()).isEqualTo(user.email());
}
}
No @SpringBootTest. No context loading. That test runs in milliseconds and tests exactly one thing.
Adding a Second Domain
When you add order/ next to user/, the same pattern repeats. What doesn't repeat — and shouldn't — is any import between the two.
// ✗ Wrong
// order/domain/OrderService.java
import com.company.app.user.domain.User; // direct cross-domain import
If you write this, you've coupled two independent features. Changing User can now break Order. You've recreated the tangled mess you were trying to escape — just with feature folders instead of layer folders.
The correct approach: Order doesn't need a User. It needs a UserId.
// shared/UserId.java
public record UserId(UUID value) {}
// order/domain/Order.java
public record Order(
OrderId id,
UserId customerId, // ← just an ID, not the whole User
Money total
) {}
Domains communicate through identifiers and events. Never through direct object imports. This is the rule that makes your features independently deployable if you ever need to split them into microservices.
Visibility as Architecture Enforcement
Most developers treat public/private as a style choice. I treat them as architecture.
// user/domain/UserService.java
public class UserService {
// public — callable from api/ and infrastructure/
public User getUser(UserId id) { ... }
// package-private — stays inside domain/, no keyword needed
void validateBusinessRules(User user) { ... }
// private — implementation detail of this class
private void applyAudit(User user) { ... }
}
That middle modifier — package-private, no keyword — is the most underused tool in Java. If a class or method in domain/ is package-private, nothing in api/ or infrastructure/ can import it. The compiler enforces your architecture for free. You don't need ArchUnit. You don't need custom rules. Just don't write public when you don't mean it.
My default: make everything package-private first. Promote to public only when something genuinely needs to cross a package boundary.
Java 21 Makes This Cleaner
Three features from modern Java that fit this structure well.
Records for domain objects and DTOs. Immutable by default, one line:
// domain
public record User(UserId id, Email email) {}
// api
public record UserResponse(String id, String email) {}
Sealed interfaces for domain states. The compiler knows every possible state:
public sealed interface OrderStatus
permits Pending, Confirmed, Cancelled {}
record Pending() implements OrderStatus {}
record Confirmed(Instant at) implements OrderStatus {}
record Cancelled(String reason) implements OrderStatus {}
Pattern matching switch. No default branch needed — if you add a new state and forget to handle it somewhere, it's a compile error:
String label = switch (status) {
case Pending p -> "Waiting for confirmation";
case Confirmed c -> "Confirmed at " + c.at();
case Cancelled x -> "Cancelled: " + x.reason();
};
That last one is a big deal in practice. An enum with a default: throw new IllegalStateException(...) silently passes compilation. A sealed interface with pattern matching doesn't compile until every state is handled.
One Note on DDD
This is not full Domain-Driven Design. In DDD you'd have a fourth layer — application/ — sitting between api/ and domain/, for Use Cases and Command Handlers. Your domain model would use proper Aggregate Roots and Value Objects throughout.
What I've shown here is a lighter-weight approach that gives you 80% of the benefit without the full ceremony. It's also a solid foundation to migrate toward proper DDD if your project grows in that direction. We'll cover that properly in a dedicated episode.
When to Go Multi-Module
Not as soon as you think.
A single Maven module with good package discipline works for most projects. Go multi-module when you have a specific reason: different teams owning different domains with separate release cycles, or you want compile-time enforcement of boundaries (the domain module literally can't have Spring on its classpath).
Start with one module. Apply the folder structure. Extract modules when you have a concrete reason — not because you read somewhere that it's more "enterprise."
Test Structure
Mirror your main structure in tests, one class per production class:
src/
├── main/java/.../user/
│ ├── domain/UserService.java
│ └── infrastructure/UserRepositoryAdapter.java
└── test/java/.../user/
├── domain/UserServiceTest.java ← plain JUnit, no Spring
└── infrastructure/UserRepositoryIT.java ← @DataJpaTest
Domain tests: zero Spring context, run in milliseconds.
Infrastructure tests: @DataJpaTest or Testcontainers, test only the adapter.
API tests: @WebMvcTest, test only the controller slice.
Never load the full application context for a unit test. That's the number one cause of slow test suites — and a topic worth its own article.
The Short Version
If I had to summarise it in a few lines:
- Package by feature as the outer layer
-
api//domain//infrastructure/inside each feature -
domain/has zero Spring annotations, ever - Cross-domain communication through IDs, not object imports
- Make things package-private by default, promote to public deliberately
- Use Java 21 records and sealed interfaces — they fit this model naturally
More structure upfront? Yes, about 20 minutes. Saves hours later when the codebase actually grows.
Video walkthrough with full source code: YouTube link
Source code: GitLab link
Next up: Spring Boot @Transactional — five bugs that are probably in your production code right now.
Top comments (0)