Back to Blog
LIMS Development: Building Laboratory Information Management Systems

LIMS Development: Building Laboratory Information Management Systems

December 15, 2024
11 min read
Tushar Agrawal

Complete guide to developing Laboratory Information Management Systems (LIMS). Learn architecture patterns, sample tracking, instrument integration, regulatory compliance, and best practices for clinical and research laboratories.

Introduction

Laboratory Information Management Systems (LIMS) are the digital backbone of modern laboratories. From clinical diagnostics to pharmaceutical research, LIMS manages samples, automates workflows, ensures compliance, and maintains data integrity. Having built LIMS solutions at Dr. Dangs Lab, I'll share comprehensive insights into LIMS development.

What is LIMS?

LIMS is specialized software that manages laboratory operations:

┌─────────────────────────────────────────────────────────────────────┐
│                    LIMS CORE FUNCTIONS                               │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐              │
│  │   Sample     │  │   Workflow   │  │  Instrument  │              │
│  │   Management │  │   Automation │  │  Integration │              │
│  └──────────────┘  └──────────────┘  └──────────────┘              │
│                                                                      │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐              │
│  │   Data       │  │   Quality    │  │   Reporting  │              │
│  │   Management │  │   Control    │  │   & Analytics│              │
│  └──────────────┘  └──────────────┘  └──────────────┘              │
│                                                                      │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐              │
│  │   Inventory  │  │   Regulatory │  │   User       │              │
│  │   Management │  │   Compliance │  │   Management │              │
│  └──────────────┘  └──────────────┘  └──────────────┘              │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

LIMS Architecture

Modern LIMS Architecture

┌─────────────────────────────────────────────────────────────────────┐
│                        PRESENTATION LAYER                            │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐                 │
│  │ Web Portal  │  │ Mobile App  │  │ Desktop App │                 │
│  │ (React/Next)│  │ (React Nat.)│  │ (Electron)  │                 │
│  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘                 │
└─────────┼────────────────┼────────────────┼─────────────────────────┘
          │                │                │
          └────────────────┼────────────────┘
                           │
┌──────────────────────────┼──────────────────────────────────────────┐
│                    API GATEWAY LAYER                                 │
│                 (Authentication, Rate Limiting, Routing)            │
└──────────────────────────┼──────────────────────────────────────────┘
                           │
┌──────────────────────────┼──────────────────────────────────────────┐
│                    MICROSERVICES LAYER                               │
│  ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐       │
│  │ Sample  │ │Workflow │ │ Result  │ │ Report  │ │Inventory│       │
│  │ Service │ │ Service │ │ Service │ │ Service │ │ Service │       │
│  └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘       │
└───────┼───────────┼───────────┼───────────┼───────────┼─────────────┘
        │           │           │           │           │
┌───────┼───────────┼───────────┼───────────┼───────────┼─────────────┐
│                    DATA LAYER                                        │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐                 │
│  │ PostgreSQL  │  │    Redis    │  │Elasticsearch│                 │
│  │  (Primary)  │  │   (Cache)   │  │  (Search)   │                 │
│  └─────────────┘  └─────────────┘  └─────────────┘                 │
└─────────────────────────────────────────────────────────────────────┘
                           │
┌──────────────────────────┼──────────────────────────────────────────┐
│                INTEGRATION LAYER                                     │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐                 │
│  │ HL7 Engine  │  │  ASTM/LIS   │  │ FHIR API    │                 │
│  │             │  │  Interface  │  │             │                 │
│  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘                 │
└─────────┼────────────────┼────────────────┼─────────────────────────┘
          │                │                │
          ▼                ▼                ▼
    ┌──────────┐    ┌──────────┐    ┌──────────┐
    │Lab       │    │Analyzers │    │External  │
    │Equipment │    │          │    │Systems   │
    └──────────┘    └──────────┘    └──────────┘

Database Schema Design

from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum, Float, Boolean, Text
from sqlalchemy.orm import relationship, declarative_base
from sqlalchemy.dialects.postgresql import UUID, JSONB
import uuid

Base = declarative_base()

