Spring Framework Explained: IoC, Dependency Injection, Beans & MVC - A Complete Developer Guide

Spring Framework Explained: IoC, Dependency Injection, Beans & MVC - A Complete Developer Guide

Spring Framework powers over 60% of enterprise Java applications worldwide. Yet despite its dominance, most developers who use it daily can't explain why it works the way it does. They know the annotations. They copy the patterns. But the moment something breaks - a bean doesn't inject, a request doesn't route, validation silently fails - they have no mental model to debug from.

This guide changes that. It explains the five foundational concepts that make Spring work: Inversion of Control, Dependency Injection, Bean management, Spring MVC's request flow, and validation with error handling. Not just what they are - but why they exist, how they connect, and exactly where they appear in real Spring code.

By the end of this guide, you'll understand not just how to use Spring but how Spring thinks. That mental model is the difference between copying Spring code and writing it with intention. If you're building your Java foundation first, Board Infinity's guide on core Java concepts and syntax is essential reading before diving into Spring - and their overview of OOP concepts in Java explains the class, interface, and object model that Spring's entire DI architecture is built on.


Who This Guide Is For

This guide is ideal for developers who:

  • Know Java basics but are new to the Spring Framework
  • Can write Spring Boot apps but want to understand what's happening under the hood
  • Have used @Autowired and @Service but can't explain how they work
  • Are preparing for Spring-related technical interviews
  • Want to understand the Java interface model that makes IoC genuinely click - Board Infinity's multiple inheritance in Java guide explains how Java interfaces enable Spring's loose coupling: when OrderService declares private final UserRepository userRepo, it depends on the interface - not any specific implementation

1. What Is Inversion of Control - The Core Idea Behind Spring

Inversion of Control (IoC) is the foundational principle that Spring is built on. To understand it, you need to understand what it inverts.

In traditional Java code, you control object creation. If OrderService needs a UserRepository, you write UserRepository repo = new UserRepository() inside OrderService. You create it. You manage it. You're in control.

With IoC, you invert that control. Instead of OrderService creating its own dependencies, Spring creates them and hands them in. OrderService doesn't know or care how UserRepository was created - it just declares it needs one, and Spring provides it. The framework is now in control of object lifecycle, not your code.

This inversion has a profound consequence: your classes become loosely coupled. OrderService doesn't depend on a specific UserRepository implementation - it depends on whatever Spring injects. Swap the implementation, change the configuration, and OrderService needs zero changes. Understanding how Java interfaces enable this pattern is what makes IoC genuinely click. Board Infinity's abstraction in Java guide covers the foundational principle directly: IoC works precisely because OrderService depends on the abstract UserRepository interface, not a concrete class - abstraction is what makes the implementation swappable without touching the dependent class.

Java - Traditional Control vs Inversion of Control
// WITHOUT IoC: tight coupling, hard to test or swap
public class OrderService {
    // You create dependencies - tightly coupled to this exact class
    private UserRepository userRepo = new UserRepositoryImpl();
    private EmailService    email    = new SmtpEmailService();
public void placeOrder(Order order) {
    // Can't swap UserRepository without editing this class
    userRepo.save(order);
}
}
// WITH IoC + Spring: loose coupling, testable, swappable
@Service
public class OrderService {
// Spring creates and injects these - you declare, Spring provides
private final UserRepository userRepo;
private final EmailService    email;
public OrderService(UserRepository userRepo, EmailService email) {
    this.userRepo = userRepo;
    this.email    = email;
    // Swap EmailService implementation in config - zero change here
}
}
๐Ÿ”
IoC Container = The Brain of Spring

The Spring IoC container (also called the Application Context) is the runtime object that reads your configuration, creates all beans, wires their dependencies, and manages their lifecycle. When Spring Boot starts, it boots this container. Everything you interact with - services, repositories, controllers - lives inside this container. Understanding the container is understanding Spring.

2. Dependency Injection: Constructor vs Setter vs Field

Dependency Injection (DI) is the mechanism Spring uses to implement IoC - the actual way Spring hands dependencies to your classes. There are three ways to inject: constructor injection, setter injection, and field injection. They produce the same result but differ significantly in quality, testability, and safety.

