Building a Reflection-Based ORM in Java

Building a Reflection-Based ORM in Java

Examines how to build a lightweight annotation-based ORM core with Java reflection. Metadata caching, SQL generation, type conversion, transaction boundaries, and performance costs are evaluated.

The fundamental problem of a reflection-based ORM layer is not generating SQL. The real challenge is establishing a correct, fast, and auditable mapping between Java objects and a database schema that cannot be changed. If field names, column types, null behavior, key generation, transaction boundaries, and connection routing are not addressed together, a small data-access helper can quickly turn into a framework with ambiguous behavior.

The requirements that arise while developing a special-purpose ORM package can differ from those of a conventional application ORM. A system may operate on a legacy database structure distributed across many Oracle schemas, with no permission to alter production schemas. In that case, merely executing queries is not enough for the data-access layer. Schema routing, transaction management, native DML execution, and controlled processing of large result sets must be handled within the same architectural boundary.

At first glance, reflection is a suitable tool for reducing repetitive JDBC code. Fields and annotations are scanned, column lists are extracted, and INSERT, UPDATE, or DELETE statements are generated automatically. When reflection runs on every call, however, the cost is not limited to execution time. Errors move into runtime, SQL generation becomes mixed into the data path, and metadata is rebuilt repeatedly for the same entity. An acceptable design for a critical system should remove reflection from the hot path.

Building the mapping model

The first layer of an ORM library is the entity definition. An entity class should not be viewed only as a Java data carrier. It also carries the contract for the corresponding database object. The table name, field-to-column mapping, key information, writable fields, and null behavior should be defined explicitly.

An example entity can have the following structure:

@DataTable(name = "APP_RECORD")
public final class ApplicationRecord {

    @DataId
    @DataColumn(name = "ID")
    private Long id;

    @DataColumn(name = "STATUS")
    private Integer status;

    @DataColumn(name = "CONTENT")
    private String content;

    @DataColumn(name = "UPDATED_AT")
    private LocalDateTime updatedAt;
}

Increasing the number of annotations does not always produce a more accurate model. If information that can be derived reliably from the database or the Java type system is repeated in annotations, two separate sources of truth are created. An explicit mapping is necessary when a field name differs from its column name. A read-only field should also be identified. In contrast, adding annotations for information that can be inferred reliably from the Java type, field modifiers, or class structure creates unnecessary complexity.

The mapping layer should provide definite answers to at least the following questions:

  • Which table does the entity represent?
  • To which column is a Java field written?
  • Which field is the primary key?
  • Is the key generated by the application or by a sequence?
  • Is a null value written as SQL NULL, or is the field excluded from the update?
  • Can a field participate in INSERT, UPDATE, or both operations?
  • Which conversion is applied between the Oracle type and the Java type?

Null behavior is particularly important. Interpreting null as "do not update this column" is not the same operation as writing NULL to the column. If these two meanings are hidden inside one generic update(entity) method, data loss can occur. Selective-field updates should be represented by an explicit update model or a separate command object.

Dynamic SQL can be generated from the current values of an entity object. Generating a different statement for every null combination can, however, reduce statement-cache efficiency and make runtime behavior harder to reason about. In critical write paths, defining the set of columns to update in advance and exposing dynamic column selection through a separate explicit API is safer.

Moving reflection cost to startup

Moving reflection-based SQL generation from every data operation to application startup produces a safer structure. A metadata builder examines entity classes once. The extracted information is converted into field, insert, update, delete, and entity metadata objects. These objects are then retained in an immutable metadata registry.

This separation creates two execution phases:

  1. At startup, classes are scanned, mappings are validated, and SQL templates are generated.
  2. At runtime, prepared metadata is read and only parameter binding is performed.

For example, EntityMeta can hold all information required for an entity:

public final class EntityMeta {

private final Class<?> entityType; private final String tableName; private final List<DataField> insertFields; private final List<DataField> updateFields; private final DataField idField; private final InsertMeta insertMeta; private final UpdateMeta updateMeta; private final DeleteMeta deleteMeta; }

A real implementation should include constructor validation, null checks, defensive copies, and immutable collections. The essential point is that the metadata object does not change after construction. An immutable registry reduces the need for locking under concurrent access. Different threads can no longer generate different SQL for the same entity.

