Optivem Journal

Optivem Journal

Clean Architecture

Clean Architecture Mistake: ORM ≠ Domain

Do NOT confuse ORM entities with domain entities.

Valentina Jemuović's avatar
Valentina Jemuović
Jul 09, 2026
∙ Paid

Your ORM has entities.

Clean Architecture talks about entities.

They sound like the same thing.

They’re not.

❌ Your ORM Becomes Your Domain

You create an ORM entity (JPA entity) because you need to store orders.

@Entity
class Order {
    @Id
    private Long id;

    private OrderStatus status;
    private int quantity;
    private BigDecimal unitPrice;
}

And an ORM repository (JPA repository) to save and load it:

interface OrderJpaRepository extends JpaRepository<Order, Long> {
    // findById and save come out-of-the-box:
    // Optional<Order> findById(Long id);
    // Order save(Order order);
}

So far, so good.

Its job is persistence.

Then you need to place an order — and you expect an order to be in a valid state before it is saved. So you give Order a constructor that refuses to build an invalid order — e.g. one with a non-positive quantity or a null unit price:

Order(int quantity, BigDecimal unitPrice) {
    if (quantity <= 0) {
        throw new RuntimeException("quantity must be positive");
    }

    if (unitPrice == null) {
        throw new RuntimeException("unit price is required");
    }

    this.status = OrderStatus.PLACED;
    this.quantity = quantity;
    this.unitPrice = unitPrice;
}

Now an Order cannot exist in a broken state.

Your use case builds one through it:

class PlaceOrder {

    void execute(int quantity, BigDecimal unitPrice) {
        var order = new Order(quantity, unitPrice);
        orderRepository.save(order);
    }
}

And your use case depends on it.

Your ORM entity has become your domain entity.

It looks fine. It compiles. It runs.

Except — declaring that constructor removed Java’s free no-arg one. And JPA cannot live without one.

That’s worse than a plain compile error. It compiles, it starts up, and it even saves correctly — then breaks the first time someone reads the order back. Nothing warns you until then.

So you’re forced to add it back:

protected Order() {}   // required by JPA — status null, quantity 0, unitPrice null

You wrote a constructor to reject invalid orders.

JPA makes you add a no-arg one next to it — one that builds an order with a null status, zero quantity, no price.

Marking it protected doesn’t help. When Hibernate loads a row, it ignores every access modifier by using reflection: it invokes that no-arg constructor, then sets the fields one by one.

Your validating constructor never runs. Your checks never execute.

An order no business would accept — and Hibernate builds one on every load.

Here is the problem.

An object can only enforce its invariants in one place: its constructor. JPA bypasses it — it forces a no-arg constructor, then sets the fields by reflection, skipping your real constructor on every load.

So the real problem is not that “your domain is coupled.”

It’s this:

This entity cannot guarantee it's always valid (enforce business rules).

Your validation only runs when you call the constructor yourself. JPA never calls it — on every load it constructs the object empty and sets the fields directly.

And every transition you validate later — cancel, ship, and so on — then runs on an object that was never validated in the first place.

The code compiles, it runs, and every invariant you thought you wrote is optional.

And it doesn’t stop at Order.

Use a Money value object instead of BigDecimal — immutable, always valid, exactly what DDD wants:

final class Money {
    private final BigDecimal amount;

    Money(BigDecimal amount) {
        if (amount == null) {
            throw new RuntimeException("amount is required");
        }

        this.amount = amount;
    }
}

To persist it, JPA needs it as @Embeddable. And @Embeddable demands the same price: drop final, add a no-arg constructor.

@Embeddable
class Money {                    // no longer final
    private BigDecimal amount;   // no longer final

    protected Money() {}         // required by JPA — amount null

    Money(BigDecimal amount) {
        if (amount == null) {
            throw new RuntimeException("amount is required");
        }

        this.amount = amount;
    }
}

Your always-valid Money can now be built with a null amount too.

The trap isn’t contained to one class. It spreads to every value object you embed.

Notice what’s happening. You’re no longer modelling the domain — you’re shaping it to fit the ORM. You drop final, add constructors you’d never write, expose state you meant to hide — not because the business asked for it, but because the ORM expects it that way.

You end up violating the very principles the domain is supposed to protect, encapsulation first among them, just to keep the persistence framework happy.

That is not Clean Architecture.


Want to avoid the ORM trap?

📅 Join me: Clean Architecture for Backend Developers on Wed 26th Aug, 5:00 - 6:30 PM (CEST)


✅Domain ≠ ORM

This post is for paid subscribers

Already a paid subscriber? Sign in
© 2026 Valentina Jemuović, Optivem · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture