JPA & Hibernate

Object-Relational Mapping made simple

← Back to Index

What is JPA?

JPA (Jakarta Persistence API), formerly Java Persistence API, is a specification that describes how to manage relational data in Java applications. It defines a standard way to map Java objects to database tables (ORM - Object-Relational Mapping) and provides APIs for CRUD operations, queries, and transactions.

JPA is a specification, not an implementation. You need a JPA provider (implementation) to use JPA. Hibernate is the most popular JPA provider, but others exist (EclipseLink, OpenJPA).

JPA vs Hibernate vs JDBC

// Architecture comparison:

┌─────────────────────────────────────────────────────────────┐
│                    YOUR APPLICATION                          │
└─────────────────────────────────────────────────────────────┘
           │                    │                    │
           ▼                    ▼                    ▼
┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐
│   Pure JDBC     │  │      JPA        │  │    Hibernate    │
│  (Manual SQL)   │  │ (Specification) │  │  (Native API)   │
└─────────────────┘  └─────────────────┘  └─────────────────┘
           │                    │                    │
           │                    ▼                    │
           │         ┌─────────────────┐             │
           │         │    Hibernate    │◄────────────┘
           │         │ (JPA Provider)  │
           │         └─────────────────┘
           │                    │
           └────────────────────┼────────────────────┘
                                ▼
                    ┌─────────────────┐
                    │      JDBC       │
                    └─────────────────┘
                                │
                                ▼
                    ┌─────────────────┐
                    │    Database     │
                    └─────────────────┘
Aspect JDBC JPA/Hibernate
Abstraction Level Low - write SQL High - work with objects
Boilerplate Code High Low
Learning Curve Lower Higher
Performance Control Full control Less direct control
Caching Manual Built-in (L1, L2)
Database Portability SQL dialect issues Better portability

Setting Up JPA with Hibernate

Maven Dependencies

<!-- JPA API -->
<dependency>
    <groupId>jakarta.persistence</groupId>
    <artifactId>jakarta.persistence-api</artifactId>
    <version>3.1.0</version>
</dependency>

<!-- Hibernate (JPA Provider) -->
<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-core</artifactId>
    <version>6.4.1.Final</version>
</dependency>

<!-- Database Driver -->
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>8.3.0</version>
</dependency>

<!-- For Spring Boot (all-in-one) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

Configuration (persistence.xml)

<!-- src/main/resources/META-INF/persistence.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="https://jakarta.ee/xml/ns/persistence"
             version="3.0">

    <persistence-unit name="myPU" transaction-type="RESOURCE_LOCAL">
        <provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>

        <!-- Entity classes -->
        <class>com.example.entity.Employee</class>
        <class>com.example.entity.Department</class>

        <properties>
            <!-- Database connection -->
            <property name="jakarta.persistence.jdbc.driver"
                      value="com.mysql.cj.jdbc.Driver"/>
            <property name="jakarta.persistence.jdbc.url"
                      value="jdbc:mysql://localhost:3306/mydb"/>
            <property name="jakarta.persistence.jdbc.user"
                      value="root"/>
            <property name="jakarta.persistence.jdbc.password"
                      value="password"/>

            <!-- Hibernate settings -->
            <property name="hibernate.dialect"
                      value="org.hibernate.dialect.MySQLDialect"/>
            <property name="hibernate.hbm2ddl.auto"
                      value="update"/>
            <property name="hibernate.show_sql"
                      value="true"/>
            <property name="hibernate.format_sql"
                      value="true"/>

            <!-- Connection pool (HikariCP) -->
            <property name="hibernate.hikari.minimumIdle"
                      value="5"/>
            <property name="hibernate.hikari.maximumPoolSize"
                      value="20"/>
        </properties>
    </persistence-unit>
</persistence>

Spring Boot Configuration (application.yml)

