Last Week
I identified issues with the database design (and by extension my data layer logic). The work to redesign the database and code refactoring has begun.
What does it mean in English?
Imagine you’re building a house. Instead of throwing everything together in one big mess, you’d organize construction into layers: foundation, framing, electrical, plumbing, finishing, etc. Each layer has its own purpose, and specialists work on each part.
Software architecture works similarly. I did not design the warehouse that stores all the information correctly. Now I need to redesign it.
Nerdy Details
Layered architecture divides the application into separate layers, each with a specific responsibility:
- Presentation Layer: User interfaces and displays
- Domain Layer: Core business logic and models
- Data Layer: Communication with databases, APIs, etc.
Domain Layer is the heart of the application. It contains:
- Business Entities/Models: The core objects in your system
- Business Rules: How these entities interact
- Interfaces: Contracts that define how to interact with your domain
The domain doesn’t know or care about databases, APIs, or UI - it just defines what your application is and what it does.
// This is part of the domain layer - a business entity
data class Restaurant(
@SerialName("restaurant_id")
val restaurantId: Uuid,
val name: String,
val address: String? = null,
val phone: String? = null,
val email: String? = null,
@SerialName("created_at")
val createdAt: Instant? = null,
@SerialName("updated_at")
val updatedAt: Instant? = null
)
// This is also domain layer - an interface defining operations on restaurants
interface RestaurantRepository {
suspend fun getRestaurantsForUser(userId: String): Result<List<Restaurant>>
suspend fun createRestaurant(restaurant: Restaurant): Result<Restaurant>
// Other methods...
}
Data Layer is responsible for:
- Data Access: How to retrieve and store data
- External Communication: APIs, databases, file systems
- Data Mapping: Converting between domain objects and external formats
// This is in the data layer - concrete implementation of the repository
class SupabaseRestaurantRepository(
private val appSecrets: AppSecrets,
) : RestaurantRepository {
private val client: SupabaseClient = createSupabaseClient(
supabaseUrl = appSecrets.SUPABASE_URL,
supabaseKey = appSecrets.SUPABASE_KEY
) {
install(Postgrest)
}
override suspend fun getRestaurantsForUser(userId: String): Result<List<Restaurant>> {
// Implementation that talks to Supabase
}
// Other implemented methods...
}
Think of a repository as a collection of objects. Just like a real-world repository (like a museum), it:
- Stores things: Keeps your domain objects
- Manages access: Controls how things are added, removed, or modified
- Hides complexity: You don’t need to know how the museum organizes its storage rooms
In code terms, a repository:
- Provides an abstraction over data storage
- Defines clear operations (get, create, update, delete)
- Returns domain objects, not database records
Imagine a library:
- You (application) want a book (domain object)
- You ask the librarian (repository interface)
- The librarian knows the cataloging system (data layer implementation)
- You don’t care how they find the book, just that they give you what you asked for
Let’s walk through how this all works together:
// 1. DOMAIN LAYER: Define the data model
data class Restaurant(
val restaurantId: Uuid,
val name: String,
// Other properties...
)
// 2. DOMAIN LAYER: Define the repository interface
interface RestaurantRepository {
suspend fun getRestaurantById(id: Uuid): Result<Restaurant>
// Other methods...
}
// 3. DATA LAYER: Implement the repository
class SupabaseRestaurantRepository(private val supabaseClient: SupabaseClient) : RestaurantRepository {
override suspend fun getRestaurantById(id: Uuid): Result<Restaurant> {
return try {
val restaurant = supabaseClient.postgrest
.from("restaurants")
.select {
filter { eq("restaurant_id", id) }
}
.decodeSingleOrNull<Restaurant>()
if (restaurant != null) {
Result.success(restaurant)
} else {
Result.failure(NoSuchElementException("Restaurant not found"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
// 4. APPLICATION LAYER: Use cases that orchestrate operations
class GetRestaurantDetailsUseCase(private val repository: RestaurantRepository) {
suspend fun execute(restaurantId: Uuid): Result<Restaurant> {
return repository.getRestaurantById(restaurantId)
}
}
// 5. PRESENTATION LAYER: ViewModel that connects to UI
class RestaurantDetailsViewModel(private val getRestaurantDetailsUseCase: GetRestaurantDetailsUseCase) {
// State management, UI logic, etc.
fun loadRestaurant(id: Uuid) {
viewModelScope.launch {
val result = getRestaurantDetailsUseCase.execute(id)
// Handle result, update UI state, etc.
}
}
}
Benefits of This Approach
- Testability: You can easily test business logic by mocking the repository
// Testing business logic with a mock repository
class FakeRestaurantRepository : RestaurantRepository {
val restaurants = mutableListOf<Restaurant>()
override suspend fun getRestaurantById(id: Uuid): Result<Restaurant> {
return restaurants.find { it.restaurantId == id }
?.let { Result.success(it) }
?: Result.failure(NoSuchElementException("Not found"))
}
// Other implementations...
}
// In tests
val repository = FakeRestaurantRepository()
repository.restaurants.add(Restaurant(Uuid.randomUUID(), "Test Restaurant"))
val useCase = GetRestaurantDetailsUseCase(repository)
// Now you can test useCase without a real database
- Flexibility: Want to switch from Supabase to another database? Just create a new implementation:
class FirebaseRestaurantRepository : RestaurantRepository {
// Implement same interface but using Firebase
}
// Then in your DI setup
val repositoryModule = module {
// To switch implementations, just change this line:
singleOf(::SupabaseRestaurantRepository) { bind<RestaurantRepository>() }
// To:
// singleOf(::FirebaseRestaurantRepository) { bind<RestaurantRepository>() }
}
- Separation of Concerns: Domain code stays pure and focused on business logic
Common Pitfalls
- Leaky Abstractions: Don’t expose data layer concepts in your repository interface
// BAD: Leaks data layer details into domain
interface BadRepository {
suspend fun getRestaurantBySupabaseId(id: String): Result<Restaurant>
}
// GOOD: Domain doesn't care about data source specifics
interface GoodRepository {
suspend fun getRestaurantById(id: Uuid): Result<Restaurant>
}
- Anemic Domain Model: Don’t put all behavior in services, leaving entities as just data carriers
// BETTER: Entity with behavior
data class Restaurant(
val restaurantId: Uuid,
val name: String,
val tables: List<Table>
) {
fun canAccommodateParty(partySize: Int): Boolean {
return tables.filter { it.isAvailable }.sumOf { it.capacity } >= partySize
}
}
- Overcomplicating: Start simple and add complexity only when needed
// Start with basic repository operations
interface SimpleRestaurantRepository {
suspend fun getById(id: Uuid): Result<Restaurant>
suspend fun create(restaurant: Restaurant): Result<Restaurant>
suspend fun update(restaurant: Restaurant): Result<Restaurant>
suspend fun delete(id: Uuid): Result<Unit>
}
// Add more specific operations later as needed
The Repository Pattern gives you a clean separation between:
- What data your application needs (domain layer)
- How that data is stored and retrieved (data layer)
This makes your codebase more maintainable, testable, and adaptable to change - critical factors for long-term project success.
Next Week
Complete database redesign and data layer refactor.