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/42not/users/42.json
HTTP Method Semantics
| Method | Semantics | Idempotent | Safe |
|---|---|---|---|
| GET | Fetch | Yes | Yes |
| POST | Create | No | No |
| PUT | Replace (full update) | Yes | No |
| PATCH | Partial update | No (by spec) | No |
| DELETE | Remove | Yes | No |
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
| Concept | Rule |
|---|---|
| Resource naming | Nouns (plural), kebab-case, no verbs in URL |
| HTTP methods | GET=read, POST=create, PUT=replace, PATCH=partial, DELETE=remove |
| 400 vs 422 | 400=malformed request; 422=valid format, invalid content |
| Offset pagination | Simple; suffers page drift on inserts |
| Cursor pagination | Stable; preferred for real-time feeds |
| Error body | code (machine), message (human), details (per-field), request_id |
| NetworkResult | Sealed wrapper; parse error body from HttpException |
safeApiCall | Centralize try/catch; classify IOException vs HttpException |