class Sample(Base):
    __tablename__ = 'samples'

    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    accession_number = Column(String(50), unique=True, nullable=False, index=True)
    patient_id = Column(UUID(as_uuid=True), ForeignKey('patients.id'), nullable=False)
    sample_type = Column(String(50), nullable=False)
    collection_date = Column(DateTime, nullable=False)
    received_date = Column(DateTime)
    status = Column(String(50), default='registered')
    priority = Column(String(20), default='routine')
    storage_location = Column(String(100))
    temperature = Column(Float)
    volume = Column(Float)
    container_type = Column(String(50))
    collected_by = Column(UUID(as_uuid=True), ForeignKey('users.id'))
    collection_site = Column(String(100))
    notes = Column(Text)
    metadata = Column(JSONB, default={})
    created_at = Column(DateTime, default=datetime.utcnow)
    updated_at = Column(DateTime, onupdate=datetime.utcnow)

    # Relationships
    patient = relationship('Patient', back_populates='samples')
    tests = relationship('SampleTest', back_populates='sample')
    status_history = relationship('SampleStatusHistory', back_populates='sample')

class SampleTest(Base):
    __tablename__ = 'sample_tests'

    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    sample_id = Column(UUID(as_uuid=True), ForeignKey('samples.id'), nullable=False)
    test_id = Column(UUID(as_uuid=True), ForeignKey('tests.id'), nullable=False)
    status = Column(String(50), default='pending')
    assigned_to = Column(UUID(as_uuid=True), ForeignKey('users.id'))
    started_at = Column(DateTime)
    completed_at = Column(DateTime)
    analyzer_id = Column(UUID(as_uuid=True), ForeignKey('analyzers.id'))

    sample = relationship('Sample', back_populates='tests')
    results = relationship('TestResult', back_populates='sample_test')

class TestResult(Base):
    __tablename__ = 'test_results'

    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    sample_test_id = Column(UUID(as_uuid=True), ForeignKey('sample_tests.id'), nullable=False)
    parameter_id = Column(UUID(as_uuid=True), ForeignKey('test_parameters.id'), nullable=False)
    value = Column(String(100))
    numeric_value = Column(Float)
    unit = Column(String(50))
    flag = Column(String(10))  # H, L, HH, LL, C
    reference_min = Column(Float)
    reference_max = Column(Float)
    method = Column(String(100))
    performed_by = Column(UUID(as_uuid=True), ForeignKey('users.id'))
    performed_at = Column(DateTime)
    validated_by = Column(UUID(as_uuid=True), ForeignKey('users.id'))
    validated_at = Column(DateTime)
    comments = Column(Text)

class AuditLog(Base):
    __tablename__ = 'audit_logs'

    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    table_name = Column(String(100), nullable=False)
    record_id = Column(UUID(as_uuid=True), nullable=False)
    action = Column(String(20), nullable=False)  # CREATE, UPDATE, DELETE
    old_values = Column(JSONB)
    new_values = Column(JSONB)
    user_id = Column(UUID(as_uuid=True), ForeignKey('users.id'))
    ip_address = Column(String(45))
    user_agent = Column(String(500))
    timestamp = Column(DateTime, default=datetime.utcnow)

Core LIMS Features

1. Sample Lifecycle Management

from enum import Enum
from datetime import datetime
from typing import Optional

