Scaling Beyond Bots: Using NestJS Microservices for High-Complexity Clinical Workflows
Technology Blogs

Scaling Beyond Bots: Using NestJS Microservices for High-Complexity Clinical Workflows

Shubham Gapat
Full stack developer

Most healthcare automation starts the same way. A bot watches a queue, picks up a task, calls an API, and marks it done. It works beautifully in the demo. Then the real world arrives: a lab result comes back out of order, an external EHR times out halfway through a sync, a patient turns out to already exist under three slightly different names, and a regulator asks you to prove exactly what happened to a record at 2:14 AM last Tuesday.

This is the point where a clever script stops being enough. Clinical workflows are not a sequence of API calls, they are long-running, stateful, failure-prone, audited business processes that touch human lives. Scaling them means scaling correctness under failure, not just throughput.

This post is about what that looks like in practice: using NestJS as the application framework, a real orchestration engine for the long-running parts, and FHIR as the source of truth. The patterns generalize, but the examples lean on a stack I’ve found holds up well, NestJS, Temporal, and Medplum.

Why Clinical Workflows Break the Bot Model

A “bot” by which I mean any single-process, request-in-response-out automation makes three assumptions that clinical work violates constantly.

The first is that work completes quickly. In reality, a prior-authorization workflow might wait days for a payer response. A patient-matching review might sit until a human clears it. You cannot hold a process open for three days, and you cannot afford to lose its state if the pod restarts.

The second assumption is that operations are independent and retryable for free. In healthcare, retrying a “create patient” call without care gives you duplicate patients. Retrying a “send order to lab” message gives you duplicate orders, which becomes duplicate blood draws. Idempotency isn’t a nice-to-have here; it’s a patient-safety requirement.

The third assumption is that nobody will ever ask what happened. Every other industry can tolerate some opacity. Clinical systems are audited. You need a durable, queryable history of every state transition, every decision, and every actor automated or human that touched a record.

The fix is not a bigger bot. It’s decomposing the work into services with clear boundaries and putting a durable orchestrator in charge of the parts that span time and failure.

The Shape of the System

Think of the architecture in three layers.

At the edge sits NestJS handling synchronous, fast work: REST and GraphQL for clients, validation, authentication, and authorization. NestJS is a good fit because its module system and dependency injection push you toward exactly the kind of bounded, testable units that microservices want, and because it has first-class transport adapters for the asynchronous side.

In the middle is the orchestration layer. This is where long-running, multi-step processes live the workflows that must survive restarts, wait for external events, and recover from partial failure. An engine like Temporal owns this. It persists workflow state for you, so a process that’s been waiting two days for a payer simply resumes.

Underneath is the clinical data layer. Rather than inventing your own patient and observation schemas, you store data as FHIR resources in something like Medplum, which gives you a compliant FHIR store, subscriptions, and access policies out of the box. Your services speak FHIR to each other and to the outside world, which dramatically reduces the integration tax when you connect to external EHRs.

NestJS as the Composition Layer, Not the Workhorse

A mistake I see often is asking the request-handling framework to also be the long-running engine packing a multi-day saga into a NestJS provider with a pile of setTimeout calls and a database table of “pending” flags. It works until it doesn’t, and when it doesn’t, you’re reverse-engineering your own half-built workflow engine at midnight.

Keep NestJS doing what it’s excellent at: receiving a request, validating it, enforcing access, and kicking off durable work. A controller should look boring.

typescript
@Controller('patients')
export class PatientIntakeController {
constructor(
@Inject('TEMPORAL_CLIENT') private readonly temporal: WorkflowClient,
) {}
@Post('intake')
async startIntake(@Body() dto: PatientIntakeDto): Promise<{ workflowId: string }> {
// Deterministic ID derived from the request, so a retried POST
// attaches to the same workflow instead of starting a duplicate.
const workflowId = `intake-${dto.externalId}`;
const handle = await this.temporal.start(patientIntakeWorkflow, {
taskQueue: 'clinical-intake',
workflowId,
args: [dto],
// Reject a second start with the same ID rather than racing.
workflowIdReusePolicy: 'REJECT_DUPLICATE',
});
return { workflowId: handle.workflowId };
}
}

The controller doesn’t know how intake works. It starts a named, durable process and hands back a handle. That separation is the whole point: the API can scale and redeploy freely without putting in-flight clinical work at risk, because the work doesn’t live in the API.

The Orchestration Layer is Where Complexity Goes to be Tamed