# application.yml - much simpler!
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/mydb
    username: root
    password: password
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: update  # create, create-drop, validate, update, none
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        dialect: org.hibernate.dialect.MySQLDialect

Entity Mapping Basics

Simple Entity

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;

@Entity                                   // Marks class as JPA entity
@Table(name = "employees")               // Maps to table name
public class Employee {

    @Id                                    // Primary key
    @GeneratedValue(strategy = GenerationType.IDENTITY)  // Auto-increment
    private Long id;

    @Column(name = "first_name", nullable = false, length = 50)
    private String firstName;

    @Column(name = "last_name", nullable = false, length = 50)
    private String lastName;

    @Column(unique = true, nullable = false)
    private String email;

    @Column(precision = 10, scale = 2)
    private BigDecimal salary;

    @Column(name = "hire_date")
    private LocalDate hireDate;

    @Column(nullable = false)
    private Boolean active = true;

    @Enumerated(EnumType.STRING)           // Store enum as string, not ordinal
    private EmployeeStatus status;

    @Lob                                   // Large object (TEXT/BLOB)
    private String biography;

    @Transient                             // Not persisted to database
    private String fullName;

    // Default constructor required by JPA
    public Employee() {}

    // Getters and setters...

    // Calculated field
    public String getFullName() {
        return firstName + " " + lastName;
    }
}

public enum EmployeeStatus {
    ACTIVE, INACTIVE, ON_LEAVE, TERMINATED
}

ID Generation Strategies

// AUTO - Let JPA choose the strategy
@GeneratedValue(strategy = GenerationType.AUTO)

// IDENTITY - Database auto-increment (MySQL, PostgreSQL SERIAL)
@GeneratedValue(strategy = GenerationType.IDENTITY)

// SEQUENCE - Database sequence (Oracle, PostgreSQL)
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "emp_seq")
@SequenceGenerator(name = "emp_seq", sequenceName = "employee_sequence", allocationSize = 50)

// TABLE - Simulated sequence using a table
@GeneratedValue(strategy = GenerationType.TABLE, generator = "emp_gen")
@TableGenerator(name = "emp_gen", table = "id_generator", pkColumnValue = "employee")

// UUID - For distributed systems
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;

Embedded Objects

@Embeddable
public class Address {
    private String street;
    private String city;
    private String state;
    private String zipCode;
    private String country;

    // Constructors, getters, setters...
}

@Entity
public class Employee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @Embedded
    private Address homeAddress;

    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "street", column = @Column(name = "work_street")),
        @AttributeOverride(name = "city", column = @Column(name = "work_city")),
        @AttributeOverride(name = "state", column = @Column(name = "work_state")),
        @AttributeOverride(name = "zipCode", column = @Column(name = "work_zip")),
        @AttributeOverride(name = "country", column = @Column(name = "work_country"))
    })
    private Address workAddress;
}

Auditing Fields

@MappedSuperclass  // Not an entity itself, inherited by entities
public abstract class BaseEntity {

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

    @Column(name = "created_at", updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at")
    private LocalDateTime updatedAt;

    @Column(name = "created_by", updatable = false)
    private String createdBy;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
        updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        updatedAt = LocalDateTime.now();
    }
}

@Entity
public class Employee extends BaseEntity {
    private String name;
    // id, createdAt, updatedAt inherited
}

EntityManager Operations

Basic CRUD Operations

import jakarta.persistence.*;

public class EmployeeService {

    private EntityManagerFactory emf = Persistence.createEntityManagerFactory("myPU");

    // CREATE - persist()
    public void save(Employee employee) {
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        try {
            tx.begin();
            em.persist(employee);  // INSERT
            tx.commit();
        } catch (Exception e) {
            if (tx.isActive()) tx.rollback();
            throw e;
        } finally {
            em.close();
        }
    }

    // READ - find()
    public Employee findById(Long id) {
        EntityManager em = emf.createEntityManager();
        try {
            return em.find(Employee.class, id);  // SELECT by primary key
        } finally {
            em.close();
        }
    }

