Course – LS – All

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE

1. Overview

We have multiple options for connecting to a database using Java applications. Usually, we refer to different layers, starting from JDBC. Then, we move to JPA, with implementations like Hibernate. JPA will eventually use JDBC but make it more transparent to the user with an Object-Entity management approach.

Finally, we can have a framework-like integration, for example, Spring Data JPA, with pre-defined interfaces to access entities but still using JPA and an entity manager under the hood.

In this tutorial, we’ll talk about the difference between Spring Data JPA and JPA. We’ll also explain how they both work with some high-level overviews and code snippets. Let’s start by explaining some of the history of JDBC and how JPA came to be.

2. From JDBC to JPA

Since 1997 version 1.1 of JDK (Java Development Kit), we’ve had access to relational databases with JDBC.

The key points about JDBC that are also essential to understand JPA include:

  • DriverManager and interfaces to connect and execute queries: This enables connections to any ODBC-accessible data source commonly using a specific driver, for example, a MySQL Java connector. We can connect to the database and open/close a transaction over it. Most importantly, we can use any database like MySQL, Oracle, or PostgreSQL only by changing the database driver.
  • Data Source: For both Java Enterprise and frameworks like Spring, this is important to understand how we can define and get a database connection in the working context.
  • Connection pool, which acts like a cache of database connection objects: We can reuse open connections that live in active/passive states and reduce the number of times they are created.
  • Distributed transactions: These consist of one or more statements that update data on multiple databases or resources within the same transaction.

After JDBC’s creation, persistence frameworks (or ORM tools) like Hibernate, which maps database resources as plain old Java objects, started to appear. We refer to ORM as the layer that defines, for example, the schema generation or the database dialect.

Also, Entity Java Bean (EJB) creates standards to manage server-side components encapsulating the business logic of an application. Features like transactional processing, JNDI, and persistence services are now Java beans. Furthermore, Annotation and Dependency Injection now simplify the configuration and integration of different systems.

With the EJB 3.0 release, persistence frameworks were incorporated into the Java Persistence API (JPA), and projects such as Hibernate or EclipseLink have become implementations of the JPA specification.

3. JPA

With JPA, we can write building blocks in an object-oriented syntax independent of the database we are using.

To demonstrate, let’s see an example of an employee table definition. We can finally define a table as a POJO using the @Entity annotation:

@Entity
@Table(name = "employee")
public class Employee implements Serializable {
    
    @Id
    @Generated
    private Long id;

    @Column(nullable = false)
    private String firstName;

    // other fields, setter and getters
}

JPA classes can manage database table features, such as primary key strategies and relationships like many-to-many. This is relevant, for instance, while using foreign keys. JPA can do lazy initialization of collections and get access to the data only when we need to.

We can perform all the CRUD operations (create, retrieve, update, delete) on entities using EntityManager. JPA is implicitly handling transactions. This can be done through a container like Spring transaction management, or simply by the ORM tools like Hibernate using EntityManager.

Once we access the EntityManager, we can, for example, persist an Employee:

Employee employee = new Employee();
// set properties
entityManager.persist(employee);

3.1. Criteria Queries and JPQL

We can then, for example, find an Employee by id:

Employee response = entityManger.find(Employee.class, id);

More interestingly, we can use Criteria Queries in a type-safe way to interact with an @Entity. For example, still finding by id, we can use the CriteriaQuery interface:

CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Employee> cr = cb.createQuery(Employee.class);
Root<Employee> root = cr.from(Employee.class);
cr.select(root);
criteriaQuery.where(criteriaBuilder.equal(root.get(Employee_.ID), employee.getId()));
Employee employee = entityManager.createQuery(criteriaQuery).getSingleResult();

Furthermore, we can also apply sorting and pagination:

criteriaQuery.orderBy(criteriaBuilder.asc(root.get(Employee_.FIRST_NAME)));

TypedQuery<Employee> query = entityManager.createQuery(criteriaQuery);
query.setFirstResult(0);
query.setMaxResults(3);

