Blog/A first-principles approach

A first-principles approach to your data layer

Take the most concise thing you can write to work with a database table, then follow where it leads. Nothing gets added along the way. That one small definition keeps turning out to be the answer to the next problem, and the next, until it adds up to a whole data layer.

September 30, 2025Design4 min read

The simplest thing you can write

Start at the bottom. The database owns the data model; the application just needs a concise, typed way to work with it. So what is the least you can write to make a database row usable in application code? Only its shape: a typed, named set of fields. In a modern language that is a record or a data class, and there is a principle hiding in how little that is.

City.kt Kotlin
1
2
3
4
5
data class City(
    @PK val id: Int,
    val name: String,
    val country: String
)

The names come from convention, and Kotlin nullability says which values may be absent in application code. That is the entire application-facing shape. The first principle is to keep it exactly this small: nothing more than the data, with no hidden lifecycle, behavior, or framework state attached. Everything that follows, follows from refusing to add to it.

It happens to be the perfect carrier

Here is the first thing you get for free. That most concise form, a plain data class, happens to be the ideal way to carry the data around. Nothing is attached to it, so it moves through every layer untouched: out of the repository, into your services, through the controller, out to a serializer, across a thread boundary, and back. You did not design a carrier. You wrote the smallest useful application shape, and it turns out that a carrier is exactly what that is. What you loaded is what you pass.

And it queries the whole graph

Then the second thing falls out. A relationship can be part of that shape too. If a user always needs its city, the field is typed as City; if the load should be deferred, it is typed as Ref<City>.

User.kt Kotlin
1
2
3
4
5
data class User(
    @PK val id: Int,
    val name: String,
    @FK val city: City
)

So relationships live in the shape, and the same definition that carries the data also describes how to query it. ST/ORM generates a metamodel from it, and because the relationships are in the shape, you can follow them across the relation graph: users.findAll(User_.city.name eq "Sunnyvale") reaches from a user to its city in one line, type-checked, with the join derived from the relationship path rather than written by hand at every call site.

What falls out of keeping it small

Step back and the picture is simple. The same small shape gives application code a value to pass around, a typed contract for queries, and a path into the relation graph. Those are usually separate pieces you write, map, and keep in sync. In ST/ORM they fall out of one decision: keep the application-facing view of database data small, typed, and free of hidden state.