This document presents a detailed low-level design for building a robust Object-Relational Mapping framework that bridges object-oriented programming with relational database operations, focusing on performance, extensibility, and enterprise-grade features.
1. Problem Statement & Requirements
Clarifying questions
- What scale and performance requirements are expected? Will this ORM handle millions of records, high concurrent read/write loads, or is it for smaller-scale applications?
- What consistency requirements are there? Are strong consistency, eventual consistency, or eventual consistency with read-replicas preferred?
- What are the key entities and their relationships? Are we modeling a simple CRUD application or complex domain with many-to-many, inheritance hierarchies?
- What's the read/write ratio? Is this primarily a read-heavy workload or more balanced?
- What latency sensitivity is expected? Are sub-100ms response times critical, or can we accept higher latencies for more complex queries?
- What authentication and authorization model is required? Should the ORM support row-level security, multi-tenant isolation, or simple user roles?
- What external integrations are expected? Should the ORM support other databases (PostgreSQL, MongoDB), or is it database-specific?
Functional requirements
- Core CRUD operations: Create, Read, Update, Delete entities with full support for complex queries (joins, aggregations, subqueries).
- Entity mapping: Map Java objects to database tables with support for inheritance and composition.
- Query building: Support both type-safe query builders (e.g., JPQL, Criteria API) and raw SQL execution.
- Transaction management: Support for ACID transactions with proper isolation levels and rollback handling.
- Lazy loading: Support for lazy loading of associations to optimize memory usage and query performance.
- Caching integration: Integration with caching layers for improved read performance.
- Audit logging: Support for audit trails on entity changes (who, what, when).
- Soft delete support: Option to mark entities as deleted without actual removal from DB.
Non-functional requirements
- Latency targets:
- p50: <50ms for simple queries
- p95: <200ms for complex queries (joins, aggregations)
- p99: <500ms for complex queries
- Throughput:
- 10,000 concurrent read operations/sec (read-heavy workload)
- 500 concurrent write operations/sec
- Availability:
- 99.9% uptime (99.99% for critical systems)
- Support for database failover and read replicas
Constraints and assumptions
- Database support: Initially target PostgreSQL and MySQL, with extensibility for other RDBMS.
- Java version: Java 17+ (support for modern features like records, sealed classes).
- No external dependencies: Avoid third-party ORM frameworks (e.g., Hibernate) to build a custom solution.
- No schema migration: Assume schema is managed separately (e.g., Flyway, Liquibase).
- No ORM caching layer: Use external caching (Redis) for performance optimization.
Out of scope
- Schema migration tooling: Schema generation, version control, or migration scripts.
- Database connection pooling: Use external libraries like HikariCP for DB connection management.
- Cross-database compatibility: Focus on single RDBMS support (PostgreSQL) with extensibility.
- Advanced ORM features: Advanced features like change tracking, dirty checking, or automatic query plan optimization.
- No support for NoSQL: Focus on relational database modeling only.
- No UI or web framework integration: This is a backend-only ORM framework.
2. API Design
API Style Selection
gRPC with Protocol Buffers is selected for the ORM framework API design due to:
- Strong typing and schema evolution: Protocol Buffers provide compile-time type safety and robust versioning support, essential for an ORM that needs to handle evolving data models.
- Efficient binary serialization: gRPC's binary format is more compact and faster than JSON, crucial for high-throughput database operations.
- Built-in support for streaming: gRPC supports bidirectional streaming, which is valuable for real-time data synchronization and change data capture.
- First-class support in modern frameworks: gRPC has excellent support in Java, Go, and other backend languages commonly used for ORM systems.
- Cross-language compatibility: The framework needs to support multiple backend languages, and gRPC provides native support for this.
Complete Endpoint Definitions
| Method | Path | Description | Auth |
|---|---|---|---|
Create | /orm.v1.ORMService/Create | Create a new entity in the database | JWT |
Read | /orm.v1.ORMService/Read | Read a single entity by ID | JWT |
Update | /orm.v1.ORMService/Update | Update an existing entity | JWT |
Delete | /orm.v1.ORMService/Delete | Delete an entity by ID | JWT |
List | /orm.v1.ORMService/List | List entities with filtering and pagination | JWT |
Query | /orm.v1.ORMService/Query | Execute complex queries with joins and aggregations | JWT |
Transaction | /orm.v1.ORMService/Transaction | Execute multiple operations in a transaction | JWT |
Schema | /orm.v1.ORMService/GetSchema | Retrieve database schema information | JWT |
Health | /orm.v1.ORMService/Health | Health check endpoint | None |
Request and Response Schemas
Create Request
message CreateRequest {
string entity_type = 1;
map<string, Value> fields = 2;
repeated string include_fields = 3;
}
message CreateResponse {
string entity_id = 1;
map<string, Value> created_entity = 2;
}
Read Request
message ReadRequest {
string entity_type = 1;
string entity_id = 2;
repeated string include_fields = 3;
}
message ReadResponse {
map<string, Value> entity = 1;
}
Update Request
message UpdateRequest {
string entity_type = 1;
string entity_id = 2;
map<string, Value> fields = 3;
repeated string include_fields = 4;
}
message UpdateResponse {
map<string, Value> updated_entity = 1;
}
Delete Request
message DeleteRequest {
string entity_type = 1;
string entity_id = 2;
}
message DeleteResponse {
bool success = 1;
}
List Request
message ListRequest {
string entity_type = 1;
Filter filter = 2;
Sort sort = 3;
Pagination pagination = 4;
repeated string include_fields = 5;
}
message ListResponse {
repeated map<string, Value> entities = 1;
PaginationInfo pagination_info = 2;
}
message Filter {
repeated Condition conditions = 1;
string operator = 2; // "AND" or "OR"
}
message Condition {
string field = 1;
string operator = 2; // "=", ">", "<", "LIKE", etc.
Value value = 3;
}
message Sort {
string field = 1;
bool ascending = 2;
}
message Pagination {
int32 page = 1;
int32 page_size = 2;
}
message PaginationInfo {
int32 total_count = 1;
int32 current_page = 2;
int32 total_pages = 3;
}
Query Request
message QueryRequest {
string query = 1;
repeated Value parameters = 2;
bool include_fields = 3;
}
message QueryResponse {
repeated map<string, Value> results = 1;
}
Transaction Request
message TransactionRequest {
repeated Operation operations = 1;
}
message Operation {
string operation_type = 1; // "CREATE", "UPDATE", "DELETE", "QUERY"
CreateRequest create_request = 2;
UpdateRequest update_request = 3;
DeleteRequest delete_request = 4;
QueryRequest query_request = 5;
}
message TransactionResponse {
repeated OperationResult results = 1;
}
message OperationResult {
bool success = 1;
string error_message = 2;
CreateResponse create_response = 3;
UpdateResponse update_response = 4;
DeleteResponse delete_response = 5;
QueryResponse query_response = 6;
}
HTTP Status Codes and Error Response Format
| HTTP Status | gRPC Code | Description |
|---|---|---|
| 200 | OK | Success |
| 400 | INVALID_ARGUMENT | Invalid request parameters |
| 401 | UNAUTHENTICATED | Authentication required |
| 403 | PERMISSION_DENIED | Insufficient permissions |
| 404 | NOT_FOUND | Entity not found |
| 429 | RESOURCE_EXHAUSTED | Rate limit exceeded |
| 500 | INTERNAL | Internal server error |
| 503 | UNAVAILABLE | Service unavailable |
Standardized error response format:
message Error {
string code = 1;
string message = 2;
repeated string details = 3;
int64 timestamp = 4;
}
API Versioning Strategy
Versioning follows semantic versioning (SemVer) with a major version prefix in the gRPC service name:
orm.v1.ORMService- Current stable version- Future versions:
orm.v2.ORMService, etc.
Versioning strategy:
- Backward compatibility: All changes within the same major version must be backward compatible
- New features: Added in new minor versions (v1.1, v1.2)
- Breaking changes: Only introduced in new major versions (v2.0, v3.0)
- Deprecation policy: Old versions supported for 12 months with deprecation warnings
Pagination Design
Cursor-based pagination is selected over offset-based pagination due to:
- Consistent performance: Cursor-based pagination maintains consistent query execution time regardless of page size
- Stable results: Handles data changes (inserts/deletes) gracefully without skipping or duplicating records
- Better for large datasets: More efficient for large datasets and high-volume operations
Cursor format:
{
"cursor": "eyJpZCI6IjEyMzQ1NiIsInRpbWVzdGFtcCI6IjE2Nzg5MDAwMDAwMDAifQ=="
}
Authentication and Authorization
JWT-based authentication with OAuth2 integration:
-
Authentication Flow:
- Client authenticates via OAuth2 provider (e.g., Google, GitHub)
- JWT token is issued with claims:
sub,iss,exp,aud,roles - JWT tokens are validated using public key verification (JWKs endpoint)
-
Authorization:
- Role-based access control (RBAC) with roles:
admin,user,read_only - Per-entity permission checks in service layer
- JWT claims are used to enforce access policies
- Role-based access control (RBAC) with roles:
Rate Limiting Strategy
Rate limiting per endpoint:
| Endpoint | Limit | Period |
|---|---|---|
| Create/Update/Delete | 1000 req/min | 1 minute |
| Read/List | 5000 req/min | 1 minute |
| Query | 100 req/min | 1 minute |
| Transaction | 50 req/min | 1 minute |
Rate limiting implementation:
- In-memory token bucket for each endpoint
- Distributed rate limiter using Redis for multi-instance deployments
- Exponential backoff with retry headers (Retry-After)
Idempotency Design
Idempotency key strategy:
-
Request-level idempotency:
- Client includes
X-Idempotency-Keyheader in requests - Server stores last response for each key within 5 minutes
- If same key is used, return cached result instead of reprocessing
- Client includes
-
Idempotency key format:
{entity_type}:{operation}:{client_id}:{timestamp} -
Implementation:
- Store in Redis with TTL of 5 minutes
- Use atomic operations to prevent race conditions
- Log idempotency hits for monitoring and debugging
Example Request Flow (Create)
{
"entity_type": "User",
"fields": {
"name": "John Doe",
"email": "john@example.com",
"age": 30
},
"include_fields": ["id", "name", "email"]
}
{
"entity_id": "user_123456",
"created_entity": {
"id": "user_123456",
"name": "John Doe",
"email": "john@example.com",
"age": 30,
"created_at": "2023-01-01T10:00:00Z"
}
}
3. Data Model Design
Entity-Relationship diagram (Mermaid)
erDiagram
ENTITY {
string id PK
string name
string description
timestamp created_at
timestamp updated_at
boolean is_active
}
PROPERTY {
string id PK
string entity_id FK
string name
string data_type
string value
timestamp created_at
timestamp updated_at
}
ENTITY_PROPERTY {
string entity_id FK
string property_id FK
timestamp created_at
}
ENTITY_PROPERTY_ASSOCIATION {
string parent_entity_id FK
string child_entity_id FK
string relationship_type
timestamp created_at
}
ENTITY_PROPERTY ||--o{ PROPERTY : "has"
ENTITY_PROPERTY ||--o{ ENTITY_PROPERTY_ASSOCIATION : "associates"
PROPERTY ||--o{ ENTITY_PROPERTY_ASSOCIATION : "linked_to"
Table / Collection schema definitions with field types, constraints, indices
-- Main Entity table
CREATE TABLE entities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- Indexes
INDEX idx_entities_name (name),
INDEX idx_entities_active (is_active),
INDEX idx_entities_created_at (created_at)
);
-- Property table
CREATE TABLE properties (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_id UUID NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
data_type VARCHAR(50) NOT NULL, -- e.g., 'string', 'integer', 'boolean'
value TEXT, -- JSONB for complex types
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- Indexes
INDEX idx_properties_entity_id (entity_id),
INDEX idx_properties_name (name),
INDEX idx_properties_data_type (data_type),
INDEX idx_properties_created_at (created_at)
);
-- Entity-Property relationship table
CREATE TABLE entity_property_relations (
entity_id UUID NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
property_id UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- Composite primary key
PRIMARY KEY (entity_id, property_id),
-- Indexes
INDEX idx_entity_property_relations_entity_id (entity_id),
INDEX idx_entity_property_relations_property_id (property_id)
);
-- Entity-Entity relationship table
CREATE TABLE entity_entity_relations (
parent_entity_id UUID NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
child_entity_id UUID NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
relationship_type VARCHAR(100) NOT NULL, -- e.g., 'has', 'belongs_to'
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- Composite primary key
PRIMARY KEY (parent_entity_id, child_entity_id),
-- Indexes
INDEX idx_entity_entity_relations_parent (parent_entity_id),
INDEX idx_entity_entity_relations_child (child_entity_id),
INDEX idx_entity_entity_relations_type (relationship_type)
);
Primary key strategy (UUID vs. auto-increment vs. ULID — with justification)
Using UUIDs as primary keys for the following reasons:
- Scalability: UUIDs support distributed system operation without coordination
- Security: Prevents enumeration attacks by not exposing sequential IDs
- Global uniqueness: No need for coordination between database instances or sharding
- Sharding friendly: UUIDs can be distributed across shards without ID conflicts
Index design (composite, partial, covering indices — with justification per index)
-
Composite indexes:
idx_entities_name_active: For filtering by name and active status togetheridx_entity_property_relations_entity_id_property_id: For efficient lookups of properties by entity
-
Partial indexes:
idx_entities_active_only:WHERE is_active = TRUEto optimize active entity queriesidx_properties_data_type_name:WHERE data_type = 'string'for type-specific queries
-
Covering indexes:
idx_entities_name_active_created: Covers queries that fetch name, active status, and created_at fields
Data partitioning / sharding strategy
Partitioning by created_at timestamp with monthly or quarterly ranges:
-- Example partitioning strategy for entities table
CREATE TABLE entities_2024_01 PARTITION OF entities
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE entities_2024_02 PARTITION OF entities
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
Soft delete vs. hard delete decision with justification
Soft delete is implemented using is_active boolean field:
- Auditability: Historical data can be preserved for compliance and analytics
- Recovery capability: Data can be restored without complex recovery procedures
- Referential integrity: Foreign key constraints remain valid during soft deletion
- Simpler application logic: Applications don't need to handle cascading deletes
Audit trail design (who, what, when)
-- Audit log table for tracking changes
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type VARCHAR(100) NOT NULL,
entity_id UUID NOT NULL,
operation_type VARCHAR(20) NOT NULL, -- 'CREATE', 'UPDATE', 'DELETE'
changed_fields JSONB,
old_values JSONB,
new_values JSONB,
user_id UUID, -- Optional for tracking who made the change
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
INDEX idx_audit_logs_entity_type_id (entity_type, entity_id),
INDEX idx_audit_logs_created_at (created_at),
INDEX idx_audit_logs_user_id (user_id)
);
Data archival strategy and retention policy
- Archival criteria: Entities older than 2 years with no recent activity
- Archive storage: Separate database or data lake for archived entities
- Retention policy:
- 2 years active data (current database)
- 5 years archived data (cold storage)
- Permanent audit logs with compliance retention requirements
-- Example archival trigger (simplified)
CREATE OR REPLACE FUNCTION archive_old_entities()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.updated_at < CURRENT_DATE - INTERVAL '2 years' THEN
-- Move to archive table or external storage
INSERT INTO archived_entities SELECT * FROM entities WHERE id = OLD.id;
DELETE FROM entities WHERE id = OLD.id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER archive_trigger
AFTER UPDATE ON entities
FOR EACH ROW EXECUTE FUNCTION archive_old_entities();
4. Class Design & Service Layer
classDiagram
class ORMFramework {
+initialize()
+getSession()
+executeQuery()
+executeUpdate()
}
class Session {
-entityManager: EntityManager
-connection: Connection
-transaction: Transaction
+beginTransaction()
+commit()
+rollback()
+find(Class, id)
+persist(Object)
+merge(Object)
+remove(Object)
+flush()
}
class EntityManager {
-session: Session
-entityCache: Cache
+find(Class, id)
+persist(Object)
+merge(Object)
+remove(Object)
}
class Transaction {
-connection: Connection
-isolationLevel: IsolationLevel
+begin()
+commit()
+rollback()
+setIsolationLevel(IsolationLevel)
}
class QueryBuilder {
-query: String
-parameters: Map~String, Object~
+select(String)
+from(String)
+where(String)
+orderBy(String)
+limit(int)
+build()
}
class Query {
-sql: String
-parameters: Map~String, Object~
-resultType: Class
+execute()
}
class EntityMetadata {
-entityClass: Class
-tableName: String
-primaryKey: Field
-fields: List~Field~
-relations: Map~String, Relation~
}
class Relation {
-type: RelationType
-mappedBy: String
-targetEntity: Class
}
class MappingProcessor {
-entityMetadata: Map~Class, EntityMetadata~
+processEntity(Class)
+getMetadata(Class)
}
class QueryCompiler {
-sqlTemplate: String
+compile(Query)
+generateSQL(Query)
}
class ConnectionPool {
-maxConnections: int
-connections: Queue~Connection~
+getConnection()
+releaseConnection(Connection)
}
class EventPublisher {
-listeners: List~EventListener~
+publish(Event)
}
class Event {
-eventType: String
-payload: Object
+getPayload()
}
ORMFramework --> Session : creates
Session --> EntityManager : uses
Session --> Transaction : manages
Session --> QueryBuilder : builds
EntityManager --> QueryBuilder : delegates
EntityManager --> EntityMetadata : reads
QueryBuilder --> Query : builds
Query --> ConnectionPool : gets connection
MappingProcessor --> EntityMetadata : creates
QueryCompiler --> Query : compiles
EventPublisher --> Event : publishes
class Repository {
+find(Class, id)
+findAll(Class)
+save(Object)
+update(Object)
+delete(Object)
}
class GenericRepository {
-entityClass: Class
-session: Session
+find(Class, id)
+findAll(Class)
+save(Object)
+update(Object)
+delete(Object)
}
Repository <|-- GenericRepository
stateDiagram-v2
[*] --> Idle
Idle --> Active : beginTransaction()
Active --> Committing : commit()
Active --> RollingBack : rollback()
Committing --> Idle : transactionComplete()
RollingBack --> Idle : transactionComplete()
Active --> Error : exception()
Error --> Idle : rollback()
Service Layer Architecture
The ORM framework's service layer is designed with a layered architecture that follows the principle of separation of concerns. The core components include:
- ORM Framework: Main entry point that initializes the framework and provides session management.
- Session Management: Handles database transactions, entity persistence, and query execution.
- Entity Management: Manages entity lifecycle through the EntityManager.
- Query Building & Execution: Provides fluent API for building and executing queries.
- Metadata Processing: Handles entity mapping and metadata discovery.
- Transaction Management: Ensures ACID properties through transaction handling.
- Repository Pattern: Provides generic repository implementation for common CRUD operations.
Business Logic Encapsulation
The framework implements the Strategy Pattern for query compilation to support different SQL dialects and optimization strategies.
public class QueryCompiler {
private final Map<DatabaseType, SqlGenerator> sqlGenerators;
public QueryCompiler() {
this.sqlGenerators = new HashMap<>();
sqlGenerators.put(DatabaseType.MYSQL, new MySqlGenerator());
sqlGenerators.put(DatabaseType.POSTGRESQL, new PostgreSqlGenerator());
}
public String compile(Query query, DatabaseType dbType) {
SqlGenerator generator = sqlGenerators.getOrDefault(dbType, new DefaultSqlGenerator());
return generator.generate(query);
}
}
interface SqlGenerator {
String generate(Query query);
}
class MySqlGenerator implements SqlGenerator {
@Override
public String generate(Query query) {
// MySQL-specific SQL generation logic
return "SELECT * FROM " + query.getFrom() + " WHERE " + query.getWhere();
}
}
class PostgreSqlGenerator implements SqlGenerator {
@Override
public String generate(Query query) {
// PostgreSQL-specific SQL generation logic
return "SELECT * FROM " + query.getFrom() + " WHERE " + query.getWhere();
}
}
Critical-Path Method Implementation
Below is the implementation of persist() operation which represents the most critical path in the ORM framework:
public class Session {
private final EntityManager entityManager;
private final ConnectionPool connectionPool;
private final Transaction transaction;
private final EventPublisher eventPublisher;
private final Cache entityCache;
public void persist(Object entity) {
// Validate input
if (entity == null) {
throw new IllegalArgumentException("Entity cannot be null");
}
// Begin transaction if not already started
if (!transaction.isActive()) {
transaction.begin();
}
// Get entity metadata
Class<?> entityClass = entity.getClass();
EntityMetadata metadata = getEntityMetadata(entityClass);
// Check if entity is already managed
Object existingEntity = entityCache.get(metadata.getTableName(), getPrimaryKeyValue(entity, metadata));
if (existingEntity != null) {
throw new IllegalStateException("Entity already managed");
}
// Validate entity state
validateEntity(entity, metadata);
// Acquire database connection
Connection connection = null;
try {
connection = connectionPool.getConnection();
// Start transaction if not already started
if (!connection.getAutoCommit()) {
connection.setAutoCommit(false);
}
// Generate and execute INSERT SQL
String sql = generateInsertSQL(metadata, entity);
PreparedStatement stmt = connection.prepareStatement(sql);
// Set parameters
setParameters(stmt, metadata, entity);
// Execute update
int rowsAffected = stmt.executeUpdate();
if (rowsAffected != 1) {
throw new RuntimeException("Failed to persist entity");
}
// Cache the entity
entityCache.put(metadata.getTableName(), getPrimaryKeyValue(entity, metadata), entity);
// Publish persistence event
eventPublisher.publish(new EntityPersistedEvent(entityClass.getSimpleName(), entity));
} catch (SQLException e) {
// Handle transaction rollback
try {
if (transaction.isActive()) {
transaction.rollback();
}
} catch (Exception rollbackException) {
// Log but don't rethrow - we want to propagate original exception
logger.error("Failed to rollback transaction", rollbackException);
}
throw new DataAccessException("Failed to persist entity", e);
} finally {
if (connection != null) {
connectionPool.releaseConnection(connection);
}
}
}
private void validateEntity(Object entity, EntityMetadata metadata) {
// Validate required fields
for (Field field : metadata.getFields()) {
if (field.isAnnotationPresent(NotNull.class) &&
field.get(entity) == null) {
throw new ValidationException("Required field " + field.getName() + " cannot be null");
}
}
}
private String generateInsertSQL(EntityMetadata metadata, Object entity) {
StringBuilder sql = new StringBuilder("INSERT INTO ");
sql.append(metadata.getTableName()).append(" (");
List<Field> fields = metadata.getFields();
for (int i = 0; i < fields.size(); i++) {
if (i > 0) sql.append(", ");
sql.append(fields.get(i).getName());
}
sql.append(") VALUES (");
for (int i = 0; i < fields.size(); i++) {
if (i > 0) sql.append(", ");
sql.append("?");
}
sql.append(")");
return sql.toString();
}
private void setParameters(PreparedStatement stmt, EntityMetadata metadata, Object entity) throws SQLException {
int index = 1;
for (Field field : metadata.getFields()) {
try {
Object value = field.get(entity);
stmt.setObject(index++, value);
} catch (IllegalAccessException e) {
throw new DataAccessException("Failed to access field " + field.getName(), e);
}
}
}
private Object getPrimaryKeyValue(Object entity, EntityMetadata metadata) {
try {
Field pkField = metadata.getPrimaryKey();
return pkField.get(entity);
} catch (IllegalAccessException e) {
throw new DataAccessException("Failed to get primary key value", e);
}
}
private EntityMetadata getEntityMetadata(Class<?> entityClass) {
return mappingProcessor.getMetadata(entityClass);
}
}
Transaction Boundary Design
The transaction boundary in this ORM framework is defined as follows:
- Single Unit of Work: Each session represents a single unit of work that can span multiple database operations.
- Explicit Transaction Control: Transactions must be explicitly started using
beginTransaction()and committed or rolled back. - Implicit Transaction Management: When a session is used for the first time, it implicitly starts a transaction if one isn't already active.
- Nested Transaction Support: The framework supports nested transactions through savepoint management.
Service Interface Contracts
public interface ORMFramework {
void initialize(Configuration config);
Session getSession();
<T> T find(Class<T> entityClass, Object id);
void persist(Object entity);
void merge(Object entity);
void remove(Object entity);
}
public interface Session {
void beginTransaction();
void commit();
void rollback();
<T> T find(Class<T> entityClass, Object id);
void persist(Object entity);
void merge(Object entity);
void remove(Object entity);
QueryBuilder createQueryBuilder();
void flush();
}
public interface Repository<T> {
T findById(Object id);
List<T> findAll();
void save(T entity);
void update(T entity);
void delete(T entity);
}
Saga / Orchestration Pattern
For distributed transactions or complex operations involving multiple entities, the framework supports a simplified saga pattern through:
- Event-driven persistence: Each entity operation publishes events to a message queue.
- Compensating actions: For complex operations, the system can trigger compensating actions if a step fails.
- State tracking: The framework maintains state of ongoing operations to support recovery.
Domain Object / Value Object Design
// Entity class example
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "username")
private String username;
@Column(name = "email")
private String email;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private List<Order> orders;
// Getters and setters
}
// Value object example
public class Address {
private String street;
private String city;
private String zipCode;
// Constructor, getters, setters, equals, hashCode
}
// Repository interface for specific entities
public class UserRepository implements Repository<User> {
private final Session session;
public UserRepository(Session session) {
this.session = session;
}
@Override
public User findById(Object id) {
return session.find(User.class, id);
}
@Override
public void save(User user) {
session.persist(user);
}
// Other methods...
}
5. Concurrency & Thread Safety
Concurrency Model
The ORM framework will support concurrent database operations through a thread-safe architecture that leverages:
- Thread-local connection management for each operation
- Shared connection pool with thread-safe acquisition/release mechanisms
- Async/Await pattern for non-blocking I/O operations (Java 11+ Virtual Threads or CompletableFuture)
- Read-write lock patterns for schema metadata caching
Race Condition Prevention Strategy
Race conditions are mitigated through:
- Connection Pool Isolation: Each thread gets its own database connection from a thread-safe pool, preventing shared state corruption
- Transaction Isolation: Implementation of proper transaction boundaries with appropriate isolation levels (READ_COMMITTED by default)
- Version-based Optimistic Locking: For entities with version fields, concurrent updates are detected and handled gracefully
- Database-level Constraints: Enforced through unique constraints, foreign key checks, and triggers to prevent data inconsistency
Optimistic vs. Pessimistic Locking Selection
Optimistic Locking is selected for the ORM framework due to:
- Better scalability in high-concurrency scenarios
- Reduced database lock contention and deadlocks
- More flexible application-level control over conflict resolution
- Suitable for typical use cases where concurrent modifications are infrequent
Pessimistic Locking is available as an optional feature for specific high-conflict scenarios:
// Example of optimistic locking in entity
@Entity
@Table(name = "users")
public class User {
@Id
private UUID id;
@Version
private Long version;
private String name;
// When updating, framework checks if version matches
// If not, throws OptimisticLockException
}
Deadlock Prevention Strategy
Deadlock prevention includes:
- Consistent Lock Ordering: All entities are locked in a consistent order across transactions
- Timeout Configuration: Transaction timeouts set to prevent indefinite lock waits (e.g., 30 seconds)
- Retry Logic: Automatic retry with exponential backoff for deadlock scenarios
- Lock Acquisition Timeout: Configurable timeout for lock acquisition (500ms default)
Idempotency Implementation for Concurrent Duplicate Requests
Idempotency is handled at the service layer by:
- Request Deduplication: Using request IDs or unique operation keys to identify and skip duplicate operations
- Database Constraints: Unique constraints on business keys prevent creation of duplicate entities
- Idempotency Key Pattern: Framework supports idempotency keys for write operations
// Idempotency key handling in service layer
public class IdempotencyService {
private final RedisTemplate<String, String> redisTemplate;
public <T> T executeWithIdempotency(String idempotencyKey,
Supplier<T> operation) {
String cacheKey = "idempotency:" + idempotencyKey;
String result = redisTemplate.opsForValue().get(cacheKey);
if (result != null) {
return deserialize(result);
}
T operationResult = operation.get();
redisTemplate.opsForValue().set(cacheKey, serialize(operationResult),
Duration.ofMinutes(5));
return operationResult;
}
}
Thread Safety in Core Components
- Connection Pool: Thread-safe implementation using synchronized collections or concurrent data structures
- Query Builder: Immutable builders with thread-local state management
- Result Set Processing: Thread-safe result mapping with proper synchronization for shared resources
- Transaction Manager: Synchronized access to transaction context and status tracking
Concurrency Control in Batch Operations
Batch operations use:
- Thread Pool Configuration: Configurable thread pool size for parallel query execution
- Batch Size Limits: Prevents overwhelming database with too many concurrent operations
- Semaphore-based Throttling: Controls concurrent batch processing to prevent resource exhaustion
Performance Considerations
- Connection Pooling: Thread-safe connection pool with adaptive sizing based on concurrent load
- Query Caching: Thread-safe query plan caching with LRU eviction policy
- Result Set Streaming: For large datasets, streaming approach prevents memory contention
- Async Operation Support: Non-blocking operations for better throughput under concurrent load
Testing Strategy for Concurrency
- Stress Testing: Concurrent database operations with >100 simultaneous threads
- Race Condition Detection: Automated tests that simulate high-concurrency scenarios
- Deadlock Simulation: Test cases that trigger database lock contention scenarios
- Performance Benchmarking: Throughput measurements under various concurrent loads
6. Sequence Diagrams for Key Flows
Happy Path Sequence Diagram
sequenceDiagram
participant Client
participant ORMFramework
participant ConnectionPool
participant Database
Client->>ORMFramework: save(entity)
ORMFramework->>ConnectionPool: acquireConnection()
ConnectionPool-->>ORMFramework: connection
ORMFramework->>ORMFramework: generateSQL()
ORMFramework->>Database: executeStatement(sql)
Database-->>ORMFramework: result
ORMFramework->>ConnectionPool: releaseConnection()
ORMFramework-->>Client: entityId
Error / Edge Case Sequence Diagram
sequenceDiagram
participant Client
participant ORMFramework
participant ConnectionPool
participant Database
Client->>ORMFramework: save(entity)
ORMFramework->>ConnectionPool: acquireConnection()
ConnectionPool-->>ORMFramework: connection
ORMFramework->>ORMFramework: generateSQL()
ORMFramework->>Database: executeStatement(sql)
Database-->>ORMFramework: error (duplicate key)
ORMFramework->>ConnectionPool: releaseConnection()
ORMFramework-->>Client: ConflictException
Async Flow Sequence Diagram
sequenceDiagram
participant Client
participant ORMFramework
participant EventPublisher
participant MessageQueue
Client->>ORMFramework: save(entity)
ORMFramework->>ORMFramework: generateSQL()
ORMFramework->>Database: executeStatement(sql)
Database-->>ORMFramework: success
ORMFramework->>EventPublisher: publish(event)
EventPublisher->>MessageQueue: queueEvent()
MessageQueue-->>EventPublisher: acknowledged
ORMFramework-->>Client: entityId
7. Caching Design
Cache Strategy Selection
For an ORM framework, we'll use cache-aside pattern as the primary caching strategy. This approach gives us fine-grained control over cache operations, allows for better error handling, and enables flexible cache invalidation strategies. The cache-aside pattern is ideal for ORM systems because it allows the framework to make intelligent decisions about when and how to cache data based on query patterns, entity relationships, and access frequency.
Cache Key Design
Cache keys will follow a structured namespace approach to avoid collisions and enable efficient cache management:
public class CacheKey {
private final String namespace;
private final String entityType;
private final String entityId;
private final String queryHash;
public CacheKey(String namespace, String entityType, String entityId, String queryHash) {
this.namespace = namespace;
this.entityType = entityType;
this.entityId = entityId;
this.queryHash = queryHash;
}
public String getFullKey() {
return String.format("%s:%s:%s:%s", namespace, entityType, entityId, queryHash);
}
}
TTL Strategy
| Data Type | TTL | Reason |
|---|---|---|
| Entity by ID | 5 minutes | High read frequency, moderate write frequency |
| Query results | 2 minutes | Short-lived query results with potential for rapid invalidation |
| Metadata (schema, indexes) | 1 hour | Schema rarely changes, expensive to compute |
| Aggregated statistics | 10 minutes | Periodic updates, not real-time critical |
Cache Eviction Policy
We'll implement a hybrid LRU + TTL eviction policy using Redis with custom expiration logic:
public class CacheEvictionPolicy {
private final int maxEntries;
private final long ttlMillis;
public CacheEvictionPolicy(int maxEntries, long ttlMillis) {
this.maxEntries = maxEntries;
this.ttlMillis = ttlMillis;
}
public boolean shouldEvict(String key, long timestamp) {
// Check if TTL expired
if (timestamp > System.currentTimeMillis() - ttlMillis) {
return true;
}
// Check if max entries exceeded (LRU logic)
return getCacheSize() > maxEntries;
}
}
Cache Stampede Prevention
To prevent cache stampedes, we'll implement probabilistic early expiry with distributed locks:
public class CacheStampedePrevention {
private final RedisTemplate<String, String> redisTemplate;
private static final String LOCK_PREFIX = "cache_lock:";
public boolean acquireLock(String key, int timeoutSeconds) {
String lockKey = LOCK_PREFIX + key;
String lockValue = UUID.randomUUID().toString();
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, timeoutSeconds, TimeUnit.SECONDS);
return acquired != null && acquired;
}
public void releaseLock(String key, String lockValue) {
String lockKey = LOCK_PREFIX + key;
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
lockValue
);
}
}
Distributed Cache Architecture
We'll design for a Redis cluster with sentinel support:
- Primary Redis Cluster: 3 master nodes, 3 replica nodes for high availability
- Sentinel Monitoring: Automatic failover handling with health checks every 10 seconds
- Cache Sharding: Keys are sharded across cluster nodes using consistent hashing
- Cross-region replication: For global redundancy (optional)
Cache Invalidation Strategy
We'll implement a hybrid event-driven + TTL-based invalidation:
public class CacheInvalidator {
private final RedisTemplate<String, String> redisTemplate;
private final EventBus eventBus;
public void invalidateEntity(String entityType, String entityId) {
// Event-driven invalidation
eventBus.publish(new EntityInvalidatedEvent(entityType, entityId));
// TTL-based fallback (if event didn't reach)
String pattern = String.format("*:%s:%s:*", entityType, entityId);
Set<String> keys = redisTemplate.keys(pattern);
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
}
@Subscribe
public void handleEntityInvalidated(EntityInvalidatedEvent event) {
String pattern = String.format("*:%s:%s:*", event.getEntityType(), event.getEntityId());
Set<String> keys = redisTemplate.keys(pattern);
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
}
}
Cache Tier Design
The ORM framework will support a multi-tier caching strategy:
- In-Memory Cache (Local): For frequently accessed entities within the same JVM
- Distributed Cache (Redis): For cross-JVM and cross-service caching
- Query Result Cache: Specialized for complex query results with composite keys
public class MultiTierCache {
private final LocalCache localCache;
private final RedisCache distributedCache;
public <T> T get(String key, Class<T> type) {
// Try local cache first
T result = localCache.get(key, type);
if (result != null) {
return result;
}
// Try distributed cache
result = distributedCache.get(key, type);
if (result != null) {
localCache.put(key, result);
return result;
}
return null;
}
public void put(String key, Object value) {
localCache.put(key, value);
distributedCache.put(key, value);
}
}
Cache Read-Through Strategy
The ORM framework will implement read-through caching for entities:
public class CachedEntityRepository<T> {
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
public T findById(String id, Class<T> type) {
String cacheKey = buildCacheKey("entity", type.getSimpleName(), id);
// Try cache first
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return objectMapper.readValue(cached, type);
}
// Cache miss - fetch from DB
T entity = database.findById(id, type);
if (entity != null) {
// Cache the result
redisTemplate.opsForValue().set(
cacheKey,
objectMapper.writeValueAsString(entity),
300, TimeUnit.SECONDS
);
}
return entity;
}
}
Cache Write-Through Strategy
For write operations, we'll implement write-through with async invalidation:
public class CachedEntityRepository<T> {
public void save(T entity) {
String id = extractId(entity);
String cacheKey = buildCacheKey("entity", entity.getClass().getSimpleName(), id);
// Write to DB first (atomic operation)
database.save(entity);
// Update cache
redisTemplate.opsForValue().set(
cacheKey,
objectMapper.writeValueAsString(entity),
300, TimeUnit.SECONDS
);
// Schedule cache invalidation for related entities (async)
scheduleCacheInvalidation(entity);
}
}
This caching design provides a robust foundation for an ORM framework with proper cache management, prevention of stampedes, and support for multi-tier caching strategies that can scale with application needs.
8. Repository / Data Access Layer
Repository Pattern Implementation
The ORM framework implements the Repository pattern to abstract data access logic and provide a clean separation between domain objects and persistence concerns. Each entity has its own repository interface that defines the standard CRUD operations and custom queries.
public interface UserRepository extends Repository<User, Long> {
User findByEmail(String email);
List<User> findByStatus(UserStatus status);
Page<User> findAll(Pageable pageable);
long countByStatus(UserStatus status);
}
public interface OrderRepository extends Repository<Order, UUID> {
List<Order> findByUserId(Long userId);
List<Order> findByStatus(OrderStatus status);
Page<Order> findByUserIdAndStatus(Long userId, OrderStatus status, Pageable pageable);
}
ORM vs. Raw Query Decisions
The framework supports both ORM and raw query execution based on performance requirements:
- ORM Queries: Used for standard CRUD operations and simple joins where type safety and maintainability are prioritized
- Raw SQL: Used for complex analytics queries, bulk operations, and performance-critical scenarios
// ORM approach - type-safe, maintainable
List<Order> orders = orderRepository.findByUserIdAndStatus(userId, OrderStatus.PENDING);
// Raw SQL - performance optimized for analytics
List<AnalyticsResult> analytics = entityManager.createNativeQuery(
"SELECT u.name, COUNT(o.id) as order_count FROM users u " +
"JOIN orders o ON u.id = o.user_id " +
"WHERE o.created_at >= ? GROUP BY u.name",
AnalyticsResult.class)
.setParameter(1, startDate)
.getResultList();
Read vs. Write Separation
The framework implements CQRS (Command Query Responsibility Segregation) pattern for read-heavy workloads:
// Read-only repository for analytics queries
public interface AnalyticsUserRepository {
List<UserAnalytics> getActiveUsersWithOrderCount();
UserStatistics getUserStatistics(Long userId);
}
// Write repository for domain operations
public interface UserRepository {
User save(User user);
void delete(User user);
User update(User user);
}
Connection Pool Configuration
Connection pooling is configured with optimal settings for high-concurrency environments:
@Configuration
public class DatabaseConfig {
@Bean
public HikariDataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/mydb");
config.setUsername("user");
config.setPassword("password");
// Pool configuration
config.setMaximumPoolSize(20);
config.setMinimumIdle(5);
config.setConnectionTimeout(30000);
config.setIdleTimeout(600000);
config.setMaxLifetime(1800000);
config.setLeakDetectionThreshold(60000);
return new HikariDataSource(config);
}
}
N+1 Query Prevention Strategy
The framework provides eager loading capabilities and batch fetch strategies to prevent N+1 query problems:
// Using @Fetch annotation for batch loading
@Entity
public class User {
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@Fetch(FetchMode.BATCH) // Batch size 10
private List<Order> orders;
}
// Manual eager loading in repository query
@Query("SELECT u FROM User u JOIN FETCH u.orders o WHERE u.id = :userId")
User findWithOrders(@Param("userId") Long userId);
// Using DataLoader pattern for complex object graphs
public class UserDataLoader {
private final Map<Long, CompletableFuture<User>> userCache = new ConcurrentHashMap<>();
public CompletableFuture<User> loadUser(Long userId) {
return userCache.computeIfAbsent(userId, id -> {
return CompletableFuture.supplyAsync(() -> userRepository.findById(id));
});
}
}
Batch Operation Design
Batch operations are implemented with optimized bulk insert/update/delete capabilities:
public class BatchRepository<T> {
private final EntityManager entityManager;
public void batchInsert(List<T> entities, int batchSize) {
for (int i = 0; i < entities.size(); i += batchSize) {
int end = Math.min(i + batchSize, entities.size());
List<T> batch = entities.subList(i, end);
// Use JDBC batch operations for better performance
String sql = buildInsertSQL(batch.get(0));
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
for (T entity : batch) {
setParameters(stmt, entity);
stmt.addBatch();
}
stmt.executeBatch();
} catch (SQLException e) {
throw new DataAccessException("Batch insert failed", e);
}
}
}
public void batchUpdate(List<T> entities, int batchSize) {
// Similar implementation for updates
}
}
Query Builder Pattern
The framework provides a fluent query builder for complex queries:
public class QueryBuilder<T> {
private final Class<T> entityClass;
private final StringBuilder query = new StringBuilder();
private final Map<String, Object> parameters = new HashMap<>();
public QueryBuilder<T> select(String... fields) {
query.append("SELECT ");
query.append(String.join(", ", fields));
query.append(" FROM ").append(entityClass.getSimpleName());
return this;
}
public QueryBuilder<T> where(String condition, Object... params) {
query.append(" WHERE ").append(condition);
for (int i = 0; i < params.length; i++) {
parameters.put("param" + i, params[i]);
}
return this;
}
public QueryBuilder<T> orderBy(String field, boolean ascending) {
query.append(" ORDER BY ").append(field);
query.append(ascending ? " ASC" : " DESC");
return this;
}
public List<T> buildAndExecute() {
// Execute query with parameters
return entityManager.createQuery(query.toString(), entityClass)
.setParameters(parameters)
.getResultList();
}
}
Transaction Management
Repository methods are wrapped in proper transaction boundaries:
@Repository
public class UserRepositoryImpl implements UserRepository {
@PersistenceContext
private EntityManager entityManager;
@Transactional
public User save(User user) {
if (user.getId() == null) {
entityManager.persist(user);
return user;
} else {
return entityManager.merge(user);
}
}
@Transactional(readOnly = true)
public User findById(Long id) {
return entityManager.find(User.class, id);
}
@Transactional
public void delete(User user) {
entityManager.remove(user);
}
}
Error Handling and Retry Logic
Repository layer includes robust error handling with retry capabilities:
@Repository
public class RetryableRepository<T> {
private static final int MAX_RETRIES = 3;
private static final long BASE_DELAY_MS = 1000;
public T executeWithRetry(Supplier<T> operation) {
Exception lastException = null;
for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return operation.get();
} catch (DataAccessException e) {
lastException = e;
if (attempt < MAX_RETRIES && isRetryable(e)) {
long delay = BASE_DELAY_MS * (1L << attempt) +
ThreadLocalRandom.current().nextInt(1000);
try {
Thread.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new DataAccessException("Interrupted during retry", ie);
}
} else {
break;
}
}
}
throw new DataAccessException("Operation failed after " + MAX_RETRIES + " retries", lastException);
}
private boolean isRetryable(DataAccessException e) {
// Check if exception is retryable (e.g., connection timeout, deadlock)
return e.getCause() instanceof SQLException &&
((SQLException) e.getCause()).getErrorCode() == 40001; // Deadlock
}
}
9. Asynchronous Processing & Messaging
Event-driven boundaries in this feature
The ORM framework's asynchronous processing is primarily concerned with event publishing from entity operations and background task execution for bulk operations or data synchronization. The key event-driven boundaries are:
- Entity lifecycle events: When entities are created, updated, or deleted, the ORM framework publishes domain events to a message broker (e.g., Kafka or RabbitMQ) for downstream consumers.
- Bulk operation completion: Long-running bulk operations (e.g., batch inserts, data migrations) publish completion events to notify listeners.
- Audit trail generation: Asynchronous audit logging for sensitive operations (e.g., user data modifications) to decouple from the main transaction.
These boundaries ensure that the ORM remains lightweight and responsive while enabling extensibility through event-driven architecture.
Message Schema Design
We define a standardized message schema using Protobuf for type-safe, versioned communication:
// MessageSchema.proto
syntax = "proto3";
package orm.events;
message EntityEvent {
string event_id = 1;
string entity_type = 2;
string operation = 3; // CREATE, UPDATE, DELETE
string entity_id = 4;
bytes payload = 5; // JSON or serialized object
int64 timestamp = 6;
string user_id = 7;
string correlation_id = 8;
}
message BulkOperationEvent {
string operation_id = 1;
string operation_type = 2; // BATCH_INSERT, MIGRATION
int32 total_items = 3;
int32 processed_items = 4;
string status = 5; // IN_PROGRESS, COMPLETED, FAILED
string error_message = 6;
}
Versioning is handled via protobuf package versioning (orm.events.v1, orm.events.v2) to support backward compatibility.
Dead Letter Queue Strategy and Poison Message Handling
We implement a Dead Letter Queue (DLQ) strategy to handle failed message consumption:
- Messages that fail more than 3 retries are sent to a DLQ topic.
- Poison messages (malformed or consistently failing) are logged with metadata for manual inspection.
- A dedicated DLQ consumer monitors the DLQ and alerts on persistent failures.
At-least-once vs. Exactly-once Delivery
We choose at-least-once delivery for ORM events:
- Justification: The ORM framework is not responsible for business logic consistency; it only ensures that domain events are published.
- Idempotency: Consumers must implement idempotent handling for operations like
CREATEorUPDATE. - Use case: For audit logs and external system synchronization, eventual consistency is acceptable.
Retry Policy for Consumers
Retry policy for consumers:
- Max attempts: 3 retries
- Backoff strategy: Exponential backoff with jitter (
1s,2s,4s) - Non-retryable errors: Invalid message format, authorization failures
- Retry logic:
public class RetryPolicy { private static final int MAX_RETRIES = 3; private static final long BASE_DELAY_MS = 1000L; public boolean shouldRetry(int attempt, Exception error) { return attempt < MAX_RETRIES && !(error instanceof InvalidMessageException); } public long getDelay(int attempt) { return (1L << attempt) * BASE_DELAY_MS + ThreadLocalRandom.current().nextInt(1000); } }
Outbox Pattern for Reliable Event Publishing
We implement the outbox pattern to ensure that ORM events are published reliably:
- When an entity operation occurs, the ORM framework writes a
PendingEventto a local database table. - A background task polls this table and publishes events to the message broker.
- If publishing fails, the event remains in the pending state for retry.
This ensures that even if the message broker is unavailable, events are not lost and will be retried.
Sequence Diagram for Async Flows
sequenceDiagram
participant ORM as ORM Framework
participant DB as Local DB
participant EventPublisher as Event Publisher
participant MessageBroker as Kafka/RabbitMQ
ORM->>DB: Persist PendingEvent (CREATE, UPDATE, DELETE)
DB-->>ORM: Acknowledge
ORM->>EventPublisher: Trigger async publish
EventPublisher->>MessageBroker: Publish EntityEvent
MessageBroker-->>EventPublisher: Acknowledge
EventPublisher->>DB: Mark event as published
This sequence ensures that events are persisted before publishing, providing durability guarantees for asynchronous processing.
10. Error Handling Design
Error Classification Hierarchy
The ORM framework implements a structured error handling system with three primary error categories:
- Domain Errors - Business logic violations (e.g., invalid entity state, constraint violations)
- Infrastructure Errors - Data access and persistence issues (e.g., connection failures, query timeouts)
- Validation Errors - Input validation failures (e.g., missing required fields, invalid data types)
public abstract class OrmException extends RuntimeException {
private final ErrorCode errorCode;
protected OrmException(ErrorCode code, String message) {
super(message);
this.errorCode = code;
}
protected OrmException(ErrorCode code, String message, Throwable cause) {
super(message, cause);
this.errorCode = code;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}
public class DomainException extends OrmException {
public DomainException(ErrorCode code, String message) {
super(code, message);
}
public DomainException(ErrorCode code, String message, Throwable cause) {
super(code, message, cause);
}
}
public class InfrastructureException extends OrmException {
public InfrastructureException(ErrorCode code, String message) {
super(code, message);
}
public InfrastructureException(ErrorCode code, String message, Throwable cause) {
super(code, message, cause);
}
}
public class ValidationException extends OrmException {
public ValidationException(ErrorCode code, String message) {
super(code, message);
}
public ValidationException(ErrorCode code, String message, Throwable cause) {
super(code, message, cause);
}
}
Error Propagation Strategy
Errors are propagated up the call stack with appropriate wrapping to preserve context:
- Infrastructure errors are wrapped in
InfrastructureExceptionand re-thrown as-is to allow upstream services to handle database-specific issues - Domain errors are wrapped in
DomainExceptionand re-thrown to maintain business logic context - Validation errors are wrapped in
ValidationExceptionand immediately handled at the service boundary
Retry Strategy with Exponential Backoff and Jitter
For transient infrastructure errors (e.g., connection timeouts, deadlocks), the framework implements a retry mechanism:
public class RetryableOperation {
private static final int MAX_RETRIES = 3;
private static final long BASE_DELAY_MS = 1000;
private static final double JITTER_FACTOR = 0.2;
public <T> T executeWithRetry(Supplier<T> operation) {
int attempts = 0;
while (true) {
try {
return operation.get();
} catch (InfrastructureException e) {
if (attempts >= MAX_RETRIES || !isRetryable(e)) {
throw e;
}
long delay = calculateDelay(attempts);
try {
Thread.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new InfrastructureException(ErrorCode.RETRY_INTERRUPTED, "Retry interrupted", ie);
}
attempts++;
}
}
}
private boolean isRetryable(InfrastructureException e) {
return e.getErrorCode() == ErrorCode.CONNECTION_TIMEOUT ||
e.getErrorCode() == ErrorCode.DEADLOCK ||
e.getErrorCode() == ErrorCode.QUERY_TIMEOUT;
}
private long calculateDelay(int attempt) {
long delay = (long) (BASE_DELAY_MS * Math.pow(2, attempt));
long jitter = (long) (delay * JITTER_FACTOR * (Math.random() - 0.5) * 2);
return delay + jitter;
}
}
Circuit Breaker Design
For external database connections or third-party integrations, the framework implements a circuit breaker pattern:
public class CircuitBreaker {
private final int failureThreshold;
private final long timeoutMs;
private final int successThreshold;
private volatile CircuitState state = CircuitState.CLOSED;
private volatile int failureCount = 0;
private volatile long lastFailureTime = 0;
public enum CircuitState {
CLOSED, OPEN, HALF_OPEN
}
public <T> T execute(Supplier<T> operation) {
switch (state) {
case CLOSED:
return executeClosed(operation);
case OPEN:
return executeOpen(operation);
case HALF_OPEN:
return executeHalfOpen(operation);
default:
throw new IllegalStateException("Unknown circuit state");
}
}
private <T> T executeClosed(Supplier<T> operation) {
try {
T result = operation.get();
onSuccess();
return result;
} catch (Exception e) {
onFailure(e);
throw e;
}
}
private <T> T executeOpen(Supplier<T> operation) {
if (System.currentTimeMillis() - lastFailureTime > timeoutMs) {
state = CircuitState.HALF_OPEN;
throw new InfrastructureException(ErrorCode.CIRCUIT_OPEN, "Circuit breaker is open");
}
throw new InfrastructureException(ErrorCode.CIRCUIT_OPEN, "Circuit breaker is open");
}
private <T> T executeHalfOpen(Supplier<T> operation) {
try {
T result = operation.get();
state = CircuitState.CLOSED;
failureCount = 0;
return result;
} catch (Exception e) {
state = CircuitState.OPEN;
lastFailureTime = System.currentTimeMillis();
throw e;
}
}
private void onSuccess() {
if (state == CircuitState.HALF_OPEN) {
failureCount = 0;
state = CircuitState.CLOSED;
}
}
private void onFailure(Exception e) {
if (state == CircuitState.CLOSED) {
failureCount++;
lastFailureTime = System.currentTimeMillis();
if (failureCount >= failureThreshold) {
state = CircuitState.OPEN;
}
}
}
}
Fallback Behavior Design
For operations that can provide fallback behavior:
public class FallbackHandler {
public <T> T executeWithFallback(Supplier<T> operation, Supplier<T> fallback) {
try {
return operation.get();
} catch (InfrastructureException e) {
if (isFallbackApplicable(e)) {
return fallback.get();
}
throw e;
}
}
private boolean isFallbackApplicable(InfrastructureException e) {
return e.getErrorCode() == ErrorCode.CACHE_MISS ||
e.getErrorCode() == ErrorCode.QUERY_TIMEOUT;
}
}
Error Logging Format and Alerting Thresholds
All ORM exceptions are logged with structured context:
public class OrmErrorLogger {
private static final Logger logger = LoggerFactory.getLogger(OrmErrorLogger.class);
public void logError(OrmException exception, Map<String, Object> context) {
logger.error(
"ORM Error [errorCode={}, message={}, traceId={}]",
exception.getErrorCode(),
exception.getMessage(),
context.get("traceId"),
exception
);
}
public void logErrorWithMetrics(OrmException exception, String operation) {
// Log structured error
logger.error(
"ORM Error [errorCode={}, message={}, operation={}, userId={}, entityId={}]",
exception.getErrorCode(),
exception.getMessage(),
operation,
context.get("userId"),
context.get("entityId"),
exception
);
// Increment error metrics
Metrics.counter("orm.errors", "operation", operation, "error_code", exception.getErrorCode().name()).increment();
}
}
Alerting thresholds:
- Critical: > 100 errors per minute for any operation
- Warning: > 50 errors per minute for any operation
- Info: > 10 errors per minute for any operation
Error Code Definitions
public enum ErrorCode {
// Validation errors
INVALID_INPUT,
MISSING_REQUIRED_FIELD,
// Infrastructure errors
CONNECTION_TIMEOUT,
QUERY_TIMEOUT,
DEADLOCK,
DATABASE_ERROR,
// Domain errors
ENTITY_NOT_FOUND,
CONSTRAINT_VIOLATION,
CONCURRENT_MODIFICATION,
// Retry and circuit breaker
RETRY_INTERRUPTED,
CIRCUIT_OPEN,
// Internal errors
INTERNAL_ERROR,
UNEXPECTED_ERROR
}
11. Security Design
Input Validation and Sanitization Strategy
Input validation is implemented at multiple layers using a combination of:
- Schema validation with JSON Schema for API requests
- Custom validators for domain-specific constraints (e.g., email format, phone number patterns)
- SQL injection prevention through parameterized queries and ORM query builders
- HTML sanitization using OWASP Java HTML Sanitizer for user-generated content
public class InputValidator {
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$");
public static boolean isValidEmail(String email) {
return email != null && EMAIL_PATTERN.matcher(email).matches();
}
public static String sanitizeHtml(String input) {
if (input == null) return null;
return HtmlSanitizer.sanitize(input);
}
}
SQL Injection Prevention
All database queries are constructed using parameterized statements and ORM query builders to prevent SQL injection attacks. Raw SQL execution is strictly prohibited in the ORM framework.
// Example of safe query construction
public List<User> findUsersByEmail(String email) {
return entityManager.createQuery(
"SELECT u FROM User u WHERE u.email = :email",
User.class)
.setParameter("email", email)
.getResultList();
}
Authentication Middleware Design
Authentication middleware is implemented as a filter that:
- Validates JWT tokens for API requests
- Extracts user context from token claims
- Sets security context in thread-local storage for subsequent service layer operations
@Component
public class AuthenticationFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String token = extractToken(httpRequest);
if (token != null && validateToken(token)) {
AuthenticationContext context = parseToken(token);
SecurityContextHolder.setContext(context);
}
chain.doFilter(request, response);
}
}
Authorization Enforcement
Authorization is enforced using Role-Based Access Control (RBAC) at the service layer:
@PreAuthorize("hasRole('ADMIN') or @userSecurityService.isOwner(#userId)")
public void deleteUser(Long userId) {
// Implementation
}
// Custom authorization check for sensitive operations
public boolean canAccessResource(String resourceType, String resourceId) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
return false;
}
String userRole = auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.findFirst()
.orElse("USER");
return "ADMIN".equals(userRole) ||
resourceOwnerChecker.isOwner(resourceType, resourceId);
}
Sensitive Data Encryption
At Rest: All sensitive data (passwords, PII) is encrypted using AES-256 with a key rotation strategy:
@Component
public class DataEncryptionService {
private final Cipher cipher;
public String encrypt(String plainText) throws Exception {
SecretKeySpec keySpec = new SecretKeySpec(
encryptionKeyProvider.getCurrentKey(), "AES");
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
byte[] encrypted = cipher.doFinal(plainText.getBytes());
return Base64.getEncoder().encodeToString(encrypted);
}
}
In Transit: All API communications use TLS 1.3 with HSTS header enforcement:
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.requiresChannel(channel ->
channel.requestMatchers(r -> r.getHeader("X-Forwarded-Proto") != null)
.requiresSecure());
return http.build();
}
}
PII Handling and Masking Strategy
PII data is handled according to the following policies:
- Masking: Email addresses are masked (e.g., "john.doe@example.com" → "j***@example.com")
- Redaction: Full PII is never logged or stored in plain text
- Encryption: All PII fields are encrypted at rest
public class PiiMasker {
public static String maskEmail(String email) {
if (email == null || email.isEmpty()) return email;
int atIndex = email.indexOf('@');
if (atIndex <= 1) return email;
String localPart = email.substring(0, atIndex);
String domain = email.substring(atIndex);
return localPart.charAt(0) + "****" + domain;
}
}
Secrets Management
Secrets are managed through AWS Secrets Manager with:
- Automatic rotation every 90 days
- Secure retrieval using IAM roles and policies
- Cache with TTL to minimize API calls
@Component
public class SecretManager {
private final AWSSecretsManager secretsManager;
public String getSecret(String secretName) {
GetSecretValueRequest request = GetSecretValueRequest.builder()
.secretId(secretName)
.build();
return secretsManager.getSecretValue(request).secretString();
}
}
Audit Logging Design
All security-relevant operations are logged with:
- Timestamps in UTC
- User identifiers and session IDs
- Action type and success/failure status
- IP address and user agent information
@Component
public class SecurityAuditLogger {
public void logSecurityEvent(String action, String userId,
String ipAddress, boolean success) {
AuditLog log = AuditLog.builder()
.timestamp(Instant.now())
.userId(userId)
.action(action)
.ipAddress(ipAddress)
.success(success)
.userAgent(getUserAgent())
.build();
auditLogRepository.save(log);
}
}
Security Headers Implementation
Security headers are added to all HTTP responses:
@Component
public class SecurityHeadersFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setHeader("X-Content-Type-Options", "nosniff");
httpResponse.setHeader("X-Frame-Options", "DENY");
httpResponse.setHeader("X-XSS-Protection", "1; mode=block");
httpResponse.setHeader("Strict-Transport-Security",
"max-age=31536000; includeSubDomains");
chain.doFilter(request, response);
}
}
12. Testing Strategy
Unit Testing Scope and Coverage Targets (%)
- Service Layer: 85% code coverage with focus on business logic, edge cases, and error conditions
- Repository Layer: 90% coverage with emphasis on query correctness and data mapping
- Entity Mapping: 100% coverage for all entity-to-database field mappings and validation rules
- Core ORM Components: 95% coverage for query building, parameter binding, and transaction handling
- Integration Points: 100% coverage for cross-component interactions (e.g., entity lifecycle hooks, event publishing)
Integration Testing Strategy
- Database Integration: Use Testcontainers with PostgreSQL/MySQL to test actual database interactions, including schema migrations and query execution
- Repository Layer: Test repository methods against real database connections with proper transaction isolation
- Event Publishing: Validate event publishing through embedded Kafka/Redis mock instances to ensure correct message format and routing
- Transaction Boundaries: Use embedded database with rollback transactions to test atomicity and consistency guarantees
Contract Testing
- API Contract Testing: Implement Pact-based contract testing between ORM service and consuming microservices to ensure message format compatibility
- Database Schema Contract: Validate that database schema changes don't break existing ORM mappings through schema evolution tests
- External Dependency Contracts: Test integration points with external systems (e.g., database drivers) using WireMock or equivalent
Performance Testing Tool and Key Scenarios
- Tool: k6 for load testing, JMeter for complex transaction scenarios
- Key Scenarios:
- High-concurrency query execution (1000 concurrent read operations)
- Batch operation performance (1000 entities in single transaction)
- Large result set handling (100K+ records with pagination)
- Transaction throughput under load (500 concurrent transactions)
- Metrics Tracked: Throughput, latency percentiles, memory usage, database connection pool utilization
Test Data Management Strategy
- Fixtures: Use JSON/YAML fixtures for static test data with clear schema definitions
- Factories: Implement Factory pattern for generating test entities with realistic but non-sensitive data
- Seeding: Use database migration scripts to seed test environments with consistent baseline data
- Isolation: Each test runs in isolated database transaction that gets rolled back after execution to ensure test independence
- Data Generation: For performance testing, generate synthetic data sets with controlled distribution patterns to simulate real-world usage
// Example unit test for entity mapping validation
@Test
public void testEntityMappingValidation() {
// Given
TestEntity entity = new TestEntity();
entity.setName("valid-name");
// When
ValidationContext context = new ValidationContext();
boolean isValid = entityValidator.validate(entity, context);
// Then
assertTrue(isValid);
assertEquals(0, context.getErrors().size());
}
// Example integration test for query execution
@Test
public void testQueryExecutionPerformance() {
// Given
List<TestEntity> entities = createTestData(1000);
// When
long startTime = System.nanoTime();
List<TestEntity> results = repository.findAll();
long endTime = System.nanoTime();
// Then
assertEquals(1000, results.size());
long executionTime = endTime - startTime;
assertTrue("Query should complete under 100ms", executionTime < 100_000_000);
}
13. Observability Design
Structured Logging Schema
The ORM framework will implement structured logging with consistent schema across all services and components. Each log entry will include:
{
"timestamp": "2023-10-15T14:30:45.123Z",
"level": "INFO",
"service": "orm-framework",
"trace_id": "12345678-1234-1234-1234-123456789012",
"span_id": "abcdef12-3456-7890-abcd-ef1234567890",
"user_id": "user_12345",
"entity_id": "order_98765",
"operation": "query",
"sql": "SELECT * FROM orders WHERE user_id = ?",
"duration_ms": 45,
"error_code": "DB_CONNECTION_TIMEOUT",
"error_message": "Connection pool exhausted",
"stack_trace": "..."
}
PII masking rules:
- User IDs and entity IDs will be logged in masked format (e.g.,
user_12345→user_XXXXX) - SQL queries will mask sensitive data in WHERE clauses with parameter placeholders
- Stack traces will exclude sensitive method names and parameters
Distributed Tracing Instrumentation
The framework will integrate with OpenTelemetry for distributed tracing:
Custom spans to add:
orm.query.execute- For query execution durationorm.connection.acquire- For connection pool acquisition timingorm.transaction.begin/commit/rollback- Transaction lifecycleorm.cache.hit/miss- Cache operation metrics
Attributes to capture per key operation:
Span span = tracer.spanBuilder("orm.query.execute")
.setAttribute("sql", query)
.setAttribute("parameters", parameters)
.setAttribute("entity_type", entityType)
.setAttribute("connection_pool_size", poolSize)
.setAttribute("retry_count", retryCount)
.startSpan();
Metrics Design
RED Method metrics (Rate, Errors, Duration):
// Counter for query operations
Counter queryCounter = meter.counterBuilder("orm.query.count")
.setDescription("Number of ORM queries executed")
.build();
// Histogram for query duration
Histogram queryDuration = meter.histogramBuilder("orm.query.duration.ms")
.setDescription("Query execution duration in milliseconds")
.setUnit("ms")
.build();
// Counter for database connection errors
Counter dbErrorCounter = meter.counterBuilder("orm.db.errors")
.setDescription("Database connection or query execution errors")
.build();
// Gauge for active connections
Gauge activeConnections = meter.gaugeBuilder("orm.connection.active")
.setDescription("Current number of active database connections")
.build();
Labels for metrics:
operation(query, update, delete, transaction)entity_type(User, Order, Product)error_code(DB_TIMEOUT, CONNECTION_POOL_EXHAUSTED, etc.)pool_size(size of connection pool)is_cached(true/false)
Health Check Endpoint Design
@RestController
public class HealthController {
@GetMapping("/health")
public ResponseEntity<HealthStatus> health() {
HealthStatus status = new HealthStatus();
status.setStatus("healthy");
status.setTimestamp(Instant.now());
status.setDatabaseHealthy(isDatabaseHealthy());
status.setCacheHealthy(isCacheHealthy());
return ResponseEntity.ok(status);
}
@GetMapping("/ready")
public ResponseEntity<ReadyStatus> ready() {
ReadyStatus status = new ReadyStatus();
status.setDatabaseReady(isDatabaseReady());
status.setCacheReady(isCacheReady());
return ResponseEntity.ok(status);
}
@GetMapping("/live")
public ResponseEntity<LiveStatus> live() {
LiveStatus status = new LiveStatus();
status.setTimestamp(Instant.now());
status.setUptime(Duration.ofMillis(System.currentTimeMillis() - startTime));
return ResponseEntity.ok(status);
}
}
Alerting Thresholds
Critical alerts (require immediate on-call response):
orm.db.errors> 100 per minute (5 min window)orm.query.duration.p99> 5000ms (5 min window)orm.connection.active< 1 for > 30 seconds
Warning alerts (monitoring):
orm.db.errors> 10 per minute (5 min window)orm.query.duration.p99> 2000ms (5 min window)orm.connection.active< 5 for > 2 minutes
On-call escalation path:
- First alert → Slack notification to ORM team
- Second alert within 5 min → Email to engineering manager
- Third alert within 10 min → On-call engineer page with incident creation
Service-Level Observability Signals
Metrics dashboard includes:
- Query performance (p50/p95/p99 durations)
- Connection pool utilization
- Cache hit ratio (by entity type)
- Transaction success/failure rates
- Error distribution by type and operation
Log aggregation:
- All logs sent to centralized logging system (e.g., ELK stack)
- Error logs tagged with error codes for filtering
- SQL query logs with performance metrics for slow query analysis
Trace correlation:
- All operations linked via trace_id across service boundaries
- Span relationships visualized in Jaeger or Zipkin
- Error spans tagged with error details for root cause analysis
14. Open Questions, Risks & Trade-offs
Decisions requiring product / engineering alignment
- ORM vs. Raw SQL trade-off: Should the framework provide first-class support for raw SQL queries, or enforce strict ORM usage? This decision affects developer productivity vs. query optimization control.
- Database vendor support strategy: Should we support multiple databases (PostgreSQL, MySQL, Oracle) or focus on a single database type? This impacts complexity and testing requirements.
- Query execution model: Should queries execute synchronously or support async execution patterns? This affects performance characteristics and developer experience.
- Versioning strategy: How should we handle breaking changes in the ORM API? Should we use semantic versioning or follow database driver versioning?
Known trade-offs and justifications
-
Performance vs. Developer Experience:
- Trade-off: Optimizing for raw query performance (via direct SQL) vs. providing high-level abstractions that simplify development
- Justification: The ORM framework prioritizes developer productivity and maintainability over micro-optimizations, as most applications benefit more from clean code than marginal query performance gains
-
Flexibility vs. Consistency:
- Trade-off: Offering extensive customization options vs. maintaining consistent, predictable behavior
- Justification: Consistent API design and predictable behavior reduce cognitive load for developers and minimize integration issues
-
Feature Completeness vs. Simplicity:
- Trade-off: Supporting all database features vs. focusing on core CRUD operations and common patterns
- Justification: Starting with essential features ensures a solid foundation before adding complexity
Risks and mitigation strategies
-
Query Performance Degradation:
- Risk: ORM-generated queries may be less efficient than hand-crafted SQL
- Mitigation: Provide query logging, performance monitoring, and raw SQL escape hatches; implement query plan analysis
-
Complexity of Join Handling:
- Risk: Complex join scenarios may lead to N+1 query problems or inefficient query generation
- Mitigation: Implement eager loading strategies, provide join hints, and include performance warnings
-
Database Schema Evolution:
- Risk: ORM mapping changes may not align with actual database schema evolution
- Mitigation: Include schema validation tools, migration support, and clear documentation for mapping changes
-
Memory Overhead:
- Risk: Object mapping and change tracking may increase memory consumption
- Mitigation: Implement lazy loading, object pooling, and provide configuration options for memory usage
Technical debt acknowledged
- Limited Query Optimization: The framework may not support advanced query optimization techniques that database-specific tools provide, potentially leading to suboptimal query execution plans.
- Limited Database-Specific Features: Supporting only generic ORM features may exclude database-specific optimizations and advanced features like window functions, CTEs, or stored procedures.
- Testing Complexity: Comprehensive testing of ORM behavior across different database vendors and query patterns requires extensive test coverage that may not be fully achievable in initial releases.
- Migration Path: Future ORM version upgrades may require significant refactoring of existing code, particularly around query composition and mapping patterns.
- Performance Monitoring Gaps: The framework may not provide sufficient visibility into query execution performance, making it difficult to identify and address performance bottlenecks.