Startup validation should not check only for the presence of annotations. The following errors should be detected before the application begins accepting requests:

  • Multiple fields mapped to the same column
  • Multiple identity fields
  • An update or delete definition with no identity field
  • An insert model with no writable fields
  • An unsupported Java type
  • An invalid table or column name
  • A mismatch between parameter count and field count
  • An empty UPDATE SET clause
  • SQL that updates the key field accidentally
  • Conflicting table definitions for the same entity
  • Duplicate field mappings inherited through a class hierarchy
  • Unsupported composite-key definitions
  • Ambiguous null or JDBC type mappings

The application should not start when these validations fail. Partially running with defective metadata is more dangerous in a critical system than failing early and explicitly during startup.

Field order must also be defined explicitly. Instead of relying directly on the order returned by the reflection API, fields should be sorted according to a specified rule or their order should be fixed while metadata is built. The parameter order in SQL and the field order used by the binder should be produced from the same immutable data structure.

Reflection does not have to be eliminated completely. Field.get() can still be used to read field values. A more advanced implementation can use MethodHandle, VarHandle, or prebuilt access plans. In most database operations, however, connection waiting, network communication, and SQL execution cost more than field access. Optimization decisions should therefore be based on measurement. The primary gain is not merely reducing reflection cost but moving runtime uncertainty into the startup phase.

Service and transport-layer boundaries

Using the ORM layer directly from a controller may appear convenient, but it moves transaction and business-rule boundaries into the HTTP layer. A controller should validate the incoming request and pass it to the appropriate service method. The decision to write data, the start of the transaction, and the order of multiple DML operations should remain in the service layer.

Application services can use repository objects, a transaction-scoped connection or persistence-context instance, and the metadata registry. Flows such as starting a record, completing a record, updating text, changing media state, or recording user interaction represent actual business operations. Reducing them to one generic save(Object entity) method is inappropriate. Each operation can modify different tables in a different order and require different rollback behavior after failure.

A public service method can establish the transaction boundary while lower-level operations such as insert, update, and delete remain private:

@Transactional public void saveContent(final ContentCommand command) { updateContent(command); updateStatus(command); }

private void updateContent(final ContentCommand command) { }

private void updateStatus(final ContentCommand command) { }

This structure keeps the entire business operation in one transaction. A controller that calls two separate service methods in sequence does not provide the same atomicity. If the first operation commits and the second fails, the system remains in a partial state.

Marking private methods in the same class with @Transactional does not create a new transaction boundary in most proxy-based transaction infrastructures. The transaction scope should be established on the externally invoked public service method. Internal calls should share that same transaction context.

Accepting an entity object directly as HTTP input is also unsafe. Exposing every writable database field to an external client can allow the client to change columns that should remain under server control. The request model and entity should remain separate. The controller accepts a command object, the service applies business rules, and the ORM writes only the necessary fields.

A general ORM does not require services to become generic CRUD services. Explicitly named methods are safer in critical workflows. The intent of a call such as saveMediaStatus is easier to trace than the intent of save(Object). SQL generation can be automated, but business intent should not be automated away.

Native DML and query execution

In immutable legacy schemas, the JPA entity life cycle is not suitable for every operation. Partial updates, schema-specific SQL constructs, unusual key arrangements in existing tables, and trigger behavior can make native DML a more direct means of control.

An insert based on prepared metadata follows this order:

  1. Find EntityMeta by entity type.
  2. Obtain the pre-generated SQL.
  3. Create the PreparedStatement.
  4. Bind field values in the defined order.
  5. Validate the affected-row count.
  6. If a key was generated, transfer it to the entity or command result.

The SQL text should not be concatenated on every call. It can be stored as a constant in InsertMeta:

INSERT INTO APP_RECORD (ID, STATUS, CONTENT, UPDATED_AT) VALUES (?, ?, ?, ?)

Parameter binding should not be delegated only to setObject(). Although Oracle JDBC type inference works for many common types, explicit JDBC types provide more stable behavior for date and time types, LOB fields, null values, and driver-version-dependent cases.

A central parameter binder should handle LocalDate, LocalDateTime, OffsetDateTime, byte[], CLOB, BLOB, BigDecimal, and numeric types. The binder should obtain the Java type, JDBC type, and any database-specific conversion information from metadata.

When binding a null value, the JDBC type must be known:

statement.setNull(index, Types.VARCHAR);

The type information can be retained in DataField in advance. Runtime field inspection is then unnecessary, and null handling does not depend on driver inference.

The number of affected rows should not be ignored silently in update and delete operations. If an operation intended to modify one row affects zero rows or more than one row, the data model, filter, or transaction context may differ from expectations. This condition should produce a controlled exception.

Not every zero-row update is an error. Zero rows can be valid for idempotent commands, conditional updates, or expected state transitions. The expected row count should be part of the operation metadata or service-method contract.

If a legacy table has no version column, the implementation should not claim to provide true optimistic locking. Limited conflict detection can be performed by adding previous field values or state information to the update condition:

UPDATE APP_RECORD SET STATUS = ? WHERE ID = ? AND STATUS = ?

This approach is not equivalent to the general optimistic-locking semantics provided by a version column. The behavior should be designed explicitly, documented, and verified with concurrent-update tests.

Schema and connection routing

Connection routing is one of the riskiest parts of a data-access layer that operates across many schemas. A valid SQL statement sent to the wrong schema can execute successfully, making the error difficult to detect.

When a schema key is carried in request, thread, or transaction context, its life cycle should be managed explicitly. With ThreadLocal-based routing, failing to clear the context in a finally block can cause the next request on the pooled thread to use the previous schema.

schemaContext.set(schemaKey);

try { service.execute(command); } finally { schemaContext.clear(); }

Classical ThreadLocal assumptions require separate evaluation with virtual threads or reactive execution models. Instead of binding context transfer to thread identity, an explicit parameter, scoped context, or a context-propagation mechanism appropriate to the execution model can be used.

An unknown schema key should not fall back silently to a default schema. Fail-fast behavior is safer than a successful write to the wrong target. The routing decision should be made before the transaction starts. If changing the connection or schema within the same transaction is unsupported, that restriction should be enforced explicitly.

Processing large result sets

An ORM layer is not responsible only for generating DML. The way large result sets are consumed is also part of the data-access model. Loading every row into a list is convenient for small data sets, but it can exhaust the heap when result size is uncontrolled.

One of the following methods should be selected for large queries:

  • Pagination
  • Cursor-based incremental reading
  • A callback or row consumer
  • Controlled stream-like consumption
  • Database-side filtering and projection

The result stream depends on the life cycles of the connection, statement, and result set. Passing the stream beyond the service method can lead to reading after the transaction or connection has closed. Resource life cycle and consumption model should be designed together.

Fetch size depends on the driver and query type. Larger values can reduce round trips while increasing memory use. Smaller values can cause more network interaction. The value should be determined from actual data size and latency measurements rather than a fixed general default.

The limit of experimental status

Developing a custom ORM library can be instructive and useful in some specialized systems. The first working version should nevertheless not be considered production-ready. Experimental status does not arise because the code was developed internally or by an individual. It arises because the behavioral space has not yet been tested sufficiently.

An ORM layer can fail in the following areas:

  • Type conversions
  • Null binding
  • Transaction rollback
  • Connection leaks
  • Heap exhaustion with large result sets
  • Concurrent access to the same metadata object
  • Incorrect classification of Oracle error codes
  • Schema-routing context remaining attached to a thread
  • Partial success in batch operations
  • Premature closure of LOB streams
  • Time-zone conversions
  • Duplicate-key and constraint errors
  • A connection remaining in an uncertain state after statement timeout
  • Inability of the client to verify the commit result
  • Repetition of the same write during a retry

Unit tests validate only the SQL text. Integration tests with the real Oracle driver and actual table types are required. Transaction rollback, connection loss, statement timeout, deadlock, constraint violation, and pool exhaustion should be produced in controlled tests.

Property-based testing is also useful for reflection-driven metadata. Different field combinations, inheritance structures, access modifiers, and annotation errors can be generated automatically to verify that the builder rejects invalid models.

Performance testing should not consist only of a single-threaded microbenchmark. At minimum, the following values should be measured:

  • Metadata access latency
  • Parameter-binding cost
  • Operations per second
  • P95 and P99 transaction duration
  • Connection wait time
  • Heap use with large result sets
  • Error rate under concurrent operation
  • Connection and object leaks during long-running tests
  • Statement-cache hit rate
  • Garbage-collection pauses
  • Queue length and saturation behavior

