Sitemap
Heap & Wire

Deep dives into Java, Spring and modern backend architecture

ORMs Are a Response to Java Not Having Structural Types

Sent as aNewsletter
11 min read5 days ago

--

There is a peculiar ritual in enterprise Java.

You have a database table:

select id, name, email
from customer;

You want three values.So somebody builds:

@Entity
public class Customer {
@Id
private Long id;

private String name;
private String email;
// getters
// setters
// constructors
// equals
// hashCode
// maybe lazy-loading surprises
}

Then a repository. Then a mapper. Then a DTO.

Then a service.

Then perhaps another DTO because the endpoint does not actually need the database entity.

Then a query projection because loading the whole entity was wasteful.

Then a framework-generated proxy because apparently we were not suffering enough.

And eventually someone asks the dangerous question:

“Why do I need all of this just to get three fields?”

That question leads somewhere interesting.

Java is nominally typed. A type is primarily identified by a declared type name and its relationships, not simply by the shape of the values it contains.

A language with structural types can often say something closer to:

“I need any value that has an id, a name, and an email.”

Java generally says:

“I need a Customer.”

That difference looks small.

It is not.

It shapes how we build DTOs, projections, mappers, repositories, serializers, adapters, and eventually ORMs.

Now, to be precise, ORMs did not appear only because Java lacks structural types. ORMs are primarily a response to the object-relational impedance mismatch: objects, classes, identity, inheritance, references, and collections do not map neatly onto tables, rows, foreign keys, and joins.

But Java’s nominal, class-centric model makes one part of that mismatch particularly expensive:

the shape of the data is rarely enough. The type name has to line up too.

And that is where this gets fun.

The tiny problem hiding inside a giant ORM stack

Suppose the database contains:

customer
--------------------------------
id | name | email | status | address_id | created_at | ...

But your screen only needs:

id
name
email

In a structurally typed language, you might imagine working with a shape:

{
id: Long,
name: String,
email: String
}

You do not necessarily care what the original domain class is called.

In Java, the natural representation is a named type:

record CustomerSummary(
long id,
String name,
String email
) {}

That is perfectly reasonable.

But now we have introduced a new nominal type just to represent a particular shape.

Then we need to construct it.

Then we need to map to it.

Then the query needs to know how to populate it.

That is the tiny seam where frameworks start multiplying.

Java asks “what type?” before “what shape?”

Consider this method:

void sendEmail(EmailContact contact) {
...
}

The compiler expects:

EmailContact

It does not care that another record has exactly the same members:

record CustomerContact(
String name,
String email
) {}

These two shapes may be identical, but they are different types.

You cannot casually substitute one for the other.

That is a feature, not a bug.

Nominal typing gives you identity, clear contracts, refactoring support, and a useful way to prevent accidental interchangeability.

But it also means that when the real problem is simply:

I need these fields

you often end up inventing another named type.

And large enterprise systems contain a lot of “I need these fields” problems.

This is why DTOs multiply

Imagine an endpoint returning:

{
"id": 42,
"name": "Ada"
}

The database entity has:

@Entity
class Customer {
Long id;
String name;
String email;
String status;
Address address;
Instant createdAt;
Instant updatedAt;
// ...
}

You probably do not want to expose the whole entity.

So you create:

record CustomerResponse(
Long id,
String name
) {}

Then somewhere:

new CustomerResponse(
customer.getId(),
customer.getName()
);

Now imagine twenty endpoints.

The business logic is not necessarily complicated.

The mapping is.

This is the same underlying pressure that gives us:

entities
DTOs
view models
projection interfaces
mapper classes
record projections
serialization metadata

The more frequently your application asks for slightly different shapes, the more painful nominal typing can become.

ORMs are partially an answer to this pressure

Here is the classic ORM promise:

row ↔ object
foreign key ↔ reference
join table ↔ collection relationship
column ↔ field/property

The framework says:

“Don’t write all the boring translation code. Tell me your model and I will bridge the worlds.”

That is incredibly useful.

Hibernate and Jakarta Persistence let you define mappings declaratively:

@Entity
class Customer {

@Id
private Long id;
private String name;
private String email;
}

Then code can work in terms of:

Customer customer = entityManager.find(Customer.class, id);

instead of manually writing:

execute SQL
read ResultSet
construct object
convert types
manage identity
track relationships
handle lifecycle

The ORM absorbs a huge amount of object-relational plumbing.

That is the good part.

The cost is that the framework is now trying to reconcile two different type systems and two different notions of structure.

The database is already structurally flexible

This is the part that makes the comparison interesting.

A SQL query can return a shape without inventing a new nominal domain type.

For example:

select
c.id,
c.name,
c.email
from customer c;

The result shape is effectively:

(Long, String, String)

or, conceptually:

{id: Long, name: String, email: String}

SQL is very comfortable with ad hoc result shapes.