List<Employee> employeeList = query.getResultList();

We can use criteria queries for persistence. For example, we can do an update using the CriteriaUpdate interface. Suppose we want to update the email address of an employee:

CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
CriteriaUpdate<Employee> criteriaQuery = criteriaBuilder.createCriteriaUpdate(Employee.class);
Root<Employee> root = criteriaQuery.from(Employee.class);
criteriaQuery.set(Employee_.EMAIL, email);
criteriaQuery.where(criteriaBuilder.equal(root.get(Employee_.ID), employee));

entityManager.createQuery(criteriaQuery).executeUpdate();

Finally, JPA also provides JPQL (or HQL if we natively use Hibernate), which allows us to create a query in an SQL-like syntax, but still referring to the @Entity bean:

public Employee getEmployeeById(Long id) {
    Query jpqlQuery = getEntityManager().createQuery("SELECT e from Employee e WHERE e.id=:id");
    jpqlQuery.setParameter("id", id);
    return jpqlQuery.getSingleResult();
}

3.2. JDBC

JPA can adapt to many different databases with generic interfaces. However, in real-life applications, most likely, we will need JDBC support. This is to use specific database query syntax or for performance reasons like, for example, in batch processing.

Even if we use JPA, we can still write in a database’s native language using the createNativeQuery method. For example, we might want to use the rownum Oracle keyword:

Query query = entityManager
  .createNativeQuery("select * from employee where rownum < :limit", Employee.class);
query.setParameter("limit", limit);
List<Employee> employeeList = query.getResultList();

Furthermore, this works for all sorts of functions and procedures that are still related to a database-specific language. For example, we can create and execute a stored procedure:

StoredProcedureQuery storedProcedure = em.createStoredProcedureQuery("calculate_something");
// set parameters
storedProcedure.execute();
Double result = (Double) storedProcedure.getOutputParameterValue("output");

3.3. Annotations

JPA comes with a set of annotations. We’ve already seen @Table, @Entity, @Id, and @Column.

If we often reuse a query, we can annotate it as @NamedQuery at the class level with @Entity, still using JPQL:

@NamedQuery(name="Employee.findById", query="SELECT e FROM Employee e WHERE e.id = :id") 

Then, we can create a Query from the template:

Query query = em.createNamedQuery("Employee.findById", Employee.class);
query.setParameter("id", id);
Employee result = query.getResultList();

Similarly to @NamedQuery, we can use @NamedNativeQuery for a database native query:

@NamedNativeQuery(name="Employee.findAllWithLimit", query="SELECT * FROM employee WHERE rownum < :limit")

3.4. Metamodel

We might want to generate a metamodel that allows us to statically access table fields in a type-safe way. For example, let’s see the Employee_ class that generates from Employee:

@Generated(value = "org.hibernate.jpamodelgen.JPAMetaModelEntityProcessor")
@StaticMetamodel(Employee.class)
public abstract class Employee_ {

    public static volatile SingularAttribute<Employee, String> firstName;
    public static volatile SingularAttribute<Employee, String> lastName;
    public static volatile SingularAttribute<Employee, Long> id;
    public static volatile SingularAttribute<Employee, String> email;

    public static final String FIRST_NAME = "firstName";
    public static final String LAST_NAME = "lastName";
    public static final String ID = "id";
    public static final String EMAIL = "email";
}

We can statically access these fields. The class will regenerate the class if we make changes to the data model.

4. Spring Data JPA

Part of the large Spring Data family, Spring Data JPA is built as an abstraction layer over the JPA. So, we have all the features of JPA plus the Spring ease of development.

For years, developers have written boilerplate code to create a JPA DAO for basic functionalities. Spring helps to significantly reduce this amount of code by providing minimal interfaces and actual implementations.

4.1. Repositories

For example, suppose we want to create a CRUD repository for the Employee table. We can use the JpaRepository:

public interface EmployeeRepository extends JpaRepository<Employee, Long> {
}

