Spring BootSpring Boot Architecture
Spring Boot

Spring Boot Architecture

Spring Boot follows a layered architecture that separates concerns clearly — presentation, business logic, data access, and the database. Each layer has a specific responsibility and communicates only with the layer directly below it. Understanding this architecture is the foundation for building maintainable, testable Spring Boot applications.

The Four-Layer Architecture

Spring Boot applications are typically structured in four layers, each with a clear responsibility: Presentation Layer (Controller) — Handles HTTP requests and responses. Validates input, maps request data to objects, calls the service layer, and returns responses. No business logic here. Business Layer (Service) — Contains all business logic. Orchestrates operations, applies business rules, handles transactions. No knowledge of HTTP or database technology. Persistence Layer (Repository) — Handles all database interaction. Reads and writes data. No business logic. Database Layer — The actual database (MySQL, PostgreSQL, MongoDB, etc.). The application doesn't live here — this is just the storage. Each layer depends only on the layer below it. The controller never directly accesses the repository. The service never parses HTTP requests. This separation makes each layer independently testable and replaceable.

The Presentation Layer — Controllers

Controllers are the entry point for all HTTP traffic. They receive requests, validate input, delegate to services, and return responses. In REST APIs, @RestController combines @Controller and @ResponseBody, automatically serializing return values to JSON.
Java
@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    // Constructor injection — preferred approach:
    public UserController(UserService userService) {
        this.userService = userService;
    }

    // GET /api/users
    @GetMapping
    public List<UserResponse> getAllUsers() {
        return userService.getAllUsers();
    }

    // GET /api/users/1
    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> getUserById(@PathVariable Long id) {
        return userService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    // POST /api/users
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserResponse createUser(@RequestBody @Valid CreateUserRequest request) {
        return userService.createUser(request);
    }

    // PUT /api/users/1
    @PutMapping("/{id}")
    public UserResponse updateUser(@PathVariable Long id,
                                   @RequestBody @Valid UpdateUserRequest request) {
        return userService.updateUser(id, request);
    }

    // DELETE /api/users/1
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
    }
}

The Business Layer — Services

Services contain the application's business logic. They're annotated with @Service and @Transactional, and they coordinate between repositories, apply business rules, and handle cross-cutting concerns.
Java
@Service
@Transactional
public class UserService {

    private final UserRepository userRepository;
    private final EmailService emailService;
    private final PasswordEncoder passwordEncoder;

    public UserService(UserRepository userRepository,
                       EmailService emailService,
                       PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.emailService = emailService;
        this.passwordEncoder = passwordEncoder;
    }

    // Business logic lives here:
    public UserResponse createUser(CreateUserRequest request) {
        // Business rule: email must be unique
        if (userRepository.existsByEmail(request.email())) {
            throw new DuplicateEmailException("Email already registered: " + request.email());
        }

        // Business logic: hash password before saving
        User user = new User(
            request.email(),
            request.name(),
            passwordEncoder.encode(request.password())
        );

        User saved = userRepository.save(user);

        // Orchestrate multiple operations:
        emailService.sendWelcomeEmail(saved.getEmail(), saved.getName());

        return UserResponse.from(saved);
    }

    @Transactional(readOnly = true)
    public Optional<UserResponse> findById(Long id) {
        return userRepository.findById(id).map(UserResponse::from);
    }

    @Transactional(readOnly = true)
    public List<UserResponse> getAllUsers() {
        return userRepository.findAll().stream()
            .map(UserResponse::from)
            .toList();
    }
}

The Persistence Layer — Repositories

Repositories handle all data access. Spring Data JPA generates the implementation automatically from the interface — you declare the queries you need by method name, and Spring writes the SQL.
Java
// JpaRepository provides: save, findById, findAll, delete, count, exists, etc.
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    // Spring Data derives SQL from method names automatically:
    Optional<User> findByEmail(String email);
    boolean existsByEmail(String email);
    List<User> findByActiveTrue();
    List<User> findByNameContainingIgnoreCase(String name);
    List<User> findByCreatedAtAfter(LocalDateTime date);

    // Custom JPQL query:
    @Query("SELECT u FROM User u WHERE u.email = :email AND u.active = true")
    Optional<User> findActiveUserByEmail(@Param("email") String email);

    // Native SQL query:
    @Query(value = "SELECT * FROM users WHERE last_login < :date", nativeQuery = true)
    List<User> findInactiveUsers(@Param("date") LocalDateTime date);

    // Pagination and sorting:
    Page<User> findByActiveTrue(Pageable pageable);
}

