Immigration Management System
Relational schema + CRUD for immigration ops.
- ROLE
- Developer
- DOMAIN
- Student work
OVERVIEW
A normalized (3NF) MySQL schema for immigration case management: people, cases, a junction table for who plays which role on each case, documents with issue and expiration dates, and a status-history table as the audit trail. Indexes cover the frequent lookups, such as documents expiring soon, and a stored procedure creates a case and its primary applicant in one transaction.
ARRIVED AS
“Normalize complex person-/case-centric data.”
Immigration case management means tracking how people, cases, documents and legal processes relate, and those links get messy fast. I needed a normalized relational schema that could handle:
- Multiple people per case (applicants, dependents, sponsors)
- Various case types (family-based, employment-based, asylum, etc.)
- Document lifecycle management (submission, expiration, renewals)
- Time-sensitive deadlines and status transitions
- Historical audit trails for compliance
WHAT I BUILT
- 01ERD → SQL DDL; indexes for frequent lookups; CRUD console.
WHAT CHANGED
- Predictable performance & cleaner data model.
ARCHITECTURE
Database Design Approach:
The schema follows a normalized relational model (3NF) with some denormalization for performance:
-
Core Entity Tables:
persons- Central person registry (applicants, dependents, sponsors)cases- Immigration case metadata with type and statusdocuments- Document tracking with expiration managementcase_persons- Many-to-many relationship with role differentiation
-
Supporting Tables:
case_status_history- Audit trail for state transitionsdocument_types- Lookup table for standardized document categoriescase_types- Immigration case category definitions
-
Indexing Strategy:
- Composite indexes on
(case_id, person_id, role)for relationship queries - A composite B-tree index on
(expiration_date, status)for deadline monitoring - A composite index on
(last_name, first_name)for name lookups
- Composite indexes on
Normalization Decisions:
- Separated person data from case data to avoid duplication (one person can be in multiple cases)
- Created junction table for many-to-many relationships with additional attributes (role, is_primary flag)
- Extracted case types and document types into lookup tables for referential integrity
Data flow
1. Case Creation Flow:
User Input → Validate Person Data → Insert/Update persons table
→ Create case record in cases table
→ Link persons to case via case_persons junction table
→ Initialize case_status_history with "OPEN" status
2. Document Submission Flow:
Document Upload → Validate document type against document_types
→ Insert into documents table with case_id reference
→ Calculate expiration date based on document type
→ Update case status if all required documents submitted
3. Case Status Transition:
Status Change Request → Validate allowed transitions (business rules)
→ Update cases.status field
→ Insert audit record in case_status_history
→ Trigger notifications if needed (via application layer)
4. Query Patterns:
- Find Expiring Documents: Index scan on
idx_expirationforexpiration_date BETWEEN NOW() AND NOW() + 30 DAYS - Case Details: JOIN cases, case_persons, persons to fetch complete case view
- Person History: JOIN persons, case_persons, cases to show all cases for a person
COMPONENTS
1. Data Definition Layer (DDL)
- Table creation scripts with proper constraints (PRIMARY KEY, FOREIGN KEY, NOT NULL, ENUM)
- Index definitions for query optimization
- View definitions for common query patterns
2. CRUD Operations Layer
- Parameterized SQL procedures for safe data manipulation
- Transaction management for multi-table operations (e.g., creating a case and its primary applicant atomically)
- Input validation at the database level through NOT NULL columns and ENUM statuses
3. Query Interface
- Console-based application for data entry and retrieval
- Pre-built queries for common operations (case search, document expiration alerts)
- Export functionality for reporting
4. Data Integrity Mechanisms
- Foreign key constraints to maintain referential integrity
DEFAULT CURRENT_TIMESTAMPoncreated_atandON UPDATE CURRENT_TIMESTAMPonupdated_atfor automatic timestamps- Cascading deletes configured appropriately (CASCADE vs RESTRICT based on business rules)
Decisions, with the cost of each.
A decision without its trade-off is marketing. Each row says what was chosen, why, and what it gave up.
Keep person data normalized, denormalize case counts in persons table
Person data changes infrequently but is queried often. Storing active_case_count avoids expensive COUNT queries while accepting occasional inconsistency risk.
AT THE COST OF
Every case change has to keep active_case_count in step; a missed update shows a wrong count until it is recomputed.
Use case_persons junction table with role enum (applicant, dependent, sponsor, attorney)
One person can hold several roles in the same case (e.g., sponsor and attorney) and the schema stays clean.
AT THE COST OF
The role list is an ENUM, so a new role is a schema change (ALTER TABLE), not a row.
Store both issue_date and expiration_date rather than calculating on-the-fly
Different document types have different validity periods, and some may be extended. Storing the date up front makes deadline checks a fast index lookup.
AT THE COST OF
An extension is an update to the stored date, and nothing in the schema stops a stored date from disagreeing with its document type's validity period.
Separate case_status_history table instead of JSON field in cases table
History can be queried with plain SQL, the schema stays normalized, and range queries are fast (e.g., "all cases that were pending in Q2 2023").
AT THE COST OF
Every status change is two writes inside one transaction, and a case's history is a join away.
MySQL with the InnoDB engine
Immigration data needs ACID guarantees. Row-level locking handles concurrent case updates, and foreign keys and transactions are built in.
AT THE COST OF
The schema leans on MySQL specifics (ENUM columns, ON UPDATE CURRENT_TIMESTAMP), so moving to another database is a rewrite of those parts.
UTF-8 encoding for all text fields
Names and addresses come in many languages. UTF-8 stores and sorts those characters properly.
AT THE COST OF
In utf8mb4 a character takes up to four bytes, so long indexed VARCHAR columns reach InnoDB's index-length limit sooner.
The part that mattered.
The code that did the work.
-- Person entity (normalized to avoid duplication)
CREATE TABLE persons (
person_id INT PRIMARY KEY AUTO_INCREMENT,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
date_of_birth DATE NOT NULL,
nationality VARCHAR(100),
passport_number VARCHAR(50) UNIQUE,
email VARCHAR(255),
phone VARCHAR(20),
active_case_count INT DEFAULT 0, -- Denormalized for performance
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_name (last_name, first_name),
INDEX idx_dob (date_of_birth)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Case entity
CREATE TABLE cases (
case_id INT PRIMARY KEY AUTO_INCREMENT,
case_number VARCHAR(50) UNIQUE NOT NULL,
case_type_id INT NOT NULL,
status ENUM('OPEN', 'PENDING', 'APPROVED', 'DENIED', 'CLOSED') DEFAULT 'OPEN',
filing_date DATE NOT NULL,
target_completion_date DATE,
priority ENUM('LOW', 'MEDIUM', 'HIGH', 'URGENT') DEFAULT 'MEDIUM',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (case_type_id) REFERENCES case_types(case_type_id),
INDEX idx_status_date (status, filing_date),
INDEX idx_completion (target_completion_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Many-to-many relationship with role differentiation
CREATE TABLE case_persons (
case_person_id INT PRIMARY KEY AUTO_INCREMENT,
case_id INT NOT NULL,
person_id INT NOT NULL,
role ENUM('APPLICANT', 'DEPENDENT', 'SPONSOR', 'ATTORNEY') NOT NULL,
is_primary BOOLEAN DEFAULT FALSE,
relationship VARCHAR(50), -- e.g., "spouse", "child", "employer"
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (case_id) REFERENCES cases(case_id) ON DELETE CASCADE,
FOREIGN KEY (person_id) REFERENCES persons(person_id) ON DELETE RESTRICT,
UNIQUE KEY unique_case_person_role (case_id, person_id, role),
INDEX idx_person_cases (person_id, case_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Document tracking with expiration management
CREATE TABLE documents (
document_id INT PRIMARY KEY AUTO_INCREMENT,
case_id INT NOT NULL,
document_type_id INT NOT NULL,
document_number VARCHAR(100),
issue_date DATE NOT NULL,
expiration_date DATE,
file_path VARCHAR(500),
status ENUM('PENDING', 'SUBMITTED', 'APPROVED', 'REJECTED', 'EXPIRED') DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (case_id) REFERENCES cases(case_id) ON DELETE CASCADE,
FOREIGN KEY (document_type_id) REFERENCES document_types(document_type_id),
INDEX idx_expiration (expiration_date, status),
INDEX idx_case_docs (case_id, document_type_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- View for complete case information
CREATE VIEW v_case_details AS
SELECT
c.case_id,
c.case_number,
ct.case_type_name,
c.status,
c.filing_date,
c.target_completion_date,
p.person_id,
CONCAT(p.first_name, ' ', p.last_name) AS person_name,
p.nationality,
cp.role,
cp.is_primary,
cp.relationship,
COUNT(DISTINCT d.document_id) AS total_documents,
COUNT(DISTINCT CASE WHEN d.status = 'APPROVED' THEN d.document_id END) AS approved_documents,
MIN(d.expiration_date) AS next_expiration
FROM cases c
INNER JOIN case_types ct ON c.case_type_id = ct.case_type_id
LEFT JOIN case_persons cp ON c.case_id = cp.case_id
LEFT JOIN persons p ON cp.person_id = p.person_id
LEFT JOIN documents d ON c.case_id = d.case_id
GROUP BY c.case_id, p.person_id, cp.case_person_id;
-- Query to find cases needing attention (expiring documents)
SELECT
c.case_number,
c.status,
p.first_name,
p.last_name,
dt.document_type_name,
d.expiration_date,
DATEDIFF(d.expiration_date, CURDATE()) AS days_until_expiration
FROM documents d
INNER JOIN cases c ON d.case_id = c.case_id
INNER JOIN case_persons cp ON c.case_id = cp.case_id AND cp.is_primary = TRUE
INNER JOIN persons p ON cp.person_id = p.person_id
INNER JOIN document_types dt ON d.document_type_id = dt.document_type_id
WHERE d.expiration_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 30 DAY)
AND d.status NOT IN ('EXPIRED', 'REJECTED')
AND c.status NOT IN ('CLOSED', 'DENIED')
ORDER BY d.expiration_date ASC;
-- Procedure to create a case with primary applicant atomically
DELIMITER $$
CREATE PROCEDURE create_immigration_case(
IN p_case_number VARCHAR(50),
IN p_case_type_id INT,
IN p_filing_date DATE,
IN p_first_name VARCHAR(100),
IN p_last_name VARCHAR(100),
IN p_date_of_birth DATE,
IN p_nationality VARCHAR(100),
IN p_passport_number VARCHAR(50),
OUT p_case_id INT,
OUT p_person_id INT
)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;
START TRANSACTION;
-- Check if person already exists by passport
SELECT person_id INTO p_person_id
FROM persons
WHERE passport_number = p_passport_number
LIMIT 1;
-- Create person if not exists
IF p_person_id IS NULL THEN
INSERT INTO persons (first_name, last_name, date_of_birth, nationality, passport_number)
VALUES (p_first_name, p_last_name, p_date_of_birth, p_nationality, p_passport_number);
SET p_person_id = LAST_INSERT_ID();
END IF;
-- Create case
INSERT INTO cases (case_number, case_type_id, filing_date, status)
VALUES (p_case_number, p_case_type_id, p_filing_date, 'OPEN');
SET p_case_id = LAST_INSERT_ID();
-- Link person to case as primary applicant
INSERT INTO case_persons (case_id, person_id, role, is_primary)
VALUES (p_case_id, p_person_id, 'APPLICANT', TRUE);
-- Update denormalized counter
UPDATE persons SET active_case_count = active_case_count + 1
WHERE person_id = p_person_id;
-- Create initial status history entry
INSERT INTO case_status_history (case_id, old_status, new_status, changed_by)
VALUES (p_case_id, NULL, 'OPEN', USER());
COMMIT;
END$$
DELIMITER ;
LEARNED
Database Design Insights:
-
Normalization is Not Always Absolute
- Started with strict 3NF but found that denormalizing
active_case_countin the persons table removed an aggregate query every case listing would otherwise run. - For read-heavy fields, denormalizing is worth the extra work of keeping the copy consistent.
- Started with strict 3NF but found that denormalizing
-
Junction Tables Should Carry Business Logic
- Initially made
case_personsa pure junction table with just IDs. Later addedrole,is_primary, andrelationshipfields. - Many-to-many relationships in real-world domains often have attributes that belong to the relationship itself, not either entity.
- Initially made
-
Date Fields Require Careful Indexing
- Queries filtering by
expiration_dateranges were slow until adding composite index(expiration_date, status). - Date range queries need the right index, and putting status in the composite index got rid of the filesorts.
- Queries filtering by
-
Enums vs. Foreign Keys Trade-off
- Used ENUMs for
statusandrolefields (limited, stable values) but foreign keys forcase_typeanddocument_type(extensible). - ENUMs are faster and enforce constraints at the schema level, but they're rigid. Use them only for stable domains.
- Used ENUMs for
-
Audit Trails Are Essential for Compliance
- Immigration data has legal implications. The
case_status_historytable earned its keep answering "when did this case move to PENDING?" - For regulated domains, invest in audit infrastructure early. Retrofitting is painful.
- Immigration data has legal implications. The
-
Constraints Prevent Bad Data
- Foreign key constraints reject a case that names a case type which does not exist.
- NOT NULL issue dates and ENUM statuses kept document rows to values the queries could trust.
- Let the database enforce business rules where possible. It's the last line of defense against data corruption.
-
UTF-8 Encoding is Non-Negotiable
- Names with accented characters (José, Müller, etc.) need utf8mb4; latin1 would mangle them, and converting a populated table later is painful.
- Always use UTF-8 (specifically utf8mb4 in MySQL) from day one when dealing with international data.
-
Transactions Ensure Consistency
- Creating a case involves 4-5 table inserts. Without transactions, partial failures left orphaned records.
- Stored procedure with transaction boundaries solved this completely.
- Multi-table operations must be wrapped in transactions. ACID compliance is worth the learning curve.