With constructor injection, all required dependencies are set at object creation time and can be declared final - guaranteeing they're never null and never reassigned. With field injection, the field can't be final, dependencies can be null before Spring initialises them, and unit testing requires a Spring context or reflection hacks. The final keyword on constructor-injected fields is one of the most important access control distinctions in Spring development. Board Infinity's access modifiers in Java guide explains why private final is the correct combination: private restricts external access to the dependency, and final guarantees immutability - once Spring injects the dependency through the constructor, it can never be replaced or set to null by any code path.

Injection Type How It Works Testable Without Spring Spring Team Recommendation
Constructor Dependencies passed via constructor parameters โœ… Yes - just call new Service(dep) โœ… Recommended - use for mandatory dependencies
Setter Dependencies set via @Autowired setter method โœ… Yes - call the setter directly โš ๏ธ Use only for optional dependencies
Field @Autowired directly on a private field โŒ No - requires Spring container or reflection โŒ Avoid - makes testing and debugging harder
Java - All 3 Injection Types Compared
// 1. CONSTRUCTOR INJECTION (recommended)
@Service
public class UserService {
private final UserRepository userRepository; // final = immutable

// Spring injects via constructor - no @Autowired needed in Spring 4.3+
public UserService(UserRepository userRepository) {
    this.userRepository = userRepository;
}
}
// 2. SETTER INJECTION (for optional dependencies)
@Service
public class ReportService {
private CacheService cacheService; // optional - may not exist

@Autowired(required = false) // won't fail if CacheService bean missing
public void setCacheService(CacheService cacheService) {
    this.cacheService = cacheService;
}
}
// 3. FIELD INJECTION (avoid - shown for recognition only)
@Service
public class OrderService {
@Autowired // directly on field - can't be final, hard to test
private OrderRepository orderRepository;
// Avoid this pattern - Spring team explicitly recommends against it
}
โš ๏ธ
Always Use Constructor Injection - Here's Why

With constructor injection, all required dependencies are set at object creation time and can be declared final - guaranteeing they're never null and never reassigned. With field injection, the field can't be final, dependencies can be null before Spring initialises them, and unit testing requires a Spring context or reflection hacks. The Spring team has officially recommended constructor injection since Spring 4.0.

3. Spring Beans: Lifecycle, Scopes, and When They Matter

A Spring Bean is any object managed by the Spring IoC container. When you annotate a class with @Component, @Service, @Repository, or @Controller, you're registering it as a bean. Spring creates it, manages its lifecycle, and destroys it when the application shuts down.

Bean Scopes: The scope defines how many instances of a bean Spring creates. The two most important scopes are singleton (one instance shared across the entire application - the default) and prototype (a new instance created every time the bean is requested).

For most Spring Boot applications, the default singleton scope is exactly what you want. Services, repositories, and controllers are stateless - they don't hold user-specific data - so one shared instance works perfectly. Prototype scope is useful for beans that hold state. The List<CartItem> items = new ArrayList<>() in the ShoppingCart prototype-scoped bean is exactly why scope matters - each user session gets its own ShoppingCart instance with its own ArrayList. Board Infinity's Java List guide covers ArrayList directly: this mutable, ordered collection is safe in a prototype bean (each instance has its own list) but would be a critical concurrency bug in a singleton bean (all users sharing the same list). The singleton class in Java guide provides the foundational context - Spring's singleton scope is the same pattern as the Java Singleton, and understanding why the pattern guarantees one instance helps explain why singleton beans must be stateless.

Java - Bean Scopes + Lifecycle Hooks
// SINGLETON scope (default) - one instance for entire app
@Service // implicitly @Scope("singleton")
public class UserService {
    // Spring creates ONE instance - shared across all controllers
    // Safe because UserService holds NO user-specific state
}
// PROTOTYPE scope - new instance per injection point
@Component
@Scope("prototype")
public class ShoppingCart {
private List<CartItem> items = new ArrayList<>();
// New instance per user session - holds user-specific state
}
// BEAN LIFECYCLE HOOKS
@Service
public class DatabaseConnectionService {
@PostConstruct // runs AFTER Spring injects all dependencies
public void initialise() {
    System.out.println("Bean ready - connection pool initialised");
}

@PreDestroy // runs BEFORE Spring destroys the bean on shutdown
public void cleanup() {
    System.out.println("Bean shutting down - closing connections");
}
}
โš ๏ธ
Never Store User-Specific State in a Singleton Bean

