Last Week
The clean architecture implementation is complete! All four layers are now in place with comprehensive tests enforcing architectural boundaries. The infrastructure prevents shortcuts that plagued the previous attempt – pre-commit hooks run ktlint and Konsist tests, pre-push hooks verify coverage and build integrity, and GitHub Actions provides an additional safety net.
Building the UI Foundation
With clean architecture established, attention turns to the first user-facing screens: splash and login. This requires three essential libraries to work in harmony:
Koin for Dependency Injection
While partially implemented during the architecture setup, Koin needs full integration across all layers. This ensures proper separation of concerns and testability.
Voyager for Navigation
Despite Jetpack Compose Multiplatform finally releasing an official navigation library (still in alpha), Voyager remains the mature choice. The ecosystem around it is well-established with proven patterns and solutions.
Orbit MVI for State Management
The previous manual MVI implementation worked but required significant boilerplate. Orbit provides the same Model-View-Intent pattern with cleaner syntax and better testing support.
What does it mean in English?
Think of building an app like constructing a building. Clean architecture provides the foundation and structure. Now we’re installing the essential systems – plumbing (dependency injection), elevators (navigation), and electrical (state management). These need to work together seamlessly before adding any features.
Nerdy Details
The integration of Koin, Voyager, and Orbit MVI into the clean architecture requires careful orchestration. Each library must respect architectural boundaries while working seamlessly together.
Koin Dependency Injection Patterns
The Koin setup spans all architectural layers with strict enforcement:
// Domain layer module - pure business logic
val domainModule = module {
factory<CustomerRepository> { CustomerRepositoryImpl(get()) }
factory { AddCustomerToWaitlistUseCase(get(), get()) }
factory { NotifyCustomerUseCase(get(), get()) }
factory { CustomerValidator() }
}
// Data layer module - external dependencies
val dataModule = module {
single { createSupabaseClient() }
single { get<SupabaseClient>().auth }
single { get<SupabaseClient>().postgrest }
factory { CustomerRemoteDataSource(get()) }
factory { NotificationService(get()) }
}
// Presentation layer module - UI components
val presentationModule = module {
viewModelOf(::LoginViewModel)
viewModelOf(::DashboardViewModel)
viewModelOf(::WaitlistViewModel)
}
The Konsist tests enforce these patterns rigorously:
// konsist/DependencyInjectionTests.kt
class DependencyInjectionTests {
@Test
fun `all use cases must be registered in Koin modules`() {
Konsist.scopeFromProject()
.classes()
.withNameEndingWith("UseCase")
.assert { useCase ->
val moduleName = when {
useCase.resideInPackage("..domain..") -> "domainModule"
else -> error("UseCase in wrong package")
}
// Check if the use case is registered in the correct module
val moduleFile = File("di/$moduleName.kt").readText()
moduleFile.contains(useCase.name)
}
}
@Test
fun `ViewModels must use Koin injection syntax`() {
Konsist.scopeFromProject()
.classes()
.withNameEndingWith("ViewModel")
.assert { viewModel ->
// Must use viewModelOf syntax in Koin module
val hasProperRegistration = File("di/presentationModule.kt")
.readText()
.contains("viewModelOf(::${viewModel.name})")
// Constructor must have proper parameters
val hasInjectableConstructor = viewModel
.primaryConstructor
?.parameters
?.all { it.type.isInterface() } ?: false
hasProperRegistration && hasInjectableConstructor
}
}
}
Orbit MVI Implementation
Orbit MVI provides a clean state management pattern with built-in testing support:
// State definition
data class LoginState(
val email: String = "",
val password: String = "",
val isLoading: Boolean = false,
val error: String? = null
)
// Side effects
sealed interface LoginSideEffect {
object NavigateToDashboard : LoginSideEffect
data class ShowToast(val message: String) : LoginSideEffect
}
// ViewModel with Orbit
class LoginViewModel(
private val loginUseCase: LoginUseCase,
private val validateEmailUseCase: ValidateEmailUseCase
) : ViewModel(), ContainerHost<LoginState, LoginSideEffect> {
override val container = container<LoginState, LoginSideEffect>(LoginState())
fun onEmailChanged(email: String) = intent {
reduce { state.copy(email = email) }
// Real-time validation
validateEmailUseCase(email).fold(
onSuccess = { reduce { state.copy(error = null) } },
onFailure = { reduce { state.copy(error = it.message) } }
)
}
fun onLoginClicked() = intent {
reduce { state.copy(isLoading = true) }
loginUseCase(state.email, state.password).fold(
onSuccess = {
reduce { state.copy(isLoading = false) }
postSideEffect(LoginSideEffect.NavigateToDashboard)
},
onFailure = { error ->
reduce {
state.copy(
isLoading = false,
error = error.message
)
}
}
)
}
}
Testing Orbit ViewModels becomes straightforward:
class LoginViewModelTest {
@Test
fun `login with valid credentials navigates to dashboard`() = runTest {
val loginUseCase = mockk<LoginUseCase>()
coEvery { loginUseCase(any(), any()) } returns Result.success(User())
val viewModel = LoginViewModel(loginUseCase, mockk())
val testContainer = viewModel.container.test()
viewModel.onEmailChanged("[email protected]")
viewModel.onLoginClicked()
// Verify state transitions
testContainer.assert(LoginState()) {
states(
{ copy(email = "[email protected]") },
{ copy(isLoading = true) },
{ copy(isLoading = false) }
)
// Verify side effects
sideEffects(LoginSideEffect.NavigateToDashboard)
}
}
}
Voyager Navigation with Clean Architecture
Voyager screens become thin layers that delegate to ViewModels:
// Screen definition
class LoginScreen : Screen {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val viewModel = koinViewModel<LoginViewModel>()
val state by viewModel.container.stateFlow.collectAsState()
// Handle side effects
viewModel.container.sideEffectFlow.collectAsEffect { effect ->
when (effect) {
is LoginSideEffect.NavigateToDashboard ->
navigator.replace(DashboardScreen())
is LoginSideEffect.ShowToast ->
showToast(effect.message)
}
}
LoginContent(
state = state,
onEmailChanged = viewModel::onEmailChanged,
onLoginClicked = viewModel::onLoginClicked
)
}
}
// Pure Composable for testing
@Composable
fun LoginContent(
state: LoginState,
onEmailChanged: (String) -> Unit,
onLoginClicked: () -> Unit
) {
// UI implementation
}
The Supabase Testing Challenge
The testing challenge with Supabase runs deeper than just inline functions. The entire SDK is designed around coroutines and real-time features:
// This is what we want to test
class AuthRepositoryImpl(
private val supabaseClient: SupabaseClient
) : AuthRepository {
override suspend fun login(email: String, password: String): Result<User> {
return try {
// These inline functions can't be mocked
val result = supabaseClient.auth.signInWith(Email) {
this.email = email
this.password = password
}
Result.success(result.user.toDomainUser())
} catch (e: Exception) {
Result.failure(e)
}
}
}
// The solution: Integration tests with Docker
class AuthRepositoryIntegrationTest {
companion object {
private lateinit var supabaseContainer: GenericContainer<*>
@BeforeClass
@JvmStatic
fun setupSupabase() {
supabaseContainer = GenericContainer("supabase/postgres:15.1")
.withExposedPorts(5432, 8000)
.withEnv(mapOf(
"POSTGRES_PASSWORD" -> "test",
"JWT_SECRET" -> "test-secret"
))
.withCommand("supabase", "start")
supabaseContainer.start()
// Run initialization scripts
runInitScript("create-test-users.sql")
}
}
@Test
fun `login with valid credentials returns user`() = runTest {
val client = createTestClient(supabaseContainer.getMappedPort(8000))
val repository = AuthRepositoryImpl(client)
val result = repository.login("[email protected]", "password123")
assertTrue(result.isSuccess)
assertEquals("[email protected]", result.getOrNull()?.email)
}
}
The initialization scripts need to seed the test database:
-- create-test-users.sql
INSERT INTO auth.users (id, email, encrypted_password, created_at)
VALUES
(gen_random_uuid(), '[email protected]', crypt('password123', gen_salt('bf')), now()),
(gen_random_uuid(), '[email protected]', crypt('admin123', gen_salt('bf')), now());
-- Create test restaurant data
INSERT INTO public.restaurants (id, name, owner_id)
VALUES
(gen_random_uuid(), 'Test Restaurant',
(SELECT id FROM auth.users WHERE email = '[email protected]'));
Enforcement Through Pre-commit Hooks
All these patterns are enforced before code can even be committed:
// .git/hooks/pre-commit
#!/bin/bash
echo "Running architecture tests..."
./gradlew konsistTest
if [ $? -ne 0 ]; then
echo "Architecture violations detected!"
exit 1
fi
echo "Running Orbit MVI pattern tests..."
./gradlew testOrbitPatterns
echo "Checking Koin module completeness..."
./gradlew verifyKoinModules
# Check for common anti-patterns
if grep -r "GlobalScope.launch" --include="*.kt" .; then
echo "ERROR: GlobalScope usage detected. Use viewModelScope or lifecycleScope"
exit 1
fi
if grep -r "@Composable.*ViewModel\(" --include="*.kt" .; then
echo "ERROR: ViewModel creation in Composable. Use koinViewModel()"
exit 1
fi
The Complete Testing Strategy
The multi-layered testing approach ensures quality at every level:
- Unit Tests: Pure business logic in domain layer (95% coverage required)
- Integration Tests: Repository implementations with real Supabase instances
- UI Tests: Composable functions with test states
- Architecture Tests: Konsist rules for clean architecture adherence
- Pattern Tests: Proper usage of Koin, Orbit, and Voyager
// Complete test suite example
class WaitlistFeatureTests {
@Test
fun `domain - add customer validates input`() {
val validator = CustomerValidator()
val result = validator.validate(CustomerInput(name = ""))
assertTrue(result.isFailure)
}
@Test
fun `data - repository saves customer to Supabase`() = runIntegrationTest {
val repository = CustomerRepositoryImpl(supabaseClient)
val customer = Customer(name = "John Doe", phone = "+1234567890")
val result = repository.save(customer)
assertTrue(result.isSuccess)
}
@Test
fun `presentation - ViewModel updates state correctly`() = runTest {
val viewModel = WaitlistViewModel(mockk(), mockk())
viewModel.container.test {
viewModel.onAddCustomer(validInput)
assertState { state -> state.customers.size == 1 }
}
}
@Test
fun `architecture - layers respect boundaries`() {
Konsist.scopeFromProject()
.assertArchitecture {
val domain = Layer("Domain", "..domain..")
val data = Layer("Data", "..data..")
val presentation = Layer("Presentation", "..presentation..")
domain.doesNotDependOn(data, presentation)
data.doesNotDependOn(presentation)
}
}
}
GitHub Actions CI/CD Pipeline
The CI/CD setup leverages GitHub’s generous free tier (2000 minutes on 2-core machines) to run comprehensive checks:
# .github/workflows/ci.yml
name: CI/CD Pipeline
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
services:
supabase:
image: supabase/postgres:15.1
env:
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
- 8000:8000
steps:
- uses: actions/checkout@v3
- name: Setup JDK
uses: actions/setup-java@v3
with:
java-version: '17'
- name: Run Konsist Architecture Tests
run: ./gradlew konsistTest
- name: Run Unit Tests with Coverage
run: ./gradlew test jacocoTestReport
- name: Verify Coverage Thresholds
run: |
./gradlew verifyCoverage
# Fails if domain < 95% or other layers < 90%
- name: Setup Supabase Test Environment
run: |
npx supabase db reset --db-url postgresql://postgres:test@localhost:5432/postgres
npx supabase db seed
- name: Run Integration Tests
run: ./gradlew integrationTest
- name: Build All Platforms
run: |
./gradlew assembleDebug
./gradlew linkDebugFrameworkIosX64
- name: Run Detekt Static Analysis
run: ./gradlew detekt
- name: Upload Test Results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: |
**/build/reports/tests/
**/build/reports/jacoco/
Pre-Push Hook Details
The pre-push hook acts as the final gatekeeper before code reaches the remote repository:
#!/bin/bash
# .git/hooks/pre-push
echo "🔍 Running pre-push checks..."
# 1. Coverage Report Generation
echo "📊 Generating coverage report..."
./gradlew koverMergedReport
COVERAGE=$(grep -oP 'Total.*?(\d+)%' build/reports/kover/merged/html/index.html | grep -oP '\d+' | tail -1)
if [ "$COVERAGE" -lt 90 ]; then
echo "❌ Coverage is $COVERAGE%, required: 90%"
exit 1
fi
# 2. Build Verification for All Platforms
echo "🏗️ Verifying Android build..."
./gradlew assembleDebug
echo "🏗️ Verifying iOS framework..."
./gradlew linkDebugFrameworkIosX64
# 3. Run Platform-Specific Tests
echo "🤖 Running Android instrumentation tests..."
if command -v emulator &> /dev/null; then
./gradlew connectedAndroidTest
else
echo "⚠️ Android emulator not available, skipping instrumentation tests"
fi
# 4. Verify Clean Architecture Compliance
echo "🏛️ Checking architecture compliance..."
./gradlew konsistTest
# 5. Check for Common Issues
echo "🐛 Checking for common issues..."
# No hardcoded API keys
if grep -r "api_key\s*=\s*[\"'][^\"']\+[\"']" --include="*.kt" .; then
echo "❌ Hardcoded API key detected!"
exit 1
fi
# No TODO comments in production code
if grep -r "TODO" --include="*.kt" app/src/main shared/src/commonMain; then
echo "⚠️ TODO comments found in production code"
fi
echo "✅ All pre-push checks passed!"
Why Supabase Mocking Fails - Deep Dive
The Supabase Kotlin SDK’s architecture makes traditional mocking approaches fail in multiple ways:
// 1. Inline functions can't be mocked
inline fun <reified T> SupabaseClient.from(table: String): PostgrestQueryBuilder<T> {
// Implementation
}
// 2. Extension functions with reified types
inline fun <reified T> PostgrestQueryBuilder<T>.select(
columns: String = "*",
head: Boolean = false,
count: Count? = null
): PostgrestFilterBuilder<T> {
// Implementation
}
// 3. DSL builders that use inline functions
suspend inline fun Auth.signInWith(
provider: OAuthProvider,
block: SignInWithOAuthConfig.() -> Unit = {}
): Unit {
// Implementation
}
// Attempting to mock fails at compile time
val mockClient = mockk<SupabaseClient>()
val mockAuth = mockk<Auth>()
every { mockClient.auth } returns mockAuth // OK
// This fails - can't mock inline function
coEvery {
mockAuth.signInWith(any(), any())
} returns Unit // Compile error!
// Even relaxed mocks don't help
val relaxedClient = mockk<SupabaseClient>(relaxed = true)
// Still can't intercept inline function calls
The solution requires a complete abstraction layer:
// Domain layer abstraction
interface AuthenticationService {
suspend fun signIn(email: String, password: String): Result<User>
suspend fun signOut(): Result<Unit>
suspend fun getCurrentUser(): User?
}
// Data layer implementation
class SupabaseAuthenticationService(
private val client: SupabaseClient
) : AuthenticationService {
override suspend fun signIn(email: String, password: String): Result<User> {
return try {
// Real Supabase calls hidden behind interface
client.auth.signInWith(Email) {
this.email = email
this.password = password
}
client.auth.currentUserOrNull()?.let {
Result.success(it.toDomainUser())
} ?: Result.failure(Exception("Sign in failed"))
} catch (e: Exception) {
Result.failure(e)
}
}
}
// Now we can mock in tests
class LoginUseCaseTest {
@Test
fun `successful login returns user`() = runTest {
val authService = mockk<AuthenticationService>()
coEvery {
authService.signIn(any(), any())
} returns Result.success(testUser)
val useCase = LoginUseCase(authService)
val result = useCase("[email protected]", "password")
assertTrue(result.isSuccess)
}
}
The Four Layers in Practice
Here’s how the clean architecture layers work together for a single feature:
// 1. Domain Layer - Pure Business Logic
data class WaitlistEntry(
val id: String,
val customerName: String,
val partySize: Int,
val phoneNumber: String,
val estimatedWaitMinutes: Int,
val createdAt: Instant
)
interface WaitlistRepository {
suspend fun add(entry: WaitlistEntry): Result<WaitlistEntry>
suspend fun remove(id: String): Result<Unit>
suspend fun getActive(): Result<List<WaitlistEntry>>
}
class AddToWaitlistUseCase(
private val repository: WaitlistRepository,
private val notificationService: NotificationService,
private val waitTimeCalculator: WaitTimeCalculator
) {
suspend operator fun invoke(
name: String,
partySize: Int,
phone: String
): Result<WaitlistEntry> {
// Business logic only - no framework dependencies
val estimatedWait = waitTimeCalculator.calculate(partySize)
val entry = WaitlistEntry(
id = UUID.randomUUID().toString(),
customerName = name,
partySize = partySize,
phoneNumber = phone,
estimatedWaitMinutes = estimatedWait,
createdAt = Clock.System.now()
)
return repository.add(entry).onSuccess {
notificationService.scheduleReminder(it)
}
}
}
// 2. Data Layer - External Service Integration
class SupabaseWaitlistRepository(
private val client: SupabaseClient
) : WaitlistRepository {
override suspend fun add(entry: WaitlistEntry): Result<WaitlistEntry> {
return try {
val response = client.from("waitlist").insert(
WaitlistDto(
customerName = entry.customerName,
partySize = entry.partySize,
phoneNumber = entry.phoneNumber,
estimatedWaitMinutes = entry.estimatedWaitMinutes
)
).decodeSingle<WaitlistDto>()
Result.success(response.toDomain())
} catch (e: Exception) {
Result.failure(e)
}
}
}
// 3. Presentation Layer - UI State Management
class WaitlistViewModel(
private val addToWaitlist: AddToWaitlistUseCase,
private val removeFromWaitlist: RemoveFromWaitlistUseCase,
private val getActiveWaitlist: GetActiveWaitlistUseCase
) : ViewModel(), ContainerHost<WaitlistState, WaitlistSideEffect> {
override val container = container<WaitlistState, WaitlistSideEffect>(
WaitlistState()
)
init {
loadWaitlist()
}
fun onAddCustomer(name: String, partySize: Int, phone: String) = intent {
reduce { state.copy(isAddingCustomer = true) }
addToWaitlist(name, partySize, phone).fold(
onSuccess = { entry ->
reduce {
state.copy(
isAddingCustomer = false,
entries = state.entries + entry
)
}
postSideEffect(WaitlistSideEffect.ShowSuccess)
},
onFailure = { error ->
reduce { state.copy(isAddingCustomer = false) }
postSideEffect(
WaitlistSideEffect.ShowError(
error.message ?: "Failed to add customer"
)
)
}
)
}
}
// 4. UI Layer - Compose UI
@Composable
fun WaitlistScreen() {
val navigator = LocalNavigator.currentOrThrow
val viewModel = koinViewModel<WaitlistViewModel>()
val state by viewModel.container.stateFlow.collectAsState()
WaitlistContent(
state = state,
onAddCustomer = viewModel::onAddCustomer,
onRemoveCustomer = viewModel::onRemoveCustomer
)
}
Next Week
The focus remains on getting Koin, Voyager, and Orbit MVI properly integrated with comprehensive tests. Only after these foundations are rock-solid will the first Supabase integration begin. The testing infrastructure for Supabase – Docker containers, initialization scripts, and CI/CD integration – represents a significant undertaking that may extend into the following week.
Every shortcut avoided now saves hours of debugging later.