Spring Boot Core Concepts — Dependency Injection & Auto-Configuration

Table of Contents

Recap — From Java main() to a Running Web Server

In Lecture 1, we created a Spring Boot application with a simple HelloController. Let us quickly revisit what happens when you run the application:

@SpringBootApplication
public class BlogApiApplication {
    public static void main(String[] args) {
        SpringApplication.run(BlogApiApplication.class, args);
    }
}

When SpringApplication.run() executes, it performs a series of steps behind the scenes:

  1. Creates the Application Context — a container that holds and manages all the objects (called “beans”) in your application.
  2. Component Scanning — scans your packages for classes annotated with @Component@Service@Controller@Repository, and others. It creates instances of these classes and registers them in the Application Context.
  3. Auto-Configuration — examines your classpath (the libraries in your pom.xml) and automatically configures things like the embedded Tomcat server, JSON serialization, and more.
  4. Starts the embedded web server — Tomcat begins listening on port 8080.

In Lecture 1, we treated all of this as magic. In this lecture, we are going to open the box and understand the two most important concepts that make Spring work: Inversion of Control and Dependency Injection.

These are not just Spring concepts — they are fundamental software design principles that will make you a better developer regardless of what framework you use.


What is Inversion of Control (IoC)?

The Traditional Way: You Control Everything

In the Java code you have written before, you probably created objects like this:

public class OrderService {

    // You create the dependencies yourself
    private EmailService emailService = new EmailService();
    private OrderRepository orderRepository = new OrderRepository();
    private PaymentGateway paymentGateway = new StripePaymentGateway();

    public void placeOrder(Order order) {
        orderRepository.save(order);
        paymentGateway.charge(order.getTotal());
        emailService.sendConfirmation(order);
    }
}

In this code, OrderService is in full control. It decides:

  • Which EmailService to use
  • Which OrderRepository to use
  • Which PaymentGateway implementation to use (StripePaymentGateway)

This seems natural, but it creates several problems.

Problem 1: Tight Coupling

OrderService is tightly coupled to StripePaymentGateway. If you want to switch to PayPal, you must modify the OrderService code:

// You have to change OrderService just because you want a different payment provider
private PaymentGateway paymentGateway = new PayPalPaymentGateway();

What if 20 other classes also use StripePaymentGateway? You have to change all 20.

Problem 2: Hard to Test

How do you test OrderService without actually sending emails, charging credit cards, and writing to a real database? You cannot easily replace these dependencies with mock objects because they are hardcoded inside the class.

// You want to test placeOrder(), but it will:
// - Try to connect to a real database (OrderRepository)
// - Try to charge a real credit card (StripePaymentGateway)
// - Try to send a real email (EmailService)
// This makes testing slow, unreliable, and potentially costly!

Problem 3: Hidden Dependencies

Looking at the OrderService class from the outside, you have no idea what it depends on. The dependencies are buried inside the class. A developer using OrderService does not know that it needs an email service, a repository, and a payment gateway until they read the implementation.

The IoC Way: The Container Controls Everything

Inversion of Control flips the responsibility. Instead of the class creating its own dependencies, an external entity (the IoC container, which in Spring is the Application Context) creates the dependencies and provides them to the class.

public class OrderService {

    // Dependencies are declared, but NOT created here
    private final EmailService emailService;
    private final OrderRepository orderRepository;
    private final PaymentGateway paymentGateway;

    // Dependencies are provided from the outside through the constructor
    public OrderService(EmailService emailService,
                        OrderRepository orderRepository,
                        PaymentGateway paymentGateway) {
        this.emailService = emailService;
        this.orderRepository = orderRepository;
        this.paymentGateway = paymentGateway;
    }

    public void placeOrder(Order order) {
        orderRepository.save(order);
        paymentGateway.charge(order.getTotal());
        emailService.sendConfirmation(order);
    }
}

Now OrderService no longer controls the creation of its dependencies. It simply declares what it needs, and something else provides them. The control has been inverted — hence the name “Inversion of Control.”

An Analogy

Imagine you are at a restaurant:

  • Without IoC (Traditional): You go to the kitchen, find the ingredients, cook the meal yourself, wash the dishes, and then eat. You control everything.
  • With IoC (Spring): You sit at a table, tell the waiter what you want, and the kitchen prepares it for you. You only focus on eating — the restaurant controls the preparation.

In this analogy:

  • You are the OrderService
  • The ingredients are the dependencies (EmailServicePaymentGateway, etc.)
  • The restaurant/kitchen is the Spring IoC Container
  • The waiter delivering the food is Dependency Injection

Dependency Injection Explained

Dependency Injection (DI) is the mechanism through which the IoC container provides dependencies to a class. It is the “how” of Inversion of Control.

There are three types of Dependency Injection in Spring:

Constructor Injection (Recommended)

The dependencies are provided through the class constructor:

@Service
public class OrderService {

    private final EmailService emailService;
    private final OrderRepository orderRepository;

