Skip to main content

01 - Models

Concept​

1. Why​

Every layer needs a few shared shapes: what a domain model is, what an API payload is, how a page of items looks, how a result or a loading state is represented. Without shared shapes, each app invents its own. Each repository then writes a mapper function per endpoint, and each paged list maps its items by hand.

sinew_models holds those shapes and nothing else. It depends on no other package. Its central idea is the self-mapping envelope:

  • A backend wraps its payloads in a few recurring shapes: one object, a list, a page. Each shape is modelled once per backend, generically.
  • Each envelope names the DTO type it parses and the domain type that DTO maps to. The compiler then knows every item has a toDomain(), so the envelope maps its payload, including every item of a list or page.
  • One envelope per shape replaces a mapping function per endpoint.

Backends disagree on those shapes, and one app can talk to several. A single backend can even have three different paged shapes. So Sinew ships no concrete envelope with fixed JSON keys. It ships abstract envelope bases that do the mapping, and each app declares its own envelopes: the fields, the JSON keys and how its page metadata reads.

2. Shape​

Four kinds of model​

KindBase typeShaped likeVisibility in the appExample
Domain modelDomainwhat the app needs, in business languagepublicOrder
Response DTOResponse<D : Domain>the JSON the backend sendsprivate to its data moduleOrderResponse
Local entityEntity<D : Domain>what's stored on the deviceprivate to its data moduleOrderEntity
Request DTOnonethe JSON the backend acceptsprivate to its data moduleCreateOrderRequest

The mapper lives on the DTO (toDomain()), because the DTO is private and exists only to be mapped. The envelope calls it, so nothing else does.

Envelopes​

Every envelope implements one interface, so one guard processes any of them:

interface Envelope<R> {
success: Boolean = true // a backend without a success flag keeps the default
message: String = ""
errors: Map<String, List<String>>? = null // field → messages, for validation failures
toDomain(): R
}

Sinew provides four abstract bases on top of it. Each implements toDomain() once; the app's subclass only declares fields.

BaseThe app's subclass declarestoDomain() returns
ObjectEnvelope<T : Response<D>, D : Domain>data: T?D (throws EnvelopeDataMissing when data is null)
ListEnvelope<T : Response<D>, D : Domain>items: List<T>?List<D> (null → empty)
PagedEnvelope<T : Response<D>, D : Domain>items: List<T>? and meta(): PagingMetaDomainPagingDomain<D>: every item mapped, plus the app's meta
VoidEnvelopenothing beyond the interfacenothing (Unit / void)

Two type parameters, on purpose. ObjectEnvelope<T : Response<D>, D : Domain> names both the DTO and the domain type. The bound tells the compiler that T.toDomain() returns D. That's what lets PagedEnvelope map every item without a mapper argument.

meta() is where the app reads its own page keys. One backend says total_page, another max_page, a third nests it under metadata.pagination. Each app envelope turns its keys into the one PagingMetaDomain shape.

Example 1: a backend with a success flag and a meta object​
// JSON: { "success": true, "message": "", "data": [ … ], "meta": { "page": 1, "limit": 20, "total": 95, "totalPage": 5, "hasNextPage": true } }

class PageMeta(page?, limit?, total?, totalPage?, hasNextPage?) // the app's meta DTO, every key optional

class ShopPage<T : Response<D>, D : Domain>(
success = true, message = "", errors = null,
@key("data") items: List<T>? = null,
@key("meta") pageMeta: PageMeta? = null,
) : PagedEnvelope<T, D>() {
meta() = PagingMetaDomain(
page = pageMeta?.page ?: 1, limit = pageMeta?.limit ?: 0, total = pageMeta?.total ?: 0,
totalPage = pageMeta?.totalPage ?: 0, hasNextPage = pageMeta?.hasNextPage ?: false,
)
}

class ShopObject<T : Response<D>, D : Domain>(success = true, message = "", errors = null, @key("data") data: T? = null) : ObjectEnvelope<T, D>()
Example 2: a backend with no success flag, records nested in data​
// JSON: { "data": { "records": [ … ], "max_page": 5, "total": 95, "page_size": 20, "current_page": 1 } }

