Skip to main content

Data Layer

The data layer is the outermost layer in the app's Clean Architecture stack. It owns every detail of how data is fetched, cached, and persisted β€” Retrofit APIs, DTOs, mappers, DataStores β€” so the domain and presentation layers never depend on networking or storage specifics.

Architecture overview​

The data layer follows the Dependency Inversion Principle: domain defines repository interfaces, and the data layer supplies concrete implementations. Hilt's @Binds in RepositoryModule wires each interface β†’ impl at compile time, so the rest of the app only ever sees the domain contract.

Key characteristics:

  • Retrofit + Gson for all HTTP communication.
  • OkHttp interceptors for auth tokens, logging, and automatic token refresh.
  • @Singleton scope β€” every repository, API, and mapper is a singleton to avoid redundant instances.
  • Suspend functions everywhere β€” all repository and API methods are coroutine- based (except AuthApiSync, which uses Call<T> for the OkHttp authenticator thread).
  • Error mapping β€” repository implementations catch HttpException / IOException and re-throw domain-level NetworkException.

Repository pattern​

Interface in domain​

// domain/repositories/EventRepository.kt
interface EventRepository {
suspend fun getEvents(deviceId: Int?, from: Instant?, ...): List<DeviceEvent>
suspend fun getEvent(id: Int): DeviceEvent
}

Implementation in data​

// data/repositories/EventRepositoryImpl.kt
@Singleton
class EventRepositoryImpl @Inject constructor(
private val eventApi: EventApi,
private val eventMapper: EventMapper
) : EventRepository {

override suspend fun getEvents(...): List<DeviceEvent> = try {
val events = eventApi.getEvents(...)
eventMapper.toDomain(events)
} catch (e: HttpException) {
throw mapHttpException(e)
} catch (e: IOException) {
throw NetworkException("Network error fetching events", e)
}
}

Hilt binding​

All 11 domain-interface bindings live in a single abstract module:

// di/RepositoryModule.kt
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindEventRepository(impl: EventRepositoryImpl): EventRepository
// ... 10 more bindings
}

BillingRepository is the exception β€” it is bound in build-variant-specific modules (DebugBillingModule / ReleaseBillingModule) so debug builds can swap in a fake billing implementation.

The three data-only repositories (NotificationPermissionRepository, NotificationSettingsRepository, NotificationStateRepository) have no domain interface; they are injected directly via @Inject constructor.

DTO β†’ domain mapping pattern​

DTOs (data/remote/dto/) mirror the JSON shapes returned by the backend. Mapper classes (data/mappers/) convert them to domain entities, isolating the domain from API changes.

Every mapper follows the same conventions:

ConventionExample
@Singleton class FooMapper @Inject constructor()No dependencies β€” pure transformation
fun toDomain(dto: FooDto): FooSingle-object mapping
fun toDomain(dtos: List<FooDto>): List<Foo>List convenience overload
Null-safe defaultsdto.attributes ?: emptyMap(), dto.name ?: ""
Enum parsingEventType.fromString(dto.type)
Timestamp parsingInstant.parse(dateString) with fallback + logging

Some mappers also provide reverse mapping (domain β†’ DTO) for create/update requests β€” e.g. GeofenceMapper.toCreateRequest(), SharingMapper.toDto().

Repository inventory​

Domain repositories (interface + impl)​

