Spring MVC Hibernate CRUD Example with Spring Boot 4

Build a contacts address book with Spring MVC, Hibernate 7 and Thymeleaf on Spring Boot 4: list, create, edit and delete pages, form validation, Post/Redirect/Get, and MockMvc and @DataJpaTest tests.

Valid Form Submission

A Spring MVC and Hibernate CRUD application lets users create, read, update and delete database rows through web pages. A @Controller receives the HTML form and a @Service opens a transaction. Then a DAO (data access object) calls Hibernate’s EntityManager, and Hibernate writes the SQL.

We build a Spring MVC and Hibernate CRUD app when users manage records through server-rendered HTML pages, such as an admin page for products or an internal address book.

The following example is a contacts address book built with Spring Boot 4.1 and Hibernate 7.4, with Thymeleaf for the pages. It has a list page, a shared create and edit form with validation, delete buttons, and the Post/Redirect/Get pattern, which stops a page refresh from saving the same contact twice.

@PostMapping
public String create(@Valid @ModelAttribute("contact") Contact contact, BindingResult result,
                     RedirectAttributes redirect) {
  if (result.hasErrors()) {
    return "contacts/form";                    // 200, the form with error messages
  }
  contactService.create(contact);              // insert into contact (city,email,name,phone,id) values (?,?,?,?,default)
  redirect.addFlashAttribute("message", "Contact " + contact.getName() + " saved");
  return "redirect:/contacts";                 // 302 Location: /contacts
}
em.persist(contact);                           // insert into contact (city,email,name,phone,id) values (?,?,?,?,default)
Contact loaded = em.find(Contact.class, id);   // select c1_0.id,c1_0.city,... from contact c1_0 where c1_0.id=?
contact.setCity("Mumbai");                     // update contact set city=?,email=?,name=?,phone=? where id=? (at commit)
em.remove(contact);                            // delete from contact where id=?

Notice that the controller never sees SQL, and the setCity() call becomes an UPDATE at commit without any save call.

Next, we follow a request through the layers and build each class of the address book with the SQL it runs. The FAQs answer the questions that come up when we move from older XML-based Spring MVC tutorials.

1. How a Request Travels From the Browser to the Database

Every request passes through the same layers. Spring’s DispatcherServlet (the front servlet that receives all requests) picks the controller method by URL, and the controller validates the form and calls the service.

The service starts a transaction and calls the DAO, which calls Hibernate to load or save entities. Each layer has one job, and only the DAO calls Hibernate.

Say the address book later gets a REST API for a mobile app. The new REST controller calls the same ContactService, so the transaction rules and the duplicate email check stay in one place.

Request flow for saving a contact: Browser POST /contacts goes to the Controller (@Controller, binds and validates the form), then the Service (@Service, opens and commits the transaction), the DAO (@Repository, runs persist, find, merge, remove and JPQL) and Hibernate (EntityManager, tracks entities and writes SQL), which stores the row in the H2 contact table; a dashed box marks Service, DAO and Hibernate as one @Transactional call; the bottom row shows the insert statement Hibernate printed, the controller answering redirect:/contacts with a flash message and HTTP 302, and the browser loading GET /contacts rendered by Thymeleaf
One service call is one transaction. The controller only sees entities and view names, never SQL.

The stereotype annotations @Controller, @Service and @Repository register each class as a Spring bean (an object that Spring creates and injects where it is needed).

LayerAnnotationJobClass in our example
Web@ControllerMap URLs, bind and validate forms, choose the viewContactController
Service@Service + @TransactionalBusiness rules, transaction boundariesContactService
Data access@RepositoryQueries and saves through EntityManagerContactDao
ORMnone (Spring Boot sets it up)Turns entity changes into SQLHibernate 7.4
ViewnoneRenders HTML from the modelThymeleaf templates

The address book needs six URLs. Pages that only read data handle a GET request, whereas every change is a POST that ends with a redirect.

