Best Practices and Performance

Section 16 of 18
89% complete

Connection Pooling

Configure HTTP connection pooling for optimal performance. Creating new HTTP connections for each request adds significant latency. Connection pooling reuses existing connections, reducing TCP handshake overhead and improving throughput. Set pool sizes based on expected concurrent requests, with defaultMaxPerRoute limiting connections per host and maxTotal limiting total connections.

import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;

public class OptimizedFhirClient {

    private static FhirContext ctx;
    private static PoolingHttpClientConnectionManager connectionManager;

    static {
        ctx = FhirContext.forR4();

        // Configure connection pooling
        connectionManager = new PoolingHttpClientConnectionManager();
        connectionManager.setMaxTotal(200);
        connectionManager.setDefaultMaxPerRoute(20);

        // Set timeouts
        ctx.getRestfulClientFactory().setConnectTimeout(20000);
        ctx.getRestfulClientFactory().setSocketTimeout(20000);
        ctx.getRestfulClientFactory().setConnectionRequestTimeout(20000);

        // Set connection manager
        ctx.getRestfulClientFactory().setHttpClient(
            HttpClientBuilder.create()
                .setConnectionManager(connectionManager)
                .build()
        );
    }

    public static IGenericClient createClient(String serverBase) {
        return ctx.newRestfulGenericClient(serverBase);
    }

    public static void shutdown() {
        if (connectionManager != null) {
            connectionManager.close();
        }
    }
}

Caching Strategies

Implement caching to reduce server load and improve response times. Caching stores frequently accessed resources locally, avoiding network round-trips for repeated reads. Use different cache configurations for different access patterns: longer expiration for stable reference data, shorter for frequently changing clinical data. Always implement cache invalidation when resources are updated.

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import java.util.concurrent.TimeUnit;

public class FhirCacheExample {

    private final IGenericClient client;
    private final Cache<String, IBaseResource> resourceCache;
    private final Cache<String, Bundle> searchCache;

    public FhirCacheExample(IGenericClient client) {
        this.client = client;

        // Resource cache (1000 entries, 10 minutes)
        this.resourceCache = CacheBuilder.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .build();

        // Search results cache (100 entries, 5 minutes)
        this.searchCache = CacheBuilder.newBuilder()
            .maximumSize(100)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .build();
    }

    public Patient getPatient(String patientId) {
        String cacheKey = "Patient/" + patientId;

        // Check cache first
        IBaseResource cached = resourceCache.getIfPresent(cacheKey);
        if (cached != null) {
            return (Patient) cached;
        }

        // Fetch from server
        Patient patient = client.read()
            .resource(Patient.class)
            .withId(patientId)
            .execute();

        // Store in cache
        resourceCache.put(cacheKey, patient);

        return patient;
    }

    public Bundle searchPatients(String familyName) {
        String cacheKey = "Patient?family=" + familyName;

        // Check cache
        Bundle cached = searchCache.getIfPresent(cacheKey);
        if (cached != null) {
            return cached;
        }

        // Execute search
        Bundle results = client.search()
            .forResource(Patient.class)
            .where(Patient.FAMILY.matches().value(familyName))
            .returnBundle(Bundle.class)
            .execute();

        // Cache results
        searchCache.put(cacheKey, results);

        return results;
    }

    public void invalidateCache(String resourceType, String resourceId) {
        String cacheKey = resourceType + "/" + resourceId;
        resourceCache.invalidate(cacheKey);

        // Optionally clear all search caches
        searchCache.invalidateAll();
    }
}

Batch Processing

Process large datasets efficiently in batches. Sending thousands of individual requests overwhelms servers and wastes network resources. Transaction bundles combine multiple operations into single requests, dramatically improving throughput. Choose batch sizes based on server capabilities and resource sizes. Implement error handling to identify and retry failed batches.

public class BatchProcessingExample {

    private final IGenericClient client;
    private final int batchSize = 100;

    public BatchProcessingExample(IGenericClient client) {
        this.client = client;
    }

    public void processLargeDataset(List<IBaseResource> resources) {
        List<List<IBaseResource>> batches = partition(resources, batchSize);

        int processed = 0;
        for (List<IBaseResource> batch : batches) {
            try {
                processBatch(batch);
                processed += batch.size();
                System.out.println("Processed " + processed + "/" + resources.size());

            } catch (Exception e) {
                System.err.println("Batch failed: " + e.getMessage());
                // Could implement retry logic here
            }
        }
    }