    // READ - getReference() - lazy loading proxy
    public Employee getReference(Long id) {
        EntityManager em = emf.createEntityManager();
        try {
            return em.getReference(Employee.class, id);  // Returns proxy, may throw EntityNotFoundException
        } finally {
            em.close();
        }
    }

    // UPDATE - merge()
    public Employee update(Employee employee) {
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        try {
            tx.begin();
            Employee merged = em.merge(employee);  // UPDATE
            tx.commit();
            return merged;
        } catch (Exception e) {
            if (tx.isActive()) tx.rollback();
            throw e;
        } finally {
            em.close();
        }
    }

    // DELETE - remove()
    public void delete(Long id) {
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        try {
            tx.begin();
            Employee employee = em.find(Employee.class, id);
            if (employee != null) {
                em.remove(employee);  // DELETE
            }
            tx.commit();
        } catch (Exception e) {
            if (tx.isActive()) tx.rollback();
            throw e;
        } finally {
            em.close();
        }
    }
}

Entity States and Lifecycle

// Entity States in JPA:

┌──────────────────────────────────────────────────────────────┐
│                                                              │
│  ┌─────────┐                      ┌──────────┐              │
│  │   NEW   │──────persist()──────▶│ MANAGED  │              │
│  │(Transient)│                    │          │              │
│  └─────────┘                      └────┬─────┘              │
│       ▲                                │                    │
│       │                          find() │ merge()           │
│   new Entity()                   ┌─────▼─────┐              │
│       │                          │ Database  │              │
│       │                          └───────────┘              │
│       │                                │                    │
│       │                         remove()│                   │
│       │                                ▼                    │
│       │                          ┌──────────┐              │
│       │                          │ REMOVED  │              │
│       │                          └────┬─────┘              │
│       │                               │                    │
│       │                         close()│ clear()           │
│       │                  detach()      │                    │
│       │      ┌─────────────────────────┴──────────────┐    │
│       │      ▼                                         │    │
│  ┌─────────────────┐                                   │    │
│  │    DETACHED     │──────────────merge()──────────────┘    │
│  └─────────────────┘                                        │
│                                                              │
└──────────────────────────────────────────────────────────────┘

// NEW (Transient): Just created, not associated with EntityManager
Employee emp = new Employee();
emp.setName("John");

// MANAGED: Tracked by EntityManager, changes auto-synced
em.persist(emp);  // Now managed
emp.setName("Jane");  // Change will be saved on commit!

// DETACHED: Was managed, but EntityManager closed
em.close();  // emp is now detached
emp.setName("Jack");  // Change NOT tracked!

// REMOVED: Marked for deletion
em.remove(emp);  // Will be deleted on commit

JPQL and Criteria API

JPQL (Java Persistence Query Language)

public class EmployeeRepository {

    private EntityManager em;

    // Simple query
    public List<Employee> findAll() {
        return em.createQuery(
            "SELECT e FROM Employee e",
            Employee.class
        ).getResultList();
    }

    // Query with parameter
    public List<Employee> findByDepartment(String deptName) {
        return em.createQuery(
            "SELECT e FROM Employee e WHERE e.department.name = :deptName",
            Employee.class
        )
        .setParameter("deptName", deptName)
        .getResultList();
    }

    // Query with multiple parameters
    public List<Employee> findBySalaryRange(BigDecimal min, BigDecimal max) {
        return em.createQuery(
            "SELECT e FROM Employee e WHERE e.salary BETWEEN :min AND :max ORDER BY e.salary DESC",
            Employee.class
        )
        .setParameter("min", min)
        .setParameter("max", max)
        .getResultList();
    }

    // Pagination
    public List<Employee> findAllPaginated(int page, int size) {
        return em.createQuery("SELECT e FROM Employee e ORDER BY e.id", Employee.class)
            .setFirstResult(page * size)
            .setMaxResults(size)
            .getResultList();
    }

