Skip to main content
Innovation|Innovation

Spring Boot Essentials: How Java Applications Come to Life

A beginner-friendly guide to Spring Boot fundamentals — application lifecycle, IoC container, dependency injection, annotations cheat sheet, N-Tier architecture, DTOs with records, global exception handling, and validation with @Valid.

April 8, 202613 min read3 views0 comments
Share:

What Is Spring Boot and Why Should You Care?

Imagine you want to build a house. You could buy every brick, wire, and pipe separately, figure out how they connect, and spend months just on plumbing. Or you could hire a builder who already knows the blueprint, has the materials ready, and hands you a finished house — you just pick the paint color.

That is what Spring Boot does for Java developers. Plain Java gives you bricks. The Spring Framework gives you a blueprint. Spring Boot gives you a finished house with the lights already on.

In this guide, you will learn how a Spring Boot application starts up, how it manages your objects, how you organize your code into clean layers, and how you handle errors and validation — all with practical code examples you can run today.

The Application Lifecycle: From main() to Ready

Every Spring Boot application starts with a single class. Think of it as pressing the power button on a computer — one click, and dozens of things happen behind the scenes.

@SpringBootApplication — The One Annotation to Rule Them All

@SpringBootApplication is actually three annotations combined into one:

  • @SpringBootConfiguration — marks this class as a configuration source (like a settings file)
  • @EnableAutoConfiguration — tells Spring Boot to automatically configure things based on what libraries you have
  • @ComponentScan — tells Spring to search for classes with special annotations in this package and below
@SpringBootApplication
public class BookStoreApplication {
    public static void main(String[] args) {
        SpringApplication.run(BookStoreApplication.class, args);
    }
}

When you run this, Spring Boot does the following in order:

  1. Creates the ApplicationContext — a container that holds all your objects (called "beans")
  2. Scans for components — finds every class with @Service, @Repository, @Controller, etc.
  3. Auto-configures — sees you have a database driver? It sets up a connection pool. Have a web dependency? It starts an embedded Tomcat server.
  4. Runs startup hooks — executes any CommandLineRunner or ApplicationRunner beans
  5. Ready — your app is live and listening for requests
// Want to run something on startup? Use CommandLineRunner
@Component
public class DataLoader implements CommandLineRunner {
    @Override
    public void run(String... args) {
        System.out.println("Application started! Loading initial data...");
    }
}

IoC Container: Let Spring Cook for You

IoC stands for Inversion of Control. Here is the simplest analogy:

Without IoC (cooking at home): You go to the store, buy ingredients, chop vegetables, heat the stove, cook the meal, wash the dishes. You control everything.

With IoC (eating at a restaurant): You sit down, order from the menu, and the chef brings you food. You do not control how the food is made — the restaurant does. You just say what you want.

In Spring Boot, the IoC Container (also called the ApplicationContext) is the restaurant. Your classes are the dishes. You describe what you need, and Spring creates, connects, and manages everything for you.

What Is a Bean?

A bean is just a Java object that Spring creates and manages. Instead of you writing new ProductService(), Spring creates it, keeps it in memory, and gives it to anyone who needs it.

// YOU do not write this:
ProductRepository repo = new ProductRepository();
ProductService service = new ProductService(repo);

// SPRING does it for you — you just ask:
@Service
public class ProductService {
    private final ProductRepository repository;

    // Spring sees this constructor, finds a ProductRepository bean,
    // and passes it in automatically
    public ProductService(ProductRepository repository) {
        this.repository = repository;
    }
}

Dependency Injection: Asking for What You Need

Dependency Injection (DI) is the how of IoC. It is how Spring gives your class the objects it depends on. Think of it like ordering room service — you call the front desk and say "I need towels," and they bring towels to your door. You do not go to the laundry room yourself.

Three Types of Injection

// 1. CONSTRUCTOR INJECTION (recommended — the best way)
@Service
public class OrderService {
    private final ProductRepository productRepo;
    private final EmailService emailService;

    // Spring injects both dependencies through the constructor
    public OrderService(ProductRepository productRepo, EmailService emailService) {
        this.productRepo = productRepo;
        this.emailService = emailService;
    }
}

// 2. SETTER INJECTION (for optional dependencies)
@Service
public class ReportService {
    private CacheService cacheService;

    @Autowired(required = false)
    public void setCacheService(CacheService cacheService) {
        this.cacheService = cacheService;
    }
}

// 3. FIELD INJECTION (avoid — hard to test)
@Service
public class NotificationService {
    @Autowired  // Works but NOT recommended
    private EmailService emailService;
}

Why constructor injection is best:

  • Your fields can be final — once set, they never change
  • Easy to test — just pass mock objects in the constructor
  • If a dependency is missing, the app fails at startup (not at runtime when a user clicks something)

