Spring’s core idea is dependency injection — your classes declare what they need and the framework supplies it, rather than each class constructing its own dependencies. Everything else Spring does is built on that. Spring Boot then removes the configuration that historically made Spring intimidating.
The problem it solves#
public class OrderService {
private final EmailSender email = new SmtpEmailSender("smtp.example.com");
private final OrderRepository repo = new JdbcOrderRepository(dataSource);
public void place(Order order) {
repo.save(order);
email.send(order.getCustomerEmail(), "Order confirmed");
}
}
Three problems. The class is tied to those exact implementations. Testing it sends real email. And the SMTP host is buried inside business logic.
@Service
public class OrderService {
private final EmailSender email;
private final OrderRepository repo;
public OrderService(EmailSender email, OrderRepository repo) {
this.email = email;
this.repo = repo;
}
public void place(Order order) {
repo.save(order);
email.send(order.getCustomerEmail(), "Order confirmed");
}
}
Now the class states what it needs and someone else decides what to supply. In production Spring supplies the real implementations; in a test you pass fakes. The class itself did not change.
Beans and the container#
A bean is any object Spring manages. At start-up, Spring scans your packages, creates the beans, works out what each one needs, and wires them together. That collection is the application context.
@Component // a generic managed object
@Service // business logic
@Repository // data access - also translates database exceptions
@Controller // handles web requests
@RestController // a controller returning data rather than views
All four of the specific ones are @Component underneath. Using the precise one documents intent and, in the case of @Repository, adds behaviour.
For a class you do not own, declare it in a configuration class:
@Configuration
public class AppConfig {
@Bean
public ObjectMapper objectMapper() {
return JsonMapper.builder()
.addModule(new JavaTimeModule())
.build();
}
@Bean
public RestClient restClient(RestClient.Builder builder) {
return builder.baseUrl("https://api.example.com").build();
}
}
Note that the second method takes a parameter. Spring supplies it — injection works for bean methods as well as constructors.
Constructor injection#
// Preferred
@Service
public class OrderService {
private final OrderRepository repo;
public OrderService(OrderRepository repo) {
this.repo = repo;
}
}
// Avoid
@Service
public class OrderService {
@Autowired
private OrderRepository repo;
}
Three reasons the first is better. The field can be final, so it cannot change. The dependency is visible in the signature, so nobody can construct the object in an invalid state. And you can create it in a test with new OrderService(fake), with no Spring involved at all.
The @Autowired annotation on a constructor is optional when the class has only one, which is why you rarely see it in modern code.
When there is more than one candidate#
public interface EmailSender { void send(String to, String body); }
@Service
public class SmtpEmailSender implements EmailSender { }
@Service
public class LoggingEmailSender implements EmailSender { }
Spring cannot choose, and start-up fails with an unsatisfied dependency error. Three ways to resolve it:
// 1. Mark one as the default
@Service
@Primary
public class SmtpEmailSender implements EmailSender { }
// 2. Name them and ask by name
@Service("logging")
public class LoggingEmailSender implements EmailSender { }
public OrderService(@Qualifier("logging") EmailSender email) { }
// 3. Choose by environment
@Service
@Profile("dev")
public class LoggingEmailSender implements EmailSender { }
You can also inject all of them at once, which is how plugin-style designs work:
public NotificationService(List<EmailSender> senders) { }
Scopes#
| Scope | One instance per |
|---|---|
singleton (default) |
application |
prototype |
injection point |
request |
HTTP request |
session |
user session |
Nearly everything is a singleton, which has an important consequence: beans must be stateless. A mutable field on a service is shared by every concurrent request, which is a race condition waiting to happen.
Configuration#
@ConfigurationProperties(prefix = "email")
public record EmailProperties(String host, int port, String from) { }
email.host=smtp.example.com
email.port=587
email.from=noreply@example.com
@Service
public class SmtpEmailSender implements EmailSender {
private final EmailProperties props;
public SmtpEmailSender(EmailProperties props) {
this.props = props;
}
}
Typed configuration beats scattered value lookups: a missing or misspelled property fails at start-up rather than at the moment you try to send an email.
Spring Boot#
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
That one annotation combines three: component scanning from this package downwards, auto-configuration, and marking the class as a configuration source.
Auto-configuration is the part that feels like magic. Spring Boot looks at what is on your classpath and configures it. A database driver plus a connection URL gives you a working DataSource. The web starter gives you an embedded server. Nothing is generated — it is conditional configuration that activates when the conditions are met.
# See exactly what it decided and why
java -jar app.jar --debug
How a request travels#
HTTP request
-> DispatcherServlet Spring's single front door
-> HandlerMapping which controller method matches this URL
-> Interceptors and filters authentication, logging
-> Controller method your code
-> Service business logic
-> Repository database
-> return value
-> Message converter object to JSON
-> HTTP response
Understanding this sequence is what turns Spring from a black box into something you can debug. When a request behaves unexpectedly, you can name the stage to look at.
Transactions#
@Service
public class TransferService {
@Transactional
public void transfer(long from, long to, BigDecimal amount) {
accounts.debit(from, amount);
accounts.credit(to, amount);
}
}
Both updates commit together or neither does. Two things catch people out: the annotation only works when the method is called from outside the class, because it relies on a proxy; and by default a rollback happens only for unchecked exceptions.
Questions people ask#
What is the difference between Spring and Spring Boot?
Spring is the framework. Spring Boot is a layer on top that configures it with sensible defaults, bundles dependencies into starters, and embeds a web server. Almost nobody starts a new project with plain Spring.
Why is my bean null?
Almost always because you created the object with new instead of letting Spring inject it. Spring only populates beans it manages.
Is Spring heavy?
Start-up takes a few seconds and memory use is higher than a minimal framework. In exchange you get a very large amount of solved problems. Quarkus and Micronaut trade some of that for faster start-up.
Do I need to understand the whole framework?
No. Dependency injection, controllers, services, repositories and configuration properties cover most day-to-day work. Add the rest when you meet a problem that needs it.
Where to go next#
- Java web development — building on this foundation.
- Java testing — where injection pays off.
- Object-oriented programming in Java — interfaces and why they matter here.