A robust data access layer abstraction serves as the critical bridge between application logic and persistent storage, enabling clean separation of concerns while providing flexibility for database migration, caching strategies, and transaction management across diverse backend architectures.
1. Problem Statement & Requirements
Clarifying questions
- What scale and throughput expectations are we targeting?
- Are we designing for millions of concurrent database connections or for a smaller-scale internal tool?
- What consistency requirements are there?
- Do we need strong consistency across all reads/writes, or can eventual consistency be acceptable for some operations?
- What are the key entities and their relationships?
- Is this a monolithic system or microservices architecture? What are the main domain entities (e.g., User, Order, Product)?
- What is the read/write ratio?
- Is this primarily a write-heavy or read-heavy system? How does that impact caching and data access patterns?
- What latency sensitivity is expected?
- Are sub-100ms response times critical, or can we tolerate higher latencies for less time-sensitive operations?
- What authentication and authorization model is in place?
- Are we integrating with OAuth2, JWTs, or API keys? Do we need to enforce RBAC or ABAC?
- Are there any external integrations or third-party data sources?
- Do we need to support data from other databases, cloud storage services, or external APIs?
Functional requirements
- Support CRUD operations (Create, Read, Update, Delete) for domain entities
- Provide abstraction over multiple data sources (SQL, NoSQL, in-memory)
- Allow for query composition and filtering
- Support transactional operations across multiple entities
- Enable data hydration (e.g., loading related entities in one operation)
- Support soft-deletes and audit logging
- Allow for data partitioning (sharding) and horizontal scaling
Non-functional requirements
- Latency targets:
- p50: <50ms
- p95: <100ms
- p99: <200ms
- Throughput:
- 10,000 RPS (reads + writes) for the data access layer
- Availability:
- 99.9% uptime (99.99% for critical paths)
- Scalability:
- Support horizontal scaling of data access layer components
Constraints and assumptions
- Assumption: The system will use a mix of relational (PostgreSQL) and document-based (MongoDB) databases
- Assumption: The application is built in Java with Spring Boot framework
- Constraint: Must support both synchronous and asynchronous data access patterns
- Constraint: Must be compatible with existing ORM tooling (e.g., Hibernate, Spring Data JPA)
- Assumption: The system will use a shared database connection pool across services
Out of scope
- Database schema migration management (e.g., Flyway, Liquibase)
- Data encryption at rest or in transit
- Real-time data streaming (e.g., Kafka integration)
- Complex analytics or aggregation queries (e.g., windowed aggregates, full-text search)
- Multi-tenancy support for data isolation
- Data deduplication or conflict resolution logic
2. API Design
API Style Selection
For this Data Access Layer abstraction, I'll select gRPC as the primary API style with JSON over HTTP as a fallback for client compatibility and debugging purposes.
Justification:
- gRPC provides strong typing, code generation, and efficient binary serialization
- Built-in support for streaming and bidirectional communication
- First-class support for versioning through proto files
- Better performance characteristics than REST for high-throughput scenarios
- Native support for authentication and authorization mechanisms
API Endpoints
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /v1/data-access/entities/{entityType} | Fetch entities by type with filters | JWT |
| POST | /v1/data-access/entities | Create new entities | JWT |
| PUT | /v1/data-access/entities/{id} | Update existing entity by ID | JWT |
| DELETE | /v1/data-access/entities/{id} | Delete entity by ID | JWT |
| GET | /v1/data-access/entities/{id}/relations | Fetch entity relations | JWT |
| POST | /v1/data-access/entities/{id}/relations | Create relation between entities | JWT |
| GET | /v1/data-access/query | Execute complex queries with filters and aggregations | JWT |
| POST | /v1/data-access/batch | Batch operations on entities | JWT |
Request and Response Schemas
Create Entity Request:
{
"entityType": "user",
"data": {
"name": "John Doe",
"email": "john@example.com"
}
}
Create Entity Response:
{
"id": "uuid-12345",
"entityType": "user",
"data": {
"name": "John Doe",
"email": "john@example.com"
},
"createdAt": "2023-01-01T10:00:00Z",
"updatedAt": "2023-01-01T10:00:00Z"
}
Query Request:
{
"entityType": "user",
"filters": [
{
"field": "email",
"operator": "EQUALS",
"value": "john@example.com"
}
],
"sort": [
{
"field": "createdAt",
"direction": "DESC"
}
],
"limit": 10,
"offset": 0
}
Query Response:
{
"entities": [
{
"id": "uuid-12345",
"entityType": "user",
"data": {
"name": "John Doe",
"email": "john@example.com"
}
}
],
"total": 1,
"hasMore": false
}
HTTP Status Codes and Error Format
Standard Status Codes:
- 200 OK - Successful GET, PUT, DELETE
- 201 Created - Successful POST
- 400 Bad Request - Invalid input or malformed request
- 401 Unauthorized - Missing or invalid auth token
- 403 Forbidden - Insufficient permissions
- 404 Not Found - Entity not found
- 429 Too Many Requests - Rate limit exceeded
- 500 Internal Server Error - Server-side error
Standardized Error Response:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid email format",
"details": [
{
"field": "email",
"message": "Email must be in valid format"
}
],
"timestamp": "2023-01-01T10:00:00Z",
"requestId": "req-12345"
}
}
API Versioning Strategy
Versioning will be implemented through the path prefix:
/v1/data-access/...- Current stable version- Future versions will use
/v2/data-access/...for breaking changes
Versioning will be enforced at the service level, with automatic migration paths documented in release notes.
Pagination Design
Cursor-based pagination will be used for better performance and consistency:
Pagination Parameters:
cursor- Base64 encoded cursor for next pagelimit- Number of items to return (default 50, max 1000)direction- ASC/DESC for ordering (default ASC)
Justification:
- Cursor-based pagination is more stable and handles data changes better than offset-based
- Provides consistent results even when data is modified during pagination
- Better performance for large datasets
Authentication and Authorization
Authentication:
- JWT tokens with HS256 signing algorithm
- Token expiration of 24 hours
- Refresh token support with 7-day validity
Authorization:
- RBAC model with roles (ADMIN, USER, READ_ONLY)
- Entity-level permissions based on user role and ownership
- Resource-based access control for sensitive operations
Rate Limiting
Rate Limits:
- 1000 requests per hour per user (hard limit)
- 50 requests per second per user (soft limit)
- 10000 requests per hour per IP (for unauthenticated access)
Enforcement Strategy:
- Distributed rate limiting using Redis with token bucket algorithm
- Leaky bucket algorithm for burst handling
- Immediate rejection of requests exceeding limits
Idempotency Design
Idempotency Key Strategy:
- Client-provided
idempotency-keyheader for all write operations - Server stores operation metadata with key as primary identifier
- If same key is used, return cached result instead of re-executing
- Key expiration after 24 hours
Implementation:
public class IdempotencyService {
private final RedisTemplate<String, String> redisTemplate;
public ResponseEntity<?> executeWithIdempotency(
String idempotencyKey,
Supplier<ResponseEntity<?>> operation) {
String cacheKey = "idempotency:" + idempotencyKey;
String cachedResult = redisTemplate.opsForValue().get(cacheKey);
if (cachedResult != null) {
return ResponseEntity.ok().body(cachedResult);
}
ResponseEntity<?> result = operation.get();
redisTemplate.opsForValue().set(
cacheKey,
result.getBody().toString(),
Duration.ofHours(24)
);
return result;
}
}
3. Data Model Design
Entity-Relationship diagram (Mermaid)
erDiagram
USER ||--o{ ORDER : places
USER ||--o{ PAYMENT : makes
ORDER ||--o{ ORDER_ITEM : contains
ORDER_ITEM ||--o{ PRODUCT : references
PRODUCT ||--o{ INVENTORY : has
ORDER ||--o{ SHIPPING : uses
SHIPPING ||--o{ ADDRESS : delivers_to
USER {
string id PK
string email
string name
timestamp created_at
timestamp updated_at
}
ORDER {
string id PK
string user_id FK
string status
decimal total_amount
timestamp created_at
timestamp updated_at
}
ORDER_ITEM {
string id PK
string order_id FK
string product_id FK
integer quantity
decimal price
timestamp created_at
}
PRODUCT {
string id PK
string name
string description
decimal price
integer stock_quantity
timestamp created_at
timestamp updated_at
}
PAYMENT {
string id PK
string order_id FK
string provider
string status
timestamp created_at
}
INVENTORY {
string product_id PK FK
integer quantity
timestamp updated_at
}
SHIPPING {
string id PK
string order_id FK
string tracking_number
string status
timestamp created_at
}
ADDRESS {
string id PK
string user_id FK
string street
string city
string country
string postal_code
timestamp created_at
}
Table / Collection schema definitions with field types, constraints, indices
User Table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_email_format CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
);
Order Table
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
total_amount DECIMAL(10,2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_orders_user_id (user_id),
INDEX idx_orders_status (status),
INDEX idx_orders_created_at (created_at)
);
Order Item Table
CREATE TABLE order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
quantity INTEGER NOT NULL CHECK (quantity > 0),
price DECIMAL(10,2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_order_items_order_id (order_id),
INDEX idx_order_items_product_id (product_id)
);
Product Table
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
price DECIMAL(10,2) NOT NULL CHECK (price >= 0),
stock_quantity INTEGER NOT NULL DEFAULT 0 CHECK (stock_quantity >= 0),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_products_name (name),
INDEX idx_products_price (price)
);
Payment Table
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_payments_order_id (order_id),
INDEX idx_payments_status (status)
);
Inventory Table
CREATE TABLE inventory (
product_id UUID PRIMARY KEY REFERENCES products(id) ON DELETE CASCADE,
quantity INTEGER NOT NULL DEFAULT 0 CHECK (quantity >= 0),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_inventory_product_id (product_id)
);
Shipping Table
CREATE TABLE shipping (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
tracking_number VARCHAR(255),
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_shipping_order_id (order_id),
INDEX idx_shipping_status (status)
);
Address Table
CREATE TABLE addresses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
street VARCHAR(255) NOT NULL,
city VARCHAR(255) NOT NULL,
country VARCHAR(255) NOT NULL,
postal_code VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_addresses_user_id (user_id)
);
Primary key strategy (UUID vs. auto-increment vs. ULID — with justification)
Using UUID as primary key for all tables. Justification:
- Global uniqueness: UUIDs ensure uniqueness across distributed systems and databases without coordination
- No single point of failure: Unlike auto-increment, UUIDs don't require centralized sequence generators
- Scalability: No need to coordinate increments across multiple database shards or replicas
- Security: UUIDs don't reveal information about data volume or insertion order
Index design (composite, partial, covering indices — with justification per index)
-
Orders by user_id and status:
INDEX idx_orders_user_id (user_id), INDEX idx_orders_status (status)- For efficient user order listing and status filtering
- Composite index needed for multi-column queries
-
Orders by creation time:
INDEX idx_orders_created_at (created_at)- For chronological order queries and analytics
-
Order items by order_id:
INDEX idx_order_items_order_id (order_id)- For retrieving all items in an order
-
Product name search:
INDEX idx_products_name (name)- For product search and filtering by name
-
Payment status lookup:
INDEX idx_payments_status (status)- For payment reconciliation and status monitoring
-
Inventory lookup:
INDEX idx_inventory_product_id (product_id)- For stock availability checks
Data partitioning / sharding strategy
Partition orders by user_id hash to achieve horizontal sharding:
- Shard key:
user_id % 100(assuming 100 shards) - Partitioning strategy: Hash-based sharding to ensure even distribution
- Cross-shard queries: Handled via application-level joins or denormalized data
- Order ID generation: UUIDs maintain global uniqueness across shards
Soft delete vs. hard delete decision with justification
Soft delete is used for the users and orders tables:
- Audit trail: Historical data retention for compliance and analytics
- Data recovery: Ability to restore deleted records if needed
- Referential integrity: Maintains foreign key relationships in related tables
Hard delete for order_items and payments:
- Data cleanup: These are direct references that can be safely deleted when parent is removed
- Storage efficiency: Reduces storage overhead for temporary data
Audit trail design (who, what, when)
All tables have created_at and updated_at timestamps. For audit logging:
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
table_name VARCHAR(100) NOT NULL,
record_id UUID NOT NULL,
operation VARCHAR(20) NOT NULL, -- INSERT/UPDATE/DELETE
old_values JSONB,
new_values JSONB,
user_id UUID REFERENCES users(id),
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_audit_logs_table_record (table_name, record_id),
INDEX idx_audit_logs_timestamp (timestamp)
);
Data archival strategy and retention policy
- Orders older than 5 years: Archived to separate storage (S3, BigQuery)
- Payment data: Retained for 7 years for tax and compliance purposes
- User data: Retained per privacy policy (typically 2-5 years after inactivity)
- Archival process: Scheduled nightly job that moves data to archive storage and soft-deletes from primary tables
4. Class Design & Service Layer
UML Class Diagram
classDiagram
class DataAccessException {
<<exception>>
+String message
+Throwable cause
}
class Repository {
<<interface>>
+<T> T findById(String id)
+<T> List~T~ findAll()
+<T> T save(T entity)
+<T> T update(T entity)
+boolean delete(String id)
}
class RepositoryImpl {
-DataSource dataSource
-EntityMapper mapper
+<T> T findById(String id)
+<T> List~T~ findAll()
+<T> T save(T entity)
+<T> T update(T entity)
+boolean delete(String id)
}
class RepositoryFactory {
+Repository createRepository(String entityType)
}
class EntityMapper {
<<interface>>
+<T> T mapToEntity(Map~String, Object~ data)
+Map~String, Object~ mapToMap(T entity)
}
class EntityMapperImpl {
+<T> T mapToEntity(Map~String, Object~ data)
+Map~String, Object~ mapToMap(T entity)
}
class TransactionManager {
+void executeInTransaction(TransactionCallback callback)
+void beginTransaction()
+void commit()
+void rollback()
}
class TransactionCallback {
<<interface>>
+void execute()
}
class QueryBuilder {
+String buildSelectQuery(String table, Map~String, Object~ conditions)
+String buildUpdateQuery(String table, Map~String, Object~ updates, Map~String, Object~ conditions)
+String buildDeleteQuery(String table, Map~String, Object~ conditions)
}
class DataLayerConfig {
-DataSource dataSource
-TransactionManager transactionManager
-QueryTimeoutMs queryTimeoutMs
}
Repository <|-- RepositoryImpl
RepositoryFactory --> Repository : creates
EntityMapper <|-- EntityMapperImpl
TransactionManager --> TransactionCallback : executes
RepositoryImpl --> DataLayerConfig : uses
RepositoryImpl --> QueryBuilder : builds queries
RepositoryImpl --> EntityMapper : maps entities
TransactionManager --> DataLayerConfig : uses
class User {
+String id
+String name
+String email
+LocalDateTime createdAt
}
class Order {
+String id
+String userId
+BigDecimal amount
+OrderStatus status
}
class OrderStatus {
PENDING
CONFIRMED
CANCELLED
}
User "1" --> "*" RepositoryImpl : manages
Order "1" --> "*" RepositoryImpl : manages
State Machine Diagram for Order Entity
stateDiagram-v2
[*] --> PENDING
PENDING --> CONFIRMED : payment_received
PENDING --> CANCELLED : user_cancelled
CONFIRMED --> SHIPPED : shipped
CONFIRMED --> CANCELLED : admin_cancelled
SHIPPED --> DELIVERED : delivered
SHIPPED --> RETURNED : returned
CANCELLED --> [*]
DELIVERED --> [*]
RETURNED --> [*]
Service Class Structure and Responsibilities
The Data Access Layer abstraction consists of the following core components:
-
Repository Pattern Implementation: Provides a clean abstraction over data access operations with consistent interfaces across different entity types.
-
Transaction Management: Ensures atomicity and consistency across related database operations.
-
Entity Mapping: Handles conversion between database records and domain objects.
-
Query Building: Generates parameterized SQL queries to prevent injection attacks.
-
Configuration Management: Centralized configuration for data sources, timeouts, and transaction behavior.
Interface / Abstract Class Design
public interface Repository<T> {
T findById(String id);
List<T> findAll();
T save(T entity);
T update(T entity);
boolean delete(String id);
}
public interface EntityMapper<T> {
T mapToEntity(Map<String, Object> data);
Map<String, Object> mapToMap(T entity);
}
public interface TransactionCallback {
void execute();
}
public class DataLayerConfig {
private final DataSource dataSource;
private final TransactionManager transactionManager;
private final int queryTimeoutMs;
public DataLayerConfig(DataSource dataSource,
TransactionManager transactionManager,
int queryTimeoutMs) {
this.dataSource = dataSource;
this.transactionManager = transactionManager;
this.queryTimeoutMs = queryTimeoutMs;
}
// Getters
}
Business Logic Encapsulation Patterns
We implement the Strategy Pattern for query building to support different database dialects and optimize performance based on data access patterns.
public interface QueryStrategy {
String buildSelectQuery(String table, Map<String, Object> conditions);
String buildUpdateQuery(String table, Map<String, Object> updates, Map<String, Object> conditions);
String buildDeleteQuery(String table, Map<String, Object> conditions);
}
public class PostgresQueryStrategy implements QueryStrategy {
@Override
public String buildSelectQuery(String table, Map<String, Object> conditions) {
// PostgreSQL-specific query building logic
return buildGenericQuery(table, conditions, "SELECT * FROM %s WHERE %s");
}
@Override
public String buildUpdateQuery(String table, Map<String, Object> updates, Map<String, Object> conditions) {
// PostgreSQL-specific update logic
return buildGenericQuery(table, conditions, "UPDATE %s SET %s WHERE %s");
}
@Override
public String buildDeleteQuery(String table, Map<String, Object> conditions) {
// PostgreSQL-specific delete logic
return buildGenericQuery(table, conditions, "DELETE FROM %s WHERE %s");
}
private String buildGenericQuery(String table, Map<String, Object> conditions, String template) {
// Implementation for PostgreSQL-specific query building
return String.format(template, table, buildWhereClause(conditions));
}
private String buildWhereClause(Map<String, Object> conditions) {
return conditions.entrySet().stream()
.map(entry -> String.format("%s = ?", entry.getKey()))
.collect(Collectors.joining(" AND "));
}
}
public class QueryBuilder {
private final QueryStrategy queryStrategy;
public QueryBuilder(QueryStrategy queryStrategy) {
this.queryStrategy = queryStrategy;
}
public String buildSelectQuery(String table, Map<String, Object> conditions) {
return queryStrategy.buildSelectQuery(table, conditions);
}
public String buildUpdateQuery(String table, Map<String, Object> updates, Map<String, Object> conditions) {
return queryStrategy.buildUpdateQuery(table, updates, conditions);
}
public String buildDeleteQuery(String table, Map<String, Object> conditions) {
return queryStrategy.buildDeleteQuery(table, conditions);
}
}
Critical-Path Method Implementation
public class OrderRepositoryImpl implements Repository<Order> {
private final DataSource dataSource;
private final QueryBuilder queryBuilder;
private final EntityMapper<Order> orderMapper;
private final TransactionManager transactionManager;
public OrderRepositoryImpl(DataSource dataSource,
QueryBuilder queryBuilder,
EntityMapper<Order> orderMapper,
TransactionManager transactionManager) {
this.dataSource = dataSource;
this.queryBuilder = queryBuilder;
this.orderMapper = orderMapper;
this.transactionManager = transactionManager;
}
@Override
public Order save(Order order) {
return transactionManager.executeInTransaction(() -> {
try (Connection connection = dataSource.getConnection()) {
// Validate order status transition
if (order.getStatus() == null) {
throw new DataAccessException("Order status cannot be null");
}
// Insert order with generated ID
String insertQuery = "INSERT INTO orders (id, user_id, amount, status, created_at) VALUES (?, ?, ?, ?, ?)";
try (PreparedStatement stmt = connection.prepareStatement(insertQuery)) {
String orderId = UUID.randomUUID().toString();
order.setId(orderId);
stmt.setString(1, orderId);
stmt.setString(2, order.getUserId());
stmt.setBigDecimal(3, order.getAmount());
stmt.setString(4, order.getStatus().name());
stmt.setTimestamp(5, Timestamp.valueOf(LocalDateTime.now()));
stmt.executeUpdate();
}
// Log audit trail
publishAuditEvent("ORDER_CREATED", order.getId(),
Map.of("userId", order.getUserId(), "amount", order.getAmount()));
return order;
} catch (SQLException e) {
throw new DataAccessException("Failed to save order", e);
}
});
}
@Override
public Order findById(String id) {
return transactionManager.executeInTransaction(() -> {
try (Connection connection = dataSource.getConnection()) {
String selectQuery = "SELECT * FROM orders WHERE id = ?";
try (PreparedStatement stmt = connection.prepareStatement(selectQuery)) {
stmt.setString(1, id);
ResultSet rs = stmt.executeQuery();
if (rs.next()) {
return orderMapper.mapToEntity(resultSetToMap(rs));
}
return null;
}
} catch (SQLException e) {
throw new DataAccessException("Failed to find order by ID", e);
}
});
}
@Override
public Order update(Order order) {
return transactionManager.executeInTransaction(() -> {
try (Connection connection = dataSource.getConnection()) {
// Validate order exists
Order existing = findById(order.getId());
if (existing == null) {
throw new DataAccessException("Order not found for update: " + order.getId());
}
// Check status transition validity
validateStatusTransition(existing.getStatus(), order.getStatus());
String updateQuery = "UPDATE orders SET amount = ?, status = ? WHERE id = ?";
try (PreparedStatement stmt = connection.prepareStatement(updateQuery)) {
stmt.setBigDecimal(1, order.getAmount());
stmt.setString(2, order.getStatus().name());
stmt.setString(3, order.getId());
stmt.executeUpdate();
}
// Publish status change event
publishOrderStatusChangeEvent(order.getId(), existing.getStatus(), order.getStatus());
return order;
} catch (SQLException e) {
throw new DataAccessException("Failed to update order", e);
}
});
}
private void validateStatusTransition(OrderStatus from, OrderStatus to) {
if (from == null || to == null) return;
// Simple validation - in real system would be more comprehensive
if (from == OrderStatus.CANCELLED && to != OrderStatus.CANCELLED) {
throw new DataAccessException("Cannot change status from CANCELLED");
}
}
private void publishAuditEvent(String eventType, String entityId, Map<String, Object> details) {
// Publish to audit event bus or log
// This would typically be done asynchronously with proper error handling
}
private void publishOrderStatusChangeEvent(String orderId, OrderStatus from, OrderStatus to) {
// Publish order status change event for downstream consumers
}
private Map<String, Object> resultSetToMap(ResultSet rs) throws SQLException {
ResultSetMetaData metaData = rs.getMetaData();
Map<String, Object> map = new HashMap<>();
for (int i = 1; i <= metaData.getColumnCount(); i++) {
String columnName = metaData.getColumnName(i);
Object value = rs.getObject(i);
map.put(columnName, value);
}
return map;
}
@Override
public List<Order> findAll() {
// Implementation for findAll method
return Collections.emptyList();
}
@Override
public boolean delete(String id) {
// Implementation for delete method
return false;
}
}
Transaction Boundary Design
Transaction boundaries are defined by:
- Explicit transaction scope: Each repository operation that requires consistency
- Read-only vs read-write operations: Different transaction strategies based on operation type
- Nested transaction support: Using savepoints for complex operations
Service Interface Contracts
public interface OrderService {
Order createOrder(String userId, BigDecimal amount);
Order updateOrderStatus(String orderId, OrderStatus newStatus);
Order getOrderById(String orderId);
List<Order> getAllOrders();
boolean cancelOrder(String orderId);
}
Saga / Orchestration Pattern
For distributed transactions involving multiple services:
- Saga pattern is implemented at the service layer to coordinate cross-service operations
- Compensating actions are registered for rollback in case of failure
- Event-driven coordination ensures eventual consistency
Domain Object / Value Object Design
public class Order {
private String id;
private String userId;
private BigDecimal amount;
private OrderStatus status;
private LocalDateTime createdAt;
// Constructors, getters, setters
}
public enum OrderStatus {
PENDING,
CONFIRMED,
CANCELLED,
SHIPPED,
DELIVERED,
RETURNED
}
5. Concurrency & Thread Safety
Concurrency Model
The backend system uses a thread pool model with Java's ExecutorService for handling concurrent database operations. Each request is processed in a dedicated thread from the application's thread pool, ensuring that I/O-bound database operations don't block the main request handling thread. For high-throughput scenarios, we leverage reactive programming with Project Reactor or RxJava to handle asynchronous database operations without blocking threads.
Race Condition Prevention Strategy
Race conditions are mitigated through:
- Database-level atomic operations using
SELECT FOR UPDATEin transactions to lock rows during read-modify-write cycles - Optimistic locking with version fields in entities to detect and prevent concurrent modifications
- Idempotency checks for operations that can be safely retried without side effects
Optimistic vs. Pessimistic Locking Selection
We use optimistic locking for most entities due to:
- Lower system overhead compared to pessimistic locking
- Better scalability in read-heavy workloads
- Reduced risk of deadlocks in concurrent systems
Pessimistic locking is applied selectively for:
- Critical write operations where data consistency is paramount
- Batch processing scenarios with high contention
Deadlock Prevention Strategy
Deadlock prevention includes:
- Consistent lock ordering - All transactions acquire locks in the same order across entities
- Lock timeout configuration (e.g., 5 seconds) to prevent indefinite waits
- Transaction isolation level set to READ_COMMITTED or REPEATABLE_READ depending on consistency needs
- Application-level retry logic with exponential backoff for deadlock-recovered transactions
Idempotency Implementation for Concurrent Duplicate Requests
Idempotency is handled through:
- Idempotency keys in request headers (e.g.,
X-Idempotency-Key) - Cache-based deduplication for operations within a time window (e.g., 5 minutes)
- Database uniqueness constraints on idempotency keys for critical operations
// Example: Idempotent operation with cache-based deduplication
public class IdempotentOperationService {
private final Map<String, CompletableFuture<OperationResult>> operationCache = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
public CompletableFuture<OperationResult> executeWithIdempotency(
String idempotencyKey,
Supplier<CompletableFuture<OperationResult>> operation) {
return operationCache.computeIfAbsent(idempotencyKey, key -> {
CompletableFuture<OperationResult> future = operation.get();
// Remove from cache after TTL
scheduler.schedule(() -> operationCache.remove(key), 5, TimeUnit.MINUTES);
return future;
});
}
}
Thread Safety in Repository Layer
The repository layer ensures thread safety through:
- Immutable entity objects with proper encapsulation to prevent concurrent modification
- Thread-local database connections in connection pools to avoid cross-thread contamination
- Synchronized access to shared resources like query builders or prepared statements when necessary
Transaction Isolation and Consistency
- Read Committed isolation level for most operations to prevent dirty reads
- Repeatable Read for operations requiring consistent snapshot views (e.g., reporting)
- Serializable for critical financial or audit operations where strict consistency is required
Concurrency Control in Batch Operations
Batch operations use:
- Partitioned processing to reduce contention across batches
- Lock-free data structures for intermediate state management (e.g., ConcurrentLinkedQueue)
- Batch size limiting to prevent overwhelming database connections
Performance Considerations
- Thread pool sizing tuned based on database connection pool size and I/O characteristics
- Async database drivers (e.g., async PostgreSQL driver) to minimize thread blocking
- Connection pool monitoring and adaptive sizing based on concurrent load patterns
6. Sequence Diagrams for Key Flows
Happy Path Sequence Diagram
sequenceDiagram
participant Client
participant Service
participant Repository
participant Database
Client->>Service: GET /users/123
Service->>Repository: findById(123)
Repository->>Database: SELECT * FROM users WHERE id = 123
Database-->>Repository: User object
Repository-->>Service: User object
Service-->>Client: 200 OK, User JSON
Error / Edge Case Sequence Diagram
sequenceDiagram
participant Client
participant Service
participant Repository
participant Database
Client->>Service: GET /users/999
Service->>Repository: findById(999)
Repository->>Database: SELECT * FROM users WHERE id = 999
Database-->>Repository: Empty result
Repository-->>Service: EntityNotFoundException
Service-->>Client: 404 Not Found, Error JSON
Async Flow Sequence Diagram
sequenceDiagram
participant Client
participant Service
participant Repository
participant EventPublisher
participant MessageQueue
participant EventHandler
Client->>Service: POST /users
Service->>Repository: save(user)
Repository->>Database: INSERT INTO users (name, email) VALUES (?, ?)
Database-->>Repository: User ID generated
Repository->>EventPublisher: publish(userCreatedEvent)
EventPublisher->>MessageQueue: send(userCreatedEvent)
MessageQueue-->>EventHandler: userCreatedEvent
EventHandler->>Service: processUserCreated(user)
Service-->>Client: 201 Created, User JSON
7. Caching Design
Cache Strategy Selection
For this data access layer abstraction, we'll use cache-aside pattern with a distributed Redis cluster as the primary cache store. This approach provides:
- Explicit control over cache population
- Clear separation between application logic and caching concerns
- Support for cache warming and invalidation strategies
- Horizontal scalability across multiple cache nodes
Cache Key Design
Cache keys will follow a structured namespace pattern to avoid collisions and enable efficient cache management:
{namespace}:{entity_type}:{entity_id}:{version}:{field_name?}
Example cache keys:
user:12345:v1:profileorder:67890:v2:itemsproduct:abc123:v1:metadata
TTL Strategy
| Data Type | TTL | Reason |
|---|---|---|
| User Profile | 5 minutes | Frequent updates, low consistency needs |
| Order Details | 10 minutes | Moderate update frequency, high read volume |
| Product Catalog | 1 hour | Stable data, infrequent updates |
| Session Tokens | 24 hours | Long-lived, security-sensitive |
| Configuration Data | 30 minutes | Dynamic config, acceptable staleness |
Cache Eviction Policy
We'll implement LRU (Least Recently Used) eviction policy with a TTL-based fallback:
- Primary eviction: LRU algorithm to maintain hot data
- Fallback: TTL expiration for stale entries that haven't been accessed recently
- Cache size limits enforced at Redis cluster level (5GB per node)
Cache Stampede Prevention
To prevent thundering herd on cache misses:
- Probabilistic Early Expiry: Cache entries expire 20% before TTL to allow for early refresh
- Distributed Locking: Use Redis distributed locks (
SET key value NX EX ttl) for cache population - Cache Backfill Strategy: Pre-warm hot keys during low-traffic periods
Distributed Cache Architecture
We'll implement a Redis Cluster with:
- 3 master nodes (for data partitioning)
- 3 replica nodes (for high availability)
- Automatic failover capability
- Client-side sharding using consistent hashing
Cache Invalidation Strategy
We'll use event-driven invalidation:
- Write Operations: Invalidate cache entries when data is modified
- Batch Operations: Use cache tags to invalidate groups of related entities
- Explicit Purge: Support for manual cache clearing via admin endpoints
Cache Implementation in Java
public class RedisCacheService {
private final RedisTemplate<String, Object> redisTemplate;
private final String cacheNamespace = "data-access-layer";
public <T> T get(String key, Class<T> type) {
String cacheKey = buildCacheKey(key);
return (T) redisTemplate.opsForValue().get(cacheKey);
}
public <T> void put(String key, T value, Duration ttl) {
String cacheKey = buildCacheKey(key);
redisTemplate.opsForValue().set(cacheKey, value, ttl);
}
public boolean lock(String key, String lockValue, Duration ttl) {
String lockKey = buildLockKey(key);
return redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, ttl);
}
public void unlock(String key, String lockValue) {
String lockKey = buildLockKey(key);
String storedValue = (String) redisTemplate.opsForValue().get(lockKey);
if (lockValue.equals(storedValue)) {
redisTemplate.delete(lockKey);
}
}
private String buildCacheKey(String key) {
return String.format("%s:%s", cacheNamespace, key);
}
private String buildLockKey(String key) {
return String.format("%s:lock:%s", cacheNamespace, key);
}
}
Cache Integration with Repository Pattern
public class CachedRepository<T> implements Repository<T> {
private final Repository<T> delegate;
private final RedisCacheService cacheService;
@Override
public T findById(String id) {
String cacheKey = "entity:" + id;
T cached = cacheService.get(cacheKey, (Class<T>) entityClass);
if (cached != null) {
return cached;
}
// Acquire distributed lock to prevent cache stampede
String lockKey = "lock:" + id;
String lockValue = UUID.randomUUID().toString();
if (cacheService.lock(lockKey, lockValue, Duration.ofSeconds(5))) {
try {
T result = delegate.findById(id);
if (result != null) {
cacheService.put(cacheKey, result, Duration.ofMinutes(5));
}
return result;
} finally {
cacheService.unlock(lockKey, lockValue);
}
}
// Fallback to cache if lock acquisition fails
return cacheService.get(cacheKey, (Class<T>) entityClass);
}
@Override
public void save(T entity) {
delegate.save(entity);
// Invalidate cache for this entity
String cacheKey = "entity:" + getEntityId(entity);
cacheService.delete(cacheKey);
}
}
8. Repository / Data Access Layer
Repository Pattern Implementation
The repository pattern is implemented as a clean abstraction layer between the service layer and data storage, providing a collection-like interface for data access operations. This pattern enables loose coupling, testability, and supports various data sources.
// Repository interface - defines contract for data access operations
public interface UserRepository extends Repository<User, Long> {
Optional<User> findByEmail(String email);
List<User> findByStatus(UserStatus status);
Page<User> findByStatusWithPagination(UserStatus status, Pageable pageable);
List<User> findActiveUsersWithRoles(List<Role> roles);
}
// Generic Repository base interface
public interface Repository<T, ID> {
Optional<T> findById(ID id);
List<T> findAll();
T save(T entity);
void delete(T entity);
boolean existsById(ID id);
}
// Repository implementation for concrete data source
public class UserRepositoryImpl implements UserRepository {
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;
public UserRepositoryImpl(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
this.jdbcTemplate = jdbcTemplate;
this.objectMapper = objectMapper;
}
@Override
public Optional<User> findById(Long id) {
String sql = "SELECT * FROM users WHERE id = ?";
try {
return Optional.ofNullable(jdbcTemplate.queryForObject(sql, new Object[]{id}, new UserRowMapper()));
} catch (EmptyResultDataAccessException e) {
return Optional.empty();
}
}
@Override
public User save(User user) {
if (user.getId() == null) {
return create(user);
} else {
return update(user);
}
}
private User create(User user) {
String sql = "INSERT INTO users (name, email, status, created_at) VALUES (?, ?, ?, ?)";
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
ps.setString(1, user.getName());
ps.setString(2, user.getEmail());
ps.setString(3, user.getStatus().name());
ps.setTimestamp(4, Timestamp.valueOf(user.getCreatedAt()));
return ps;
}, keyHolder);
user.setId(keyHolder.getKey().longValue());
return user;
}
private User update(User user) {
String sql = "UPDATE users SET name = ?, email = ?, status = ?, updated_at = ? WHERE id = ?";
jdbcTemplate.update(sql,
user.getName(),
user.getEmail(),
user.getStatus().name(),
Timestamp.valueOf(user.getUpdatedAt()),
user.getId());
return user;
}
@Override
public void delete(User user) {
String sql = "DELETE FROM users WHERE id = ?";
jdbcTemplate.update(sql, user.getId());
}
}
ORM vs. Raw Query Decisions
For this design, we use raw SQL queries with JDBC Template instead of full ORM like Hibernate for several reasons:
- Performance: Raw SQL provides better performance for complex queries and reduces object mapping overhead
- Control: Direct control over query execution, allowing for optimization and complex joins
- Maintainability: Easier to debug and tune specific queries when performance issues arise
- Database Portability: Clear separation between query logic and database-specific features
// Example of complex query using raw SQL with parameter binding
public List<User> findActiveUsersWithRoles(List<Role> roles) {
String sql = """
SELECT u.*, r.name as role_name
FROM users u
JOIN user_roles ur ON u.id = ur.user_id
JOIN roles r ON ur.role_id = r.id
WHERE u.status = ? AND r.name IN (?)
""";
// Build dynamic IN clause for roles
String rolePlaceholders = roles.stream()
.map(r -> "?")
.collect(Collectors.joining(","));
// This would be built dynamically with proper parameter binding
return jdbcTemplate.query(sql, new Object[]{UserStatus.ACTIVE.name(), rolePlaceholders},
new UserWithRoleRowMapper());
}
Read vs. Write Separation
The repository pattern supports read and write separation through distinct interfaces:
// Read-only operations interface
public interface UserReadRepository {
Optional<User> findByEmail(String email);
List<User> findByStatus(UserStatus status);
Page<User> findByStatusWithPagination(UserStatus status, Pageable pageable);
long countActiveUsers();
}
// Write operations interface
public interface UserWriteRepository {
User save(User user);
void delete(User user);
void batchInsert(List<User> users);
}
// Combined repository interface
public interface UserRepository extends UserReadRepository, UserWriteRepository {
// Combined operations
}
Connection Pool Configuration
Connection pool is configured with optimal settings for high-concurrency environments:
@Configuration
public class DatabaseConfig {
@Bean
public DataSource 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);
// Validation settings
config.setConnectionTestQuery("SELECT 1");
config.setValidationTimeout(5000);
return new HikariDataSource(config);
}
}
N+1 Query Prevention Strategy
To prevent N+1 query issues, we implement eager loading with batch operations:
// Batch operation for efficient data loading
public List<User> findUsersWithRoles(List<Long> userIds) {
if (userIds.isEmpty()) return Collections.emptyList();
String sql = """
SELECT u.*, r.name as role_name, r.id as role_id
FROM users u
LEFT JOIN user_roles ur ON u.id = ur.user_id
LEFT JOIN roles r ON ur.role_id = r.id
WHERE u.id IN (?)
ORDER BY u.id
""";
// Use batch query with proper parameter binding
return jdbcTemplate.query(sql, new Object[]{buildInClause(userIds)},
new UserWithRoleBatchMapper());
}
// Helper method to build IN clause safely
private String buildInClause(List<Long> ids) {
return ids.stream()
.map(String::valueOf)
.collect(Collectors.joining(",", "(", ")"));
}
Batch Operation Design
Batch operations are implemented to reduce database round trips:
public class UserBatchRepository {
public void batchInsert(List<User> users) {
String sql = "INSERT INTO users (name, email, status, created_at) VALUES (?, ?, ?, ?)";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
User user = users.get(i);
ps.setString(1, user.getName());
ps.setString(2, user.getEmail());
ps.setString(3, user.getStatus().name());
ps.setTimestamp(4, Timestamp.valueOf(user.getCreatedAt()));
}
@Override
public int getBatchSize() {
return users.size();
}
});
}
public void batchUpdate(List<User> users) {
String sql = "UPDATE users SET name = ?, email = ?, status = ?, updated_at = ? WHERE id = ?";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
User user = users.get(i);
ps.setString(1, user.getName());
ps.setString(2, user.getEmail());
ps.setString(3, user.getStatus().name());
ps.setTimestamp(4, Timestamp.valueOf(user.getUpdatedAt()));
ps.setLong(5, user.getId());
}
@Override
public int getBatchSize() {
return users.size();
}
});
}
}
9. Asynchronous Processing & Messaging
Event-driven boundaries in this feature — what triggers async vs. what stays synchronous, and why
The Data Access Layer abstraction is primarily concerned with synchronous database operations. However, the abstraction layer can be extended to support asynchronous processing for specific use cases:
- Synchronous operations: All direct CRUD operations (create, read, update, delete) remain synchronous to ensure immediate consistency and response.
- Async event publishing: When entities are created, updated, or deleted, the system publishes domain events asynchronously to support eventual consistency and decoupled systems.
- Batch operations: Bulk data ingestion or transformation can be processed asynchronously to avoid blocking the main request path.
- Audit logging: Audit trail creation is handled asynchronously to prevent performance degradation of the primary operation.
The decision to make some operations asynchronous is driven by:
- Performance isolation (avoiding blocking primary operations)
- System decoupling (loose coupling between services)
- Scalability (handling bursts of operations without overloading the database)
Message schema design with a sample payload
{
"version": "1.0",
"event_type": "user.created",
"timestamp": "2023-10-15T14:30:00Z",
"source": "user-service",
"payload": {
"user_id": "uuid-12345",
"email": "john.doe@example.com",
"created_at": "2023-10-15T14:30:00Z"
},
"correlation_id": "corr-67890",
"tenant_id": "tenant-abc"
}
Dead letter queue strategy and poison message handling
Poison messages are handled through a dead-letter queue (DLQ) pattern:
- Messages that fail more than 3 times are moved to a DLQ
- DLQ messages are logged with full context for manual inspection
- A separate monitoring system alerts on DLQ queue size and error rates
- Failed messages are retried with exponential backoff (5s, 10s, 20s) before moving to DLQ
At-least-once vs. exactly-once delivery — decision with justification for this feature
At-least-once delivery is chosen for the Data Access Layer abstraction because:
- Database operations are idempotent in nature (e.g., upserts, updates)
- The abstraction layer can handle duplicate processing gracefully
- Simpler implementation with better performance characteristics
- For most data access operations, eventual consistency is acceptable
Retry policy for consumers (max attempts, backoff formula, non-retryable error classification)
public class RetryPolicy {
private static final int MAX_RETRIES = 3;
private static final long INITIAL_DELAY_MS = 5000;
private static final double BACKOFF_MULTIPLIER = 2.0;
private static final double JITTER_RANGE = 0.1;
public static boolean shouldRetry(Exception exception) {
return !(exception instanceof ValidationException ||
exception instanceof AuthorizationException ||
exception instanceof ResourceNotFoundException);
}
public static long calculateDelay(int attempt) {
long baseDelay = (long) (INITIAL_DELAY_MS * Math.pow(BACKOFF_MULTIPLIER, attempt - 1));
long jitter = (long) (baseDelay * JITTER_RANGE * (Math.random() - 0.5));
return baseDelay + jitter;
}
}
Outbox pattern for reliable event publishing — include or explicitly justify omission
Outbox pattern is included for the Data Access Layer abstraction because:
- Ensures atomicity between database writes and event publishing
- Prevents data loss in case of publish failures
- Maintains consistency between database state and event stream
- Provides better error handling and retry capabilities
The outbox pattern is implemented by:
- Writing events to an
event_outboxtable within the same database transaction as the data operation - A separate background worker processes events from the outbox table and publishes them to the message broker
- Events are marked as published after successful delivery
Sequence diagram for all async flows (Mermaid)
sequenceDiagram
participant Client
participant ServiceLayer
participant Repository
participant Database
participant OutboxTable
participant EventPublisher
participant MessageBroker
Client->>ServiceLayer: CreateUserRequest
ServiceLayer->>Repository: save(user)
Repository->>Database: INSERT INTO users (...)
Repository->>OutboxTable: INSERT INTO event_outbox (...)
Repository-->>ServiceLayer: User saved
ServiceLayer-->>Client: UserCreatedResponse
loop Process Events
EventPublisher->>OutboxTable: SELECT * FROM event_outbox WHERE published=false
EventPublisher->>MessageBroker: PublishEvent(event)
MessageBroker-->>EventPublisher: Acknowledge
EventPublisher->>OutboxTable: UPDATE event_outbox SET published=true WHERE id=...
end
10. Error Handling Design
Error Classification Hierarchy
The error handling strategy follows a hierarchical classification system to enable appropriate handling and response:
- Domain Errors - Business logic violations (e.g., insufficient funds, invalid state transitions)
- Validation Errors - Input data format or constraints violations (e.g., missing fields, invalid formats)
- Infrastructure Errors - System-level issues (e.g., database connection failures, timeout errors)
- Authorization Errors - Access control violations (e.g., insufficient permissions, invalid tokens)
- Internal System Errors - Unexpected system failures (e.g., unhandled exceptions, service unavailability)
Error Propagation Strategy
Errors are propagated up the call stack using a layered approach:
- Service Layer: Converts infrastructure and domain exceptions into standardized API errors
- Controller Layer: Maps service errors to appropriate HTTP responses with proper status codes
- Global Exception Handler: Catches unhandled exceptions and logs them for monitoring
// Example error propagation in service layer
public class OrderService {
public Order placeOrder(PlaceOrderRequest request) {
try {
// Business logic
return orderRepository.save(order);
} catch (DataAccessException e) {
throw new InfrastructureException("Database operation failed", e);
} catch (ValidationException e) {
throw new ValidationException("Order validation failed", e);
}
}
}
Retry Strategy with Exponential Backoff and Jitter
For transient infrastructure errors, a retry mechanism is implemented using exponential backoff with jitter:
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) {
throw e;
}
long delay = calculateDelay(attempts);
Thread.sleep(delay);
attempts++;
}
}
}
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
The circuit breaker pattern prevents cascading failures in distributed systems:
public class CircuitBreaker {
private final int failureThreshold = 5;
private final long timeout = 60000; // 1 minute
private final int successThreshold = 5;
private volatile CircuitState state = CircuitState.CLOSED;
private int failureCount = 0;
private long lastFailureTime = 0;
public <T> T execute(Supplier<T> operation) {
switch (state) {
case CLOSED:
try {
T result = operation.get();
onSuccess();
return result;
} catch (Exception e) {
onFailure();
throw e;
}
case OPEN:
if (System.currentTimeMillis() - lastFailureTime > timeout) {
state = CircuitState.HALF_OPEN;
return operation.get();
}
throw new CircuitBreakerOpenException("Circuit is open");
case HALF_OPEN:
try {
T result = operation.get();
state = CircuitState.CLOSED;
return result;
} catch (Exception e) {
state = CircuitState.OPEN;
throw e;
}
}
return null;
}
private void onSuccess() {
failureCount = 0;
if (state == CircuitState.HALF_OPEN) {
state = CircuitState.CLOSED;
}
}
private void onFailure() {
failureCount++;
lastFailureTime = System.currentTimeMillis();
if (failureCount >= failureThreshold) {
state = CircuitState.OPEN;
}
}
}
Fallback Behavior Design
Fallbacks are implemented for critical dependencies to maintain system availability:
public class FallbackService {
public Order getOrderByFallback(String orderId) {
// Return cached order or default value
return orderCache.get(orderId);
}
public List<Order> getOrdersByFallback(String customerId) {
// Return cached orders or empty list
return orderCache.getOrdersByCustomer(customerId);
}
}
Error Logging Format and Alerting Thresholds
Structured logging includes trace context and error details:
public class ErrorLogger {
private static final Logger logger = LoggerFactory.getLogger(ErrorLogger.class);
public void logError(String operation, Exception error, Map<String, String> context) {
Map<String, Object> logContext = new HashMap<>();
logContext.put("trace_id", MDC.get("trace_id"));
logContext.put("span_id", MDC.get("span_id"));
logContext.put("operation", operation);
logContext.put("error_type", error.getClass().getSimpleName());
logContext.put("error_message", error.getMessage());
logContext.put("context", context);
logger.error("Operation failed", new Exception(error.getMessage(), error));
}
}
Alerting thresholds:
- Critical: >100 errors per minute in the past 5 minutes (escalate to on-call)
- Warning: >50 errors per minute in the past 5 minutes (notify team)
- Info: >10 errors per minute in the past 5 minutes (monitoring dashboard)
11. Security Design
Input Validation and Sanitization Strategy
All API inputs are validated using a combination of:
- Library: Apache Commons Validator for standard validations (email, URL, phone)
- Custom Rules: Regex patterns for specific formats like ID patterns, name sanitization
- Sanitization: HTML escaping using OWASP Java HTML Sanitizer for any user-provided content that might be rendered in UI
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_PATTERN.matcher(email).matches();
}
public static String sanitizeHtml(String input) {
return HtmlSanitizer.sanitize(input);
}
}
SQL Injection Prevention
- ORM Usage: All database queries are constructed using Hibernate/JPA with parameterized queries to prevent SQL injection
- No Dynamic Query Building: All query building is done through JPA Criteria API or HQL, never string concatenation
- Whitelisting: Column and table names are validated against a predefined whitelist
Authentication Middleware Design
- JWT-based Authentication: JWT tokens are used for stateless authentication with 24-hour expiration
- Middleware Chain: Custom Spring Security filter chain that intercepts all requests and validates JWT tokens
- Token Refresh: Support for refresh tokens with 7-day validity
Authorization Enforcement
- RBAC Implementation: Role-based access control where users are assigned roles (ADMIN, USER, GUEST)
- Permission Checking: Service layer methods check permissions before execution using
@PreAuthorizeannotations - Resource Scoping: Users can only access resources they own or have explicit permissions for
@PreAuthorize("hasRole('ADMIN') or @userPermissionService.canAccessUser(#userId)")
public User getUserById(Long userId) {
return userRepository.findById(userId);
}
Sensitive Data Encryption
- At Rest: AES-256 encryption using AWS KMS for sensitive fields like passwords, PII, and financial data
- In Transit: TLS 1.3 with HSTS enabled for all API communications
- Key Management: Keys are rotated every 90 days with audit trail
PII Handling and Masking Strategy
- Masking: Credit card numbers are masked (show only last 4 digits), email addresses are partially redacted
- Storage: PII is stored in encrypted format and only accessible to authorized services
- Logging: PII fields are excluded from logs or masked before logging
Secrets Management
- Vault Integration: AWS Secrets Manager is used for managing database credentials, API keys, and encryption keys
- Rotation: Credentials are rotated every 90 days with automatic failover
- Access Control: Fine-grained access control using IAM policies to limit who can read secrets
Audit Logging Design
- Events Logged: All CRUD operations on sensitive entities (users, orders, payments) are logged
- Log Format: Structured JSON with fields:
timestamp,user_id,operation,entity_type,entity_id,ip_address,user_agent - Retention: Audit logs are retained for 3 years with daily backups to S3
- Alerting: Security events trigger immediate alerts to the security team
{
"timestamp": "2023-10-15T14:30:45.123Z",
"user_id": "user-12345",
"operation": "UPDATE_USER",
"entity_type": "USER",
"entity_id": "user-12345",
"ip_address": "192.168.1.100",
"user_agent": "Mozilla/5.0..."
}
12. Testing Strategy
Unit Testing Scope and Coverage Targets (%)
- Service Layer: 85% code coverage, focusing on business logic and error paths
- Repository Layer: 90% code coverage, including edge cases and query validation
- Domain Objects: 100% for value objects, 80% for entities with complex state transitions
- Data Access Patterns: 95% for CRUD operations and complex queries
- Error Handling: 100% coverage of all error paths in service and repository layers
Integration Testing Strategy
- Database Integration: Use testcontainers for PostgreSQL/MySQL to validate actual query execution and schema compliance
- External Service Mocks: WireMock for third-party API interactions (e.g., payment gateway, notification service)
- Cross-Service Integration: Embedded Redis and Kafka for testing async flows and event handling
- Transaction Boundaries: Validate rollback behavior with real database transactions using rollback-only transactions in test environments
Contract Testing
- Tool: Pact (consumer-driven contract testing)
- Scope: All external API integrations (payment provider, email service, SMS gateway)
- Strategy: Define contracts for each consumer and provider pair, ensuring that changes in one service don't break consumers
- CI Integration: Run contract tests as part of CI pipeline before merging to main branch
Performance Testing Tool and Key Scenarios
- Tool: k6 (JavaScript-based load testing)
- Scenarios:
- Concurrent read-heavy workload (500 concurrent users, 1000 requests/sec)
- Write-heavy workload with transactional consistency (200 concurrent users, 500 requests/sec)
- Batch operation performance (1000 records in batch, 50 concurrent batches)
- Metrics Tracked:
- p50/p95/p99 response times (target: <200ms for reads, <500ms for writes)
- Throughput (target: 1000+ req/sec for read path, 500+ req/sec for write path)
- Error rates (target: <0.1% error rate)
Test Data Management Strategy
- Fixtures: YAML-based fixtures for common test data (users, products, orders)
- Factories: Factory classes using Builder pattern to generate realistic test data with proper constraints
- Seeding Strategy:
- Use database migrations for initial schema and seed data (e.g., default roles, categories)
- For tests requiring specific states, use lightweight test data setup with transactional rollbacks
- Data Isolation: Each test runs in a separate database transaction that is rolled back after completion to ensure isolation
- Test Data Generation: Use libraries like Faker for generating realistic but non-sensitive test data
// Example: Factory pattern for generating test entities
public class TestEntityFactory {
public static User createUser(String email) {
return User.builder()
.id(UUID.randomUUID())
.email(email)
.name("Test User")
.role(Role.USER)
.createdAt(Instant.now())
.build();
}
public static Order createOrder(User user, Product product) {
return Order.builder()
.id(UUID.randomUUID())
.user(user)
.items(List.of(OrderItem.builder()
.product(product)
.quantity(1)
.build()))
.status(OrderStatus.PENDING)
.createdAt(Instant.now())
.build();
}
}
13. Observability Design
Structured Logging Schema
The logging system will use structured JSON format to ensure consistency and enable efficient querying. Each log entry will include the following required fields:
{
"timestamp": "2023-10-27T14:30:00.000Z",
"level": "INFO|WARN|ERROR",
"service": "data-access-layer",
"trace_id": "12345678-1234-1234-1234-123456789012",
"span_id": "abcdef12-3456-7890-abcd-ef1234567890",
"user_id": "user_12345",
"entity_id": "order_98765",
"operation": "findUserById",
"error_code": "USER_NOT_FOUND",
"message": "User not found in database",
"duration_ms": 15,
"stack_trace": "...",
"request_id": "req_abcde12345"
}
PII masking rules will be applied to sensitive fields:
- User IDs will be masked in logs when not needed for debugging
- Email addresses and phone numbers will be truncated or hashed
- Full credit card numbers will never be logged
Distributed Tracing Instrumentation
OpenTelemetry will be used for distributed tracing with custom spans added to key operations:
Span Types and Attributes:
-
Database Operation Spans
- Span name:
db.query.{table}.{operation} - Attributes:
db.operation: SELECT, INSERT, UPDATE, DELETEdb.table: user, order, paymentdb.query: actual SQL or MongoDB querydb.connection_id: connection pool identifier
- Span name:
-
Repository Method Spans
- Span name:
repository.{method}.{entity} - Attributes:
repository.method: findById, save, deleteentity.type: User, Order, Paymentdb.query.duration_ms: duration of database operation
- Span name:
-
Service Layer Spans
- Span name:
service.{operation} - Attributes:
service.operation: findUser, createOrderuser.id: user identifier when available
- Span name:
Metrics Design at Service Level
RED metrics will be collected for all repository operations:
| Metric Name | Type | Labels | Description |
|---|---|---|---|
db_query_count | Counter | operation, table, status | Number of database queries executed |
db_query_duration | Histogram | operation, table | Query execution time in milliseconds |
db_query_errors | Counter | operation, table, error_type | Count of failed database queries |
repository_method_count | Counter | method, entity, status | Repository method invocations |
repository_method_duration | Histogram | method, entity | Repository method execution time |
db_connection_pool_active | Gauge | pool_name | Active database connections |
db_connection_pool_idle | Gauge | pool_name | Idle database connections |
Health Check Endpoint Design
Health Check Endpoints:
-
/health - Application health status
{ "status": "healthy|unhealthy", "checks": { "database": "healthy|unhealthy", "cache": "healthy|unhealthy", "external_api": "healthy|unhealthy" }, "timestamp": "2023-10-27T14:30:00.000Z" } -
/ready - Readiness for traffic
{ "status": "ready|not_ready", "details": { "database_connection": "connected|failed", "cache_connection": "connected|failed" } } -
/live - Liveness check
{ "status": "alive", "uptime_seconds": 3600, "timestamp": "2023-10-27T14:30:00.000Z" }
Alerting Thresholds per Key Metric
| Metric | Alert Level | Threshold | Action |
|---|---|---|---|
db_query_errors (per minute) | Warning | > 5 errors | Alert team |
db_query_errors (per minute) | Critical | > 50 errors | Alert on-call |
db_query_duration (p99) | Warning | > 500ms | Investigate slow queries |
db_query_duration (p99) | Critical | > 2000ms | Alert on-call, investigate |
repository_method_duration (p99) | Warning | > 200ms | Review repository logic |
repository_method_duration (p99) | Critical | > 1000ms | Alert on-call |
db_connection_pool_active | Warning | < 5 active connections | Investigate pool exhaustion |
db_connection_pool_active | Critical | < 1 active connections | Alert on-call |
On-call escalation path:
- Warning alerts → Slack channel
- Critical alerts → SMS to on-call engineer with escalation to team lead after 15 minutes
14. Open Questions, Risks & Trade-offs
Decisions requiring product / engineering alignment
- ORM vs. Raw SQL trade-off: The choice between ORM and raw SQL impacts developer productivity, query optimization control, and migration complexity. This decision should be made at the organization level based on team expertise and performance requirements.
- Database vendor selection: The choice of database (PostgreSQL, MySQL, MongoDB) affects the implementation approach and tooling stack. This decision should align with existing infrastructure and team capabilities.
- CQRS vs. CTA pattern: Whether to implement Command-Query Responsibility Segregation or a simpler Command-Transaction-Action approach requires architectural alignment and team consensus on complexity vs. maintainability trade-offs.
Known trade-offs and justifications
| Trade-off | Justification |
|---|---|
| Single repository interface vs. multiple specialized repositories | Single interface provides simplicity but can lead to performance issues with complex queries. Multiple specialized repositories improve query performance and type safety at the cost of increased complexity in maintaining multiple interfaces. |
| Generic repository vs. domain-specific repository methods | Generic repositories provide consistency but may not expose database-specific optimizations or complex query patterns. Domain-specific methods enable better performance and maintainability at the cost of increased interface complexity. |
| Synchronous vs. asynchronous data access | Synchronous operations provide simpler error handling and debugging but can cause performance bottlenecks under high load. Asynchronous operations improve throughput but add complexity in error handling and debugging. |
| Read/write separation vs. unified data access | Separating read and write operations enables better scaling and optimization but increases system complexity and potential consistency issues. Unified access simplifies development but can create bottlenecks under read-heavy loads. |
Risks and mitigation strategies
| Risk | Mitigation |
|---|---|
| Performance degradation from generic repository patterns | Implement query optimization strategies, provide performance monitoring, and allow domain-specific overrides where needed. |
| Complexity of supporting multiple database backends | Implement database abstraction layers with clear boundaries, use feature flags for database-specific optimizations, and maintain comprehensive test coverage. |
| Inconsistent error handling across repositories | Define strict error handling contracts, implement centralized error translation, and enforce error handling through code reviews. |
| Caching invalidation issues | Implement cache invalidation strategies with event-driven updates, use cache versioning, and maintain cache consistency through transactional events. |
| Data consistency violations in distributed systems | Implement proper transaction boundaries, use saga patterns for distributed operations, and implement idempotency checks. |
Technical debt acknowledged
- Repository interface bloat: As more domain entities are added, the repository interface may grow unwieldy. This can be mitigated by introducing domain-specific repository interfaces or using generic type parameters.
- ORM query optimization limitations: ORM tools may not always generate optimal SQL queries, potentially leading to performance bottlenecks. This debt can be addressed through query profiling and manual SQL overrides where necessary.
- Testing complexity: The abstraction layer adds another layer of indirection that can make unit testing more complex. This is mitigated by comprehensive integration tests and proper mocking strategies.
- Version compatibility issues: Changes to the repository abstraction layer can break existing implementations. This is managed through strict versioning, clear migration paths, and comprehensive documentation.
- Limited database-specific features: Generic repository patterns may not expose all database-specific capabilities, potentially limiting performance optimization opportunities. This is addressed by providing extension points for database-specific features and maintaining clear documentation of supported features.
The design intentionally balances flexibility with maintainability, acknowledging that some trade-offs are necessary to achieve a robust and scalable data access abstraction layer.