Skip to main content

01 - Architecture

Concept​

1. Why​

An app built in layers rewrites the same plumbing every time:

  • turning API payloads into domain models;
  • a result type and an error hierarchy;
  • paging;
  • an HTTP client that refreshes tokens;
  • secure storage.

None of that is business logic, and none of it changes between apps. Only its configuration does: base URLs, where tokens live, which errors a module reports. Sinew draws that line. It ships each mechanism once, as a small library, and leaves every app-specific value to the app.

This note covers:

  • where Sinew sits in an app;
  • how its packages depend on each other;
  • what the app has to supply;
  • how the packages are wired, published and versioned.

2. Shape​

Where Sinew sits in an app​

A layered app has three kinds of modules below its screens. Sinew replaces the reusable part of the bottom two:

App module kindOwnsWhat Sinew provides to it
Feature / product modulesPages, view models, page statesinew_presentation (+ an adapter), sinew_paging
Data modules (one per business capability)Repositories, API clients, DTOs, local sourcessinew_models (envelopes, Response, Entity), the guards from sinew_network and sinew_storage
Infrastructure modules (app-specific setup)The configured HTTP client(s), named stores, the session's token source, the crash reporterThe mechanisms: SinewHttp, SecureStore, KeyValueStore, FieldCipher, BiometricVault, the CrashReporter contract
Library modules (app-agnostic)Code reusable in another projectSinew itself sits here, as published packages instead of in-repo modules

The app never forks Sinew to configure it. Anything app-specific goes in through a constructor parameter or one of the small interfaces in the next section.

How the packages depend on each other​