    // Single result
    public Employee findByEmail(String email) {
        try {
            return em.createQuery(
                "SELECT e FROM Employee e WHERE e.email = :email",
                Employee.class
            )
            .setParameter("email", email)
            .getSingleResult();
        } catch (NoResultException e) {
            return null;
        }
    }

    // JOIN queries
    public List<Employee> findWithDepartment() {
        return em.createQuery(
            "SELECT e FROM Employee e JOIN FETCH e.department",
            Employee.class
        ).getResultList();
    }

    // Aggregate functions
    public Double getAverageSalary() {
        return em.createQuery(
            "SELECT AVG(e.salary) FROM Employee e",
            Double.class
        ).getSingleResult();
    }

    // Projection (DTO)
    public List<EmployeeDTO> findAllAsDTO() {
        return em.createQuery(
            "SELECT NEW com.example.dto.EmployeeDTO(e.id, e.firstName, e.lastName, e.email) FROM Employee e",
            EmployeeDTO.class
        ).getResultList();
    }

    // UPDATE query
    public int giveRaise(Long deptId, BigDecimal percentage) {
        return em.createQuery(
            "UPDATE Employee e SET e.salary = e.salary * (1 + :pct) WHERE e.department.id = :deptId"
        )
        .setParameter("pct", percentage.divide(new BigDecimal("100")))
        .setParameter("deptId", deptId)
        .executeUpdate();
    }

    // DELETE query
    public int deleteInactive() {
        return em.createQuery(
            "DELETE FROM Employee e WHERE e.active = false"
        ).executeUpdate();
    }
}

Named Queries

@Entity
@NamedQueries({
    @NamedQuery(
        name = "Employee.findAll",
        query = "SELECT e FROM Employee e ORDER BY e.lastName"
    ),
    @NamedQuery(
        name = "Employee.findByDepartment",
        query = "SELECT e FROM Employee e WHERE e.department.id = :deptId"
    ),
    @NamedQuery(
        name = "Employee.findActive",
        query = "SELECT e FROM Employee e WHERE e.active = true"
    )
})
public class Employee {
    // ...
}

// Usage
List<Employee> employees = em.createNamedQuery("Employee.findAll", Employee.class)
    .getResultList();

List<Employee> deptEmployees = em.createNamedQuery("Employee.findByDepartment", Employee.class)
    .setParameter("deptId", departmentId)
    .getResultList();

Criteria API (Type-Safe Queries)

import jakarta.persistence.criteria.*;

public class EmployeeRepository {

    private EntityManager em;

    // Simple criteria query
    public List<Employee> findAll() {
        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<Employee> cq = cb.createQuery(Employee.class);
        Root<Employee> root = cq.from(Employee.class);

        cq.select(root);

        return em.createQuery(cq).getResultList();
    }

    // Criteria with WHERE clause
    public List<Employee> findByDepartment(Long deptId) {
        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<Employee> cq = cb.createQuery(Employee.class);
        Root<Employee> root = cq.from(Employee.class);

        cq.select(root)
          .where(cb.equal(root.get("department").get("id"), deptId));

        return em.createQuery(cq).getResultList();
    }

    // Dynamic criteria with multiple conditions
    public List<Employee> search(String name, BigDecimal minSalary, Boolean active) {
        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<Employee> cq = cb.createQuery(Employee.class);
        Root<Employee> root = cq.from(Employee.class);

        List<Predicate> predicates = new ArrayList<>();

        if (name != null) {
            predicates.add(cb.like(
                cb.lower(root.get("lastName")),
                "%" + name.toLowerCase() + "%"
            ));
        }

        if (minSalary != null) {
            predicates.add(cb.greaterThanOrEqualTo(root.get("salary"), minSalary));
        }

        if (active != null) {
            predicates.add(cb.equal(root.get("active"), active));
        }

        cq.where(predicates.toArray(new Predicate[0]));
        cq.orderBy(cb.asc(root.get("lastName")));

        return em.createQuery(cq).getResultList();
    }

