We closed module 1 with a promise: to stop using Spring Boot by imitation. In CicloUrbana we have already written @SpringBootApplication, @RestController, @Component, @Order and @EventListener without explaining what they are or how they work. This lesson settles that debt. We will see what an annotation actually is in Java, how Spring reads them at startup (spoiler: with reflection and a little bytecode analysis, not magic), take @SpringBootApplication apart piece by piece, compare the five stereotypes, understand when to declare beans with @Bean instead of @Component, and build our own stereotype annotation. We will finish by applying all of it to CicloUrbana: splitting the provisional StationStore into a StationService and an InMemoryStationRepository, which is the structure the project will carry for the rest of the course.

Contents

  1. What an annotation is in Java
  2. How Spring processes annotations at startup
  3. @SpringBootApplication taken apart
  4. Spring's stereotypes
  5. @Repository and exception translation
  6. @Configuration and @Bean: the declarative route
  7. @ComponentScan and its filters
  8. Building your own stereotype annotation
  9. Refactoring CicloUrbana: service and repository
  10. Annotation map for the course
  11. Common Mistakes and Tips
  12. Exercises

  1. What an annotation is in Java

An annotation is metadata attached to code: information about a class, a method, a field or a parameter that the compiler stores in the .class file and that another program can read later. On its own, an annotation does absolutely nothing. It runs no code, it changes no method behaviour, it creates no instances. It is a label.

This is a complete annotation, written by hand:

package com.ciclourbana.common;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.TYPE)               // can be placed on classes/interfaces
@Retention(RetentionPolicy.RUNTIME)     // survives until runtime
public @interface Provisional {
    String until() default "module-4";  // attribute with a default value
}

And this is how you use it:

@Provisional(until = "module-4")
public class InMemoryStationRepository { /* ... */ }

Three details worth being clear about:

Element What it means
@interface The keyword that declares an annotation (it is not a normal interface).
@Target Where it can be placed: TYPE (class), METHOD, FIELD, PARAMETER, ANNOTATION_TYPE...
@Retention How long it lives. SOURCE (discarded by the compiler), CLASS (kept in the .class but not loaded), RUNTIME (reachable through reflection).

Every Spring annotation is RUNTIME. That is the essential condition for the container to be able to find them while the application starts. If they were SOURCE, like @Override, Spring would see nothing.

Reading an annotation at runtime is as simple as this:

Class<?> type = InMemoryStationRepository.class;
Provisional marker = type.getAnnotation(Provisional.class);

if (marker != null) {
    System.out.println("Provisional class until: " + marker.until());
    // -> Provisional class until: module-4
}

That snippet —getAnnotation on a Class<?>— is, conceptually, everything Spring does. The rest is scale and engineering.

  1. How Spring processes annotations at startup

When we looked at the refresh() phase of startup in module 1, we skated over what happens inside it. Now we can pin it down. The journey is this:

flowchart TD
    A["@SpringBootApplication on CicloUrbanaApplication"] --> B["@ComponentScan determines<br/>the base package: com.ciclourbana"]
    B --> C["ClassPathScanningCandidateComponentProvider<br/>walks the .class files on the classpath"]
    C --> D["Reads each .class with ASM<br/>(without loading the class into memory)"]
    D --> E{"Does it carry @Component<br/>directly or meta-annotated?"}
    E -- No --> F["Discarded"]
    E -- Yes --> G["Creates a BeanDefinition:<br/>name, class, scope, dependencies"]
    G --> H["Registered in the BeanDefinitionRegistry"]
    H --> I["BeanFactoryPostProcessors<br/>adjust the definitions"]
    I --> J["Singletons instantiated,<br/>resolving constructors"]
    J --> K["BeanPostProcessor:<br/>@Autowired, @Value, AOP proxies"]
    K --> L["Beans ready in the ApplicationContext"]

Three points in this flow are worth underlining:

Scanning does not load the classes. Spring uses ASM, a bytecode analysis library, to inspect .class files without putting them through the ClassLoader. That way it can quickly discard the thousands of classpath classes it has no interest in, without paying the cost of loading them all.