class RecordsBody<T>(@key("records") records: List<T>?, @key("max_page") maxPage?, @key("total") total?,
@key("page_size") pageSize?, @key("current_page") page?)

class RecordsPage<T : Response<D>, D : Domain>(@key("data") body: RecordsBody<T>? = null) : PagedEnvelope<T, D>() {
items = body?.records
meta() = PagingMetaDomain(
page = body?.page ?: 1, limit = body?.pageSize ?: 0, total = body?.total ?: 0, totalPage = body?.maxPage ?: 0,
hasNextPage = (body?.page ?: 1) < (body?.maxPage ?: 0),
)
}

success, message and errors keep their interface defaults here: this backend signals failure with HTTP status codes only, which the network guard already handles.

Paging shapes​

PagingDomain<T>(items: List<T> = [], meta: PagingMetaDomain = PagingMetaDomain())

PagingMetaDomain(
page = 0, // the page this response is
limit = 0, // items per page
total = 0, // items across all pages, when the backend knows
totalPage = 0, // pages, when the backend knows
hasNextPage = false,
hasPreviousPage = false,
nextKey = "" // cursor for the next page; "" = none
)

One shape for every backend, whatever its meta fields are called. Paging can be automated in the presentation layer because of it: the pager computes the next page or cursor from PagingDomain.meta and merges pages.

PagingEntity<T> is the storage counterpart: a page of stored items plus PagingMetaEntity. It maps with toDomain(convert), because stored items aren't necessarily Entity subclasses.

Results and view states​

Result<T> is what every guard, repository and use case returns.

KotlinFlutter
Typekotlin.Result<T> from the standard librarySinew's own Result<T>: Success(data, message) / Failure(error, stackTrace)
The failure's erroralways an AppException, guaranteed by the guardsthe same
User-facing textrendered from the exception's message at display time (see Exception)the same
Consumingfold, onSuccess, onFailure, getOrThrowwhen, fold, transform, requireData

ViewState<T> is what a page renders.

ViewState<T> =
Loading(data: T? = null)
| Done(data: T, message = "")
| Failed(data: T? = null, error: AppException, stackTrace = null)

Loading and Failed can still carry the previous data, so a reload or a failed reload doesn't blank the screen. Read the renderable data with dataOrNull.

Failed keeps the AppException, never pre-rendered text: the page renders the message every time it builds, so a language switch updates an error already on screen. Done(message) is a success message from the backend, shown as it arrives.

SituationState
A slice loaded when the page opens (the initial state)Loading(): the first frame is already a skeleton
Several slices on one page (a home page)each starts Loading() and loads in parallel, so each section appears as soon as its data arrives
Reload of a single item with data on screenLoading(data: previous)
Loaded (an empty list is still Done)Done(data)
First load failedFailed(error)
Reload of a single item failed with data on screenFailed(data: previous, error)

A paged list has its own rules (refresh drops the data, see Paging).

There's no Idle state. State is for rendering, and rendering a state again is always safe. One-time reactions (a snackbar, a dialog, navigation) are effects, never reactions to a state change (see Presentation). So nothing needs a "reset to idle" after a success or a failure. The two cases that look like they need one have their own shapes:

NeedShape
An action at rest (cancelling, submitting)a Boolean that drives the button's spinner; the outcome is an effect
A slice loaded on demand, not requested yet (a details section, search results before typing)a nullable slice, ViewState<T>?: null means not requested

Without Idle, ViewState maps one-to-one onto Riverpod's AsyncValue (AsyncLoading, AsyncData, AsyncError).

DTO rules​