Here’s a realistic intake workflow: ingest a referral, match the patient against your master index, create or merge the patient record, request records from the prior provider, wait for them to arrive, then notify the care team. Several of these steps can fail, some can take days, and one of them matching sometimes needs a human.

typescript
export async function patientIntakeWorkflow(dto: PatientIntakeDto): Promise {
// Activities are the only place side effects (FHIR writes, API calls)
// happen. The engine retries them with backoff and records each result.
const { matchPatient, createPatient, requestRecords, notifyCareTeam } =
proxyActivities({
startToCloseTimeout: '2 minutes',
retry: { maximumAttempts: 5 },
});
const match = await matchPatient(dto);
let patientId: string;
if (match.confidence === 'high') {
patientId = match.patientId;
} else if (match.confidence === 'none') {
patientId = await createPatient(dto);
} else {
// Ambiguous match: pause and wait for a human decision.
// This wait survives restarts and can last for days.
const decision = await condition(() => reviewSignal !== null, '7 days');
if (!decision) throw ApplicationFailure.nonRetryable('Review timed out');
patientId = reviewSignal.resolvedPatientId;
}
await requestRecords(patientId, dto.priorProvider);
await notifyCareTeam(patientId);
return { patientId, status: 'complete' };
}

Two things make this hold up where a bot wouldn’t. The waiting is durable condition(…) blocks for up to seven days without holding a thread or a database lock, and if the worker process dies and restarts, the workflow resumes exactly where it left off. And the side effects are isolated into activities, each retried independently with backoff. A flaky records-request endpoint doesn’t restart the whole intake; it just retries that one step.

The human-in-the-loop case deserves emphasis because it’s where clinical software diverges hardest from generic automation. A patient match that the algorithm isn’t sure about must go to a person. The workflow models that as a first-class wait state rather than an exception. When the reviewer decides, NestJS sends a signal:

typescript
@Post('intake/:workflowId/resolve')
async resolveMatch(
@Param('workflowId') workflowId: string,
@Body() body: { resolvedPatientId: string },
) {
const handle = this.temporal.getHandle(workflowId);
await handle.signal(reviewDecisionSignal, body);
return { ok: true };
}

The reviewer’s HTTP request and the multi-day workflow meet cleanly, and neither one had to know much about the other.

Building Clinical Workflows That Outgrow Simple Automation? Talk to Us

Idempotency and the Sin of the Duplicate Patient

The single most important property of a clinical activity is that running it twice does no harm. Engines retry. Networks lie. A call can succeed on the server and time out on the client. If your “create patient” activity isn’t idempotent, retries manufacture duplicates, and in an EMPI context duplicate patients are precisely the disease you’re trying to cure.

FHIR gives you a clean tool for this: conditional create. You ask the store to create a resource only if one matching your criteria doesn’t already exist.

typescript
export async function createPatient(dto: PatientIntakeDto): Promise {
// Conditional create: the FHIR server creates the Patient only if no
// existing Patient matches the identifier. A retry is a no-op that
// returns the existing resource instead of making a second one.
const patient = await medplum.createResourceIfNoneExist(
{
resourceType: 'Patient',
identifier: [{ system: dto.identifierSystem, value: dto.externalId }],
name: [{ family: dto.lastName, given: [dto.firstName] }],
birthDate: dto.birthDate,
},
`identifier=${dto.identifierSystem}|${dto.externalId}`,
);
return patient.id!;
}

The combination durable retries from the orchestrator, idempotent operations at the data layer is what lets you be aggressive about reliability without being reckless about safety. You want retries; you’ve made them harmless.

Transports Between Services

Inside the system, services talk over more than plain HTTP. NestJS supports several transports through the same programming model, which lets you pick the right tool per interaction.

For synchronous internal calls where you want a typed contract and low latency say, the matching service asking the demographics service to normalize an address gRPC is a strong default. For events where you want to fan out and decouple “a patient was merged,” consumed by billing, the care team, and an audit pipeline a message broker like NATS or a Kafka stream fits better. NestJS exposes both as message patterns and event patterns:

typescript
@Controller()
export class MatchingService {
// Request/response over gRPC: caller waits for the match result.
@GrpcMethod('Matcher', 'Match')
async match(req: MatchRequest): Promise {
return this.matcher.score(req);
}
// Fire-and-forget event: many consumers, no caller waiting.
@EventPattern('patient.merged')
async onPatientMerged(@Payload() evt: PatientMergedEvent) {
await this.reindex(evt.survivingPatientId);
}
}