Method and URLHandlerResultSQL
GET /contactslist()list.htmlselect … order by c1_0.name
GET /contacts/newnewForm()empty form.htmlnone
POST /contactscreate()302 to /contactsinsert into contact …
GET /contacts/{id}/editeditForm()filled form.htmlselect … where c1_0.id=?
POST /contacts/{id}update()302 to /contactsupdate contact set …
POST /contacts/{id}/deletedelete()302 to /contactsdelete from contact where id=?

2. Spring MVC and Hibernate CRUD Example

The project runs on Java 25, Spring Boot 4.1.1 (Spring Framework 7.0.9), Hibernate ORM 7.4.5.Final, Hibernate Validator 9.1.3, Thymeleaf 3.1.5 and H2 2.4.240. The complete project on GitHub contains every file and 27 tests.

2.1. Maven Dependencies and Configuration

Spring Boot replaces the manual setup of older tutorials, so we need no web.xml, no hibernate.cfg.xml, no view resolver bean and no transaction manager bean. Five starters add Spring MVC with embedded Tomcat, Thymeleaf, Spring Data JPA with Hibernate, Hibernate Validator and the H2 driver.

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.1.1</version>
</parent>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>
  <dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
  </dependency>
</dependencies>

In Spring Boot 4, spring-boot-starter-web still exists but is deprecated in favor of spring-boot-starter-webmvc. The parent manages every version, so the Hibernate version comes from Spring Boot.

A few properties complete the setup. From the datasource URL, Spring Boot creates a HikariCP connection pool and builds the EntityManagerFactory for us.

spring.datasource.url=jdbc:h2:mem:contacts
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.open-in-view=false
spring.jpa.defer-datasource-initialization=true
server.servlet.session.tracking-modes=cookie
PropertyWhat it does
ddl-auto=create-dropHibernate creates the contact table at startup and drops it at shutdown
show-sql=truePrints every SQL statement (SQL logging has more options)
open-in-view=falseDatabase access ends with the service transaction (see section 3.4)
defer-datasource-initialization=trueRuns data.sql after Hibernate created the table, so the app starts with two contacts
tracking-modes=cookieKeeps ;jsessionid=… out of redirect URLs

2.2. The Contact Entity

Contact is a JPA entity, which is a class that Hibernate maps to a table. The same fields also carry the Bean Validation constraints that the form checks.

@Entity
public class Contact {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @NotBlank(message = "Name is required")
  @Size(max = 50, message = "Name must be at most 50 characters")
  private String name;

  @NotBlank(message = "Email is required")
  @Email(message = "Email is not valid")
  @Column(unique = true)
  private String email;

  @Pattern(regexp = "[0-9 +-]{0,20}", message = "Phone may contain digits, spaces, + and - only")
  private String phone;

  @Size(max = 40, message = "City must be at most 40 characters")
  private String city;
}

The @Entity, @Id and @GeneratedValue annotations come from jakarta.persistence, and the validation annotations come from jakarta.validation.constraints. Hibernate also reads the validation constraints when it creates the table, so @NotBlank becomes not null and @Size(max = 50) becomes varchar(50).

create table contact (id bigint generated by default as identity, city varchar(40), name varchar(50) not null,
  email varchar(255) not null unique, phone varchar(255), primary key (id))

2.3. The DAO With EntityManager

EntityManager is the Jakarta Persistence interface to Hibernate, and Spring injects a shared, thread-safe proxy of it with @PersistenceContext. Each call on the proxy goes to the persistence context of the current transaction, which is the set of entities Hibernate loaded or saved in that transaction.

@Repository
public class ContactDao {

  @PersistenceContext
  private EntityManager em;

  public List<Contact> findAll() {
    return em.createQuery("select c from Contact c order by c.name", Contact.class)
        .getResultList();
  }

  public Optional<Contact> findById(Long id) {
    return Optional.ofNullable(em.find(Contact.class, id));
  }

  public Contact save(Contact contact) {
    if (contact.getId() == null) {
      em.persist(contact);
      return contact;
    }
    return em.merge(contact);
  }

  public void delete(Contact contact) {
    em.remove(contact);
  }
}

Each method maps to one EntityManager call and one SQL statement.