// The Entity — maps to the database table:
@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String password;

    private boolean active = true;

    @CreatedDate
    private LocalDateTime createdAt;
}

Data Transfer Objects — The Layer Boundary

DTOs (Data Transfer Objects) prevent Entity classes from leaking out of the persistence layer. The controller receives request DTOs, services work with domain objects, and the controller returns response DTOs. This protects internal data structures from external exposure.
Java
// Request DTO — what the API accepts:
public record CreateUserRequest(
    @NotBlank @Email String email,
    @NotBlank @Size(min = 2, max = 100) String name,
    @NotBlank @Size(min = 8) String password
) { }

// Response DTO — what the API returns:
// Note: password is NOT included — never expose it
public record UserResponse(
    Long id,
    String email,
    String name,
    boolean active,
    LocalDateTime createdAt
) {
    // Factory method to convert from Entity to DTO:
    public static UserResponse from(User user) {
        return new UserResponse(
            user.getId(),
            user.getEmail(),
            user.getName(),
            user.isActive(),
            user.getCreatedAt()
        );
    }
}

// Why DTOs matter:
// 1. Security — control exactly what data is exposed in the API response
// 2. Stability — API contract is independent of database schema
// 3. Validation — request DTOs carry validation annotations
// 4. Evolution — change the entity without breaking the API, or vice versa

// Never return JPA entities directly from controllers:
// - Triggers lazy loading issues
// - Exposes internal fields
// - Ties API contract to database schema

Exception Handling — Centralized with @ControllerAdvice

Spring Boot's @ControllerAdvice provides a single place to handle exceptions across all controllers. Instead of try-catch blocks in every endpoint, exceptions propagate up and are caught here.
Java
@RestControllerAdvice
public class GlobalExceptionHandler {

    // Handle custom business exceptions:
    @ExceptionHandler(DuplicateEmailException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public ErrorResponse handleDuplicateEmail(DuplicateEmailException ex) {
        return new ErrorResponse("DUPLICATE_EMAIL", ex.getMessage());
    }

    // Handle not found:
    @ExceptionHandler(EntityNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleNotFound(EntityNotFoundException ex) {
        return new ErrorResponse("NOT_FOUND", ex.getMessage());
    }

    // Handle validation failures (@Valid annotations):
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleValidation(MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(e -> e.getField() + ": " + e.getDefaultMessage())
            .toList();
        return new ErrorResponse("VALIDATION_FAILED", errors.toString());
    }

    // Catch-all for unexpected exceptions:
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorResponse handleGeneral(Exception ex) {
        log.error("Unexpected error", ex);
        return new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred");
    }
}

public record ErrorResponse(String code, String message) { }

Request Flow — End to End

Here's the complete journey of an HTTP request through a Spring Boot application:
Java
// POST /api/users  with body: {"email":"alice@example.com","name":"Alice","password":"secret123"}

// 1. DispatcherServlet receives the request
//    — Spring's front controller that routes all HTTP traffic

// 2. HandlerMapping finds UserController.createUser()
//    — matches POST /api/users to the @PostMapping method

// 3. Argument resolution
//    — @RequestBody + Jackson deserializes JSON → CreateUserRequest object
//    — @Valid triggers Bean Validation on the request object

// 4. UserController.createUser() executes
//    — validates input (via @Valid)
//    — delegates to userService.createUser(request)

// 5. UserService.createUser() executes
//    — checks business rules (duplicate email check)
//    — creates User entity
//    — calls userRepository.save(user)
//    — calls emailService.sendWelcomeEmail()
//    — returns UserResponse DTO

// 6. UserRepository.save() executes
//    — Hibernate generates INSERT SQL
//    — executes against database
//    — returns saved entity with generated ID

// 7. Controller receives UserResponse from service
//    — @ResponseStatus(CREATED) sets HTTP 201
//    — Jackson serializes UserResponse → JSON

// 8. DispatcherServlet sends HTTP response:
//    HTTP/1.1 201 Created
//    Content-Type: application/json
//    {"id":1,"email":"alice@example.com","name":"Alice","active":true}

// If anything throws an exception:
// → Propagates up to GlobalExceptionHandler (@ControllerAdvice)
// → Returns appropriate error response with correct HTTP status