A few microseconds of overhead relative to direct JDBC calls may be acceptable in many systems. If class scanning, SQL concatenation, and repeated collection creation occur at runtime, however, that cost accumulates under high traffic. Measurement should show not only average duration but also tail latency under queues and resource saturation.

Error classification and retry policy

Database failures should not all be wrapped in the same generic exception. The upper layer must be able to distinguish whether an error is retryable and whether the transaction outcome is known with certainty.

The error model should distinguish at least the following classes:

  • Constraint violation
  • Duplicate key
  • Data-conversion error
  • Connection-establishment error
  • Connection loss
  • Statement timeout
  • Deadlock
  • Lock timeout
  • Unexpected affected-row count
  • Schema-routing error
  • Unsupported metadata
  • Ambiguous commit result

Blind retries should not be applied to writes. If the connection drops during commit, the client may be unable to determine whether the operation committed. Sending the same operation again can create duplicate data. A retry should be used only when the operation is idempotent or duplication is prevented safely through a business key, unique constraint, and deduplication mechanism.

A limited retry may be possible for failures such as a deadlock that clearly result in rollback. Permanent errors such as invalid data, a constraint violation, or an incorrect schema should not be retried. The retry policy should depend on exception type, operation idempotency, and certainty of the transaction result.

Criteria for production use in a critical system

A special-purpose reflection-based ORM library can be used in critical systems. Its experimental character must first be constrained through a controlled productization process.

The first condition is a narrow scope. The library should not attempt to reproduce every JPA feature. If the required scope is connection and schema routing, query execution, native DML, transaction compatibility, and controlled result processing, development should remain within that boundary. Features such as lazy loading, entity graphs, automatic relationship management, and a general query language should not be added without an actual requirement.

The second condition is deterministic behavior. The same entity metadata should generate the same SQL at every startup. Field order should not depend on the incidental order returned by the reflection API. The registry should not change after initialization. An invalid model should be rejected at startup, not during the first request.

The third condition is an explicit error model. Constraint violations, connection failures, timeouts, deadlocks, missing data, and unexpected row counts should not be hidden inside one generic exception. The upper layer should know which errors are retryable. Blind retries should not be applied to writes.

The fourth condition is observability. Instead of logging the complete SQL text and personal data, the system should record the operation name, entity type, duration, affected-row count, schema key, and error class. Slow operations, connection waits, and pool saturation should be monitored with separate metrics.

Parameter values should not be logged by default. If an SQL template is retained for debugging, sensitive fields should be masked and access to logs restricted. A trace or correlation identifier should associate multiple DML steps that belong to the same business operation.

The fifth condition is a rollback plan. The new ORM layer can first run in read-only or noncritical flows. Shadow comparison can be performed against the previous data-access layer by comparing the results of the same queries. Writes require controlled traffic, a feature flag, and a rapid disable mechanism.

A production decision should not be made before the following evidence is obtained:

  • Every supported Java and Oracle type has been tested.
  • Transaction atomicity has been verified with fault injection.
  • No connection, statement, or result-set leaks remain.
  • Long-running load tests produce stable results.
  • Schema-routing errors do not produce silent fallback.
  • Generated SQL is validated at startup.
  • Affected-row counts are checked for critical DML operations.
  • Unsupported library behavior is documented.
  • The procedure for returning to the previous access method has been tested.
  • The code has undergone at least one independent technical review.
  • Retry and idempotency behavior has been verified with failure scenarios.
  • Sensitive data is shown not to enter logs.
  • The system is proven to fail in a controlled manner during connection-pool saturation.

When these conditions are satisfied, reflection itself does not create a production risk. The risk depends on when reflection is used and how much work it performs. If class analysis completes at startup, metadata remains immutable, and only prepared access plans are used on the hot path, behavior becomes more predictable.

A special-purpose ORM layer can begin as an experimental idea without remaining experimental forever. Its reliability in production is measured not by feature count but by clarity of boundaries. The architecture remains auditable when the controller stays in the transport layer, the service owns business and transaction boundaries, and the ORM remains responsible only for data access.

The decisive criterion for moving into a critical system is not whether the library works under normal conditions. It is whether its behavior under failure, saturation, connection loss, concurrency, and partial failure has been measured and demonstrated.

QR code for this page