    // Spring sees this constructor, finds beans of type EmailService
    // and OrderRepository in the Application Context, and passes them in.
    public OrderService(EmailService emailService,
                        OrderRepository orderRepository) {
        this.emailService = emailService;
        this.orderRepository = orderRepository;
    }

    public void placeOrder(Order order) {
        orderRepository.save(order);
        emailService.sendConfirmation(order);
    }
}

Key characteristics:

  • Dependencies are declared as final fields — they cannot be changed after construction.
  • The object is always in a valid state — all dependencies are guaranteed to be present when the constructor finishes.
  • If the class has only one constructor, Spring uses it automatically — you do not even need @Autowired.

Setter Injection

The dependencies are provided through setter methods:

@Service
public class OrderService {

    private EmailService emailService;
    private OrderRepository orderRepository;

    // Spring calls these setters after creating the object
    @Autowired
    public void setEmailService(EmailService emailService) {
        this.emailService = emailService;
    }

    @Autowired
    public void setOrderRepository(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }
}

This approach is less common in modern Spring. The main problem is that the object can exist in an incomplete state — between construction and setter injection, the fields are null.

Field Injection

The dependencies are injected directly into the fields using @Autowired:

@Service
public class OrderService {

    // Spring injects these directly using reflection —
    // no constructor or setter needed
    @Autowired
    private EmailService emailService;

    @Autowired
    private OrderRepository orderRepository;
}

This looks like the simplest approach, but it has serious drawbacks:

  • Fields cannot be final, so dependencies can be changed accidentally.
  • You cannot create the object without Spring (makes testing harder).
  • Dependencies are hidden — you cannot tell what the class needs by looking at the constructor.
  • The class becomes tightly coupled to the Spring framework itself.

Which One Should You Use?

Always use Constructor Injection. This is the official recommendation from the Spring team. Here is a comparison:

CriteriaConstructorSetterField
Immutability (final fields)YesNoNo
Always in valid stateYesNoNo
Easy to test without SpringYesYesNo
Dependencies visible from outsideYesSomewhatNo
Recommended by Spring teamYesOptional deps onlyNo

The only exception: use setter injection for optional dependencies that have a sensible default. For everything else, use constructor injection.


The Spring Application Context & Bean Lifecycle

What is a Bean?

In Spring, a bean is simply a Java object that is managed by the Spring IoC container. When you annotate a class with @Component@Service@Controller, or @Repository, Spring creates an instance of that class and manages it. That instance is a bean.

@Service  // This tells Spring: "Create an instance of this class and manage it"
public class UserService {
    // This class will become a bean in the Application Context
}

What is the Application Context?

The Application Context is the container that holds all beans. Think of it as a large HashMap<String, Object> where the keys are bean names and the values are bean instances.

When your application starts, Spring:

  1. Scans for classes annotated with stereotypes (@Component@Service, etc.)
  2. Creates instances (beans) of those classes
  3. Resolves dependencies between beans (Dependency Injection)
  4. Stores all beans in the Application Context
  5. Makes them available to any class that needs them

Bean Lifecycle

Every bean goes through a lifecycle from creation to destruction. Here is the simplified version:

Bean Definition Found (@Component scan)
       ↓
Bean Instantiated (constructor called)
       ↓
Dependencies Injected (via constructor, setter, or field)
       ↓
@PostConstruct Method Called (if any)
       ↓
Bean is Ready to Use
       ↓
   ... application runs ...
       ↓
@PreDestroy Method Called (if any — on application shutdown)
       ↓
Bean Destroyed

You can hook into this lifecycle with @PostConstruct and @PreDestroy:

@Service
public class CacheService {

    private Map<String, Object> cache;

    // Called after the bean is created and dependencies are injected.
    // Use this for initialization logic that requires dependencies.
    @PostConstruct
    public void init() {
        System.out.println("CacheService initialized!");
        cache = new HashMap<>();
        // Load initial data, warm up cache, etc.
    }

    // Called when the application is shutting down.
    // Use this for cleanup: closing connections, flushing buffers, etc.
    @PreDestroy
    public void cleanup() {
        System.out.println("CacheService shutting down!");
        cache.clear();
    }
}

Note: Do not put heavy logic in the constructor. The constructor runs before dependency injection (for setter/field injection) and before @PostConstruct. Use @PostConstruct for initialization that depends on injected dependencies.

Bean Scope

By default, Spring beans are singletons — only one instance is created and shared across the entire application. This is important to understand:

@Service
public class UserService {
    // There is only ONE instance of UserService in the entire application.
    // Every class that depends on UserService gets the SAME instance.
}

You can change the scope, but singleton is the correct choice for 99% of beans in a web application:

// Singleton (default) — one instance for the entire application
@Scope("singleton")

// Prototype — a new instance every time someone requests this bean
@Scope("prototype")

// Request — one instance per HTTP request (web apps only)
@Scope("request")

// Session — one instance per HTTP session (web apps only)
@Scope("session")

For this series, we will use the default singleton scope for all our beans. Just remember: because beans are singletons, they should be stateless — do not store request-specific data in instance fields.


Key Annotations: @Component, @Service, @Repository, @Controller