    private void processBatch(List<IBaseResource> resources) {
        Bundle bundle = new Bundle();
        bundle.setType(Bundle.BundleType.TRANSACTION);

        for (IBaseResource resource : resources) {
            bundle.addEntry()
                .setResource((Resource) resource)
                .getRequest()
                    .setMethod(Bundle.HTTPVerb.POST)
                    .setUrl(resource.fhirType());
        }

        client.transaction().withBundle(bundle).execute();
    }

    private <T> List<List<T>> partition(List<T> list, int size) {
        List<List<T>> partitions = new ArrayList<>();
        for (int i = 0; i < list.size(); i += size) {
            partitions.add(list.subList(i, Math.min(i + size, list.size())));
        }
        return partitions;
    }
}

Async Processing

Use asynchronous operations for better throughput. When fetching data for multiple patients or gathering related resources, sequential requests wait unnecessarily. CompletableFuture enables parallel execution of independent operations, dramatically reducing total wait time. Use thread pools to control resource consumption and implement proper shutdown to release resources.

import java.util.concurrent.*;

public class AsyncFhirOperations {

    private final IGenericClient client;
    private final ExecutorService executor;

    public AsyncFhirOperations(IGenericClient client) {
        this.client = client;
        this.executor = Executors.newFixedThreadPool(10);
    }

    public CompletableFuture<Patient> getPatientAsync(String patientId) {
        return CompletableFuture.supplyAsync(() -> {
            return client.read()
                .resource(Patient.class)
                .withId(patientId)
                .execute();
        }, executor);
    }

    public CompletableFuture<List<Observation>> getObservationsAsync(String patientId) {
        return CompletableFuture.supplyAsync(() -> {
            Bundle results = client.search()
                .forResource(Observation.class)
                .where(Observation.PATIENT.hasId(patientId))
                .returnBundle(Bundle.class)
                .execute();

            return results.getEntry().stream()
                .map(e -> (Observation) e.getResource())
                .collect(Collectors.toList());
        }, executor);
    }

    public CompletableFuture<PatientSummary> getPatientSummary(String patientId) {
        // Parallel fetch of patient and observations
        CompletableFuture<Patient> patientFuture = getPatientAsync(patientId);
        CompletableFuture<List<Observation>> obsFuture = getObservationsAsync(patientId);

        return patientFuture.thenCombine(obsFuture, (patient, observations) -> {
            return new PatientSummary(patient, observations);
        });
    }

    public void shutdown() {
        executor.shutdown();
        try {
            if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
                executor.shutdownNow();
            }
        } catch (InterruptedException e) {
            executor.shutdownNow();
        }
    }

    static class PatientSummary {
        private Patient patient;
        private List<Observation> observations;

        public PatientSummary(Patient patient, List<Observation> observations) {
            this.patient = patient;
            this.observations = observations;
        }

        public Patient getPatient() { return patient; }
        public List<Observation> getObservations() { return observations; }
    }
}

Error Handling Best Practices

Implement robust error handling with retries. Network failures and server issues are inevitable in distributed systems. Distinguish between retryable errors (429, 5xx) and permanent failures (4xx). Exponential backoff progressively increases delays between retries, reducing load on struggling services. Always handle specific exceptions before generic ones for appropriate recovery strategies.

import ca.uhn.fhir.rest.server.exceptions.*;

public class RobustFhirClient {

    private final IGenericClient client;
    private final int maxRetries = 3;

    public RobustFhirClient(IGenericClient client) {
        this.client = client;
    }