The guiding principle: synchronous transports for things a caller must wait on, asynchronous events for things the rest of the system should learn about but that shouldn’t block the originating action. Overusing synchronous calls re-couples services into a distributed monolith, where one slow dependency stalls everything upstream.

Observability and audit are the same problem, solved twice

In an ordinary system, observability (is it healthy?) and audit (what happened and who did it?) are separate concerns. In clinical systems they overlap heavily, and a well-instrumented orchestration layer gives you a head start on both.

The orchestrator already records every workflow’s full execution history every step, every retry, every input and output. That history is your operational debugging tool and a large part of your audit story. Layer distributed tracing on top, propagating a trace context from the inbound NestJS request through the workflow and into each activity, and you can follow a single referral across every service it touched.

The piece the engine won’t do for you is the clinical audit: the FHIR-native record of access and change. FHIR has a resource for exactly this AuditEvent and the discipline is to write one whenever a workflow reads or mutates protected data, capturing the actor, the action, the resource, and the outcome. Done consistently, “show me everything that happened to this patient’s record” becomes a query rather than an archaeology project.

Where the Boundaries Actually Go

Microservices fail most often not from technology but from drawing the lines in the wrong places. A few boundaries have earned their keep in clinical work.

Patient identity and matching belongs in its own service, because matching logic is genuinely complex, changes on its own cadence, and benefits from being independently testable and tunable. The intake and orchestration logic is a separate concern from the data it manipulates orchestration should be ignorant of FHIR storage details and talk to the data layer through a thin interface. External integrations each EHR, lab, or payer connection deserve isolation behind an adapter, so that one partner’s eccentric API or scheduled downtime is contained rather than spread across your domain logic.

What should not be a separate service is anything you’d never deploy or scale independently, or anything that shares a transaction boundary. If two pieces of logic must always succeed or fail together, splitting them across a network just turns a transaction into a distributed-systems problem you didn’t need.

Bringing it Together

The arc from bot to system is really an arc in what you choose to make durable. A bot keeps its state in memory and its guarantees in your optimism. A real clinical platform pushes state into an orchestrator that survives failure, pushes data into a FHIR store that enforces structure and idempotency, and keeps the application framework NestJS focused on the fast, synchronous edge where it shines.

None of this makes the workflows simpler. Clinical work is irreducibly complex. What it does is put the complexity somewhere it can be managed: explicit wait states instead of hidden timers, isolated retryable activities instead of all-or-nothing scripts, named events instead of tangled call graphs, and a queryable history instead of a shrug. That’s what scaling beyond bots means not handling more requests per second, but handling the hard cases correctly, every time, and being able to prove you did.

Conclusion

Clinical workflows don’t break because of bad code. They break because bots assume the world moves in milliseconds and never repeats itself. Real intake spans days, retries duplicate patients and orders, and regulators expect a full record of what happened and when. A single-process automation script can’t carry that weight.

The fix is splitting responsibilities cleanly: NestJS handles the fast, synchronous edge (auth, validation, routing), Temporal owns the long-running, failure-prone orchestration, and Medplum’s FHIR store keeps clinical data structured and idempotent. Each layer does one job well, so a flaky lab integration or a multi-day human review never takes down the whole system.

The result isn’t simpler workflows. Clinical work stays genuinely complex. But it becomes manageable: explicit wait states replace hidden timers, retried activities replace all-or-nothing scripts, and a durable execution history replaces guesswork. That’s the real win, not more throughput, but provable correctness on every hard case.

Shubham Gapat

Shubham Gapat

Full stack developer

Connect Now

Shubham is a Full stack developer with 3.5+ years of experience. He has experience in technologies like ReactJS, Redux, Python, Django and UI Frameworks. His expertise in building interactive and responsive web applications, writing efficient, optimized and DRY code. He enjoys learning about new technologies.

Share This Blog

Read More Similar Blogs

Let’s #Transform Healthcare,# Together.

Partner with us to design, build, and scale digital solutions that drive better outcomes.

Location

Global Tech Teams LLC, 525 Washington Blvd, Industrious at Newport Tower, Jersey City, NJ 07310, United States.

Contact

+1 408 786 5974
contact@mindbowser.com
BOOK A QUICK CONSULTATION

Have a Healthcare Project in Mind?

Let’s discuss your goals, workflows, and next steps in a focused consultation call.

Calendar icon Schedule a Call

Contact form