Because singleton beans are shared across all requests, storing state like a current user's data in an instance variable is a critical bug - one user's data will overwrite another's. Services should be stateless. If you need request-specific state, use method parameters, request-scoped beans (@Scope("request")), or Spring Security's SecurityContextHolder.

4. Spring MVC Flow: DispatcherServlet - Controller - View

Spring MVC is Spring's web framework for handling HTTP requests. Understanding its flow is essential for debugging routing issues, understanding where validation runs, and knowing where to add cross-cutting logic like logging or authentication.

Every HTTP request that reaches a Spring MVC application passes through the same six-step pipeline: DispatcherServlet receives the request, HandlerMapping finds the matching controller method, HandlerAdapter calls it with the right arguments, your @Controller executes, ViewResolver resolves the view name, and the response is sent back.

The DispatcherServlet at the centre of this pipeline is a Java servlet - it extends HttpServlet and processes every HTTP request. Board Infinity's understanding servlets in Java guide covers the Java servlet model that DispatcherServlet is built on: understanding how HttpServletRequest and HttpServletResponse work at the servlet level explains why Spring MVC can extract @PathVariable, @RequestParam, and @RequestBody values from incoming requests, and why the response is written back through HttpServletResponse. The @ModelAttribute ProductForm form in the createProduct() POST method also connects to Java's class model - Spring uses the Product class's setters to bind form fields to the Java object. Board Infinity's classes and objects in Java guide covers how Java objects are instantiated and their fields set through constructors and setters - the same mechanism Spring's data binding uses to populate @ModelAttribute and @RequestBody objects from HTTP request data.

Step Component What Happens
1 DispatcherServlet Receives all requests - the single entry point for Spring MVC
2 HandlerMapping Finds which controller method maps to this URL + HTTP method
3 HandlerAdapter Calls the controller method with the right arguments
4 Your @Controller Executes your business logic, returns a view name or response body
5 ViewResolver Resolves the view name to an actual template (Thymeleaf, JSP etc.)
6 HttpServletResponse Sends the rendered HTML or JSON back to the client
Java - Full Request Lifecycle in Spring MVC
// DispatcherServlet is auto-configured by Spring Boot
// It intercepts ALL requests to your application
// Step 4 - Your controller handles the request
@Controller
@RequestMapping("/products")
public class ProductController {
private final ProductService productService;

public ProductController(ProductService productService) {
    this.productService = productService;
}

// GET /products - returns view name "products/list"
@GetMapping
public String listProducts(Model model) {
    model.addAttribute("products", productService.getAll());
    return "products/list"; // ViewResolver - templates/products/list.html
}

// GET /products/42 - path variable extraction
@GetMapping("/{id}")
public String getProduct(@PathVariable Long id, Model model) {
    model.addAttribute("product", productService.findById(id));
    return "products/detail";
}

// POST /products - form submission
@PostMapping
public String createProduct(@ModelAttribute ProductForm form) {
    productService.create(form);
    return "redirect:/products"; // PRG pattern - redirect after POST
}
}

5. REST Controllers vs Traditional MVC Controllers

Spring supports two controller styles: @Controller for traditional MVC (returns view names for rendering HTML) and @RestController for REST APIs (returns data serialised to JSON/XML directly). The difference is one annotation - but the underlying behaviour is meaningfully different.

@RestController is shorthand for @Controller + @ResponseBody. The @ResponseBody annotation tells Spring to skip the ViewResolver entirely and write the return value directly to the HTTP response body as JSON (using Jackson, which Spring Boot auto-configures).

The List<UserDto> return type from getAllUsers() - serialised directly to a JSON array - uses Java generics throughout. Board Infinity's generics in Java guide covers the <UserDto> type parameter: it tells Spring's Jackson serialiser exactly what type each element in the JSON array represents, giving compile-time type safety from the service layer through the controller to the HTTP response. The ResponseEntity<UserDto> return type in getUserById() and createUser() - and its generic <T> parameter - uses the same generics model: ResponseEntity<T> wraps any response body type with HTTP status and headers control, and the type parameter <UserDto> ensures compile-time checking that the response body type matches what the client expects. The understanding wrapper class in Java guide is also relevant: @PathVariable Long id uses the Long wrapper rather than long primitive - Spring's type conversion system needs nullable wrapper types to handle missing path variables correctly.

