Whether you are onboarding into a new backend engineering role, transitioning into the Java ecosystem, or solidifying your core fundamentals, mastering the foundational layer of modern Java and Spring Boot is essential before tackling distributed systems, caching, or microservices.
This comprehensive guide covers Phase 0 of a structured backend engineering curriculum. We will break down the core theory, architectural conventions, and practical implementation details required to take a request from an HTTP client, validate it, process business logic, and persist it into a relational database.
Optional, and Exception Handling.Before diving into Spring Boot magic, a strong grasp of modern Java (Java 17/21+) is critical. Modern Java reduces boilerplate significantly while improving type safety and readability.
At the heart of enterprise Java lies four foundational pillars:
private fields and exposing controlled access via methods.interface or abstract class. (In Spring, we frequently define a Service interface or Repository interface to decouple contract from implementation).extends), such as a BaseEntity holding createdAt and updatedAt timestamps.EmailNotificationService and SmsNotificationService both implement NotificationService).Generics provide compile-time type safety, allowing classes and methods to operate on objects of various types while preventing ClassCastException at runtime.
// Generic API wrapper class that can hold any payload type T
public class ApiResponse<T> {
private final boolean success;
private final String message;
private final T data;
public ApiResponse(boolean success, String message, T data) {
this.success = success;
this.message = message;
this.data = data;
}
public boolean isSuccess() { return success; }
public String getMessage() { return message; }
public T getData() { return data; }
}
Core Collections Overview:
List<E> (ArrayList, LinkedList): Ordered collection that allows duplicates. Use ArrayList for fast random access ($O(1)$) and iteration.Set<E> (HashSet, LinkedHashSet, TreeSet): Collection that forbids duplicate elements. Rely on equals() and hashCode() contracts.Map<K, V> (HashMap, ConcurrentHashMap): Key-value pairs with unique keys. HashMap offers $O(1)$ average lookup time.record (Immutable Data Carriers)Introduced in Java 16, a record is a concise syntax for creating immutable data-carrier classes—making it the ideal choice for DTOs (Data Transfer Objects). It automatically generates the constructor, getters (accessor methods without the get prefix), equals(), hashCode(), and toString().
package com.example.banking.dto;
import java.math.BigDecimal;
// Automatically immutable with private final fields, constructor, equals, hashCode, and toString!
public record AccountSummaryDto(
Long id,
String accountNumber,
String ownerName,
BigDecimal balance
) {}
Lambda expressions enable functional programming by letting you pass behavior as data (implementing a @FunctionalInterface with a single abstract method). The Stream API uses lambdas to process sequences of elements declaratively.
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class StreamExamples {
public record Transaction(Long id, String type, BigDecimal amount, boolean active) {}
public static void main(String[] args) {
List<Transaction> transactions = List.of(
new Transaction(1L, "CREDIT", new BigDecimal("1500.00"), true),
new Transaction(2L, "DEBIT", new BigDecimal("200.00"), true),
new Transaction(3L, "CREDIT", new BigDecimal("500.00"), false),
new Transaction(4L, "CREDIT", new BigDecimal("3200.00"), true)
);
// 1. Filter active CREDIT transactions, sort by amount descending, and collect IDs
List<Long> highValueCreditIds = transactions.stream()
.filter(Transaction::active)
.filter(tx -> "CREDIT".equals(tx.type()))
.sorted((t1, t2) -> t2.amount().compareTo(t1.amount()))
.map(Transaction::id)
.toList(); // Java 16+ unmodifiable list
// 2. Group active transactions by type and sum their amounts
Map<String, BigDecimal> totalByType = transactions.stream()
.filter(Transaction::active)
.collect(Collectors.groupingBy(
Transaction::type,
Collectors.reducing(BigDecimal.ZERO, Transaction::amount, BigDecimal::add)
));
System.out.println("High Value Credit IDs: " + highValueCreditIds);
System.out.println("Totals by Type: " + totalByType);
}
}
Optional<T>Optional<T> is a container object that may or may not contain a non-null value. It forces callers to explicitly handle the absence of a value instead of risking a NullPointerException (NPE).
Best Practice: Never use
Optional.get()without checking, and avoid passingOptionalas a method parameter or entity field. Use it primarily as a return type.
// Anti-pattern:
Account acc = accountRepository.findById(id).get(); // Throws NoSuchElementException if missing!
// Best Practice 1: Throw a domain-specific exception if absent
Account account = accountRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Account not found with ID: " + id));
// Best Practice 2: Transform value with map() and provide a fallback
String ownerName = accountRepository.findById(id)
.map(Account::getOwnerName)
.orElse("Unknown Owner");
Java separates exceptions into two primary categories:
extends Exception): Compiler forces you to handle or declare them (e.g., IOException, SQLException).extends RuntimeException): Indicate programming bugs or domain rule violations (e.g., IllegalArgumentException, custom ResourceNotFoundException). In Spring Boot, we predominantly use unchecked exceptions because Spring's transaction manager automatically rolls back database transactions on unchecked exceptions, and global exception handlers can catch them cleanly without polluting method signatures with throws.A backend application is only as reliable as its data layer. Spring Data JPA abstracts boilerplate JDBC code using Hibernate (an Object-Relational Mapping, or ORM, framework) while still allowing full control over SQL when needed.
Before mapping objects, understand the underlying SQL operations corresponding to CRUD:
INSERT INTO accounts (account_number, owner_name, balance) VALUES ('ACC-1001', 'Alice', 5000.00);SELECT id, account_number, owner_name, balance FROM accounts WHERE account_number = 'ACC-1001';UPDATE accounts SET balance = 4500.00 WHERE id = 1;DELETE FROM accounts WHERE id = 1;An Entity is a lightweight persistence domain object that maps directly to a table in your relational database.
package com.example.banking.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "accounts", uniqueConstraints = {
@UniqueConstraint(name = "uk_accounts_number", columnNames = "account_number")
})
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "account_number", nullable = false, length = 30)
private String accountNumber;
@Column(name = "owner_name", nullable = false, length = 100)
private String ownerName;
@Column(name = "email", nullable = false, length = 150)
private String email;
@Column(name = "balance", nullable = false, precision = 19, scale = 4)
private BigDecimal balance;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
// JPA requires a no-arg constructor (can be protected or public)
protected Account() {}
public Account(String accountNumber, String ownerName, String email, BigDecimal balance) {
this.accountNumber = accountNumber;
this.ownerName = ownerName;
this.email = email;
this.balance = balance;
}
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}
// Standard Getters and Setters
public Long getId() { return id; }
public String getAccountNumber() { return accountNumber; }
public void setAccountNumber(String accountNumber) { this.accountNumber = accountNumber; }
public String getOwnerName() { return ownerName; }
public void setOwnerName(String ownerName) { this.ownerName = ownerName; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public BigDecimal getBalance() { return balance; }
public void setBalance(BigDecimal balance) { this.balance = balance; }
public LocalDateTime getCreatedAt() { return createdAt; }
}
By extending JpaRepository<Entity, ID>, Spring automatically generates the implementation for standard CRUD operations, pagination, and sorting at startup.
package com.example.banking.repository;
import com.example.banking.entity.Account;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
// Derived Query Method: Spring translates this method name into:
// SELECT * FROM accounts WHERE account_number = ?
Optional<Account> findByAccountNumber(String accountNumber);
// Checks existence: SELECT count(*) > 0 FROM accounts WHERE account_number = ?
boolean existsByAccountNumber(String accountNumber);
}
Spring Boot simplifies enterprise Java development by removing tedious XML configurations and providing opinionated defaults.
new AccountRepository(), the Spring IoC Container (ApplicationContext) manages object creation and lifecycles.@Component, @Service, @Repository, @RestController) or @Bean methods inside a @Configuration class.@Autowired directly on fields). Constructor injection guarantees required dependencies are non-null at instantiation, allows final fields (immutability), and makes unit testing trivial without booting up Spring.Triggered by @SpringBootApplication (which combines @SpringBootConfiguration, @EnableAutoConfiguration, and @ComponentScan), Spring Boot inspects your pom.xml or build.gradle classpath.
spring-boot-starter-web, it automatically configures an embedded Apache Tomcat server and DispatcherServlet.spring-boot-starter-data-jpa and a PostgreSQL driver, it automatically configures a DataSource, Hibernate EntityManagerFactory, and PlatformTransactionManager.Enterprise applications run in multiple environments (local, dev, staging, prod). Spring Profiles let you segregate configuration properties so you never hardcode credentials or environment-specific URLs in code.
Base Configuration (src/main/resources/application.yml):
spring:
application:
name: core-banking-api
profiles:
active: local # Default active profile if none is specified via CLI/ENV
jpa:
open-in-view: false # Best practice: disable OSIV to prevent hidden lazy-loading queries in controllers
properties:
hibernate:
format_sql: true
server:
port: 8080
Local Profile (src/main/resources/application-local.yml):
spring:
datasource:
url: jdbc:postgresql://localhost:5432/banking_local
username: postgres
password: secretpassword
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: update # Automatically updates schema in local dev
show-sql: true
Production Profile (src/main/resources/application-prod.yml):
spring:
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate # Never auto-modify schema in prod!
show-sql: false
A clean REST API separates external API contracts (DTOs) from internal database models (Entities), uses proper HTTP semantics, validates incoming payloads, and returns structured error responses.
| HTTP Method | CRUD Operation | Idempotent? | Typical Success Status Code |
|---|---|---|---|
POST | Create a new resource | No | 201 Created |
GET | Read a single resource or collection | Yes | 200 OK (or 404 Not Found) |
PUT | Replace an entire resource | Yes | 200 OK |
PATCH | Partially update a resource | No* | 200 OK |
DELETE | Remove a resource | Yes | 204 No Content |
Never expose your JPA @Entity classes directly in your @RestController:
Account entity in a POST request, a malicious user could pass "id": 1 or manipulate internal audit fields.LazyInitializationException or infinite recursion when Jackson serializes bidirectional entity relationships.By adding spring-boot-starter-validation, we can annotate our incoming request DTOs with declarative constraints (@NotBlank, @Email, @PositiveOrZero, @Pattern) and trigger validation in the controller using @Valid.
package com.example.banking.dto;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
public record CreateAccountRequest(
@NotBlank(message = "Account number is required")
@Pattern(regexp = "^ACC-\\d{4,10}$", message = "Account number must follow format ACC-XXXX")
String accountNumber,
@NotBlank(message = "Owner name is required")
@Size(min = 2, max = 100, message = "Owner name must be between 2 and 100 characters")
String ownerName,
@NotBlank(message = "Email is required")
@Email(message = "Must provide a valid email address")
String email,
@NotNull(message = "Initial balance is required")
@PositiveOrZero(message = "Initial balance cannot be negative")
BigDecimal initialBalance
) {}
package com.example.banking.dto;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
public record UpdateAccountRequest(
@NotBlank(message = "Owner name is required")
@Size(min = 2, max = 100, message = "Owner name must be between 2 and 100 characters")
String ownerName,
@NotBlank(message = "Email is required")
@Email(message = "Must provide a valid email address")
String email,
@NotNull(message = "Balance is required")
@PositiveOrZero(message = "Balance cannot be negative")
BigDecimal balance
) {}
package com.example.banking.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public record AccountResponse(
Long id,
String accountNumber,
String ownerName,
String email,
BigDecimal balance,
LocalDateTime createdAt
) {}
The Service Layer (@Service) holds all business rules, coordinates transactions (@Transactional), maps DTOs to Entities, and keeps the Controller thin.
package com.example.banking.service;
import com.example.banking.dto.*;
import com.example.banking.entity.Account;
import com.example.banking.exception.DuplicateResourceException;
import com.example.banking.exception.ResourceNotFoundException;
import com.example.banking.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class AccountService {
private final AccountRepository accountRepository;
// Constructor Injection (No @Autowired annotation needed for single constructor)
public AccountService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
@Transactional
public AccountResponse createAccount(CreateAccountRequest request) {
if (accountRepository.existsByAccountNumber(request.accountNumber())) {
throw new DuplicateResourceException(
"Account already exists with number: " + request.accountNumber()
);
}
Account account = new Account(
request.accountNumber(),
request.ownerName(),
request.email(),
request.initialBalance()
);
Account savedAccount = accountRepository.save(account);
return mapToResponse(savedAccount);
}
@Transactional(readOnly = true)
public AccountResponse getAccountById(Long id) {
Account account = accountRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Account not found with ID: " + id));
return mapToResponse(account);
}
@Transactional(readOnly = true)
public List<AccountResponse> getAllAccounts() {
return accountRepository.findAll()
.stream()
.map(this::mapToResponse)
.toList();
}
@Transactional
public AccountResponse updateAccount(Long id, UpdateAccountRequest request) {
Account account = accountRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Account not found with ID: " + id));
account.setOwnerName(request.ownerName());
account.setEmail(request.email());
account.setBalance(request.balance());
// Dirty checking in @Transactional automatically persists changes at commit,
// but calling save() explicitly is also clear and common.
Account updated = accountRepository.save(account);
return mapToResponse(updated);
}
@Transactional
public void deleteAccount(Long id) {
if (!accountRepository.existsById(id)) {
throw new ResourceNotFoundException("Account not found with ID: " + id);
}
accountRepository.deleteById(id);
}
private AccountResponse mapToResponse(Account account) {
return new AccountResponse(
account.getId(),
account.getAccountNumber(),
account.getOwnerName(),
account.getEmail(),
account.getBalance(),
account.getCreatedAt()
);
}
}
The Controller (@RestController) handles HTTP routing, deserializes JSON payloads, triggers @Valid checks, delegates work to AccountService, and wraps responses in ResponseEntity<T>.
package com.example.banking.controller;
import com.example.banking.dto.*;
import com.example.banking.service.AccountService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/accounts")
public class AccountController {
private final AccountService accountService;
public AccountController(AccountService accountService) {
this.accountService = accountService;
}
@PostMapping
public ResponseEntity<AccountResponse> createAccount(
@Valid @RequestBody CreateAccountRequest request) {
AccountResponse created = accountService.createAccount(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@GetMapping("/{id}")
public ResponseEntity<AccountResponse> getAccountById(@PathVariable Long id) {
return ResponseEntity.ok(accountService.getAccountById(id));
}
@GetMapping
public ResponseEntity<List<AccountResponse>> getAllAccounts() {
return ResponseEntity.ok(accountService.getAllAccounts());
}
@PutMapping("/{id}")
public ResponseEntity<AccountResponse> updateAccount(
@PathVariable Long id,
@Valid @RequestBody UpdateAccountRequest request) {
return ResponseEntity.ok(accountService.updateAccount(id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteAccount(@PathVariable Long id) {
accountService.deleteAccount(id);
return ResponseEntity.noContent().build();
}
}
@RestControllerAdvice)Instead of returning default stack traces when an exception occurs or validation fails, use @RestControllerAdvice to return clean, predictable JSON error responses.
package com.example.banking.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
public record ErrorResponse(
int status,
String error,
String message,
LocalDateTime timestamp,
Map<String, String> validationErrors
) {}
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
"Not Found",
ex.getMessage(),
LocalDateTime.now(),
null
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(DuplicateResourceException.class)
public ResponseEntity<ErrorResponse> handleConflict(DuplicateResourceException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.CONFLICT.value(),
"Conflict",
ex.getMessage(),
LocalDateTime.now(),
null
);
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = new HashMap<>();
for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
fieldErrors.put(fieldError.getField(), fieldError.getDefaultMessage());
}
ErrorResponse error = new ErrorResponse(
HttpStatus.BAD_REQUEST.value(),
"Validation Failed",
"Request payload contains invalid fields",
LocalDateTime.now(),
fieldErrors
);
return ResponseEntity.badRequest().body(error);
}
}
Writing code is only half the equation; professional software engineers rely on build tools, version control, convention-driven project structures, and interactive debugging.
Keep your packages modular and organized by responsibility:
src/main/java/com/example/banking
├── BankingApplication.java # Main entry point (@SpringBootApplication)
├── controller/
│ └── AccountController.java # REST Endpoints (@RestController)
├── service/
│ └── AccountService.java # Business Logic & Transactions (@Service)
├── repository/
│ └── AccountRepository.java # Data Access Interface (@Repository)
├── entity/
│ └── Account.java # JPA Database Table Mapping (@Entity)
├── dto/
│ ├── CreateAccountRequest.java # Inbound Payload + Validation
│ ├── UpdateAccountRequest.java # Inbound Update Payload
│ └── AccountResponse.java # Outbound API Contract
└── exception/
├── ResourceNotFoundException.java
├── DuplicateResourceException.java
└── GlobalExceptionHandler.java # Centralized Error Handling
pom.xml)Maven manages project dependencies and the build lifecycle (mvn clean compile, mvn test, mvn package, mvn spring-boot:run).
<dependencies>
<!-- REST API & Embedded Tomcat -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Data JPA & Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Jakarta Bean Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- PostgreSQL Database Driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Unit & Integration Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Understanding how an HTTP request travels through a Spring Boot application is essential for architecture reviews and debugging:
[ HTTP Client (Postman / Frontend) ]
│
▼ 1. Raw HTTP POST /api/v1/accounts (JSON Body)
[ Embedded Tomcat & DispatcherServlet ]
│
▼ 2. Jackson deserializes JSON -> CreateAccountRequest
│ 3. Hibernate Validator checks @Valid annotations
│ (If invalid -> throws MethodArgumentNotValidException -> GlobalExceptionHandler returns 400)
[ AccountController.createAccount() ]
│
▼ 4. Calls accountService.createAccount(request)
[ Spring AOP Transaction Proxy (@Transactional) ]
│
▼ 5. Opens DB Transaction -> invokes AccountService
[ AccountService.createAccount() ]
│
▼ 6. Checks business rules (existsByAccountNumber)
│ 7. Maps Request DTO -> Account Entity
│ 8. Calls accountRepository.save(account)
[ AccountRepository (Spring Data JPA Proxy / Hibernate) ]
│
▼ 9. Executes SQL INSERT via HikariCP Connection Pool
[ PostgreSQL Database ]
│
▼ 10. Returns generated ID -> Service maps Entity to AccountResponse DTO
▼ 11. Transaction Proxy commits transaction
▼ 12. Controller wraps DTO in ResponseEntity (201 Created) -> Jackson serializes to JSON
Instead of scattering System.out.println() statements across your codebase, use your IDE's interactive debugger (IntelliJ IDEA or Eclipse):
AccountController.createAccount().AccountService.createAccount() right before accountRepository.save(account).GlobalExceptionHandler.handleValidationErrors().Shift + F9 in IntelliJ) rather than Run.POST /api/v1/accounts request using Postman or curl.request variable in the Variables pane to verify Jackson deserialized every field properly.F8): Execute the current line and move to the next line in the same method.F7): Jump inside the method being called on the current line (e.g., stepping from the Controller into accountService.createAccount()).Alt + F8): Open an interactive popup while paused at a breakpoint to test expressions on the fly (e.g., evaluating accountRepository.existsByAccountNumber(request.accountNumber()) live against the database).F9): Continue execution until the next breakpoint or completion of the request.Before moving on to advanced API refinements, complex entity relationships, schema migrations, and automated testing in Phase 1, verify that you can comfortably check off every item below:
Optional without throwing unexpected NullPointerExceptions.application.yml profiles connected to a local relational database.POST, GET, PUT, DELETE) and status codes (201, 200, 204, 400, 404, 409).@Valid.No comments yet. Start the conversation below.