The Core Problem: In large applications, objects depend on other objects. Without a framework, you end up manually wiring everything together, leading to rigid, hard-to-test code.
Spring solves this through Inversion of Control (IoC) and Dependency Injection (DI):
public class OrderService {
// Tightly coupled - OrderService creates its own dependencies
private OrderRepository repo = new OrderRepositoryImpl();
private PaymentService payment = new StripePaymentService();
private EmailService email = new SmtpEmailService();
// Hard to test - can't swap implementations
// Hard to change - payment provider change = code change
}
@Service
public class OrderService {
// Loosely coupled - dependencies injected
private final OrderRepository repo;
private final PaymentService payment;
private final EmailService email;
public OrderService(OrderRepository repo,
PaymentService payment,
EmailService email) {
this.repo = repo;
this.payment = payment;
this.email = email;
}
}
| Spring Framework | Spring Boot |
|---|---|
| Core framework - IoC container, DI, AOP | Opinionated layer on top of Spring |
| Requires manual configuration | Auto-configuration based on classpath |
| Need to configure web server separately | Embedded Tomcat/Jetty/Undertow |
| XML or Java config for everything | Sensible defaults, minimal config |
| Fine-grained control | Convention over configuration |
For new projects, use Spring Boot. You get Spring Framework underneath with sensible defaults.
A Bean is an object managed by Spring's IoC container. The container handles:
// Component scanning - Spring finds these automatically
@Component // Generic bean
@Service // Business logic layer
@Repository // Data access layer
@Controller // Web controller
@RestController // REST API controller
@Service
public class UserService {
private final UserRepository userRepository;
// Constructor injection - preferred approach
// Spring auto-injects UserRepository bean
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
}
// Explicit Java configuration - useful for third-party classes
@Configuration
public class AppConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplateBuilder()
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(10))
.build();
}
@Bean
@Profile("production") // Only create in production
public PaymentService stripePaymentService() {
return new StripePaymentService(stripeApiKey);
}
@Bean
@Profile("development") // Mock for development
public PaymentService mockPaymentService() {
return new MockPaymentService();
}
}
# Python (FastAPI with dependency injection)
# Similar concept but more manual
from fastapi import Depends
class UserRepository:
def find_by_id(self, id: int) -> User:
...
class UserService:
def __init__(self, repo: UserRepository):
self.repo = repo
def find_by_id(self, id: int) -> User:
return self.repo.find_by_id(id)
# Manual wiring with FastAPI's Depends
def get_user_repository() -> UserRepository:
return UserRepository()
def get_user_service(
repo: UserRepository = Depends(get_user_repository)
) -> UserService:
return UserService(repo)
@app.get("/users/{id}")
def get_user(
id: int,
service: UserService = Depends(get_user_service)
):
return service.find_by_id(id)
# Spring does this automatically via component scanning
# and constructor injection - less boilerplate
// The entry point
@SpringBootApplication // Combines @Configuration, @EnableAutoConfiguration, @ComponentScan
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public List<UserDTO> getAllUsers() {
return userService.findAll();
}
@GetMapping("/{id}")
public ResponseEntity<UserDTO> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserDTO createUser(@Valid @RequestBody CreateUserRequest request) {
return userService.create(request);
}
@PutMapping("/{id}")
public UserDTO updateUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
return userService.update(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteUser(@PathVariable Long id) {
userService.delete(id);
}
// Query parameters
@GetMapping("/search")
public Page<UserDTO> searchUsers(
@RequestParam String query,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return userService.search(query, PageRequest.of(page, size));
}
}
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
app = FastAPI()
@app.get("/api/v1/users")
def get_all_users():
return user_service.find_all()
@app.get("/api/v1/users/{id}")
def get_user(id: int):
user = user_service.find_by_id(id)
if not user:
raise HTTPException(status_code=404)
return user
@app.post("/api/v1/users", status_code=201)
def create_user(request: CreateUserRequest):
return user_service.create(request)
@app.get("/api/v1/users/search")
def search_users(
query: str,
page: int = Query(default=0),
size: int = Query(default=20)
):
return user_service.search(query, page, size)
from flask import Flask, jsonify, request
app = Flask(__name__)
@app.route("/api/v1/users", methods=["GET"])
def get_all_users():
return jsonify(user_service.find_all())
@app.route("/api/v1/users/<int:id>", methods=["GET"])
def get_user(id):
user = user_service.find_by_id(id)
if not user:
return jsonify({"error": "Not found"}), 404
return jsonify(user)
@app.route("/api/v1/users", methods=["POST"])
def create_user():
data = request.get_json()
return jsonify(user_service.create(data)), 201
// Using Jakarta Bean Validation (formerly javax.validation)
public record CreateUserRequest(
@NotBlank(message = "Email is required")
@Email(message = "Must be valid email")
String email,
@NotBlank
@Size(min = 2, max = 50)
String name,
@NotNull
@Min(18)
Integer age
) {}
// Global exception handler for validation errors
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleValidation(MethodArgumentNotValidException ex) {
return ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
FieldError::getDefaultMessage
));
}
}
Spring Data JPA eliminates boilerplate for database access. Define an interface, Spring implements it.
@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;
@Enumerated(EnumType.STRING)
private UserStatus status = UserStatus.ACTIVE;
@CreatedDate
private Instant createdAt;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private List<Order> orders = new ArrayList<>();
// Getters, setters, constructors...
}
// Just an interface - Spring generates the implementation!
public interface UserRepository extends JpaRepository<User, Long> {
// Derived queries - method name becomes SQL
Optional<User> findByEmail(String email);
List<User> findByStatus(UserStatus status);
List<User> findByNameContainingIgnoreCase(String name);
// Custom JPQL query
@Query("SELECT u FROM User u WHERE u.createdAt > :since AND u.status = :status")
List<User> findRecentActiveUsers(
@Param("since") Instant since,
@Param("status") UserStatus status
);
// Native SQL when needed
@Query(value = "SELECT * FROM users WHERE email LIKE %:domain", nativeQuery = true)
List<User> findByEmailDomain(@Param("domain") String domain);
// Pagination built-in
Page<User> findByStatus(UserStatus status, Pageable pageable);
// Modifying queries
@Modifying
@Query("UPDATE User u SET u.status = :status WHERE u.id = :id")
int updateStatus(@Param("id") Long id, @Param("status") UserStatus status);
}
@Service
@Transactional(readOnly = true) // Read-only by default
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public Optional<UserDTO> findById(Long id) {
return userRepository.findById(id)
.map(this::toDTO);
}
@Transactional // Write operation - override read-only
public UserDTO create(CreateUserRequest request) {
if (userRepository.findByEmail(request.email()).isPresent()) {
throw new EmailAlreadyExistsException(request.email());
}
var user = new User();
user.setEmail(request.email());
user.setName(request.name());
return toDTO(userRepository.save(user));
}
public Page<UserDTO> findAll(Pageable pageable) {
return userRepository.findAll(pageable).map(this::toDTO);
}
private UserDTO toDTO(User user) {
return new UserDTO(user.getId(), user.getEmail(), user.getName());
}
}
# Python SQLAlchemy equivalent
from sqlalchemy import Column, Integer, String, Enum
from sqlalchemy.orm import Session
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
email = Column(String, unique=True, nullable=False)
name = Column(String, nullable=False)
status = Column(Enum(UserStatus), default=UserStatus.ACTIVE)
# Manual repository - Spring generates this for you
class UserRepository:
def __init__(self, db: Session):
self.db = db
def find_by_id(self, id: int) -> User | None:
return self.db.query(User).filter(User.id == id).first()
def find_by_email(self, email: str) -> User | None:
return self.db.query(User).filter(User.email == email).first()
def save(self, user: User) -> User:
self.db.add(user)
self.db.commit()
return user
# Every query method must be manually implemented
# Spring Data JPA generates all of this from method names!
findByStatusAndCreatedAtAfter becomes
SELECT * FROM users WHERE status = ? AND created_at > ?.
Know the naming conventions: findBy, countBy, existsBy,
deleteBy, with operators like And, Or, Between,
LessThan, Like, In, OrderBy.
# application.yml - default configuration
spring:
application:
name: my-service
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: ${DB_USERNAME:postgres} # Environment variable with default
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate # Never use 'create' in production!
show-sql: false
server:
port: 8080
logging:
level:
com.example: DEBUG
org.springframework: INFO
# Custom properties
app:
feature-flags:
new-checkout: true
external-service:
url: https://api.example.com
timeout: 5s
# application-prod.yml - production overrides
spring:
datasource:
url: jdbc:postgresql://prod-db:5432/mydb
hikari:
maximum-pool-size: 20
logging:
level:
com.example: INFO
app:
feature-flags:
new-checkout: false # Not ready for prod yet
// Type-safe configuration with @ConfigurationProperties
@ConfigurationProperties(prefix = "app.external-service")
public record ExternalServiceConfig(
String url,
Duration timeout
) {}
// Enable in main class
@SpringBootApplication
@ConfigurationPropertiesScan
public class MyApplication { ... }
// Use it
@Service
public class ExternalApiClient {
private final ExternalServiceConfig config;
public ExternalApiClient(ExternalServiceConfig config) {
this.config = config;
}
public Response call() {
return webClient
.get()
.uri(config.url())
.retrieve()
.bodyToMono(Response.class)
.timeout(config.timeout())
.block();
}
}
@Configuration
public class MessagingConfig {
@Bean
@Profile("production")
public MessageQueue kafkaQueue() {
return new KafkaMessageQueue();
}
@Bean
@Profile("!production") // Any profile except production
public MessageQueue inMemoryQueue() {
return new InMemoryMessageQueue();
}
}
// Activate profile: java -jar app.jar --spring.profiles.active=production
// Or: SPRING_PROFILES_ACTIVE=production
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // Disable for APIs (use tokens instead)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/users/**").hasAnyRole("USER", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/users/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(Customizer.withDefaults())) // JWT validation
.build();
}
@Bean
public JwtDecoder jwtDecoder() {
return JwtDecoders.fromIssuerLocation("https://auth.example.com");
}
}
// Access current user in controller
@GetMapping("/me")
public UserDTO getCurrentUser(@AuthenticationPrincipal Jwt jwt) {
String userId = jwt.getSubject();
return userService.findById(userId);
}
// Method-level security
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.subject")
public UserDTO getUser(String userId) {
return userService.findById(userId);
}
SecurityContextPersistenceFilter,
UsernamePasswordAuthenticationFilter, BearerTokenAuthenticationFilter,
ExceptionTranslationFilter, FilterSecurityInterceptor.
// Pure unit test - no Spring context
class UserServiceTest {
private UserRepository userRepository;
private UserService userService;
@BeforeEach
void setUp() {
userRepository = mock(UserRepository.class);
userService = new UserService(userRepository);
}
@Test
void findById_existingUser_returnsUser() {
// Given
var user = new User(1L, "test@example.com", "Test User");
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
// When
var result = userService.findById(1L);
// Then
assertThat(result).isPresent();
assertThat(result.get().email()).isEqualTo("test@example.com");
}
@Test
void create_duplicateEmail_throwsException() {
// Given
when(userRepository.findByEmail("exists@example.com"))
.thenReturn(Optional.of(new User()));
var request = new CreateUserRequest("exists@example.com", "Name");
// When/Then
assertThatThrownBy(() -> userService.create(request))
.isInstanceOf(EmailAlreadyExistsException.class);
}
}
// Full integration test - loads entire Spring context
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class UserControllerIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Test
void createUser_validRequest_returns201() {
// Given
var request = new CreateUserRequest("new@example.com", "New User");
// When
var response = restTemplate.postForEntity(
"/api/v1/users", request, UserDTO.class);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody().email()).isEqualTo("new@example.com");
assertThat(userRepository.findByEmail("new@example.com")).isPresent();
}
}
// With Testcontainers for real database
@SpringBootTest
@Testcontainers
class UserRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}
// Slice tests - load only relevant parts of context
// Web layer only - no service/repository beans
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean // Spring's mock that integrates with context
private UserService userService;
@Test
void getUser_notFound_returns404() throws Exception {
when(userService.findById(999L)).thenReturn(Optional.empty());
mockMvc.perform(get("/api/v1/users/999"))
.andExpect(status().isNotFound());
}
@Test
void createUser_invalidEmail_returns400() throws Exception {
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email": "not-an-email", "name": "Test"}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.email").exists());
}
}
// JPA layer only
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void findByEmail_existingEmail_returnsUser() {
// Given
entityManager.persist(new User(null, "test@example.com", "Test"));
entityManager.flush();
// When
var result = userRepository.findByEmail("test@example.com");
// Then
assertThat(result).isPresent();
}
}
@SpringBootTest loads the full context (slow).
Use slice tests (@WebMvcTest, @DataJpaTest) when possible - they only load
relevant beans. For unit tests, don't use Spring at all.
| Starter | Purpose |
|---|---|
spring-boot-starter-web |
REST APIs with embedded Tomcat |
spring-boot-starter-data-jpa |
JPA + Hibernate + Spring Data |
spring-boot-starter-security |
Authentication and authorization |
spring-boot-starter-validation |
Bean validation (@Valid, @NotNull, etc.) |
spring-boot-starter-actuator |
Health checks, metrics, monitoring |
spring-boot-starter-cache |
Caching abstraction |
spring-boot-starter-webflux |
Reactive web framework |
spring-boot-starter-test |
JUnit, Mockito, AssertJ, MockMvc |
# application.yml
management:
endpoints:
web:
exposure:
include: health, info, metrics, prometheus
endpoint:
health:
show-details: when_authorized
probes:
enabled: true # Kubernetes readiness/liveness probes
metrics:
tags:
application: ${spring.application.name}
Available endpoints:
/actuator/health - Application health (DB, disk, custom checks)/actuator/info - Application info/actuator/metrics - JVM, HTTP, custom metrics/actuator/prometheus - Prometheus-format metrics// Custom health indicator
@Component
public class ExternalServiceHealthIndicator implements HealthIndicator {
private final ExternalApiClient client;
@Override
public Health health() {
try {
client.ping();
return Health.up()
.withDetail("service", "external-api")
.build();
} catch (Exception e) {
return Health.down()
.withException(e)
.build();
}
}
}
| Concept | Key Points |
|---|---|
| IoC/DI | Container creates and wires objects. Enables testability, loose coupling, configurability. |
| Bean Scopes | singleton (default), prototype, request, session |
| @Transactional | AOP-based. Proxy wraps method, manages commit/rollback. Don't call transactional method from same class! |
| N+1 Problem | Lazy loading causes extra queries. Fix with @EntityGraph or JOIN FETCH in JPQL. |
| Auto-configuration | Spring Boot examines classpath and configures beans automatically. Override with your own @Bean definitions. |
| Spring vs Spring Boot | Spring = core framework. Spring Boot = opinionated defaults + embedded server + auto-config. |
| @Primary vs @Qualifier | @Primary = default when multiple beans match. @Qualifier = explicit selection by name. |
| Constructor vs Field Injection | Constructor = required deps, immutable, testable. Field (@Autowired) = harder to test, hidden deps. |