Java - @Controller vs @RestController
// @Controller - returns view name, renders HTML
@Controller
public class PageController {
@GetMapping("/dashboard")
public String dashboard(Model model) {
    model.addAttribute("user", "Alice");
    return "dashboard"; // templates/dashboard.html (Thymeleaf)
}
}
// @RestController - returns JSON directly
@RestController           // = @Controller + @ResponseBody on every method
@RequestMapping("/api/users")
public class UserApiController {
private final UserService userService;

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

@GetMapping
public List<UserDto> getAllUsers() {
    return userService.findAll(); // serialised to JSON - no view
}

@GetMapping("/{id}")
public ResponseEntity<UserDto> getUserById(@PathVariable Long id) {
    return ResponseEntity.ok(userService.findById(id));
}

@PostMapping
public ResponseEntity<UserDto> createUser(@RequestBody CreateUserRequest req) {
    UserDto created = userService.create(req);
    return ResponseEntity.status(201).body(created);
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
    userService.delete(id);
    return ResponseEntity.noContent().build(); // 204 No Content
}
}
๐Ÿ’ก
Always Return ResponseEntity From REST Controllers

Returning a plain object (like UserDto) from a @RestController method defaults to HTTP 200. That works for GETs - but for POSTs you should return 201, for deletes 204, and for not-found scenarios 404. ResponseEntity<T> gives you full control over the HTTP status code, headers, and body. Use it for all write operations and whenever HTTP status precision matters.

6. Validation in Spring: Bean Validation + Custom Validators

Spring Boot integrates with the Bean Validation API (JSR-380) out of the box via the spring-boot-starter-validation dependency. This lets you annotate your request DTOs with constraints like @NotNull, @NotBlank, @Size, @Email, and @Min - and Spring validates them automatically before your controller method runs.

The validation annotations on CreateUserRequest - @NotBlank, @Size, @Email, @Min - are applied to private fields. The encapsulation that makes these fields private is also what makes the validation contract meaningful. Board Infinity's abstraction vs encapsulation in Java guide explains why private fields with validation annotations form a clean data contract: the CreateUserRequest class encapsulates its validation rules together with the data they protect - external code can't bypass @NotBlank on name by directly setting the field. When validation fails, Spring throws MethodArgumentNotValidException - an unchecked exception that propagates up to the @RestControllerAdvice handler. Board Infinity's throw and throws in Java guide covers the Java exception model that makes this propagation work: MethodArgumentNotValidException extends RuntimeException (unchecked), so it bubbles up through the call stack without requiring try-catch at every level until the global handler catches it.

Java - Bean Validation on Request DTOs
// Request DTO with validation constraints
public class CreateUserRequest {
@NotBlank(message = "Name is required")
@Size(min = 2, max = 50, message = "Name must be 2-50 characters")
private String name;

@NotBlank(message = "Email is required")
@Email(message = "Must be a valid email address")
private String email;

@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
private String password;

@Min(value = 18, message = "Must be at least 18 years old")
private int age;
}
// Controller activates validation with @Valid
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public ResponseEntity<UserDto> create(
        @Valid @RequestBody CreateUserRequest request) {
    // @Valid triggers Bean Validation before this method body runs
    // If any constraint fails - MethodArgumentNotValidException thrown
    return ResponseEntity.status(201).body(userService.create(request));
}
}
div class="bi-tip info"> ๐Ÿ”
@Valid vs @Validated - Know the Difference

@Valid is from the standard Bean Validation API (javax/jakarta) and validates the entire object. @Validated is Spring's extension and adds support for validation groups - useful when the same DTO is used for create (all fields required) and update (only changed fields required) operations. For most use cases, @Valid is sufficient. Use @Validated when you need group-based validation.

7. Error Handling: @ControllerAdvice and Global Exception Management

Validation and business logic will throw exceptions. Without a global error handling strategy, those exceptions become raw 500 errors or inconsistent JSON structures. Spring's @RestControllerAdvice provides a single place to catch every exception from every controller and return a consistent, clean error response.

