Skip to content

Persistence Modeling Guidelines

Principle

Model the persistence layer for data storage, not for business logic. The persistence layer is an implementation detail of the application. It should remain isolated from both the domain model and the presentation layer.


These guidelines aim to create a persistence layer that is:

  • Simple
  • Explicit
  • Testable
  • Performant
  • Replaceable
  • Independent of business logic

The persistence layer should never dictate the architecture of the application.


In a Hexagonal Architecture, persistence is an infrastructure concern.

┌──────────────────────────────┐
│ Presentation │
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
│ Application │
└──────────────┬───────────────┘
│ Repository Contract
┌──────────────▼───────────────┐
│ Domain │
└──────────────┬───────────────┘
│
───────────────┼──────────────────────
│
┌──────────────▼───────────────┐
│ Persistence Adapter │
│ Quarkus + Hibernate │
│ PostgreSQL │
└──────────────────────────────┘

The domain never knows how data is stored.


Not every use case requires the same persistence approach.

Use Case Recommended Approach
Simple CRUD Quarkus Data Repository
Aggregate Loading Repository + Entity Mapping
Complex Filtering Fluent SQL Builder
Reporting Raw SQL
Analytics Raw SQL
Bulk Updates Raw SQL
Bulk Imports Batch SQL
Data Export Streaming SQL
Read Models DTO/Projection

Rule

Use the simplest persistence mechanism that solves the problem.

Do not force every query through Hibernate.


Repositories exist to retrieve and persist aggregates.

Repositories are not query engines.

Repositories are not business services.

Repositories are not report generators.

Good:

interface CustomerRepository {
fun findById(id: CustomerId): Customer?
fun save(customer: Customer)
}

Bad:

interface CustomerRepository {
fun findCustomersByAgeAndCityAndStatusAndRegistrationDate(...)
fun exportCustomerReport(...)
fun calculateRevenue(...)
}

Persistence entities represent the database schema.

Nothing more.

@Entity
@Table(name = "customers")
class CustomerEntity(
@Id
val id: UUID,
val firstName: String,
val lastName: String,
val email: String
)

Persistence entities:

  • represent tables
  • contain persistence annotations
  • should remain inside the persistence adapter

They should never leak into:

  • Application
  • Domain
  • REST API

Domain models represent business concepts.

data class Customer(
val id: CustomerId,
val name: CustomerName,
val email: EmailAddress
)

A domain model should never contain:

  • @Entity
  • @Column
  • @ManyToOne
  • @OneToMany
  • Hibernate-specific annotations

Every architectural layer owns its own model.

REST DTO
↓
Domain Model
↓
Persistence Entity

Each model has a different responsibility.

Model Purpose
DTO API contract
Domain Business rules
Entity Database storage

Mapping is required at architectural boundaries.

Avoid unnecessary mapping inside a single layer.

Good:

DTO
↓
Domain
↓
Entity

Bad:

DTO
↓
Command
↓
Model
↓
BusinessModel
↓
PersistenceModel
↓
Entity

Every additional mapping should have a clear architectural reason.


Simple CRUD should use Quarkus Data repositories.

@ApplicationScoped
class CustomerRepositoryImpl(
private val repository: CustomerJpaRepository
) : CustomerRepository {
override fun findById(id: CustomerId): Customer? =
repository.findById(id.value)
?.toDomain()
}

Keep repositories small.

Keep repositories focused.


Complex filtering should not abuse ORM.

Prefer explicit SQL generation.

Example:

val query = CustomerQueryBuilder()
.active()
.locatedIn(country)
.registeredAfter(date)
.build()

This keeps queries:

  • composable
  • readable
  • testable

Reporting is fundamentally different from aggregate loading.

Use SQL.

val sql = """
SELECT
customer_name,
revenue
FROM customer_revenue
WHERE year = ?
"""

Do not construct enormous object graphs just to generate reports.


When only a subset of data is required, use projections.

Good:

data class CustomerSummary(
val id: UUID,
val name: String
)

Instead of:

CustomerEntity

when only two columns are required.


ORMs are optimized for aggregates.

Not millions of rows.

Use SQL for:

  • imports
  • exports
  • batch updates
  • batch deletes

Good:

UPDATE orders
SET archived = true
WHERE created_at < ?

Bad:

orders.forEach {
it.archived = true
repository.persist(it)
}

Never load huge datasets into memory.

Prefer streaming.

Example:

connection.prepareStatement(sql).use { statement ->
statement.fetchSize = 500
val rs = statement.executeQuery()
while (rs.next()) {
// process row
}
}

Transactions belong to the application layer.

Keep transactions:

  • short
  • explicit
  • focused

Never:

  • call external APIs
  • send emails
  • publish events synchronously

inside a database transaction.


Performance rules:

  • Simplicity first
  • Measure before optimizing
  • Avoid N+1 queries
  • Fetch only required columns
  • Use indexes intentionally
  • Prefer SQL for heavy workloads

Persistence should primarily be tested through integration tests.

Preferred:

  • Testcontainers
  • PostgreSQL
  • Real SQL
  • Real transactions

Avoid mocking persistence behavior.


✅ Keep persistence inside the infrastructure layer.

✅ Keep entities persistence-only.

✅ Map at architectural boundaries.

✅ Prefer repositories for CRUD.

✅ Use SQL for reporting.

✅ Use SQL for analytics.

✅ Stream large datasets.

✅ Use projections for read models.

✅ Measure performance.

✅ Write integration tests.


❌ Put business logic inside entities.

❌ Expose entities through REST.

❌ Share entities with the domain.

❌ Load entire aggregates for reports.

❌ Use Hibernate for bulk updates.

❌ Load millions of rows into memory.

❌ Build repositories with hundreds of query methods.

❌ Create unnecessary mapping layers.

❌ Optimize before measuring.

❌ Mock your database in integration tests.


When generating persistence code, AI assistants must follow these rules.

  • Treat persistence as an infrastructure adapter.
  • Never leak persistence entities outside the persistence layer.
  • Respect Hexagonal Architecture.

Generate repositories only for aggregate persistence.

Do not generate repositories that contain business logic.

Choose the persistence strategy based on the use case.

Situation Preferred Solution
CRUD Quarkus Data Repository
Search Fluent SQL Builder
Reporting SQL
Export Streaming SQL
Analytics SQL

Generate only necessary mappings.

Never introduce extra DTOs or models without architectural value.

Always prefer:

  • projections over entities
  • SQL over ORM for reporting
  • streaming over loading collections
  • batch operations over row-by-row updates

Generated code should be:

  • explicit
  • readable
  • immutable where possible
  • easy to test
  • free from framework magic

Avoid introducing abstractions that are not justified by the architecture.


The persistence layer exists to store and retrieve data.

It is not the domain.

It is not the API.

It is not the business layer.

Choose the simplest persistence mechanism that fits the use case, keep persistence concerns isolated, and optimize only where measurements demonstrate the need.