Building modern, efficient, and maintainable applications involves using a structured framework that simplifies common tasks such as object creation, lifecycle management, and dependency handling. This topic explains the essential Spring annotations and concepts, including their practical applications, best practices, and clear guidelines for dependency handling.
Core Stereotype Annotations
Spring’s stereotype annotations are how you tell the framework which classes to manage. Rather than isolating roles with terse bullet points, let’s explore each annotation through a narrative lens.
@Component
Imagine you’ve built a utility that transforms strings into URL-compatible slugs. You want Spring to discover and initialize this helper automatically, without you wiring it up manually. That’s what @Component does: it flags your class for inclusion in the application context. When component scanning runs, Spring spots the slugifier, instantiates it, and makes it available for injection anywhere in your code.
@Component
class Slugifier {
fun toSlug(input: String): String {
return input.trim()
.lowercase()
.replace(Regex("[^a-z0-9]+"), "-")
.trim('-')
}
}@Component
public class Slugifier {
public String toSlug(String input) {
return input.trim()
.toLowerCase()
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("^-+|-+$", "");
}
}Use @Component whenever you have a generic helper—parsers, formatters, validators—that doesn’t neatly fit a service, repository, or controller.
@Service
When your code encapsulates core business processes—like order processing, payment workflows, or user registration—@Service makes your intentions clear. Picture an OrderService that saves orders, charges a payment gateway, and emits domain events. By labeling it with @Service, you signal: this class holds business logic, and it’s a prime candidate for transactions.
@Service
class OrderService(
private val repository: OrderRepository,
private val paymentClient: PaymentGateway
) {
@Transactional
fun process(order: Order) {
repository.save(order)
paymentClient.charge(order)
// emit domain notifications, update inventory, etc.
}
}@Service
public class OrderService {
private final OrderRepository repository;
private final PaymentGateway paymentClient;
public OrderService(OrderRepository repository, PaymentGateway paymentClient) {
this.repository = repository;
this.paymentClient = paymentClient;
}
@Transactional
public void process(Order order) {
repository.save(order);
paymentClient.charge(order);
// emit domain notification, update inventory, etc.
}
}Spring treats @Service beans just like components at runtime, but this semantic distinction helps future maintainers see where business rules live.
@Repository
At the data-access layer, your focus shifts to translating database rows into domain objects and turning SQL exceptions into Spring’s unified DataAccessException. By marking a class with @Repository, Spring wraps persistence calls in exception translators and adds clarity to your architecture.
@Repository
class UserRepository(
private val jdbcTemplate: JdbcTemplate
) {
fun findById(id: String): User? =
jdbcTemplate.queryForObject(
"SELECT id,name,email FROM users WHERE id=?",
RowMapper { rs, _ -> User(rs.getString("id"), rs.getString("name"), rs.getString("email")) },
id
)
}@Repository
public class UserRepository {
private final JdbcTemplate jdbcTemplate;
public UserRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public User findById(String id) {
try {
return jdbcTemplate.queryForObject(
"SELECT id, name, email FROM users WHERE id=?",
(rs, rowNum) -> new User(
rs.getString("id"),
rs.getString("name"),
rs.getString("email")
),
id
);
} catch (EmptyResultDataAccessException e) {
return null;
}
}
}When unexpected SQL errors occur, Spring automatically converts them into unchecked exceptions that you can catch or let bubble up consistently.
@Controller and @RestController
Used for classes that handle incoming web requests and generate appropriate responses. @Controller is typically used for returning views (HTML), while @RestController is designed for APIs returning structured data like JSON. These classes map HTTP verbs (GET, POST, etc.) to function calls and serve as the interface between clients and the internal system.
@RestController
@RequestMapping("/api/users")
class UserController(
private val userService: UserService
) {
@GetMapping
fun getAllUsers(): List<UserDto> = userService.findAll().map { it.toDto() }
@PostMapping
fun createUser(@RequestBody input: CreateUserRequest): UserDto =
userService.create(input.toDomain()).toDto()
}@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public List<UserDto> getAllUsers() {
return userService.findAll().stream()
.map(User::toDto)
.collect(Collectors.toList());
}
@PostMapping
public UserDto createUser(@RequestBody CreateUserRequest input) {
return userService.create(input.toDomain()).toDto();
}
}Behind the scenes, Spring wires in validation, binding, and error handling, so your methods can stay focused on request and response logic.
Configuration and Bean Factories
Sometimes you need to control exactly how and when beans are created. Configuration classes with @Bean methods give you that power.
When you annotate a class with @Configuration, Spring uses CGLIB to wrap it in a proxy. Any time you call a @Beanmethod on that class—whether externally or from another method within—the proxy intercepts the call and returns the already-managed singleton instance. This preserves the singleton guarantee even for internal method invocations.
@Configuration
class AppConfig {
@Bean
fun objectMapper(): ObjectMapper =
ObjectMapper().registerKotlinModule()
@Bean
fun clock(): Clock = Clock.systemUTC()
@Bean
@Profile("dev")
fun devHelper(): DevHelper = DevHelper()
}@Configuration
public class AppConfig {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper().registerModule(new JavaTimeModule());
}
@Bean
public Clock clock() {
return Clock.systemUTC();
}
@Bean
@Profile("dev")
public DevHelper devHelper() {
return new DevHelper();
}
}In contrast, placing @Bean in a plain @Component skips proxying. Every call to that method spawns a fresh instance—a subtle pitfall worth avoiding unless you explicitly want prototype behavior.
Lifecycle hooks like @PostConstruct and @PreDestroy let you run initialization or cleanup logic on any Spring bean. Imagine preloading a cache or closing external connections:
@Component
class CacheManager {
@PostConstruct
fun loadCache() { /* fetch and store frequently accessed data */ }
@PreDestroy
fun clearCache() { /* write back or free resources */ }
}@Component
public class CacheManager {
@PostConstruct
public void loadCache() { /* fetch and store frequently accessed data */ }
@PreDestroy
public void clearCache() { /* write back or free resources */ }
}Bean scopes (singleton, prototype, web-specific scopes) and profiles (dev, prod, test) allow you to shape your application for different environments and lifecycles.
Dependency Injection Patterns
Dependency Injection (DI) is a fundamental principle for decoupling object creation from usage. It improves testability, promotes clear API design, and enables better separation of concerns.
Constructor Injection (Recommended)
Constructor injection is the most idiomatic and preferred approach. Dependencies are declared as constructor parameters, making them explicit, immutable, and enforced at compile time.
@Service
class NotificationService(
private val emailSender: EmailSender,
private val smsSender: SmsSender
) {
fun notify(user: User, message: String) {
emailSender.send(user.email, message)
smsSender.send(user.phone, message)
}
}@Service
public class NotificationService {
private final EmailSender emailSender;
private final SmsSender smsSender;
public NotificationService(EmailSender emailSender, SmsSender smsSender) {
this.emailSender = emailSender;
this.smsSender = smsSender;
}
public void notify(User user, String message) {
emailSender.send(user.getEmail(), message);
smsSender.send(user.getPhone(), message);
}
}This approach allows for simple testing with constructor arguments, and avoids the need for mutable state.
Setter Injection
Setter injection via @Autowired on methods fits scenarios where a dependency truly is optional or needs to be swapped later—though this introduces mutable state and should be used sparingly:
@Component
class ReportScheduler {
private var generator: ReportGenerator? = null
@Autowired(required = false)
fun setGenerator(gen: ReportGenerator) { generator = gen }
fun schedule() { generator?.generate() }
}@Component
public class ReportScheduler {
private ReportGenerator generator;
@Autowired(required = false)
public void setGenerator(ReportGenerator gen) {
this.generator = gen;
}
public void schedule() {
if (generator != null) {
generator.generate();
}
}
}Field Injection
Field injection injects dependencies directly into class properties. Although concise, this method is generally discouraged in modern Spring development because it relies on reflection, breaks immutability, and makes unit testing harder.
@Component
class AlertManager {
@Autowired
lateinit var smsSender: SmsSender
}@Component
public class AlertManager {
@Autowired
private SmsSender smsSender;
}It also hides the required dependencies from the constructor signature, making code harder to reason about.
Disambiguation and Configuration Binding
Large applications often have multiple implementations of the same interface or complex configuration properties.
When two beans implement the same contract, @Primary designates a default, while @Qualifier picks a specific one by name:
@Component
@Primary
class FastPayment : PaymentProcessor { /* fast path */ }
@Component("secure")
class SecurePayment : PaymentProcessor { /* extra checks */ }
@Service
class Checkout(
@Qualifier("secure")
private val processor: PaymentProcessor
) { /* uses the secure path */ }@Component
@Primary
class FastPayment implements PaymentProcessor { /* fast path */ }
@Component("secure")
class SecurePayment implements PaymentProcessor { /* extra checks */ }
@Service
class Checkout {
private final PaymentProcessor processor;
public Checkout(@Qualifier("secure") PaymentProcessor processor) {
this.processor = processor;
}
/* uses the secure path */
}For configuration properties, @Value handles simple values, but @ConfigurationProperties shines when you bind an entire group of settings into a class, complete with type safety and default values:
@ConfigurationProperties(prefix = "storage")
class StorageProps {
lateinit var bucket: String
var timeoutSeconds: Long = 30
}
@Configuration
@EnableConfigurationProperties(StorageProps::class)
class StorageConfig(private val props: StorageProps) {
@Bean
fun storageClient(): StorageClient = StorageClient(props.bucket, Duration.ofSeconds(props.timeoutSeconds))
}@ConfigurationProperties(prefix = "storage")
public class StorageProps {
private String bucket;
private long timeoutSeconds = 30;
public String getBucket() {
return bucket;
}
public void setBucket(String bucket) {
this.bucket = bucket;
}
public long getTimeoutSeconds() {
return timeoutSeconds;
}
public void setTimeoutSeconds(long timeoutSeconds) {
this.timeoutSeconds = timeoutSeconds;
}
}
@Configuration
@EnableConfigurationProperties(StorageProps.class)
public class StorageConfig {
private final StorageProps props;
public StorageConfig(StorageProps props) {
this.props = props;
}
@Bean
public StorageClient storageClient() {
return new StorageClient(props.getBucket(), Duration.ofSeconds(props.getTimeoutSeconds()));
}
}Proxy-Backed Features and Self-Reference
Framework-level features such as @Transactional, @Async, and @Cacheable are implemented using proxy objects. When a method inside a class calls another method within the same class directly, the proxy is bypassed — and the annotation won’t have any effect.
Self-injection is a strategy that allows you to invoke those methods via the proxy, ensuring the annotations are respected.
@Service
class AccountManager(@Lazy private val self: AccountManager) {
@Transactional
fun transfer(from: Account, to: Account, amount: Double) {
debit(from, amount)
self.credit(to, amount) // @Transactional will be applied
}
@Transactional
fun credit(account: Account, amount: Double) {
// This runs within a transaction
}
private fun debit(account: Account, amount: Double) {
// Internal logic without transaction requirement
}
}@Service
public class AccountManager {
private final AccountManager self;
public AccountManager(@Lazy AccountManager self) {
this.self = self;
}
@Transactional
public void transfer(Account from, Account to, double amount) {
debit(from, amount);
self.credit(to, amount); // @Transactional will be applied
}
@Transactional
public void credit(Account account, double amount) {
// This runs within a transaction
}
private void debit(Account account, double amount) {
// Internal logic without transaction requirement
}
}This approach ensures transactional boundaries are preserved. Without self-injection, the internal credit() call would skip the proxy and execute without a transaction — which could lead to data inconsistency.
Self-injection is useful when you want annotations like @Transactional, @Async, or @Cacheable to behave as expected. This is particularly important when calling an annotated method from within the same class, as direct calls bypass the proxy that handles those annotations. It also applies when cross-cutting concerns—such as logging, security, or monitoring—are implemented via proxy mechanisms.
Final Thoughts
Applying clear annotations (@Component, @Service, @Repository, @RestController), controlled bean setup (@Configuration/@Bean), and preferred injection (constructor over field) makes your code predictable and testable. Use qualifiers and configuration properties to manage complexity, and self-injection to honor proxies (@Transactional, @Async). These practices ensure your Spring apps stay modular, maintainable, and ready to grow.