DAO methodHibernate callSQL Hibernate printed
findAll()JPQL queryselect c1_0.id,c1_0.city,c1_0.email,c1_0.name,c1_0.phone from contact c1_0 order by c1_0.name
findById(id)em.find()select … from contact c1_0 where c1_0.id=?, or Optional.empty() when the row is missing
save(new contact)em.persist()insert into contact (city,email,name,phone,id) values (?,?,?,?,default)
save(detached contact)em.merge()Copies the state onto a managed copy and returns that copy
delete(contact)em.remove()delete from contact where id=?

With IDENTITY ids, the database generates the id, so persist() sends the INSERT at once and contact.getId() has a value right after the call. The DAO never starts a transaction, because its methods run inside the transaction the service opened.

2.4. The Same Data Access With Spring Data JPA

Most Spring Boot projects skip the hand-written DAO, because Spring Data generates the implementation of a JpaRepository interface. The generated implementation, SimpleJpaRepository, calls the same EntityManager methods.

public interface ContactRepository extends JpaRepository<Contact, Long> {

  List<Contact> findAllByOrderByNameAsc();     // select ... order by c1_0.name

  boolean existsByEmail(String email);          // select c1_0.id from contact c1_0 where c1_0.email=? fetch first ? rows only
}
contactRepository.save(priya);                 // insert into contact ... (persist() for a new entity, merge() otherwise)
long count = contactRepository.count();        // select count(*) from contact c1_0   -> 3
contactRepository.deleteById(priya.getId());   // delete from contact where id=?
EntityManager DAOSpring Data JpaRepository
Code we writeEvery methodAn interface; query methods from their names
Shows the Hibernate callsYesHidden in SimpleJpaRepository
Paging, sorting, count()Write themBuilt in
Best forLearning, custom persistence codeMost applications

Our service uses the DAO, because the DAO shows each Hibernate call. ContactRepositoryTest in the project runs the repository against the same table.

2.5. The Service and Its Transactions

The service holds the business rules and the transaction boundaries. @Transactional on a method makes Spring start a transaction before the call and commit it after the call. When the method throws a RuntimeException, Spring rolls the transaction back. We inject the DAO through the constructor, so no @Autowired is needed.

@Service
@Transactional(readOnly = true)
public class ContactService {

  private final ContactDao contactDao;

  public ContactService(ContactDao contactDao) {
    this.contactDao = contactDao;
  }

  public List<Contact> findAll() {
    return contactDao.findAll();
  }

  public Contact findById(Long id) {
    return contactDao.findById(id).orElseThrow(() -> new ContactNotFoundException(id));
  }

  @Transactional
  public Contact create(Contact contact) {
    if (contactDao.existsByEmail(contact.getEmail(), null)) {
      throw new DuplicateEmailException(contact.getEmail());
    }
    return contactDao.save(contact);
  }

  @Transactional
  public Contact update(Long id, Contact form) {
    Contact contact = findById(id);
    if (contactDao.existsByEmail(form.getEmail(), id)) {
      throw new DuplicateEmailException(form.getEmail());
    }
    contact.setName(form.getName());
    contact.setEmail(form.getEmail());
    contact.setPhone(form.getPhone());
    contact.setCity(form.getCity());
    return contact;
  }

  @Transactional
  public void delete(Long id) {
    contactDao.delete(findById(id));
  }
}

The class-level @Transactional(readOnly = true) covers the read methods, and each write method overrides it with a plain @Transactional. A read-only transaction does not write changes, so in our test, a city changed inside a read-only transaction still had the old value afterward.

The method update() has no save call, because findById() returns a managed entity that Hibernate tracks in the persistence context. At commit, Hibernate compares the entity with the state it loaded and writes the difference, which is called dirty checking.

select c1_0.id,c1_0.city,c1_0.email,c1_0.name,c1_0.phone from contact c1_0 where c1_0.id=?
select count(c1_0.id) from contact c1_0 where c1_0.email=? and (? is null or c1_0.id<>?)
update contact set city=?,email=?,name=?,phone=? where id=?

ContactNotFoundException carries @ResponseStatus(HttpStatus.NOT_FOUND), so an unknown id in the URL returns HTTP 404. A custom error page can replace the default one.

2.6. The Controller With GET and POST Mappings