That’s all we need for a start. So, if we want to persist or update, we can get an instance of the repository and save an employee:

employeeRepository.save(employee);

We have great support also for writing queries. Interestingly, we can define query methods by simply declaring their method signatures:

public interface EmployeeRepository extends JpaRepository<Employee, Long> {

    List<Employee> findByFirstName(String firstName);
}

Spring will create repository implementations automatically, at runtime, from the repository interface.

So, we can use these methods without having to implement them:

List<Employee> employees = employeeRepository.findByFirstName("John");

We have also support for sorting and paginating repositories:

public interface EmployeeRepository extends PagingAndSortingRepository<Employee, Long> {
}

We can then create a Pageable object with the page size, number, and sorting criteria:

Pageable pageable = PageRequest.of(5, 10, Sort.by("firstName"));
Page<Employee> employees = employeeRepositorySortAndPaging.findAll(pageable);

4.2. Queries

Another great feature is the extensive support for the @Query annotation. Similarly to JPA, this helps to define JPQL-like or native queries. Let’s see an example of how we can use it in the repository interface to get a list of employees by applying a sort:

@Query(value = "SELECT e FROM Employee e")
List<Employee> findAllEmployee(Sort sort);

Again, we’ll use the repository and fetch the list:

List<Employee> employees = employeeRepository.findAllEmployee(Sort.by("firstName"));

4.3. QueryDsl

Likewise the JPA, we have criteria-like support called QueryDsl that also has a metamodel generation. For example, suppose we want a list of employees, filtering on the name:

QEmployee employee = QEmployee.employee;
List<Employee> employees = queryFactory.selectFrom(employee)
  .where(
    employee.firstName.eq("John"),
    employee.lastName.eq("Doe"))
  .fetch();

5. JPA Tests

Let’s create and test a simple JPA application. We can make Hibernate manage the transactionality.

5.1. Dependencies

Let’s have a look at the dependencies. We’ll need JPA, Hibernate core, and H2 database imports in our pom.xml.

<dependency>
    <groupId>javax.persistence</groupId>
    <artifactId>javax.persistence-api</artifactId>
    <version>3.1.0</version>
</dependency>
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-core</artifactId>
    <version>6.4.2.Final</version>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>2.1.214</version>
</dependency>

Furthermore, we need a plugin for the metamodel generation:

<plugin>
  <groupId>org.bsc.maven</groupId>
  <artifactId>maven-processor-plugin</artifactId>
  <version>3.3.3</version>
  <executions>
      <execution>
          <id>process</id>
          <goals>
              <goal>process</goal>
          </goals>
          <phase>generate-sources</phase>
          <configuration>
              <outputDirectory>${project.build.directory}/generated-sources</outputDirectory>
              <processors>
                  <processor>org.hibernate.jpamodelgen.JPAMetaModelEntityProcessor</processor>
              </processors>
          </configuration>
      </execution>
  </executions>
  <dependencies>
      <dependency>
          <groupId>org.hibernate</groupId>
          <artifactId>hibernate-jpamodelgen</artifactId>
          <version>6.4.2.Final</version>
      </dependency>
  </dependencies>
</plugin>

5.2. Configuration

To keep it plain JPA, we use a persistence.xml file:

<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence
             http://xmlns.jcp.org/xml/ns/persistence/persistence_2_1.xsd"
             version="2.1">
    <persistence-unit name="pu-test">
        <provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
        <class>com.baeldung.spring.data.persistence.springdata_jpa_difference.model.Employee</class>
        <properties>
            <property name="javax.persistence.jdbc.driver" value="org.h2.Driver"/>
            <property name="javax.persistence.jdbc.url" value="jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"/>
            <property name="javax.persistence.jdbc.user" value="sa"/>
            <property name="javax.persistence.jdbc.password" value=""/>
            <property name="hibernate.dialect" value="org.hibernate.dialect.H2Dialect"/>
            <property name="hibernate.hbm2ddl.auto" value="create-drop"/>
        </properties>
    </persistence-unit>

</persistence>

