This document provides comprehensive guidance on the database configuration, entity modeling, and migration strategy implemented for the Chamaa API.
The Chamaa API uses a robust, production-ready database setup with:
- PostgreSQL as the primary relational database for production and staging
- H2 for development and testing (in-memory, PostgreSQL-compatible mode)
- Spring Data JPA with Hibernate ORM for object-relational mapping
- HikariCP connection pooling for efficient resource management
- Flyway for versioned database migrations
The application supports three Spring profiles for environment-specific configuration:
- Database: H2 (in-memory)
- DDL Auto:
validate(validates schema exists) - Flyway: Disabled (H2 handles schema in startup)
- Configuration:
application-dev.properties - Connection Pool: 5 max connections, 2 minimum idle
- SQL Logging: DEBUG level for development debugging
- Database: PostgreSQL
- DDL Auto:
validate(no auto schema changes) - Flyway: Enabled (applies versioned migrations)
- Configuration:
application-prod.properties - Connection Pool: 20 max connections, 10 minimum idle
- SQL Logging: WARN level for performance
- Uses H2 in-memory database
- Spring Test Context handles schema setup
Key environment variables for database configuration:
# Database Connection
DB_URL=jdbc:postgresql://localhost:5432/chamaa_db
DB_USERNAME=postgres
DB_PASSWORD=your_secure_password
# Connection Pool (HikariCP)
DB_POOL_SIZE=20 # Maximum pool size
DB_MIN_IDLE=10 # Minimum idle connections
# JPA/Hibernate
JPA_DDL_AUTO=validate # validate | update | create | create-drop
JPA_DIALECT=org.hibernate.dialect.PostgreSQLDialect
JPA_SHOW_SQL=false # Enable SQL logging
# Flyway Migrations
FLYWAY_ENABLED=true # Enable/disable migration runner
# Logging
LOG_LEVEL_HIBERNATE=WARN # Hibernate logging level# Uses H2 in-memory database
# Configured with PostgreSQL mode for compatibility testing
spring.datasource.url=jdbc:h2:mem:chamaadb;MODE=PostgreSQL;...
spring.jpa.hibernate.ddl-auto=validate
spring.flyway.enabled=false# H2 in-memory with H2 console enabled
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.jpa.show-sql=false
logging.level.org.hibernate.SQL=DEBUG# PostgreSQL with Flyway migrations
spring.datasource.url=jdbc:postgresql://...
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.flyway.enabled=trueAll entities inherit from BaseEntity which provides:
- Auto-generated Long ID (IDENTITY strategy)
- Audit timestamps (
createdAt,updatedAt) - JPA Auditing integration
Primary user entity for the platform.
@Entity
@Table(name = "users", indexes = {
@Index(name = "idx_users_email", columnList = "email", unique = true),
@Index(name = "idx_users_phone_number", columnList = "phone_number", unique = true),
@Index(name = "idx_users_wallet_address", columnList = "wallet_address"),
@Index(name = "idx_users_is_active", columnList = "is_active")
})
public class User extends BaseEntity {
@Column(nullable = false, unique = true, length = 255)
private String email;
@Column(nullable = false, length = 255)
private String password;
@Column(nullable = false, length = 100)
private String firstName;
@Column(nullable = false, length = 100)
private String lastName;
@Column(unique = true, length = 20)
private String phoneNumber;
@Column(length = 500)
private String profileImageUrl;
@Column(nullable = false)
private Boolean isActive = true;
@Column(length = 255)
private String walletAddress;
}Relationships:
- One-to-One: User ↔ Wallet (inverse side)
- One-to-Many: User → Groups (as creator)
- One-to-Many: User → Loans (as borrower)
Constraints:
- Unique: email, phoneNumber
- Unique constraint indexed:
uc_users_email,uc_users_phone_number
Represents user's balance and transaction account.
@Entity
@Table(name = "wallets", indexes = {
@Index(name = "idx_wallets_user_id", columnList = "user_id", unique = true),
@Index(name = "idx_wallets_wallet_address", columnList = "wallet_address", unique = true),
@Index(name = "idx_wallets_is_active", columnList = "is_active")
})
public class Wallet extends BaseEntity {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false, unique = true)
private User user;
@Column(nullable = false)
private Double balance = 0.0;
@Column(unique = true, length = 255)
private String walletAddress;
@Column(nullable = false, length = 10)
private String currency = "USD";
@Column(nullable = false)
private Boolean isActive = true;
}Relationships:
- One-to-One: Wallet ← User (owning side)
- One-to-Many: Wallet → Transactions (as fromWallet)
- One-to-Many: Wallet → Transactions (as toWallet)
Constraints:
- Foreign Key:
user_id(UNIQUE, NOT NULL) - Unique: walletAddress
Represents a lending group with members.
@Entity
@Table(name = "groups", indexes = {
@Index(name = "idx_groups_creator_id", columnList = "creator_id"),
@Index(name = "idx_groups_is_active", columnList = "is_active"),
@Index(name = "idx_groups_name", columnList = "name")
})
public class Group extends BaseEntity {
@Column(nullable = false, length = 255)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "creator_id", nullable = false)
private User creator;
@Column(nullable = false)
private Integer memberCount = 0;
@Column(nullable = false)
private Boolean isActive = true;
@Column(nullable = false)
private Double targetAmount = 0.0;
@Column(length = 500)
private String groupImageUrl;
}Relationships:
- Many-to-One: Group ← User (creator, owning side)
- One-to-Many: Group → Loans
- One-to-Many: Group → Transactions
Constraints:
- Foreign Key:
creator_id(NOT NULL, CASCADE delete) - Index:
idx_groups_name
Records all fund transfers between wallets.
@Entity
@Table(name = "transactions", indexes = {
@Index(name = "idx_transactions_from_wallet", columnList = "from_wallet_id"),
@Index(name = "idx_transactions_to_wallet", columnList = "to_wallet_id"),
@Index(name = "idx_transactions_group", columnList = "group_id"),
@Index(name = "idx_transactions_status", columnList = "status"),
@Index(name = "idx_transactions_created_at", columnList = "created_at")
})
public class Transaction extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "from_wallet_id", nullable = false)
private Wallet fromWallet;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "to_wallet_id", nullable = false)
private Wallet toWallet;
@Column(nullable = false)
private Double amount;
@Column(nullable = false, length = 50)
private String status = "PENDING";
@Column(length = 255)
private String transactionHash;
@Column(columnDefinition = "TEXT")
private String description;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "group_id")
private Group group;
}Relationships:
- Many-to-One: Transaction → Wallet (fromWallet, owning side)
- Many-to-One: Transaction → Wallet (toWallet, owning side)
- Many-to-One: Transaction → Group (optional, owning side)
Constraints:
- Foreign Keys:
from_wallet_id,to_wallet_id(RESTRICT delete - prevent removing wallets in use) - Foreign Key:
group_id(SET NULL on cascade - optional relationship) - Status values: PENDING, COMPLETED, FAILED
Represents a loan request within a group.
@Entity
@Table(name = "loans", indexes = {
@Index(name = "idx_loans_borrower_id", columnList = "borrower_id"),
@Index(name = "idx_loans_group_id", columnList = "group_id"),
@Index(name = "idx_loans_status", columnList = "status"),
@Index(name = "idx_loans_created_at", columnList = "created_at")
})
public class Loan extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "borrower_id", nullable = false)
private User borrower;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "group_id", nullable = false)
private Group group;
@Column(nullable = false)
private Double amount;
@Column(nullable = false)
private Double interestRate = 0.0;
@Column(nullable = false, length = 50)
private String status = "PENDING";
@Column(columnDefinition = "TEXT")
private String purpose;
@Column(nullable = false)
private Integer durationInMonths;
@Column(nullable = false)
private Double paidAmount = 0.0;
}Relationships:
- Many-to-One: Loan → User (borrower, owning side)
- Many-to-One: Loan → Group (owning side)
Constraints:
- Foreign Keys:
borrower_id,group_id(CASCADE delete) - Status values: PENDING, APPROVED, ACTIVE, COMPLETED, REJECTED, DEFAULTED
Flyway migrations are located in src/main/resources/db/migration/.
Naming Convention: V{VERSION}__{DESCRIPTION}.sql
Examples:
V1__Initial_schema.sql- Core tables and relationshipsV2__Add_audit_columns.sql- Additional audit fieldsV3__Create_views.sql- Database views
The initial migration creates:
-
Users Table
CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, password VARCHAR(255) NOT NULL, first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, phone_number VARCHAR(20) UNIQUE, profile_image_url VARCHAR(500), is_active BOOLEAN DEFAULT true NOT NULL, wallet_address VARCHAR(255), created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL );
-
Groups Table (Foreign Key to Users)
-
Wallets Table (One-to-One with Users)
-
Transactions Table (Foreign Keys to Wallets, optional Group)
-
Loans Table (Foreign Keys to Users, Groups)
The migration includes:
- Primary Indexes: On all ID fields
- Unique Indexes: Email, phone number, wallet address
- Performance Indexes: On frequently queried columns
- Foreign Key Constraints: With appropriate cascade/restrict policies
Automatically (Production):
# Flyway runs on application startup
SPRING_PROFILES_ACTIVE=prod mvn spring-boot:runManually:
# Show migration status
mvn flyway:info
# Validate migrations
mvn flyway:validate
# Repair (remove failed migrations)
mvn flyway:repair
# Reset (DESTRUCTIVE - use only in dev!)
mvn flyway:clean# Dialect - tells Hibernate how to generate SQL for PostgreSQL
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
# DDL Auto - prevent automatic schema changes in production
spring.jpa.hibernate.ddl-auto=validate
# Batch Processing - improves performance for bulk operations
spring.jpa.properties.hibernate.jdbc.batch_size=20
spring.jpa.properties.hibernate.jdbc.fetch_size=50
# Query Optimization
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
# Connection Management
spring.jpa.open-in-view=false # Prevent N+1 query problemsAll associations use FetchType.LAZY to prevent unnecessary data loading:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "creator_id")
private User creator;Benefits:
- Reduces memory footprint
- Prevents N+1 query problems
- Improves response time
Usage:
// Load group without creator
Group group = groupRepository.findById(1L).get();
// Lazy loading happens here (separate query)
User creator = group.getCreator();# Pool sizing for production
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=10
# Timeouts and cleanup
spring.datasource.hikari.idle-timeout=600000 # 10 minutes
spring.datasource.hikari.max-lifetime=1800000 # 30 minutes
spring.datasource.hikari.connection-timeout=30000 # 30 secondsCheck pool status:
@GetMapping("/health/db-pool")
public ResponseEntity<Map<String, Object>> checkPool() {
// Pool statistics available via Spring Boot Actuator
return ResponseEntity.ok(Map.of(
"active_connections", hikariPool.getActiveConnections(),
"idle_connections", hikariPool.getIdleConnections(),
"pending_threads", hikariPool.getPendingThreads()
));
}// ✅ Good - Lazy loaded
@ManyToOne(fetch = FetchType.LAZY)
private User creator;
// ❌ Avoid - Eager loading
@ManyToOne(fetch = FetchType.EAGER)
private User creator;// ✅ Good - Clear constraints
@Column(nullable = false, length = 100)
private String firstName;
// ❌ Avoid - No constraints
private String firstName;// ✅ Good - Indexed for searches
@Index(name = "idx_users_email", columnList = "email", unique = true)
// ❌ Avoid - Missing indexes on WHERE clauses// ✅ Good - Cascades: Delete user → deletes wallet
@JoinColumn(name = "user_id", nullable = false)
private User user;
// ✅ Good - Restricts: Can't delete wallet in transaction
@JoinColumn(name = "from_wallet_id")
private Wallet fromWallet;
// ✅ Good - Nullifies: Delete group → nulls group_id in transaction
@JoinColumn(name = "group_id")
private Group group;Use JPA projections or explicit joins:
// ❌ N+1 Problem
List<Group> groups = groupRepository.findAll();
for (Group group : groups) {
User creator = group.getCreator(); // Separate query per group!
}
// ✅ Solution: Use EntityGraph
@EntityGraph(attributePaths = {"creator"})
List<Group> findAll();# Start with H2 (default)
./mvnw spring-boot:run
# Or explicitly:
SPRING_PROFILES_ACTIVE=dev ./mvnw spring-boot:run-
Provision PostgreSQL:
docker run -d \ -e POSTGRES_DB=chamaa_db \ -e POSTGRES_PASSWORD=secure_password \ -p 5432:5432 \ postgres:16-alpine
-
Set Environment Variables:
export SPRING_PROFILES_ACTIVE=prod export DB_URL=jdbc:postgresql://localhost:5432/chamaa_db export DB_USERNAME=postgres export DB_PASSWORD=secure_password export DB_POOL_SIZE=20 export FLYWAY_ENABLED=true
-
Start Application:
./mvnw spring-boot:run # Flyway automatically runs V1 migration on first startup
The application includes Docker Compose configurations:
# Production deployment
docker-compose up -d
# Development deployment (with hot-reload)
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d# Check migration history
mvn flyway:info
# Repair broken migration
mvn flyway:repair
# Validate all migrations
mvn flyway:validate# Verify PostgreSQL is running
psql -h localhost -U postgres -d chamaa_db
# Check connection in logs
tail -f logs/application.log | grep "Connection"LazyInitializationException: could not initialize proxy
Solution: Load related entities within transaction:
@Transactional
public GroupDTO getGroupWithCreator(Long id) {
Group group = groupRepository.findById(id).get();
group.getCreator().getName(); // Initialize within transaction
return mapper.toDTO(group);
}