Annotations Cheat Sheet: The Labels That Tell Spring What to Do

Annotations are like name tags at a party. When you put @Service on a class, you are telling Spring: "Hey, I am a service — please manage me." Here is every annotation you need to know:

Component Annotations (Spring scans for these)

// @Component — generic "I am a Spring-managed bean"
@Component
public class PdfGenerator { }

// @Service — business logic layer (same as @Component, but clearer intent)
@Service
public class EmployeeService {
    public Employee findById(Long id) { ... }
}

// @Repository — data access layer (adds automatic exception translation)
@Repository
public class EmployeeRepository {
    public Employee save(Employee employee) { ... }
}

// @RestController — handles HTTP requests and returns JSON directly
@RestController
@RequestMapping("/api/products")
public class ProductController {
    @GetMapping("/{id}")
    public Product getProduct(@PathVariable Long id) { ... }
}

// @Controller — handles HTTP requests and returns HTML views
@Controller
public class HomeController {
    @GetMapping("/")
    public String home(Model model) {
        return "index";  // returns the index.html template
    }
}

Configuration Annotations

// @Bean — manually create a bean inside a @Configuration class
@Configuration
public class AppConfig {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }

    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    }
}

// @Value — inject values from application.properties
@Service
public class EmailService {
    @Value("${app.email.from:noreply@example.com}")
    private String fromAddress;

    @Value("${app.email.max-retries:3}")
    private int maxRetries;
}

// @Profile — activate beans only in specific environments
@Configuration
@Profile("dev")
public class DevConfig {
    @Bean
    public DataSource dataSource() {
        // H2 in-memory database for development
        return new EmbeddedDatabaseBuilder()
            .setType(EmbeddedDatabaseType.H2)
            .build();
    }
}

@Configuration
@Profile("prod")
public class ProdConfig {
    @Bean
    public DataSource dataSource() {
        // Real PostgreSQL for production
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl("jdbc:postgresql://db-server:5432/myapp");
        return ds;
    }
}

Quick Reference Table

AnnotationPurposeLayer
@ComponentGeneric Spring beanAny
@ServiceBusiness logicService
@RepositoryData access + exception translationData
@RestControllerREST API endpoints (returns JSON)Web
@ControllerMVC endpoints (returns views)Web
@ConfigurationDefines beans manuallyConfig
@BeanCreates a bean inside @ConfigurationConfig
@ValueInjects property valuesAny
@ProfileActivates beans per environmentConfig
@AutowiredInjects dependencies (prefer constructor)Any

N-Tier Architecture: Organizing Code Like a Well-Run Kitchen

A professional kitchen has stations: one person preps vegetables, another grills meat, another plates the food. Nobody does everything. That is N-Tier architecture — each layer has one job.

In Spring Boot, the standard setup is three layers:

HTTP Request
    |
    v
[Controller Layer]  — Takes the order (parses HTTP requests)
    |
    v
[Service Layer]     — Cooks the meal (business logic)
    |
    v
[Repository Layer]  — Gets ingredients from the pantry (database access)
    |
    v
[Database]          — The pantry (stores everything)

Full Example: Employee Management

Let us build a complete flow from HTTP request to database and back.

Step 1: The Entity — what is stored in the database

@Entity
@Table(name = "employees")
public class Employee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

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

    private String department;

    // constructors, getters, setters
}

Step 2: The Repository — talks to the database

@Repository
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
    // Spring writes the SQL for you based on the method name!
    List<Employee> findByDepartment(String department);
    Optional<Employee> findByEmail(String email);
    boolean existsByEmail(String email);
}

Step 3: The Service — contains all business logic

@Service
public class EmployeeService {
    private final EmployeeRepository repository;

    public EmployeeService(EmployeeRepository repository) {
        this.repository = repository;
    }

    public List<Employee> getAllEmployees() {
        return repository.findAll();
    }

    public Employee getEmployeeById(Long id) {
        return repository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Employee not found with id: " + id));
    }

    public Employee createEmployee(Employee employee) {
        if (repository.existsByEmail(employee.getEmail())) {
            throw new DuplicateResourceException("Email already exists: " + employee.getEmail());
        }
        return repository.save(employee);
    }

    public Employee updateEmployee(Long id, Employee updated) {
        Employee existing = getEmployeeById(id);
        existing.setName(updated.getName());
        existing.setEmail(updated.getEmail());
        existing.setDepartment(updated.getDepartment());
        return repository.save(existing);
    }

    public void deleteEmployee(Long id) {
        Employee employee = getEmployeeById(id);
        repository.delete(employee);
    }
}

Step 4: The Controller — handles HTTP requests