RuleWhy
DTO fields are nullable or have defaults. Domain fields are as strict as the business allows. toDomain() decides the fallback.The backend is outside the app's control. A missing field should degrade one value, not fail a whole list.
Enums get an unknown value, and parsing falls back to itA status the backend adds later doesn't break older app versions
Dates, money and IDs become real types in toDomain() (an instant in UTC, a money value object)Domain code never parses strings
DTO field names match the JSON. Domain names match the business.The DTO documents the wire format; the domain speaks the app's language.
Request DTOs contain only the fields the client may set, and are built in the repositoryNothing server-controlled is sent by mistake, and callers never see request shapes
One DTO per JSON shape. A response DTO is never reused as a request body.The two contracts change separately
Stored data uses entities, never response DTOsThe storage schema and the API schema change for different reasons

What lives here from the error model​

ViewState and Result carry an AppException, and sinew_exception depends on this package. So the base contract of the error model lives here, in the leaf:

  • AppException, ExceptionLayer and ErrorMessage (who provides the user-facing text, see Exception);
  • EnvelopeDataMissing, a plain error an envelope throws when its payload is absent.

The concrete exception types, the handlers and the guards live in Exception. Its handlers map EnvelopeDataMissing to ParseException with the guard's module and function. The envelope itself doesn't know them.

No code generation​

Result, ViewState and the envelope base types are hand-written sealed classes on both platforms. Sinew needs no code generator. An app may still generate its own DTO parsing (kotlinx.serialization, json_serializable).

3. API​

NameSignature (pseudocode)For
Domainabstract class Domain with value equalitymarks domain models
Response<D>abstract class Response<D : Domain> { toDomain(): D }API DTOs
Entity<D>abstract class Entity<D : Domain> { toDomain(): D }stored rows
Envelope<R>interface { success = true, message = "", errors = null, toDomain(): R }anything a guard can process
ObjectEnvelope<T, D>abstract { abstract data: T?; toDomain(): D }base for one-object envelopes
ListEnvelope<T, D>abstract { abstract items: List<T>?; toDomain(): List<D> }base for list envelopes
PagedEnvelope<T, D>abstract { abstract items: List<T>?; abstract meta(): PagingMetaDomain; toDomain(): PagingDomain<D> }base for paged envelopes
VoidEnvelopeabstract : Envelope<Unit>base for payload-less envelopes
PagingDomain<T>(items = [], meta = PagingMetaDomain()), copy(items, meta)a page of items
PagingMetaDomainfields above, copy(…)page information
PagingEntity<T>(items, meta: PagingMetaEntity), toDomain(convert: (T) -> R): PagingDomain<R>a stored page
ViewState<T>Loading | Done | Failed, dataOrNull, isLoadingpage rendering
AppException, ExceptionLayer, ErrorMessagethe base contract from the Error Modelso ViewState and Result can carry it
EnvelopeDataMissinga plain error (not an AppException)thrown by an envelope with no payload. The guard maps it to ParseException.
Result<T> (Flutter)Success(data, message = "") | Failure(error: AppException, stackTrace?), when, fold, transform, requireData()operation outcome

ObjectEnvelope.toDomain() with data == null throws EnvelopeDataMissing. The guard's handler turns that into ParseException with its module and function, returns it as a failed Result, and reports it.

4. Build steps​

  1. Domain with value equality, Response, Entity, and the error base contract (AppException, ExceptionLayer, EnvelopeDataMissing).
  2. Envelope and the four abstract bases, with tests on a test-only subclass of each:
    • a full payload;
    • missing optional keys;
    • errors present;
    • a subclass that relies on the interface defaults (no success flag).
  3. PagingDomain, PagingMetaDomain, PagingEntity, with a test that a PagedEnvelope subclass maps every item through its own toDomain() and returns the subclass's meta().
  4. ViewState and its helpers, with tests for dataOrNull on each variant.
  5. Flutter only: Result and its helpers. requireData() throws the failure's AppException, so a use case can read results in a straight line.
  6. Write both examples from §2 as test fixtures. They double as the documentation's worked examples.

Done when

  • Each abstract base, through a test subclass, maps its fixture to the right domain type.
  • A paged envelope maps every item and its meta without a mapper argument.
  • ObjectEnvelope with data: null throws EnvelopeDataMissing (and, through a guard, becomes ParseException), never a crash.
  • The package has no dependencies and no code generation.