class SampleLifecycle:
    """Manages complete sample lifecycle"""

    VALID_TRANSITIONS = {
        'registered': ['collected', 'cancelled'],
        'collected': ['in_transit', 'received'],
        'in_transit': ['received', 'lost'],
        'received': ['accessioned', 'rejected'],
        'accessioned': ['in_process', 'on_hold'],
        'in_process': ['completed', 'on_hold', 'failed'],
        'on_hold': ['in_process', 'cancelled'],
        'completed': ['reported', 'review_required'],
        'review_required': ['completed', 'reported'],
        'reported': ['archived'],
        'rejected': ['recollection_required'],
        'failed': ['retest_required', 'cancelled']
    }

    def __init__(self, db, audit_service, notification_service):
        self.db = db
        self.audit = audit_service
        self.notifications = notification_service

    async def transition(
        self,
        sample_id: str,
        new_status: str,
        user_id: str,
        reason: Optional[str] = None
    ) -> dict:
        """Transition sample to new status"""
        sample = await self.db.get_sample(sample_id)
        current_status = sample['status']

        # Validate transition
        if new_status not in self.VALID_TRANSITIONS.get(current_status, []):
            raise ValueError(
                f"Invalid transition: {current_status} -> {new_status}"
            )

        # Perform transition
        await self.db.update_sample(sample_id, {'status': new_status})

        # Create audit record
        await self.audit.log(
            table='samples',
            record_id=sample_id,
            action='STATUS_CHANGE',
            old_values={'status': current_status},
            new_values={'status': new_status},
            user_id=user_id,
            metadata={'reason': reason}
        )

        # Trigger status-specific actions
        await self._handle_status_actions(sample, new_status, user_id)

        return {'sample_id': sample_id, 'status': new_status}

    async def _handle_status_actions(self, sample: dict, status: str, user_id: str):
        """Handle actions triggered by status changes"""
        actions = {
            'received': self._on_received,
            'completed': self._on_completed,
            'reported': self._on_reported,
            'rejected': self._on_rejected
        }

        handler = actions.get(status)
        if handler:
            await handler(sample, user_id)

    async def _on_completed(self, sample: dict, user_id: str):
        """Actions when sample testing is complete"""
        # Check if all tests are complete
        tests = await self.db.get_sample_tests(sample['id'])
        all_complete = all(t['status'] == 'completed' for t in tests)

        if all_complete:
            # Auto-trigger report generation
            await self.notifications.queue_report_generation(sample['id'])

2. Instrument Integration

import asyncio
import serial
from abc import ABC, abstractmethod

class InstrumentInterface(ABC):
    """Base class for instrument interfaces"""

    @abstractmethod
    async def connect(self):
        pass

    @abstractmethod
    async def send_worklist(self, samples: list):
        pass

    @abstractmethod
    async def receive_results(self) -> list:
        pass

    @abstractmethod
    async def disconnect(self):
        pass

class ASTMInstrumentInterface(InstrumentInterface):
    """ASTM E1394 protocol interface for lab analyzers"""

    def __init__(self, port: str, baudrate: int = 9600):
        self.port = port
        self.baudrate = baudrate
        self.serial = None

    # ASTM control characters
    STX = b'\x02'  # Start of text
    ETX = b'\x03'  # End of text
    EOT = b'\x04'  # End of transmission
    ENQ = b'\x05'  # Enquiry
    ACK = b'\x06'  # Acknowledge
    NAK = b'\x15'  # Negative acknowledge
    CR = b'\x0D'   # Carriage return
    LF = b'\x0A'   # Line feed

    async def connect(self):
        self.serial = serial.Serial(
            port=self.port,
            baudrate=self.baudrate,
            bytesize=serial.EIGHTBITS,
            parity=serial.PARITY_NONE,
            stopbits=serial.STOPBITS_ONE,
            timeout=30
        )

    async def receive_results(self) -> list:
        """Receive and parse ASTM results"""
        results = []
        message = b''

        while True:
            byte = self.serial.read(1)

            if byte == self.ENQ:
                # Instrument wants to send
                self.serial.write(self.ACK)

            elif byte == self.STX:
                # Start of frame
                message = b''

            elif byte == self.ETX:
                # End of frame
                self.serial.write(self.ACK)
                parsed = self._parse_astm_message(message.decode())
                if parsed:
                    results.append(parsed)
                message = b''

            elif byte == self.EOT:
                # End of transmission
                break

            else:
                message += byte

        return results

    def _parse_astm_message(self, message: str) -> dict:
        """Parse ASTM message into structured data"""
        records = message.split('\r')
        result = {}

        for record in records:
            if not record:
                continue

            record_type = record[0]
            fields = record.split('|')

            if record_type == 'H':
                # Header record
                result['sender'] = fields[4] if len(fields) > 4 else ''

            elif record_type == 'P':
                # Patient record
                result['patient_id'] = fields[2] if len(fields) > 2 else ''

            elif record_type == 'O':
                # Order record
                result['sample_id'] = fields[2] if len(fields) > 2 else ''
                result['test_id'] = fields[4] if len(fields) > 4 else ''

            elif record_type == 'R':
                # Result record
                result['value'] = fields[3] if len(fields) > 3 else ''
                result['unit'] = fields[4] if len(fields) > 4 else ''
                result['flag'] = fields[6] if len(fields) > 6 else ''

        return result

