When we open a large native project, one of the first places we look is the Core/Models folder. It almost always holds a User, a Product and, depending on the business, a Booking or an Order. The file is long, half the fields are optional, and everyone on the team appears in its git history. Our position is plain: these types should not be shared. Each feature should own the shape it needs, and whatever stays common should live in a shared kernel small enough to count on one hand. The rest of this article is the reasoning behind that position, the code, and how to get there in an existing project.
The running example is a hypothetical travel app: flight search, checkout, "my trips" and profile, in a codebase that four or five teams work on.
How Booking reaches sixty fields
Nobody designs a sixty-field model. In the first release Booking has eight fields and mirrors the backend's /bookings/{id} response. Then the search screen reuses it for the offer list, because it is "almost the same thing anyway". Checkout adds the tax breakdown, my trips adds the PNR and the boarding pass, the loyalty team adds points. A pattern we see often in large codebases:
// Core/Models/Booking.swift
public struct Booking: Codable, Equatable {
public var id: String
public var pnr: String?
public var status: String? // "CONFIRMED", "confirmed", sometimes "OK"
public var origin: String?
public var destination: String?
public var departure_date: String? // date only in search, date and time in detail
public var return_date: String?
public var pax_list: [Passenger]?
public var price: Double? // per person in search, total in checkout
public var total_price: Double?
public var currency: String?
public var taxes: [TaxLine]?
public var fare_rules: FareRules?
public var payment: PaymentInfo?
public var boarding_pass_url: String?
public var loyalty_points: Int?
public var is_refundable: Bool?
public var isSelected: Bool? // UI state, never sent by the server
public var isExpanded: Bool?
// ... forty more fields
}
Half the fields are optional for a structural reason, and carelessness has little to do with it. One type carries the responses of three endpoints: search returns a summary, detail returns everything, checkout returns the priced version. To decode all three, every field they do not have in common must be optional. The type can then no longer say which fields are guaranteed on which screen, and that knowledge spreads through the code:
// Features/Checkout/CheckoutSummaryView.swift
Text("\(booking.total_price ?? booking.price ?? 0, specifier: "%.2f") \(booking.currency ?? "TRY")")
That line has three separate problems. A checkout screen cannot exist without a total, yet the type does not say so. The meaning of price depends on the screen. And the backend's snake_case naming, string dates and inconsistent status values have travelled all the way to the view layer. The Android version looks the same: a @Serializable data class Booking in :core:model, every property ? = null, and !! or ?: "" on every screen.
The other half of the bill is paid at build time. Since everything imports Core, a single field added to Booking recompiles features that never read it. We cover how that cost grows in the real cost of modularization.
The module graph is clean, the coupling is in the type
Draw the dependency graph of one of these projects and you will usually find nothing to complain about:
Search ──┐
Checkout ─┼──▶ Core (Models, Networking)
Trips ───┤
Profile ─┘
Features do not import each other, there are no cycles, the arrows point one way. But Search and Checkout both read Booking.price, and each gives it a different meaning. The day the checkout team starts filling price with the tax-inclusive total, the search list shows wrong prices, and nothing fails to compile. A Search → Checkout dependency that the graph does not show has passed through the type.
In his Bounded Context article Martin Fowler uses the word polysemy for "Customer" and "Product": the same name means different things in different parts of the organisation. On mobile this is very concrete. To search, a flight is an offer whose price can change and which expires in half an hour. To checkout, it is a draft contract with a guaranteed price and named passengers. To my trips, it is a record with a PNR and an approaching check-in time. Calling all three Booking does not simplify the code. It only moves the difference out of the type and into if statements. Where to draw the line is the subject of our domain boundaries article; here we look at what that line means for the data model.
Two features that import the same type are as coupled as if they imported each other.
Each feature owns its shape
In the layout we recommend, search defines FlightOffer and checkout defines PricedItinerary. Both describe the same real-world thing, and neither can see the other's type. Only identity travels between them.
Swift
// Features/Search/Domain/FlightOffer.swift
import SharedKernel
struct FlightOffer: Identifiable, Hashable, Sendable {
let id: OfferID
let route: Route
let departure: Date
let arrival: Date
let stops: Int
let pricePerAdult: Money // indicative price
let seatsLeft: Int? // the backend does not always send it: genuinely optional
}
// Features/Checkout/Domain/PricedItinerary.swift
import SharedKernel
struct PricedItinerary: Sendable {
let offerID: OfferID
let passengers: [PassengerFare]
let taxes: [TaxLine]
let total: Money // tax-inclusive amount guaranteed by the backend
let priceGuaranteedUntil: Date
let refundPolicy: RefundPolicy
}
FlightOffer has one optional left, and that optional tells you something. In PricedItinerary, total is required, because there is no checkout screen without a total. When a search result is selected we do not hand a FlightOffer to Checkout. We hand it an OfferID, and Checkout builds its own model from its own endpoint. For the general question of what should travel between features, see global flow context.
Kotlin
// feature/search/domain/FlightOffer.kt
data class FlightOffer(
val id: OfferId,
val route: Route,
val departure: Instant,
val arrival: Instant,
val stops: Int,
val pricePerAdult: Money,
val seatsLeft: Int?,
)
// feature/checkout/domain/PricedItinerary.kt
data class PricedItinerary(
val offerId: OfferId,
val passengers: List<PassengerFare>,
val taxes: List<TaxLine>,
val total: Money,
val priceGuaranteedUntil: Instant,
val refundPolicy: RefundPolicy,
)
The objection we hear most is "but eighty percent of the fields are the same". Today they are. We ignore how similar the fields look today and ask why each type changes. The search model changes because of sorting, filters and campaign badges. The checkout model changes because of payment, tax and refund rules. When two types change for different reasons, looking alike today is a coincidence.
DTO, domain and UI model: when three layers is too many
We do not defend a layer count as a principle. Our default is two layers: a DTO in the data layer and one model inside the feature. Android's official architecture guide makes the same split: its data layer page recommends separating the network model (ArticleApiModel) from the model exposed to other layers, and its domain layer page calls that layer optional. That matches our practice.
We separate the DTO from the feature model almost every time. Even when your own team owns the backend, the shape of the JSON and the needs of the screen change at different speeds. The exception is a read-only screen backed by a screen-specific response (a BFF) with nothing to validate or transform. There we display the DTO directly and do not count it as debt.
We add the third layer, a separate UI model, only in these cases: the same domain model is rendered differently on more than one screen, the formatting (money, dates, plurals) is complicated enough to deserve tests, or the screen has state of its own such as selected, expanded or loading. Building a FlightOffer → FlightOfferUIModel → FlightOfferRowViewData chain for a five-line list cell means writing mappers for your mappers. If you see the same six fields in each of three layers, delete one.
Keeping mapping boring
The price of domain-specific models is mapping code, and that price is real. What makes it bearable is mappers that are dumb, short and in one place.
Our rules are these. The mapper lives next to the DTO, in the feature's data layer. It is a pure function: it does not hit the network, read the clock or take anything from DI. It does not quietly fill invalid data with defaults; it throws. Only domain types appear in the signatures the repository exposes.
Swift: DTO, mapper and test
// Features/Search/Data/FlightOfferDTO.swift
struct FlightOfferDTO: Decodable {
let offerId: String
let origin: String
let destination: String
let departureAt: Date
let arrivalAt: Date
let stopCount: Int
let price: PriceDTO
let seatsLeft: Int?
struct PriceDTO: Decodable {
let amountMinor: Int64
let currency: String
}
}
enum MappingError: Error, Equatable {
case invalid(field: String)
}
extension FlightOffer {
init(dto: FlightOfferDTO) throws {
guard dto.arrivalAt > dto.departureAt else { throw MappingError.invalid(field: "arrival_at") }
guard dto.stopCount >= 0 else { throw MappingError.invalid(field: "stop_count") }
self.init(
id: OfferID(dto.offerId),
route: Route(origin: dto.origin, destination: dto.destination),
departure: dto.departureAt,
arrival: dto.arrivalAt,
stops: dto.stopCount,
pricePerAdult: Money(minorUnits: dto.price.amountMinor, currencyCode: dto.price.currency),
seatsLeft: dto.seatsLeft
)
}
}
The DTO has no CodingKeys because the decoder is configured once in the data layer with keyDecodingStrategy = .convertFromSnakeCase and dateDecodingStrategy = .iso8601. The detail that matters is that the domain type is not Codable. The conformance the compiler synthesises uses property names as keys. Once FlightOffer is Codable, renaming a property becomes a change that breaks the wire format or the cache on disk. If you need to persist it, keep a separate record type for that in the data layer too.
One honest caveat: internal in Swift is scoped to the module. If Search is a single target, view code can see the DTO and only code review stops it. If you want the compiler to stop it, the data layer has to be its own target. We do not do that for small features; folder layout and the repository signature are enough.
Mapper tests are the cheapest tests you will write. A few JSON fixtures captured from real responses and Swift Testing will do (Fixtures is our own small helper that reads a file from the bundle and decodes it with the same decoder):
import Testing
@testable import SearchFeature
struct FlightOfferMappingTests {
@Test func mapsAValidOffer() throws {
let dto = try Fixtures.decode(FlightOfferDTO.self, from: "offer_valid.json")
let offer = try FlightOffer(dto: dto)
#expect(offer.pricePerAdult == Money(minorUnits: 124_990, currencyCode: "TRY"))
#expect(offer.stops == 0)
}
@Test func rejectsArrivalBeforeDeparture() throws {
let dto = try Fixtures.decode(FlightOfferDTO.self, from: "offer_arrival_before_departure.json")
#expect(throws: MappingError.invalid(field: "arrival_at")) {
try FlightOffer(dto: dto)
}
}
}
Kotlin: DTO, mapper and test
// feature/search/data/FlightOfferDto.kt
@Serializable
internal data class FlightOfferDto(
@SerialName("offer_id") val offerId: String,
val origin: String,
val destination: String,
@SerialName("departure_at") val departureAt: String,
@SerialName("arrival_at") val arrivalAt: String,
@SerialName("stop_count") val stopCount: Int,
val price: PriceDto,
@SerialName("seats_left") val seatsLeft: Int? = null,
)
@Serializable
internal data class PriceDto(
@SerialName("amount_minor") val amountMinor: Long,
val currency: String,
)
internal fun FlightOfferDto.toDomain(): FlightOffer {
val departure = OffsetDateTime.parse(departureAt).toInstant()
val arrival = OffsetDateTime.parse(arrivalAt).toInstant()
require(arrival > departure) { "arrival_at is before departure_at (offer $offerId)" }
require(stopCount >= 0) { "stop_count is negative (offer $offerId)" }
return FlightOffer(
id = OfferId(offerId),
route = Route(origin, destination),
departure = departure,
arrival = arrival,
stops = stopCount,
pricePerAdult = Money(price.amountMinor, price.currency),
seatsLeft = seatsLeft,
)
}
class FlightOfferMappingTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `maps a valid offer`() {
val dto = json.decodeFromString<FlightOfferDto>(fixture("offer_valid.json"))
assertEquals(Money(124_990, "TRY"), dto.toDomain().pricePerAdult)
}
@Test
fun `rejects arrival before departure`() {
val dto = json.decodeFromString<FlightOfferDto>(fixture("offer_arrival_before_departure.json"))
assertFailsWith<IllegalArgumentException> { dto.toDomain() }
}
}
Kotlin's internal is scoped to the Gradle module, so the same caveat applies: the compiler keeps the DTO from leaving the feature, and does nothing to keep the feature's own ViewModel away from it. The list of types that should not leave the data layer is short: @Serializable classes, anything carrying @SerialName, JsonElement, and the Json instance itself. By default kotlinx.serialization fails on an unknown key. If you do not want the backend adding a field to count as a breaking change, set ignoreUnknownKeys = true once, in the data layer. On the Swift side, synthesised Decodable conformance already ignores unknown keys.
What is fine to share: a small shared kernel
We are not saying "share nothing". If every feature writes its own Money, sooner or later someone adds cents with a Double. This is what DDD's Shared Kernel is for: a small, explicitly bounded subset that teams share on purpose and do not change without consulting each other.
Ours usually holds Money, typed identifiers (OfferID, BookingID, UserID), DateRange, and network or session-level error types. The admission rule is four questions, and all four need a yes:
- Does it mean the same thing and carry the same rules in every feature?
- Is the field set fixed? Are we sure nobody will ask to "just add one more thing" to it?
- Can it be written with no dependency beyond the standard library?
- Do at least three features use it today?
Booking fails the first question. Money passes all four.
Swift
// SharedKernel/Money.swift
public struct OfferID: Hashable, Sendable {
public let rawValue: String
public init(_ rawValue: String) { self.rawValue = rawValue }
}
public struct Money: Hashable, Sendable {
public let minorUnits: Int64 // cents, kuruş
public let currencyCode: String // ISO 4217
public init(minorUnits: Int64, currencyCode: String) {
self.minorUnits = minorUnits
self.currencyCode = currencyCode
}
public func adding(_ other: Money) throws -> Money {
guard currencyCode == other.currencyCode else { throw MoneyError.currencyMismatch }
return Money(minorUnits: minorUnits + other.minorUnits, currencyCode: currencyCode)
}
}
public enum MoneyError: Error { case currencyMismatch }
Kotlin
// core/kernel/Money.kt
@JvmInline
value class OfferId(val value: String)
data class Money(val minorUnits: Long, val currencyCode: String) {
init { require(currencyCode.length == 3) { "Expected an ISO 4217 code: $currencyCode" } }
operator fun plus(other: Money): Money {
require(currencyCode == other.currencyCode) { "Currency mismatch" }
return copy(minorUnits = Math.addExact(minorUnits, other.minorUnits))
}
}
Typed identifiers are the cheapest win in all of this: with cancel(bookingId: String, userId: String), swapping the arguments becomes a compiler error. In Kotlin a value class is compiled to its underlying type on the JVM wherever possible, so most calls allocate nothing extra. Two things to watch: functions that take a value class get mangled names on the JVM and need @JvmName if Java has to call them. And we do not put @Serializable or Codable on kernel types. The DTO holds a String and the mapper wraps it in an OfferId. That keeps the kernel free of the serialization library, and question three stays true.
For error types we draw the line like this: "no connection", "session expired" and "server error", which every feature handles the same way, live in the kernel. "Price guarantee expired" is checkout's error and stays there.
When the backend changes, one place changes
Say the backend used to send the price as a plain number and v2 turns it into an object:
{ "price": 1249.90, "currency": "TRY" }
{ "price": { "amount_minor": 124990, "currency": "TRY" } }
With a god-model this change touches every screen that reads booking.price. With feature-owned models the DTO and the mapper change. FlightOffer.pricePerAdult is still a Money, and views and view models do not even need to recompile. If you have to support both versions during the transition, that also stays in the data layer. PriceFieldDTO below is a two-case enum whose init(from:) tries the object first and falls back to the number:
extension Money {
init(dto: PriceFieldDTO, fallbackCurrency: String?) throws {
switch dto {
case .v2(let price):
self.init(minorUnits: price.amountMinor, currencyCode: price.currency)
case .v1(let major):
guard let currency = fallbackCurrency else { throw MappingError.invalid(field: "currency") }
// v1 only ever handled two-decimal currencies
self.init(minorUnits: Int64((major * 100).rounded()), currencyCode: currency)
}
}
}
The same place absorbs the backend's inconsistencies. Do not decode status as an enum in the DTO: with default settings, both Swift's raw-value enums and kotlinx.serialization fail the decode on an unknown value, and a new state added by the backend takes down the whole list. Keeping a String in the DTO and deciding in the mapper holds up better:
internal fun String.toTripStatus(): TripStatus = when (lowercase()) {
"confirmed", "ok" -> TripStatus.Confirmed
"cancelled", "canceled" -> TripStatus.Cancelled
"pending" -> TripStatus.AwaitingPayment
else -> TripStatus.Unknown(raw = this)
}
The knowledge that "OK" and "confirmed" are the same thing now sits in one file with a test. Before, it lived as four different if statements on four screens.
Splitting a god-model one consumer at a time
In an existing project, do not try to delete Booking in one sprint. This is the order we follow.
First find out who reads what. The most reliable method is primitive: rename a field temporarily, build, and note the failing files by feature. For sixty fields that takes an afternoon and gives you a table of consumers per field. You will usually find that a good share of the fields are read by a single feature, and some by nobody.
Then freeze the model. No new fields, and the type is deprecated:
// core/model/Booking.kt
@Deprecated("Do not add fields. Move to a feature-owned model: docs/models.md")
@Serializable
data class Booking(/* ... */)
The Swift equivalent is @available(*, deprecated, message: "..."). The warnings are noisy, which is what we want at this stage.
Third, pick the consumer with the narrowest usage and give it its own model, without touching networking yet. The new model is built from the old one:
// Features/Trips/Data/Trip+Legacy.swift
extension Trip {
init(legacy booking: Booking) throws {
guard let pnr = booking.pnr else { throw MappingError.invalid(field: "pnr") }
guard let departure = booking.departure_date.flatMap(LegacyDates.parse) else {
throw MappingError.invalid(field: "departure_date")
}
self.init(
id: BookingID(booking.id),
pnr: pnr,
departure: departure,
status: TripStatus(raw: booking.status ?? "")
)
}
}
This adapter is ugly and temporary. Its value is that every ?? and force unwrap in the feature collapses into one file. From then on the rest of the feature works with Trip, and the number of files importing Booking drops to one.
Fourth, the feature gets its own DTO and its own endpoint call, and the adapter is deleted. At that point the feature has no tie to Booking. Then you move to the next consumer. Each time a consumer leaves, delete from the god-model the fields only it was reading. The compiler tells you whether you were wrong. The few fields left at the end are either common enough for the kernel or dead fields nobody reads.
We go consumer by consumer because each step ships on its own. A migration abandoned halfway is no worse than one never started: two features are out, the rest work as before.
How to tell it worked
We will not give you a percentage here. These are things you can measure in your own project.
- Do the field count of the god-model and the number of files importing it drop every sprint? Both are a one-line script.
- When you add a field to a feature model and run an incremental build, which modules recompile? Look at Xcode's build timeline or the list of tasks Gradle executed, before and after the migration. The goal is that the change does not spill outside its own feature.
- Is the count of
??,!!and force unwraps in feature code going down? Do the remaining optionals really mean "may be absent"? - When a backend field changes, how many feature folders does the PR touch? The target is one.
- Did test fixtures shrink? Building a six-field
FlightOfferin place of a sixty-parameterBooking.mock()shows up in test readability at once.
Where it is not worth it
With one team, ten screens and a backend that returns screen-specific responses, a mapping layer makes work for you and buys nothing. Stay with a single model layer and split when the symptoms above appear. The usual symptom: a second team wants to add a field to the same type and says it has to be optional.
Decision table
| Type | Verdict | Why |
|---|---|---|
| Backend response types (DTOs) | Feature-owned | Stay in the data layer, never appear in a repository signature |
"Entities" such as Booking, User, Product |
Feature-owned | Each feature defines its own projection; identity travels between them |
Typed identifiers (BookingID, UserID) |
Shared kernel | Same meaning everywhere, close to zero change |
Money, DateRange |
Shared kernel | Universal rules, expensive to get wrong |
| Network and session errors | Shared kernel | Every feature handles them the same way |
| Feature-specific errors (price expired) | Feature-owned | Nobody else can do anything meaningful with them |
| UI models, view state | Feature-owned | Change with the screen; there is no reason to share |
| Signed-in user's identity and display name | Share | Owned by the session module, exposed through a narrow interface, not kept in Models |
| Input types of design system components | Share | They are the design system module's public API |
Enums such as BookingStatus |
Feature-owned | The set of states differs per feature; move to the kernel only once the meaning has proved identical |
| Two feature models that look identical today | Feature-owned | Duplicate them. Revisit if changes arrive together three times |
| Records stored for the disk cache | Feature-owned | Like the wire format, an internal matter of the data layer |
Need support on this?
Let's review together whether these controls are set up correctly in your app. We're here with security audits and advisory.
Sources
- Martin Fowler · Bounded Context
- Domain Language · DDD Reference
- Android Developers · Data layer
- Android Developers · Domain layer
- Kotlin · Inline value classes
- Kotlin · Visibility modifiers
- kotlinx.serialization · JSON features
- Apple · Encoding and decoding custom types
- Apple · JSONDecoder.KeyDecodingStrategy
- Apple · Swift Testing
Comments and questions
Disagree with something, or stuck on this in your own project? Write to us and let's talk.