The previous lesson closed module 3 with an uncomfortable observation: the CicloUrbana API is complete, documented and ready to publish, but when the process restarts everything disappears. Ribalta's four stations are born again out of DemoStationLoader, the bikes registered through the API evaporate and the day's rentals are lost. Everything lives in the ConcurrentHashMap inside InMemoryStationRepository, which exists for exactly as long as the JVM does. This module replaces that stopgap with real persistence on a relational database, and it does so without touching a single controller, because ever since 02-01 we have hidden storage behind the StationRepository interface.

Before writing a single annotation it pays to understand the terrain. This lesson is conceptual, and it answers the questions almost nobody asks before starting and everybody asks when something breaks: why an ORM exists, what exactly each of the four acronyms that get mixed up daily means —JDBC, JPA, Hibernate, Spring Data JPA—, what the persistence context is and why an entity can be "managed" or "detached", and what Spring Boot does on its own as soon as it detects the starter. Without this foundation JPA looks like magic; with it, it is a predictable machine.

Contents

  1. The object-relational impedance mismatch
  2. What an ORM is and what problem it really solves
  3. Who's who: JDBC, JPA, Hibernate and Spring Data JPA
  4. What spring-boot-starter-data-jpa brings
  5. EntityManager, persistence unit and persistence context
  6. The four states of an entity
  7. What Spring Boot generates when it detects the starter
  8. Alternatives to JPA in the Spring ecosystem
  9. CicloUrbana's target data model
  10. Common Mistakes and Tips
  11. Exercises

  1. The object-relational impedance mismatch

In Java, Station is an object: it has its own identity (two distinct objects even when they match field by field), direct references to other objects, inheritance, and it lives in a graph managed by the garbage collector. In a relational database a station is a row in a table: its identity comes from the primary key, it relates to other rows through foreign keys, it inherits from nothing and it only exists inside a set of tuples.

Both models are good, but they do not fit together. That collection of disagreements is called the object-relational impedance mismatch, and it has five concrete faces:

Mismatch In Java In SQL Practical consequence
Granularity Station holds a Location object with latitude and longitude The stations table has two loose columns Objects must be flattened or expanded
Identity == (reference) and equals() (value) are different things Only the primary key exists Two objects can represent the same row
Inheritance Incident → BatteryIncident, VandalismIncident There is no inheritance between tables You have to pick a mapping strategy
Association Directed reference: Bike.station Symmetric foreign key, navigable both ways Bidirectionality has to be built by hand
Navigation bike.getStation().getName(), hop by hop JOIN, everything at once Navigating object by object generates many queries

An example from CicloUrbana makes it tangible. To get the name of a bike's station, the natural thing in Java is:

String name = bike.getStation().getName();

In SQL the natural thing is the opposite: ask for it all at once.

SELECT b.plate, s.name
FROM bikes b
JOIN stations s ON s.id = b.station_id
WHERE b.id = 42;

The first form, run inside a loop over a hundred bikes, produces a hundred queries. That is the seed of the N+1 problem we will see in 04-04. It is not a JPA defect: it is the mismatch showing through.

  1. What an ORM is and what problem it really solves

An ORM (Object-Relational Mapping) is a layer that translates automatically between objects and rows. You write stationRepository.save(station) and somebody generates the INSERT; you read station.getBikes() and somebody generates the SELECT.

To appreciate what it contributes, look at what doing it by hand with plain JDBC would cost. This would be the save for a station without an ORM:

public Station save(Station station) {
    String sql = """
            INSERT INTO stations (name, address, capacity, latitude, longitude)
            VALUES (?, ?, ?, ?, ?)
            """;
    try (Connection connection = dataSource.getConnection();
         PreparedStatement statement =
                 connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {

        statement.setString(1, station.getName());
        statement.setString(2, station.getAddress());
        statement.setInt(3, station.getCapacity());
        statement.setDouble(4, station.getLatitude());
        statement.setDouble(5, station.getLongitude());
        statement.executeUpdate();

        try (ResultSet keys = statement.getGeneratedKeys()) {
            if (keys.next()) {
                station.setId(keys.getLong(1));
            }
        }
        return station;
    } catch (SQLException e) {
        throw new DataStoreException("Could not save the station", e);
    }
}

Twenty lines for a five-column table. Multiply that by findById, findAll, update, delete, and then by the six tables in the model. And we still owe the ResultSet → object conversion, the null handling, and the fact that every ALTER TABLE forces you to review all those hand-numbered parameter positions.

What an ORM contributes, ordered by real importance:

  • It removes the repetitive mapping code. Eighty percent of that snippet disappears.
  • It manages identity and state. It knows which objects it loaded and which ones changed, and it generates an UPDATE for the modified parts only.
  • It translates engine-specific SQL. The same code runs against H2 in development and PostgreSQL in production.
  • It offers an object-oriented query language (JPQL) validated against the model rather than against strings.
  • It automates the loading of associations and gives you control over when to load them.

And what an ORM does not contribute, because it is worth saying from the outset:

  • It does not excuse you from knowing SQL. When something is slow, the diagnosis is made by reading the generated SQL.
  • It does not turn a bad relational model into a good one.
  • It is not free: it adds a layer with rules of its own that you have to understand (precisely what this module is about).

  1. Who's who: JDBC, JPA, Hibernate and Spring Data JPA

These four pieces get named daily as if they were interchangeable, and they are not. Each lives at a different level of abstraction and depends on the previous one.

Piece What it is Who maintains it Kind of artefact Example of use
JDBC Standard Java API for talking to a database via SQL Java SE specification API + one driver per engine connection.prepareStatement(sql)
JPA (Jakarta Persistence) Specification: it defines annotations, EntityManager, JPQL and the lifecycle. It executes nothing Jakarta EE (formerly JSR-338) Interfaces and annotations @Entity, EntityManager.persist()
Hibernate Implementation of JPA: the engine that actually generates and runs the SQL Red Hat Library (hibernate-core) It is the one that writes the INSERT
Spring Data JPA Abstraction on top of JPA: it generates repository implementations from interfaces Spring (VMware/Broadcom) Library (spring-data-jpa) interface StationRepository extends JpaRepository<...>

The critical distinction is that JPA is a specification and Hibernate is an implementation. JPA states that @Entity exists and what it must mean; Hibernate writes the code that makes it mean that. Other implementations are EclipseLink and OpenJPA, but Spring Boot ships Hibernate by default and it is the de facto standard choice.

graph TD
    A["StationService (your business code)"] --> B["StationRepository<br/>Spring Data JPA interface"]
    B --> C["SimpleJpaRepository<br/>generated implementation"]
    C --> D["EntityManager<br/>JPA API (specification)"]
    D --> E["Hibernate<br/>JPA implementation"]
    E --> F["JDBC + PostgreSQL driver"]
    F --> G[("PostgreSQL 16")]

Every downward arrow is a translation. stationRepository.findById(1L) becomes a call to EntityManager.find(Station.class, 1L), which Hibernate turns into a SELECT ... WHERE id = ?, which the JDBC driver sends over the socket to PostgreSQL. When something goes wrong, the diagnosis consists of walking down these layers until you find the one where the translation broke.

One nuance that saves confusion: Spring Data JPA does not replace Hibernate, it leans on it. And Spring Data JPA is not Spring Data JDBC; they share a family and the repository style, but underneath they are different projects (we cover this in section 8).

  1. What spring-boot-starter-data-jpa brings

Like every starter (02-06), it is a POM with no code that pulls in a coherent set of dependencies whose versions are already compatible with each other.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

That single line adds to the classpath:

Dependency What it is for
spring-boot-starter-jdbc DataSource, JdbcTemplate and the HikariCP pool
hibernate-core The ORM engine: the JPA implementation
jakarta.persistence-api The annotations and interfaces of the specification
spring-data-jpa The repositories and their automatic generation
spring-orm Spring's integration with JPA and its transaction manager
spring-aspects AOP support, the basis of @Transactional (04-07)
jakarta.transaction-api The transaction API

What it does not include —deliberately— is the database driver, because the starter cannot know which engine you are going to connect to. Adding H2 or PostgreSQL is the first thing we will do in 04-02.

You can check the real tree in your project:

./mvnw dependency:tree -Dincludes=org.hibernate.orm:*,org.springframework.data:*

  1. EntityManager, persistence unit and persistence context

These three terms are JPA's basic vocabulary. Confusing them is the cause of half the forum questions about "why aren't my changes being saved".

Persistence unit: the complete configuration of a data store —which DataSource to use, which classes are entities, which SQL dialect, which Hibernate properties—. In a classic JPA application it was declared in persistence.xml. In Spring Boot that file does not exist: the persistence unit is built from application.yml and from the automatic scanning of entities. It is one of the things the starter does for you.

EntityManager: JPA's central interface. It is your gateway to persistence. Its essential operations:

Method What it does
persist(entity) Marks a new entity to be inserted
find(Class, id) Retrieves by primary key (looks in the context first)
getReference(Class, id) Returns a lazy proxy without hitting the database
merge(entity) Reattaches a detached entity to the context
remove(entity) Marks a managed entity for deletion
flush() Forces the pending SQL to be sent to the database
detach(entity) / clear() Takes one entity, or all of them, out of the context
createQuery(jpql) Creates a JPQL query

With Spring Data JPA you will almost never use it directly, because repositories encapsulate it. But it is down there, and understanding its behaviour explains the behaviour of repositories.

Persistence context: this is the concept you really have to internalise. It is a first-level cache that the EntityManager keeps with every entity it has loaded or saved during a unit of work. It has three properties that change everything:

  1. It guarantees unique identity: within the same context, two lookups of station 1 return exactly the same Java object. stationA == stationB is true.
  2. It detects changes automatically (dirty checking): it keeps a copy of the original state; on flush it compares and generates an UPDATE only if something changed. That is why, on a managed entity, there is no need to call save() (we will develop this in 04-07).
  3. It defers writes (write-behind): it accumulates the SQL and sends it at the last moment, which allows it to be batched and ordered.

In a typical Spring application the persistence context lives as long as the transaction, usually a @Transactional method on a service. Outside it, entities are left detached. That link between context and transaction is the axis of this module and the reason lesson 04-07 exists.

  1. The four states of an entity

Every instance of an @Entity class is always in one of these four states. Knowing which one it is in explains why a change is saved or lost.

State Has an id? Is it in the context? Is it synchronised with the DB?
Transient (transient/new) Normally not No No
Managed (managed/persistent) Yes Yes Yes, automatically
Detached (detached) Yes No No
Removed (removed) Yes Yes, marked for deletion It will be deleted on flush
stateDiagram-v2
    [*] --> Transient: new Station()
    Transient --> Managed: persist() / save()
    Managed --> Detached: end of transaction / detach() / clear()
    Detached --> Managed: merge()
    Managed --> Removed: remove() / delete()
    Removed --> Managed: persist()
    Removed --> [*]: commit (DELETE)
    Detached --> [*]: garbage collector

Let's walk through the four with a Ribalta station:

// 1. TRANSIENT: a plain, ordinary Java object.
Station station = new Station("Main Square", "Main Square 1", 24);
// Hibernate does not know it exists. If the JVM ends, it is lost.

// 2. MANAGED: from save() onwards, the context watches it.
Station managed = stationRepository.save(station);
managed.setCapacity(26);
// There is NO need to call save() again: dirty checking
// will detect the change and issue the UPDATE on commit.

// 3. DETACHED: when the transaction ends, the context closes.
// From here on, changes no longer propagate on their own:
managed.setCapacity(30);   // this change does NOT reach the database

// 4. To reattach it you have to merge it:
Station reattached = stationRepository.save(managed); // performs a merge

Two consequences that surprise everybody the first time:

  • Step 2 does not need an explicit save(). A great deal of Spring code calls it "just in case"; it is harmless but it reveals that the persistence context has not been understood.
  • Step 3 does need it, and there save() does not insert: it merges. The difference is detailed in 04-05.

The detached state is the one that produces the dreaded LazyInitializationException: trying to navigate a lazy association on an entity whose context is already closed. We will see it in 04-04.

  1. What Spring Boot generates when it detects the starter

As soon as spring-boot-starter-data-jpa is on the classpath and there is a configured DataSource, the autoconfiguration from 02-06 fires. The classes involved are DataSourceAutoConfiguration, HibernateJpaAutoConfiguration and JpaRepositoriesAutoConfiguration, and between them they register these beans without you writing a single line:

Bean created What it does Condition
DataSource (HikariCP) Connection pool There is a spring.datasource.url or an embedded DB
EntityManagerFactory Factory for the EntityManager; builds the persistence unit There is a DataSource + Hibernate
EntityManager (per-transaction proxy) The gateway to JPA There is an EntityManagerFactory
JpaTransactionManager Transaction manager for @Transactional There is an EntityManagerFactory
JpaVendorAdapter Adapts Hibernate to Spring's abstraction Hibernate on the classpath
Repositories One proxy per interface extending Repository Implicit @EnableJpaRepositories
PlatformTransactionManager The basis of transaction management (04-07) —

It also scans @Entity classes automatically from the package of the class annotated with @SpringBootApplication downwards. Since CicloUrbanaApplication lives in com.ciclourbana, every entity in com.ciclourbana.stations, .bikes and .rentals is detected on its own. It is the same component-scanning rule from 01-04, applied to entities.

You can verify it with the autoconfiguration report from 02-06:

./mvnw spring-boot:run -Dspring-boot.run.arguments=--debug

Under Positive matches you will see entries such as:

HibernateJpaAutoConfiguration matched:
  - @ConditionalOnClass found required classes 'jakarta.persistence.EntityManager',
    'org.hibernate.SessionFactory' (OnClassCondition)
  - @ConditionalOnBean found bean 'dataSource' (OnBeanCondition)

JpaRepositoriesAutoConfiguration#jpaRepositoriesFactoryBean matched:
  - @ConditionalOnBean found bean 'entityManagerFactory'

And if you do not configure a DataSource while having the starter, startup fails with a very characteristic message:

Failed to configure a DataSource: 'url' attribute is not specified and no embedded
datasource could be configured.
Reason: Failed to determine a suitable driver class

That is exactly the problem the next lesson solves.

  1. Alternatives to JPA in the Spring ecosystem

JPA is the default option, not the only one and not always the best. Knowing the map avoids the mistake of forcing it where it does not fit.

Technology Level of abstraction Strengths Weaknesses When to choose it
JdbcTemplate Low: you write the SQL Total control, no surprises, no state Manual mapping, repetitive code Reports, complex queries, bulk loads
Spring Data JDBC Medium Simple model, no cache or lazy loading, very predictable No lazy relationships or inheritance DDD models with well-bounded aggregates
Spring Data JPA High Productivity, relationships, cache, portability Learning curve, implicit behaviour CRUD over a rich domain, the general case
jOOQ Low, with typed SQL SQL checked at compile time, perfect for complex queries Requires code generation; commercial licence on proprietary databases Applications where SQL is the centre
MyBatis Low-medium SQL in XML or annotations, flexible mapping More configuration, less automation Migrating applications with legacy SQL
R2DBC Medium Reactive, non-blocking access Smaller ecosystem, JPA not possible WebFlux applications with heavy concurrency

A practical and honest criterion: JPA shines when you write and read domain aggregates; it gets in the way when you produce reports. In CicloUrbana we will use Spring Data JPA for all the CRUD over stations, bikes and rentals, and in 04-06 we will see native queries for cases such as "nearest stations", where PostgreSQL-specific SQL wins hands down. They are not mutually exclusive: they coexist in the same project over the same DataSource.

  1. CicloUrbana's target data model

This is where the module is heading. Six tables that reflect the business of the Ribalta network:

erDiagram
    STATIONS ||--o{ BIKES : "hosts"
    STATIONS ||--o{ RENTALS : "origin"
    STATIONS ||--o{ RENTALS : "destination"
    BIKES ||--o{ RENTALS : "is rented in"
    BIKES ||--o{ INCIDENTS : "accumulates"
    USERS ||--o{ RENTALS : "makes"
    FARES ||--o{ RENTALS : "is applied in"

    STATIONS {
        bigint id PK
        varchar name UK
        varchar address
        int capacity
        numeric latitude
        numeric longitude
        boolean active
        bigint version
    }
    BIKES {
        bigint id PK
        varchar plate UK
        varchar status
        int battery_level
        bigint station_id FK
        bigint version
    }
    USERS {
        bigint id PK
        varchar email UK
        varchar name
        varchar fare_type
        date signup_date
    }
    RENTALS {
        bigint id PK
        bigint user_id FK
        bigint bike_id FK
        bigint origin_station_id FK
        bigint destination_station_id FK
        timestamp started_at
        timestamp ended_at
        numeric total_amount
    }
    FARES {
        bigint id PK
        varchar code UK
        numeric price_per_minute
        numeric minimum_amount
    }
    INCIDENTS {
        bigint id PK
        bigint bike_id FK
        varchar type
        varchar description
        timestamp reported_at
    }

Notice three details that anticipate specific lessons:

  • The version column in stations and bikes is the optimistic locking of 04-03, the one that will replace the ShallowEtagHeaderFilter from 03-03.
  • rentals has two foreign keys to stations (origin and destination): a case where mappedBy is not enough and each @JoinColumn has to be named explicitly (04-04).
  • total_amount and price_per_minute are numeric, never double. The reason is in 04-03, and it is one of the least negotiable rules in the whole module.

The route through the module, resting on this model:

Lesson What it adds to CicloUrbana
04-02 H2 in development, PostgreSQL 16 in Docker, HikariCP tuned
04-03 Station, Bike and Rental as entities with @Version and auditing
04-04 The relationships in the diagram, LAZY and the N+1 problem
04-05 StationRepository extends JpaRepository, pagination in the API
04-06 Derived queries, JPQL, native queries and projections
04-07 @Transactional in the services, propagation and locks
04-08 Flyway: the schema as versioned code

Common Mistakes and Tips

Believing that JPA is Hibernate. They are different things: JPA is the specification, Hibernate the implementation. It matters when an annotation such as @BatchSize shows up, which belongs to Hibernate, not to JPA: it works, but it ties you to the provider. Always check the package in the import: jakarta.persistence.* is standard; org.hibernate.annotations.* is provider-specific.

Thinking an ORM spares you from knowing SQL. It is exactly the other way round: the more automatic the SQL generation, the more important it is to be able to read it. Turn on statement logging from day one (04-02) and get into the habit of looking at it.

Calling save() on an already managed entity. It does no harm, but it signals that dirty checking has not been understood. Inside a @Transactional method, modifying a loaded entity is enough.

Forgetting that the persistence context dies with the transaction. It is the origin of LazyInitializationException and of "changes that don't get saved". When something odd happens, the first question must be: what state is this entity in?

Choosing JPA for everything, reports included. An aggregation query over a million rentals should not go through entity mapping. JdbcTemplate or a native query with a projection is the right answer (04-06).

Tip: draw the relational model before annotating classes. The ER diagram above was drawn before the entities, not after. Annotating without a considered model produces schemas that have to be redone, and in 04-08 you will see that redoing a schema in production is expensive.

Tip: keep the StationRepository interface. The whole module is the demonstration that isolating storage behind an interface lets you change technology without touching services or controllers.

Exercises

Exercise 1: classify the state of the entities

Given the following method from a CicloUrbana service, state which state the station variable is in at each marked point and whether the change on the final line reaches the database. Justify your answer.

@Transactional
public void renameStation(Long id, String newName) {
    Station station = new Station();            // (A)
    station = stationRepository.findById(id)    // (B)
            .orElseThrow(() -> new ResourceNotFoundException("Station", id));
    station.setName(newName);                   // (C)
}                                                // (D) end of method

Exercise 2: choose the data access technology

For each of the Ribalta council's needs, choose between JdbcTemplate, Spring Data JPA, jOOQ or R2DBC, and justify your choice in one sentence.

  1. Full CRUD over stations with validation and relationships with bikes.
  2. A monthly report that crosses rentals, users and fares with six JOINs, three subqueries and window functions.
  3. Loading 500,000 sensor readings from the bikes in one go every night.
  4. A real-time dashboard holding 20,000 open connections showing station occupancy.

Exercise 3: walk through the layers

Explain, layer by layer, what happens when StationService runs stationRepository.findById(3L) and the "River Park" station is not yet in the persistence context. Name the five layers involved and what each one does.

Solutions

Solution 1.

  • (A) Transient. new Station() creates an ordinary Java object; Hibernate does not know it, it has no id and nothing watches it. Besides, this line is useless: the reference is overwritten immediately. It is a code smell, not an error.
  • (B) Managed. findById loads the row and attaches it to the persistence context of the transaction opened by @Transactional. Hibernate keeps a copy of its original state for dirty checking.
  • (C) Managed and "dirty". The object is still managed; its name now differs from the original copy. No UPDATE has been executed yet.
  • (D) The change DOES reach the database. When the method ends, the transactional proxy commits; before the commit a flush runs that compares the current state with the original copy, detects the modified name and issues UPDATE stations SET name = ? WHERE id = ?. After the commit the context closes and the entity becomes detached.

There is no need to call save(). This is the canonical example of dirty checking. If the method did not carry @Transactional, the context would live only for the duration of the repository call, the entity would come back detached and the change would be lost silently: the worst kind of failure, because it throws no exception at all.

Solution 2.

  1. Spring Data JPA. This is its ideal case: a domain aggregate, relationships, validation, writing and reading by identifier. The productivity more than compensates for the abstraction.
  2. jOOQ (or JdbcTemplate with native SQL). Six JOINs, subqueries and window functions are not comfortably expressible in JPQL, and forcing it produces unreadable queries. jOOQ gives typed SQL checked at compile time; JdbcTemplate is the alternative with no added dependencies.
  3. JdbcTemplate with batchUpdate. A bulk load must not go through the persistence context: 500,000 managed entities would exhaust memory and dirty checking would do pointless work. Batch insertion in plain JDBC.
  4. R2DBC with WebFlux. Twenty thousand concurrent connections with JDBC's blocking model would demand twenty thousand threads. Non-blocking reactive access is designed for exactly this. (With Java 21 virtual threads change part of this calculation, but R2DBC is still the by-the-book option.)

Solution 3.

  1. StationService calls stationRepository.findById(3L) on the interface. It knows nothing about JPA.
  2. The Spring Data JPA proxy —created at startup, as we will see in 04-05— receives the call and delegates it to SimpleJpaRepository, the standard implementation.
  3. SimpleJpaRepository translates it into entityManager.find(Station.class, 3L) and wraps the result in an Optional.
  4. The EntityManager (Hibernate) looks in the persistence context first. As it is not there, it generates SQL adapted to the dialect: select s1_0.id, s1_0.capacity, ... from stations s1_0 where s1_0.id=?.
  5. JDBC and the PostgreSQL driver request a connection from the HikariCP pool, send the prepared statement and return a ResultSet.

On the way back, Hibernate builds the Station instance, registers it in the persistence context with a copy of its state, and returns it wrapped in an Optional. If the service asked for station 3 again in the same transaction, step 5 would not be repeated: the first-level cache would return the same object.

Conclusion

You now have the full map of the terrain. You know that the object-relational impedance mismatch is not a whim but five concrete disagreements between two equally valid models, and that an ORM exists to absorb them in exchange for introducing rules of its own. You can tell apart with precision the four pieces that get confused daily: JDBC as the low-level API, JPA as the specification, Hibernate as the implementation that generates the real SQL, and Spring Data JPA as the layer that manufactures repositories from interfaces. You know the seven dependencies that spring-boot-starter-data-jpa pulls in and, above all, the one it does not: the driver. You understand what the persistence context is and its three properties —unique identity, dirty checking and deferred writing—, which explain why modifying a managed entity is enough to save it. You can place any instance in one of the four states and predict whether its changes will survive. You know which beans the autoconfiguration creates as soon as it detects the starter and how to verify it with --debug. And you have the judgement to know when JPA is not the answer and JdbcTemplate, jOOQ or R2DBC is called for.

You also have the destination in sight: the six tables of the CicloUrbana model, with their version column for optimistic locking, the two foreign keys from rentals to stations and their amounts in numeric.

One thing is still missing, the very first one: there is no database at all. If you add the starter right now and start up, the application will fail with "Failed to configure a DataSource". Lesson 04-02, Configuring Data Sources, solves that: we will set up in-memory H2 for development with its web console, bring up PostgreSQL 16 with a docker-compose.yml of CicloUrbana's own, tune the HikariCP pool parameter by parameter with real sizing criteria, decide which ddl-auto value to use in each environment and why open-in-view must be switched off from day one, and leave SQL logging configured so we can see exactly what Hibernate sends to Ribalta.

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