class HL7Interface(InstrumentInterface):
    """HL7 v2.x interface for healthcare systems"""

    def __init__(self, host: str, port: int):
        self.host = host
        self.port = port
        self.reader = None
        self.writer = None

    MLLP_START = b'\x0B'  # Vertical tab
    MLLP_END = b'\x1C\x0D'  # File separator + CR

    async def connect(self):
        self.reader, self.writer = await asyncio.open_connection(
            self.host, self.port
        )

    async def send_message(self, message: str):
        """Send HL7 message wrapped in MLLP"""
        mllp_message = self.MLLP_START + message.encode() + self.MLLP_END
        self.writer.write(mllp_message)
        await self.writer.drain()

        # Wait for ACK
        response = await self.reader.read(1024)
        return self._parse_ack(response)

    def create_oru_message(self, result: dict) -> str:
        """Create HL7 ORU (Observation Result) message"""
        timestamp = datetime.now().strftime('%Y%m%d%H%M%S')

        segments = [
            f"MSH|^~\\&|LIMS|LAB|HIS|HOSPITAL|{timestamp}||ORU^R01|{uuid.uuid4().hex[:8]}|P|2.5",
            f"PID|1||{result['patient_id']}||{result['patient_name']}",
            f"OBR|1|{result['order_id']}||{result['test_code']}^{result['test_name']}",
            f"OBX|1|NM|{result['param_code']}^{result['param_name']}||{result['value']}|{result['unit']}|{result['reference_range']}|{result['flag']}|||F"
        ]

        return '\r'.join(segments)

3. Quality Control Module

import numpy as np
from dataclasses import dataclass
from typing import List, Optional

@dataclass
class QCResult:
    level: str  # Level 1, Level 2, Level 3
    value: float
    target_mean: float
    target_sd: float
    analyzer_id: str
    test_code: str
    lot_number: str
    expiry_date: datetime
    performed_by: str
    performed_at: datetime

class WestgardRules:
    """Implementation of Westgard QC rules"""

    def evaluate(self, results: List[QCResult]) -> dict:
        """Evaluate QC results against Westgard rules"""
        if not results:
            return {'status': 'insufficient_data', 'violations': []}

        violations = []
        values = [r.value for r in results]
        mean = results[0].target_mean
        sd = results[0].target_sd

        # Calculate z-scores
        z_scores = [(v - mean) / sd for v in values]

        # 1:3s - Single result > 3SD
        if abs(z_scores[-1]) > 3:
            violations.append({
                'rule': '1:3s',
                'description': 'Single result exceeds 3 SD',
                'severity': 'reject'
            })

        # 1:2s - Single result > 2SD (warning)
        if abs(z_scores[-1]) > 2:
            violations.append({
                'rule': '1:2s',
                'description': 'Single result exceeds 2 SD',
                'severity': 'warning'
            })

        # 2:2s - Two consecutive results > 2SD same side
        if len(z_scores) >= 2:
            if (z_scores[-1] > 2 and z_scores[-2] > 2) or \
               (z_scores[-1] < -2 and z_scores[-2] < -2):
                violations.append({
                    'rule': '2:2s',
                    'description': 'Two consecutive results exceed 2 SD on same side',
                    'severity': 'reject'
                })

        # R:4s - Range of results > 4SD
        if len(z_scores) >= 2:
            if abs(z_scores[-1] - z_scores[-2]) > 4:
                violations.append({
                    'rule': 'R:4s',
                    'description': 'Range between two results exceeds 4 SD',
                    'severity': 'reject'
                })

        # 4:1s - Four consecutive results > 1SD same side
        if len(z_scores) >= 4:
            last_four = z_scores[-4:]
            if all(z > 1 for z in last_four) or all(z < -1 for z in last_four):
                violations.append({
                    'rule': '4:1s',
                    'description': 'Four consecutive results exceed 1 SD on same side',
                    'severity': 'reject'
                })

        # 10x - Ten consecutive results same side of mean
        if len(z_scores) >= 10:
            last_ten = z_scores[-10:]
            if all(z > 0 for z in last_ten) or all(z < 0 for z in last_ten):
                violations.append({
                    'rule': '10x',
                    'description': 'Ten consecutive results on same side of mean',
                    'severity': 'reject'
                })

        # Determine overall status
        has_reject = any(v['severity'] == 'reject' for v in violations)
        status = 'reject' if has_reject else ('warning' if violations else 'pass')

        return {
            'status': status,
            'violations': violations,
            'z_score': z_scores[-1],
            'evaluated_at': datetime.utcnow()
        }