Spring provides four annotations to mark classes as beans. They are called stereotype annotations, and they all do the same fundamental thing — register the class as a bean in the Application Context. The difference is semantic, meaning they communicate the purpose of the class to other developers.

@Component — The Generic Annotation

@Component is the base annotation. It simply says: “This class is a Spring-managed bean.”

@Component
public class EmailValidator {

    public boolean isValid(String email) {
        return email != null && email.contains("@");
    }
}

Use @Component when the class does not fit neatly into one of the other categories below.

@Service — Business Logic Layer

@Service is a specialization of @Component. It indicates that the class contains business logic.

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User createUser(String name, String email) {
        // Validate input
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("Name cannot be empty");
        }
        // Business logic here
        User user = new User(name, email);
        return userRepository.save(user);
    }
}

@Repository — Data Access Layer

@Repository is a specialization of @Component for classes that interact with the database. It has one extra behavior: Spring automatically translates database-specific exceptions into Spring’s DataAccessException hierarchy, making error handling consistent across different databases.

@Repository
public class UserRepository {

    // In future lectures, we will use Spring Data JPA and won't
    // write repository implementations manually. For now, we use
    // an in-memory list to simulate database operations.
    private final List<User> users = new ArrayList<>();
    private long nextId = 1;

    public User save(User user) {
        user.setId(nextId++);
        users.add(user);
        return user;
    }

    public Optional<User> findById(Long id) {
        return users.stream()
                .filter(u -> u.getId().equals(id))
                .findFirst();
    }

    public List<User> findAll() {
        return Collections.unmodifiableList(users);
    }
}

@Controller / @RestController — Web Layer

@Controller and @RestController are specializations of @Component for classes that handle HTTP requests. We used @RestController in Lecture 1.

The difference between them:

  • @Controller — Returns view names (HTML templates). Used for server-rendered web pages.
  • @RestController — Returns data directly (JSON). Used for REST APIs. It is @Controller + @ResponseBody.
@RestController
public class UserController {

    private final UserService userService;

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

    @GetMapping("/api/users")
    public List<User> getAllUsers() {
        return userService.findAllUsers();
    }
}

The Layered Architecture

These annotations naturally organize your code into layers:

HTTP Request
     ↓
@RestController  (Web Layer — handles HTTP, delegates to service)
     ↓
@Service         (Service Layer — business logic, validation, orchestration)
     ↓
@Repository      (Data Access Layer — database operations)
     ↓
Database

Each layer has a clear responsibility:

  • Controller: Receives HTTP requests, extracts parameters, calls the service, returns HTTP responses. No business logic here.
  • Service: Contains all business logic — validation, calculations, orchestration of multiple repositories. Does not know about HTTP.
  • Repository: Handles data access — CRUD operations on the database. Does not know about business rules or HTTP.

This separation is crucial. It makes your code modular, testable, and maintainable. We will follow this pattern for the entire series.

Technically, They Are the Same

If you look at the source code of @Service, you will see:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component  // <-- @Service IS a @Component
public @interface Service {
    String value() default "";
}

@Service is literally @Component with a different name. The same is true for @Repository and @Controller. They all register the class as a bean. The distinction is purely for:

  • Code readability — other developers immediately know the class’s role
  • Framework features — @Repository gets exception translation, @Controller gets web handling
  • AOP targeting — you can apply cross-cutting concerns to all @Service classes, for example

@Autowired and Why Constructor Injection is Preferred

How @Autowired Works

@Autowired tells Spring: “Find a bean of this type in the Application Context and inject it here.”

@Service
public class NotificationService {

    @Autowired  // Spring finds a bean of type EmailService and injects it
    private EmailService emailService;
}

But here is the good news: you often do not need @Autowired at all.

The Magic of Single-Constructor Injection

Starting with Spring 4.3, if a class has only one constructor, Spring automatically uses it for dependency injection — no @Autowired required:

@Service
public class NotificationService {

    private final EmailService emailService;
    private final SmsService smsService;

    // Only one constructor → Spring automatically uses it for DI.
    // No @Autowired needed!
    public NotificationService(EmailService emailService,
                                SmsService smsService) {
        this.emailService = emailService;
        this.smsService = smsService;
    }
}

This is the cleanest and most recommended approach. No annotations on the constructor, final fields, and maximum clarity.

If a class has multiple constructors, you must mark the one you want Spring to use with @Autowired:

@Service
public class NotificationService {

    private final EmailService emailService;
    private final SmsService smsService;

    @Autowired  // Required because there are multiple constructors
    public NotificationService(EmailService emailService,
                                SmsService smsService) {
        this.emailService = emailService;
        this.smsService = smsService;
    }

    // Second constructor — Spring will NOT use this one
    public NotificationService(EmailService emailService) {
        this(emailService, null);
    }
}

Why Constructor Injection is Preferred: A Concrete Example

Let us see why constructor injection matters with a testing example.

With field injection (bad):

@Service
public class OrderService {

    @Autowired
    private PaymentGateway paymentGateway;

    @Autowired
    private EmailService emailService;

