In this example, we’ll build a simple Store to fetch and cache posts for the Trails app.

The code for this example is available in the Trails repository.

Prerequisites

Quick Links to Prerequisites:

Installation

1

Add the Dependency

Add the Store library to your project’s dependencies. Since we’re working with a KMP project, we’ll add the dependency to the commonMain source set.

libs.versions.toml
[versions]
store = "5.1.0"

[libraries]
store = { module = "org.mobilenativefoundation.store:store5", version.ref = "store" }
build.gradle.kts
commonMain {
  dependencies {
    implementation(libs.store)
  }
}

Understanding Version Catalogs:

The libs.versions.toml file is part of Gradle’s Version Catalogs feature, which simplifies dependency management.

If you’re not using Version Catalogs, you can add the dependency directly to your build.gradle.kts:

dependencies {
    implementation("org.mobilenativefoundation.store:store5:5.1.0")
}

For more information on Version Catalogs, visit the Gradle Documentation.

2

Sync the Project

After adding the dependency, sync your project with Gradle to download the Store library.

Building a Store

Now, let’s build a simple Store to fetch and cache posts from our API and cache it for offline access.

1

Define the Data Models

Define the models for a post.

Using SqlDelight for Local Database Models:

The SQL schema defined using SqlDelight will generate Kotlin models for you. Here’s how the PostEntity class might look after generation:

data class PostEntity(
    val id: Long,
    val creator_id: Long,
    val caption: String?,
    val created_at: String,
    val likes_count: Long,
    val comments_count: Long,
    val shares_count: Long,
    val views_count: Long,
    val is_sponsored: Long,
    val cover_url: String,
    val platform: Platform,
    val location_name: String?,
    val is_favorited_by_current_user: Long
)

For more details on how SqlDelight generates Kotlin classes from SQL schemas, check out the SqlDelight Docs.

2

Create the API Interface

Define and implement an interface for your network calls. In this example, we’ll use Ktor for HTTP requests.

Dependency Injection Context:

Trails uses kotlin-inject, a compile-time dependency injection library for Kotlin. The @Inject annotation indicates a class can be injected.

3

Implement Converters

We need to convert between our network model, domain model, and local database model.

4

Set Up the Store Factory

We’ll use a factory for creating a PostStore instance.

About the TODO() Placeholders:

The TODO() placeholders indicate where implementations will be provided in the subsequent steps.

package org.mobilenativefoundation.trails.xplat.lib.market.post.impl.store

typealias PostStore = Store<Int, Post>

class PostStoreFactory(
    private val client: PostOperations,
    private val trailsDatabase: TrailsDatabase,
) {

    fun create(): PostStore {
        TODO()
    }

    private fun createFetcher(): Fetcher<Int, PostNetworkModel> {
        TODO()
    }

    private fun createSourceOfTruth(): SourceOfTruth<Int, PostEntity, Post> {
        TODO()
    }

    private fun createConverter(): Converter<PostNetworkModel, PostEntity, Post> {
        TODO()
    }

    private fun createUpdater(): Updater<Int, Post, Boolean> {
        TODO()
    }

    private fun createBookkeeper(): Bookkeeper<Int> {
        TODO()
    }
}
5

Implement the Fetcher

Our Fetcher will interact with the network data source using the PostOperations interface.

package org.mobilenativefoundation.trails.xplat.lib.market.post.impl.store

private fun createFetcher(): Fetcher<Int, PostNetworkModel> =
  Fetcher.of { id ->
      // Fetch post from the network
      client.getPost(id) ?: throw IllegalArgumentException("Post with ID $id not found.")
  }
6

Implement the Source of Truth

Our Source of Truth will delegate to a local SqlDelight database.

package org.mobilenativefoundation.trails.xplat.lib.market.post.impl.store

private fun createSourceOfTruth(): SourceOfTruth<Int, PostEntity, Post> =
  SourceOfTruth.of(
      reader = { id ->
          flow {
              // Query the database for a post
              emit(trailsDatabase.postQueries.selectPostById(id.toLong()))
          }
      },
      writer = { _, postEntity ->
          trailsDatabase.postQueries.insertPost(postEntity)
      }
  )

