1. Overview
Today, cloud-hosted managed databases have become increasingly popular. One such example is Cloud Firestore, a NoSQL document database offered by Firebase and Google, which provides on-demand scalability, flexible data modelling, and offline support for mobile and web applications.
In this tutorial, we’ll explore how to use Cloud Firestore for data persistence in a Spring Boot application. To make our learning more practical, we’ll create a rudimentary task management application that allows us to create, retrieve, update, and delete tasks using Cloud Firestore as the backend database.
2. Cloud Firestore 101
Before diving into the implementation, let’s look at some of the key concepts of Cloud Firestore.
In Cloud Firestore, data is stored in documents, grouped into collections. A collection is a container for documents, and each document contains a set of key-value pairs of varying data structures, like a JSON object.
Cloud Firestore uses a hierarchical naming convention for document paths. A document path consists of a collection name followed by a document ID, separated by a forward slash. For example, tasks/001 represents a document with ID 001 within the tasks collection.
3. Setting up the Project
Before we can start interacting with Cloud Firestore, we’ll need to include an SDK dependency and configure our application correctly.
3.1. Dependencies
Let’s start by adding the Firebase admin dependency to our project’s pom.xml file:
<dependency>
<groupId>com.google.firebase</groupId>
<artifactId>firebase-admin</artifactId>
<version>9.3.0</version>
</dependency>
This dependency provides us with the necessary classes to interact with Cloud Firestore from our application.
3.2. Data Model
Now, let’s define our data model:
class Task {
public static final String PATH = "tasks";
private String title;
private String description;
private String status;
private Date dueDate;
// standard setters and getters
}
The Task class is the central entity in our tutorial, and represents a task in our task management application.
The PATH constant defines the Firestore collection path where we’ll store our task documents.
3.3. Defining Firestore Configuration Bean
Now, to interact with the Cloud Firestore database, we need to configure our private key to authenticate API requests.
For our demonstration, we’ll create the private-key.json file in our src/main/resources directory. However, in production, the private key should be loaded from an environment variable or fetched from a secret management system to enhance security.
We’ll load our private key using the @Value annotation and use it to define our Firestore bean:
@Value("classpath:/private-key.json")
private Resource privateKey;
@Bean
public Firestore firestore() {
InputStream credentials = new ByteArrayInputStream(privateKey.getContentAsByteArray());
FirebaseOptions firebaseOptions = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(credentials))
.build();
FirebaseApp firebaseApp = FirebaseApp.initializeApp(firebaseOptions);
return FirestoreClient.getFirestore(firebaseApp);
}
The Firestore class is the main entry point for interacting with the Cloud Firestore database.
4. Setting up Local Test Environment With Testcontainers
To facilitate local development and testing, we’ll use the GCloud module of Testcontainers to set up a Cloud Firestore emulator. For this, we’ll add its dependency to our pom.xml file:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>gcloud</artifactId>
<version>1.20.1</version>
<scope>test</scope>
</dependency>
The prerequisite for running the Firestore emulator via Testcontainers is an active Docker instance.
Once we’ve added the required dependency, we’ll create a @TestConfiguration class that defines a new Firestore bean:
private static FirestoreEmulatorContainer firestoreEmulatorContainer = new FirestoreEmulatorContainer(
DockerImageName.parse("gcr.io/google.com/cloudsdktool/google-cloud-cli:488.0.0-emulators")
);
@TestConfiguration
static class FirestoreTestConfiguration {
@Bean
public Firestore firestore() {
firestoreEmulatorContainer.start();
FirestoreOptions options = FirestoreOptions
.getDefaultInstance()
.toBuilder()
.setProjectId(RandomString.make().toLowerCase())
.setCredentials(NoCredentials.getInstance())
.setHost(firestoreEmulatorContainer.getEmulatorEndpoint())
.build();
return options.getService();
}
}
We use the Google Cloud CLI Docker image to create a container of our emulator. Then inside our firestore() bean method, we start the container and configure our Firestore bean to connect to the emulator endpoint.
This setup allows us to spin up a throwaway instance of the Cloud Firestore emulator and have our application connect to it instead of the actual Cloud Firestore database.
5. Performing CRUD Operations
With our test environment set up, let’s explore how to perform CRUD operations on our Task data model.
5.1. Creating Documents
Let’s start by creating a new task document:
Task task = Instancio.create(Task.class);
DocumentReference taskReference = firestore
.collection(Task.PATH)
.document();
taskReference.set(task);
String taskId = taskReference.getId();
assertThat(taskId).isNotBlank();
We use Instancio to create a new Task object with random test data. Then we call the document() method on our tasks collection to obtain a DocumentReference object, which represents the document’s location in the Cloud Firestore database. Finally, we set the task data on our DocumentReference object to create a new task document.
When we invoke the document() method without any arguments, Firestore auto-generates a unique document ID for us. We can retrieve this auto-generated ID using the getId() method.
Alternatively, we can create a task document with a custom ID:
Task task = Instancio.create(Task.class);
String taskId = Instancio.create(String.class);
firestore
.collection(Task.PATH)
.document(taskId)
.set(task);
Awaitility.await().atMost(3, TimeUnit.SECONDS).untilAsserted(() -> {
DocumentSnapshot taskSnapshot = firestore
.collection(Task.PATH)
.document(taskId)
.get().get();
assertThat(taskSnapshot.exists())
.isTrue();
});
Here, we generate a random taskId and pass it to the document() method to create a new task document against it. We then use Awaitility to wait for the document to be created and assert its existence.
5.2. Retrieving and Querying Documents
Although we’ve indirectly looked at how to retrieve a task document by its ID in the previous section, let’s take a closer look:
Task task = Instancio.create(Task.class);
String taskId = Instancio.create(String.class);
// ... save task in Firestore
DocumentSnapshot taskSnapshot = firestore
.collection(Task.PATH)
.document(taskId)
.get().get();
Task retrievedTask = taskSnapshot.toObject(Task.class);
assertThat(retrievedTask)
.usingRecursiveComparison()
.isEqualTo(task);
To retrieve our task document, we call the get() method on the DocumentReference object. This method returns an ApiFuture
To convert the DocumentSnapshot object into a Task object, we use the toObject() method.
Furthermore, we can also query documents based on specific conditions:
// Set up test data
Task completedTask = Instancio.of(Task.class)
.set(field(Task::getStatus), "COMPLETED")
.create();
Task inProgressTask = // ... task with status IN_PROGRESS
Task anotherCompletedTask = // ... task with status COMPLETED
List<Task> tasks = List.of(completedTask, inProgressTask, anotherCompletedTask);
// ... save all the tasks in Firestore
// Retrieve completed tasks
List<QueryDocumentSnapshot> retrievedTaskSnapshots = firestore
.collection(Task.PATH)
.whereEqualTo("status", "COMPLETED")
.get().get().getDocuments();
// Verify only matching tasks are retrieved
List<Task> retrievedTasks = retrievedTaskSnapshots
.stream()
.map(snapshot -> snapshot.toObject(Task.class))
.toList();
assertThat(retrievedTasks)
.usingRecursiveFieldByFieldElementComparator()
.containsExactlyInAnyOrder(completedTask, anotherCompletedTask);
In our above example, we create multiple task documents with different status values and save them to our Cloud Firestore database. We then use the whereEqualTo() method to retrieve only the task documents with a COMPLETED status.
Additionally, we can combine multiple query conditions:
List<QueryDocumentSnapshot> retrievedTaskSnapshots = firestore
.collection(Task.PATH)
.whereEqualTo("status", "COMPLETED")
.whereGreaterThanOrEqualTo("dueDate", Date.from(Instant.now()))
.whereLessThanOrEqualTo("dueDate", Date.from(Instant.now().plus(7, ChronoUnit.DAYS)))
.get().get().getDocuments();
Here, we query for all COMPLETED tasks with the dueDate value within the next 7 days.
5.3. Updating Documents
To update a document in Cloud Firestore, we follow a similar process to creating one. If the specified document ID doesn’t exist, Cloud Firestore creates a new document; otherwise, it updates the existing document:
// Save initial task in Firestore
String taskId = Instancio.create(String.class);
Task initialTask = Instancio.of(Task.class)
.set(field(Task::getStatus), "IN_PROGRESS")
.create();
firestore
.collection(Task.PATH)
.document(taskId)
.set(initialTask);
// Update the task
Task updatedTask = initialTask;
updatedTask.setStatus("COMPLETED");
firestore
.collection(Task.PATH)
.document(taskId)
.set(initialTask);
// Verify the task was updated correctly
Task retrievedTask = firestore
.collection(Task.PATH)
.document(taskId)
.get().get()
.toObject(Task.class);
assertThat(retrievedTask)
.usingRecursiveComparison()
.isNotEqualTo(initialTask)
.ignoringFields("status")
.isEqualTo(initialTask);
We first create a new task document with an IN_PROGRESS status. We then update its status to COMPLETED by calling the set() method again with the updated Task object. Finally, we fetch the document from the database and verify the changes were applied correctly.
5.4. Deleting Documents
Finally, let’s take a look at how we can delete our task documents:
// Save task in Firestore
Task task = Instancio.create(Task.class);
String taskId = Instancio.create(String.class);
firestore
.collection(Task.PATH)
.document(taskId)
.set(task);
// Ensure the task is created
Awaitility.await().atMost(3, TimeUnit.SECONDS).untilAsserted(() -> {
DocumentSnapshot taskSnapshot = firestore
.collection(Task.PATH)
.document(taskId)
.get().get();
assertThat(taskSnapshot.exists())
.isTrue();
});
// Delete the task
firestore
.collection(Task.PATH)
.document(taskId)
.delete();
// Assert that the task is deleted
Awaitility.await().atMost(3, TimeUnit.SECONDS).untilAsserted(() -> {
DocumentSnapshot taskSnapshot = firestore
.collection(Task.PATH)
.document(taskId)
.get().get();
assertThat(taskSnapshot.exists())
.isFalse();
});
Here, we first create a new task document and ensure its existence. We then call the delete() method on the DocumentReference object to delete our task and verify that the document no longer exists.
6. Conclusion
In this article, we’ve explored using Cloud Firestore for data persistence in a Spring Boot application.
We walked through the necessary configurations, including setting up a local test environment using Testcontainers, and performed CRUD operations on our task data model.
As always, all the code examples used in this article are available over on GitHub.