The Map<String, String> fieldErrors in ApiError - populated by Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage) - uses Java's Stream API and method references directly in the error handler. Board Infinity's map stream in Java guide covers Collectors.toMap() and method references: FieldError::getField is a method reference to the getField() method on each FieldError object, applied to each element in the stream to build the error map. The Map<String, String> itself - mapping field names to error messages - uses Java's HashMap through the Streams API. Board Infinity's HashSet and Java collections guides cover Java's collection framework, and the Map<String, String> here provides structured, field-level error information that API clients can use to show targeted validation messages.

Java - Complete Global Error Handling with @RestControllerAdvice
// Standard error response structure
public class ApiError {
    private int              status;
    private String           message;
    private LocalDateTime    timestamp;
    private Map<String, String> fieldErrors;
public static ApiError of(int status, String message) {
    return new ApiError(status, message, LocalDateTime.now(), null);
}
}
// Global exception handler
@RestControllerAdvice
public class GlobalExceptionHandler {
// Handle validation failures - returns field-level error map
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidation(
        MethodArgumentNotValidException ex) {

    Map<String, String> fieldErrors = ex.getBindingResult()
        .getFieldErrors().stream()
        .collect(Collectors.toMap(
            FieldError::getField,
            FieldError::getDefaultMessage
        ));

    ApiError error = new ApiError(400, "Validation failed",
        LocalDateTime.now(), fieldErrors);
    return ResponseEntity.badRequest().body(error);
}

// Handle not-found scenarios
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(
        ResourceNotFoundException ex) {
    return ResponseEntity.status(404)
        .body(ApiError.of(404, ex.getMessage()));
}

// Catch-all for unexpected errors
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleAll(Exception ex) {
    return ResponseEntity.internalServerError()
        .body(ApiError.of(500, "An unexpected error occurred"));
}
}
๐Ÿ“Œ
Always Have a Catch-All @ExceptionHandler(Exception.class)

Your named exception handlers cover known failure modes. But unexpected exceptions - database connection failures, null pointers from edge cases, third-party library errors - will always occur in production. A catch-all @ExceptionHandler(Exception.class) ensures these never leak stack traces to clients. Log the full exception server-side, return a generic 500 message to the client. This pattern is a production requirement, not a nice-to-have.

Further Reading

Board Infinity Guides:

External Resources:

๐Ÿš€ Build Real Spring Projects - Not Just Theory

Spring Framework: Core & Web Development on Coursera

This free Coursera course by Board Infinity takes every concept covered in this guide - IoC, DI, Bean management, Spring MVC, validation, and error handling - and applies them through real project work. Every module builds toward a complete, working Spring web application.

Module 1
Spring Core & Dependency Injection IoC container, constructor/setter/field injection, Spring Beans, bean scopes and lifecycle hooks
Module 2
Configuring Spring Applications Java-based configuration, component scanning, Spring annotations, profiles and environment properties
Module 3
Building Web Apps with Spring MVC DispatcherServlet flow, REST controllers, URL mapping, request data handling and form submissions
Module 4
Validation & Error Handling Bean Validation API, custom validators, @ControllerAdvice, global exception management and clean error responses
Start Learning on Coursera โ†’

โœ“ Certificate available  ยท  โœ“ Self-paced  ยท  โœ“ Beginner-friendly

Conclusion

Spring Framework's power comes from five interconnected ideas: IoC gives the framework control of object creation, DI hands dependencies to your classes through their constructors, beans are the managed objects that form your application, Spring MVC's pipeline routes every request through a predictable flow, and validation + error handling make your API's failure behaviour as professional as its success behaviour.

These concepts don't exist independently - they form a system. IoC enables DI. DI enables testable beans. Well-structured beans enable a clean MVC layer. A clean MVC layer with proper validation and error handling produces production-grade APIs. Understanding each concept individually is useful; understanding how they connect is what makes you a confident Spring developer.

The developers who debug Spring issues quickly, architect maintainable Spring applications, and write Spring code without guessing are the ones who internalised these five concepts early. They're not doing anything magical - they just understand the framework they're using. The Java fundamentals that make all five concepts work - polymorphism, interfaces, generics, encapsulation, and exception handling - are covered comprehensively in Board Infinity's understanding polymorphism in Java guide and essentials of back-end development guide, which explains how Spring's architecture fits into the complete backend development picture.

Programming Language Ioc Dependency Injection Spring Framework Web Development