Observing Database Changes Over Time:

To ensure your SourceOfTruth observes changes in the database, use asFlow() and appropriate mapping functions.

trailsDatabase
    .postQueries
    .selectPostById(id.toLong())
    .asFlow()
    .mapToOneOrNull()
7

Implement the Converter

Our Converter will convert between our network model, local database model, and domain model using the PostExtensions object.

Understanding the Converter’s Role:

The Converter bridges the gap between the network model, local database model, and domain model within the Store.

While you have extension functions for conversions, the Converter integrates these into the Store’s pipeline, ensuring data flows correctly through each layer.

package org.mobilenativefoundation.trails.xplat.lib.market.post.impl.store

private fun createConverter(): Converter<PostNetworkModel, PostEntity, Post> =
  Converter.Builder<PostNetworkModel, PostEntity, Post>()
      .fromOutputToLocal { post -> post.asPostEntity() }
      .fromNetworkToLocal { postNetworkModel -> postNetworkModel.asPost() }
      .build()
8

Implement the Updater

Our Updater will make a network call to update the post.

package org.mobilenativefoundation.trails.xplat.lib.market.post.impl.store

private fun createUpdater(): Updater<Int, Post, Boolean> =
  Updater.by(
      post = { _, updatedPost ->
          val networkModel = updatedPost.asNetworkModel()
          val success = client.updatePost(networkModel)
          if (success) {
              UpdaterResult.Success.Typed(success)
          } else {
              UpdaterResult.Error.Message("Something went wrong.")
          }
      }
  )
9

Implement the Bookkeeper

Our Bookkeeper keeps track of failed syncs to enable eagerly resolving conflicts after local mutations.

package org.mobilenativefoundation.trails.xplat.lib.market.post.impl.store

private fun createBookkeeper(): Bookkeeper<Int> =
  Bookkeeper.by(
      getLastFailedSync = { id ->
          trailsDatabase.postBookkeepingQueries
              .selectMostRecentFailedSync(id).executeAsOneOrNull()?.let { failedSync ->
                  timestampToEpochMilliseconds(timestamp = failedSync.timestamp)
              }
      },
      setLastFailedSync = { id, timestamp ->
          try {
              trailsDatabase.postBookkeepingQueries.insertFailedSync(
                  PostFailedSync(
                      post_id = id,
                      timestamp = epochMillisecondsToTimestamp(timestamp)
                  )
              )
              true
          } catch (e: SQLException) {
              // Handle the exception
              false
          }
      },
      clear = { id ->
          try {
              trailsDatabase.postBookkeepingQueries.clearByPostId(id)
              true
          } catch (e: SQLException) {
              // Handle the exception
              false
          }
      },
      clearAll = {
          try {
              trailsDatabase.postBookkeepingQueries.clearAll()
              true
          } catch (e: SQLException) {
              // Handle the exception
              false
          }
      }
  )
10

Build the Store

Provide the implementations to the Store Builder.

How Components Work Together:

Each component of the Store has a specific role:

  • Fetcher: Retrieves data from the network.
  • Source of Truth: Manages local data storage.
  • Converter: Handles data transformations between models.
  • Updater: Syncs local changes back to the network.
  • Bookkeeper: Keeps track of failed updates for retry mechanisms.

By integrating these components, the Store efficiently manages data flow between the network, local storage, and your app’s UI.

package org.mobilenativefoundation.trails.xplat.lib.market.post.impl.store

class PostStoreFactory(
    private val client: PostOperations,
    private val trailsDatabase: TrailsDatabase,
) {

    fun create(): PostStore {
        return MutableStoreBuilder.from(
            fetcher = createFetcher(),
            sourceOfTruth = createSourceOfTruth(),
            converter = createConverter()
        ).build(
            updater = createUpdater(),
            bookkeeper = createBookkeeper()
        )
    }

    private fun createFetcher(): Fetcher<Int, PostNetworkModel> {...}

    private fun createSourceOfTruth(): SourceOfTruth<Int, PostEntity, Post> {...}

    private fun createConverter(): Converter<PostNetworkModel, PostEntity, Post> {...}

    private fun createUpdater(): Updater<Int, Post, Boolean> {...}

    private fun createBookkeeper(): Bookkeeper<Int> {...}
}

Using the Store

Now, let’s use the Store to fetch and cache post data for the Trails post detail screen.

1

Create a Post Repository

We’ll create a PostRepository that uses the PostStore to fetch and cache post data. The primary reason for this extra layer is it enables us to extract Store from the domain layer as an implementation detail of the PostRepository. It also enables us to add additional methods and strategies to the PostRepository in the future.

PostRepository is feature agnostic. We define the PostRepository under the common lib/market/post/api namespace and implement it in the lib/market/post/impl module to facilitate its reuse across features. Consumers can depend on the lib/market/post/api module without being exposed to the implementation details, such as the PostStore.

A market is a composition of stores and systems enabling exchange between consumers and providers.

2

Define the Post Detail Screen

Trails is built with a Circuit architecture. Before we can interact with the PostRepository, we need to define the Screen, State, and Event classes.

package org.mobilenativefoundation.trails.xplat.feat.postDetailScreen.api

interface PostDetailScreen : Screen {
    sealed interface State : CircuitUiState {
        data class Loaded(
          val post: Post,
          val eventSink: (Event) -> Unit,
        ) : State
        data object Loading: State
    }

    sealed interface Event : CircuitUiEvent {
        data object Favorite: Event
        data object Unfavorite: Event
    }

    interface UI : CircuitUI<State>
    interface Presenter : CircuitPresenter<State>
}
3

Implement the Post Detail Presenter

A Circuit Presenter is intended to be the business logic for a screen’s UI and a translation layer in front of the data layer. Our PostDetailScreenPresenter will use the PostRepository to load the post data and update the UI in response to user actions.

package org.mobilenativefoundation.trails.xplat.feat.postDetailScreen.impl

@Inject
class PostDetailScreenPresenter(
    private val postRepository: PostRepository,
    @Assisted private val postId: Int
) : PostDetailScreen.Presenter {

    @Composable
    override fun present(): PostDetailScreen.State {
        var post: Post? by remember { mutableStateOf(null) }

        LaunchedEffect(postId) {
          post = postRepository.getPost(postId)
        }

        return if (post != null) {
          PostDetailScreen.State.Loaded(post, eventSink = ::handleEvent)
        } else {
          PostDetailScreen.State.Loading
        }
    }

    private fun handleEvent(prevState: PostDetailScreen.State, event: PostDetailScreen.Event) {
      when (event) {
        is PostDetailScreen.Event.Favorite -> handleFavorite(prevState)
        is PostDetailScreen.Event.Unfavorite -> handleUnfavorite(prevState)
      }
    }

    private fun handleFavorite(prevState: PostDetailScreen.State) {
      val nextPost = postRepository.updatePost(
        postId = postId,
        isFavoritedByCurrentUser = true,
        likesCount = prevState.post.likesCount + 1
      )
      post = nextPost
    }

    private fun handleUnfavorite(prevState: PostDetailScreen.State) {
      val nextPost = postRepository.updatePost(
        postId = postId,
        isFavoritedByCurrentUser = false,
        likesCount = prevState.post.likesCount - 1
      )
      post = nextPost
    }
}
4

Display the Post Detail Screen

package org.mobilenativefoundation.trails.xplat.feat.postDetailScreen.impl

@Inject
class PostDetailScreenUI : PostDetailScreen.UI {
    @Composable
    override fun Content(state: PostDetailScreen.State, modifier: Modifier) {
      when (state) {
        is PostDetailScreen.State.Loading -> {
          LoadingView()
        }

        is PostDetailScreen.State.Loaded -> {
          PostDetailView(
            post = state.post,
            onFavorite = { state.eventSink(PostDetailScreen.Event.Favorite) },
            onUnfavorite = { state.eventSink(PostDetailScreen.Event.Unfavorite) }
          )
        }
      }
    }
}

Next Steps

Now that you have built your first Store, it’s time to explore what else is possible: