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.

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).
| Layer | Annotation | Job | Class in our example |
|---|---|---|---|
| Web | @Controller | Map URLs, bind and validate forms, choose the view | ContactController |
| Service | @Service + @Transactional | Business rules, transaction boundaries | ContactService |
| Data access | @Repository | Queries and saves through EntityManager | ContactDao |
| ORM | none (Spring Boot sets it up) | Turns entity changes into SQL | Hibernate 7.4 |
| View | none | Renders HTML from the model | Thymeleaf 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 URL | Handler | Result | SQL |
|---|---|---|---|
| GET /contacts | list() | list.html | select … order by c1_0.name |
| GET /contacts/new | newForm() | empty form.html | none |
| POST /contacts | create() | 302 to /contacts | insert into contact … |
| GET /contacts/{id}/edit | editForm() | filled form.html | select … where c1_0.id=? |
| POST /contacts/{id} | update() | 302 to /contacts | update contact set … |
| POST /contacts/{id}/delete | delete() | 302 to /contacts | delete 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
| Property | What it does |
|---|---|
| ddl-auto=create-drop | Hibernate creates the contact table at startup and drops it at shutdown |
| show-sql=true | Prints every SQL statement (SQL logging has more options) |
| open-in-view=false | Database access ends with the service transaction (see section 3.4) |
| defer-datasource-initialization=true | Runs data.sql after Hibernate created the table, so the app starts with two contacts |
| tracking-modes=cookie | Keeps ;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 method | Hibernate call | SQL Hibernate printed |
|---|---|---|
| findAll() | JPQL query | 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 |
| 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 DAO | Spring Data JpaRepository | |
|---|---|---|
| Code we write | Every method | An interface; query methods from their names |
| Shows the Hibernate calls | Yes | Hidden in SimpleJpaRepository |
| Paging, sorting, count() | Write them | Built in |
| Best for | Learning, custom persistence code | Most 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.

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.

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>
| Annotation | Package in Spring Boot 4 | What it starts |
|---|---|---|
| @SpringBootTest | org.springframework.boot.test.context | The whole application |
| @AutoConfigureMockMvc | org.springframework.boot.webmvc.test.autoconfigure | MockMvc for @SpringBootTest |
| @WebMvcTest | org.springframework.boot.webmvc.test.autoconfigure | Controllers only, services must be mocked |
| @DataJpaTest | org.springframework.boot.data.jpa.test.autoconfigure | JPA, 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 setup | Spring Boot 4 |
|---|---|
| hibernate.cfg.xml, LocalSessionFactoryBean | *spring.datasource. and *spring.jpa. properties |
| HibernateTransactionManager, @EnableTransactionManagement | JpaTransactionManager, auto-configured |
| AbstractAnnotationConfigDispatcherServletInitializer | Embedded Tomcat and DispatcherServlet, auto-configured |
| JSP with InternalResourceViewResolver | Thymeleaf templates in src/main/resources/templates |
| c3p0 pool | HikariCP |
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.
| Layer | What happens |
|---|---|
| Service | existsByEmail() finds the email and throws DuplicateEmailException |
| Controller | result.rejectValue(“email”, “duplicate”, “Email is already used”) shows it under the input |
| Database | email 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
- Spring Framework 7: Spring Web MVC
- Spring Framework 7: @ModelAttribute method arguments
- Spring Framework 7: Flash Attributes
- Spring Framework 7: Using @Transactional
- Spring Framework 7: MockMvc
- Spring Boot 4: SQL Databases
- Spring Boot 4: Servlet Web Applications
- Spring Boot 4: Testing Spring Boot Applications
- Spring Data JPA Reference
- Thymeleaf + Spring Tutorial
- EntityManager JavaDoc (Jakarta Persistence 3.2)
- Hibernate ORM 7.4 User Guide
- Jakarta Validation 3.1
Happy Learning !!