    // Criteria with JOIN
    public List<Employee> findByDepartmentName(String deptName) {
        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<Employee> cq = cb.createQuery(Employee.class);
        Root<Employee> root = cq.from(Employee.class);

        Join<Employee, Department> deptJoin = root.join("department");

        cq.select(root)
          .where(cb.equal(deptJoin.get("name"), deptName));

        return em.createQuery(cq).getResultList();
    }
}

Fetching Strategies

Lazy vs Eager Loading

@Entity
public class Employee {

    @ManyToOne(fetch = FetchType.LAZY)  // Load department only when accessed
    private Department department;

    @OneToMany(fetch = FetchType.LAZY)  // Default for collections
    private List<Project> projects;

    @ManyToOne(fetch = FetchType.EAGER) // Load immediately with employee
    private Manager manager;
}

// Defaults:
// @OneToMany, @ManyToMany -> LAZY
// @OneToOne, @ManyToOne -> EAGER

// Best practice: Make everything LAZY, use JOIN FETCH when needed

N+1 Problem and Solutions

// THE N+1 PROBLEM:
List<Employee> employees = em.createQuery(
    "SELECT e FROM Employee e", Employee.class
).getResultList();  // 1 query

for (Employee e : employees) {
    System.out.println(e.getDepartment().getName());  // N queries!
}
// Total: 1 + N queries (very slow!)

// SOLUTION 1: JOIN FETCH
List<Employee> employees = em.createQuery(
    "SELECT e FROM Employee e JOIN FETCH e.department",
    Employee.class
).getResultList();  // 1 query with JOIN

// SOLUTION 2: Entity Graph
@Entity
@NamedEntityGraph(
    name = "Employee.withDepartment",
    attributeNodes = @NamedAttributeNode("department")
)
public class Employee { ... }

// Usage
EntityGraph<?> graph = em.getEntityGraph("Employee.withDepartment");
List<Employee> employees = em.createQuery("SELECT e FROM Employee e", Employee.class)
    .setHint("jakarta.persistence.fetchgraph", graph)
    .getResultList();

// SOLUTION 3: Batch fetching (Hibernate-specific)
@BatchSize(size = 25)  // Fetch 25 departments at a time
@ManyToOne(fetch = FetchType.LAZY)
private Department department;

Caching

First Level Cache (Session Cache)

// L1 Cache: Enabled by default, per EntityManager/Session

EntityManager em = emf.createEntityManager();

// First call - hits database
Employee emp1 = em.find(Employee.class, 1L);  // SELECT from DB

// Second call - from L1 cache (no DB hit!)
Employee emp2 = em.find(Employee.class, 1L);  // From cache

System.out.println(emp1 == emp2);  // true - same instance!

// Clear L1 cache
em.clear();  // Detaches all entities

// Now hits DB again
Employee emp3 = em.find(Employee.class, 1L);  // SELECT from DB

Second Level Cache (Shared Cache)

// L2 Cache: Shared across EntityManagers, must be configured

// persistence.xml configuration
<property name="hibernate.cache.use_second_level_cache" value="true"/>
<property name="hibernate.cache.region.factory_class"
          value="org.hibernate.cache.jcache.JCacheRegionFactory"/>
<property name="hibernate.javax.cache.provider"
          value="org.ehcache.jsr107.EhcacheCachingProvider"/>

// Mark entity as cacheable
@Entity
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Employee {
    // ...
}

// Cache strategies:
// READ_ONLY - Never changes, safest
// NONSTRICT_READ_WRITE - Rarely changes, eventual consistency OK
// READ_WRITE - Changes often, needs locking
// TRANSACTIONAL - Full transaction isolation

