Here’s a pseudo-code-y example of a board:
@DatabaseTable(tableName = "boards") data class Board( @DatabaseField(columnName = “name”) @SerializedName("BoardName") private var boardName: String?, private var cards: List<Card>?, private var lastViewedTime: Long? )
While it was allocation efficient and somewhat convenient to have all this data in one place, more often it was just a pain in the ass:
Availability sadness - Any given field might only be available some of the time. For example,
Board.cards is returned by the API, but a DB query would not return that field (since it was stored in a separate table). Conversely, we might store some local state in the DB (e.g., when the board was last viewed in the client via
Board.lastViewedTime) and that data wouldn’t be present in API responses.
In both cases, it made figuring out what data was actually available in any given place a minefield. Sure, you could do a null-check, but what if you really needed the data? How can you guarantee it’s available? It was hard to reason about the code.
On top of that, it was a constant source of bugs while making Trello Android work offline. Whenever we switched from API calls to DB queries, it would change which fields would be populated, causing issues.
Mutability sadness - These models were highly mutable because of OrmLite - even if we didn’t mutate them, OrmLite might when reusing objects via caching. A piece of the UI might query the DB for a
Board, use it once, then later use that exact same
Board instance again only to find that the
Board’s data changed in secret.
Nullability sadness - Trello’s API lets you choose which fields to return for any given query. As a result, we typically needed all fields to be nullable. When we switched to using Kotlin, we were constantly having to use
!! or null-checks when we knew data was present but couldn’t prove it to the compiler.
Naming sadness - A given field could have up to three different names - one for how it looks in the API, one for the column name in the DB, and one for the actual property in the model. This caused confusion, especially when writing generic logic based on the names of API or DB fields.
Bloat sadness - Perhaps some part of the UI only needs the board’s name - too bad! You’re getting a dozen other fields with it. A relatively minor problem compared to the others, but it always felt problematic to provide so much information to the UI that it wasn’t using. For one thing, it made testing more complex, since how could you guarantee that this random field wasn’t being used when it was part of the input?
There’s one root cause of all these problems: overloading the data model. Whenever we gave a field multiple roles at once, it caused sadness. Each layer - the API, the DB, and the UI - all had different needs, and there was no one-size-fits-all solution.
A Better Solution
What do you do if you’ve got one class with too many roles? You split it up!
Rather than one single OmniModel, we’d instead create (at least) three: an ApiModel, a DbModel, and a UiModel. Each would be tailored to its domain, which is why I call them domain-specific models*.
Let’s go through each in turn:
ApiModels match the API schema. They are immutable (because they’re just used for one-time communication) and nullable (since we might request a subset of fields).
DbModels match the DB schema. They are mutable (because of OrmLite) but provide more non-null property guarantees. (In an ideal, non-ORM world, they would be immutable as well.)
UiModels match whatever the UI needs. They are immutable and their properties are generally non-null.
Between each of these models are converters. We send a network request to the server and get back an ApiModel; that’s converted into a DbModel for insertion into the database. Then the UI queries the database, which converts the DbModel into whatever UiModels the UI requires.
If this all seems like a lot more work… that’s because it is! However, I’ve found that the high down payment is well worth the long-term low interest rate. While there’s a lot more code (especially converting from one model to another), it’s overall much simpler to grok and use.
Also, it solves all of the problems outlined above: all data is available in each model, immutability can be achieved where it really counts, we can make more non-null guarantees, there’s only one name per property per model, and the UiModels can be limited to only the data needed and nothing more.
There is one key trade-off you’re making here: domain-specific models are not as efficient as all-in-one models. They allocate more objects and add work when converting between the types. In practice I did not find this to be an issue, but of course your situation may be different, so make sure to measure and verify that this solution doesn’t bog down your application.
In short: if you’re ever in a situation where a data model seems to be doing too much at once, try splitting it up into its separate domains. You’d be surprised how much of a win it can be.
* It’s possible there’s a more formal name for this concept that already exists, let me know!
Many thanks to Zac Sweers for reviewing this post!