    public <T extends IBaseResource> T readWithRetry(
            Class<T> resourceType,
            String id) {

        int attempt = 0;
        while (attempt < maxRetries) {
            try {
                return client.read()
                    .resource(resourceType)
                    .withId(id)
                    .execute();

            } catch (ResourceNotFoundException e) {
                // Don't retry - resource doesn't exist
                throw e;

            } catch (AuthenticationException e) {
                // Don't retry - auth issue
                throw e;

            } catch (BaseServerResponseException e) {
                // Check if retryable
                int statusCode = e.getStatusCode();
                if (statusCode == 429 || // Too many requests
                    statusCode >= 500) {  // Server error

                    attempt++;
                    if (attempt >= maxRetries) {
                        throw new RuntimeException(
                            "Failed after " + maxRetries + " attempts", e);
                    }

                    // Exponential backoff
                    try {
                        Thread.sleep((long) Math.pow(2, attempt) * 1000);
                    } catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                        throw new RuntimeException("Interrupted during retry", ie);
                    }
                } else {
                    throw e;
                }
            }
        }

        throw new RuntimeException("Unexpected: exceeded max retries");
    }

    public void safeDelete(String resourceType, String id) {
        try {
            client.delete()
                .resourceById(resourceType, id)
                .execute();

        } catch (ResourceNotFoundException e) {
            // Already deleted - that's fine
            System.out.println("Resource already deleted: " + resourceType + "/" + id);

        } catch (Exception e) {
            System.err.println("Delete failed: " + e.getMessage());
            throw e;
        }
    }

    public Bundle searchWithErrorHandling(String resourceType, String searchParams) {
        try {
            return client.search()
                .byUrl(resourceType + "?" + searchParams)
                .returnBundle(Bundle.class)
                .execute();

        } catch (InvalidRequestException e) {
            System.err.println("Invalid search parameters: " + e.getMessage());
            // Return empty bundle
            Bundle emptyBundle = new Bundle();
            emptyBundle.setType(Bundle.BundleType.SEARCHSET);
            emptyBundle.setTotal(0);
            return emptyBundle;

        } catch (Exception e) {
            System.err.println("Search failed: " + e.getMessage());
            throw e;
        }
    }
}

Monitoring and Metrics

Track FHIR operations for observability. Production systems need visibility into request rates, latencies, and error rates. Micrometer provides a vendor-neutral metrics facade compatible with Prometheus, Datadog, and other monitoring systems. Instrument critical operations to detect performance degradation and troubleshoot issues quickly.

import io.micrometer.core.instrument.*;

public class FhirMetricsCollector {

    private final MeterRegistry registry;
    private final Counter requestCounter;
    private final Timer requestTimer;
    private final Counter errorCounter;

    public FhirMetricsCollector(MeterRegistry registry) {
        this.registry = registry;

        this.requestCounter = Counter.builder("fhir.requests")
            .description("Total FHIR requests")
            .tags("client", "hapi")
            .register(registry);

        this.requestTimer = Timer.builder("fhir.request.duration")
            .description("FHIR request duration")
            .register(registry);

        this.errorCounter = Counter.builder("fhir.errors")
            .description("FHIR request errors")
            .register(registry);
    }

    public <T> T executeWithMetrics(String operation, Callable<T> callable) {
        requestCounter.increment();

        Timer.Sample sample = Timer.start(registry);

        try {
            T result = callable.call();
            sample.stop(requestTimer);
            return result;

        } catch (Exception e) {
            errorCounter.increment();
            sample.stop(requestTimer);
            throw new RuntimeException(e);
        }
    }

    public Patient readPatient(IGenericClient client, String patientId) {
        return executeWithMetrics("read-patient", () ->
            client.read()
                .resource(Patient.class)
                .withId(patientId)
                .execute()
        );
    }
}

Performance Best Practices Summary

CategoryRecommendation
FhirContextCreate once, reuse everywhere
Connection PoolingUse PoolingHttpClientConnectionManager
CachingCache frequently accessed resources
Batch OperationsUse transaction bundles for bulk operations
Async ProcessingUse CompletableFuture for parallel operations
Error HandlingImplement retry with exponential backoff
TimeoutsAlways configure appropriate timeouts
MonitoringTrack metrics for all operations

Common Anti-Patterns to Avoid

Anti-PatternBetter Approach
Creating FhirContext per requestCreate once, share across application
Individual creates in a loopUse transaction bundle
No timeout configurationSet connect/socket/request timeouts
Catching generic ExceptionHandle specific FHIR exceptions
No retry logicImplement exponential backoff
Fetching all data at onceUse pagination and streaming

Quiz: Best Practices and Performance

Question 1 of 5

Why should FhirContext be created once and reused?