How to build a Spring + Hibernate + gRPC application
1. Overview
This tutorial will provide an introduction to Spring Data JPA having a Spring project using Java 17, Hibernate 6, lombok, and gRPC , it will cover configuring the persistence layer. For a step-by-step introduction to setting up the Spring context using Java-based configuration and gRPC service definition plus support classes generation, you can checkout the projection configuration and sources from our repository
2. gRPC
gRPC (Google Remote Procedure Calls) is a cross-platform open source high performance remote procedure call (RPC) framework. gRPC was initially created by Google, which has used a single general-purpose RPC infrastructure called Stubby to connect the large number of microservices running within and across its data centers for over a decade
3. JPA (Jakarta Persistence API)
Jakarta Persistence API (JPA; formerly Java Persistence API) is a Jakarta EE application programming interface specification that describes the management of relational data in enterprise Java applications, is the most well know Java object/relational mapping API, that uses Java Annotations and has been used in the industry for many years
4. Entity Class
Creating the JPA's entities, is one of the most important tasks on our implementation since this where the ORM (object/relational mapping) is happening, the class will map the tables and fields that exists in our database, lately since the transition from Java EE to Jakarta EE we have to use the new package "jakarta.persistence" instead of "java.persistence" or else entities will not be found by the normal spring entity scan (@EntityScan)
in the below example we use @Entity to define that en class will be an entity and by default this will be the name of the table and it can be overwritten by @Table, in order the map the fields of the table we use @Column, in this example (Only for Java) we use Lombok to avoid Java's boilerplate code of getter/setters which are a requirement for JPA
import jakarta.persistence.*;
import lombok.*;
@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "UserData")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name="identifier")
private Long id;
@Column(name="fullName", length = 40, nullable = false)
private String fullName;
@Column(name="email", length = 50, unique = true, nullable = false)
private String email;
}
User.java
import jakarta.persistence.*
@Entity
@Table(name = "UserData")
class User (
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "identifier")
var id: Long? = null,
@Column(name="fullName", length = 40, nullable = false)
var fullName: String,
@Column(name="email", length = 50, unique = true, nullable = false)
var email: String
)
User.kt
import jakarta.persistence.{Column, Entity, GeneratedValue, Id, Table}
import scala.beans.BeanProperty
@Entity
@Table(name = "UserData")
class User (@BeanProperty
@Column(name = "fullName", length = 40, nullable = false)
var fullName: String,
@Column(name = "email", length = 50, nullable = false, unique = true)
@BeanProperty var email: String){
@Id
@GeneratedValue
@Column(name = "identifier")
var id: Long = _
def this() = this(null,null)
}
User.scala
5. Repository
The main advantage of Spring Data is avoiding the boilerplate code from JPA, specially the generating JPQL queries
by implementing one of the Repository interfaces, the repository will already have some basic CRUD methods (and queries) defined and implemented. To define more specific access methods, Spring Data JPA supports quite the following options:
- Simply define a new method in the interface
- Provide the actual JPQL query by using the @Query annotation
- Use the more advanced Specification and Querydsl support in Spring Data
- Define custom queries via JPA Named Queries
import com.the.sample.app.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository {
Optional findByEmail(String email);
}
UserRepository.java
import com.the.sample.app.model.User
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.util.*
@Repository
interface UserRepository : JpaRepository{
fun findByEmail(email: String?): Optional
}
UserRepository.kt
import com.the.sample.app.model.User
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
trait UserRepository extends JpaRepository[User,Long]{
def findByEmail(email: String): User
}
UserRepository.scala
6. Service
The service layer defines any business logic that needs to be implemented as well the transactional layer by using the @Transactional, any data access outside this layer is not permitted, ex a common issue is trying to access a collection that depends on a JPA releations on the integration layer like Rest or gRPC, this will trigger a JPA session error.
import com.the.sample.app.model.User;
import com.the.sample.app.repository.UserRepository;
import lombok.AllArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
@Transactional
@AllArgsConstructor
public class UserServiceImpl implements UserService{
private final UserRepository userRepository;
@Override
public List findAll(int page, int pageSize) {
return userRepository.findAll(PageRequest.of(page,pageSize)).toList();
}
@Override
public Optional findById(Long id) {
return userRepository.findById(id);
}
@Override
public Optional findByEmail(String email) {
return userRepository.findByEmail(email);
}
@Override
public void save(User user) {
userRepository.save(user);
}
@Override
public void deleteById(Long id) {
userRepository.deleteById(id);
}
}
UserServiceImpl.java
import com.the.sample.app.model.User
import com.the.sample.app.repository.UserRepository
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
interface UserService {
fun findAll(page: Int, pageSize: Int): List
fun findById(id: Long): User?
fun findByEmail(email: String): User?
fun save(user: User)
fun deleteById(id: Long)
}
@Service
@Transactional
class UserServiceImpl(val userRepository: UserRepository) : UserService{
override fun findAll(page: Int, pageSize: Int): List {
return userRepository.findAll(PageRequest.of(page,pageSize)).toList()
}
override fun findById(id: Long): User? {
return userRepository.findById(id).orElse(null)
}
override fun findByEmail(email: String): User? {
return userRepository.findByEmail(email).orElse(null)
}
override fun save(user: User) {
userRepository.save(user)
}
override fun deleteById(id: Long) {
userRepository.deleteById(id)
}
}
UserService.kt
import com.the.sample.app.model.User
import com.the.sample.app.repository.UserRepository
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
trait UserService {
def findAll(page: Int, pageSize: Int): java.util.List[User]
def findById(id: Long): Option[User]
def findByEmail(email: String): Option[User]
def save(user: User): Unit
def deleteById(id: Long): Unit
}
@Service
@Transactional
class UserServiceImpl(userRepository: UserRepository) extends UserService {
import scala.jdk.OptionConverters._
override def findAll(page: Int, pageSize: Int): java.util.List[User] =
userRepository.findAll(PageRequest.of(page, pageSize)).toList
override def findById(id: Long): Option[User] = userRepository.findById(id).toScala
override def findByEmail(email: String): Option[User] = userRepository.findByEmail(email).toScala
override def save(user: User): Unit = userRepository.save(user)
override def deleteById(id: Long): Unit = userRepository.deleteById(id)
}
UserService.scala
7. Grpc Message / Service Definition
This is where we define the Messages and Services where our microservice can be called by gRPC using a binary protocol reducing latency. this definition is made on protobuf
syntax = "proto3";
import "google/protobuf/empty.proto";
option java_package = "com.the.sample.app.grpc";
message CreateUserRequest{
string email = 1;
string fullName = 2;
}
message UpdateUserRequest{
uint64 id = 1;
string email = 2;
string fullName = 3;
}
message UserByIdRequest{
uint64 id = 1;
}
message UserByEmailRequest{
string email = 1;
}
message UserDto{
uint64 id = 1;
string email = 2;
string fullName = 3;
}
message UserResponse{
UserDto user = 1;
}
service UserServiceEndpoint{
rpc findById(UserByIdRequest) returns (UserResponse);
rpc findByEmail(UserByEmailRequest) returns (UserResponse);
rpc save(CreateUserRequest) returns (google.protobuf.Empty);
rpc update(UpdateUserRequest) returns (google.protobuf.Empty);
rpc deleteById(UserByIdRequest) returns (google.protobuf.Empty);
}
this definition will be compile by the protobuf compiler and generate the supported classes (Stub and Messages classes) to implement the gRPC service, in order to facilitate this compilation they are many maven, gradle and sbt plugins that automatically makes this compilation.
- Java/Kotlin - Checkout the gradle.build
- Scala - we use ScalaPB, unlike Java/Kotlin, this will generate Scala sources rather than Java making the implementation more scala like, Checkout the build.sbt
8. Grpc Endpoint
The protobuf difinition file allow use to define the messages and service that will become in our gRPC integration layer
import com.google.protobuf.Empty;
import com.the.sample.app.model.User;
import com.the.sample.app.service.UserService;
import io.grpc.stub.StreamObserver;
import lombok.AllArgsConstructor;
import org.lognet.springboot.grpc.GRpcService;
import static com.the.sample.app.grpc.UserServiceEndpointGrpc.UserServiceEndpointImplBase;
import static com.the.sample.app.grpc.UserServiceEndpointOuterClass.UserByIdRequest;
import static com.the.sample.app.grpc.UserServiceEndpointOuterClass.UserResponse;
import static com.the.sample.app.grpc.UserServiceEndpointOuterClass.CreateUserRequest;
import static com.the.sample.app.grpc.UserServiceEndpointOuterClass.UpdateUserRequest;
import static com.the.sample.app.grpc.UserServiceEndpointOuterClass.UserDto;
import static com.the.sample.app.grpc.UserServiceEndpointOuterClass.UserByEmailRequest;
import java.util.Optional;
@GRpcService
@AllArgsConstructor
public class UserServiceEndpointImpl extends UserServiceEndpointImplBase {
private final UserService userService;
@Override
public void findById(UserByIdRequest request, StreamObserver responseObserver) {
UserDto userDto = Optional.ofNullable(request).map(UserByIdRequest::getId).
map(userService::findById).
flatMap(res ->
res.map(userData->UserDto.newBuilder().
setEmail(userData.getEmail()).
setFullName(userData.getFullName()).build())).orElse(null);
responseObserver.onNext(UserResponse.newBuilder().setUser(userDto).build());
responseObserver.onCompleted();
}
@Override
public void findByEmail(UserByEmailRequest request, StreamObserver responseObserver) {
UserDto userDto = Optional.ofNullable(request).map(UserByEmailRequest::getEmail).
map(userService::findByEmail).
flatMap(res ->
res.map(userData->UserDto.newBuilder().
setEmail(userData.getEmail()).
setFullName(userData.getFullName()).build())).orElse(null);
responseObserver.onNext(UserResponse.newBuilder().setUser(userDto).build());
responseObserver.onCompleted();
}
@Override
public void save(CreateUserRequest request, StreamObserver responseObserver) {
Optional.ofNullable(request).ifPresent(req ->
userService.save(User.builder().
email(req.getEmail()).
fullName(req.getFullName()).build()));
responseObserver.onNext(Empty.newBuilder().build());
responseObserver.onCompleted();
}
@Override
public void update(UpdateUserRequest request, StreamObserver responseObserver) {
Optional.ofNullable(request).ifPresent(req ->
userService.save(User.builder().
id(req.getId()).
email(req.getEmail()).
fullName(req.getFullName()).build()));
responseObserver.onNext(Empty.newBuilder().build());
responseObserver.onCompleted();
}
@Override
public void deleteById(UserByIdRequest request, StreamObserver responseObserver) {
Optional.ofNullable(request).
map(UserByIdRequest::getId).
ifPresent(id->userService.deleteById(id));
responseObserver.onNext(Empty.newBuilder().build());
responseObserver.onCompleted();
}
}
UserServiceEndpointImpl.java
import com.google.protobuf.Empty
import com.the.sample.app.grpc.UserServiceEndpointGrpc.UserServiceEndpointImplBase
import com.the.sample.app.grpc.UserServiceEndpointOuterClass.*
import com.the.sample.app.model.User
import com.the.sample.app.service.UserService
import io.grpc.stub.StreamObserver
import org.lognet.springboot.grpc.GRpcService
import java.util.*
@GRpcService
class UserServiceEndpointImpl(val userService: UserService) : UserServiceEndpointImplBase() {
override fun findById(request: UserByIdRequest, responseObserver: StreamObserver) {
val userDto: UserDto? = Optional.ofNullable(request).
map { request: UserByIdRequest -> request.id }.
map { id: Long -> userService.findById(id) }.
flatMap { res-> Optional.ofNullable(res).map { userData: User ->
UserDto.newBuilder().setEmail(userData.email).setFullName(userData.fullName).build() } }.
orElse(null)
responseObserver.onNext(UserResponse.newBuilder().setUser(userDto).build())
responseObserver.onCompleted()
}
override fun findByEmail(request: UserByEmailRequest, responseObserver: StreamObserver) {
val userDto: UserDto? = Optional.ofNullable(request).
map { request: UserByEmailRequest -> request.email }.
map { email: String -> userService.findByEmail(email) }.
flatMap { res-> Optional.ofNullable(res).map { userData: User ->
UserDto.newBuilder().setEmail(userData.email).setFullName(userData.fullName).build() } }.
orElse(null)
responseObserver.onNext(UserResponse.newBuilder().setUser(userDto).build())
responseObserver.onCompleted()
}
override fun save(request: CreateUserRequest, responseObserver: StreamObserver) {
Optional.ofNullable(request).ifPresent { req: CreateUserRequest ->
userService!!.save(
User(email = req.email, fullName = req.fullName)
)
}
responseObserver.onNext(Empty.newBuilder().build())
responseObserver.onCompleted()
}
override fun update(request: UpdateUserRequest?, responseObserver: StreamObserver) {
Optional.ofNullable(request).ifPresent { req: UpdateUserRequest ->
userService!!.save(
User(id = req.id,
email = req.email,
fullName = req.fullName)
)
}
responseObserver.onNext(Empty.newBuilder().build())
responseObserver.onCompleted()
}
override fun deleteById(request: UserByIdRequest, responseObserver: StreamObserver) {
Optional.ofNullable(request).map { obj: UserByIdRequest -> obj.id }.ifPresent { id: Long? ->
userService!!.deleteById(
id!!
)
}
responseObserver.onNext(Empty.newBuilder().build())
responseObserver.onCompleted()
}
}
UserServiceEndpointImpl.kt
import com.the.sample.app.model.User
import com.google.protobuf.empty.Empty
import com.the.sample.app.grpc.UserServiceEndpoint.{CreateUserRequest, UpdateUserRequest, UserByEmailRequest, UserByIdRequest, UserDto, UserResponse, UserServiceEndpointGrpc}
import com.the.sample.app.service.UserService
import org.lognet.springboot.grpc.GRpcService
import scala.concurrent.Future
@GRpcService
class UserServiceEndpointImpl(val userService: UserService) extends UserServiceEndpointGrpc.UserServiceEndpoint{
override def findById(request: UserByIdRequest): Future[UserResponse] =
Future.successful(UserResponse(user = Option(request).map(_.id).
flatMap(userService.findById(_)).map(user=>
UserDto(id = user.id,
email = user.email,
fullName = user.fullName))))
override def findByEmail(request: UserByEmailRequest): Future[UserResponse] =
Future.successful(UserResponse(user = Option(request).map(_.email).
flatMap(userService.findByEmail(_)).map(user=>
UserDto(id = user.id,
email = user.email,
fullName = user.fullName))))
override def save(request: CreateUserRequest): Future[Empty] = {
Option(request).
map(req=>new User(req.email,req.fullName)).
foreach(userService.save(_))
Future.successful(Empty.defaultInstance)
}
override def update(request: UpdateUserRequest): Future[Empty] = {
Option(request).
map(req=>{
val user = new User(req.email,req.fullName)
user.id = req.id
user
}).
foreach(userService.save(_))
Future.successful(Empty.defaultInstance)
}
override def deleteById(request: UserByIdRequest): Future[Empty] = {
Option(request).
map(_.id).
foreach(userService.deleteById(_))
Future.successful(Empty.defaultInstance)
}
}
UserServiceEndpointImpl.scala
9. Spring Boot Context Configuration
This is the main component that starts our Spring Context and our JPA session manager
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication
@EntityScan(basePackages = "com.the.sample.app")
@EnableJpaRepositories(basePackages = {"com.the.sample.app"})
@EnableTransactionManagement
public class SpringHibernateCrudApplication {
public static void main(String[] args) {
SpringApplication.run(SpringHibernateCrudApplication.class, args);
}
}
SpringHibernateCrudApplication.java
import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.autoconfigure.domain.EntityScan
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
import org.springframework.transaction.annotation.EnableTransactionManagement
@SpringBootApplication
@EntityScan(basePackages = ["com.the.sample.app"])
@EnableJpaRepositories(basePackages = ["com.the.sample.app"])
@EnableTransactionManagement
class SpringHibernateCrudApplication
fun main(args: Array) {
SpringApplication.run(SpringHibernateCrudApplication::class.java, *args)
}
SpringHibernateCrudApplication.kt
import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.autoconfigure.domain.EntityScan
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
import org.springframework.transaction.annotation.EnableTransactionManagement
@SpringBootApplication
@EntityScan(basePackages = Array("com.the.sample.app"))
@EnableJpaRepositories(basePackages = {
Array("com.the.sample.app")
})
@EnableTransactionManagement
class SpringHibernateCrudApplication
object SpringHibernateCrudApplication extends App{
SpringApplication.run(classOf[SpringHibernateCrudApplication])
}
SpringHibernateCrudApplication.scala
- spring.datasource.driver-class-name - Defines the JDBC driver class name to use (JPA support the most common RDMS databases such as MySql, Postgres, Oracle, DB2)
- spring.datasource.url - defines the JDBC connection url
- spring.jpa.properties.hibernate.dialect - defines the database dialect to use for Queries or DDL statements