Spring Data JPA sits on top of JPA (Jakarta Persistence API), which itself sits on top of a JPA provider (Hibernate by default in Spring Boot).
spring-boot-starter-data-jpa pulls in Hibernate, Spring Data JPA, and Spring ORM.
The autoconfiguration wires a DataSource, a LocalContainerEntityManagerFactoryBean, and a JpaTransactionManager — all without any explicit configuration in this project.
EntityManagerFactory is the JPA entry point; Spring wraps it and manages transactions transparently via AOP proxies on @Transactional boundaries.spring.jpa.hibernate.ddl-auto) defaults to create-drop for embedded databases — schema is created on startup and dropped on shutdown.spring-boot-h2console is a dedicated starter (separate from h2) that enables the H2 web console at /h2-console. It is distinct from the spring.h2.console.enabled=true property approach.maven-compiler-plugin via <annotationProcessorPaths>, and explicitly excluded from the fat JAR via spring-boot-maven-plugin excludes — it generates code at compile time and has zero runtime footprint.<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-h2console</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
spring.datasource.generate-unique-name=false disables this so the JDBC URL is predictable: jdbc:h2:mem:mtitek-spring-jpa.spring.datasource.url is set — the datasource name drives the in-memory H2 URL automatically when using an embedded database.http://localhost:8080/h2-console) requires the exact JDBC URL to connect: jdbc:h2:mem:mtitek-spring-jpa.spring.application.name=mtitek-spring-jpa spring.datasource.name=mtitek-spring-jpa spring.datasource.generate-unique-name=false
spring.datasource.name — jdbc:h2:mem:mtitek-spring-jpa. The default H2 console pre-fills jdbc:h2:~/test; this must be changed manually on each connection.APP_USER, APP_PROFILE) and a join table APP_USER_APP_PROFILES. Column names follow Hibernate's default naming strategy (field names to snake_case for Spring Boot's SpringPhysicalNamingStrategy).URL: http://localhost:8080/h2-console JDBC URL: jdbc:h2:mem:mtitek-spring-jpa Driver: org.h2.Driver User: sa Password: (empty)
@Id with a String type and no @GeneratedValue means the application is responsible for assigning IDs. JPA will not auto-generate them. Saving with a duplicate ID triggers a DataIntegrityViolationException.Role enum is persisted as a string by default (EnumType.ORDINAL is the JPA default, but Hibernate's default for enums stored without explicit @Enumerated in newer versions may vary). Without @Enumerated(EnumType.STRING), ordinal storage means enum reordering breaks existing data.@NoArgsConstructor(access = AccessLevel.PRIVATE, force = true): JPA requires a no-arg constructor; force = true initializes all final fields to their defaults. Making it PRIVATE hides it from application code while satisfying the JPA spec (Hibernate uses reflection to invoke it).@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PRIVATE, force = true)
public class AppProfile {
@Id
private String id;
private String name;
private Role role;
public enum Role {
USER, ADMIN, SUPPORT;
}
}
GenerationType.AUTO delegates strategy selection to Hibernate. For H2, Hibernate typically uses a sequence-based strategy, creating a shared hibernate_sequence table or sequence object.@ManyToMany() with no explicit @JoinTable causes Hibernate to generate a join table named APP_USER_APP_PROFILES (entity name + field name, uppercased). Both foreign keys reference the owning side (AppUser). There is no mappedBy, so this is a unidirectional many-to-many — AppProfile has no back-reference.@Size(min = 1) on appProfiles is a Bean Validation constraint, not a JPA constraint. It validates the collection size during controller binding (@Valid) before any persistence call.= new ArrayList<>() ensures the collection is never null on new instances. Without it, addAppProfile() would throw an NPE on a freshly constructed entity. Lombok's @NoArgsConstructor(force = true) alone would initialize the field to null if no initializer were present.@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor(force = true)
public class AppUser {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@NotNull
@Size(min = 3, message = "Name must be at least 3 characters long")
private String name;
@Size(min = 1, message = "You must choose at least 1 appProfile")
@ManyToMany()
private List<AppProfile> appProfiles = new ArrayList<>();
public void addAppProfile(AppProfile appProfile) {
this.appProfiles.add(appProfile);
}
}
CrudRepository, not JpaRepository. CrudRepository provides save, findById, findAll, delete, and count. JpaRepository additionally exposes flush, saveAndFlush, and batch delete operations.AppProfileRepository is String (matching the @Id String id in AppProfile). Spring Data uses this to generate the correct findById query and to distinguish new vs. existing entities: since there is no @GeneratedValue, Spring Data checks isNew() — for non-Persistable entities with a non-null ID, it defaults to calling merge instead of persist.findAll() on CrudRepository returns Iterable<T>, not List<T>. The controller uses StreamSupport.stream(appProfiles.spliterator(), false) to bridge the Iterable to a stream for filtering — necessary because Iterable has no direct stream() method.public interface AppUserRepository extends CrudRepository<AppUser, Long> {
}
public interface AppProfileRepository extends CrudRepository<AppProfile, String> {
}
CommandLineRunner is invoked after the full application context is refreshed and all beans are initialized — including the EntityManagerFactory and the Hibernate schema creation.AppProfileRepository bean is injected directly as a method parameter of the @Bean method — this is standard Spring DI for @Bean factory methods and does not require @Autowired.create-drop DDL by default, these seed records are inserted on every application start and dropped on shutdown. A production setup would use spring.jpa.hibernate.ddl-auto=none.@Bean
public CommandLineRunner saveAppProfiles(AppProfileRepository repo) {
return new CommandLineRunner() {
@Override
public void run(String... args) throws Exception {
repo.save(new AppProfile("1", "AppProfile 1 USER", Role.USER));
repo.save(new AppProfile("2", "AppProfile 2 ADMIN", Role.ADMIN));
repo.save(new AppProfile("3", "AppProfile 3 SUPPORT", Role.SUPPORT));
}
};
}
@ModelAttribute(name = "appUser") on the factory method initializes the session-scoped AppUser only if it is not already present in the session. Once @SessionAttributes("appUser") is active and the model attribute exists in the session, Spring binds the session object — the factory method is not called again for subsequent requests in the same session.@Controller
@RequestMapping("/appProfiles")
@SessionAttributes("appUser")
public class AppProfileController {
@ModelAttribute
public void addAppProfilesToModel(Model model) {
Iterable<AppProfile> appProfiles = appProfileRepository.findAll();
Role[] roles = AppProfile.Role.values();
for (Role role : roles) {
model.addAttribute(role.toString().toLowerCase(), filterByRole(appProfiles, role));
}
}
@ModelAttribute(name = "appUser")
public AppUser appUser() {
return new AppUser();
}
@PostMapping
public String addAppProfile(@RequestParam(required = false) String appProfileId, @ModelAttribute AppUser appUser, Model model) {
Optional<AppProfile> appProfile = appProfileRepository.findById(appProfileId);
appUser.addAppProfile(appProfile.get());
return "redirect:/appUsers/user";
}
}
appUserRepository.save(appUser) persists the AppUser along with its appProfiles list. Because @ManyToMany has no cascade attribute, the AppProfile entities in the list must already be managed/persistent — which they are, having been loaded via findById earlier. Attempting to save with a transient (detached or new) AppProfile in the list would throw a TransientPropertyValueException.sessionStatus.setComplete() clears the @SessionAttributes entries from the HTTP session immediately after a successful save. Without this call, the AppUser would persist in the session and be reused (with its accumulated appProfiles) on the next form visit.@SessionAttributes("appUser"): Spring's session attribute store is keyed by the attribute name within a single controller's session scope — not shared across controllers by default. An AppUser placed in the session by AppProfileController is accessible to AppUserController only because both are in the same HTTP session and both declare the same @SessionAttributes name, allowing Spring to retrieve it from the underlying HttpSession.@Controller
@RequestMapping("/appUsers")
@SessionAttributes("appUser")
public class AppUserController {
@PostMapping
public String processAppUser(@Valid AppUser appUser, Errors errors, SessionStatus sessionStatus) {
if (errors.hasErrors()) {
return "appUserForm";
}
appUserRepository.save(appUser);
sessionStatus.setComplete();
return "redirect:/appProfiles";
}
}