Example Project: Employee Management
A worked mini-project: schema design, business queries, and a validated transfer procedure.
This page pulls together everything covered so far into one realistic worked project: a small employee/department/project management system, built from scratch — schema, seed data, a set of business queries a real manager or HR system would actually ask, and a stored procedure that performs a validated, audited multi-step operation.
Designing the schema
Four tables, each with a clear relationship to the others: departments and employees extend the running schema used throughout this track, and projects/employee_projects add the project-tracking side of the system — employee_projects is a many-to-many junction table, since one employee can work on several projects and one project has several employees assigned to it.
CREATE TABLE departments (
department_id NUMBER(4) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
department_name VARCHAR2(50) NOT NULL,
location VARCHAR2(50)
);
CREATE TABLE employees (
employee_id NUMBER(6) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
first_name VARCHAR2(30) NOT NULL,
last_name VARCHAR2(30) NOT NULL,
email VARCHAR2(100) NOT NULL UNIQUE,
hire_date DATE DEFAULT SYSDATE NOT NULL,
job_title VARCHAR2(50),
salary NUMBER(8,2) CHECK (salary > 0),
department_id NUMBER(4) REFERENCES departments (department_id),
manager_id NUMBER(6) REFERENCES employees (employee_id)
);
CREATE TABLE projects (
project_id NUMBER(6) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
project_name VARCHAR2(100) NOT NULL,
department_id NUMBER(4) REFERENCES departments (department_id),
start_date DATE NOT NULL,
end_date DATE,
budget NUMBER(10,2) CHECK (budget >= 0)
);
CREATE TABLE employee_projects (
employee_id NUMBER(6) REFERENCES employees (employee_id),
project_id NUMBER(6) REFERENCES projects (project_id),
role VARCHAR2(50),
allocation_percent NUMBER(3) CHECK (allocation_percent BETWEEN 1 AND 100),
assigned_date DATE DEFAULT SYSDATE NOT NULL,
CONSTRAINT pk_employee_projects PRIMARY KEY (employee_id, project_id)
);
Note the schema uses GENERATED ALWAYS AS IDENTITY throughout (the modern, 12c+ approach from the transactions/sequences page) rather than a separate sequence and trigger — every INSERT below simply omits the ID columns and lets Oracle assign them.
Seed data
INSERT INTO departments (department_name, location) VALUES ('Engineering', 'Austin');
INSERT INTO departments (department_name, location) VALUES ('Sales', 'Chicago');
INSERT INTO departments (department_name, location) VALUES ('Finance', 'New York');
-- department_ids assigned: 1 = Engineering, 2 = Sales, 3 = Finance
-- (a 4th department, 4 = Legal, is intentionally left with no employees for the queries below)
INSERT INTO departments (department_name, location) VALUES ('Legal', 'Boston');
INSERT INTO employees (first_name, last_name, email, hire_date, job_title, salary, department_id, manager_id)
VALUES ('Amara', 'Okafor', 'amara.okafor@example.com', DATE '2018-02-01', 'VP Engineering', 15000, 1, NULL);
INSERT INTO employees (first_name, last_name, email, hire_date, job_title, salary, department_id, manager_id)
VALUES ('Erik', 'Lindgren', 'erik.lindgren@example.com', DATE '2019-06-15', 'Engineering Manager', 11000, 1, 1);
INSERT INTO employees (first_name, last_name, email, hire_date, job_title, salary, department_id, manager_id)
VALUES ('Sofia', 'Bianchi', 'sofia.bianchi@example.com', DATE '2021-03-10', 'Software Engineer', 8700, 1, 2);
INSERT INTO employees (first_name, last_name, email, hire_date, job_title, salary, department_id, manager_id)
VALUES ('Kenji', 'Suzuki', 'kenji.suzuki@example.com', DATE '2022-09-01', 'Software Engineer', 8200, 1, 2);
INSERT INTO employees (first_name, last_name, email, hire_date, job_title, salary, department_id, manager_id)
VALUES ('Priya', 'Nair', 'priya.nair@example.com', DATE '2020-01-20', 'Sales Director', 10500, 2, 1);
INSERT INTO employees (first_name, last_name, email, hire_date, job_title, salary, department_id, manager_id)
VALUES ('Diego', 'Fernandez', 'diego.fernandez@example.com', DATE '2023-04-11', 'Account Executive', 6800, 2, 5);
INSERT INTO projects (project_name, department_id, start_date, end_date, budget)
VALUES ('Checkout Redesign', 1, DATE '2025-01-01', NULL, 250000);
INSERT INTO projects (project_name, department_id, start_date, end_date, budget)
VALUES ('Internal Analytics Platform', 1, DATE '2024-06-01', DATE '2025-05-01', 180000);
INSERT INTO projects (project_name, department_id, start_date, end_date, budget)
VALUES ('Regional Expansion', 2, DATE '2025-03-01', NULL, 120000);
INSERT INTO employee_projects (employee_id, project_id, role, allocation_percent)
VALUES (3, 1, 'Lead Developer', 80);
INSERT INTO employee_projects (employee_id, project_id, role, allocation_percent)
VALUES (4, 1, 'Developer', 60);
INSERT INTO employee_projects (employee_id, project_id, role, allocation_percent)
VALUES (4, 2, 'Developer', 40);
INSERT INTO employee_projects (employee_id, project_id, role, allocation_percent)
VALUES (6, 3, 'Account Lead', 100);
COMMIT;
(Employee IDs are shown above as plain integers purely for readability of the seed script — with GENERATED ALWAYS AS IDENTITY, the actual assigned values come from Oracle's internal sequence in insert order, starting at 1 in a fresh schema.)
Business queries
Employees earning above their department's average
A correlated subquery (see the SQL track's subqueries page) recomputes the average per row's own department, comparing each employee only against their direct peers rather than the company-wide average:
SELECT e.first_name, e.last_name, d.department_name, e.salary
FROM employees e
JOIN departments d ON e.department_id = d.department_id
WHERE e.salary > (
SELECT AVG(e2.salary)
FROM employees e2
WHERE e2.department_id = e.department_id
)
ORDER BY d.department_name, e.salary DESC;
Departments with no employees
The Legal department was seeded deliberately with zero employees — exactly the kind of row a plain JOIN would silently drop. NOT EXISTS (the safest correlated pattern for "no matching row at all," see this app's SQL subqueries page) finds it directly:
SELECT d.department_name
FROM departments d
WHERE NOT EXISTS (
SELECT 1 FROM employees e WHERE e.department_id = d.department_id
);
DEPARTMENT_NAME
Legal
The management chain for a given employee
Reusing the CONNECT BY PRIOR technique from the tricky-queries page — every manager above Sofia Bianchi, walking up to the top of the org:
SELECT employee_id, first_name, last_name, manager_id, LEVEL AS depth
FROM employees
START WITH last_name = 'Bianchi'
CONNECT BY PRIOR manager_id = employee_id
ORDER BY depth;
EMPLOYEE_ID FIRST_NAME LAST_NAME MANAGER_ID DEPTH
3 Sofia Bianchi 2 1
2 Erik Lindgren 1 2
1 Amara Okafor (null) 3
Employees assigned to more than one active project
An "active" project is one with no end_date yet (still ongoing). Grouping employee_projects joined against only the active projects, then keeping groups with more than one row, surfaces anyone spread across multiple concurrent projects — useful for spotting over-allocation:
SELECT e.first_name, e.last_name, COUNT(*) AS active_project_count,
SUM(ep.allocation_percent) AS total_allocation_percent
FROM employees e
JOIN employee_projects ep ON e.employee_id = ep.employee_id
JOIN projects p ON ep.project_id = p.project_id
WHERE p.end_date IS NULL
GROUP BY e.first_name, e.last_name
HAVING COUNT(*) > 1;
FIRST_NAME LAST_NAME ACTIVE_PROJECT_COUNT TOTAL_ALLOCATION_PERCENT
Kenji Suzuki 1 40
(Only one row qualifies in this seed data — Kenji is on two employee_projects rows total, but only one points at a still-active project once Internal Analytics Platform's end_date is accounted for. Widening the WHERE to drop the active-only filter would surface him with a count of 2 instead — worth trying against the seed data above to see the difference directly.)
Department budget exposure
A quick aggregate rollup — total committed project budget per department, alongside current headcount, is the kind of at-a-glance summary that's a strong candidate for a materialized view (see the views/materialized-views page) once the underlying tables grow large:
SELECT d.department_name,
COUNT(DISTINCT e.employee_id) AS headcount,
NVL(SUM(DISTINCT p.budget), 0) AS total_project_budget
FROM departments d
LEFT JOIN employees e ON e.department_id = d.department_id
LEFT JOIN projects p ON p.department_id = d.department_id
GROUP BY d.department_name;
A stored procedure: transferring an employee between departments
Moving an employee to a different department is a real multi-step operation, not a bare UPDATE: it needs to confirm both the employee and the target department actually exist, reject a meaningless no-op transfer, and — because of the audit trigger defined below — leave a clear record of exactly what changed and when.
First, the audit table and the trigger that populates it automatically on every department change:
CREATE TABLE employee_department_audit (
audit_id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
employee_id NUMBER NOT NULL,
old_department_id NUMBER,
new_department_id NUMBER,
changed_by VARCHAR2(30),
changed_at TIMESTAMP
);
CREATE OR REPLACE TRIGGER trg_employees_audit_department
AFTER UPDATE OF department_id ON employees
FOR EACH ROW
BEGIN
INSERT INTO employee_department_audit (
employee_id, old_department_id, new_department_id, changed_by, changed_at
) VALUES (
:OLD.employee_id, :OLD.department_id, :NEW.department_id, USER, SYSTIMESTAMP
);
END;
/
Now the procedure itself:
CREATE OR REPLACE PROCEDURE transfer_employee (
p_employee_id IN employees.employee_id%TYPE,
p_new_department_id IN employees.department_id%TYPE
) AS
v_current_department_id employees.department_id%TYPE;
v_department_exists NUMBER;
BEGIN
-- 1. Confirm the employee exists, and capture their current department
SELECT department_id INTO v_current_department_id
FROM employees
WHERE employee_id = p_employee_id;
-- 2. Confirm the target department exists
SELECT COUNT(*) INTO v_department_exists
FROM departments
WHERE department_id = p_new_department_id;
IF v_department_exists = 0 THEN
RAISE_APPLICATION_ERROR(-20010, 'Department ' || p_new_department_id || ' does not exist.');
END IF;
-- 3. Reject a no-op transfer
IF v_current_department_id = p_new_department_id THEN
RAISE_APPLICATION_ERROR(-20011, 'Employee ' || p_employee_id || ' is already in that department.');
END IF;
-- 4. Perform the transfer -- the AFTER UPDATE trigger above fires automatically and logs it
UPDATE employees
SET department_id = p_new_department_id
WHERE employee_id = p_employee_id;
EXCEPTION
WHEN NO_DATA_FOUND THEN
RAISE_APPLICATION_ERROR(-20012, 'Employee ' || p_employee_id || ' does not exist.');
END transfer_employee;
/
Calling it, then checking that the audit trigger fired as expected:
BEGIN
transfer_employee(p_employee_id => 6, p_new_department_id => 1);
COMMIT;
END;
/
SELECT * FROM employee_department_audit WHERE employee_id = 6;
AUDIT_ID EMPLOYEE_ID OLD_DEPARTMENT_ID NEW_DEPARTMENT_ID CHANGED_BY CHANGED_AT
1 6 2 1 HR_ADMIN 2026-08-26 11:02:47
Notice the procedure itself never calls COMMIT — that decision is deliberately left to the caller (the design note from the stored-procedures page): transfer_employee might reasonably be called as one step inside a larger transaction that also updates a payroll record or a project assignment, and it shouldn't unilaterally decide when that larger unit of work is considered final. The three validation steps run before any data actually changes, so a rejected transfer (an unknown employee, an unknown department, or a same-department no-op) never touches a row or fires the trigger at all — exactly the behavior you'd want from a procedure other code is going to call in production without re-checking its work.
What this project demonstrates
Every piece from earlier in this track shows up here doing real work: GENERATED AS IDENTITY surrogate keys, foreign keys enforcing the department/employee/project relationships, a correlated subquery and NOT EXISTS for two of the business queries, CONNECT BY PRIOR for the management chain, a JOIN-and-GROUP BY/HAVING combination for the over-allocation check, and a stored procedure with real input validation, custom exceptions, and an audit trigger firing as a side effect of a plain UPDATE — a small but genuinely representative slice of what a production Oracle-backed application looks like end to end.