What gets registered first is a BeanDefinition, not an object. A BeanDefinition is a recipe: which class to instantiate, with which constructor, with which scope, with which initialisation method. The real objects are created afterwards, in a second pass. This split into two phases is what lets Spring detect configuration errors (missing dependencies, ambiguities) before building anything.

Annotations are resolved transitively. Spring does not look literally for @Component; it looks for @Component or any annotation that is itself annotated with @Component. That is called a meta-annotation, and it is the mechanism the whole stereotype system is built on. We will use it ourselves in section 8.

The practical conclusion: a Spring annotation only has an effect if the class sits where the scan can see it, or if some other mechanism registers it. A class with @Service outside the base package is dead text. That is the source of half the "it doesn't work" moments of the first few days.

  1. @SpringBootApplication taken apart

Our main class is this one:

package com.ciclourbana;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class CicloUrbanaApplication {

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

@SpringBootApplication is a convenience meta-annotation. Its real declaration, simplified, is:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = {
        @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
        @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class)
})
public @interface SpringBootApplication {
    // attributes: exclude, excludeName, scanBasePackages, proxyBeanMethods...
}

In other words, writing @SpringBootApplication is exactly the same as writing these three annotations:

package com.ciclourbana;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;

@SpringBootConfiguration          // 1. this class is a configuration class
@EnableAutoConfiguration          // 2. turns autoconfiguration on
@ComponentScan                    // 3. scans this package and everything below it
public class CicloUrbanaApplication {

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

You can make this substitution in CicloUrbana right now, start the application and check that everything behaves the same. It is an excellent exercise for internalising that nothing is hidden.

What each one contributes:

Annotation Responsibility
@SpringBootConfiguration A specialised @Configuration. It marks the class as a source of bean definitions and, on top of that, acts as an anchor: integration tests (@SpringBootTest, module 6) search upwards through the package tree until they find it. There must be exactly one per application.
@EnableAutoConfiguration Triggers the mechanism by which Spring Boot registers beans based on what it finds on the classpath. It is the engine behind "I add spring-boot-starter-web and Tomcat appears". We will open it up completely in lesson 02-06.
@ComponentScan With no attributes, it scans the package of the annotated class and all its subpackages. That is why CicloUrbanaApplication lives in com.ciclourbana and not in com.ciclourbana.app.

The two excludeFilters it ships with stop the scan from picking up test classes and autoconfiguration classes by accident. They are not something you have to touch.

When to take the meta-annotation apart

In real projects @SpringBootApplication is almost always left as it is. The usual exceptions:

// Scan an additional package outside the com.ciclourbana tree
@SpringBootApplication(scanBasePackages = {"com.ciclourbana", "com.ribalta.payments"})
public class CicloUrbanaApplication { }

// Exclude a specific autoconfiguration (we will see this in 02-06)
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
public class CicloUrbanaApplication { }

  1. Spring's stereotypes

A stereotype is an annotation that marks a class as a bean candidate and, in addition, states what role it plays in the architecture. They all derive from @Component:

flowchart TD
    C["@Component<br/>(generic bean)"] --> S["@Service<br/>business logic"]
    C --> R["@Repository<br/>data access"]
    C --> Ctrl["@Controller<br/>MVC web layer"]
    Ctrl --> RC["@RestController<br/>= @Controller + @ResponseBody"]
    C --> Cfg["@Configuration<br/>bean factory"]

Side by side in a table:

Annotation What it adds over @Component When to use it in CicloUrbana
@Component Nothing. It is the generic base. Pieces that fit no layer in particular: StartupNotice, ArgumentReader, utilities.
@Service Nothing functional: semantics. It states that the class holds business logic. StationService, RentalService: the rules of the Ribalta network.
@Repository Automatic translation of persistence exceptions (section 5). InMemoryStationRepository today; the JPA repositories of module 4.
@Controller The DispatcherServlet detects it as a request handler; by default its methods return view names. We will not use it: CicloUrbana is an API, not a template-driven website.
@RestController @Controller + @ResponseBody: each method's return value is serialised into the response body. StationController and every controller in module 3.

One point that regularly confuses people: @Service does nothing technically. If you swap @Service for @Component in StationService, the application behaves identically. So why use it?