#InterfaceImplementationAPIMapperSub-doc
1AuthRepositoryAuthRepositoryImplAuthApiUserMapperauth.md
2BillingRepositoryBillingRepositoryImplBillingApiβ€”billing.md
3CommandRepositoryCommandRepositoryImplCommandApiCommandMappercommands.md
4DeviceLifecycleRepositoryDeviceLifecycleRepositoryImplDeviceApiDeviceMapperdevices.md
5DeviceRepositoryDeviceRepositoryImplDeviceApi, PositionApiDeviceMapper, PositionMapperdevices.md
6EventRepositoryEventRepositoryImplEventApiEventMapperevents.md
7GeofenceRepositoryGeofenceRepositoryImplGeofenceApiGeofenceMappergeofences.md
8NotificationRepositoryNotificationRepositoryImplNotificationApiβ€”notifications.md
9PositionRepositoryPositionRepositoryImplPositionApiPositionMapperpositions.md
10RealTimeRepositoryRealTimeRepositoryImplWebSocketManagerWebSocketMapperΒΉreal-time.md
11SharingRepositorySharingRepositoryImplSharingApiSharingMappersharing.md
12SubscriptionRepositorySubscriptionRepositoryImplSubscriptionApiβ€”subscriptions.md

ΒΉ WebSocketMapper is used inside WebSocketManager, not directly in the repository impl.

Data-only repositories (no domain interface)​

#ClassPurpose
1NotificationPermissionRepositoryChecks Android runtime notification permission
2NotificationSettingsRepositoryReads/writes notification sound preferences via SharedPreferences
3NotificationStateRepositoryIn-memory store for FCM token, pending navigation, and dismissed events

API inventory​

All Retrofit interfaces live in data/remote/api/.

#InterfaceBase pathEndpointsNotes
1AuthApiapi/auth/25Login, register, 2FA, profile, password, legal document flows
2AuthApiSyncapi/auth/1Synchronous Call<T> for TokenAuthenticator
3BillingApiapi/billing/, api/devices/3Subscription status, license, purchase verification
4CommandApiapi/commands/6Command types, definitions, send, status, history
5DeviceApiapi/devices/12CRUD, claim/unclaim, suspend, mute, icons
6EventApiapi/events2List and single event retrieval
7GeofenceApiapi/geofences6Full CRUD for geofences
8NotificationApiapi/notifications/19History, tokens, user/device settings, contacts, sounds
9PositionApiapi/positions3Latest, history, delete
10SharingApiapi/sharing/7Share, revoke, leave, invites
11SubscriptionApiapi/billing/3Status, checkout, portal

All APIs are provided as singletons via NetworkModule using retrofit.create(FooApi::class.java). AuthApiSync uses a separate @NoAuthRetrofit instance (no TokenAuthenticator) to avoid circular dependency during token refresh.

Mapper inventory​

All mappers live in data/mappers/ and are @Singleton @Inject constructor().

#MapperDTO sourceDomain target(s)Direction
1CommandMapperCommandDtos.ktCommand, CommandTypesResponse, AllCommandTypesResponse, CommandDefinition, CommandSendResult, CommandStatusResult, CommandHistoryEntry, CommandHistoryResponseDTO β†’ Domain
2DeviceMapperDeviceDtos.ktDevice, DeviceIcon, DeviceIconCategoryDTO β†’ Domain
3EventMapperEventDto.ktDeviceEventDTO β†’ Domain
4GeofenceMapperGeofenceDtos.ktGeofence, CreateGeofenceRequest, UpdateGeofenceRequestBidirectional
5PositionMapperPositionDto.ktPositionDTO β†’ Domain
6SharingMapperSharingDtos.ktSharePermissions, SharedUser, DeviceShare, PendingInvite, DeviceSharesInfo, ShareDeviceResult, UserInvite, AcceptInviteResultBidirectional
7SubscriptionMapperSubscriptionDtos.ktSubscription, CheckoutSession, BillingPortalDTO β†’ Domain
8UserMapperAuthDtos.ktUser, LoginResult, RegisterResult, VerifyEmailResult, TwoFactorSetupDTO β†’ Domain
9WebSocketMapperWebSocketDtos.ktPosition, Geofence, NotificationHistoryItem, ShareInviteReceivedEvent, ShareAcceptedEventDTO β†’ Domain

Design decisions​

Why Retrofit + Gson?​