class QCService:
    def __init__(self, db, notification_service):
        self.db = db
        self.notifications = notification_service
        self.westgard = WestgardRules()

    async def record_qc(self, qc_result: QCResult) -> dict:
        """Record QC result and evaluate"""
        # Save QC result
        await self.db.qc_results.insert_one(qc_result.__dict__)

        # Get recent QC results for evaluation
        recent_results = await self.db.qc_results.find({
            'analyzer_id': qc_result.analyzer_id,
            'test_code': qc_result.test_code,
            'level': qc_result.level
        }).sort('performed_at', -1).limit(10).to_list(10)

        # Evaluate against Westgard rules
        evaluation = self.westgard.evaluate(recent_results)

        # Record evaluation
        await self.db.qc_evaluations.insert_one({
            'qc_result_id': qc_result.id,
            **evaluation
        })

        # Handle failures
        if evaluation['status'] == 'reject':
            await self._handle_qc_failure(qc_result, evaluation)

        return evaluation

    async def _handle_qc_failure(self, qc_result: QCResult, evaluation: dict):
        """Handle QC failure - lock analyzer, notify supervisor"""
        # Lock analyzer
        await self.db.analyzers.update_one(
            {'id': qc_result.analyzer_id},
            {'$set': {'status': 'qc_locked', 'locked_at': datetime.utcnow()}}
        )

        # Notify supervisor
        await self.notifications.send_qc_alert(
            analyzer_id=qc_result.analyzer_id,
            test_code=qc_result.test_code,
            violations=evaluation['violations']
        )

Compliance Requirements

21 CFR Part 11 Compliance

class ComplianceService:
    """Ensure regulatory compliance"""

    async def ensure_electronic_signature(
        self,
        user_id: str,
        document_type: str,
        document_id: str,
        meaning: str  # "Reviewed", "Approved", "Released"
    ) -> dict:
        """21 CFR Part 11 compliant electronic signature"""

        # Require re-authentication
        credentials = await self._request_credentials(user_id)

        # Verify credentials
        if not await self._verify_credentials(user_id, credentials):
            raise SecurityError("Authentication failed")

        # Create signature record
        signature = {
            'id': str(uuid.uuid4()),
            'user_id': user_id,
            'document_type': document_type,
            'document_id': document_id,
            'meaning': meaning,
            'timestamp': datetime.utcnow(),
            'ip_address': self._get_client_ip(),
            'signature_hash': self._create_signature_hash(
                user_id, document_id, meaning
            )
        }

        await self.db.electronic_signatures.insert_one(signature)

        # Create audit trail entry
        await self.audit.log(
            action='ELECTRONIC_SIGNATURE',
            record_id=document_id,
            user_id=user_id,
            metadata={'meaning': meaning}
        )

        return signature

    async def ensure_audit_trail(self):
        """Ensure complete audit trail for all changes"""
        # Implemented via database triggers and application middleware
        pass

    async def verify_data_integrity(self, record_id: str) -> bool:
        """Verify data hasn't been tampered with"""
        record = await self.db.get_record(record_id)
        stored_hash = record.get('integrity_hash')
        calculated_hash = self._calculate_hash(record)
        return stored_hash == calculated_hash

Key Takeaways

1. Compliance First: Build compliance into architecture from day one 2. Instrument Integration: Standard protocols (ASTM, HL7) are essential 3. Audit Everything: Complete audit trails are non-negotiable 4. Quality Control: Implement Westgard rules for analytical quality 5. Data Integrity: Hash verification for tamper detection 6. Scalability: Design for growing sample volumes

Conclusion

LIMS development requires deep understanding of laboratory workflows, regulatory requirements, and healthcare data standards. A well-designed LIMS transforms laboratory operations, improving efficiency, ensuring compliance, and ultimately contributing to better patient care.

---

Building laboratory systems? Connect on LinkedIn to discuss LIMS architecture.

Share this article

Related Articles