  • It communicates intent. Whoever opens the file knows which layer they are in without reading a line of code.
  • It allows filtering. Analysis tools, AOP aspects and @ComponentScan filters can act on "every @Service". With @Component everywhere, that distinction is lost.
  • It is the universal convention of the ecosystem. A Spring developer expects to find it.

The only one on the list that does contribute behaviour of its own is @Repository.

  1. @Repository and exception translation

Every persistence technology throws its own exceptions: JDBC throws SQLException, JPA throws PersistenceException, Hibernate throws ConstraintViolationException. If your service layer catches those exceptions, it ends up coupled to the technology: moving from JDBC to JPA would force you to rewrite the catch blocks.

Spring solves this with a hierarchy of unchecked exceptions of its own hanging off DataAccessException:

DataAccessException
├── DataIntegrityViolationException      (constraint violation, duplicate key)
├── DuplicateKeyException
├── EmptyResultDataAccessException       (one row was expected and none came back)
├── OptimisticLockingFailureException
└── CannotAcquireLockException           (lock timeout)

@Repository activates a BeanPostProcessor called PersistenceExceptionTranslationPostProcessor that wraps the bean in a proxy. That proxy intercepts the underlying technology's exceptions and translates them into Spring's hierarchy:

sequenceDiagram
    participant S as StationService
    participant P as Proxy (@Repository)
    participant R as JpaStationRepository
    participant DB as PostgreSQL

    S->>P: save(station)
    P->>R: save(station)
    R->>DB: INSERT ...
    DB-->>R: ERROR: unique constraint
    R-->>P: PersistenceException (JPA)
    P-->>S: DataIntegrityViolationException (Spring)

The practical benefit: the service writes catch (DataIntegrityViolationException e) and that code survives a change of persistence technology.

// In StationService, in module 4. It does not apply yet today.
try {
    return stationRepository.save(newStation);
} catch (DataIntegrityViolationException e) {
    // Just as valid whether JDBC, JPA or MongoDB sits underneath
    throw new DuplicateStationException(newStation.name(), e);
}

Our in-memory repository has no exceptions to translate, but annotating it with @Repository from today leaves the contract ready for module 4 and communicates its role correctly.

  1. @Configuration and @Bean: the declarative route

Component scanning only works with classes you write yourself. How do you register as a bean a class from a third-party library, whose source code you cannot annotate? That is where @Configuration + @Bean comes in.

package com.ciclourbana.common;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;

import java.time.Clock;
import java.time.ZoneId;

/**
 * CicloUrbana infrastructure beans that come from external libraries
 * and therefore cannot be annotated with @Component.
 */
@Configuration
public class CommonConfig {

    /**
     * Centralised clock. Injecting a Clock instead of calling
     * LocalDateTime.now() lets us freeze the time in tests (module 6).
     * The bean name is the method name: "ribaltaClock".
     */
    @Bean
    public Clock ribaltaClock() {
        return Clock.system(ZoneId.of("Europe/Madrid"));
    }

    /**
     * HTTP client for calling the municipal transit service.
     * We will use it for real in module 7; here it serves as an example
     * of a bean built by hand with a builder.
     */
    @Bean
    public RestClient ribaltaTransitClient() {
        return RestClient.builder()
                .baseUrl("https://transit.ribalta.example/api")
                .build();
    }
}

How it works: at startup, Spring detects the class (which is also a @Component by meta-annotation, so the scan picks it up), invokes each @Bean method and registers the returned object in the container. The bean name is the method name unless you give it another one with @Bean("someOtherName").

The key difference: proxyBeanMethods

One @Bean method can call another:

@Configuration
public class CommonConfig {

    @Bean
    public Clock ribaltaClock() {
        return Clock.system(ZoneId.of("Europe/Madrid"));
    }

