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
@Autowiredand@Servicebut 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
OrderServicedeclaresprivate 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.
// 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 } }
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 |
// 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 }
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.
// 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"); } }
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 |
// 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.
// @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 } }
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.
// 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)); } }
@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.
// 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")); } }
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:
- OOP Concepts in Java
- Core Java Concepts and Syntax
- Classes and Objects in Java
- Abstraction in Java
- Abstraction vs Encapsulation in Java
- Understanding Polymorphism in Java
- Multiple Inheritance in Java - Interfaces
- Access Modifiers in Java
- Understanding Singleton Class in Java
- Understanding Servlets in Java
- Generics in Java
- Understanding Wrapper Class in Java
- Learn About Java List
- Learn About Map Stream in Java
- Understand HashSet in Java
- Learn About Throw and Throws in Java
- Essentials of Back-End Development: From APIs to Databases
External Resources:
- Spring Framework Official Documentation - IoC Container
- Spring MVC Official Documentation
- Jakarta Bean Validation API Reference
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.
โ 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.