You want to build an e-commerce platform like Tokopedia β premium UI, big scale, and a modern tech stack. Kotlin Multiplatform for mobile, Rust for backend, GraphQL for API, Redis for caching, and local DB for offline support. That's ambitious for a beginner, but I'm going to walk you through the foundation step-by-step. No fluff, no theory dumps β just real code you can run today.
I'll assume you know basic programming (any language) and have a terminal ready. Let's start.
- Why Rust + GraphQL? Rust gives us memory safety and performance at scale. GraphQL lets the mobile client request exactly the data it needs β crucial for mobile bandwidth. I'm using
async-graphqlbecause it's the most mature Rust GraphQL library. - Why Kotlin Multiplatform (KMP)? Shared business logic between Android and iOS. We'll use Jetpack Compose for Android UI and SwiftUI for iOS, but the shared code will handle networking, caching, and local DB.
- Why Redis? Caching product data and session tokens. It's fast and simple. For a Tokopedia-scale system, Redis is a must.
- Local DB? We'll use SQLDelight for offline-first β product catalog and user preferences persist on device.
- Infrastructure for scale: We'll design with horizontal scaling in mind: stateless Rust servers behind a load balancer, Redis cluster, and PostgreSQL with read replicas.
Mobile App (KMP) β ββ> GraphQL Query (via Apollo KMP) β ββ> Rust Server (async-graphql) β ββ> Redis (cache hit) ββ> return data β ββ> PostgreSQL (cache miss) ββ> update Redis ββ> return data β ββ> Local DB (SQLDelight) ββ> offline cache
project-root/ βββ backend/ β βββ Cargo.toml β βββ src/ β βββ main.rs β βββ schema.rs β βββ models.rs β βββ db.rs β βββ cache.rs βββ mobile/ β βββ shared/ β β βββ build.gradle.kts β β βββ src/commonMain/ β β βββ api/ β β βββ cache/ β β βββ db/ β βββ androidApp/ β βββ iosApp/ βββ docker-compose.yml βββ README.md
Open terminal, create a new Rust project:
cargo init backend
cd backend
Edit `Cargo.toml`:// backend/Cargo.toml [package] name = "tokopedia-rust" version = "0.1.0" edition = "2021"
[dependencies] async-graphql = "7.0" async-graphql-actix-web = "7.0" actix-web = "4" actix-cors = "0.7" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "uuid"] } tokio = { version = "1.0", features = ["full"] } redis = { version = "0.24", features = ["tokio-comp"] } uuid = { version = "1.0", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] }
Why this way? I'm using actix-web for HTTP server β it's performant and well-tested. sqlx for async PostgreSQL queries. redis for caching. async-graphql integrates nicely with Actix.
The struggle: First time I ran cargo build, it took forever because of async-graphql compilation. That's normal β Rust compile times are long. Grab a coffee.
Create src/schema.rs:
// backend/src/schema.rs
use async_graphql::*;
#[derive(SimpleObject)]
pub struct Product {
pub id: uuid::Uuid,
pub name: String,
pub price: f64,
pub description: String,
pub image_url: String,
}
pub struct QueryRoot;
#[Object]
impl QueryRoot {
async fn products(&self, ctx: &Context<'_>) -> Vec<Product> {
// We'll implement this later
vec![]
}
async fn product(&self, ctx: &Context<'_>, id: uuid::Uuid) -> Option<Product> {
None
}
}
pub struct MutationRoot;
#[Object]
impl MutationRoot {
async fn create_product(&self, ctx: &Context<'_>, name: String, price: f64, description: String, image_url: String) -> Product {
unimplemented!()
}
}
pub type Schema = async_graphql::Schema<QueryRoot, MutationRoot, EmptySubscription>;
**Why this way?** We define types first. `SimpleObject` auto-derives resolver. Later we'll add database calls.
**The struggle:** I forgot to add `#[derive(SimpleObject)]` and got a compile error. Check the attribute.
Create `src/db.rs`:// backend/src/db.rs use sqlx::postgres::PgPool;
pub async fn initpool(databaseurl: &str) -> PgPool { PgPool::connect(database_url) .await .expect("Failed to connect to PostgreSQL") }
Create src/cache.rs:
// backend/src/cache.rs
use redis::{Client, AsyncCommands};
pub async fn get_cached_product(client: &Client, key: &str) -> Option<String> {
let mut conn = client.get_async_connection().await.ok()?;
conn.get(key).await.ok()
}
pub async fn set_cached_product(client: &Client, key: &str, value: &str) {
let mut conn = client.get_async_connection().await.ok();
if let Some(ref mut conn) = conn {
let _: Result<(), _> = conn.set_ex(key, value, 300).await; // 5 min TTL
}
}
**Why this way?** Separate concerns: DB pool in one place, Redis commands in another. `set_ex` ensures cache expires after 5 minutes.
**The struggle:** I used `redis::Client::open()` but forgot to enable the `tokio-comp` feature. Got a runtime error. Double-check your `Cargo.toml`.
Edit `src/main.rs`:// backend/src/main.rs mod schema; mod db; mod cache;
use actixweb::{web, App, HttpServer, middleware}; use actixcors::Cors; use asyncgraphqlactix_web::{GraphQLRequest, GraphQLResponse}; use schema::Schema;
async fn graphqlhandler(schema: web::Data<Schema>, req: GraphQLRequest) -> GraphQLResponse { schema.execute(req.intoinner()).await.into() }
#[actixweb::main] async fn main() -> std::io::Result<()> { let databaseurl = std::env::var("DATABASEURL").expect("DATABASEURL must be set"); let redisurl = std::env::var("REDISURL").unwrapor("redis://127.0.0.1/".tostring());
let pool = db::initpool(&databaseurl).await; let redisclient = cache::initclient(&redis_url).await;
let schema = Schema::build(schema::QueryRoot, schema::MutationRoot, EmptySubscription) .data(pool) .data(redis_client) .finish();
println!("GraphQL server running at http://0.0.0.0:8080/graphql");
HttpServer::new(move || { let cors = Cors::default() .allowanyorigin() .allowanymethod() .allowanyheader() .max_age(3600);
App::new() .wrap(cors) .appdata(web::Data::new(schema.clone())) .route("/graphql", web::post().to(graphqlhandler)) .route("/graphql", web::get().to(graphql_handler)) // GraphiQL }) .bind("0.0.0.0:8080")? .run() .await }
// Need to add EmptySubscription import use async_graphql::EmptySubscription;
Why this way? actix-cors allows mobile apps to connect. We expose both GET and POST for GraphiQL playground.
The struggle: I forgot to add EmptySubscription β got a compile error about missing type. Also, the graphql_handler route expects web::Data<Schema>, but I passed web::Data::new(schema.clone()) β that's fine because Schema is Send + Sync.
We'll connect to PostgreSQL and implement the products query.
Create a migrations/ folder and initial SQL:
-- backend/migrations/20250101_initial.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
price DECIMAL(10,2) NOT NULL,
description TEXT,
image_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
INSERT INTO products (name, price, description, image_url) VALUES
('Premium Wireless Headphones', 299.99, 'Noise-cancelling, 30h battery', 'https://picsum.photos/seed/headphones/400/400'),
('Organic Cotton T-Shirt', 39.99, 'Soft, breathable, eco-friendly', 'https://picsum.photos/seed/tshirt/400/400');
Run migration via `sqlx migrate run` ( `sqlx-cli` installed).
**The struggle:** First time I ran `sqlx migrate run`, it failed because my `.env` file was missing. I created a `.env` file with `DATABASE_URL=postgres://user:pass@localhost/tokopedia`. Don't forget that.
Update `src/schema.rs`:// backend/src/schema.rs use async_graphql::*; use sqlx::PgPool; use uuid::Uuid;
#[derive(SimpleObject, sqlx::FromRow)] pub struct Product { pub id: Uuid, pub name: String, pub price: f64, pub description: Option<String>, pub image_url: Option<String>, }
pub struct QueryRoot;
#[Object] impl QueryRoot { async fn products(&self, ctx: &Context<'>) -> Vec<Product> { let pool = ctx.data::<PgPool>().unwrap(); sqlx::queryas::<, Product>("SELECT * FROM products") .fetchall(pool) .await .unwrapordefault() }
async fn product(&self, ctx: &Context<'>, id: Uuid) -> Option<Product> { let pool = ctx.data::<PgPool>().unwrap(); sqlx::queryas::<, Product>("SELECT * FROM products WHERE id = $1") .bind(id) .fetchoptional(pool) .await .unwrap_or(None) } }
pub struct MutationRoot;
#[Object] impl MutationRoot { async fn createproduct(&self, ctx: &Context<'>, name: String, price: f64, description: Option<String>, imageurl: Option<String>) -> Product { let pool = ctx.data::<PgPool>().unwrap(); let id = Uuid::newv4(); sqlx::queryas::<, Product>( "INSERT INTO products (id, name, price, description, imageurl) VALUES ($1, $2, $3, $4, $5) RETURNING *" ) .bind(id) .bind(&name) .bind(price) .bind(&description) .bind(&imageurl) .fetch_one(pool) .await .expect("Failed to insert product") } }
Why this way? sqlx::FromRow derives let us map query results directly to our struct. ctx.data::<PgPool>() gives us the pool we injected in main.
The struggle: I initially used sqlx::queryas! macro, but it requires compile-time DB check. For beginners, queryas is simpler. Also, price is f64 but PostgreSQL DECIMAL β sqlx handles it, but beware of precision loss. For production, use rust_decimal.
Now cache the products query. Update src/cache.rs to add a helper:
// backend/src/cache.rs
use redis::{Client, AsyncCommands};
pub async fn init_client(redis_url: &str) -> Client {
Client::open(redis_url).expect("Invalid Redis URL")
}
pub async fn get_cached_products(client: &Client) -> Option<String> {
let mut conn = client.get_async_connection().await.ok()?;
conn.get("products:all").await.ok()
}
pub async fn set_cached_products(client: &Client, data: &str) {
let mut conn = client.get_async_connection().await.ok();
if let Some(ref mut conn) = conn {
let _: Result<(), _> = conn.set_ex("products:all", data, 300).await;
}
}
Then modify the `products` resolver in `schema.rs`:async fn products(&self, ctx: &Context<'>) -> Vec<Product> { let pool = ctx.data::<PgPool>().unwrap(); let redisclient = ctx.data::<Client>().unwrap();
// Try cache first if let Some(cached) = cache::getcachedproducts(redisclient).await { if let Ok(products) = serdejson::from_str::<Vec<Product>>(&cached) { return products; } }
// Fallback to DB let products = sqlx::queryas::<, Product>("SELECT * FROM products") .fetchall(pool) .await .unwrapor_default();
// Cache result if let Ok(json) = serdejson::tostring(&products) { cache::setcachedproducts(redis_client, &json).await; }
products }
Why this way? Cache-aside pattern: read from Redis first, if miss, read DB and populate cache. Reduces DB load.
The struggle: I forgot to add ; after await in the cache set line. Rust's strict compiler caught it. Also, serdejson::fromstr expects a Vec<Product>, but Product must implement Deserialize. Add #[derive(Deserialize)] to Product.
We'll create the shared module for API calls and local caching.
Create a new KMP project using Android Studio's template. Add dependencies to build.gradle.kts in shared/:
// mobile/shared/build.gradle.kts
plugins {
kotlin("multiplatform")
id("com.android.library")
id("app.cash.sqldelight")
}
kotlin {
androidTarget { compilations.all { kotlinOptions { jvmTarget = "1.8" } } }
listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework { baseName = "shared" }
}
sourceSets {
val commonMain by getting {
dependencies {
implementation("com.apollographql.apollo3:apollo-runtime:3.8.0")
implementation("app.cash.sqldelight:coroutines-extensions:2.0.0")
}
}
}
}
sqldelight {
databases {
create("TokopediaDatabase") {
packageName.set("com.tokopedia.shared.db")
}
}
}
**Why this way?** Apollo KMP provides GraphQL client for both Android and iOS. SQLDelight generates local DB code.
**The struggle:** I had to set the `jvmTarget` to 1.8 because some Android libraries still require it. Also, iOS framework name must match the target.
Create `mobile/shared/src/commonMain/graphql/GetProducts.graphql`:query GetProducts { products { id name price description imageUrl } }
Apollo will generate Kotlin classes. Then in shared/src/commonMain/kotlin/:
// mobile/shared/src/commonMain/kotlin/com/tokopedia/shared/api/ProductApi.kt
package com.tokopedia.shared.api
import com.apollographql.apollo3.ApolloClient
class ProductApi(private val apolloClient: ApolloClient) {
suspend fun getProducts(): List<GetProductsQuery.Product> {
val response = apolloClient.query(GetProductsQuery()).execute()
return response.data?.products ?: emptyList()
}
}
**Why this way?** Single place for all API calls. The generated types are type-safe.
**The struggle:** I forgot to add `apollo-runtime` dependency. The build failed with unresolved reference to ApolloClient. Double-check imports.
Create SQLDelight schema `mobile/shared/src/commonMain/sqldelight/com/tokopedia/shared/db/Product.sq`:CREATE TABLE ProductEntity ( id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL, price REAL NOT NULL, description TEXT, image_url TEXT );
selectAll: SELECT * FROM ProductEntity;
insertOrReplace: INSERT OR REPLACE INTO ProductEntity(id, name, price, description, image_url) VALUES (?, ?, ?, ?, ?);
deleteAll: DELETE FROM ProductEntity;
Then use it in a repository:
// mobile/shared/src/commonMain/kotlin/com/tokopedia/shared/repository/ProductRepository.kt
package com.tokopedia.shared.repository
import com.tokopedia.shared.api.ProductApi
import com.tokopedia.shared.db.TokopediaDatabase
class ProductRepository(
private val api: ProductApi,
private val database: TokopediaDatabase
) {
suspend fun getProducts(): List<Product> {
// Try local first for offline
val local = database.productEntityQueries.selectAll().executeAsList()
if (local.isNotEmpty()) {
return local.map { it.toProduct() }
}
// Fetch from network
val remote = api.getProducts()
database.transaction {
database.productEntityQueries.deleteAll()
remote.forEach { product ->
database.productEntityQueries.insertOrReplace(
id = product.id,
name = product.name,
price = product.price.toDouble(),
description = product.description,
image_url = product.imageUrl
)
}
}
return remote.map { it.toProduct() }
}
}
data class Product(val id: String, val name: String, val price: Double, val description: String?, val imageUrl: String?)
fun GetProductsQuery.Product.toProduct() = Product(id, name, price, description, imageUrl)
**Why this way?** Offline-first: show local data immediately, then sync. `transaction` ensures atomic writes.
**The struggle:** SQLDelight expects column names as `image_url`, but GraphQL returns `imageUrl`. I had to map manually. Also, `price` is `Double` in Kotlin, but SQLDelight's `REAL` maps to `Double`. Works.
Now that the MVP works, let's talk about production.
Create `docker-compose.yml`:version: '3.8' services: postgres: image: postgres:16 environment: POSTGRESUSER: tokopedia POSTGRESPASSWORD: secret POSTGRES_DB: tokopedia ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
redis: image: redis:7-alpine ports:
- "6379:6379"
backend: build: ./backend ports:
- "8080:8080"
environment: DATABASEURL: postgres://tokopedia:secret@postgres/tokopedia REDISURL: redis://redis:6379 depends_on:
- postgres
- redis
volumes: pgdata:
Why this way? Separate services, persistent volume for DB. The backend waits for DB and Redis to be ready.
The struggle: I forgot to add depends_on and the backend started before PostgreSQL was ready. Use a health check script or wait-for-it.sh.
- Stateless backend: Our Rust server stores no session data; tokens are in Redis. So you can run multiple instances behind a load balancer (e.g., NGINX or AWS ALB).
- Redis cluster: For production, use Redis Cluster or ElastiCache to shard cache across nodes.
- PostgreSQL replicas: Use read replicas for
SELECTqueries, write to primary. We can configuresqlxwith a pool of replicas. - GraphQL caching: Use
async-graphql's built-in caching or a CDN (like Cloudflare) to cache product queries. - Mobile app: Use Apollo's normalized cache for client-side caching, plus SQLDelight for offline persistence.
Further Learning
You've built a working foundation: Rust GraphQL backend with Redis caching, Kotlin Multiplatform mobile app with offline support, and Docker Compose for local development. From here, you can add:
- User authentication (JWT stored in Redis)
- Shopping cart (local DB + sync)
- Payment integration (Xendit, Midtrans)
- Real-time notifications (WebSocket via
actix-web) - Search with Elasticsearch
This is just the beginning. The key is to keep the architecture modular and scalable. Happy coding β and remember, when you hit a compile error, it's just Rust reminding you to be careful.
Memuat komentar...