The controller maps the six URLs from section 1. A class-level @RequestMapping sets the /contacts prefix, and @GetMapping and @PostMapping add the rest of each path. Each method returns a view name or a redirect: URL, and Thymeleaf resolves a view name to templates/<name>.html.

@Controller
@RequestMapping("/contacts")
public class ContactController {

  @GetMapping
  public String list(Model model) {
    model.addAttribute("contacts", contactService.findAll());
    return "contacts/list";
  }

  @GetMapping("/new")
  public String newForm(Model model) {
    model.addAttribute("contact", new Contact());
    return "contacts/form";
  }

  @GetMapping("/{id}/edit")
  public String editForm(@PathVariable Long id, Model model) {
    model.addAttribute("contact", contactService.findById(id));
    return "contacts/form";
  }

  @PostMapping("/{id}/delete")
  public String delete(@PathVariable Long id, RedirectAttributes redirect) {
    contactService.delete(id);
    redirect.addFlashAttribute("message", "Contact deleted");
    return "redirect:/contacts";
  }
}

The create and edit pages share form.html. A new contact has a null id, so the form posts to /contacts, whereas a loaded contact has an id, so the same form posts to /contacts/1. The update handler looks like create() from the first snippet, with the id from the path.

@PostMapping("/{id}")
public String update(@PathVariable Long id, @Valid @ModelAttribute("contact") Contact contact,
                     BindingResult result, RedirectAttributes redirect) {
  if (result.hasErrors()) {
    return "contacts/form";
  }
  try {
    contactService.update(id, contact);
  } catch (DuplicateEmailException e) {
    result.rejectValue("email", "duplicate", "Email is already used");
    return "contacts/form";
  }
  redirect.addFlashAttribute("message", "Contact " + contact.getName() + " updated");
  return "redirect:/contacts";
}

Delete is a POST form, not a link. HTML forms send only GET and POST, and a crawler or a browser prefetch can follow a GET link and delete data by accident.

2.7. Form Validation and Error Messages

@ModelAttribute binds the request parameters to a new Contact, and @Valid then runs Hibernate Validator on the object. The errors go into the BindingResult parameter that follows. When BindingResult has errors, the controller returns the form view in the same request, so the form still shows the typed values and the messages.

Six steps of form validation: 1 request parameters name empty, email priya-at-example, phone call me, city Pune; 2 data binding creates new Contact and calls the setters; 3 @Valid makes Hibernate Validator check @NotBlank, @Email, @Pattern and @Size; 4 BindingResult holds three field errors for name, email and phone; 5 the controller sees result.hasErrors() and returns contacts/form with HTTP 200, no service call and no SQL; 6 form.html refills value Pune with th:field and prints Email is not valid with th:errors
An invalid form never reaches the service. The same request renders the form again with the typed values and one message per field.

In the template, the form tag binds the contact model attribute with th:object. Then th:field fills the input with the field’s value, and th:errors prints the field’s messages. When the field has no error, Thymeleaf removes the span.

<label>Email <input type="text" th:field="*{email}"></label>
<span class="error" th:errors="*{email}">Email error</span>
<span class="error">Name is required</span>
<span class="error">Email is not valid</span>

A rule that needs the database, such as a unique email, cannot be an annotation on a field. Instead, the service checks the email and throws DuplicateEmailException, and the controller turns the exception into a field error with result.rejectValue(). The form then shows “Email is already used” under the email input.

2.8. Post/Redirect/Get With Flash Messages

After a successful POST, the controller answers with a redirect, never with a page. If the POST returned the list page, a browser refresh would send the POST again and insert the contact a second time. With Post/Redirect/Get, the browser follows the 302 with a GET, so a refresh repeats only that GET.

Sequence diagram with Browser, ContactController, ContactService and H2 database: step 1 POST /contacts name=Priya; an invalid form returns 200 with form.html and errors, no SQL and no redirect; otherwise the controller calls create(contact), the service runs select count where email=? and insert into contact, commits and returns; step 2 the controller answers 302 Location: /contacts and keeps the flash message in the session; step 3 the browser sends GET /contacts, the controller calls findAll(), the service runs select order by c1_0.name, and the controller answers 200 list with the flash message; a note says refresh repeats only step 3 and the flash message is gone
The insert runs in the POST request. A refresh repeats only the GET, which reads data.

A message such as “Contact Priya saved” cannot be a normal model attribute, because the model exists only for the POST request. RedirectAttributes.addFlashAttribute() keeps the message in the HTTP session for the next request only.

HTTP/1.1 302
Set-Cookie: JSESSIONID=B24BDA1D...; Path=/; HttpOnly
Location: http://localhost:8080/contacts

<p class="message">Contact Priya saved</p>

The list template prints the message only when it exists, so a second GET of /contacts shows no message.

2.9. Testing Every Page and the DAO

Spring Boot 4 moved the test slices into their own modules, so we add two test starters.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-webmvc-test</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jpa-test</artifactId>
  <scope>test</scope>
</dependency>
AnnotationPackage in Spring Boot 4What it starts
@SpringBootTestorg.springframework.boot.test.contextThe whole application
@AutoConfigureMockMvcorg.springframework.boot.webmvc.test.autoconfigureMockMvc for @SpringBootTest
@WebMvcTestorg.springframework.boot.webmvc.test.autoconfigureControllers only, services must be mocked
@DataJpaTestorg.springframework.boot.data.jpa.test.autoconfigureJPA, Hibernate and an in-memory database, each test rolled back

MockMvc sends requests to the controllers without starting a server. With @SpringBootTest, the request goes through the real service and DAO down to Hibernate, and @Transactional on the test class rolls back each test’s changes.

mvc.perform(post("/contacts")
        .param("name", "Priya").param("email", "priya@example.com")
        .param("phone", "555-0103").param("city", "Pune"))
    .andExpect(status().isFound())                                      // 302
    .andExpect(redirectedUrl("/contacts"))
    .andExpect(flash().attribute("message", "Contact Priya saved"));

mvc.perform(post("/contacts")
        .param("name", "").param("email", "priya-at-example")
        .param("phone", "call me").param("city", "Pune"))
    .andExpect(status().isOk())                                         // 200, no redirect
    .andExpect(view().name("contacts/form"))
    .andExpect(model().attributeHasFieldErrors("contact", "name", "email", "phone"))
    .andExpect(content().string(containsString("Email is not valid")));

@DataJpaTest loads only the JPA beans, so we add the DAO and the service with @Import. The call em.flush() sends the pending SQL to the database, and em.clear() empties the persistence context, so the next read comes from the database.

@DataJpaTest
@Import({ContactDao.class, ContactService.class})
class ContactDaoTest {

  @Test
  void serviceUpdateNeedsNoSaveCall() {
    Long id = contactDao.findAll().get(1).getId();
    contactService.update(id, new Contact("Lokesh", "lokesh@example.com", "555-0199", "Delhi"));
    em.flush();                                                         // update contact set ...
    em.clear();
    assertThat(contactDao.findById(id).orElseThrow().getPhone()).isEqualTo("555-0199");
  }
}

The project has 27 tests, grouped by what they check.

  • 15 MockMvc and full-context tests for every page and action (list, new, create, invalid create, duplicate email, edit, update, invalid update, delete, flash message, 404, missing BindingResult).
  • 10 tests for the DAO and the service transactions.
  • 2 tests for the Spring Data repository.

We can also test the controller, service and DAO layers one at a time with mocks.

2.10. Running the Application

We start the app with mvn spring-boot:run and open http://localhost:8080/contacts. Each action on the pages prints its SQL in the console.

mvn spring-boot:run        # http://localhost:8080/contacts
mvn test                   # 27 tests
Hibernate: select c1_0.id,c1_0.city,c1_0.email,c1_0.name,c1_0.phone from contact c1_0 order by c1_0.name
Hibernate: select count(c1_0.id) from contact c1_0 where c1_0.email=? and (? is null or c1_0.id<>?)
Hibernate: insert into contact (city,email,name,phone,id) values (?,?,?,?,default)
...
Hibernate: update contact set city=?,email=?,name=?,phone=? where id=?
...
Hibernate: select c1_0.id,c1_0.city,c1_0.email,c1_0.name,c1_0.phone from contact c1_0 where c1_0.id=?
Hibernate: delete from contact where id=?