    @Bean
    public AuditLog auditLog() {
        return new AuditLog(ribaltaClock());   // does it create a second Clock?
    }
}

In plain Java, ribaltaClock() would run a second time and there would be two clocks. With @Configuration, no: Spring generates a CGLIB proxy subclass of the configuration class that intercepts calls to @Bean methods and returns the singleton already registered. That behaviour is called full mode and it is the default (proxyBeanMethods = true).

If your @Bean methods never call each other, you can turn it off and save generating the proxy:

@Configuration(proxyBeanMethods = false)   // "lite" mode: slightly faster startup
public class CommonConfig { }

Spring Boot's own autoconfiguration classes use proxyBeanMethods = false systematically, precisely for that reason. We will see it in lesson 02-06.

@Component or @Bean: how to decide

Criterion @Component / @Service / @Repository @Configuration + @Bean
Who writes the class You You or a third party
Can you annotate the source? Yes Not needed
Construction Spring calls the constructor You write the new or the builder
Logic during creation No room for it Yes: conditionals, builders, computed parameters
Bean name Class name with a lowercase initial (stationService) Method name
Where it usually lives Next to its functionality In com.ciclourbana.common or another configuration package

Rule of thumb for CicloUrbana: your own class → stereotype; someone else's class or non-trivial construction → @Bean.

  1. @ComponentScan and its filters

@ComponentScan with no attributes scans the package of the annotated class downwards. Its most useful attributes:

@ComponentScan(
        basePackages = {"com.ciclourbana", "com.ribalta.common"},
        // type-safe alternative: if you move the class, the package follows
        basePackageClasses = {CicloUrbanaApplication.class},
        includeFilters = @ComponentScan.Filter(
                type = FilterType.ANNOTATION,
                classes = NetworkService.class),
        excludeFilters = @ComponentScan.Filter(
                type = FilterType.REGEX,
                pattern = "com\\.ciclourbana\\..*\\.legacy\\..*")
)

The available filter types:

FilterType Criterion Example
ANNOTATION Presence of an annotation classes = NetworkService.class
ASSIGNABLE_TYPE Being a subtype of a class/interface classes = FareCalculator.class
ASPECTJ AspectJ expression pattern = "com.ciclourbana..*Service"
REGEX Regular expression over the fully qualified name pattern = ".*MockRepository"
CUSTOM Your own TypeFilter implementation classes = MyFilter.class

A realistic use: keeping some simulation classes we only want in development out of a normal startup.

@SpringBootApplication
@ComponentScan(excludeFilters = @ComponentScan.Filter(
        type = FilterType.REGEX,
        pattern = "com\\.ciclourbana\\.simulation\\..*"))
public class CicloUrbanaApplication { }

That said, to switch sets of beans on or off depending on the environment, the idiomatic mechanism is not this one but profiles (@Profile), covered in lesson 07-02. Scan filters are for structural cases, not for varying the environment.

And a performance warning: @ComponentScan("com") or, worse, an empty root package, forces Spring to walk the entire classpath, dependencies included. Startup time explodes. Always keep the scan confined to your base package.

  1. Building your own stereotype annotation

Because Spring resolves annotations transitively, you can create stereotypes of your own that are, at the same time, @Component. In CicloUrbana we are going to mark the services that make up the core of the network (the ones handling stations, bikes and rentals) so we can locate them and, later on, apply cross-cutting policies to them.

package com.ciclourbana.common;

import org.springframework.core.annotation.AliasFor;
import org.springframework.stereotype.Service;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * Our own stereotype: marks a service belonging to the operational core
 * of the Ribalta network. It is a @Service to all intents and purposes,
 * plus a semantic label that lets us find them and treat them as a group.
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Service                                    // <-- the key meta-annotation
public @interface NetworkService {

    /** Functional area the service belongs to. */
    String area() default "general";

    /** Lets you set the bean name, exactly like @Service. */
    @AliasFor(annotation = Service.class, attribute = "value")
    String value() default "";
}

And now:

@NetworkService(area = "stations")
public class StationService { /* ... */ }

Spring registers StationService as a bean exactly as it would if it carried @Service, because @NetworkService is meta-annotated with @Service, which in turn is meta-annotated with @Component.

Two technical details:

  • @AliasFor connects an attribute of your annotation with one of the meta-annotation. Without it, @NetworkService("beanName") would have no effect on the bean name.
  • The area() attribute means nothing to Spring: it is yours. You can read it by reflection, or use it as a criterion in a scan filter or in an aspect.

Retrieving every marked bean is trivial:

Map<String, Object> coreServices =
        context.getBeansWithAnnotation(NetworkService.class);
// {stationService=..., rentalService=...}

Is it worth it? In a small project, no: it adds an indirection. In a large codebase, a well-chosen stereotype of your own documents the architecture and gives you a single hook for metrics, auditing or access rules. Use it with judgement, not for sport.

  1. Refactoring CicloUrbana: service and repository

Until now the controller talked directly to StationStore, a @Component that did everything. It is time to separate responsibilities properly, because this structure is the one that will hold up the rest of the course:

flowchart LR
    C["StationController<br/>@RestController<br/>(HTTP)"] --> S["StationService<br/>@Service<br/>(business rules)"]
    S --> I["StationRepository<br/>«interface»"]
    I -.implements.-> R["InMemoryStationRepository<br/>@Repository"]
    I -.-> J["JpaStationRepository<br/>(module 4)"]

The repository interface

package com.ciclourbana.stations;

import java.util.List;
import java.util.Optional;

/**
 * Data access contract for stations. The interface lets us swap the
 * in-memory implementation for the JPA one (module 4) without
 * touching StationService.
 */
public interface StationRepository {

    List<Station> findAll();

    Optional<Station> findById(Long id);

    Station save(Station station);

    long count();
}

The in-memory implementation

package com.ciclourbana.stations;

import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

/**
 * Provisional in-memory implementation of the station repository.
 * It will be replaced by Spring Data JPA in module 4.
 *
 * It uses ConcurrentHashMap because the bean is a singleton and several
 * concurrent HTTP requests share it (reasoned through in lesson 02-03).
 */
@Repository
public class InMemoryStationRepository implements StationRepository {

    private final Map<Long, Station> byId = new ConcurrentHashMap<>();
    private final AtomicLong nextId = new AtomicLong(0);

    @Override
    public List<Station> findAll() {
        return List.copyOf(byId.values());
    }

    @Override
    public Optional<Station> findById(Long id) {
        return Optional.ofNullable(byId.get(id));
    }

    @Override
    public Station save(Station station) {
        Long id = (station.id() != null)
                ? station.id()
                : nextId.incrementAndGet();

        // The record is immutable: if we assign an id, we build a new one
        Station toSave = (station.id() != null) ? station : new Station(
                id, station.name(), station.address(),
                station.capacity(), station.latitude(), station.longitude());

        byId.put(id, toSave);
        nextId.updateAndGet(current -> Math.max(current, id));
        return toSave;
    }

    @Override
    public long count() {
        return byId.size();
    }
}

The service

package com.ciclourbana.stations;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.util.Comparator;
import java.util.List;
import java.util.Optional;

/**
 * Business logic for the stations of the Ribalta network.
 * It knows nothing about HTTP (that belongs to the controller) nor
 * about how the data is stored (that belongs to the repository).
 */
@Service
public class StationService {

    private static final Logger log = LoggerFactory.getLogger(StationService.class);

    private final StationRepository stationRepository;

    // Constructor injection: covered in depth in lesson 02-02
    public StationService(StationRepository stationRepository) {
        this.stationRepository = stationRepository;
    }

    public List<Station> listAll() {
        return stationRepository.findAll().stream()
                .sorted(Comparator.comparing(Station::name))
                .toList();
    }

    public Optional<Station> findById(Long id) {
        return stationRepository.findById(id);
    }

    public Station register(Station station) {
        // Ribalta business rule: no station with fewer than 8 docks
        if (station.capacity() < 8) {
            throw new IllegalArgumentException(
                    "A Ribalta station requires at least 8 docks; received: "
                            + station.capacity());
        }
        Station saved = stationRepository.save(station);
        log.info("Station registered: {} ({} docks)",
                saved.name(), saved.capacity());
        return saved;
    }

    public int totalNetworkCapacity() {
        return stationRepository.findAll().stream()
                .mapToInt(Station::capacity)
                .sum();
    }

    public long count() {
        return stationRepository.count();
    }
}

The controller, now leaning on the service

package com.ciclourbana.stations;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/api/v1/stations")
public class StationController {