@RestController
@RequestMapping("/api/employees")
public class EmployeeController {
    private final EmployeeService service;

    public EmployeeController(EmployeeService service) {
        this.service = service;
    }

    @GetMapping
    public List<Employee> getAll() {
        return service.getAllEmployees();
    }

    @GetMapping("/{id}")
    public Employee getById(@PathVariable Long id) {
        return service.getEmployeeById(id);
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Employee create(@Valid @RequestBody Employee employee) {
        return service.createEmployee(employee);
    }

    @PutMapping("/{id}")
    public Employee update(@PathVariable Long id, @Valid @RequestBody Employee employee) {
        return service.updateEmployee(id, employee);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        service.deleteEmployee(id);
    }
}

DTOs with Java Records: Never Expose Your Database to the Outside World

A DTO (Data Transfer Object) is like a menu at a restaurant. The kitchen has raw chicken, knives, and ovens — but you do not want to see all that. You want a clean menu that shows only what you can order.

Similarly, your Entity has database details (ID generation strategy, column constraints) that API users should not see. A DTO shows only the fields they need.

Java Records (Java 16+) make DTOs incredibly clean — no getters, setters, equals, hashCode, or toString to write:

// Request DTO — what the client sends to create an employee
public record CreateEmployeeRequest(
    @NotBlank(message = "Name is required")
    String name,

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

    String department
) {}

// Response DTO — what the client receives back
public record EmployeeResponse(
    Long id,
    String name,
    String email,
    String department,
    LocalDateTime createdAt
) {
    // Static factory method to convert Entity -> DTO
    public static EmployeeResponse from(Employee entity) {
        return new EmployeeResponse(
            entity.getId(),
            entity.getName(),
            entity.getEmail(),
            entity.getDepartment(),
            entity.getCreatedAt()
        );
    }
}

// Usage in Controller
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public EmployeeResponse create(@Valid @RequestBody CreateEmployeeRequest request) {
    Employee employee = new Employee(request.name(), request.email(), request.department());
    Employee saved = service.createEmployee(employee);
    return EmployeeResponse.from(saved);
}

@GetMapping
public List<EmployeeResponse> getAll() {
    return service.getAllEmployees().stream()
        .map(EmployeeResponse::from)
        .toList();
}

Global Exception Handling: One Place for All Errors

Without global exception handling, every controller method would need try-catch blocks — messy and repetitive. Spring provides @RestControllerAdvice, which acts like a safety net that catches exceptions from any controller.

Think of it like a customer service desk at a mall. No matter which store you have a problem in, you go to one central desk that handles all complaints.

// Step 1: Define custom exceptions
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}

public class DuplicateResourceException extends RuntimeException {
    public DuplicateResourceException(String message) {
        super(message);
    }
}

// Step 2: Create a standard error response
public record ErrorResponse(
    int status,
    String error,
    String message,
    LocalDateTime timestamp
) {
    public static ErrorResponse of(HttpStatus status, String message) {
        return new ErrorResponse(
            status.value(),
            status.getReasonPhrase(),
            message,
            LocalDateTime.now()
        );
    }
}