    public void placeOrder(Order order) {
        paymentGateway.charge(order.getTotal());
        emailService.sendConfirmation(order);
    }
}

To test this class, you need Spring or a reflection-based tool like @InjectMocks:

// Without Spring, you CANNOT create this object with its dependencies
OrderService service = new OrderService();
// service.paymentGateway is null!
// service.emailService is null!
// service.placeOrder(order) → NullPointerException!

With constructor injection (good):

@Service
public class OrderService {

    private final PaymentGateway paymentGateway;
    private final EmailService emailService;

    public OrderService(PaymentGateway paymentGateway,
                        EmailService emailService) {
        this.paymentGateway = paymentGateway;
        this.emailService = emailService;
    }

    public void placeOrder(Order order) {
        paymentGateway.charge(order.getTotal());
        emailService.sendConfirmation(order);
    }
}

Now testing is straightforward — no Spring needed:

// Create mock implementations
PaymentGateway mockPayment = new FakePaymentGateway();
EmailService mockEmail = new FakeEmailService();

// Create the service with its dependencies — just plain Java!
OrderService service = new OrderService(mockPayment, mockEmail);

// Test
service.placeOrder(testOrder);
// Verify mockPayment was charged, mockEmail was sent, etc.

What Happens When Spring Cannot Find a Bean?

If Spring cannot find a bean of the required type, the application will not start. You will see an error like:

***************************
APPLICATION FAILED TO START
***************************

Description:
Parameter 0 of constructor in com.example.OrderService
required a bean of type 'com.example.PaymentGateway'
that could not be found.

Action:
Consider defining a bean of type 'com.example.PaymentGateway'
in your configuration.

This is actually a feature, not a bug. Fail-fast behavior catches configuration problems at startup, not at runtime when a user hits the affected endpoint.

What If There Are Multiple Beans of the Same Type?

If you have two implementations of the same interface:

public interface PaymentGateway {
    void charge(double amount);
}

@Component
public class StripePaymentGateway implements PaymentGateway {
    public void charge(double amount) { /* Stripe logic */ }
}

@Component
public class PayPalPaymentGateway implements PaymentGateway {
    public void charge(double amount) { /* PayPal logic */ }
}

And a class depends on PaymentGateway:

@Service
public class OrderService {
    // Spring finds TWO beans of type PaymentGateway — which one to inject?
    public OrderService(PaymentGateway paymentGateway) { ... }
}

Spring will fail with a NoUniqueBeanDefinitionException. You have three ways to resolve this:

Option 1: @Primary — Mark one implementation as the default:

@Component
@Primary  // This will be injected when there are multiple candidates
public class StripePaymentGateway implements PaymentGateway { ... }

Option 2: @Qualifier — Specify which one you want by name:

@Service
public class OrderService {
    public OrderService(
            @Qualifier("payPalPaymentGateway") PaymentGateway paymentGateway) {
        // Spring will inject PayPalPaymentGateway specifically
    }
}

Option 3: Match by parameter name — Name your constructor parameter to match the bean name:

@Service
public class OrderService {
    // Parameter name "stripePaymentGateway" matches the bean name
    public OrderService(PaymentGateway stripePaymentGateway) { ... }
}

For most cases, @Primary is the simplest approach. Use @Qualifier when you need explicit control.


How Auto-Configuration Works Behind the Scenes

The Magic Explained

Auto-configuration is what makes Spring Boot feel like magic. When you add spring-boot-starter-web to your pom.xml, Spring Boot automatically:

  • Configures an embedded Tomcat server
  • Sets up Spring MVC with default settings
  • Registers Jackson for JSON serialization
  • Configures default error handling

But how does it know to do this?

The Mechanism

The @SpringBootApplication annotation includes @EnableAutoConfiguration, which triggers the auto-configuration process. Here is what happens:

  1. Spring Boot looks at your classpath — the JAR files in your project’s dependencies.
  2. It checks a list of auto-configuration classes (there are hundreds) and evaluates conditions for each.
  3. If the conditions are met (certain classes are present, certain beans do not already exist), it creates and configures the beans automatically.

For example, the DataSourceAutoConfiguration class has logic like this (simplified):

IF class "DataSource" is on the classpath
AND a property "spring.datasource.url" is configured
AND no bean of type DataSource already exists
THEN create a DataSource bean automatically

Conditional Annotations

Auto-configuration classes use conditional annotations to decide whether to activate:

// Only activate if the class HikariDataSource is on the classpath
@ConditionalOnClass(HikariDataSource.class)

// Only activate if no bean of type DataSource already exists
@ConditionalOnMissingBean(DataSource.class)

// Only activate if the property is set
@ConditionalOnProperty(name = "spring.datasource.url")

This means:

  • If you do not add a database driver to your pom.xml, no DataSource is configured.
  • If you create your own DataSource bean, Spring Boot’s auto-configuration backs off and uses yours instead.
  • If you do not set spring.datasource.url, the auto-configuration skips database setup.

Seeing Auto-Configuration in Action

You can see exactly what was auto-configured by adding this to application.properties:

# Shows which auto-configurations were applied and which were skipped
debug=true