    private final StationService stationService;

    public StationController(StationService stationService) {
        this.stationService = stationService;
    }

    @GetMapping
    public List<Station> listStations() {
        return stationService.listAll();
    }
}

Notice the division of labour: the controller only translates HTTP, the service applies rules and sorts, the repository stores and retrieves. Each layer depends on the one below it and none on the one above.

Finally, module 1's DemoStationLoader moves over to the service and StationStore disappears from the project:

package com.ciclourbana.stations;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
@Order(1)
public class DemoStationLoader implements CommandLineRunner {

    private static final Logger log = LoggerFactory.getLogger(DemoStationLoader.class);

    private final StationService stationService;

    public DemoStationLoader(StationService stationService) {
        this.stationService = stationService;
    }

    @Override
    public void run(String... args) {
        log.info("Loading Ribalta demo stations...");

        stationService.register(new Station(1L, "Main Square",
                "Main Square 1", 24, 40.4168, -3.7038));
        stationService.register(new Station(2L, "North Station",
                "Station Avenue 3", 30, 40.4290, -3.7020));
        stationService.register(new Station(3L, "River Park",
                "Riverside Walk 12", 18, 40.4105, -3.6950));
        stationService.register(new Station(4L, "University",
                "South Campus, Gate B", 36, 40.4402, -3.7255));

        log.info("Loaded {} stations, {} docks in total",
                stationService.count(), stationService.totalNetworkCapacity());
    }
}

Remember to adjust StartupNotice in com.ciclourbana.common too, so that it injects StationService instead of the now-vanished StationStore.

Check that the whole thing still stands up:

./mvnw spring-boot:run
curl -s http://localhost:8080/api/v1/stations | head -5

And notice that the stations now come out in alphabetical order: "Main Square", "North Station", "River Park", "University". That ordering is a business decision, and that is why it lives in the service and not in the controller or the repository.