We don’t need any Bean-based configuration.

5.3. Test Class Definition

To demonstrate, let’s create a test class. We’ll get the EntityManager with the createEntityManagerFactory method and manage transactions manually:

public class JpaDaoIntegrationTest {

    private final EntityManagerFactory emf = Persistence.createEntityManagerFactory("pu-test");
    private final EntityManager entityManager = emf.createEntityManager();

    @Before
    public void setup() {
        deleteAllEmployees();
    }

    // tests

    private void deleteAllEmployees() {
        entityManager.getTransaction()
          .begin();
        entityManager.createNativeQuery("DELETE from Employee")
          .executeUpdate();
        entityManager.getTransaction()
          .commit();
    }

    public void save(Employee entity) {
        entityManager.getTransaction()
          .begin();
        entityManager.persist(entity);
        entityManager.getTransaction()
          .commit();
    }

    public void update(Employee entity) {
        entityManager.getTransaction()
          .begin();
        entityManager.merge(entity);
        entityManager.getTransaction()
          .commit();
    }

    public void delete(Long employee) {
        entityManager.getTransaction()
          .begin();
        entityManager.remove(entityManager.find(Employee.class, employee));
        entityManager.getTransaction()
          .commit();
    }

    public int update(CriteriaUpdate<Employee> criteriaUpdate) {
        entityManager.getTransaction()
          .begin();
        int result = entityManager.createQuery(criteriaUpdate)
          .executeUpdate();
        entityManager.getTransaction()
          .commit();
        entityManager.clear();

        return result;
    }
}

5.4. Testing JPA

First, we want to test if we can find an employee by id:

@Test
public void givenPersistedEmployee_whenFindById_thenEmployeeIsFound() {
    // save employee
    assertEquals(employee, entityManager.find(Employee.class, employee.getId()));
}

Let’s see other ways we can find an Employee. For example, we can use the CriteriaQuey:

@Test
public void givenPersistedEmployee_whenFindByIdCriteriaQuery_thenEmployeeIsFound() {
    // save employee
    CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
    CriteriaQuery<Employee> criteriaQuery = criteriaBuilder.createQuery(Employee.class);
    Root<Employee> root = criteriaQuery.from(Employee.class);
    criteriaQuery.select(root);

    criteriaQuery.where(criteriaBuilder.equal(root.get(Employee_.ID), employee.getId()));

    assertEquals(employee, entityManager.createQuery(criteriaQuery)
      .getSingleResult());
}

Also, we can use JPQL:

@Test
public void givenPersistedEmployee_whenFindByIdJpql_thenEmployeeIsFound() {
    // save employee
    Query jpqlQuery = entityManager.createQuery("SELECT e from Employee e WHERE e.id=:id");
    jpqlQuery.setParameter("id", employee.getId());

    assertEquals(employee, jpqlQuery.getSingleResult());
}

Let’s see how we can create a Query from @NamedQuery:

@Test
public void givenPersistedEmployee_whenFindByIdNamedQuery_thenEmployeeIsFound() {
    // save employee
    Query query = entityManager.createNamedQuery("Employee.findById");
    query.setParameter(Employee_.ID, employee.getId());

    assertEquals(employee, query.getSingleResult());
}

Let’s also see an example of how to apply sorting and pagination:

@Test
public void givenPersistedEmployee_whenFindWithPaginationAndSort_thenEmployeesAreFound() {
    // save John, Frank, Bob, James
    CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
    CriteriaQuery<Employee> criteriaQuery = criteriaBuilder.createQuery(Employee.class);
    Root<Employee> root = criteriaQuery.from(Employee.class);
    criteriaQuery.select(root);
    criteriaQuery.orderBy(criteriaBuilder.asc(root.get(Employee_.FIRST_NAME)));

    TypedQuery<Employee> query = entityManager.createQuery(criteriaQuery);

    query.setFirstResult(0);
    query.setMaxResults(3);

    List<Employee> employeeList = query.getResultList();

    assertEquals(Arrays.asList(bob, frank, james), employeeList);
}

