Published on

Mapping data trong Android với clean architecture

Mapping data trong Android với clean architecture

Trong các dự án Android hiện đại, đặc biệt khi áp dụng Clean Architecture, việc mapping dữ liệu giữa các tầng Data, Domain và Presentation là một bước bắt buộc nếu muốn giữ cho kiến trúc thật sự "clean", dễ bảo trì và dễ test.

Bài viết này sẽ hướng dẫn bạn:

  • Vì sao cần mapping dữ liệu giữa các tầng.
  • Vai trò của từng tầng trong mapping.
  • Template code đầy đủ.
  • Cấu trúc thư mục gợi ý và một số lưu ý.

Vì sao cần mapping dữ liệu giữa các tầng?

Trong Clean Architecture, mỗi tầng có nhiệm vụ riêng và không nên “rò rỉ” model sang tầng khác:

  • Data Layer: Làm việc với dữ liệu từ API, database (DTO – Data Transfer Object).
  • Domain Layer: Chứa logic nghiệp vụ, Domain Model (thuần Kotlin, không phụ thuộc framework).
  • Presentation Layer: Xử lý UI, hiển thị dữ liệu (UI Model – có thể chứa định dạng hiển thị).

Domain là lớp trung gian nghiệp vụ. 2 layer còn lại sẽ phụ thuộc vào Domain. Domain sẽ hoàn toàn độc lập.

Nếu không mapping:

Domain có thể bị “bẩn” bởi chi tiết của API. UI có thể bị gò bó vì Domain Model không phù hợp để hiển thị trực tiếp. Việc thay đổi API hoặc UI có thể gây ảnh hưởng dây chuyền.

Vai trò mapping của từng layer

Data Layer

Mapping DTO ↔ Domain. DTO không được xuất hiện ngoài Data Layer.

Ví dụ: UserDTO.toDomain().

Domain Layer

Không chứa mapper ra Presentation. Có thể có mapper ngược về Data nếu cần gửi request (Domain → DTO).

Presentation Layer

Mapping Domain → UI Model. Thêm field phục vụ UI (ví dụ định dạng text, style).

Ví dụ: User.toPresentation().

Template code đầy đủ

Cấu trúc thư mục sẽ như thế này:

Cấu trúc thư mục gợi ý
// data.database

@Entity
data class RunEntity(
    @PrimaryKey
    val id: Long = 0,
    val distanceMeters: Long,
    val durationMillis: Long,
    val latitude: Float,
    val longitude: Float,
    val createdAt: Long
)

// data.mappers

fun RunEntity.toRun(): Run {
    return Run(
        id = id,
        distanceMeters = distanceMeters,
        duration = durationMillis.milliseconds,
        location = Location(
            latitude = latitude,
            longitude = longitude
        ),
        createdAt = Instant
            .fromEpochMilliseconds(createdAt)
            .toLocalDateTime(TimeZone.currentSystemDefault())
    )
}

fun RunDto.toRun(): Run {
    return Run(
        id = id,
        distanceMeters = distanceMeters,
        duration = durationMillis.milliseconds,
        location = Location(
            latitude = latitude,
            longitude = longitude
        ),
        createdAt = Instant
            .fromEpochMilliseconds(createdAt)
            .toLocalDateTime(TimeZone.currentSystemDefault())
    )
}

// data.network

@Serializable
data class RunDto(
    val id: Long,
    val distanceMeters: Long,
    val durationMillis: Long,
    val latitude: Float,
    val longitude: Float,
    val createdAt: Long
)

// domain.model

data class Run(
    val id: Long,
    val distanceMeters: Long,
    val duration: Duration,
    val location: Location,
    val createdAt: LocalDateTime
)

data class Location(
    val latitude: Float,
    val longitude: Float
)

// presentation.model

data class RunUi(
    val id: Long,
    val formattedDistance: String,
    val formattedDuration: String,
    val location: Location,
    val formattedCreatedAt: String
)

// presentation.mappers

fun Run.toRunUi(): RunUi {
    val distanceKm = round((distanceMeters / 1000f) / 100f) * 100f

    val totalSeconds = duration.inWholeSeconds
    val minutes = (totalSeconds / 60).toString().padStart(2, '0')
    val seconds = (totalSeconds % 60).toString().padStart(2, '0')

    return RunUi(
        id = id,
        formattedDistance = "$distanceKm km",
        formattedDuration = "$minutes:$seconds",
        location = location,
        formattedCreatedAt = createdAt.format(
            LocalDateTime.Format {
                hour()
                char(':')
                minute()
                chars(", ")
                day()
                char(' ')
                monthNumber()
                char(' ')
                year()
            }
        )
    )
}

Tuy nhiên trong thực tế người ta hay lược bớt UI Object mà có thể sử dụng luôn Domain Object

Quy tắc chung khi implement

  • Không để DTO hoặc UI Model xuất hiện trong Domain.
  • Mapper nên là extension function: dễ đọc, dễ test.
  • Với list: dùng map { it.toDomain() } hoặc tạo ListMapper nếu cần tái sử dụng nhiều.
  • UseCase không chứa mutable state và luôn có thể test độc lập.
  • Nếu dự án lớn: gom tất cả mapper vào module riêng (mappers).

Việc tách bạch rõ ràng Data, Domain và Presentation với mapper:

  • Giúp code dễ bảo trì, dễ mở rộng.
  • Giảm rủi ro khi thay đổi API hoặc UI.
  • Giúp testing đơn giản hơn.

Áp dụng cấu trúc và template trên, bạn sẽ có một kiến trúc mapping rõ ràng, sạch sẽ, và dễ quản lý trong mọi dự án Android hoặc Kotlin Multiplatform.