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.
@Singletonscope β 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 usesCall<T>for the OkHttp authenticator thread). - Error mapping β repository implementations catch
HttpException/IOExceptionand re-throw domain-levelNetworkException.
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:
| Convention | Example |
|---|---|
@Singleton class FooMapper @Inject constructor() | No dependencies β pure transformation |
fun toDomain(dto: FooDto): Foo | Single-object mapping |
fun toDomain(dtos: List<FooDto>): List<Foo> | List convenience overload |
| Null-safe defaults | dto.attributes ?: emptyMap(), dto.name ?: "" |
| Enum parsing | EventType.fromString(dto.type) |
| Timestamp parsing | Instant.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)β
| # | Interface | Implementation | API | Mapper | Sub-doc |
|---|---|---|---|---|---|
| 1 | AuthRepository | AuthRepositoryImpl | AuthApi | UserMapper | auth.md |
| 2 | BillingRepository | BillingRepositoryImpl | BillingApi | β | billing.md |
| 3 | CommandRepository | CommandRepositoryImpl | CommandApi | CommandMapper | commands.md |
| 4 | DeviceLifecycleRepository | DeviceLifecycleRepositoryImpl | DeviceApi | DeviceMapper | devices.md |
| 5 | DeviceRepository | DeviceRepositoryImpl | DeviceApi, PositionApi | DeviceMapper, PositionMapper | devices.md |
| 6 | EventRepository | EventRepositoryImpl | EventApi | EventMapper | events.md |
| 7 | GeofenceRepository | GeofenceRepositoryImpl | GeofenceApi | GeofenceMapper | geofences.md |
| 8 | NotificationRepository | NotificationRepositoryImpl | NotificationApi | β | notifications.md |
| 9 | PositionRepository | PositionRepositoryImpl | PositionApi | PositionMapper | positions.md |
| 10 | RealTimeRepository | RealTimeRepositoryImpl | WebSocketManager | WebSocketMapperΒΉ | real-time.md |
| 11 | SharingRepository | SharingRepositoryImpl | SharingApi | SharingMapper | sharing.md |
| 12 | SubscriptionRepository | SubscriptionRepositoryImpl | SubscriptionApi | β | subscriptions.md |
ΒΉ WebSocketMapper is used inside WebSocketManager, not directly in the repository impl.
Data-only repositories (no domain interface)β
| # | Class | Purpose |
|---|---|---|
| 1 | NotificationPermissionRepository | Checks Android runtime notification permission |
| 2 | NotificationSettingsRepository | Reads/writes notification sound preferences via SharedPreferences |
| 3 | NotificationStateRepository | In-memory store for FCM token, pending navigation, and dismissed events |
API inventoryβ
All Retrofit interfaces live in data/remote/api/.
| # | Interface | Base path | Endpoints | Notes |
|---|---|---|---|---|
| 1 | AuthApi | api/auth/ | 25 | Login, register, 2FA, profile, password, legal document flows |
| 2 | AuthApiSync | api/auth/ | 1 | Synchronous Call<T> for TokenAuthenticator |
| 3 | BillingApi | api/billing/, api/devices/ | 3 | Subscription status, license, purchase verification |
| 4 | CommandApi | api/commands/ | 6 | Command types, definitions, send, status, history |
| 5 | DeviceApi | api/devices/ | 12 | CRUD, claim/unclaim, suspend, mute, icons |
| 6 | EventApi | api/events | 2 | List and single event retrieval |
| 7 | GeofenceApi | api/geofences | 6 | Full CRUD for geofences |
| 8 | NotificationApi | api/notifications/ | 19 | History, tokens, user/device settings, contacts, sounds |
| 9 | PositionApi | api/positions | 3 | Latest, history, delete |
| 10 | SharingApi | api/sharing/ | 7 | Share, revoke, leave, invites |
| 11 | SubscriptionApi | api/billing/ | 3 | Status, 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().
| # | Mapper | DTO source | Domain target(s) | Direction |
|---|---|---|---|---|
| 1 | CommandMapper | CommandDtos.kt | Command, CommandTypesResponse, AllCommandTypesResponse, CommandDefinition, CommandSendResult, CommandStatusResult, CommandHistoryEntry, CommandHistoryResponse | DTO β Domain |
| 2 | DeviceMapper | DeviceDtos.kt | Device, DeviceIcon, DeviceIconCategory | DTO β Domain |
| 3 | EventMapper | EventDto.kt | DeviceEvent | DTO β Domain |
| 4 | GeofenceMapper | GeofenceDtos.kt | Geofence, CreateGeofenceRequest, UpdateGeofenceRequest | Bidirectional |
| 5 | PositionMapper | PositionDto.kt | Position | DTO β Domain |
| 6 | SharingMapper | SharingDtos.kt | SharePermissions, SharedUser, DeviceShare, PendingInvite, DeviceSharesInfo, ShareDeviceResult, UserInvite, AcceptInviteResult | Bidirectional |
| 7 | SubscriptionMapper | SubscriptionDtos.kt | Subscription, CheckoutSession, BillingPortal | DTO β Domain |
| 8 | UserMapper | AuthDtos.kt | User, LoginResult, RegisterResult, VerifyEmailResult, TwoFactorSetup | DTO β Domain |
| 9 | WebSocketMapper | WebSocketDtos.kt | Position, Geofence, NotificationHistoryItem, ShareInviteReceivedEvent, ShareAcceptedEvent | DTO β 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
BillingRepositorydebug/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