Skip to content
Browse documentation
Decided

Tech stack and coding guidelines

Languages, frameworks, project structure and the coding rules both apps follow, taken from the Saymail mobile apps.

Updated: 18 September 2026

Incico v3 is built the way Saymail’s mobile apps are built: two fully native apps, one platform-agnostic specification, one visual design, no cross-platform framework. The differences from Saymail are only where Incico’s problem is different (a server-backed shared dataset instead of a local-first mail store).

Android

ItemChoice
LanguageKotlin 2.4.20 (moved up from the spec’s 2.2.10: supabase-kt 3.8.0 ships Kotlin 2.4 metadata; KSP moved with it to 2.3.12, which no longer carries a Kotlin-version prefix)
UIJetpack Compose, Material 3, Compose BOM 2025.12.00
Min / target SDK27 / 36 (Saymail’s values; confirmed, not revisited)
ArchitectureMVVM with unidirectional state: XxxRoute.kt (nav entry and VM wiring), XxxScreen.kt (stateless composable), XxxViewModel.kt exposing StateFlow
DINo framework. A hand-written AppContainer created in Application.onCreate, exposed via LocalAppContainer, with the 8-line appViewModel { } helper from Saymail. Kept small by grouping into AuthContainer, ProfileContainer, ContactsContainer, SyncContainer (Saymail’s single 1 366-line container is the anti-pattern to avoid).
Navigationnavigation-compose 2.8.5, four tab destinations plus pushed routes in one Routes object; TabletShell for two-pane
Backend clientsupabase-kt 3.8.0 (Auth, PostgREST, Realtime modules) over Ktor 3.5.1
Local cacheRoom 2.7.1 with exported schemas, one entity per server table plus SyncCursor; destructive migration allowed only before 1.0
Background workWorkManager for periodic external-contact sync (15 min minimum, network-constrained) and for retrying failed uploads — arrives in phase 4; not built in phase 2
PushFirebase Cloud Messaging, initialised manually from build-time values — arrives in phase 4; not built in phase 2
ImagesCoil — arrives in phase 3; not built in phase 2
Phone numberslibphonenumber-android
ContactsContactsContract via a small DeviceContactsGateway
Google ContactsPeople API over Ktor with tokens from AppAuth (PKCE, public client)
Sign-inandroidx.credentials 1.5.0 + googleid 1.1.1 for native Google sign-in via Credential Manager
Web pagesandroidx.browser Custom Tabs for the FAQ, What’s new, Terms and Privacy (Settings → Help and About)
LicencesAboutLibraries Gradle plugin 15.2.0 (com.mikepenz.aboutlibraries.plugin.android) generates raw/aboutlibraries per variant; no AboutLibraries runtime or UI library, the app renders the list itself (A90)
LoggingOne AppLog facade over logcat and the diagnostics log; android.util.Log is called nowhere else, and tools/log-check.mjs fails CI on a sensitive name in a log template (A88)
Analytics and crashesA hand-written PostHog /batch client (Telemetry), no vendor SDK (A50); crash reports through the same client (A89)
TestsJUnit 4, Truth, MockK, Turbine, Robolectric, Room testing, MockWebServer; Compose UI tests with semantics only

iOS

ItemChoice
LanguageSwift 6 toolchain in Swift 5 language mode, SWIFT_STRICT_CONCURRENCY = complete with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
UISwiftUI, iOS 17 minimum (deployment target 17.0; confirmed, not revisited)
ArchitectureFeature models with @Observable, one per screen or flow, created by a small AppContainer injected through the environment. The single 114 KB Store that Saymail iOS grew into is explicitly not repeated: no type over 300 lines.
NavigationTabView + NavigationStack per tab; SplitShell for two-pane on iPad
Backend clientsupabase-swift 2.55.2 (the one third-party dependency, via Swift Package Manager, pinned)
Local cacheSQLite via SQLite3 in the document-store layout Saymail iOS uses (fixed indexed columns plus a JSON blob), WAL, one connection behind a serial queue
Background workBGAppRefreshTask for external-contact sync — arrives in phase 4; not built in phase 2
PushAPNs via UIApplicationDelegateAdaptor, one alert push per notification carrying content-available so the cache refreshes before the tap (A57) — arrives in phase 4; not built in phase 2
ContactsContacts framework via DeviceContactsGateway
Google ContactsPeople API over URLSession with tokens from ASWebAuthenticationSession (PKCE, public iOS client)
Phone numbersPhoneNumberKit is avoided to keep dependencies minimal; a vendored subset of libphonenumber metadata for parsing and formatting, generated at build time, shared with Android’s expectations by golden tests
TestsXCTest, protocol-based fakes, no mocking framework

Shared

  • mobile/design/tokens.json (design tokens) and mobile/design/i18n/en.json + nl.json (76 canonical keys, typed into string tables on both platforms) are the contract, checked by tools/tokens-check.mjs and tools/i18n-diff.mjs; this documentation site is the specification itself, not a separate SPECIFICATION.md file.
  • Golden vectors shared from docs/vectors/*.json (phone formatting, address formatting, snapshot hashing, circle-dot rendering order) start in phase 3; phase 2 has nothing to pin yet.
  • The same i18n key set, checked by tools/i18n-diff.mjs.
  • The same screen names, test tags and analytics event names.

Coding guidelines (from Saymail’s docs/coding-guidelines.md, applied)

  1. Screaming architecture. Folders by feature and use case (feature/contacts/invite/, feature/profile/addresses/), never by technical role. Shared code bubbles up one level only when two use cases need it. Incico applies the rule strictly, including for data access: a feature/contacts/ContactsRepository.kt lives with its feature, not in a data/ folder.
  2. Single responsibility, ~300-line files, ~50-line methods. Orchestration methods read like an outline of well-named private steps. A file over 300 lines is refactored, not split into partials/extensions.
  3. Create dependencies close to use. No constructing objects at the top of a method that might early-return.
  4. Error boundaries at the edge. Calls to Supabase, Google or the OS address book are wrapped once at the boundary; internal methods throw. Errors are typed: Invalid(message) surfaced verbatim, NotFound(entity id), Network, Unauthorized (forces re-login), Server (redacted, logged). No swallowed results; runCatching/try? without logging is a review failure.
  5. Untrusted input at every boundary. Trim once, reject empty and whitespace-only, cap lengths, validate lists, before binding. The server re-validates with constraints.
  6. No dead code, no commented-out code, orphan functions deleted rather than kept.
  7. Secrets only in the secure store, identifiers may be constants; one AppConfig reads build values.
  8. Document the decision in the code when a choice is non-obvious, with the date and the spec section.
  9. Tests: Arrange-Act-Assert with comments, Action_WithCondition_ShouldResult naming, one test class per source class, both success and failure paths, builders in a Builders/ folder beside the tests, test files under 400 lines.
  10. Database rules for the local cache: WAL, busy_timeout, never touch the database from UI code, tight transactions, FTS5 for contact search, no LIKE on JSON.

Website

Astro 7, Tailwind v4 tokens, MDX docs collection, hand-rolled en/nl i18n, Netlify. See the site’s own README in web/.

Repository tooling

ToolUse
ktlint + detektKotlin style and complexity (file length, method length rules set to the guideline values)
SwiftFormat + SwiftLintSwift equivalents with file_length: 300, function_body_length: 50
gitleakspre-commit and CI
tools/i18n-diff.mjs, tools/vectors-check.mjsparity checks
PlantUML in Dockerarchitecture diagrams under docs/architecture/*.puml, ASCII-only