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:

  1. Unit Tests: Pure business logic in domain layer (95% coverage required)
  2. Integration Tests: Repository implementations with real Supabase instances
  3. UI Tests: Composable functions with test states
  4. Architecture Tests: Konsist rules for clean architecture adherence
  5. 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.