// Step 3: The global exception handler — catches errors from ALL controllers
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleNotFound(ResourceNotFoundException ex) {
        return ErrorResponse.of(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(DuplicateResourceException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public ErrorResponse handleDuplicate(DuplicateResourceException ex) {
        return ErrorResponse.of(HttpStatus.CONFLICT, ex.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleValidation(MethodArgumentNotValidException ex) {
        String message = ex.getBindingResult().getFieldErrors().stream()
            .map(error -> error.getField() + ": " + error.getDefaultMessage())
            .collect(Collectors.joining(", "));
        return ErrorResponse.of(HttpStatus.BAD_REQUEST, message);
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorResponse handleGeneral(Exception ex) {
        return ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR,
            "Something went wrong. Please try again later.");
    }
}

Now if any controller throws ResourceNotFoundException, the client gets a clean JSON response like:

{
    "status": 404,
    "error": "Not Found",
    "message": "Employee not found with id: 42",
    "timestamp": "2026-04-08T10:30:00"
}

Validation with @Valid: Stop Bad Data at the Door

Validation is like a bouncer at a club. Before anyone gets in, the bouncer checks: Are you on the list? Do you have ID? Are you old enough? If any check fails, you do not get in.

Spring Boot uses Jakarta Bean Validation annotations to check incoming data automatically:

public record CreateProductRequest(
    @NotBlank(message = "Product name is required")
    @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
    String name,

    @NotNull(message = "Price is required")
    @Positive(message = "Price must be greater than zero")
    BigDecimal price,

    @Min(value = 0, message = "Stock cannot be negative")
    int stock,

    @NotBlank(message = "Category is required")
    String category,

    @Size(max = 500, message = "Description cannot exceed 500 characters")
    String description
) {}

// In the controller, @Valid triggers validation BEFORE your code runs
@PostMapping("/api/products")
public ProductResponse create(@Valid @RequestBody CreateProductRequest request) {
    // This code only runs if ALL validations pass
    return service.createProduct(request);
}

Common Validation Annotations

AnnotationWhat It ChecksExample
@NotNullNot null (but can be empty)@NotNull Integer age
@NotBlankNot null, not empty, not just spaces@NotBlank String name
@NotEmptyNot null and not empty@NotEmpty List<String> tags
@SizeLength within range@Size(min=2, max=50)
@Min / @MaxNumeric minimum / maximum@Min(0) int quantity
@PositiveGreater than zero@Positive BigDecimal price
@EmailValid email format@Email String email
@PatternMatches a regex@Pattern(regexp="[A-Z]{2}\\d{4}")
@Past / @FutureDate in the past / future@Past LocalDate birthDate

Custom Validation — When Built-In Is Not Enough

// Step 1: Create the annotation
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = NoProfanityValidator.class)
public @interface NoProfanity {
    String message() default "Content contains inappropriate language";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// Step 2: Create the validator
public class NoProfanityValidator implements ConstraintValidator<NoProfanity, String> {
    private static final Set<String> BLOCKED_WORDS = Set.of("spam", "scam");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) return true;
        String lower = value.toLowerCase();
        return BLOCKED_WORDS.stream().noneMatch(lower::contains);
    }
}

// Step 3: Use it
public record CreateReviewRequest(
    @NoProfanity
    @NotBlank
    String comment,

    @Min(1) @Max(5)
    int rating
) {}

Putting It All Together: The Big Picture

Here is how everything connects when a request hits your Spring Boot application:

POST /api/employees  (JSON body: {"name": "Alice", "email": "alice@example.com"})
        |
        v
  [@Valid] Validation runs first
        |  (fails? -> GlobalExceptionHandler returns 400 Bad Request)
        v
  [EmployeeController] receives CreateEmployeeRequest DTO
        |
        v
  [EmployeeService] checks business rules (email already exists?)
        |  (duplicate? -> throws DuplicateResourceException -> handler returns 409 Conflict)
        v
  [EmployeeRepository] saves to database
        |
        v
  [EmployeeResponse DTO] returned as JSON
        |
        v
  201 Created  {"id": 1, "name": "Alice", "email": "alice@example.com"}

Frequently Asked Questions

What is the difference between @Component, @Service, and @Repository?

Functionally, they all do the same thing — they tell Spring to create and manage a bean. The difference is intent. @Component is the generic label. @Service says "this class contains business logic." @Repository says "this class talks to the database" and adds automatic exception translation (converting database-specific exceptions into Spring's DataAccessException). Think of it like job titles: everyone is an employee (@Component), but some are managers (@Service) and some are warehouse workers (@Repository). Use the specific label so anyone reading your code instantly knows what the class does.

Three reasons. First, your dependencies can be final, meaning they are set once and never change — this makes your class safer in multi-threaded environments. Second, testing is simple: just pass mock objects in the constructor without needing a Spring context. Third, if you forget a dependency, the app fails immediately at startup with a clear error message, instead of crashing at runtime when a user triggers the code path. Field injection with @Autowired hides your dependencies and makes classes harder to test and reason about.

When should I use @Bean instead of @Component?

Use @Component (or @Service, @Repository) for classes you write. Use @Bean inside a @Configuration class for objects from third-party libraries that you cannot annotate because you do not own their source code. For example, you cannot put @Component on RestTemplate or ObjectMapper because they are from external libraries. Instead, you create a method annotated with @Bean that returns a configured instance, and Spring manages it from there.

What happens if validation fails on a request?

When you put @Valid on a @RequestBody parameter, Spring checks all the validation annotations (@NotBlank, @Email, @Size, etc.) before your controller method runs. If any check fails, Spring throws a MethodArgumentNotValidException. Without a global exception handler, this returns a messy 400 response. With a @RestControllerAdvice handler (like the one shown above), you can catch this exception and return a clean, user-friendly JSON error with exactly which fields failed and why.

Can I have multiple profiles active at the same time?

Yes. You can activate multiple profiles by setting spring.profiles.active=dev,metrics,logging in your application.properties, as a command-line argument (--spring.profiles.active=dev,metrics), or as an environment variable. All beans matching any of the active profiles will be created. This is useful for mixing concerns: dev for your database, metrics for monitoring, logging for verbose logs. You can also use @Profile("!prod") to activate a bean in every profile except production.


Comments


Login to join the conversation.

Loading comments…

More from Innovation