Part of Github Copilot with Kotlin

Initialize Firestore and save a post to the database

Sandy LaneSandy Lane

Video: Initialize Firestore and save a post to the database by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Firestore: Initialize and Save a Post

FirebaseFirestore.getInstance() for the client. db.collection("posts").add(post) for a new auto-ID document. data class Post(...) with no-arg defaults so Firestore can deserialize it back.

A common Android task: save a structured object to Firebase Firestore, the cloud document database. Today we set up the client and write one document.

Prerequisites

Before any code, the Firebase setup:

  1. Create a Firebase project at console.firebase.google.com.
  2. Add an Android app to the project (com.example.myapp package name).
  3. Download google-services.json and place it in app/ directory.
  4. Add the google-services plugin and Firestore SDK to your build.gradle.kts:
plugins {
  id("com.google.gms.google-services")
}

dependencies {
  implementation(platform("com.google.firebase:firebase-bom:33.5.1"))
  implementation("com.google.firebase:firebase-firestore-ktx")
}

The firebase-bom (Bill of Materials) is a version-aligned bundle. Pinning the BoM keeps all Firebase SDKs at compatible versions.

The Copilot prompt

import com.google.firebase.firestore.FirebaseFirestore

// Define a Post data class and a function to save it to Firestore
data class Post(

Copilot generates:

data class Post(
  val title: String = "",
  val content: String = ""
)

fun savePostToFirestore() {
  val db = FirebaseFirestore.getInstance()

  val post = Post(
    title = "Hello Firestore",
    content = "This is a post saved from my Android app."
  )

  db.collection("posts")
    .add(post)
    .addOnSuccessListener { documentRef ->
      println("Post saved with ID: ${documentRef.id}")
    }
    .addOnFailureListener { e ->
      println("Error saving post: ${e.message}")
    }
}

Walkthrough

1. Default values on data class fields. Firestore's deserialization needs a no-arg constructor. Kotlin data classes don't have one by default — but if every field has a default, Kotlin synthesizes one. That's why you see val title: String = "" instead of just val title: String.

2. FirebaseFirestore.getInstance(). The singleton client. Uses your google-services.json config to know which project to talk to. First call initializes; subsequent calls return the same instance.

3. db.collection("posts"). A reference to the posts collection. Doesn't actually fetch anything — just builds a query target.

4. .add(post). Creates a new document with an auto-generated ID. Firestore serializes the Post to a JSON-like document on the wire.

5. addOnSuccessListener / addOnFailureListener. Firestore's API uses Task<T> (Google's Promise-like type) — callback-style. Modern Kotlin code prefers coroutines (next section).

With coroutines

The firebase-firestore-ktx artifact ships extension functions like await() for use with coroutines:

import kotlinx.coroutines.tasks.await

suspend fun savePost(post: Post): String {
  val db = FirebaseFirestore.getInstance()
  val ref = db.collection("posts").add(post).await()
  return ref.id
}

await() suspends the coroutine until the Task completes, returning the result or throwing on failure. Cleaner than nested callbacks.

Reading back

suspend fun getPosts(): List<Post> {
  val snapshot = FirebaseFirestore.getInstance()
    .collection("posts")
    .get()
    .await()
  return snapshot.toObjects(Post::class.java)
}

snapshot.toObjects(Class) deserializes every document into your data class. Each Post instance has the field values from Firestore.

To include the document ID:

data class PostWithId(
  val id: String = "",
  val title: String = "",
  val content: String = "",
)

val posts = snapshot.documents.map { doc ->
  doc.toObject(Post::class.java)!!.let { p ->
    PostWithId(id = doc.id, title = p.title, content = p.content)
  }
}

The document ID isn't a field on the document itself — it's metadata. Stitch it in manually if you need it.

Listening for live updates

db.collection("posts")
  .addSnapshotListener { snapshot, error ->
    if (error != null) {
      println("Listen failed: ${error.message}")
      return@addSnapshotListener
    }
    val posts = snapshot?.toObjects(Post::class.java) ?: emptyList()
    // update UI
  }

addSnapshotListener fires once with the initial state, then again every time a document in the collection changes. Returns a ListenerRegistration; call .remove() to unsubscribe.

For a Compose-friendly Flow:

import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow

fun postsFlow(): Flow<List<Post>> = callbackFlow {
  val reg = FirebaseFirestore.getInstance()
    .collection("posts")
    .addSnapshotListener { snapshot, error ->
      if (error != null) close(error)
      else trySend(snapshot?.toObjects(Post::class.java) ?: emptyList())
    }
  awaitClose { reg.remove() }
}

Then in a Composable: val posts by postsFlow().collectAsState(initial = emptyList()).

Specific document ID

To save with a known ID instead of auto-generated:

db.collection("posts").document("my-post-id").set(post)

set overwrites the document at that ID; add creates with auto-generated ID. Use set when the ID is meaningful (e.g., user UID).

For partial updates:

db.collection("posts").document("my-post-id").update("title", "New title")

update modifies just the named fields, leaving others untouched.

Common mistakes

Forgetting default values on data class. Firestore deserialization fails with "Could not deserialize object. Class Post does not define a no-argument constructor."

Mixing collection paths. collection("posts/123") is wrong — collection paths have an odd number of segments. Document paths have an even number. posts (collection) → posts/123 (document) → posts/123/comments (subcollection).

Reading without await(). Forgetting await() returns a Task<DocumentReference>, not the document. Code compiles but doesn't do what you expect.

Not handling the error case in callbacks. addOnSuccessListener without a matching addOnFailureListener silently drops failures.

Storing massive blobs. Firestore has a 1MB document limit. For files, use Cloud Storage; store only the URL in Firestore.

Forgetting security rules. By default, Firestore allows anyone to read/write. Lock down via Firestore Security Rules in the console before production.

What's next

Episode 12: What day of the week is 2023-01-01? A LocalDate puzzle that demos the dayOfWeek enum and the cleanest way to print weekday names.

Recap

Firebase + Firestore: FirebaseFirestore.getInstance() for the client. data class Post(val title: String = "", ...) with defaults so deserialization works. .collection("posts").add(post) for auto-generated IDs; .set(post) on a specific document path for known IDs. With coroutines, use .await() from kotlinx-coroutines-play-services. Real-time updates via addSnapshotListener (or wrap as a callbackFlow for Compose).

Next episode: day of the week.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.