5. Edge cases​

CaseDecided behavior
A 2xx response with success: falseThe envelope parses normally. The guard sees success == false and returns ApiErrorException with the backend's message (ValidationException when errors is present).
An object envelope with data: nulltoDomain() throws EnvelopeDataMissing. The guard maps it to ParseException, then reports and returns it. An endpoint where "no data" is a normal answer uses the optional-read guard instead (404 → null, see Network).
A list or paged response with data: nullTreated as an empty list. A missing list is not an error.
A paged response without its meta keysThe app's meta() falls back to its defaults. With hasNextPage = false the list shows one page and stops, so an envelope whose backend omits meta on the last page should compute hasNextPage from what it has (for example page < totalPage).
An unknown enum valueThe DTO's enum falls back to unknown. The domain decides what unknown means.
A new backend wrapper shapeThe app writes one envelope per shape on the abstract bases, or implements Envelope<R> directly for anything unusual. Nothing else changes.
A backend with no success flagThe app envelope keeps the interface defaults (success = true). Failures come from HTTP status codes, handled by the network guard.
An empty list on a first loadDone(data: []), not Failed. The UI decides how to show "empty".

Implementation​

1. Why​

The base note defines the shapes every layer shares. On Kotlin, the envelopes are where most of the care goes:

  • kotlinx.serialization has to parse generic envelopes whose type parameters are bounded by Response<D>;
  • an abstract base class's abstract properties have to be serialized from the app's subclass.

This note gives the real types and shows an app envelope end to end.

2. Shape​

Base types​

public interface Domain                                   // a marker: domain models are data classes

public abstract class Response<out D : Domain> { public abstract fun toDomain(): D }
public abstract class Entity<out D : Domain> { public abstract fun toDomain(): D }

public interface Envelope<out R> {
public val success: Boolean get() = true
public val message: String get() = ""
public val errors: Map<String, List<String>>? get() = null
public fun toDomain(): R
}

public abstract class ObjectEnvelope<out T : Response<D>, D : Domain> : Envelope<D> {
public abstract val data: T?
override fun toDomain(): D = data?.toDomain() ?: throw EnvelopeDataMissing()
}

public abstract class ListEnvelope<out T : Response<D>, D : Domain> : Envelope<List<D>> {
public abstract val items: List<T>?
override fun toDomain(): List<D> = items.orEmpty().map { it.toDomain() }
}

public abstract class PagedEnvelope<out T : Response<D>, D : Domain> : Envelope<PagingDomain<D>> {
public abstract val items: List<T>?
public abstract fun meta(): PagingMetaDomain
override fun toDomain(): PagingDomain<D> = PagingDomain(items.orEmpty().map { it.toDomain() }, meta())
}

public abstract class VoidEnvelope : Envelope<Unit> { override fun toDomain() {} }

public class EnvelopeDataMissing : IllegalStateException("Envelope has no data")

The bases aren't @Serializable. The app's subclass is, and it overrides the abstract properties with constructor properties, which kotlinx.serialization serializes normally.

An app envelope​

@Serializable
internal data class ShopPage<T : Response<D>, D : Domain>(
@SerialName("success") override val success: Boolean = true,
@SerialName("message") override val message: String = "",
@SerialName("errors") override val errors: Map<String, List<String>>? = null,
@SerialName("data") override val items: List<T>? = null,
@SerialName("meta") val pageMeta: PageMeta? = null,
) : PagedEnvelope<T, D>() {
override fun meta(): PagingMetaDomain = PagingMetaDomain(
page = pageMeta?.page ?: 1, limit = pageMeta?.limit ?: 0, total = pageMeta?.total ?: 0,
totalPage = pageMeta?.totalPage ?: 0, hasNextPage = pageMeta?.hasNextPage ?: false,
)
}

// the API client: Ktor decodes the generic envelope from the reified type
internal class OrderApiImpl(private val client: HttpClient) : OrderApi {
override suspend fun getOrders(page: Int, query: String): ShopPage<OrderResponse, Order> =
client.get("api/orders") { parameter("page", page); parameter("search", query) }.body()
}