When you restart the application, you will see a detailed report in the console:

============================
CONDITIONS EVALUATION REPORT
============================

Positive matches:  (configurations that WERE applied)
-----------------
   JacksonAutoConfiguration matched:
      - @ConditionalOnClass found required class 'com.fasterxml.jackson.databind.ObjectMapper'

   WebMvcAutoConfiguration matched:
      - @ConditionalOnClass found required class 'jakarta.servlet.Servlet'

Negative matches:  (configurations that were SKIPPED)
-----------------
   DataSourceAutoConfiguration:
      - @ConditionalOnClass did not find required class 'javax.sql.DataSource'

This report is incredibly useful for debugging. If something is not working as expected, check here first.

The Key Takeaway

Auto-configuration is not magic — it is just conditional bean creation. Spring Boot checks what libraries you have, checks what beans you have already defined, and fills in the gaps with sensible defaults. You can always override any auto-configured bean by defining your own.


application.properties vs application.yml

Spring Boot supports two formats for configuration files. They are functionally identical — the difference is purely about syntax preference.

application.properties

This is the traditional Java properties format — key-value pairs:

# Server configuration
server.port=8080
server.servlet.context-path=/api

# Database configuration (we will use this in Lecture 5)
spring.datasource.url=jdbc:mariadb://localhost:3306/blogdb
spring.datasource.username=root
spring.datasource.password=secret

# JPA configuration
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Logging
logging.level.root=INFO
logging.level.com.example.blogapi=DEBUG

Pros:

  • Simple and flat
  • Familiar to Java developers
  • Easy to set individual properties from command line

application.yml

This is YAML format — uses indentation to represent hierarchy:

# Server configuration
server:
  port: 8080
  servlet:
    context-path: /api

# Database configuration
spring:
  datasource:
    url: jdbc:mariadb://localhost:3306/blogdb
    username: root
    password: secret
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
    properties:
      hibernate:
        format_sql: true

# Logging
logging:
  level:
    root: INFO
    com.example.blogapi: DEBUG

Pros:

  • Better visual hierarchy — nested properties are easier to read
  • Less repetition — you do not repeat the prefix for every property
  • Supports complex data structures (lists, maps) more naturally

Which One to Choose?

Both work exactly the same way. The Spring community leans slightly toward YAML for new projects because it is more readable, especially as the configuration grows. In this series, we will use application.properties for its simplicity, but feel free to use YAML if you prefer.

Important: Do not use both files in the same project. If both exist, application.properties takes priority, which can cause confusion.

Custom Properties

You can define your own custom properties and read them in your code:

# Custom properties
blog.app.name=My Blog API
blog.app.version=1.0.0
blog.app.max-posts-per-page=20

Read them in your code with @Value:

@Service
public class AppInfoService {

    // @Value injects the property value from application.properties.
    // The syntax ${property.name} references a property key.
    // The part after ":" is the default value if the property is not found.
    @Value("${blog.app.name:Default Blog}")
    private String appName;

    @Value("${blog.app.version:0.0.1}")
    private String appVersion;

    @Value("${blog.app.max-posts-per-page:10}")
    private int maxPostsPerPage;

    public String getAppInfo() {
        return appName + " v" + appVersion;
    }
}

For a group of related properties, use @ConfigurationProperties (a more type-safe approach):

@Component
@ConfigurationProperties(prefix = "blog.app")
public class BlogProperties {

    // Spring maps blog.app.name → name, blog.app.version → version, etc.
    // Field names must match the property names (after the prefix).
    private String name;
    private String version;
    private int maxPostsPerPage;

    // Getters and setters are required for @ConfigurationProperties
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public String getVersion() { return version; }
    public void setVersion(String version) { this.version = version; }

    public int getMaxPostsPerPage() { return maxPostsPerPage; }
    public void setMaxPostsPerPage(int maxPostsPerPage) {
        this.maxPostsPerPage = maxPostsPerPage;
    }
}

Then inject BlogProperties wherever you need it:

@Service
public class PostService {

    private final BlogProperties blogProperties;

    public PostService(BlogProperties blogProperties) {
        this.blogProperties = blogProperties;
    }

    public int getPageSize() {
        return blogProperties.getMaxPostsPerPage();
    }
}


Profiles — Managing Different Environments

The Problem

Your application behaves differently in different environments:

  • Development: You want detailed logging, an in-memory database, and relaxed security.
  • Testing: You want a test database and mock external services.
  • Production: You want optimized performance, a real database, strict security, and minimal logging.

Hardcoding these settings means changing code every time you deploy to a different environment. That is error-prone and tedious.

Spring Profiles to the Rescue

profile is a named set of configuration that can be activated or deactivated. You create profile-specific configuration files by adding the profile name as a suffix:

src/main/resources/
├── application.properties          ← Always loaded (common settings)
├── application-dev.properties      ← Loaded when "dev" profile is active
├── application-test.properties     ← Loaded when "test" profile is active
└── application-prod.properties     ← Loaded when "prod" profile is active

application.properties (common settings — always loaded):