RuleWhy
No cycles. sinew_models is the leaf.Any package can be taken alone, with only what it stands on
sinew_l10n is the only package that touches a localization frameworkApps with their own translations of the error keys can skip it
sinew_presentation and sinew_security depend on no other Sinew packagePresentation is useful in an app that keeps its own data layer. Security is useful anywhere.
Only sinew_camouflage knows Camouflage, and Camouflage never knows SinewEither library can be used without the other
sinew_testing is a test-only dependencyFakes never reach production code
Network and storage expose hook points (SinewHttpHooks, InspectableStore) that do nothing until devtools pass real hooks. sinew_devtools depends on network and storage to provide those hooks; network and storage never depend on devtools.Production code paths don't change when devtools are absent, and devtools are an opt-in dependency
The adapters depend on exception (toViewState creates UnexpectedException); the glue depends on l10n (its convenience overload reads Sinew's localizer); sinew_riverpod_providers reaches security through storageEvery edge used in code is in this graph, so the CI graph check can enforce it

Mechanism vs. configuration​

Sinew ships (mechanism)The app supplies (configuration)Through
Envelope<R> and four abstract envelope bases that map payloadsDTOs, and its own envelopes: fields, JSON keys, page metadatasubclassing Response<D> and the envelope bases
The guards and their handler listsa module code and a function code per callthe guard's parameters
The HTTP client factory and its layersbase URL, timeouts, logging switch, anonymous paths, the current localeSinewConfig
The auth layer (single-flight refresh, replay, reset)where tokens live and how to refresh themTokenSource
Several clientsone client per backend, registered under a name in the app's DIcalling the factory once per backend
SecureStore, KeyValueStoretyped, named stores with their keys (OrderPreferences)wrapping the interfaces
FieldCipher, BiometricVaultwhich fields are encrypted, when biometrics are offeredcalling them from the data and domain layers
The CrashReporter contract, no-op by defaultthe real reporterpassing an implementation at startup
Adapters for Riverpod and AndroidX ViewModelthe app's own DI wiringthe app's composition root
// the three configuration interfaces (pseudocode)
SinewConfig(baseUrl, connectTimeout, readTimeout = 30s, writeTimeout = 60s, logHttp = false, anonymousPaths: Set<String>,
locale: () -> String) // sent as Accept-Language on every request, read per request

interface TokenSource {
accessToken(): String? // in memory
refresh(): RefreshOutcome // Refreshed | Rejected (session over) | Unavailable(cause) (offline or backend down: session kept)
clear() // sign-out
}

interface CrashReporter {
recordNonFatal(error: AppException, stackTrace)
companion: none // the default: records nothing
}

Wiring​

  • Plain constructors and factories. Every package exposes them, e.g. SinewHttp.client(config, tokenSource) or NetworkChecker(). No package registers itself in a DI container or reads a global, with one documented exception: the crash reporter, installed once at startup with CrashReporter.install(reporter) because guards are plain functions called from every repository (Error Model).
  • The app's composition root wires everything in the app's own DI: GetIt or Riverpod on Flutter, Koin (or manual) on Kotlin.
  • Adapters are optional. They only bundle that wiring for one framework. sinew_riverpod provides the notifier mixins and hooks, sinew_riverpod_providers the ready providers, and sinew-viewmodel the ViewModel base classes. Koin needs no adapter: the Kotlin note shows the ten lines of wiring.
// app composition root (pseudocode)
crashReporter = MyCrashReporter()
tokenSource = SessionTokenSource(secureStore)
mainClient = SinewHttp.client(SinewConfig(baseUrl = env.mainApi, anonymousPaths = {"/auth/login", "/auth/refresh"}), tokenSource)
mediaClient = SinewHttp.client(SinewConfig(baseUrl = env.mediaApi), tokenSource)
register(mainClient, name = "main")
register(mediaClient, name = "media")

Publishing and versions​

RuleDetail
Every package is published on its ownpub.dev: sinew_*. Maven Central: com.srctool.sinew:sinew-*.
Lockstep versionsAll packages are released together with the same version number, so any set of Sinew packages at one version fits together
A BOM on Kotlincom.srctool.sinew:sinew-bom lets an app write the version once. Flutter has no BOM; lockstep plays that role.
No umbrella packageIt would pull an HTTP client, a crypto library and secure storage into an app that only wants paging
Transitive dependenciesKotlin: Sinew types an app sees are api dependencies, so they arrive with the package. Flutter: pub resolves them, but an app adds a direct dependency on any package whose types it imports (Dart's depend_on_referenced_packages lint). In practice that's sinew_models, plus dio and retrofit for API clients.
The HTTP client is exposed on purposeAPI clients are written directly on Dio (Flutter) or Ktor (Kotlin). Hiding them would mean a second HTTP API to learn.
Native setup stays with the appKeychain entitlements, Android minSdk, biometric usage descriptions, backup rules. Each platform package note has a Platform setup checklist.

Platform specifics​

TopicKotlinFlutter
TargetsKotlin Multiplatform (commonMain): Android, iOS, JVM desktop, wasmJs. Usable from a plain Android app.Flutter's six platforms, where the underlying plugin supports them. On an unsupported platform a package compiles and throws UnsupportedError with a clear message; each package note has a Platforms row.
Resultskotlin.Result<T>Sinew's own sealed Result<T>
Sealed typeshand-written sealed classeshand-written Dart 3 sealed classes
Code generationnonenone in Sinew's code, except sinew_l10n, whose localizations are generated by gen-l10n and committed, so consumers never run a generator (an app may still use json_serializable and retrofit for its DTOs and clients)
Cancellationguards rethrow CancellationException, so coroutines cancel normallycancellation becomes CancelException, which view models and the pager ignore
HTTP clientKtorDio

3. API​

This note defines no classes of its own. The cross-package contracts it introduces are:

NameSignature (pseudocode)PackageFor
SinewConfigSinewConfig(baseUrl, connectTimeout, readTimeout = 30s, writeTimeout = 60s, logHttp = false, anonymousPaths, locale)networkper-client configuration
TokenSourceaccessToken(): String?, refresh(): RefreshOutcome, clear()networkwhere tokens live and how they refresh
CrashReporterrecordNonFatal(error: AppException, stackTrace), CrashReporter.noneexceptionwhere unexpected and parse errors are reported
SinewHttpSinewHttp.client(config, tokenSource, …) (Kotlin) / SinewHttp.dio(config, tokenSource, …) (Flutter)networkbuilding one configured client

Each package's own API is in its note under 02 - Packages/.

4. Build steps​

  1. Create the umbrella repo srctool/sinew with two submodules, sinew-kotlin and sinew-dart, plus Docusaurus docs.
  2. Create one empty module or package per v1 package, with the dependency edges from the graph above and nothing else.
  3. Add a CI check that fails on any dependency edge not in the graph (a module-graph assertion on Kotlin, a script over each pubspec.yaml on Flutter).
  4. Make every empty package compile on every target (Kotlin: Android, iOS, JVM, wasmJs. Flutter: the platforms each package supports).
  5. Publish nothing yet. Publishing is milestone S6.

Done when

  • Every v1 package exists on both platforms, compiles on every supported target, and depends only on the packages the graph allows.
  • CI fails if a package gains a dependency the graph doesn't allow (shown by adding one on purpose).
  • No package registers itself in a DI container or reads a global, except CrashReporter.install.

5. Edge cases​

CaseDecided behavior
An app takes only sinew_pagingIt gets sinew_models and sinew_exception with it, and nothing else. No HTTP client, no storage.
An app uses a DI framework Sinew has no adapter forIt calls the constructors and factories directly. Adapters only save typing.
Two Sinew versions in one dependency graphLockstep releases mean any one version is a consistent set. On Kotlin the BOM aligns them. On Flutter, version constraints with the same caret range keep them together.
A Flutter plugin's native code in release buildsFlutter links every plugin's native code into release builds, even when Dart never calls it. So any Sinew feature that needs a native plugin and is not needed in production (devtools EXIF, camera, map inspectors) lives in its own optional package.
An app with several backendsOne client per backend, each built from its own SinewConfig, registered under a name. They can share one TokenSource or use separate ones.
An app that already has its own result typeIt can use Sinew's packages that don't expose Result (presentation, security), or adapt at its data boundary. Mixing two result types inside one layer is not supported.

Implementation​

1. Why​

The base note fixes the package graph and the line between mechanism and configuration. On Kotlin that becomes two concrete questions:

  • which dependencies a consumer sees (api) and which stay hidden (implementation);
  • how an app wires Sinew into its own DI.

This note answers both, with the real Gradle and Koin code.

2. Shape​

api vs implementation​

A Sinew type an app sees in a public signature is an api dependency, so it arrives with the module. A library Sinew only uses inside is implementation, so it never leaks onto the app's classpath.

Moduleapiimplementation
sinew-exceptionsinew-models
sinew-pagingsinew-models, sinew-exceptioncoroutines
sinew-viewmodelsinew-presentation, sinew-models, lifecycle-viewmodellifecycle-runtime-compose
sinew-networksinew-exception, ktor-client-core (exposed on purpose: apps write API clients on it)engines, content-negotiation, serialization
sinew-storagesinew-exception, sinew-securityDataStore
sinew-securityTink, cryptography-kotlin, Biometric
sinew-camouflagesinew-paging, camouflage-core

An app's data module that returns PagingDomain or fails with an AppException declares sinew-paging or sinew-exception as api too, so its own consumers get them.

Wiring with Koin​

There's no Koin adapter package (base review). The wiring is about ten lines in the app's composition root:

val sinewModule = module {
single<TokenSource> { SessionTokenSource(store = SecureRefreshTokenStore(get()), call = AuthRefreshCall(get(named("refresh")))) } // Sinew's epoch-guarded source; the app supplies the call
single(named("main")) { SinewHttp.client(SinewConfig(baseUrl = Env.mainApi, locale = { currentLocaleTag() }, anonymousPaths = setOf("/auth/login", "/auth/refresh")), get()) }
single(named("media")) { SinewHttp.client(SinewConfig(baseUrl = Env.mediaApi, locale = { currentLocaleTag() }), get()) }
single { SecureStore.create(get()) } // platform context comes from a platformModule
single { KeyValueStore.create(get()) }
single { FieldCipher.create(get()) }
single { NetworkChecker.create(get()) }
}

fun initSinew(reporter: CrashReporter) = CrashReporter.install(reporter) // once, before startKoin

get() for the platform context: on Android the Context from androidContext(); on iOS, desktop and web a no-argument PlatformContext from a platformModule. Sinew's factories take a PlatformContext so the same line compiles in commonMain.

Platform context​

public expect class PlatformContext            // in sinew-models, the leaf
// androidMain: public actual class PlatformContext(public val context: Context)
// iosMain, jvmMain, wasmJsMain: public actual class PlatformContext() (no state)

It's the single expect class in the public API. Every factory that needs the platform takes it.

3. API​

NameKotlin signature
PlatformContextpublic expect class PlatformContext
SinewConfigpublic data class SinewConfig(val baseUrl: String, val connectTimeout: Duration = 15.seconds, val readTimeout: Duration = 30.seconds, val writeTimeout: Duration = 60.seconds, val logHttp: Boolean = false, val anonymousPaths: Set<String> = emptySet(), val locale: () -> String, val defaultHeaders: () -> Map<String, String> = { emptyMap() }, val errorBodyReader: ErrorBodyReader = ErrorBodyReader.Default)
TokenSourcepublic interface TokenSource { public fun accessToken(): String?; public suspend fun refresh(): RefreshOutcome; public suspend fun clear() }
CrashReporterpublic fun interface CrashReporter { public fun recordNonFatal(error: AppException) } + companion object { None; install(r); val current }

The stack trace is the exception's own on Kotlin, so recordNonFatal takes only the exception.

4. Build steps​

  1. PlatformContext in sinew-models, with its four actuals.
  2. The api/implementation split in every module's build file, from the table above.
  3. A sample Koin module in :sample:shared, wiring two clients, the stores and the cipher, with CrashReporter.install before startKoin.
  4. A test (./gradlew :sample:androidApp:dependencies) asserting that tink-android isn't on the app's compile classpath, while ktor-client-core is.

Done when

  • An app compiles against sinew-network and writes an API client on HttpClient without declaring Ktor itself.
  • Tink, DataStore and the engines never appear on an app's compile classpath.
  • The Koin sample runs on all four targets.

5. Edge cases​

CaseDecided behavior
An app uses Hilt instead of KoinIt calls the same factories from its Hilt modules. Nothing in Sinew depends on a DI framework.
An app without DIIt creates the objects once in its Application (or main) and passes them down.
Two Sinew versions resolved in one buildThe BOM aligns them. Without the BOM, Gradle's conflict resolution picks the highest, and lockstep releases keep that consistent.
A Kotlin-only (non-Compose) Android appIt can use every module except the four Compose ones. ObserveEffects is replaced by its own repeatOnLifecycle collection.
Decided by default (revisit during implementation)
  • PlatformContext in sinew-models as the one public expect class, so every factory has the same shape in commonMain.
  • CrashReporter.recordNonFatal(error) without a stack-trace parameter on Kotlin, because a Throwable carries its own.