Finally, let’s see how to update an employee email using the CriteriaUpdate:

@Test
public void givenPersistedEmployee_whenUpdateEmployeeEmailWithCriteria_thenEmployeeHasUpdatedEmail() {
    // save employee
    String updatedEmail = "[email protected]";

    CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
    CriteriaUpdate<Employee> criteriaUpdate = criteriaBuilder.createCriteriaUpdate(Employee.class);
    Root<Employee> root = criteriaUpdate.from(Employee.class);

    criteriaUpdate.set(Employee_.EMAIL, updatedEmail);
    criteriaUpdate.where(criteriaBuilder.equal(root.get(Employee_.ID), employee.getId()));

    assertEquals(1, update(criteriaUpdate));
    assertEquals(updatedEmail, entityManager.find(Employee.class, employee.getId())
      .getEmail());
}

6. Spring Data JPA Tests

Let’s see how we can improve by adding Spring repositories and in-built query support.

6.1. Dependencies

In this case, we need to add the Spring Data dependency. We also need the QueryDsl dependency for the fluent query API.

<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-jpa</artifactId>
    <version>3.1.5</version>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>2.2.214</version>
</dependency>
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-apt</artifactId>
    <version>5.0.0</version>
    <classifier>jakarta</classifier>
</dependency>
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-jpa</artifactId>
    <version>5.0.0</version>
    <classifier>jakarta</classifier>
</dependency>

6.2. Configuration

First, let’s create our configuration:

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(basePackageClasses = EmployeeRepository.class)
public class SpringDataJpaConfig {

    @Bean
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) {
        LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
        em.setDataSource(dataSource);
        em.setPackagesToScan(Employee.class.getPackage().getName());

        JpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
        em.setJpaVendorAdapter(vendorAdapter);

        Properties properties = new Properties();
        properties.setProperty("hibernate.hbm2ddl.auto", "create-drop");
        properties.setProperty("hibernate.dialect", "org.hibernate.dialect.H2Dialect");

        em.setJpaProperties(properties);

        return em;
    }

    @Bean
    public PlatformTransactionManager transactionManager(LocalContainerEntityManagerFactoryBean entityManagerFactoryBean) {
        JpaTransactionManager transactionManager = new JpaTransactionManager();
        transactionManager.setEntityManagerFactory(entityManagerFactoryBean.getObject());
        return transactionManager;
    }

    @Bean
    public DataSource dataSource() {
        return DataSourceBuilder.create()
          .url("jdbc:h2:mem:db;DB_CLOSE_DELAY=-1")
          .driverClassName("org.h2.Driver")
          .username("sa")
          .password("sa")
          .build();
    }

    @Bean
    public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
        return new JPAQueryFactory((entityManager));
    }
}

Finally, let’s have a look at our JpaRepository:

@Repository
public interface EmployeeRepository extends JpaRepository<Employee, Long> {

    List<Employee> findByFirstName(String firstName);

    @Query(value = "SELECT e FROM Employee e")
    List<Employee> findAllEmployee(Sort sort);
}

Also, we want to use a PagingAndSortingRepository:

@Repository
public interface EmployeeRepositoryPagingAndSort extends PagingAndSortingRepository<Employee, Long> {

}

6.3. Test Class Definition

Let’s see our test class for Spring Data tests. We’ll rollback to keep every test atomic:

@ContextConfiguration(classes = SpringDataJpaConfig.class)
@RunWith(SpringJUnit4ClassRunner.class)
@Transactional
@Rollback
public class SpringDataJpaIntegrationTest {

    @Autowired
    private EmployeeRepository employeeRepository;

    @Autowired
    private EmployeeRepositoryPagingAndSort employeeRepositoryPagingAndSort;

    @Autowired
    private JPAQueryFactory jpaQueryFactory;

    // tests

}

6.4. Testing Spring Data JPA

Let’s start with finding an employee by id:

@Test
public void givenPersistedEmployee_whenFindById_thenEmployeeIsFound() {
    Employee employee = employee("John", "Doe");

    employeeRepository.save(employee);

    assertEquals(Optional.of(employee), employeeRepository.findById(employee.getId()));
}

Let’s see how to find employees by their first names:

@Test
public void givenPersistedEmployee_whenFindByFirstName_thenEmployeeIsFound() {
    Employee employee = employee("John", "Doe");

    employeeRepository.save(employee);

    assertEquals(employee, employeeRepository.findByFirstName(employee.getFirstName())
      .get(0));
}

We can apply sorting, for example, when querying all employees:

@Test
public void givenPersistedEmployees_whenFindSortedByFirstName_thenEmployeeAreFoundInOrder() {
    Employee john = employee("John", "Doe");
    Employee bob = employee("Bob", "Smith");
    Employee frank = employee("Frank", "Brown");

    employeeRepository.saveAll(Arrays.asList(john, bob, frank));

    List<Employee> employees = employeeRepository.findAllEmployee(Sort.by("firstName"));

    assertEquals(3, employees.size());
    assertEquals(bob, employees.get(0));
    assertEquals(frank, employees.get(1));
    assertEquals(john, employees.get(2));
}

Let’s have a look at how to build a query with QueryDsl:

@Test
public void givenPersistedEmployee_whenFindByQueryDsl_thenEmployeeIsFound() {
    Employee john = employee("John", "Doe");
    Employee frank = employee("Frank", "Doe");

    employeeRepository.saveAll(Arrays.asList(john, frank));

    QEmployee employeePath = QEmployee.employee;

    List<Employee> employees = jpaQueryFactory.selectFrom(employeePath)
      .where(employeePath.firstName.eq("John"), employeePath.lastName.eq("Doe"))
      .fetch();

    assertEquals(1, employees.size());
    assertEquals(john, employees.get(0));
}

Finally, we can check how to use the PagingAndSortingRepository:

@Test
public void givenPersistedEmployee_whenFindBySortAndPagingRepository_thenEmployeeAreFound() {
    Employee john = employee("John", "Doe");
    Employee bob = employee("Bob", "Smith");
    Employee frank = employee("Frank", "Brown");
    Employee jimmy = employee("Jimmy", "Armstrong");

    employeeRepositoryPagingAndSort.saveAll(Arrays.asList(john, bob, frank, jimmy));

    Pageable pageable = PageRequest.of(0, 2, Sort.by("firstName"));

    Page<Employee> employees = employeeRepositoryPagingAndSort.findAll(pageable);

    assertEquals(Arrays.asList(bob, frank), employees.get()
      .collect(Collectors.toList()));
}

7. How JPA and Spring Data JPA Differ

JPA defines the standard approach for object-relational mapping (ORM).

It provides an abstraction layer that makes it independent from the database we are using. JPA can also handle transactionality and is built over JDBC, so we can still use the native database language.

Spring Data JPA is yet another layer of abstraction over the JPA. However, it is more flexible than JPA and offers simple repositories and syntax for all CRUD operations. We can remove all the boilerplate code from our JPA applications and use simpler interfaces and annotations. Furthermore, we will have Spring’s ease of development, for example, to handle transactionality transparently.

Besides, there wouldn’t be Spring Data JPA without JPA, so in any case, JPA is a good starting point if we want to learn more about the Java database access layer.

8. Conclusion

In this tutorial, we have shortly seen a JDBC history and why JPA has become a standard for relational database API. We also saw examples of JPA and Spring Data JPA for persisting entities and creating dynamic queries. Finally, we presented some test cases to show the difference between a vanilla JPA application and a Spring Data JPA application.

As always, all source code is available over on GitHub.

Course – LSD (cat=Persistence)

Get started with Spring Data JPA through the reference Learn Spring Data JPA course:

>> CHECK OUT THE COURSE
Course – LS – All

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE
res – Persistence (eBook) (cat=Persistence)
Comments are open for 30 days after publishing a post. For any issues past this date, use the Contact form on the site.