3. Spring MVC and Hibernate FAQs

3.1. Do I Still Need hibernate.cfg.xml and a SessionFactory Bean?

No. Older Spring MVC tutorials configured a LocalSessionFactoryBean, a HibernateTransactionManager, an InternalResourceViewResolver and a DispatcherServlet initializer by hand, but Spring Boot creates the equivalent beans from the starters and application.properties.

Old manual setupSpring Boot 4
hibernate.cfg.xml, LocalSessionFactoryBean*spring.datasource. and *spring.jpa. properties
HibernateTransactionManager, @EnableTransactionManagementJpaTransactionManager, auto-configured
AbstractAnnotationConfigDispatcherServletInitializerEmbedded Tomcat and DispatcherServlet, auto-configured
JSP with InternalResourceViewResolverThymeleaf templates in src/main/resources/templates
c3p0 poolHikariCP

The old code called sessionFactory.getCurrentSession().save(user), but Hibernate 7 removed Session.save() and saveOrUpdate(), and Session.update() is gone too. We use persist() for new entities and merge() for detached ones. The EntityManagerFactory that Spring Boot builds is still a Hibernate SessionFactory.

3.2. Should @Transactional Go on the Controller, Service or DAO?

Put it on the service. One user action often needs several DAO calls, for example the email check and the insert in create(), and these calls must commit or roll back together. On the DAO, each call would get its own transaction, whereas on the controller, the transaction would also cover view rendering and HTTP handling.

The method must also be called from another bean, because Spring applies @Transactional through a proxy around the bean, and a call from inside the same class does not go through that proxy.

3.3. Why Does an Invalid Form Return 400 Bad Request Instead of the Errors?

The handler returns 400 when it has no BindingResult parameter, or when BindingResult is not the next parameter after the @ModelAttribute parameter. In both cases, Spring throws MethodArgumentNotValidException and returns HTTP 400.

// Wrong: invalid form -> 400 Bad Request (MethodArgumentNotValidException)
public String create(@Valid @ModelAttribute("contact") Contact contact)

// Right: BindingResult directly after the validated parameter
public String create(@Valid @ModelAttribute("contact") Contact contact, BindingResult result, ...)

For JSON request bodies, request body validation works differently, and the same CRUD operations can run as a Spring Boot and Hibernate REST API.

3.4. Should I Set spring.jpa.open-in-view to false?

Yes, for new applications. By default, Spring Boot keeps the EntityManager open until the view is rendered, so a template can load lazy associations (related entities that Hibernate loads on first access) after the service returned. The lazy-loading queries run outside any transaction and hide missing fetches in the service.

With open-in-view=false, the service loads everything the view needs, and a lazy association that was not loaded throws a LazyInitializationException instead of running a hidden query.

3.5. How Do I Show a Duplicate Email as a Form Error?

We check the email before the insert, and we also keep the unique constraint in the database for two requests that arrive at the same moment.

LayerWhat happens
ServiceexistsByEmail() finds the email and throws DuplicateEmailException
Controllerresult.rejectValue(“email”, “duplicate”, “Email is already used”) shows it under the input
Databaseemail varchar(255) not null unique rejects a duplicate that passed the check

When the database rejects the insert, @Repository translates Hibernate’s ConstraintViolationException into Spring’s DataIntegrityViolationException. The exception translation runs in the full application context, but the @DataJpaTest slice does not register the post-processor that does it, so a test in that slice sees the Hibernate exception.

4. Conclusion

A Spring MVC and Hibernate CRUD application needs four classes per table.

  • An entity with its validation rules.
  • A DAO that calls EntityManager, or a Spring Data repository.
  • A service that controls the transactions.
  • A controller that validates forms and returns a redirect for every successful POST.

Spring Boot 4 sets up Hibernate, the transaction manager, the connection pool and Thymeleaf from the starters and a few properties. As a next step, we can add pagination for long lists, and learn more about each layer from the Hibernate tutorials and the Spring MVC tutorials.

5. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.