Spring Boot Architecture

·
spring-bootarchitecturelayering

Every Spring Boot project I've inherited has the same problem: somebody threw @Autowired on everything, put business logic in the controllers, and called it a day. Here's how I actually structure Spring Boot 3 applications — and why.

The 3-Layer Architecture

This is the one I reach for 90% of the time. It's boring, it's predictable, and that's exactly why it works.

src/main/java/
└── com/example/demo/
    ├── controller/        # HTTP Layer (REST APIs)
    │   └── UserController.java
    ├── service/           # Business Logic Layer
    │   └── UserService.java
    ├── repository/        # Data Access Layer (JPA/JDBC)
    │   └── UserRepository.java
    ├── dto/               # Data Transfer Objects
    │   ├── UserRequest.java
    │   └── UserResponse.java
    └── config/            # Configuration & Beans
        └── AppConfig.java

Controller Layer

Keep it thin. The controller's job is to receive an HTTP request, validate it, pass it to the service, and return the response. That's it. The moment you see if statements with business logic in a controller, something's wrong.

  • Annotations: @RestController, @RequestMapping, @GetMapping, @PostMapping
  • Depends on: Service layer only
  • Never: Call repository directly, contain business logic, know about persistence details

Service Layer

This is where the thinking happens. Transaction boundaries, business rules, orchestration between multiple repositories — all here. I mark classes with @Service and use @Transactional at this level.

One rule I'm strict about: the service layer should never know anything about HTTP. No HttpServletRequest, no @RequestBody, no status codes. If you can't unit test your service layer without starting a web server, you've coupled things that shouldn't be coupled.

Repository Layer

Spring Data JPA makes this almost trivial. Extend JpaRepository, get CRUD for free, add custom queries as needed. The main thing I watch for is N+1 queries — use @EntityGraph or explicit JOIN FETCH when you know you'll need related entities.

DTO Layer

Separate request DTOs from response DTOs. Always. Your CreateUserRequest shouldn't expose your internal User entity's ID field. Your UserResponse shouldn't leak database columns the client doesn't need.

The 4-Layer Architecture

For larger applications or when doing DDD, I add a dedicated Domain layer that has zero dependencies on Spring or JPA. The domain objects are pure POJOs with business behavior.

The Domain layer is the hardest to get right, but it pays off on complex projects. When your Order entity knows how to calculate its own total, apply discounts, and validate state transitions — without importing a single Spring annotation — you have a domain model that's easy to test and easy to reason about.

Common Pitfalls I Keep Seeing

Calling repositories from controllers. Just don't. Even if it's "just a simple GET." The moment you skip the service layer, you lose the place where you'd add caching, authorization checks, or audit logging later.

Business logic in @Entity classes. JPA entities are persistence objects. They're not the right place for complex business rules because they're tightly coupled to your database schema. Use domain objects or service methods.

Circular dependencies. Spring will actually start up with circular @Autowired dependencies (in some cases), which makes it even more dangerous — the code "works" but the design is broken. If Service A needs Service B and Service B needs Service A, you need a mediator or event-based communication.

@Transactional on the wrong layer. Put it on the service layer. Not the controller (too early), not the repository (too late — you lose the ability to coordinate multiple repository calls in one transaction).

Example Implementation

// Repository — let Spring Data do the heavy lifting
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}

// Service — business logic lives here
@Service
@Transactional
public class UserService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User createUser(UserRequest request) {
        // Business rule: email must be unique
        userRepository.findByEmail(request.getEmail())
            .ifPresent(existing -> {
                throw new DuplicateEmailException(request.getEmail());
            });

        User user = new User();
        user.setName(request.getName());
        user.setEmail(request.getEmail());
        return userRepository.save(user);
    }
}

// Controller — thin HTTP adapter
@RestController
@RequestMapping("/users")
public class UserController {
    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping
    public ResponseEntity<UserResponse> createUser(
            @Valid @RequestBody UserRequest request) {
        User createdUser = userService.createUser(request);
        return ResponseEntity
            .status(HttpStatus.CREATED)
            .body(new UserResponse(createdUser.getId(), createdUser.getName()));
    }
}

Notice how the controller doesn't know about UserRepository. The service doesn't know about HTTP status codes. Each layer has one job and does it well.

How to cite
Pokhrel, N. (2026). "Spring Boot Architecture". Native Agents. https://nativeagents.dev/agent-skills/spring-boot/spring-boot-architecture