  1. Annotation map for the course

This is the catalogue of the annotations that will keep appearing, with the point in the course where each is studied in depth. Come back to this table whenever you meet one you do not recognise.

Annotation What it is for Where it is studied
@SpringBootApplication, @Component, @Service, @Repository, @Configuration, @Bean, @ComponentScan Bean declaration 02-01 (this lesson)
@Autowired, @Qualifier, @Primary, @Order Dependency injection and resolution 02-02
@Scope, @PostConstruct, @PreDestroy, @Lazy Scope and lifecycle 02-03
@Value, @PropertySource Property-based configuration 02-04
@ConfigurationProperties, @EnableConfigurationProperties, @ConfigurationPropertiesScan, @Validated Typed properties 02-05
@EnableAutoConfiguration, @ConditionalOnClass, @ConditionalOnMissingBean, @ConditionalOnProperty, @AutoConfiguration Autoconfiguration and starters 02-06
@RestController, @RequestMapping, @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, @PathVariable, @RequestParam, @RequestBody, @ResponseStatus REST web layer Module 3
@Valid, @NotBlank, @Min, @Positive, @Email Input validation 03-04
@RestControllerAdvice, @ExceptionHandler Global error handling 03-06
@Entity, @Id, @GeneratedValue, @Column, @OneToMany, @ManyToOne, @Query, @Transactional Persistence with JPA Module 4
@EnableWebSecurity, @PreAuthorize, @PostAuthorize, @Secured Security Module 5
@SpringBootTest, @WebMvcTest, @DataJpaTest, @MockitoBean, @Testcontainers Testing Module 6
@Profile, @Scheduled, @Async, @EnableScheduling, @EnableAsync Profiles, tasks and asynchrony Module 7
@Cacheable, @CacheEvict, @Timed Performance and monitoring Module 9

Common Mistakes and Tips

The class with @Service sits outside the base package. If StationService ended up in com.othercompany.services, the scan would not see it and startup would fail with NoSuchBeanDefinitionException. Rule: all the application code hangs off the package of CicloUrbanaApplication.

Annotating the interface instead of the implementation. @Repository on StationRepository (the interface) creates no bean at all: Spring needs a class it can instantiate. The annotation goes on InMemoryStationRepository. The exception is Spring Data JPA interfaces, where the framework itself generates the implementation (module 4).

Two @SpringBootApplication classes in the same project. This usually happens when a main class gets copied into src/test. It causes duplicate scans or failures in integration tests. There must be exactly one.

Expecting @Service to do something magical. It adds no transactions, no caching, no retries. Each of those behaviours needs its own annotation (@Transactional, @Cacheable, @Retryable) and its corresponding activation.

Putting @Component on domain classes. Station is data, not a bean. Domain objects are created with new as many times as needed; beans are infrastructure components, unique and managed. Confusing the two leads to designs that are impossible to reason about.

Tip: name beans by convention, not by hand. @Service("myStationsService") adds nothing over the default name stationService. Save explicit names for when you genuinely need to disambiguate (lesson 02-02).

Tip: use basePackageClasses instead of strings. @ComponentScan(basePackageClasses = StationService.class) refactors itself if you move the package; @ComponentScan("com.ciclourbana.stations") goes stale silently.

Tip: @Configuration(proxyBeanMethods = false) when there are no calls between @Bean methods. It is free and saves generating the CGLIB proxy at startup.

Exercises

Exercise 1: replace @SpringBootApplication with its three components

Replace @SpringBootApplication in CicloUrbanaApplication with the three equivalent annotations. Then check, using a temporary CommandLineRunner, that the context holds the expected beans: stationService, inMemoryStationRepository and stationController.

Exercise 2: a bean from an external library with @Bean

Create com.ciclourbana.common.CommonConfig with two beans you cannot annotate because they come from libraries: a Clock pinned to Europe/Madrid and a java.util.Random with a fixed seed (useful for reproducible simulations). Inject the Clock into a new NetworkClock component that exposes a currentInstant() method, and log the time at startup.

Exercise 3: your own stereotype with startup detection

Create the @NetworkService annotation from section 8, apply it to StationService with area = "stations" and write a CommandLineRunner called NetworkServiceInventory that lists on the console every bean marked with it, showing the bean name and its area.


Solutions

Solution 1

package com.ciclourbana;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;

@SpringBootConfiguration    // = specialised @Configuration, anchor for the tests
@EnableAutoConfiguration    // turns autoconfiguration on (lesson 02-06)
@ComponentScan              // scans com.ciclourbana and its subpackages
public class CicloUrbanaApplication {

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

And the temporary verifier:

package com.ciclourbana.common;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
@Order(100)   // after the station loader, which is @Order(1)
public class BeanVerifier implements CommandLineRunner {

    private static final Logger log = LoggerFactory.getLogger(BeanVerifier.class);

    private final ApplicationContext context;

    public BeanVerifier(ApplicationContext context) {
        this.context = context;
    }

    @Override
    public void run(String... args) {
        for (String name : new String[]{
                "stationService", "inMemoryStationRepository", "stationController"}) {
            log.info("Bean '{}' present: {} -> {}",
                    name,
                    context.containsBean(name),
                    context.containsBean(name)
                            ? context.getBean(name).getClass().getSimpleName()
                            : "N/A");
        }
    }
}

Expected output:

Bean 'stationService' present: true -> StationService
Bean 'inMemoryStationRepository' present: true -> InMemoryStationRepository
Bean 'stationController' present: true -> StationController

Comment: the default names are derived from the class name with a lowercase initial. The application behaves identically, which proves that @SpringBootApplication is nothing more than a shortcut.

Solution 2

package com.ciclourbana.common;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Clock;
import java.time.ZoneId;
import java.util.Random;

@Configuration(proxyBeanMethods = false)   // no @Bean method calls another
public class CommonConfig {

    /** The network clock. Injecting it lets us freeze time in tests. */
    @Bean
    public Clock ribaltaClock() {
        return Clock.system(ZoneId.of("Europe/Madrid"));
    }

    /** Fixed seed: demand simulations are reproducible. */
    @Bean
    public Random simulationRandom() {
        return new Random(42L);
    }
}
package com.ciclourbana.common;

import org.springframework.stereotype.Component;

import java.time.Clock;
import java.time.LocalDateTime;

@Component
public class NetworkClock {

