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
| Kind | Base type | Shaped like | Visibility in the app | Example |
|---|---|---|---|---|
| Domain model | Domain | what the app needs, in business language | public | Order |
| Response DTO | Response<D : Domain> | the JSON the backend sends | private to its data module | OrderResponse |
| Local entity | Entity<D : Domain> | what's stored on the device | private to its data module | OrderEntity |
| Request DTO | none | the JSON the backend accepts | private to its data module | CreateOrderRequest |
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.
| Base | The app's subclass declares | toDomain() 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(): PagingMetaDomain | PagingDomain<D>: every item mapped, plus the app's meta |
VoidEnvelope | nothing beyond the interface | nothing (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.
| Kotlin | Flutter | |
|---|---|---|
| Type | kotlin.Result<T> from the standard library | Sinew's own Result<T>: Success(data, message) / Failure(error, stackTrace) |
| The failure's error | always an AppException, guaranteed by the guards | the same |
| User-facing text | rendered from the exception's message at display time (see Exception) | the same |
| Consuming | fold, onSuccess, onFailure, getOrThrow | when, 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.
| Situation | State |
|---|---|
| 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 screen | Loading(data: previous) |
Loaded (an empty list is still Done) | Done(data) |
| First load failed | Failed(error) |
| Reload of a single item failed with data on screen | Failed(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:
| Need | Shape |
|---|---|
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
| Rule | Why |
|---|---|
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 it | A 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 repository | Nothing 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 DTOs | The 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,ExceptionLayerandErrorMessage(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
| Name | Signature (pseudocode) | For |
|---|---|---|
Domain | abstract class Domain with value equality | marks 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 |
VoidEnvelope | abstract : Envelope<Unit> | base for payload-less envelopes |
PagingDomain<T> | (items = [], meta = PagingMetaDomain()), copy(items, meta) | a page of items |
PagingMetaDomain | fields above, copy(…) | page information |
PagingEntity<T> | (items, meta: PagingMetaEntity), toDomain(convert: (T) -> R): PagingDomain<R> | a stored page |
ViewState<T> | Loading | Done | Failed, dataOrNull, isLoading | page rendering |
AppException, ExceptionLayer, ErrorMessage | the base contract from the Error Model | so ViewState and Result can carry it |
EnvelopeDataMissing | a 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
Domainwith value equality,Response,Entity, and the error base contract (AppException,ExceptionLayer,EnvelopeDataMissing).Envelopeand the four abstract bases, with tests on a test-only subclass of each:- a full payload;
- missing optional keys;
errorspresent;- a subclass that relies on the interface defaults (no success flag).
PagingDomain,PagingMetaDomain,PagingEntity, with a test that aPagedEnvelopesubclass maps every item through its owntoDomain()and returns the subclass'smeta().ViewStateand its helpers, with tests fordataOrNullon each variant.- Flutter only:
Resultand its helpers.requireData()throws the failure'sAppException, so a use case can read results in a straight line. - 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.
-
ObjectEnvelopewithdata: nullthrowsEnvelopeDataMissing(and, through a guard, becomesParseException), never a crash. - The package has no dependencies and no code generation.
5. Edge cases
| Case | Decided behavior |
|---|---|
A 2xx response with success: false | The 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: null | toDomain() 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: null | Treated as an empty list. A missing list is not an error. |
| A paged response without its meta keys | The 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 value | The DTO's enum falls back to unknown. The domain decides what unknown means. |
| A new backend wrapper shape | The 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 flag | The 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 load | Done(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:
| Name | Kotlin |
|---|---|
Domain | public interface Domain (marker; domain models are data classes) |
Envelope | public interface Envelope<out R> with default getters |
| envelope bases | public 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, PlatformContext | defined here (the leaf), documented in Exception and Architecture |
4. Build steps
- The base types and envelope bases, with a test-only
@Serializablesubclass of each base. - Fixture tests in
commonTestwithJson { ignoreUnknownKeys = true; explicitNulls = false }:- a full payload;
- missing keys;
errorspresent;- a backend with no success flag;
- the records-shaped backend from the base note (
data.records,max_page).
- A test that decoding
ShopPage<OrderResponse, Order>never callsDomainNotParsed, and that calling it directly throws. 3a. The broken-envelope tests (Review Focus 3):data: nullon an object envelope throwsEnvelopeDataMissing(which the guard turns into a reportedParseException, tested in Network), and a DTO with an unknown enum value decodes toUnknownwithSinewJson. 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
DomainNotParsedstub.
5. Edge cases
| Case | Decided behavior |
|---|---|
| kotlinx.serialization needs a serializer for the domain type argument | Each 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 another | The DTO declares it as JsonPrimitive and converts in toDomain(). |
| Unknown keys | Ignored by the app's Json configuration (ignoreUnknownKeys = true), which SinewHttp sets as its default. |
- A
DomainNotParsedstub 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. ViewStateas a sealed interface withdata classvariants, sowhenis exhaustive and states compare by value.