androidengineers.Book a session

Network Architecture

API Design: Resources, Pagination, Errors

article25 minMedium

Resource Naming Conventions

REST APIs model resources as nouns, not actions. The HTTP method conveys the action.

# WRONG — verbs in URL
GET  /getUsers
POST /createUser
POST /deleteUser/42

# CORRECT — nouns, HTTP methods carry the action
GET    /users          → list users
POST   /users          → create a user
GET    /users/42       → get user 42
PUT    /users/42       → replace user 42
PATCH  /users/42       → partial update user 42
DELETE /users/42       → delete user 42

Nested resources for relationships

GET  /users/42/orders          → orders belonging to user 42
GET  /users/42/orders/7        → order 7 of user 42
POST /users/42/orders          → create an order for user 42

Avoid nesting deeper than two levels. /users/42/orders/7/items/3 is painful to work with; flatten to /order-items/3 for direct access.

Naming conventions

  • Lowercase, hyphen-separated (kebab-case): /product-categories
  • Plural for collections: /products, /users
  • Singular for singletons: /profile, /settings
  • No file extensions: /users/42 not /users/42.json

HTTP Method Semantics

MethodSemanticsIdempotentSafe
GETFetchYesYes
POSTCreateNoNo
PUTReplace (full update)YesNo
PATCHPartial updateNo (by spec)No
DELETERemoveYesNo

Idempotent means calling it multiple times has the same effect as calling it once. DELETE /users/42 twice still results in user 42 being deleted — the second call is a no-op (possibly 404, but no harm).

Safe means no side effects — the server state is unchanged.

HTTP Status Codes That Matter

// Common status codes and their meanings

// 2xx — success
200 OK              // GET, PUT, PATCH success
201 Created         // POST success with new resource
204 No Content      // DELETE success, no body to return

// 3xx — redirect
301 Moved Permanently    // resource URL has changed
304 Not Modified         // cache hit; use cached response

// 4xx — client error (caller's fault)
400 Bad Request          // malformed request body/params
401 Unauthorized         // missing or invalid authentication
403 Forbidden            // authenticated but not authorized
404 Not Found            // resource does not exist
409 Conflict             // state conflict (e.g., duplicate email)
422 Unprocessable Entity // valid JSON, but semantically invalid (validation failed)
429 Too Many Requests    // rate limit exceeded

// 5xx — server error (server's fault)
500 Internal Server Error  // unexpected server failure
502 Bad Gateway            // upstream service error
503 Service Unavailable    // server overloaded or down for maintenance
504 Gateway Timeout        // upstream timeout

400 vs 422: Use 400 for unparseable/malformed requests (bad JSON). Use 422 for requests that parsed correctly but failed validation (email already taken, field too long).

Pagination Strategies

Pagination prevents fetching millions of rows in a single request. Two main approaches:

Offset-based pagination

GET /products?page=2&per_page=20
GET /products?offset=40&limit=20
{
  "data": [...],
  "pagination": {
    "total": 1500,
    "page": 2,
    "per_page": 20,
    "total_pages": 75
  }
}

Pros: easy to jump to arbitrary pages; simple to implement.

Cons: items shift when new rows are inserted — page 2 may repeat items from page 1 (the "page drift" problem). Slow on large datasets (database must scan offset rows).

Cursor-based pagination (preferred for feeds)

GET /posts?limit=20
GET /posts?limit=20&cursor=eyJpZCI6MTAwfQ==
{
  "data": [...],
  "next_cursor": "eyJpZCI6ODB9",
  "prev_cursor": "eyJpZCI6MTAxfQ==",
  "has_next": true,
  "has_prev": false
}

The cursor is an opaque string (often base64-encoded {"id": 80, "created_at": "..."}) pointing to a position in the dataset.

Pros: stable under inserts/deletes; efficient on large datasets.

Cons: cannot jump to arbitrary pages; harder to implement correctly.

Android Paging 3 integration

// Retrofit interface for cursor-based pagination
interface PostApi {
    @GET("posts")
    suspend fun getPosts(
        @Query("limit") limit: Int,
        @Query("cursor") cursor: String? = null
    ): PagedResponse<Post>
}

data class PagedResponse<T>(
    val data: List<T>,
    val nextCursor: String?,
    val hasPrev: Boolean
)

// PagingSource implementation
class PostPagingSource(
    private val api: PostApi
) : PagingSource<String, Post>() {

    override suspend fun load(params: LoadParams<String>): LoadResult<String, Post> {
        return try {
            val response = api.getPosts(
                limit = params.loadSize,
                cursor = params.key    // null for first page
            )
            LoadResult.Page(
                data = response.data,
                prevKey = null,        // cursor-based: no backward paging
                nextKey = response.nextCursor
            )
        } catch (e: IOException) {
            LoadResult.Error(e)
        } catch (e: HttpException) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<String, Post>): String? = null
}

Error Response Structure