    private final Clock clock;

    public NetworkClock(Clock clock) {
        this.clock = clock;
    }

    public LocalDateTime currentInstant() {
        return LocalDateTime.now(clock);
    }
}
package com.ciclourbana.common;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public class StartupTimeNotice implements CommandLineRunner {

    private static final Logger log = LoggerFactory.getLogger(StartupTimeNotice.class);

    private final NetworkClock networkClock;

    public StartupTimeNotice(NetworkClock networkClock) {
        this.networkClock = networkClock;
    }

    @Override
    public void run(String... args) {
        log.info("CicloUrbana starts at {} (Ribalta local time)",
                networkClock.currentInstant());
    }
}

Comment: Clock and Random are JDK classes; we cannot annotate them, so the only route is @Bean. The real benefit of the injected Clock will show up in module 6: you will be able to swap it for Clock.fixed(...) and test time-dependent fare rules without waiting until 22:00.

Solution 3

package com.ciclourbana.common;

import org.springframework.core.annotation.AliasFor;
import org.springframework.stereotype.Service;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Service
public @interface NetworkService {

    String area() default "general";

    @AliasFor(annotation = Service.class, attribute = "value")
    String value() default "";
}
// In StationService, replacing @Service:
@NetworkService(area = "stations")
public class StationService { /* ... */ }
package com.ciclourbana.common;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.util.Map;

@Component
@Order(200)
public class NetworkServiceInventory implements CommandLineRunner {

    private static final Logger log = LoggerFactory.getLogger(NetworkServiceInventory.class);

    private final ApplicationContext context;

    public NetworkServiceInventory(ApplicationContext context) {
        this.context = context;
    }

    @Override
    public void run(String... args) {
        Map<String, Object> services = context.getBeansWithAnnotation(NetworkService.class);

        log.info("Core network services: {}", services.size());
        services.forEach((name, bean) -> {
            // findAnnotation sees through proxies and hierarchies; getAnnotation does not always
            NetworkService marker = AnnotationUtils.findAnnotation(
                    bean.getClass(), NetworkService.class);
            log.info("  - {} (area: {})", name,
                    marker != null ? marker.area() : "unknown");
        });
    }
}

Expected output:

Core network services: 1
  - stationService (area: stations)

Comment and a frequent mistake: if you use bean.getClass().getAnnotation(NetworkService.class) and the bean is wrapped in a proxy (common as soon as @Transactional or @Cacheable appear), you will get null, because the real class is the generated proxy's. AnnotationUtils.findAnnotation —or AnnotatedElementUtils for meta-annotations— searches along the hierarchy and is the robust option.

Conclusion

Annotations have stopped being magic. You know they are metadata with RUNTIME retention, that Spring locates them by scanning the classpath with ASM, that the result of the scan is BeanDefinitions —recipes, not objects— and that the objects are built in a second phase. You have taken @SpringBootApplication apart into its three components and checked that the application behaves the same. You know the five stereotypes, you know that only @Repository contributes behaviour of its own (the translation of persistence exceptions) and that the others earn their place through what they communicate. You know when to use a stereotype and when to use @Configuration + @Bean, and why configuration classes get wrapped in a CGLIB proxy unless you declare proxyBeanMethods = false. And you have created your own stereotype by leaning on the transitive resolution of meta-annotations.

CicloUrbana has also gained its first genuine layered architecture: StationController speaks HTTP, StationService applies the rules of the Ribalta network and InMemoryStationRepository stores the data behind the StationRepository interface, ready for module 4 to replace it with JPA without touching anything above.

One loose end is obvious. We wrote public StationService(StationRepository stationRepository) and Spring found the right implementation all by itself. How? And what would have happened if there had been two implementations of StationRepository on the classpath? That is the subject of the next lesson, Dependency Injection in Spring Boot: the three types of injection and why only one is advisable, resolution by type, @Qualifier and @Primary, injecting collections of implementations and what to do when two beans need each other. We will apply it by building the first piece of Ribalta's fare system.

Spring Boot Course

Module 1: Introduction to Spring Boot

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved