Introducción: El desafío de digitalizar la salud pública
Cuando me enfrenté al desafío de diseñar un sistema de gestión para centros de salud municipales, me di cuenta de que no era un CRUD simple. Tenía que manejar pacientes, médicos, historias clínicas, internaciones, obras sociales y todo con integridad referencial, auditoría y consultas complejas.
Este proyecto me enseñó que el diseño de bases de datos no es solo crear tablas, sino modelar la realidad del negocio de forma eficiente y escalable. En este artículo comparto cómo abordé el problema, qué decisiones tomé y las mejores prácticas que apliqué.
El problema: requisitos complejos del sistema
Los requisitos del sistema eran claros pero desafiantes:
- Múltiples centros de salud organizados por zonas y barrios
- Médicos con múltiples especialidades trabajando en varios centros
- Pacientes con historias clínicas detalladas
- Internaciones con gestión de camas
- Integración con obras sociales
- Auditoría de todas las operaciones críticas
- Consultas complejas para reportes y estadísticas
Un mal diseño aquí significaría: datos inconsistentes, consultas lentas y pesadillas de mantenimiento.
Fase 1: Modelado conceptual y normalización
Lo primero fue identificar las entidades principales y sus relaciones:
- Zona → Barrio → Centro de Salud (relación jerárquica)
- Médico ↔ Especialidad (relación N:N)
- Paciente → Historia Clínica → Médico/Centro
- Internación → Cama → Centro de Salud
- Paciente → Obra Social (relación N:1)
Apliqué normalización hasta 3FN para evitar redundancia, pero sin obsesionarme (a veces un poco de desnormalización mejora el rendimiento).
-- Ejemplo: relación N:N entre Médico y Especialidad
CREATE TABLE Medico (
id_medico SERIAL PRIMARY KEY,
matricula VARCHAR(50) UNIQUE,
nombres VARCHAR(255) NOT NULL,
apellidos VARCHAR(255) NOT NULL,
-- más campos...
);
CREATE TABLE EspecialidadesMedicas (
id_especialidad SERIAL PRIMARY KEY,
nombre_especialidad VARCHAR(255) NOT NULL UNIQUE
);
-- Tabla asociativa
CREATE TABLE MedicoEspecialidad (
id_medico INT NOT NULL,
id_especialidad INT NOT NULL,
PRIMARY KEY (id_medico, id_especialidad),
FOREIGN KEY (id_medico) REFERENCES Medico(id_medico) ON DELETE CASCADE,
FOREIGN KEY (id_especialidad) REFERENCES EspecialidadesMedicas(id_especialidad) ON DELETE CASCADE
);
Las tablas asociativas fueron clave para manejar relaciones muchos-a-muchos.
Fase 2: Constraints y reglas de negocio
La integridad de datos no es opcional en salud. Implementé múltiples restricciones:
- UNIQUE en DNI de pacientes y matrícula de médicos
- NOT NULL en campos críticos
- Foreign Keys con ON DELETE CASCADE/SET NULL según el caso
- CHECK constraints (aunque finalmente usé triggers para validaciones complejas)
-- Paciente con DNI único y FK a obra social
CREATE TABLE Paciente (
id_paciente SERIAL PRIMARY KEY,
dni BIGINT UNIQUE NOT NULL,
nombres VARCHAR(255) NOT NULL,
apellidos VARCHAR(255) NOT NULL,
id_obra_social INT,
CONSTRAINT fk_paciente_obrasocial
FOREIGN KEY (id_obra_social)
REFERENCES ObraSocial(id_obra_social)
);
Aprendí que la base de datos debe ser el primer guardián de la integridad, no la aplicación.
Fase 3: Triggers para lógica compleja
Algunos requisitos no podían resolverse con constraints simples. Los triggers fueron la solución:
Trigger 1: Director único por centro
CREATE OR REPLACE FUNCTION fn_verificar_director_unico()
RETURNS TRIGGER AS $$
BEGIN
IF EXISTS (
SELECT 1 FROM CentroSalud
WHERE director = NEW.director
AND id_centro_salud <> COALESCE(NEW.id_centro_salud, -1)
) THEN
RAISE EXCEPTION 'Este director ya está asociado a otro centro de salud';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_director_unico
BEFORE INSERT OR UPDATE ON CentroSalud
FOR EACH ROW
EXECUTE FUNCTION fn_verificar_director_unico();
Trigger 2: Validar disponibilidad de cama
CREATE OR REPLACE FUNCTION fn_validar_disponibilidad_cama()
RETURNS TRIGGER AS $$
DECLARE
ocupada INT;
BEGIN
IF NEW.id_cama IS NULL THEN
RETURN NEW;
END IF;
-- Solo cuenta camas con internaciones activas
SELECT COUNT(*) INTO ocupada
FROM Internacion
WHERE id_cama = NEW.id_cama
AND fecha_alta IS NULL;
IF ocupada > 0 THEN
RAISE EXCEPTION 'La cama (id: %) está ocupada.', NEW.id_cama;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Los triggers me permitieron validar reglas de negocio complejas directamente en la base de datos.
Fase 4: Auditoría automática
En sistemas de salud, saber quién cambió qué y cuándo es crítico. Implementé una tabla de auditoría con triggers:
CREATE TABLE Auditoria (
id_auditoria SERIAL PRIMARY KEY,
tabla_afectada VARCHAR(255) NOT NULL,
tipo_operacion VARCHAR(50) NOT NULL,
id_registro_afectado INT,
usuario_operacion VARCHAR(255),
detalle_operacion TEXT,
fecha_hora TIMESTAMP DEFAULT now(),
id_usuario INT
);
-- Trigger de auditoría en Paciente
CREATE OR REPLACE FUNCTION fn_audit_paciente()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO Auditoria(tabla_afectada, tipo_operacion, id_registro_afectado,
usuario_operacion, detalle_operacion, fecha_hora)
VALUES ('Paciente', TG_OP, NEW.id_paciente, CURRENT_USER,
'INSERT Paciente', NOW());
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
INSERT INTO Auditoria(tabla_afectada, tipo_operacion, id_registro_afectado,
usuario_operacion, detalle_operacion, fecha_hora)
VALUES ('Paciente', TG_OP, NEW.id_paciente, CURRENT_USER,
'UPDATE Paciente', NOW());
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
INSERT INTO Auditoria(tabla_afectada, tipo_operacion, id_registro_afectado,
usuario_operacion, detalle_operacion, fecha_hora)
VALUES ('Paciente', TG_OP, OLD.id_paciente, CURRENT_USER,
'DELETE Paciente', NOW());
RETURN OLD;
END IF;
END;
$$ LANGUAGE plpgsql;
Ahora cada cambio en pacientes queda registrado automáticamente.
Fase 5: Procedimientos almacenados
Para operaciones comunes, creé stored procedures que encapsulan lógica compleja:
-- Insertar paciente con validación
CREATE OR REPLACE FUNCTION sp_insertar_paciente(
p_nombres VARCHAR,
p_apellidos VARCHAR,
p_dni BIGINT,
p_telefono VARCHAR,
p_domicilio VARCHAR,
p_email VARCHAR,
p_fecha_nacimiento DATE,
p_id_obra_social INT,
p_numero_afiliado VARCHAR
) RETURNS INT AS $$
DECLARE
new_id INT;
BEGIN
INSERT INTO Paciente(
nombres, apellidos, dni, telefono, domicilio, email,
fecha_nacimiento, id_obra_social, numero_afiliado
)
VALUES (
p_nombres, p_apellidos, p_dni, p_telefono, p_domicilio, p_email,
p_fecha_nacimiento, p_id_obra_social, p_numero_afiliado
)
RETURNING id_paciente INTO new_id;
RETURN new_id;
END;
$$ LANGUAGE plpgsql;
-- Uso:
-- SELECT sp_insertar_paciente('Juan', 'Pérez', 40123456, ...);
Los SPs centralizan lógica y mejoran la seguridad (permisos granulares).
Fase 6: Consultas complejas y optimización
Los requisitos incluían consultas complejas como:
- Cantidad de consultas por mes y centro
- Camas disponibles en tiempo real
- Pacientes sin obra social
- Centros que atienden traumatología
Ejemplo: Camas disponibles
CREATE OR REPLACE FUNCTION consulta_camas_disponibles()
RETURNS TABLE(
nombre_centro VARCHAR,
total_camas BIGINT,
camas_disponibles BIGINT,
camas_ocupadas BIGINT
) AS $$
BEGIN
RETURN QUERY
SELECT
cs.nombre AS nombre_centro,
COUNT(c.id_cama) AS total_camas,
SUM(CASE WHEN NOT EXISTS (
SELECT 1 FROM Internacion i
WHERE i.id_cama = c.id_cama AND i.fecha_alta IS NULL
) THEN 1 ELSE 0 END) AS camas_disponibles,
SUM(CASE WHEN EXISTS (
SELECT 1 FROM Internacion i
WHERE i.id_cama = c.id_cama AND i.fecha_alta IS NULL
) THEN 1 ELSE 0 END) AS camas_ocupadas
FROM CentroSalud cs
LEFT JOIN Cama c ON cs.id_centro_salud = c.id_centro_salud
GROUP BY cs.id_centro_salud, cs.nombre;
END;
$$ LANGUAGE plpgsql;
Para optimizar, agregué índices estratégicos:
CREATE INDEX idx_internacion_fecha_alta ON Internacion(fecha_alta);
CREATE INDEX idx_historia_fecha ON HistoriaClinica(fecha_hora);
CREATE INDEX idx_paciente_obra_social ON Paciente(id_obra_social);
Fase 7: Vistas para simplificar consultas
Creé vistas que abstraen la complejidad de los JOINs:
CREATE OR REPLACE VIEW VistaCentroSaludCompleta AS
SELECT
cs.id_centro_salud,
cs.nombre AS nombre_centro,
cs.direccion,
cs.director,
z.nombre_zona,
b.nombre_barrio,
(SELECT COUNT(*) FROM Cama c
WHERE c.id_centro_salud = cs.id_centro_salud) AS total_camas,
(SELECT COUNT(*) FROM Cama c
WHERE c.id_centro_salud = cs.id_centro_salud
AND c.disponible = TRUE) AS camas_disponibles,
(SELECT COUNT(*) FROM Internacion i
WHERE i.id_centro_salud = cs.id_centro_salud
AND i.fecha_alta IS NULL) AS internaciones_activas
FROM CentroSalud cs
LEFT JOIN Zona z ON cs.id_zona = z.id_zona
LEFT JOIN Barrio b ON cs.id_barrio = b.id_barrio;
-- Uso simple:
-- SELECT * FROM VistaCentroSaludCompleta WHERE nombre_zona = 'Zona Norte';
Fase 8: Seguridad y gestión de errores
Implementé múltiples capas de protección:
- Validación en triggers (evitar datos inválidos)
- Procedimiento seguro para eliminar centros (verifica dependencias)
- Prevención de eliminación de médicos con historias clínicas
- Manejo de errores con RAISE EXCEPTION
-- Eliminar centro solo si no tiene dependencias
CREATE OR REPLACE FUNCTION sp_eliminar_centro_salud_seguro(
p_id_centro INT
) RETURNS TEXT AS $$
DECLARE
v_count_internaciones INT;
v_count_historias INT;
BEGIN
SELECT COUNT(*) INTO v_count_internaciones
FROM Internacion WHERE id_centro_salud = p_id_centro;
SELECT COUNT(*) INTO v_count_historias
FROM HistoriaClinica WHERE id_centro_salud = p_id_centro;
IF v_count_internaciones > 0 OR v_count_historias > 0 THEN
RETURN FORMAT('No se puede eliminar: %s internaciones, %s historias',
v_count_internaciones, v_count_historias);
END IF;
DELETE FROM CentroSalud WHERE id_centro_salud = p_id_centro;
RETURN 'Centro de salud eliminado correctamente';
END;
$$ LANGUAGE plpgsql;
Fase 9: Testing y datos de prueba
Creé un script completo con datos de prueba realistas:
- 5 zonas y barrios
- 5 centros de salud
- Obras sociales (PAMI, OSDE, Swiss Medical…)
- Médicos con múltiples especialidades
- Pacientes con historias clínicas
- Internaciones activas y cerradas
Esto me permitió probar todas las consultas y validar triggers antes de producción.
Lecciones aprendidas
- Normalizar correctamente (evité redundancia sin obsesionarme)
- Usar triggers para reglas de negocio complejas
- Implementar auditoría desde el inicio
- Crear procedimientos almacenados para operaciones críticas
- Agregar índices estratégicos (no todos los campos necesitan índice)
- Usar vistas para simplificar consultas recurrentes
Errores que cometí:
- Al principio, no consideré ON DELETE CASCADE/SET NULL (causó bugs)
- Olvidé índices en campos de búsqueda frecuente (lentitud en consultas)
- Inicialmente hardcodeé algunos valores que debían ser configurables
- No documenté suficientemente los triggers al inicio
Escalabilidad y mantenibilidad
El diseño final es:
- Escalable: agregar centros, médicos o servicios no requiere cambios en el esquema
- Mantenible: la lógica está encapsulada en procedures y triggers
- Seguro: validaciones en múltiples capas
- Auditable: cada cambio crítico queda registrado
- Performante: índices estratégicos + vistas optimizadas
Conclusión
Diseñar esta base de datos me enseñó que el modelado de datos es el 70% del éxito de un sistema.
Un buen diseño permite:
- Integridad de datos garantizada por la BD, no solo por la app
- Consultas complejas de forma eficiente
- Mantenimiento sencillo a largo plazo
- Escalabilidad sin reescribir todo
Si tuviera que dar un consejo a mi yo del pasado: invierte tiempo en el diseño inicial, documenta tus decisiones, y no tengas miedo de usar features avanzadas de PostgreSQL como triggers, procedures y constraints complejos.
La base de datos no es solo un lugar para guardar datos, es el corazón de la aplicación.