A consistent error body lets clients handle errors programmatically:

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "The request contains invalid fields",
    "details": [
      {
        "field": "email",
        "code": "ALREADY_TAKEN",
        "message": "This email address is already registered"
      },
      {
        "field": "username",
        "code": "TOO_SHORT",
        "message": "Username must be at least 3 characters"
      }
    ],
    "request_id": "req_abc123"
  }
}

Keep code machine-readable (a stable string constant) and message human-readable (for logs/debug). Include request_id for correlating client errors with server logs.

Network Result Wrapper

Wrap every API call in a typed Result that carries both success data and structured error information:

sealed class NetworkResult<out T> {
    data class Success<T>(val data: T) : NetworkResult<T>()
    data class Error(
        val code: Int,
        val errorBody: ApiError?,
        val throwable: Throwable? = null
    ) : NetworkResult<Nothing>()
    object Loading : NetworkResult<Nothing>()
}

data class ApiError(
    val code: String,
    val message: String,
    val details: List<FieldError>? = null,
    val requestId: String? = null
)

data class FieldError(
    val field: String,
    val code: String,
    val message: String
)

Retrofit Error Body Parsing

Retrofit throws HttpException for non-2xx responses. The error body is available but must be parsed manually:

// Retrofit setup
interface UserApi {
    @POST("users")
    suspend fun createUser(@Body request: CreateUserRequest): UserResponse
}

data class CreateUserRequest(val name: String, val email: String)
data class UserResponse(val id: String, val name: String, val email: String)

// Generic safe API call wrapper
suspend inline fun <reified T> safeApiCall(
    crossinline call: suspend () -> T
): NetworkResult<T> {
    return try {
        NetworkResult.Success(call())
    } catch (e: HttpException) {
        val errorBody = parseErrorBody(e)
        NetworkResult.Error(code = e.code(), errorBody = errorBody, throwable = e)
    } catch (e: IOException) {
        NetworkResult.Error(code = -1, errorBody = null, throwable = e)
    } catch (e: Exception) {
        NetworkResult.Error(code = -1, errorBody = null, throwable = e)
    }
}

fun parseErrorBody(e: HttpException): ApiError? {
    return try {
        val errorJson = e.response()?.errorBody()?.string() ?: return null
        val moshi = Moshi.Builder().build()
        val adapter = moshi.adapter(ApiErrorWrapper::class.java)
        adapter.fromJson(errorJson)?.error
    } catch (parseException: Exception) {
        null
    }
}

data class ApiErrorWrapper(val error: ApiError)

// Usage in Repository
class UserRepository @Inject constructor(private val api: UserApi) {

    suspend fun createUser(name: String, email: String): NetworkResult<UserResponse> {
        return safeApiCall { api.createUser(CreateUserRequest(name, email)) }
    }
}

// Usage in ViewModel
fun createUser(name: String, email: String) {
    viewModelScope.launch {
        _uiState.update { it.copy(isLoading = true) }

        when (val result = userRepository.createUser(name, email)) {
            is NetworkResult.Success -> {
                _uiState.update { it.copy(isLoading = false, user = result.data) }
            }
            is NetworkResult.Error -> {
                val errorMsg = when {
                    result.code == 422 -> {
                        // Show field-specific validation errors
                        result.errorBody?.details
                            ?.joinToString("\n") { "${it.field}: ${it.message}" }
                            ?: "Validation failed"
                    }
                    result.code == 409 -> "Email already taken"
                    result.throwable is IOException -> "No internet connection"
                    else -> "Something went wrong (${result.code})"
                }
                _uiState.update { it.copy(isLoading = false, errorMessage = errorMsg) }
            }
            is NetworkResult.Loading -> Unit
        }
    }
}

Query Parameters and Filtering

interface ProductApi {
    @GET("products")
    suspend fun getProducts(
        @Query("category") category: String? = null,
        @Query("min_price") minPrice: Double? = null,
        @Query("max_price") maxPrice: Double? = null,
        @Query("sort") sort: String = "newest",
        @Query("page") page: Int = 1,
        @Query("per_page") perPage: Int = 20
    ): PagedResponse<Product>

    // List parameters — Retrofit encodes as ?tags=a&tags=b
    @GET("products/search")
    suspend fun search(
        @Query("q") query: String,
        @Query("tags") tags: List<String>
    ): SearchResponse<Product>
}

Key Takeaways

ConceptRule
Resource namingNouns (plural), kebab-case, no verbs in URL
HTTP methodsGET=read, POST=create, PUT=replace, PATCH=partial, DELETE=remove
400 vs 422400=malformed request; 422=valid format, invalid content
Offset paginationSimple; suffers page drift on inserts
Cursor paginationStable; preferred for real-time feeds
Error bodycode (machine), message (human), details (per-field), request_id
NetworkResultSealed wrapper; parse error body from HttpException
safeApiCallCentralize try/catch; classify IOException vs HttpException

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
API Design: Resources, Pagination, Errors | Android System Design | Android Engineers