# Settings shared across all environments
blog.app.name=My Blog API
server.servlet.context-path=/api

application-dev.properties (development settings):

# Development-specific settings
server.port=8080
logging.level.root=DEBUG
logging.level.com.example.blogapi=TRACE
spring.datasource.url=jdbc:mariadb://localhost:3306/blog_dev
spring.datasource.username=root
spring.datasource.password=devpassword
spring.jpa.show-sql=true

application-prod.properties (production settings):

# Production-specific settings
server.port=80
logging.level.root=WARN
logging.level.com.example.blogapi=INFO
spring.datasource.url=jdbc:mariadb://prod-db-server:3306/blog_prod
spring.datasource.username=blog_app
spring.datasource.password=${DB_PASSWORD}
spring.jpa.show-sql=false

Notice the ${DB_PASSWORD} in the production config — this reads the value from an environment variable. You should never hardcode production passwords in configuration files.

Activating a Profile

There are several ways to activate a profile:

Method 1: In application.properties

# Set the default active profile
spring.profiles.active=dev

Method 2: Command-line argument

java -jar blog-api.jar --spring.profiles.active=prod

Method 3: Environment variable

export SPRING_PROFILES_ACTIVE=prod
java -jar blog-api.jar

Method 4: In your IDE

In IntelliJ IDEA: Run Configuration → Environment Variables → Add SPRING_PROFILES_ACTIVE=dev

Profile-Specific Beans

You can also create beans that only exist in specific profiles:

@Service
@Profile("dev")
public class FakeEmailService implements EmailService {

    // In development, just print to console instead of sending real emails
    @Override
    public void sendEmail(String to, String subject, String body) {
        System.out.println("=== FAKE EMAIL ===");
        System.out.println("To: " + to);
        System.out.println("Subject: " + subject);
        System.out.println("Body: " + body);
        System.out.println("==================");
    }
}

@Service
@Profile("prod")
public class SmtpEmailService implements EmailService {

    // In production, send real emails via SMTP
    @Override
    public void sendEmail(String to, String subject, String body) {
        // Real SMTP email sending logic
    }
}

When the dev profile is active, Spring creates FakeEmailService. When prod is active, it creates SmtpEmailService. The rest of your code just depends on the EmailService interface and does not care which implementation is being used — that is the power of Dependency Injection combined with profiles.

Best Practices for Profiles

  • Use dev as the default profile for local development.
  • Never commit production passwords to your repository. Use environment variables.
  • Keep the common application.properties minimal — only settings that are truly shared.
  • You can activate multiple profiles: spring.profiles.active=prod,email,monitoring

Hands-on: Building a Simple Service Layer with DI

Let us put everything together by building a structured, multi-layer application. We will create a simple user management system that follows the Controller → Service → Repository pattern.

Step 1: Create the User Model

File: src/main/java/com/example/blogapi/model/User.java

package com.example.blogapi.model;

// This is a simple Java class (POJO — Plain Old Java Object).
// It represents a user in our application.
// In later lectures, we will add JPA annotations to map this to a database table.
public class User {

    private Long id;
    private String name;
    private String email;

    // Default constructor — required for JSON deserialization.
    // When a client sends JSON data, Jackson needs this to create a User object.
    public User() {
    }

    // Parameterized constructor — convenient for creating User objects in code.
    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }

    // Getters and setters — required for Jackson to convert between
    // Java objects and JSON. Jackson reads getters for serialization
    // (Java → JSON) and uses setters for deserialization (JSON → Java).

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }

    // toString() is useful for logging and debugging
    @Override
    public String toString() {
        return "User{id=" + id + ", name='" + name + "', email='" + email + "'}";
    }
}

Step 2: Create the Repository Layer

File: src/main/java/com/example/blogapi/repository/UserRepository.java

package com.example.blogapi.repository;

import com.example.blogapi.model.User;
import org.springframework.stereotype.Repository;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;

// @Repository marks this as a data access component.
// Spring will create a singleton instance and manage it.
// For now, we use an in-memory List to simulate a database.
// In Lecture 5, we will replace this with Spring Data JPA + MariaDB.
@Repository
public class UserRepository {

    // Simulated database — a simple list stored in memory.
    // WARNING: This data is lost when the application restarts!
    // A real database solves this. We will add MariaDB in Lecture 5.
    private final List<User> users = new ArrayList<>();

    // Auto-incrementing ID counter, simulating a database sequence
    private long nextId = 1;

    // Save a user — similar to SQL INSERT
    public User save(User user) {
        user.setId(nextId++);
        users.add(user);
        return user;
    }

    // Find a user by ID — similar to SQL SELECT WHERE id = ?
    // Returns Optional because the user might not exist.
    // Optional forces the caller to handle the "not found" case explicitly.
    public Optional<User> findById(Long id) {
        return users.stream()
                .filter(user -> user.getId().equals(id))
                .findFirst();
    }

    // Find all users — similar to SQL SELECT *
    // Returns an unmodifiable list to prevent external code from
    // accidentally modifying our "database"
    public List<User> findAll() {
        return Collections.unmodifiableList(users);
    }

    // Delete a user by ID — similar to SQL DELETE WHERE id = ?
    // Returns true if a user was found and removed, false otherwise
    public boolean deleteById(Long id) {
        return users.removeIf(user -> user.getId().equals(id));
    }

    // Count total users — similar to SQL SELECT COUNT(*)
    public long count() {
        return users.size();
    }
}

Step 3: Create the Service Layer

File: src/main/java/com/example/blogapi/service/UserService.java

package com.example.blogapi.service;

import com.example.blogapi.model.User;
import com.example.blogapi.repository.UserRepository;
import org.springframework.stereotype.Service;

import java.util.List;

// @Service marks this class as a business logic component.
// It sits between the Controller (HTTP layer) and the Repository (data layer).
// All business rules, validation, and orchestration logic goes here.
@Service
public class UserService {

    // Dependency declared as a final field — it cannot be changed after construction
    private final UserRepository userRepository;

    // Constructor injection — Spring sees this constructor, finds a bean of type
    // UserRepository in the Application Context, and passes it in.
    // Since this is the ONLY constructor, @Autowired is not needed.
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    // Business method: Create a new user with validation
    public User createUser(String name, String email) {

        // Business rule: name must not be empty
        if (name == null || name.trim().isEmpty()) {
            throw new IllegalArgumentException("User name cannot be empty");
        }

        // Business rule: email must contain @
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("Invalid email address: " + email);
        }

        // Business rule: trim whitespace from name
        name = name.trim();

        // Create the user and delegate to repository for storage
        User user = new User(name, email.trim().toLowerCase());
        return userRepository.save(user);
    }

    // Business method: Get a user by ID
    // Throws an exception if not found — the controller will handle this
    public User getUserById(Long id) {
        return userRepository.findById(id)
                .orElseThrow(() -> new RuntimeException(
                        "User not found with id: " + id));
    }

    // Business method: Get all users
    public List<User> getAllUsers() {
        return userRepository.findAll();
    }

    // Business method: Delete a user
    public void deleteUser(Long id) {
        boolean deleted = userRepository.deleteById(id);
        if (!deleted) {
            throw new RuntimeException("User not found with id: " + id);
        }
    }

    // Business method: Get user count
    public long getUserCount() {
        return userRepository.count();
    }
}

Step 4: Create the Controller Layer

File: src/main/java/com/example/blogapi/controller/UserController.java

package com.example.blogapi.controller;

import com.example.blogapi.model.User;
import com.example.blogapi.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

// @RestController = @Controller + @ResponseBody
// Every method returns data (JSON), not a view (HTML template)
@RestController
@RequestMapping("/api/users")  // Base path — all endpoints in this controller start with /api/users
public class UserController {

    private final UserService userService;

    // Constructor injection — Spring injects the UserService bean
    public UserController(UserService userService) {
        this.userService = userService;
    }

    // GET /api/users — Retrieve all users
    @GetMapping
    public List<User> getAllUsers() {
        // The controller simply delegates to the service.
        // No business logic here — that belongs in the service layer.
        return userService.getAllUsers();
    }

    // GET /api/users/{id} — Retrieve a specific user
    // @PathVariable extracts the {id} from the URL path.
    // Example: GET /api/users/42 → id = 42
    @GetMapping("/{id}")
    public User getUserById(@PathVariable Long id) {
        return userService.getUserById(id);
    }

    // POST /api/users — Create a new user
    // @RequestBody tells Spring to parse the HTTP request body (JSON)
    // and convert it into a Map object.
    //
    // ResponseEntity gives us control over the HTTP status code.
    // We return 201 (Created) instead of 200 (OK) because a new resource was created.
    @PostMapping
    public ResponseEntity<User> createUser(@RequestBody Map<String, String> request) {
        String name = request.get("name");
        String email = request.get("email");
        User createdUser = userService.createUser(name, email);

        // Return the created user with HTTP 201 status
        return new ResponseEntity<>(createdUser, HttpStatus.CREATED);
    }

    // DELETE /api/users/{id} — Delete a user
    // Returns a confirmation message instead of the deleted user
    @DeleteMapping("/{id}")
    public Map<String, String> deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
        Map<String, String> response = new HashMap<>();
        response.put("message", "User deleted successfully");
        response.put("deletedId", id.toString());
        return response;
    }

    // GET /api/users/count — Get total user count
    @GetMapping("/count")
    public Map<String, Long> getUserCount() {
        Map<String, Long> response = new HashMap<>();
        response.put("count", userService.getUserCount());
        return response;
    }
}

Step 5: Understanding the Dependency Chain

Let us visualize how Spring wires everything together when the application starts:

Spring scans packages and finds:
   - UserRepository  (@Repository)
   - UserService     (@Service) — needs UserRepository
   - UserController  (@RestController) — needs UserService

Spring creates beans in dependency order:
   a) UserRepository is created first (no dependencies)
   b) UserService is created, receives UserRepository via constructor
   c) UserController is created, receives UserService via constructor