You can join tables and create a new projection in one query:

select
c.id,
c.name,
o.total
from customer c
join orders o on o.customer_id = c.id;

That shape did not exist before you wrote the query.

The database does not need a class called:

CustomerOrderSummary

before it can return the row.

Java generally does.

Or at least, Java wants some declared representation if the result is going to participate in a strongly typed API.

And now we have a very productive mismatch.

Java 16+ quietly improved this story with records

Records reduce the cost of creating named data shapes:

record CustomerName(long id, String name) {}

That is a big improvement over writing:

final class CustomerName {
private final long id;
private final String name;

CustomerName(long id, String name) {
this.id = id;
this.name = name;
}
long id() { return id; }
String name() { return name; }
// equals
// hashCode
// toString
}

Records do not make Java structurally typed.

But they reduce the cost of saying:

“Here is another immutable data shape.”

That turns out to matter a lot for database projections, API models, and intermediate transformations.

Record construction still needs a bridge from SQL

Suppose:

record CustomerSummary(
long id,
String name,
String email
) {}

You can write SQL that returns exactly those columns.

But something still needs to connect:

SQL result

to:

CustomerSummary

This is where ORMs, JDBC mappers, row mappers, annotation processors, reflection, and generated code all show up.

The shape exists in one world.

The nominal Java type exists in another.

The mapping layer is the translator.

A large part of modern Java persistence is essentially a sophisticated answer to:

“How do I turn this runtime shape into that compile-time named type?”

Projection APIs are basically controlled escape hatches

JPA-style projections make this relationship explicit.

For example, you might define:

public interface CustomerNameOnly {
Long getId();
String getName();
}

and ask the persistence framework for that projection.

Conceptually, you are saying:

“I don’t actually need a Customer. I need something with these accessors.”

That is much closer to a structural notion.

And notice what the framework is doing.

It is using runtime machinery to simulate flexibility that the static type system does not naturally provide.

The framework can look at:

getId()
getName()

and adapt a row-backed representation to an interface.

Java does not need structural typing in the language itself because the framework builds a runtime layer that approximates the useful part.

That’s a recurring pattern in the Java ecosystem.

Dynamic proxies are structural adapters in disguise

Suppose a framework sees:

interface NameView {
String getName();
}

and wants to create an implementation without writing:

class NameViewImpl implements NameView {
...
}

It can generate a proxy.

Very roughly:

NameView view = (NameView) Proxy.newProxyInstance(
NameView.class.getClassLoader(),
new Class<?>[] { NameView.class },
(proxy, method, args) -> {
return row.get(method.getName());
}
);

Real frameworks are much more sophisticated than this toy example.

But the idea is revealing.

You define a shape as an interface.

The framework supplies the runtime implementation.

Get Jitin Kayyala’s stories in your inbox

Join Medium for free to get updates from this writer.

That is very close to saying:

“I don’t care what the concrete object is. I care that it has these members.”

Again, structural typing is not actually happening.

Nominal typing is still in charge because the proxy must implement a named interface.

But the runtime framework is bending the system toward shape-based use.

This is where ORMs start doing too much

The original promise of an ORM is attractive:

Customer.class
↕
customer table

But real systems rarely stop there.

They have:

partial projections
joins
aggregates
nested objects
inheritance
lazy relationships
fetch plans
pagination
sorting
filters
custom SQL

At some point the ORM starts inventing ways to represent ad hoc shapes.

That is why we get things like:

projection interfaces
constructor expressions
criteria APIs
query DSLs
Tuple
Object[]
record projections
native queries

This looks like framework complexity.

And it is.

But it is also the framework acknowledging a reality:

relational queries produce arbitrary shapes.

A single entity class cannot represent every useful database result.

So the ORM grows a language for shapes.

A query is often a better data-shape language than a class hierarchy

This is one reason SQL is so powerful.

Consider:

select
c.id,
c.name,
count(o.id) as order_count,
coalesce(sum(o.total), 0) as total_spend
from customer c
left join orders o
on o.customer_id = c.id
group by c.id, c.name;

The result has a shape:

id          : bigint
name : text
order_count : bigint
total_spend : numeric

That shape may exist only for this report.

It does not deserve to become a giant entity hierarchy.

A small Java record is a good fit:

record CustomerSpend(
long id,
String name,
long orderCount,
BigDecimal totalSpend
) {}

The record is useful because the data is useful.

The mistake is assuming every query result must map back to an entity.

Once you stop doing that, the ORM becomes less magical and more practical.

Records let you use Java’s nominal typing without paying the old ceremony tax

This is one of the best modern Java improvements for database work.

You can happily define:

record CustomerSpend(
long id,
String name,
long orderCount,
BigDecimal totalSpend
) {}

and treat it as a value.

There is no need for:

setter explosion
mutable DTOs
boilerplate equals/hashCode

So although Java has not become structurally typed, the cost of saying:

“This query returns this shape.”

has dropped substantially.

That reduces one of the reasons developers used to reach for increasingly complicated ORM magic.

What structural typing would change

Imagine Java had a structural record type that let you write something conceptually like:

{ id: long, name: String }

and say:

load anything with this shape

Then a query result could potentially flow directly into that shape without defining:

CustomerName
CustomerNameProjection
CustomerNameDto

for every tiny variation.

That could simplify:

DTOs
projections
mappers
query results
adapter types
serialization views

But there would be a trade-off.

Nominal types communicate intent beyond shape.

For example:

record CustomerId(long value) {}
record OrderId(long value) {}

Both have:

long value

A structural type system might consider them interchangeable if their shapes matched.

That could be disastrous.

Sometimes identical shape does not mean identical meaning.

Nominal typing protects you from that class of mistake.

“Same shape” and “same concept” are different things

This is the strongest argument for Java’s model.

These two values are structurally identical:

record CustomerId(long value) {}
record OrderId(long value) {}

But:

CustomerId ≠ OrderId

And that is exactly what you want.

If a method accepts:

void loadCustomer(CustomerId id) { ... }

passing:

new OrderId(42)

should fail.

A structural type system can support stronger systems of wrappers or branded types, but nominal identity is a very direct tool for expressing domain meaning.

So “Java lacks structural types” is not a complaint that should end with:

“Therefore Java should have structural types everywhere.”

The real question is:

Where do we care about shape, and where do we care about identity?

Databases and query projections care heavily about shape.

Domain boundaries often care heavily about identity.

ORMs sit in the uncomfortable middle.

A better rule for ORM-heavy Java

I would use three questions.

Does this object have domain identity?

If yes, an entity or domain type may be appropriate.

Is this object primarily a query result or transport shape?

If yes, prefer a small immutable record.

Is this relationship really about shape rather than identity?

If yes, do not force it into the entity model just because an ORM makes that convenient.

That one distinction can prevent a surprising amount of ORM complexity.

The practical architecture

A healthy persistence boundary often looks like:

Database
|
v
SQL / ORM
|
+-------+-------+
| |
Entity Projection
| |
v v
Domain model Record/value
| |
+-------+-------+
|
v
Application logic
|
v
API DTO

The important part is that the types are allowed to be different.

The database does not need to think in terms of your domain classes.

The API does not need to think in terms of your tables.

The domain does not need to expose your persistence structure.

And the mapper is not a sign that the architecture failed.

It is the explicit seam between models.

What ORMs really bought us

It is easy to tell a simplistic story:

“Java has no structural types, therefore we got ORMs.”

That is too neat.

The real history is messier.

We got ORMs because developers wanted to work with an object model while storing data in relational databases.

We got ORMs because SQL and objects optimise for different things.

We got ORMs because manually mapping rows, relationships, identity, and lifecycle is exhausting.

But Java’s nominal type system adds another layer of friction:

SQL can create a shape on demand.
Java wants a named type for that shape.

That pressure contributes to the huge ecosystem around:

DTOs
projections
mappers
reflection
code generation
query DSLs

The ORM is not only mapping objects to tables.

It is also negotiating between a shape-oriented data system and a name-oriented language.

That is the more interesting story.

And this is why records matter so much

Records do not eliminate the object-relational mismatch.

They do something more achievable and more useful.

They make tiny, explicit, immutable data shapes cheap.

That means instead of thinking:

“How do I contort the entity model to represent this query?”

you can think:

“What is the smallest value that represents this result?”

Then write:

record OrderSummary(
long orderId,
String customerName,
BigDecimal total
) {}

That is a very good modern Java answer to a very old problem.

Not structural typing.

Just less ceremony around named data.

The final rule

The interesting lesson is not:

“ORMs are bad.”

They are incredibly useful.

Nor is it:

“Java should become structurally typed.”

That would solve some problems and create others.

The more useful lesson is this:

Use nominal types when identity and meaning matter. Use small data shapes when shape is the whole point. Do not force every database result through an entity just because the ORM can.

When the database gives you:

{id, name, email}

and the use case needs:

{id, name, email}

create the smallest Java value that says exactly that.

A record is often enough.

When the use case needs a long-lived thing with identity, invariants, and behaviour, model that explicitly.

And when an ORM starts growing another projection API, another mapper layer, another proxy, and another framework abstraction because the query returned five fields that don’t fit your entity…

stop for a second.

The framework may not be failing.

It may simply be paying the tax for a language that asks:

“What is the name of this type?”

when the database already knows the answer to a different question:

“What shape are you asking for?”

That gap is one of the most interesting fault lines in enterprise Java.

And once you see it, ORMs stop looking like giant magical machines that map objects to tables.

They start looking like what they really are:

a very elaborate negotiation between two worlds that agree on data, but disagree about what a type is.

--

--