body<ShopPage<OrderResponse, Order>>() works because Ktor's content negotiation resolves the serializer from the full reified type, including both type arguments. The domain type Order is never parsed, but kotlinx.serialization still needs a serializer for each type argument of a generic class. So each domain model gets a stub serializer that fails if it's ever used:

@Serializable(with = Order.Unparsed::class)
public data class Order(…) : Domain {
internal object Unparsed : DomainNotParsed<Order>("Order")
}
// sinew-models: public abstract class DomainNotParsed<D>(name: String) : KSerializer<D> // throws if called

Paging shapes, ViewState, results​

public data class PagingDomain<out T>(val items: List<T> = emptyList(), val meta: PagingMetaDomain = PagingMetaDomain())
public data class PagingMetaDomain(val page: Int = 0, val limit: Int = 0, val total: Int = 0, val totalPage: Int = 0,
val hasNextPage: Boolean = false, val hasPreviousPage: Boolean = false, val nextKey: String = "")
public data class PagingEntity<out T>(val items: List<T> = emptyList(), val meta: PagingMetaEntity = PagingMetaEntity()) {
public fun <R> toDomain(convert: (T) -> R): PagingDomain<R>
}

public sealed interface ViewState<out T> {
public data class Loading<out T>(val data: T? = null) : ViewState<T>
public data class Done<out T>(val data: T, val message: String = "") : ViewState<T>
public data class Failed<out T>(val data: T? = null, val error: AppException) : ViewState<T>
}
public val <T> ViewState<T>.dataOrNull: T?
public val ViewState<*>.isLoading: Boolean

Results use kotlin.Result<T> (see Error Model).

3. API​

The table in the base note, with these Kotlin specifics:

NameKotlin
Domainpublic interface Domain (marker; domain models are data classes)
Envelopepublic interface Envelope<out R> with default getters
envelope basespublic abstract class ObjectEnvelope / ListEnvelope / PagedEnvelope / VoidEnvelope
DomainNotParsed<D>a KSerializer<D> that throws, for the domain type argument of generic envelopes
ViewState<T>public sealed interface with Loading, Done, Failed
AppException, ExceptionLayer, ErrorMessage, PlatformContextdefined here (the leaf), documented in Exception and Architecture

4. Build steps​

  1. The base types and envelope bases, with a test-only @Serializable subclass of each base.
  2. Fixture tests in commonTest with Json { ignoreUnknownKeys = true; explicitNulls = false }:
    • a full payload;
    • missing keys;
    • errors present;
    • a backend with no success flag;
    • the records-shaped backend from the base note (data.records, max_page).
  3. A test that decoding ShopPage<OrderResponse, Order> never calls DomainNotParsed, and that calling it directly throws. 3a. The broken-envelope tests (Review Focus 3): data: null on an object envelope throws EnvelopeDataMissing (which the guard turns into a reported ParseException, tested in Network), and a DTO with an unknown enum value decodes to Unknown with SinewJson.
  4. ViewState, dataOrNull, isLoading, with tests.

Done when

  • Both worked envelopes from the base note decode their fixtures on all four targets.
  • A domain model's only serialization trace is its DomainNotParsed stub.

5. Edge cases​

CaseDecided behavior
kotlinx.serialization needs a serializer for the domain type argumentEach domain model declares a DomainNotParsed stub. It's the one serialization trace in the domain, and it throws if ever called.
A field that's a JSON number on one endpoint and a string on anotherThe DTO declares it as JsonPrimitive and converts in toDomain().
Unknown keysIgnored by the app's Json configuration (ignoreUnknownKeys = true), which SinewHttp sets as its default.
Decided by default (revisit during implementation)
  • A DomainNotParsed stub serializer per domain model, the Kotlin counterpart of the Retrofit stub on Flutter. The alternative is envelopes with only the DTO type parameter, plus a mapping argument, which the base decision rejected.
  • ViewState as a sealed interface with data class variants, so when is exhaustive and states compare by value.