HTTP request flow:

   Client sends: POST /api/users {"name":"Alice","email":"alice@example.com"}
        ↓
   UserController.createUser() — extracts name and email from JSON
        ↓
   UserService.createUser() — validates input, creates User object
        ↓
   UserRepository.save() — stores the user, assigns ID
        ↓
   Response flows back up: User{id=1, name="Alice", email="alice@example.com"}
        ↓
   Client receives: HTTP 201 {"id":1,"name":"Alice","email":"alice@example.com"}

Step 6: Test with curl or Postman

Start the application and test each endpoint:

# Create a user
curl -X POST http://localhost:8080/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice", "email": "alice@example.com"}'

# Expected response (HTTP 201):
# {"id":1,"name":"Alice","email":"alice@example.com"}

# Create another user
curl -X POST http://localhost:8080/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Bob", "email": "bob@example.com"}'

# Expected response (HTTP 201):
# {"id":2,"name":"Bob","email":"bob@example.com"}

# Get all users
curl http://localhost:8080/api/users

# Expected response:
# [{"id":1,"name":"Alice","email":"alice@example.com"},
#  {"id":2,"name":"Bob","email":"bob@example.com"}]

# Get a specific user
curl http://localhost:8080/api/users/1

# Expected response:
# {"id":1,"name":"Alice","email":"alice@example.com"}

# Get user count
curl http://localhost:8080/api/users/count

# Expected response:
# {"count":2}

# Delete a user
curl -X DELETE http://localhost:8080/api/users/1

# Expected response:
# {"message":"User deleted successfully","deletedId":"1"}

# Try to create a user with invalid data
curl -X POST http://localhost:8080/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "", "email": "invalid"}'

# Expected response (HTTP 500 — we will improve error handling in Lecture 8):
# {"timestamp":"...","status":500,"error":"Internal Server Error",...}

Step 7: Exercises

Try these exercises to reinforce the concepts from this lecture:

  1. Add an update method: Add a PUT /api/users/{id} endpoint that updates an existing user’s name and email. You will need to add an update method to the repository and service layers.
  2. Add a search method: Add a GET /api/users/search?name=Ali endpoint that returns all users whose name contains the search string (case-insensitive). Add a findByNameContaining method to the repository.
  3. Add logging: Use System.out.println (we will use proper logging in Lecture 9) to print messages in each layer when a request is processed. Observe the flow: Controller → Service → Repository → Service → Controller.
  4. Create a profile-specific bean: Create a WelcomeService with two implementations — one for dev profile that returns “Welcome to Dev Environment!” and one for prod profile that returns “Welcome to Production!”. Create a /welcome endpoint to test it.

Summary

This lecture covered the foundational concepts that power everything in Spring Boot:

  • Inversion of Control (IoC): Instead of your classes creating their own dependencies, the Spring container creates and provides them. This leads to loosely coupled, testable, and maintainable code.
  • Dependency Injection (DI): The mechanism that delivers dependencies to your classes. Always use constructor injection — it ensures immutability, valid state, and easy testing.
  • Application Context: The container that holds all beans (managed objects). Spring creates beans, resolves their dependencies, and manages their lifecycle automatically.
  • Stereotype Annotations: Use @Controller/@RestController for web layer, @Service for business logic, @Repository for data access, and @Component for everything else.
  • Auto-Configuration: Spring Boot examines your classpath and automatically configures beans based on what libraries are available. You can override any auto-configured bean.
  • Configuration: Use application.properties or application.yml to configure your application. Read values with @Value or @ConfigurationProperties.
  • Profiles: Create environment-specific configurations (application-dev.propertiesapplication-prod.properties) and activate them at runtime.
  • Layered Architecture: Organize your code into Controller → Service → Repository layers, each with clear responsibilities.

What is Next

In Lecture 3, we will take a deeper dive into Building REST APIs with Spring MVC. You will learn about all HTTP methods, path variables, request bodies, response entities, status codes, and how to design a clean REST API. We will expand the user management system into a proper CRUD API with best practices.


Quick Reference

ConceptDescription
IoC (Inversion of Control)Design principle where the framework controls object creation and lifecycle
DI (Dependency Injection)The framework provides dependencies to a class through constructor, setter, or field
BeanA Java object managed by the Spring IoC container
Application ContextThe container that holds and manages all beans
@ComponentGeneric stereotype — registers a class as a bean
@ServiceStereotype for business logic classes
@RepositoryStereotype for data access classes (adds exception translation)
@RestControllerStereotype for REST API controllers
@AutowiredTells Spring to inject a dependency (implicit with single constructor)
@PrimaryMarks a bean as the default when multiple candidates exist
@QualifierSpecifies which bean to inject by name
@ValueInjects a property value from configuration
@ConfigurationPropertiesBinds a group of properties to a Java class
@ProfileRestricts a bean to specific profiles
@PostConstructMethod called after bean creation and DI
@PreDestroyMethod called before bean destruction
Auto-ConfigurationSpring Boot automatically configures beans based on classpath
spring.profiles.activeProperty to activate one or more profiles

Leave a Reply

Your email address will not be published. Required fields are marked *