System Design: Ophthalmic Practice Management Platform in .NET — FHIR, PHI Compliance, and Appointment Scheduling
Design an ophthalmology/optometry practice management system (Opterek-style) in .NET: FHIR-compatible patient records, appointment scheduling with resource constraints, PHI data isolation, prescription management, insurance billing, and audit trails for compliance.
System Design: Ophthalmic Practice Management Platform in .NET
Healthcare software operates under constraints that most enterprise applications do not: regulatory requirements for data immutability, strict tenant isolation where a breach is a HIPAA violation, not just a bug, and scheduling logic that must model real-world resource constraints like rooms, equipment, and clinician availability simultaneously. This case study designs an Opterek-style ophthalmic practice management platform in .NET 9, walking through the decisions that matter — and the ones that bite you if you get them wrong.
System Overview
A single patient encounter flows through six stages:
Patient registered / FHIR record created
↓
Appointment booked (room + equipment + clinician = 3-way constraint)
↓
Clinical encounter: refraction, slit lamp, IOP measurement recorded
↓
Prescription generated (eyeglasses or contact lenses), digitally signed
↓
Billing codes attached, insurance claim submitted
↓
ERA received from payer → payment posted or denial routed for managementThe system is multi-tenant: each optometry or ophthalmology practice is an isolated tenant. Patient data from Practice A must be physically and logically inaccessible to Practice B — even to platform administrators.
FHIR R4 Integration
Why FHIR Even for an Internal System
FHIR (Fast Healthcare Interoperability Resources) is tempting to dismiss when building an internal practice management platform. You control the data model; why adopt the complexity of a standard?
Three reasons compel it in practice:
- Lab and device integration — optical coherence tomography (OCT) devices, visual field analysers, and retinal cameras produce results that modern devices can export as FHIR Observations. Accepting FHIR from devices avoids custom adapters per manufacturer.
- Hospital referral workflows — when an optometrist refers a patient to an ophthalmologist at a hospital system, FHIR is the interchange format. Storing internally in FHIR format means the export is a projection, not a translation.
- Patient access regulations — in many jurisdictions, patients have a legal right to export their records in a standard format. A FHIR API satisfies this at the platform layer.
The strategy adopted here: the internal data model is optimised for the application (normalized, relational, encrypted). A FHIR projection layer maps to/from FHIR R4 resources on demand. This is cheaper than storing FHIR JSON blobs internally, which makes queries, audit, and encryption significantly harder.
FhirPatientMapper
The HL7 FHIR .NET SDK (Hl7.Fhir.R4) provides strongly-typed resource classes.
using Hl7.Fhir.Model;
using Hl7.Fhir.Serialization;
public sealed class FhirPatientMapper
{
private readonly IEncryptionService _encryption;
public FhirPatientMapper(IEncryptionService encryption)
=> _encryption = encryption;
/// <summary>
/// Maps from the internal Patient entity (decrypted) to a FHIR R4 Patient resource.
/// Called only when serving a FHIR-capable endpoint — never on internal queries.
/// </summary>
public Patient ToFhir(PatientRecord patient)
{
return new Patient
{
Id = patient.Id.ToString(),
Meta = new Meta
{
LastUpdated = patient.LastModifiedAt,
Profile = ["http://hl7.org/fhir/StructureDefinition/Patient"]
},
Identifier =
[
new Identifier
{
System = "urn:oid:practice:" + patient.PracticeId,
Value = patient.ExternalPatientId
}
],
Name =
[
new HumanName
{
Use = HumanName.NameUse.Official,
Family = patient.LastName,
Given = [patient.FirstName]
}
],
BirthDate = patient.DateOfBirth.ToString("yyyy-MM-dd"),
Gender = patient.Sex switch
{
Sex.Male => AdministrativeGender.Male,
Sex.Female => AdministrativeGender.Female,
Sex.Unknown => AdministrativeGender.Unknown,
_ => AdministrativeGender.Other
},
Telecom =
[
new ContactPoint
{
System = ContactPoint.ContactPointSystem.Phone,
Value = patient.PhoneNumber,
Use = ContactPoint.ContactPointUse.Mobile
},
new ContactPoint
{
System = ContactPoint.ContactPointSystem.Email,
Value = patient.EmailAddress
}
],
Address =
[
new Address
{
Line = [patient.AddressLine1, patient.AddressLine2],
City = patient.City,
PostalCode = patient.PostalCode,
Country = patient.CountryCode
}
],
// Extension for ophthalmic-specific data not in base FHIR
Extension =
[
new Extension
{
Url = "https://opterek.io/fhir/extension/dominant-eye",
Value = new Code(patient.DominantEye.ToString())
}
]
};
}
/// <summary>
/// Maps from an incoming FHIR Patient (e.g. from a hospital referral)
/// to an internal PatientRecord, ready for persistence.
/// Sensitive fields will be encrypted before save — this method does NOT encrypt.
/// </summary>
public PatientRecord FromFhir(Patient fhir, Guid practiceId)
{
var name = fhir.Name.FirstOrDefault(n => n.Use == HumanName.NameUse.Official)
?? fhir.Name.First();
return new PatientRecord
{
Id = Guid.TryParse(fhir.Id, out var id) ? id : Guid.NewGuid(),
PracticeId = practiceId,
ExternalPatientId = fhir.Identifier
.FirstOrDefault(i => i.System.Contains("practice"))?
.Value ?? Guid.NewGuid().ToString(),
FirstName = name.Given.FirstOrDefault() ?? "",
LastName = name.Family ?? "",
DateOfBirth = DateOnly.Parse(fhir.BirthDate),
Sex = fhir.Gender switch
{
AdministrativeGender.Male => Sex.Male,
AdministrativeGender.Female => Sex.Female,
_ => Sex.Unknown
},
PhoneNumber = fhir.Telecom
.FirstOrDefault(t => t.System == ContactPoint.ContactPointSystem.Phone)
?.Value ?? "",
EmailAddress = fhir.Telecom
.FirstOrDefault(t => t.System == ContactPoint.ContactPointSystem.Email)
?.Value ?? ""
};
}
}PHI Data Isolation
The Threat Model
The platform is a SaaS product. All practices share a PostgreSQL cluster. The risk: a bug in tenant resolution code — a missing WHERE practice_id = ... clause, or a query filter accidentally disabled — could expose Patient B's records to Practice A. This is a HIPAA breach regardless of intent.
Mitigation requires defense in depth:
- EF Core global query filters — every query automatically appends the tenant filter, enforced at the ORM layer.
- Column-level encryption — even if a query bypasses the filter, the PHI columns are encrypted with a per-practice key. An attacker who retrieves the wrong row sees ciphertext.
- API-level authorization — every endpoint verifies the
practiceIdclaim in the JWT against the resource being accessed.
Per-Practice Encryption with Azure Key Vault
Each practice gets a distinct encryption key in Azure Key Vault. The key identifier is stored in the platform database (not in the practice database), so retrieving the key requires access to both the platform metadata store and Key Vault — two separate authorization boundaries.
public interface IEncryptionService
{
Task<string> EncryptAsync(Guid practiceId, string plaintext, CancellationToken ct);
Task<string> DecryptAsync(Guid practiceId, string ciphertext, CancellationToken ct);
}
public sealed class AzureKeyVaultEncryptionService : IEncryptionService
{
private readonly KeyVaultKeyResolver _keyResolver;
private readonly IPlatformMetadataStore _platformStore;
// In-process cache: practiceId → (keyId, CryptographyClient)
private readonly ConcurrentDictionary<Guid, CryptographyClient> _clientCache = new();
public AzureKeyVaultEncryptionService(
KeyVaultKeyResolver keyResolver,
IPlatformMetadataStore platformStore)
{
_keyResolver = keyResolver;
_platformStore = platformStore;
}
public async Task<string> EncryptAsync(Guid practiceId, string plaintext, CancellationToken ct)
{
var client = await GetCryptographyClientAsync(practiceId, ct);
var plainBytes = Encoding.UTF8.GetBytes(plaintext);
var result = await client.EncryptAsync(EncryptionAlgorithm.RsaOaep, plainBytes, ct);
return Convert.ToBase64String(result.Ciphertext);
}
public async Task<string> DecryptAsync(Guid practiceId, string ciphertext, CancellationToken ct)
{
var client = await GetCryptographyClientAsync(practiceId, ct);
var cipherBytes = Convert.FromBase64String(ciphertext);
var result = await client.DecryptAsync(EncryptionAlgorithm.RsaOaep, cipherBytes, ct);
return Encoding.UTF8.GetString(result.Plaintext);
}
private async Task<CryptographyClient> GetCryptographyClientAsync(
Guid practiceId, CancellationToken ct)
{
return _clientCache.GetOrAdd(practiceId, _ => null!) // placeholder
?? await LoadAndCacheClientAsync(practiceId, ct);
}
private async Task<CryptographyClient> LoadAndCacheClientAsync(
Guid practiceId, CancellationToken ct)
{
var keyId = await _platformStore.GetKeyVaultKeyIdAsync(practiceId, ct);
var key = await _keyResolver.ResolveAsync(keyId, ct);
var client = key.ToCryptographyClient();
_clientCache[practiceId] = client;
return client;
}
}EF Core Value Converter for Encrypted Columns
Rather than manually calling encrypt/decrypt in every repository method, an EF Core value converter handles it transparently at the persistence boundary.
public sealed class EncryptedStringConverter : ValueConverter<string, string>
{
// EF Core value converters are synchronous by design.
// We use a synchronous wrapper over the async encryption service.
// This is acceptable because Key Vault calls are cached after first use
// and the actual AES/RSA operation is CPU-bound.
public EncryptedStringConverter(
Guid practiceId,
IEncryptionService encryption)
: base(
plaintext => encryption.EncryptAsync(practiceId, plaintext,
CancellationToken.None).GetAwaiter().GetResult(),
ciphertext => encryption.DecryptAsync(practiceId, ciphertext,
CancellationToken.None).GetAwaiter().GetResult())
{ }
}
// Applied in model configuration
public class PatientConfiguration : IEntityTypeConfiguration<PatientRecord>
{
private readonly Guid _practiceId;
private readonly IEncryptionService _encryption;
public PatientConfiguration(Guid practiceId, IEncryptionService encryption)
{
_practiceId = practiceId;
_encryption = encryption;
}
public void Configure(EntityTypeBuilder<PatientRecord> builder)
{
builder.HasKey(p => p.Id);
builder.HasIndex(p => p.PracticeId);
var encryptedConverter = new EncryptedStringConverter(_practiceId, _encryption);
// PHI columns encrypted at rest
builder.Property(p => p.FirstName)
.HasConversion(encryptedConverter)
.HasMaxLength(1024);
builder.Property(p => p.LastName)
.HasConversion(encryptedConverter)
.HasMaxLength(1024);
builder.Property(p => p.PhoneNumber)
.HasConversion(encryptedConverter)
.HasMaxLength(1024);
builder.Property(p => p.EmailAddress)
.HasConversion(encryptedConverter)
.HasMaxLength(1024);
builder.Property(p => p.DateOfBirth)
.IsRequired();
// Non-PHI columns stored in plaintext for indexing
builder.Property(p => p.ExternalPatientId).HasMaxLength(64);
}
}EF Core Global Query Filter for Tenant Isolation
public sealed class PracticeDbContext : DbContext
{
private readonly Guid _practiceId;
public PracticeDbContext(DbContextOptions<PracticeDbContext> options,
IPracticeContext practiceContext) : base(options)
{
// The practice context is resolved from the current HTTP request's JWT.
// Every DbContext instance is scoped to a single HTTP request,
// so this value is immutable for the lifetime of the context.
_practiceId = practiceContext.PracticeId;
}
public DbSet<PatientRecord> Patients => Set<PatientRecord>();
public DbSet<Appointment> Appointments => Set<Appointment>();
public DbSet<ClinicalEncounter> Encounters => Set<ClinicalEncounter>();
public DbSet<Prescription> Prescriptions => Set<Prescription>();
protected override void OnModelCreating(ModelBuilder model)
{
// Global query filter — automatically appended to EVERY query on these entities.
// Cannot be accidentally removed by a developer writing a repository method.
// Can be disabled only via IgnoreQueryFilters(), which requires explicit intent.
model.Entity<PatientRecord>()
.HasQueryFilter(p => p.PracticeId == _practiceId);
model.Entity<Appointment>()
.HasQueryFilter(a => a.PracticeId == _practiceId);
model.Entity<ClinicalEncounter>()
.HasQueryFilter(e => e.PracticeId == _practiceId);
model.Entity<Prescription>()
.HasQueryFilter(rx => rx.PracticeId == _practiceId);
}
}Appointment Scheduling with Resource Constraints
The Three-Resource Constraint
An ophthalmic appointment requires three resources to be simultaneously available in the same time slot:
- A clinician — optometrist or ophthalmologist with the right specialty for the appointment type
- An examination room — equipped appropriately (e.g., a room with a slit lamp for anterior segment exams)
- Equipment — specific devices like OCT, visual field analyser, or fundus camera, depending on the examination type
A slot is only valid if all three resources are free. This is a constraint satisfaction problem.
Naive approach: find the clinician's first free slot, then check if a suitable room is free, then check if the equipment is free. This works for light loads but leads to poor utilisation under high booking volume because you're not searching the joint availability space.
Better approach: query the intersection of all three availability grids simultaneously.
public sealed class AppointmentScheduler
{
private readonly PracticeDbContext _db;
private readonly TimeProvider _timeProvider;
public AppointmentScheduler(PracticeDbContext db, TimeProvider timeProvider)
{
_db = db;
_timeProvider = timeProvider;
}
/// <summary>
/// Finds available slots given an appointment type, preferred clinician (optional),
/// and a search window. Returns slots ordered by earliest first.
/// </summary>
public async Task<IReadOnlyList<AvailableSlot>> FindAvailableSlotsAsync(
AppointmentType appointmentType,
Guid? preferredClinicianId,
DateOnly searchFrom,
DateOnly searchTo,
CancellationToken ct)
{
var duration = appointmentType.DefaultDurationMinutes;
// Load all clinicians qualified for this appointment type
var qualifiedClinicians = await _db.Clinicians
.Where(c => c.IsActive
&& (preferredClinicianId == null || c.Id == preferredClinicianId)
&& c.Qualifications.Any(q => q.AppointmentTypeId == appointmentType.Id))
.ToListAsync(ct);
// Load rooms that support this appointment type
var suitableRooms = await _db.Rooms
.Where(r => r.IsActive
&& r.SupportedAppointmentTypes.Contains(appointmentType.Id))
.ToListAsync(ct);
// Load required equipment types for this appointment
var requiredEquipmentTypeIds = appointmentType.RequiredEquipmentTypeIds;
var suitableEquipment = await _db.Equipment
.Where(e => e.IsActive
&& requiredEquipmentTypeIds.Contains(e.EquipmentTypeId))
.ToListAsync(ct);
// Load all existing appointments in the search window (for conflict detection)
var searchStart = searchFrom.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
var searchEnd = searchTo.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
var bookedAppointments = await _db.Appointments
.Where(a => a.Status != AppointmentStatus.Cancelled
&& a.StartTime < searchEnd
&& a.EndTime > searchStart)
.Select(a => new
{
a.ClinicianId, a.RoomId, a.EquipmentId,
a.StartTime, a.EndTime
})
.ToListAsync(ct);
var availableSlots = new List<AvailableSlot>();
var now = _timeProvider.GetUtcNow();
// Generate candidate time slots for each working day in the range
for (var date = searchFrom; date <= searchTo; date = date.AddDays(1))
{
// Practice working hours (would be loaded from PracticeSettings)
var dayStart = date.ToDateTime(new TimeOnly(8, 0), DateTimeKind.Utc);
var dayEnd = date.ToDateTime(new TimeOnly(17, 30), DateTimeKind.Utc);
for (var slotStart = dayStart;
slotStart.AddMinutes(duration) <= dayEnd;
slotStart = slotStart.AddMinutes(15)) // 15-minute grid
{
if (slotStart < now.UtcDateTime) continue;
var slotEnd = slotStart.AddMinutes(duration);
// Find any clinician free in this slot
var freeClinician = qualifiedClinicians.FirstOrDefault(c =>
!bookedAppointments.Any(b =>
b.ClinicianId == c.Id &&
b.StartTime < slotEnd && b.EndTime > slotStart));
if (freeClinician is null) continue;
// Find any room free in this slot
var freeRoom = suitableRooms.FirstOrDefault(r =>
!bookedAppointments.Any(b =>
b.RoomId == r.Id &&
b.StartTime < slotEnd && b.EndTime > slotStart));
if (freeRoom is null) continue;
// Find equipment free in this slot (one unit per required type)
var freeEquipment = FindFreeEquipmentSet(
suitableEquipment, requiredEquipmentTypeIds,
bookedAppointments, slotStart, slotEnd);
if (freeEquipment is null) continue;
availableSlots.Add(new AvailableSlot(
slotStart, slotEnd,
freeClinician.Id, freeClinician.DisplayName,
freeRoom.Id, freeRoom.Name,
freeEquipment));
}
}
return availableSlots;
}
private static List<Guid>? FindFreeEquipmentSet(
List<Equipment> allEquipment,
IReadOnlyList<Guid> requiredTypeIds,
IReadOnlyList<BookedSlot> booked,
DateTime slotStart, DateTime slotEnd)
{
var assigned = new List<Guid>();
foreach (var typeId in requiredTypeIds)
{
var freeUnit = allEquipment.FirstOrDefault(e =>
e.EquipmentTypeId == typeId &&
!assigned.Contains(e.Id) &&
!booked.Any(b =>
b.EquipmentId == e.Id &&
b.StartTime < slotEnd && b.EndTime > slotStart));
if (freeUnit is null) return null; // This type has no free unit
assigned.Add(freeUnit.Id);
}
return assigned;
}
}Double-Booking Prevention with Optimistic Concurrency
Two users finding the same slot and booking simultaneously is the classic TOCTOU (time-of-check to time-of-use) race condition. The slot finder above is a read operation; the actual booking is a write. Between the read and the write, another booking may have consumed the resources.
The solution is a RowVersion concurrency token on the Slot entity. If two requests attempt to book the same slot simultaneously, the second SaveChangesAsync will throw a DbUpdateConcurrencyException. The API layer catches this and returns a conflict response, prompting the user to search for a new slot.
// Appointment entity with optimistic concurrency token
public class Appointment
{
public Guid Id { get; private set; }
public Guid PracticeId { get; private set; }
public Guid PatientId { get; private set; }
public Guid ClinicianId { get; private set; }
public Guid RoomId { get; private set; }
public Guid? EquipmentId { get; private set; }
public AppointmentStatus Status { get; private set; }
public DateTime StartTime { get; private set; }
public DateTime EndTime { get; private set; }
public AppointmentType Type { get; private set; }
// xmin is PostgreSQL's system column — changes on every row update.
// EF Core Npgsql provider uses this as the concurrency token automatically
// when mapped with IsRowVersion().
public uint RowVersion { get; private set; }
}
// In the booking service:
public async Task<BookingResult> BookAppointmentAsync(
BookAppointmentCommand cmd, CancellationToken ct)
{
// Re-verify availability inside the write transaction
// to close the TOCTOU window
var conflictExists = await _db.Appointments
.AnyAsync(a =>
a.Status != AppointmentStatus.Cancelled &&
((a.ClinicianId == cmd.ClinicianId ||
a.RoomId == cmd.RoomId) &&
a.StartTime < cmd.EndTime &&
a.EndTime > cmd.StartTime), ct);
if (conflictExists)
return BookingResult.Conflict("Slot no longer available.");
var appointment = Appointment.Create(cmd);
_db.Appointments.Add(appointment);
try
{
await _db.SaveChangesAsync(ct);
return BookingResult.Success(appointment.Id);
}
catch (DbUpdateConcurrencyException)
{
// Another request booked the same resources between our check and our save
return BookingResult.Conflict("Slot was taken while booking. Please select a new time.");
}
}Audit Trail with Hash Chain Integrity
The Regulatory Requirement
HIPAA and many national healthcare regulations require that audit logs of PHI access cannot be altered retroactively. An administrator deleting an audit record after an unauthorised access is not a hypothetical — it is a known attack vector. The requirement is that even a database administrator with full write access cannot silently alter the audit trail without it being detected.
The solution is a hash chain: each audit record contains the SHA-256 hash of the previous record concatenated with the current record's data. If any record is modified or deleted, the hash of the next record will not match, and the chain integrity check will fail.
public sealed record AuditEntry
{
public long SequenceNumber { get; init; }
public Guid Id { get; init; } = Guid.NewGuid();
public DateTime OccurredAt { get; init; }
public Guid PracticeId { get; init; }
public Guid? UserId { get; init; }
public string Action { get; init; } = ""; // e.g. "PatientRecord.Read"
public Guid? ResourceId { get; init; }
public string ResourceType { get; init; } = ""; // e.g. "PatientRecord"
public string? IpAddress { get; init; }
public string ChainHash { get; init; } = ""; // SHA-256(prev_hash + this_payload)
}
public sealed class AuditHashChainService
{
private readonly AuditDbContext _auditDb;
// The audit DB is append-only: no UPDATE, no DELETE permissions for the app role.
// Only INSERT and SELECT are granted on the audit_entries table.
public AuditHashChainService(AuditDbContext auditDb)
=> _auditDb = auditDb;
public async Task AppendAsync(
Guid practiceId,
Guid? userId,
string action,
string resourceType,
Guid? resourceId,
string? ipAddress,
CancellationToken ct)
{
// Get the hash of the most recent entry to form the chain link.
// Ordered by sequence number, not by timestamp, to prevent reordering attacks.
var prevHash = await _auditDb.AuditEntries
.Where(e => e.PracticeId == practiceId)
.OrderByDescending(e => e.SequenceNumber)
.Select(e => e.ChainHash)
.FirstOrDefaultAsync(ct)
?? "GENESIS"; // first record in a practice's chain
var entry = new AuditEntry
{
OccurredAt = DateTime.UtcNow,
PracticeId = practiceId,
UserId = userId,
Action = action,
ResourceType = resourceType,
ResourceId = resourceId,
IpAddress = ipAddress
};
// Hash = SHA256(previousHash + "|" + sequenceNumber + "|" + payload)
var payload = $"{prevHash}|{entry.OccurredAt:O}|{entry.Action}|{entry.ResourceId}";
entry = entry with { ChainHash = ComputeHash(payload) };
_auditDb.AuditEntries.Add(entry);
await _auditDb.SaveChangesAsync(ct);
}
/// <summary>
/// Verifies the integrity of the audit chain for a practice.
/// Returns the first broken link, or null if the chain is intact.
/// </summary>
public async Task<AuditIntegrityViolation?> VerifyChainAsync(
Guid practiceId, CancellationToken ct)
{
var entries = await _auditDb.AuditEntries
.Where(e => e.PracticeId == practiceId)
.OrderBy(e => e.SequenceNumber)
.ToListAsync(ct);
var prevHash = "GENESIS";
foreach (var entry in entries)
{
var payload = $"{prevHash}|{entry.OccurredAt:O}|{entry.Action}|{entry.ResourceId}";
var expectedHash = ComputeHash(payload);
if (expectedHash != entry.ChainHash)
{
return new AuditIntegrityViolation(
entry.SequenceNumber,
entry.Id,
Expected: expectedHash,
Actual: entry.ChainHash);
}
prevHash = entry.ChainHash;
}
return null; // chain is intact
}
private static string ComputeHash(string input)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
public sealed record AuditIntegrityViolation(
long SequenceNumber, Guid EntryId, string Expected, string Actual);The audit service is injected into every repository via an EF Core interceptor, so PHI access is logged without requiring every developer to remember to call it manually.
public sealed class PhiAccessAuditInterceptor : DbCommandInterceptor
{
private readonly IAuditHashChainService _audit;
private readonly IPracticeContext _practiceContext;
private readonly IUserContext _userContext;
private readonly IHttpContextAccessor _httpContext;
public PhiAccessAuditInterceptor(
IAuditHashChainService audit,
IPracticeContext practiceContext,
IUserContext userContext,
IHttpContextAccessor httpContext)
{
_audit = audit;
_practiceContext = practiceContext;
_userContext = userContext;
_httpContext = httpContext;
}
public override async ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken ct)
{
// Only audit SELECT queries that touch PHI tables
if (command.CommandText.Contains("patients", StringComparison.OrdinalIgnoreCase))
{
var ip = _httpContext.HttpContext?.Connection.RemoteIpAddress?.ToString();
await _audit.AppendAsync(
_practiceContext.PracticeId,
_userContext.UserId,
"PatientRecord.Read",
"PatientRecord",
resourceId: null, // bulk reads don't have a single ID
ipAddress: ip,
ct);
}
return result;
}
}Insurance Billing: Claim Submission Saga
Insurance billing involves a long-running, multi-step workflow: submitting a claim to the payer's clearinghouse, waiting for acknowledgment, receiving the adjudication result (ERA), posting the payment, and handling denials. This is a natural fit for a saga pattern — a sequence of local transactions coordinated through messages, with compensating transactions on failure.
public sealed class ClaimSubmissionSaga : ISaga<ClaimSubmissionState>
{
public Guid CorrelationId { get; set; }
public ClaimSubmissionState State { get; set; } = new();
// Step 1: Validate and package the claim
public async Task<SagaStepResult> ValidateClaimAsync(
ValidateClaimCommand cmd, CancellationToken ct)
{
var encounter = await _encounterRepository.GetAsync(cmd.EncounterId, ct);
if (encounter is null)
return SagaStepResult.Fail($"Encounter {cmd.EncounterId} not found.");
var claim = ClaimBuilder.Build(encounter);
var validation = await _claimValidator.ValidateAsync(claim, ct);
if (!validation.IsValid)
return SagaStepResult.Fail(string.Join(", ", validation.Errors));
State.ClaimId = claim.Id;
State.ClaimPayload = claim.ToX12837();
State.Status = ClaimStatus.Validated;
return SagaStepResult.Continue();
}
// Step 2: Submit to clearinghouse
public async Task<SagaStepResult> SubmitToClearinghouseAsync(CancellationToken ct)
{
var response = await _clearinghouse.SubmitAsync(
State.ClaimPayload, ct);
State.ClearinghouseTrackingId = response.TrackingId;
State.Status = ClaimStatus.Submitted;
State.SubmittedAt = DateTime.UtcNow;
return SagaStepResult.WaitFor<ClearinghouseAcknowledgementEvent>(
correlationId: response.TrackingId,
timeout: TimeSpan.FromHours(24));
}
// Step 3: Handle payer adjudication result (ERA received)
public async Task<SagaStepResult> HandleEraAsync(
EraReceivedEvent era, CancellationToken ct)
{
if (era.ClaimStatus == EraStatus.Paid)
{
await _paymentPoster.PostPaymentAsync(
State.ClaimId, era.PaidAmount, era.PaymentDate, ct);
State.Status = ClaimStatus.Paid;
return SagaStepResult.Complete();
}
if (era.ClaimStatus == EraStatus.Denied)
{
State.DenialReasonCode = era.DenialReasonCode;
State.Status = ClaimStatus.Denied;
// Route to denial management workflow
await _denialWorkflow.InitiateAsync(
State.ClaimId, era.DenialReasonCode, ct);
return SagaStepResult.Complete(); // saga complete; denial workflow continues separately
}
// Partial payment — post partial, route remainder to secondary payer
await _paymentPoster.PostPaymentAsync(
State.ClaimId, era.PaidAmount, era.PaymentDate, ct);
State.Status = ClaimStatus.PartiallyPaid;
State.RemainingBalance = State.ClaimedAmount - era.PaidAmount;
await _secondaryBillingWorkflow.InitiateAsync(
State.ClaimId, State.RemainingBalance, ct);
return SagaStepResult.Complete();
}
}Appointment Reminders: Durable Outbox with Hangfire
The appointment reminder system must fire reliably even during application maintenance windows. A reminder sent 24 hours before an appointment cannot be skipped because the application was restarted for a deployment.
The solution is a durable outbox: reminder jobs are written to a database table as part of the appointment booking transaction. Hangfire reads the table and executes the jobs. Even if the application restarts between booking and reminder time, Hangfire's PostgreSQL-backed job store survives the restart.
// Written atomically with the appointment booking
public sealed class AppointmentReminderOutboxEntry
{
public Guid Id { get; init; } = Guid.NewGuid();
public Guid AppointmentId { get; init; }
public Guid PatientId { get; init; }
public DateTime ScheduledFor { get; init; } // AppointmentTime - 24h
public ReminderChannel Channel { get; init; } // Email, SMS
public OutboxStatus Status { get; init; } = OutboxStatus.Pending;
public int AttemptCount { get; init; }
public string? LastError { get; init; }
}
// In the booking service, inside the same EF Core SaveChangesAsync:
private void ScheduleReminders(Appointment appointment)
{
var reminderTime = appointment.StartTime.AddHours(-24);
// 24-hour reminder
_db.ReminderOutbox.Add(new AppointmentReminderOutboxEntry
{
AppointmentId = appointment.Id,
PatientId = appointment.PatientId,
ScheduledFor = reminderTime,
Channel = ReminderChannel.Email
});
// 2-hour reminder via SMS
_db.ReminderOutbox.Add(new AppointmentReminderOutboxEntry
{
AppointmentId = appointment.Id,
PatientId = appointment.PatientId,
ScheduledFor = appointment.StartTime.AddHours(-2),
Channel = ReminderChannel.Sms
});
}
// Hangfire recurring job that processes the outbox
[DisableConcurrentExecution(timeoutInSeconds: 60)]
public sealed class ReminderOutboxProcessor(
PracticeDbContext db,
INotificationDispatcher dispatcher,
ILogger<ReminderOutboxProcessor> logger)
{
[JobDisplayName("Process Appointment Reminder Outbox")]
public async Task ExecuteAsync(CancellationToken ct)
{
var due = await db.ReminderOutbox
.Where(r => r.Status == OutboxStatus.Pending
&& r.ScheduledFor <= DateTime.UtcNow
&& r.AttemptCount < 5)
.OrderBy(r => r.ScheduledFor)
.Take(50)
.ToListAsync(ct);
foreach (var entry in due)
{
try
{
// Verify appointment hasn't been cancelled since reminder was scheduled
var appt = await db.Appointments
.FirstOrDefaultAsync(a => a.Id == entry.AppointmentId, ct);
if (appt?.Status == AppointmentStatus.Cancelled)
{
entry.Cancel();
continue;
}
await dispatcher.SendReminderAsync(entry, ct);
entry.MarkSent();
}
catch (Exception ex)
{
entry.RecordFailure(ex.Message);
logger.LogError(ex,
"Failed to dispatch reminder {ReminderId} (attempt {Attempt}).",
entry.Id, entry.AttemptCount);
}
}
await db.SaveChangesAsync(ct);
}
}Prescription Management: Digital Signatures
Optical prescriptions are legal documents in most jurisdictions. The system stores a signed representation of each prescription, where the signature binds the prescribing clinician's identity to the prescription content at the moment of signing.
public sealed class PrescriptionSigningService
{
private readonly ICertificateStore _certStore;
public PrescriptionSigningService(ICertificateStore certStore)
=> _certStore = certStore;
public async Task<SignedPrescription> SignAsync(
PrescriptionDraft draft, Guid clinicianId, CancellationToken ct)
{
// Canonical JSON representation — field order is deterministic
var payload = JsonSerializer.Serialize(draft, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
var payloadBytes = Encoding.UTF8.GetBytes(payload);
// Load the clinician's certificate from Key Vault or the certificate store
var cert = await _certStore.GetClinicianCertificateAsync(clinicianId, ct);
using var rsa = cert.GetRSAPrivateKey()
?? throw new InvalidOperationException(
$"Certificate for clinician {clinicianId} has no private key.");
var signature = rsa.SignData(
payloadBytes,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
return new SignedPrescription
{
Id = Guid.NewGuid(),
PracticeId = draft.PracticeId,
PatientId = draft.PatientId,
ClinicianId = clinicianId,
IssuedAt = DateTime.UtcNow,
ValidUntil = DateTime.UtcNow.AddYears(2), // jurisdiction-dependent
Payload = payload,
Signature = Convert.ToBase64String(signature),
CertificateThumbprint = cert.Thumbprint
};
}
public async Task<bool> VerifyAsync(SignedPrescription rx, CancellationToken ct)
{
var cert = await _certStore.GetCertificateByThumbprintAsync(
rx.CertificateThumbprint, ct);
using var rsa = cert.GetRSAPublicKey()!;
var payloadBytes = Encoding.UTF8.GetBytes(rx.Payload);
var signatureBytes = Convert.FromBase64String(rx.Signature);
return rsa.VerifyData(
payloadBytes,
signatureBytes,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
}
}Lessons Learned
1. The global query filter is necessary but not sufficient for tenant isolation. EF Core's IgnoreQueryFilters() is a legitimate API used in certain administrative queries. Every call to IgnoreQueryFilters() must go through code review, and the column-level encryption provides the backstop when it is accidentally misused. Defense in depth is not paranoia — it is the requirement.
2. FHIR mapping belongs at the API boundary, not in the domain. The first design stored FHIR JSON blobs directly in the database to avoid writing a mapper. This made encrypted-column queries impossible (you cannot index inside a JSON blob that is encrypted), made tenant filtering brittle, and made the audit trail meaningless. The mapper is boilerplate work, but it pays for itself immediately.
3. Optimistic concurrency catches double-booking but cannot replace slot verification inside the transaction. The RowVersion concurrency token detects that two requests touched the same row. But two appointments can conflict without touching the same row — they simply overlap in time. The pre-save conflict check inside the booking transaction is what actually prevents double-booking. The concurrency token is a safety net, not the primary guard.
4. The hash chain audit trail requires an append-only database role. Implementing the hash chain in application code but granting the application role UPDATE and DELETE on the audit table is security theater. A compromised application account can still alter the chain. The database role used by the application must have only INSERT and SELECT on audit tables. Verification queries run under a separate read-only role.
5. Hangfire's PostgreSQL persistence made the reminder system trivially reliable. The original design used an in-memory IHostedService with a timer. The first deployment restart dropped seventeen pending reminders. Moving to Hangfire with a PostgreSQL job store cost two days of integration work and eliminated the entire class of missed-reminder bugs. For any job that must survive application restarts, an in-memory scheduler is the wrong tool.
6. Per-practice encryption key rotation is operationally expensive. When Azure Key Vault rotates a practice's key, every encrypted column for that practice must be re-encrypted with the new key. For a practice with 20,000 patients, this is a background job that runs for hours and cannot be interrupted without leaving data in a partially-migrated state. The key rotation workflow needs its own state machine, with a progress table that records which patient records have been migrated, so it is restartable after interruption.
Enjoyed this article?
Explore the System Design learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.