Doc: TabMates architecture, patterns, guidelines for AI agents.
- Tech Stack: Kotlin Multiplatform (KMP), Compose Multiplatform (CMP).
- Architecture: Clean Architecture + MVVM.
- Dependency Injection: Koin.
- Navigation: Navigation 3 (Nav3).
- Networking: Ktor with ContentNegotiation (Serialization).
- Database: Room (KMP).
- Targets: Android, iOS, Desktop (JVM), Web (WasmJS).
- Package Root:
de.tabmates.
Project modularized by feature and layer. Use typesafe project accessors (e.g., projects.core.domain).
:core:domain: Pure Kotlin. Business models, standardResult<D, E>type,Errorinterfaces, global loggers.:core:data: Shared networking setup (HttpClientFactory), standard Ktor configs, common data sources.:core:presentation: Shared UI logic,UiTextfor localized strings,ObserveAsEventsfor one-time events.:core:designsystem: Shared Compose tokens (Color, Type, Shape), reusable atomic components (Buttons, TextFields, etc.).
Each feature split into:
:domain: Interfaces (Service,Repository), business models, validators.:data: Domain interface impls, DTOs, mappers, API services.:presentation: Compose UI (Screens, Components), ViewModels, navigation routes.:database(Optional): Room database, entities, DAOs, migrations (e.g.:features:tabgroup:database).:testing(Optional): Shared fakes for tests (e.g.FakeAuthServicein:features:authentication:testing).
Not all features have all layers (:features:appupdate = domain + data only). Special: :features:tabgroup:sqliteWasmWorker (web SQLite worker).
:composeApp: Main entry point for shared UI. Aggregates all features, wires Navigation/DI.:androidApp: Android-specific config, depends only on:composeApp.
- Define
interface AuthServiceorinterface GroupRepository. - Use
de.tabmates.core.domain.util.Result<D, E>for all operation outcomes. - Models: data classes, ideally immutable.
- Implement domain interfaces.
- Use
Ktorfor networking. - Use
Mappersto convert DTOs to domain models. - Convention: DTOs suffix with
RequestorResponse.
- ViewModels:
- Use
StateFlowfor UI state (e.g.,state: StateFlow<LoginState>). Pattern:stateIn(viewModelScope, WhileSubscribed(5_000), initial)withonStart { }+hasLoadedInitialDataguard. - Use
Channel+receiveAsFlow()for one-time events (e.g.,events: Flow<LoginEvent>). - Text input: hold Compose
TextFieldStateinside state; validate viasnapshotFlow { textFieldState.text }. No per-keystroke actions. - User intents: direct public functions (dominant style, e.g.
submitForgotPasswordRequest()); two screens use sealedAction+onAction()(GroupSettings, JoinGroup). Match the style of the feature you touch. - Annotate with
@KoinViewModel. Inherit fromandroidx.lifecycle.ViewModel.
- Use
- UI Components:
Rootcomposables (e.g.,LoginRoot) handle ViewModel interaction and event observation.- Screen composables (e.g.,
LoginScreen) stateless, take data/callbacks. - Use
ObserveAsEventsto handle ViewModel events (Snackbars, Navigation).
- Routes:
@Serializabledata classes/objects in feature'spresentationmodule (e.g.,data object Home : NavKey). - Graphs: Features define
EntryProviderScope<NavKey>.featureGraphextension. - Wiring: All feature graphs aggregated in
composeApp/App.ktviaNavDisplay. - Top-level Tabs: Implement
TopLevelTabandLoggedIninterfaces for consistent bottom bar behavior.
No Koin DSL (module { }, singleOf, viewModelOf) — project uses Koin Annotations:
- Per layer:
@Module @Configuration @ComponentScan("<package>") class FeatureLayerModule(seefeatures/authentication/data/.../di/AuthenticationDataModule.kt). - Bindings:
@Single(addbinds = [Interface::class]when impl name ≠ interface),@KoinViewModelon ViewModels. - Platform deps:
expect class PlatformXyzModule()in commonMain +actualper source set (seecore/data/.../di/PlatformCoreDataModule.kt+.android/.desktop/.native/.webvariants). - Assembly:
@KoinApplication class TabMatesKoinAppincomposeApp/.../di/AppModule.kt; started inApp()viaKoinApplication(configuration = koinConfiguration<TabMatesKoinApp>()). - Use
koinViewModel()in Root composables.
- Theme:
TabMatesTheme(built on Material3). - Tokens: In
:core:designsystem. UseMaterialTheme.colorSchemeor customTabMatesThemeproperties. - Resources: Use
Res.string.keyorRes.drawable.keyvia Compose Resources. - Localization: Managed in
composeResources/values/string.xml(+values-de/string.xml) within each module. Write bare apostrophes/quotes — Compose Resources renders Android-style\'escaping literally.
Use multi-preview annotations from :core:designsystem for consistent testing across themes and devices.
@PreviewThemes: Light and Dark mode previews. Preferred for most components.@PreviewPhones: Portrait and Landscape previews for phones.@PreviewScreenSizes: Phone, Foldable, Tablet, Desktop, Web previews.@PreviewAll: Every theme+screen combination (14 previews). Use sparingly.
Wrap previewed component in TabMatesTheme and Surface (if needed for background).
@PreviewThemes
@Composable
private fun MyComponentPreview() {
TabMatesTheme {
Surface {
MyComponent()
}
}
}NEVER configure KMP manually. Plugin IDs (prefix de.tabmates.convention.):
kmp.library: Standard KMP library (domain/data modules).cmp.library: CMP library with Compose dependencies.cmp.feature: Feature presentation module (includes VM, Lifecycle, Core Presentation).cmp.application::composeAppshared-UI application.cmp.resources: Compose Resources generation.android.application/android.application.compose::androidApp.room: Room with KSP.koin: Koin Annotations + KSP compiler.ktlint: ktlint checks.buildkonfig: BuildConfig-like constants (BuildKonfig).
Registrations: build-logic/convention/build.gradle.kts.
Custom hierarchy template in build-logic (HierarchyTemplate.kt):
common
├── mobile (android + ios)
├── web (wasmJs)
├── native (ios + macos)
│ └── apple → ios, macos
└── desktop (jvm)
- Ktor engines:
okhttp(android),darwin(native),js(web),apache5(desktop). - Expect/Actual: Use sparingly. Prefer interfaces in
commonMain, platform-specific impls via DI.
- Composables: PascalCase. Root composables end in
Root. - ViewModels:
FeatureViewModel. - DI Modules:
<Feature><Layer>Moduleclasses (e.g.AuthenticationDataModule,AuthPresentationModule).
- Error Handling: Always use
Result<D, E>(Success/Failure, NOTError). ConvertDataErrortoUiTextviatoUiText()(core/presentation/.../util/DataErrorToUiText.kt). - Async Work:
viewModelScope.launchin ViewModels.Dispatchers.IOfor heavy/blocking calls (Ktor/Room non-blocking). - Immutability: Prefer
valanddata classwithcopy().
- Stack: kotlin-test (
@BeforeTest,assertEquals,assertIs) + Turbine (flow.test { }) + hand-written fakes. No mocking library. - Shared fakes in
:features:<name>:testingmodules; screen-local fakes next to the test. - ViewModel tests:
Dispatchers.setMain(UnconfinedTestDispatcher())in@BeforeTest,resetMain()in@AfterTest,runTest(testDispatcher). After editing aTextFieldState:Snapshot.sendApplyNotifications()+advanceUntilIdle(). - Tests live in
commonTest; Room/repo tests may live indesktopTest(e.g.OfflineFirstSyncRepositoryTest). - Target unit tests for domain logic and ViewModels.
- Format:
./gradlew ktlintFormat. CI runsktlintCheck :build-logic:convention:ktlintCheck. - Fast verify: compile only touched modules, e.g.
./gradlew :features:tabgroup:domain:compileKotlinJvm. Full Android build:./gradlew :androidApp:assembleDebug. - Tests:
./gradlew allTests(all targets) or narrower, e.g.:androidApp:testDebugUnitTest. - Compiler warnings: CI checks build log against
.github/compiler-warnings-baseline.txtvia.github/check-compiler-warnings.sh— new warnings fail the PR pipeline. Don't introduce any. - CI parity:
.github/workflows/pr_pipeline.yml= ktlint +:androidApp:assembleDebug lintDebug testDebugUnitTest+:composeApp:desktopJar+ wasm distribution +allTests. - Local Config:
local.propertiesmust haveAPI_KEY.CLIENT_BUILD_TOKENis optional (see README) — once the backend enables its version gate, native builds without a matching one get426. - Sync:
./gradlew help(triggers sync).
| Task | Command |
|---|---|
| Format | ./gradlew ktlintFormat |
| Lint (CI parity) | ./gradlew ktlintCheck :build-logic:convention:ktlintCheck |
| Compile one module | ./gradlew :features:tabgroup:domain:compileKotlinJvm |
| Android debug build | ./gradlew :androidApp:assembleDebug |
| Android unit tests | ./gradlew :androidApp:testDebugUnitTest |
| All tests, all targets | ./gradlew allTests |
| Desktop jar | ./gradlew :composeApp:desktopJar |
| Gradle sync | ./gradlew help |
- Create
:features:<name>:domain(+data,presentation, and optionallydatabase/testing); add every module tosettings.gradle.kts. - Apply convention plugins —
kmp.libraryfor domain/data,cmp.featurefor presentation, pluskoin,room,cmp.resourceswhere needed. Never configure KMP/Android by hand. - Wire dependencies with typesafe accessors (
projects.core.domain) and respect the layer rules:presentation → domain ← data, no cross-feature dependencies. - Add
@Module @Configuration @ComponentScan("<package>")per layer that needs DI — the Koin compiler plugin aggregates them automatically. - Define
NavKeyroutes and anEntryProviderScope<NavKey>.<feature>Graphextension inpresentation; register the graph and theSerializersModuleentries incomposeApp/.../App.kt. - Add tests in
commonTest(Room/repository tests may live indesktopTest); put shared fakes in:features:<name>:testing. - Run
./gradlew ktlintFormatand build the touched modules before opening a PR.
Details live in the android-module-structure and android-navigation skills.
- Keep this file updated. Whenever the architecture, tech stack, or project structure changes — a new module, a new convention plugin, a swapped library, a changed layer rule — update
AGENTS.mdin the same change. - Never configure KMP/Android/Compose manually; use a
de.tabmates.convention.*plugin. - Never hardcode dependency versions; everything goes through
gradle/libs.versions.toml. - Never use the Koin DSL; this project is Koin Annotations only.
- Never let data-layer types (DTOs,
*Entity,*Serializable) cross intopresentation. - Never throw across a layer boundary — return
Result.Failure/EmptyResult. Always rethrowCancellationException. - Never introduce a new compiler warning; CI diffs against
.github/compiler-warnings-baseline.txt. - Never commit a code-review report; they are throwaway artifacts under the gitignored
.claude/reviews/.
This repo is set up for Claude Code and opencode. Both read the same skills and the same reviewer brief — opencode loads .claude/skills/<name>/SKILL.md for Claude Code compatibility, so the skills are written once.
.claude/
skills/<name>/SKILL.md # shared by both tools — architecture skills + code-review
skills/code-review/
SKILL.md # orchestrator workflow
reviewer-instructions.md # canonical reviewer brief (single source of truth)
reviews/ # generated review reports (gitignored, one per run)
agents/code-reviewer.md # Claude Code subagent (thin wrapper)
.opencode/
agents/code-reviewer.md # opencode subagent (thin wrapper)
commands/code-review.md # opencode /code-review slash command
| Claude Code | opencode | |
|---|---|---|
| Skills | .claude/skills/ (native) |
.claude/skills/ (compat loader) |
| Reviewer agent | .claude/agents/code-reviewer.md |
.opencode/agents/code-reviewer.md |
| Run a review | /code-review [base] (skill) |
/code-review [base] (command, subtask: true) |
Never add a .opencode/skills/ copy of an existing skill — it forks the source of truth. Add skills under .claude/skills/ only.