Query Cache

// Enable query cache
<property name="hibernate.cache.use_query_cache" value="true"/>

// Mark query as cacheable
List<Employee> employees = em.createQuery(
    "SELECT e FROM Employee e WHERE e.active = true",
    Employee.class
)
.setHint("org.hibernate.cacheable", true)
.getResultList();

// Named query with caching
@NamedQuery(
    name = "Employee.findActive",
    query = "SELECT e FROM Employee e WHERE e.active = true",
    hints = @QueryHint(name = "org.hibernate.cacheable", value = "true")
)

Spring Data JPA

Spring Data JPA dramatically reduces boilerplate by providing repository interfaces that Spring implements for you.

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface EmployeeRepository extends JpaRepository<Employee, Long> {

    // Derived query methods - Spring generates SQL from method name!
    List<Employee> findByLastName(String lastName);

    List<Employee> findByFirstNameAndLastName(String firstName, String lastName);

    List<Employee> findByDepartmentName(String deptName);

    List<Employee> findBySalaryGreaterThan(BigDecimal salary);

    List<Employee> findByActiveTrue();

    List<Employee> findByEmailContainingIgnoreCase(String email);

    List<Employee> findByHireDateBetween(LocalDate start, LocalDate end);

    // Top/First queries
    List<Employee> findTop5BySalaryOrderBySalaryDesc();

    Employee findFirstByOrderByHireDateAsc();

    // Count and exists
    long countByDepartmentId(Long deptId);

    boolean existsByEmail(String email);

    // Custom JPQL query
    @Query("SELECT e FROM Employee e WHERE e.salary > :salary AND e.department.id = :deptId")
    List<Employee> findHighEarners(@Param("salary") BigDecimal salary,
                                   @Param("deptId") Long departmentId);

    // Native SQL query
    @Query(value = "SELECT * FROM employees WHERE email LIKE %:domain", nativeQuery = true)
    List<Employee> findByEmailDomain(@Param("domain") String domain);

    // Modifying queries
    @Modifying
    @Query("UPDATE Employee e SET e.active = false WHERE e.id = :id")
    int deactivate(@Param("id") Long id);

    @Modifying
    @Query("DELETE FROM Employee e WHERE e.active = false")
    int deleteInactive();
}

// Usage in service
@Service
@Transactional
public class EmployeeService {

    private final EmployeeRepository repository;

    public EmployeeService(EmployeeRepository repository) {
        this.repository = repository;
    }

    public Employee findById(Long id) {
        return repository.findById(id)
            .orElseThrow(() -> new EntityNotFoundException("Employee not found"));
    }

    public Employee save(Employee employee) {
        return repository.save(employee);  // INSERT or UPDATE
    }

    public void delete(Long id) {
        repository.deleteById(id);
    }

    public Page<Employee> findAll(Pageable pageable) {
        return repository.findAll(pageable);  // Pagination built-in!
    }
}

Best Practices

Do's

  • Use LAZY fetching as default, JOIN FETCH when needed
  • Always use @Transactional for write operations
  • Use DTOs for read-only queries (projections)
  • Index foreign keys in the database
  • Use batch operations for bulk inserts/updates
  • Enable SQL logging in development to catch N+1

Don'ts

  • Don't use EAGER fetching without good reason
  • Don't ignore the generated SQL - review it
  • Don't use bidirectional relationships unless necessary
  • Don't modify detached entities without merge()
  • Don't use flush() unnecessarily

Summary

  • JPA: Specification for ORM in Java
  • Hibernate: Most popular JPA implementation
  • Entity: Java class mapped to database table
  • EntityManager: API for CRUD and queries
  • JPQL: Object-oriented query language
  • Criteria API: Type-safe programmatic queries
  • Lazy Loading: Load data on demand
  • Caching: L1 (session), L2 (shared), Query cache
  • Spring Data JPA: Repository abstraction, minimal boilerplate