Retrofit is the de-facto standard for type-safe HTTP on Android. Gson was chosen for JSON parsing because it requires zero boilerplate for Kotlin data classes and integrates natively with Retrofit via GsonConverterFactory. A custom NullOnEmptyConverterFactory handles empty-body responses (204, etc.) without crashing.

Why the repository pattern?​

  • Testability β€” ViewModels and use cases depend on interfaces, so unit tests can substitute fakes without touching the network.
  • Single responsibility β€” each repository owns one domain concept (devices, events, geofences…).
  • Swappability β€” the BillingRepository debug/release module split demonstrates how implementations can be swapped per build variant with zero changes to consumers.

Why separate DTOs from domain entities?​

  • Decoupling β€” backend schema changes (renamed fields, added nullability) are absorbed by the DTO + mapper; domain entities stay stable.
  • Null safety β€” DTOs mirror the JSON (nullable fields), while domain entities enforce non-null invariants with sensible defaults.
  • Readability β€” domain entities use domain-specific types (EventType, DeviceStatus, Instant) instead of raw strings.

How to add a new repository​

Follow these steps to introduce a new domain concept (e.g. "Trip"):

1. Define the domain entity​

// domain/entities/Trip.kt
data class Trip(val id: Int, val name: String, val startTime: Instant, val endTime: Instant)

2. Create the domain repository interface​

// domain/repositories/TripRepository.kt
interface TripRepository {
suspend fun getTrips(deviceId: Int): List<Trip>
suspend fun getTrip(id: Int): Trip
}

3. Add the DTO​

// data/remote/dto/TripDtos.kt
data class TripDto(
val id: Int,
val name: String?,
val startTime: String,
val endTime: String
)

4. Add the Retrofit API​

// data/remote/api/TripApi.kt
interface TripApi {
@GET("api/trips")
suspend fun getTrips(@Query("deviceId") deviceId: Int): List<TripDto>

@GET("api/trips/{id}")
suspend fun getTrip(@Path("id") id: Int): TripDto
}

5. Add the mapper​

// data/mappers/TripMapper.kt
@Singleton
class TripMapper @Inject constructor() {
fun toDomain(dto: TripDto): Trip = Trip(
id = dto.id,
name = dto.name ?: "",
startTime = Instant.parse(dto.startTime),
endTime = Instant.parse(dto.endTime)
)

fun toDomain(dtos: List<TripDto>): List<Trip> = dtos.map { toDomain(it) }
}

6. Implement the repository​

// data/repositories/TripRepositoryImpl.kt
@Singleton
class TripRepositoryImpl @Inject constructor(
private val tripApi: TripApi,
private val tripMapper: TripMapper
) : TripRepository {

override suspend fun getTrips(deviceId: Int): List<Trip> = try {
tripMapper.toDomain(tripApi.getTrips(deviceId))
} catch (e: HttpException) {
throw NetworkException("HTTP error: ${e.code()}", e)
} catch (e: IOException) {
throw NetworkException("Network error fetching trips", e)
}

override suspend fun getTrip(id: Int): Trip = try {
tripMapper.toDomain(tripApi.getTrip(id))
} catch (e: HttpException) {
throw NetworkException("HTTP error: ${e.code()}", e)
} catch (e: IOException) {
throw NetworkException("Network error fetching trip", e)
}
}

7. Register with Hilt​

Add the API to NetworkModule:

@Provides @Singleton
fun provideTripApi(retrofit: Retrofit): TripApi = retrofit.create(TripApi::class.java)

Bind the repository in RepositoryModule:

@Binds @Singleton
abstract fun bindTripRepository(impl: TripRepositoryImpl): TripRepository

Checklist​

  • Domain entity in domain/entities/
  • Repository interface in domain/repositories/
  • DTO in data/remote/dto/
  • Retrofit API in data/remote/api/
  • Mapper in data/mappers/
  • Repository impl in data/repositories/
  • API provided in NetworkModule
  • Interface bound in RepositoryModule