Nirmitee.io
openEHRClinical FormsInteroperabilityDeveloper Guide

Template Entry and EHR Composition Type: Building Clinical Forms from openEHR Templates

March 16, 202618 min readUpdated Oct 19, 2026
Written by
Jitendra Choudhary
Jitendra Choudhary

CTO & Co-Founder

CTO & Co-Founder at Nirmitee.io. Architects healthcare integrations across FHIR, SMART on FHIR, ABDM and NHCX, writing from production experience taking hospital software from sandbox to go-live.

Template Entry and EHR Composition Type: Building Clinical Forms from openEHR Templates

"Template entry" and "EHR composition type" are labels from the structure of an electronic health record. A composition is one self-contained unit of the record, such as a single consultation or a lab report, and the composition type says which kind of unit it is. A template entry is clinical data that was captured through a structured template, which is a predefined form, inside that composition. The openEHR specification defines a composition as the information committed to a record by one agent as the result of a single clinical encounter or documentation session (openEHR EHR Information Model). NHS GP Connect describes a consultation as the structure within which GP systems group record entries made at the same time, for the same purpose, by the same person (GP Connect, Encounter). If you saw these words in your own GP record, they describe how an entry was recorded, not a finding. Ask your GP practice what a specific entry means for your care.

The rest of this guide is for the people who build those forms. It shows how an openEHR template becomes a working clinical form, how every field on that form maps to a path in the record, how the template's own rules become validation, and how the finished form saves a composition to a clinical data repository (CDR). It includes a complete blood pressure example with working TypeScript and React code, and a checklist you can use on your next form. For the underlying concepts in more depth, start with openEHR explained for developers.

From openEHR template to clinical form: the pipeline in one picture, from archetype to CDR.

Free to share: you can use this diagram in presentations or docs with a link to this page.

What does "template entry: EHR composition type" mean in a health record?

In a health record, "EHR composition type" names the kind of document or event an entry belongs to, and "template entry" means the data was captured through a structured form built from a template. The two labels usually appear together because a template defines the form, and filling in that form produces one composition.

What "Template entry" and "EHR composition type" mean in your health record: composition, composition type, template and template entry in plain English.

Free to share: you can use this image in presentations or docs with a link to this page.

Structured templates are how most clinical systems capture coded data. In English general practice, for example, NHS England's Digital Weight Management programme provides its referral templates for all three GP IT systems (NHS England, Digital Weight Management). openEHR makes the same idea explicit and shareable: the template is a formal artefact, every field on the form has a stable path, and the saved composition can be queried by that path years later.

The table below translates the terms you will see in record viewers and in openEHR tooling.

TermPlain meaningopenEHR artefact
CompositionOne document or event in the record: a consultation, a lab report, a discharge summaryCOMPOSITION (Reference Model class)
Composition typeWhich kind of document or event it isThe template the composition was created from, plus its category, such as event or persistent
TemplateA predefined form for one clinical purposeOperational Template (OPT), built from archetypes
Template entryData captured through that formENTRY objects (OBSERVATION, EVALUATION, INSTRUCTION, ACTION) inside the composition
ArchetypeThe shared, reusable definition of one clinical concept, such as blood pressureArchetype published in the Clinical Knowledge Manager

Archetypes vs templates: what a template actually is

An archetype defines everything that could be recorded about one clinical concept; a template selects and constrains archetypes for one specific use, and that template is what a form is built from. The split is the core of openEHR's two-level modelling, and it is why forms built on openEHR stay compatible with the rest of the record.

Take blood pressure. The international openEHR-EHR-OBSERVATION.blood_pressure.v2 archetype defines systolic and diastolic pressure as quantities in mm[Hg] with a permitted magnitude from 0 up to, but not including, 1000, and whole-number precision (blood pressure archetype, CKM mirror). It also defines many optional details: cuff size, position, location of measurement and more. A template for a nursing observation round might keep systolic, diastolic and position, make systolic and diastolic mandatory, hide everything else and combine blood pressure with pulse and temperature archetypes in one composition.

Templates are authored in modelling tools. The web-based Archetype Designer, developed by Better and openEHR International (launch announcement), creates archetypes and templates and can export Operational Templates (OPTs) and JSON web templates (openEHR modelling tools). The OPT is the compiled, deployable artefact that you upload to your CDR. From the OPT, the CDR can hand your front end a simpler JSON description called the web template, which is where form building starts.

If you are new to the modelling side, what openEHR is and how it is used covers the two-level model end to end, and our openEHR integration services page describes how we deliver template, form and CDR work for product teams.

The template-to-form pipeline, step by step

A clinical form on openEHR is produced by a five-step pipeline: author the template, upload the OPT, fetch the web template, render and validate the form, and commit the result as a composition. Each step has a defined artefact and a defined API, so each can be tested on its own.

The openEHR template to form pipeline in five steps. Source: openEHR REST API (Definition and EHR) and Simplified Formats specifications.
  1. Author the template in a modelling tool, choosing archetypes and constraining them for the use case.
  2. Upload the OPT to the CDR with POST /definition/template/adl1.4 and Content-Type: application/xml (openEHR REST Definition API).
  3. Fetch the web template with GET /definition/template/adl1.4/{template_id} and Accept: application/openehr.wt+json. The same endpoint returns the original XML OPT when you ask for application/xml (same source).
  4. Render the form and validate input from the web template's node tree, input types and constraints.
  5. Commit the composition with POST /ehr/{ehr_id}/composition, sending the form data in the FLAT format (application/openehr.wt.flat+json) or the structured format (application/openehr.wt.structured+json) (openEHR REST EHR API).

The important property of this pipeline is that the form never invents its own data model. The template owns the structure and the rules; the form is a view of them. That is what lets a new version of a form keep writing data that old queries can still read.

The web template: the JSON your form is built from

The web template is a JSON rendering of an Operational Template designed for user interfaces: a tree of nodes, each with a readable id, a label, its Reference Model type, cardinality, its AQL path and, for fields people fill in, a list of inputs with types and constraints. It is the single most useful artefact for a form developer, because it carries everything the form needs in one document.

The openEHR Simplified Formats specification describes a web template as a processed representation of an Operational Template that includes simplified node identifiers, AQL paths for all elements, input type definitions for data entry, localised labels and descriptions, and multiplicity constraints (openEHR Simplified Formats). The older Simplified Data Template specification was retired in ITS-REST Release 1.1.0 and superseded by Simplified Formats (SDT specification status), so new work should follow Simplified Formats.

Anatomy of an openEHR web template JSON and what each node attribute tells your form. Source: openEHR Simplified Formats and EHRbase web template documentation.

At the top level, a web template carries templateId, version, defaultLanguage, languages and a tree (EHRbase web template documentation). Every node in the tree has these attributes:

AttributeWhat it tells the form
idThe readable path segment used in FLAT keys, for example systolic
name, localizedNameThe label to show the user
rmTypeThe Reference Model type: COMPOSITION, SECTION, OBSERVATION, ELEMENT, DV_QUANTITY, DV_CODED_TEXT and so on
nodeIdThe archetype node code, for example at0004
min, maxCardinality: min: 1 means required, max: -1 means the node can repeat without limit
aqlPathThe path used to query this node later with AQL
inputsFor fields a user fills in: each input's suffix, type, allowed values (list) and validation
childrenChild nodes

A quantity leaf, as published in the EHRbase documentation, looks like this (trimmed to the fields that matter here):

{
  "id": "dv_quantity",
  "rmType": "DV_QUANTITY",
  "nodeId": "at0008",
  "min": 0,
  "max": 1,
  "aqlPath": "/content[openEHR-EHR-SECTION.conformance_section.v0]/items[openEHR-EHR-OBSERVATION.conformance_observation.v0]/data[at0001]/events[at0002]/data[at0003]/items[at0008]/value",
  "inputs": [
    { "suffix": "magnitude", "type": "DECIMAL" },
    { "suffix": "unit", "type": "CODED_TEXT" }
  ]
}

Inputs can also carry constraints. A published teaching example from the openEHR CDR course for client-side developers shows an integer input limited to the range 0 to 12 (DHI-Scotland openEHR CDR guide):

"inputs": [
  {
    "type": "INTEGER",
    "validation": {
      "range": { "minOp": ">=", "min": 0, "maxOp": "<=", "max": 12 }
    }
  }
]

Open-source SDKs model the same shape. The Go openEHR SDK, for instance, defines an input as suffix, type, an optional list of allowed values (each with value, label and an optional ordinal), listOpen, validation (with range, precision and pattern) and terminology (openehr-sdk-go webtemplate package). Always inspect the web template your own CDR returns before you write the renderer; the attribute set is shared, but CDRs differ in which optional attributes they fill in.

Three ways to build forms from openEHR templates

There are three practical ways to turn a web template into a form: a schema-driven renderer that builds the form automatically, a low-code form designer where clinical teams arrange the form visually, and hand-built components where developers write each screen. Most mature programmes use more than one, matched to the kind of form.

Three ways to build openEHR clinical forms: schema-driven renderer, low-code form designer, hand-built components.

Schema-driven renderer

A schema-driven renderer walks the web template tree and emits a field for every input it finds, with labels, units, allowed values and validation taken straight from the template. When the template changes, the form changes with it. This is the fastest way to cover a large number of templates, such as assessment scales and questionnaires, and it guarantees that every field maps to a valid path. The cost is layout control: clinical screens often need grouping, ordering and conditional display that a template does not express, so renderers usually accept a small layout configuration alongside the web template.

Low-code form designer

Some openEHR platforms ship a visual designer where analysts or clinical informaticians drag template fields onto a canvas, set layout and conditions, and publish the form without code. Better Studio, for example, is described by its vendor as a healthcare-specific low-code platform for building clinical applications (Better Studio). Designers move form changes closer to the clinical team, which shortens the change cycle, but they tie the form definitions to that platform's tooling and runtime.

Hand-built components

For high-traffic screens, such as an admission workflow or a medication round, teams often build the form by hand in their own front-end framework and bind each control to a FLAT path. This gives full control over interaction design and performance. The risk is drift: a hand-built form can silently fall out of step with the template, so it needs the automated checks described in the testing section below.

Choosing between them

ConsiderationSchema-driven rendererLow-code designerHand-built components
Time to first formHours once the renderer existsHours to daysDays per form
Who changes the formModellers, by changing the templateClinical informatics teamDevelopers
Layout and interaction controlLimited, extended by layout configMedium, within the designer's widgetsFull
Risk of drifting from the templateVery lowLowHigh without automated checks
Portability across CDRsHigh if it only uses standard APIsTied to the platformHigh if it only uses standard APIs
Best fitMany long-tail forms, questionnaires, research data captureOrganisations with an informatics team that owns formsA few high-volume, highly tuned screens

There is also a bridge to the FHIR world. Some platforms can convert an openEHR OPT into a FHIR Questionnaire and turn a completed QuestionnaireResponse back into an openEHR composition, which lets teams reuse FHIR form tooling (example: Aidbox Forms documentation). If you are deciding where each standard fits, see openEHR vs FHIR.

Choosing a form approach for your openEHR platform? We build schema-driven renderers, hand-built clinical screens and the tests that keep them in step with your templates. Talk to our openEHR team about your templates and workflows.

Mapping form fields to FLAT paths

Every form field maps to one key in the FLAT format: the key is the chain of web template node ids from the template root to the field, joined with slashes, with an instance index on repeating nodes and a pipe suffix for the attribute being set. Once you understand that rule, you can read or build any FLAT composition by hand.

How a form field becomes an openEHR FLAT path: template id, node ids, instance index and suffix. Source: openEHR Simplified Formats specification.

The Simplified Formats specification defines the rules (openEHR Simplified Formats):

  • The template id is the first path segment, followed by the node ids down the tree.
  • Node ids are derived from node names: non-alphanumeric characters become underscores, repeated underscores collapse, the result is lower-cased and trimmed, and duplicates are made unique. "Blood Pressure" becomes blood_pressure.
  • Repeating nodes take an index, for example any_event:0.
  • Attributes use pipe suffixes: |magnitude and |unit for quantities; |code, |value and |terminology for coded text.
  • Composition context uses the ctx/ prefix, for example ctx/language, ctx/territory, ctx/composer_name, ctx/time and ctx/health_care_facility.
  • Dates and times are ISO 8601 strings, booleans are JSON booleans and counts are numbers.

The specification's own vital signs example shows the result:

{
  "ctx/language": "en",
  "ctx/territory": "US",
  "vital_signs/blood_pressure:0/any_event:0/systolic|magnitude": 120,
  "vital_signs/blood_pressure:0/any_event:0/systolic|unit": "mm[Hg]",
  "vital_signs/blood_pressure:0/any_event:0/diastolic|magnitude": 80,
  "vital_signs/blood_pressure:0/any_event:0/diastolic|unit": "mm[Hg]"
}

Two practical consequences follow. First, the form layer only ever needs a map from a field to its FLAT key and a value, which keeps UI code simple. Second, the keys depend on node names in the template, so renaming a node in a new template version changes the key; that is one of the reasons form versioning needs care, covered below.

Validation from template constraints

A form built on openEHR should take its validation rules from the template, not from hand-written code: required fields come from min, repeat limits from max, numeric bounds and precision from validation, allowed codes from list, and units from the unit input's options. Validating in the browser gives clinicians immediate feedback, and the CDR validates again on commit.

Where openEHR form validation rules come from in the template, and how the CDR checks them again. Source: blood_pressure.v2 archetype and openEHR REST EHR API.
Template constraintWhere it appears in the web templateForm behaviour
Occurrences (required or optional)Node minMark required, block save when empty
RepeatsNode max (-1 means unbounded)Show "add another" up to the limit
Numeric rangeInput validation.range with min, minOp, max, maxOpReject values outside the range, respecting inclusive or exclusive operators
PrecisionInput validation.precisionRound or reject extra decimal places
Coded valuesInput list with value and label; terminologyRender a select or radio group; send code, value and terminology
UnitsThe unit input's allowed valuesFix the unit or offer only allowed units

Server-side validation is not optional. When data is committed, it is validated against both the template and the Reference Model (DHI-Scotland openEHR CDR guide), and the REST API returns 422 Unprocessable Entity for semantic validation errors such as a template validation failure (openEHR REST EHR API). Your form must show those errors against the right field rather than as a generic failure, which is straightforward because the error refers to the same paths the form already knows.

Keep clinical judgement separate from data validation. The blood pressure archetype accepts any whole number from 0 to 999 mm[Hg], because it describes what can be recorded, not what is normal. A reading of 220 is valid data and may be clinically urgent; a form can highlight it, but it must never reject it. Put clinical alerts in a separate layer that clinicians own.

Worked example: a blood pressure form, end to end

The worked example below builds a blood pressure form from a web template, validates input against the template's constraints, and commits the result as a FLAT composition. It uses plain TypeScript and React with no form library, so every step is visible. The template id is vital_signs and the paths follow the specification's example above.

1. Types for the web template

// web-template.ts: the subset of the web template this form uses.
export interface RangeConstraint {
  min?: number;
  minOp?: '>' | '>=';
  max?: number;
  maxOp?: '<' | '<=';
}

export interface WebTemplateInput {
  suffix?: string;           // "magnitude", "unit", "code", "value" ...
  type: string;              // "DECIMAL", "INTEGER", "CODED_TEXT", "TEXT", "DATETIME", "BOOLEAN" ...
  list?: { value: string; label?: string; ordinal?: number }[];
  listOpen?: boolean;
  validation?: { range?: RangeConstraint; precision?: RangeConstraint; pattern?: string };
  terminology?: string;
}

export interface WebTemplateNode {
  id: string;
  name?: string;
  localizedName?: string;
  rmType: string;
  nodeId?: string;
  min: number;
  max: number;
  aqlPath: string;
  inputs?: WebTemplateInput[];
  children?: WebTemplateNode[];
}

export interface WebTemplate {
  templateId: string;
  defaultLanguage: string;
  tree: WebTemplateNode;
}

2. Fetch the web template

// cdr.ts: minimal client for a CDR that implements the openEHR REST API.
const BASE = process.env.NEXT_PUBLIC_CDR_BASE_URL!; // e.g. https://cdr.example.org/openehr/v1

export async function getWebTemplate(templateId: string): Promise<WebTemplate> {
  const res = await fetch(`${BASE}/definition/template/adl1.4/${encodeURIComponent(templateId)}`, {
    headers: { Accept: 'application/openehr.wt+json' },
  });
  if (!res.ok) throw new Error(`Web template ${templateId}: HTTP ${res.status}`);
  return res.json();
}

3. Turn the tree into form fields

The walker starts from the template id, which the Simplified Formats specification makes the first FLAT path segment, and builds each node's path as it descends. Repeating nodes get index :0 for the first instance; a form that allows several readings would increment the index per instance.

// fields.ts
import type { WebTemplate, WebTemplateNode, WebTemplateInput } from './web-template';

export interface FormField {
  path: string;              // FLAT path without the "|suffix"
  label: string;
  required: boolean;
  rmType: string;
  inputs: WebTemplateInput[];
}

// The template id is the first FLAT path segment (Simplified Formats), so start from it
// and walk the children of the tree root.
export function collectFields(wt: WebTemplate): FormField[] {
  const out: FormField[] = [];
  for (const child of wt.tree.children ?? []) walk(child, wt.templateId, out);
  return out;
}

function walk(node: WebTemplateNode, parentPath: string, out: FormField[]): void {
  const segment = node.max === 1 ? node.id : `${node.id}:0`; // repeating nodes take an index
  const path = `${parentPath}/${segment}`;
  if (node.inputs && node.inputs.length > 0) {
    out.push({
      path,
      label: node.localizedName ?? node.name ?? node.id,
      required: node.min >= 1,
      rmType: node.rmType,
      inputs: node.inputs,
    });
  }
  for (const child of node.children ?? []) walk(child, path, out);
}

In practice you will filter what the walker returns: context nodes that the CDR fills in, such as start time and composer, are usually set once through ctx/ keys rather than shown as fields.

4. Validate against the template

// validate.ts
import type { RangeConstraint } from './web-template';
import type { FormField } from './fields';

function inRange(v: number, r: RangeConstraint): boolean {
  if (r.min !== undefined && (r.minOp === '>' ? v <= r.min : v < r.min)) return false;
  if (r.max !== undefined && (r.maxOp === '<' ? v >= r.max : v > r.max)) return false;
  return true;
}

export function validateField(field: FormField, raw: string): string | null {
  if (raw.trim() === '') return field.required ? `${field.label} is required` : null;
  const magnitude = field.inputs.find((i) => i.suffix === 'magnitude');
  if (magnitude) {
    const value = Number(raw);
    if (!Number.isFinite(value)) return `${field.label} must be a number`;
    const range = magnitude.validation?.range;
    if (range && !inRange(value, range)) return `${field.label} is outside the allowed range`;
    const decimals = raw.includes('.') ? raw.split('.')[1].length : 0;
    const maxDecimals = magnitude.validation?.precision?.max;
    if (maxDecimals !== undefined && decimals > maxDecimals) return `${field.label} allows ${maxDecimals} decimal places`;
  }
  return null;
}

The blood pressure archetype sets whole-number precision and a range of 0 up to, but not including, 1000 mm[Hg]. Whether those constraints appear on the magnitude input depends on how your CDR serialises the web template, so check the JSON it returns and extend validateField to match. The CDR still validates on commit either way.

5. Render the form in React

// BloodPressureForm.tsx
import { useEffect, useMemo, useState } from 'react';
import { getWebTemplate } from './cdr';
import { collectFields, type FormField } from './fields';
import { validateField } from './validate';
import { commitFlat } from './commit';

const WANTED = ['systolic', 'diastolic']; // fields this screen shows, by node id

export function BloodPressureForm({ ehrId }: { ehrId: string }) {
  const [fields, setFields] = useState<FormField[]>([]);
  const [values, setValues] = useState<Record<string, string>>({});
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle');

  useEffect(() => {
    getWebTemplate('vital_signs').then((wt) => {
      const all = collectFields(wt);
      setFields(all.filter((f) => WANTED.some((id) => f.path.endsWith(`/${id}`))));
    });
  }, []);

  const unitFor = useMemo(
    () => (f: FormField) => f.inputs.find((i) => i.suffix === 'unit')?.list?.[0]?.value ?? 'mm[Hg]',
    [],
  );

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    const found: Record<string, string> = {};
    for (const f of fields) {
      const message = validateField(f, values[f.path] ?? '');
      if (message) found[f.path] = message;
    }
    setErrors(found);
    if (Object.keys(found).length > 0) return;

    const flat: Record<string, string | number> = {
      'ctx/language': 'en',
      'ctx/territory': 'GB',
      'ctx/composer_name': 'Ward nurse',
    };
    for (const f of fields) {
      flat[`${f.path}|magnitude`] = Number(values[f.path]);
      flat[`${f.path}|unit`] = unitFor(f);
    }
    setStatus('saving');
    const result = await commitFlat(ehrId, 'vital_signs', flat);
    setStatus(result.ok ? 'saved' : 'failed');
    if (!result.ok) setErrors({ form: result.message });
  }

  return (
    <form onSubmit={onSubmit} noValidate aria-describedby="bp-status">
      {fields.map((f) => {
        const id = f.path.replace(/[^a-z0-9]/gi, '-');
        return (
          <div key={f.path}>
            <label htmlFor={id}>
              {f.label} ({unitFor(f)}){f.required ? ' *' : ''}
            </label>
            <input
              id={id}
              inputMode="numeric"
              value={values[f.path] ?? ''}
              aria-invalid={Boolean(errors[f.path])}
              aria-describedby={errors[f.path] ? `${id}-error` : undefined}
              onChange={(ev) => setValues({ ...values, [f.path]: ev.target.value })}
            />
            {errors[f.path] && <p id={`${id}-error`} role="alert">{errors[f.path]}</p>}
          </div>
        );
      })}
      {errors.form && <p role="alert">{errors.form}</p>}
      <button type="submit" disabled={status === 'saving'}>Save</button>
      <p id="bp-status" aria-live="polite">{status === 'saved' ? 'Saved' : ''}</p>
    </form>
  );
}

6. Commit the FLAT composition

// commit.ts
const BASE = process.env.NEXT_PUBLIC_CDR_BASE_URL!;

export async function commitFlat(
  ehrId: string,
  templateId: string,
  flat: Record<string, string | number>,
): Promise<{ ok: true; versionUid: string } | { ok: false; message: string }> {
  // EHRbase takes the template id as a templateId query parameter for FLAT commits;
  // check your CDR's documentation for how it expects the template to be named.
  const url = `${BASE}/ehr/${encodeURIComponent(ehrId)}/composition?templateId=${encodeURIComponent(templateId)}`;
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/openehr.wt.flat+json',
      Accept: 'application/json',
      Prefer: 'return=representation', // the stored composition comes back with its version uid
    },
    body: JSON.stringify(flat),
  });
  if (res.status === 201) {
    const composition = await res.json();
    return { ok: true, versionUid: composition.uid?.value ?? '' };
  }
  if (res.status === 422) return { ok: false, message: 'The CDR rejected the data against the template.' };
  if (res.status === 404) return { ok: false, message: 'No EHR exists for this patient.' };
  return { ok: false, message: `Save failed (HTTP ${res.status}).` };
}

The create endpoint, the flat media type, the Prefer options and the 201, 400, 404 and 422 responses come from the openEHR REST EHR API (specification). The templateId query parameter is how EHRbase names the template for a FLAT commit (openehr-explorer, CDR compatibility notes); other CDRs may expect a header instead, so keep that detail in one place, as commitFlat does. For the rest of the API surface, see our openEHR REST API integration guide.

A blood pressure form validated against the openEHR template at the bedside.

Pre-populating forms from existing compositions with AQL

Forms often start with data the record already holds, such as the last blood pressure or the current medication list, and in openEHR you fetch that data with an Archetype Query Language (AQL) query using the same archetype paths the form writes to. The openEHR REST API runs ad-hoc queries with POST /query/aql, taking the query as q plus optional query_parameters, offset and fetch, and returning columns and rows (openEHR REST Query API).

In the blood pressure archetype, systolic is node at0004 and diastolic is at0005, inside the data tree at0003 of the any-event node at0006 (archetype source). The latest reading for one patient:

SELECT
  c/context/start_time/value AS recorded_at,
  o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude AS systolic,
  o/data[at0001]/events[at0006]/data[at0003]/items[at0005]/value/magnitude AS diastolic
FROM EHR e[ehr_id/value = $ehr_id]
  CONTAINS COMPOSITION c
    CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]
ORDER BY c/context/start_time/value DESC
LIMIT 1

Send it with the patient's EHR id as a parameter rather than string concatenation:

const res = await fetch(`${BASE}/query/aql`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
  body: JSON.stringify({ q: LATEST_BP_AQL, query_parameters: { ehr_id: ehrId } }),
});
const { columns, rows } = await res.json(); // rows[0] = [recorded_at, systolic, diastolic]

Show pre-populated values as "last recorded" context next to an empty field, rather than silently filling the field. A copied reading saved as a new observation creates a false measurement, which is a patient safety problem, not a UX preference. Pre-fill silently only where the template models genuinely persistent data, such as an allergy list the clinician is reviewing and confirming.

The syntax of AQL itself is covered in the AQL specification and its worked examples.

Saving and updating compositions safely

A form creates a composition with POST /ehr/{ehr_id}/composition and corrects one with PUT /ehr/{ehr_id}/composition/{uid_based_id}, sending the current version id in the If-Match header so that two people cannot overwrite each other's edits unnoticed. If the version has moved on, the CDR answers 412 Precondition Failed and returns the latest version id (openEHR REST EHR API).

  • Handle 412 as a conflict, not an error. Reload the latest version, show the clinician what changed and let them re-apply their edit.
  • Handle 422 per field. Map the message back to the form field; do not discard what the user typed.
  • Choose the Prefer header deliberately. return=minimal keeps responses small; return=representation returns the stored composition, which is useful when the form must display server-generated values.
  • Never retry a POST blindly. A timeout does not prove the commit failed. Check for the composition before retrying, or you create duplicates in the legal record.

Every commit creates a new version and nothing is overwritten, which is why openEHR records can show exactly what was known at any point in time. The openEHR overview explains versioning at the record level.

When the template changes: versioning forms

When a template changes, the form must follow it, and the safe pattern is to treat each template version as a separate, deployable form version while old compositions stay readable under the template they were written with. Templates change for good reasons: a new field for a national audit, a tighter constraint, an archetype upgrade. The form layer has to absorb those changes without breaking saving or querying.

Versioning openEHR forms safely when a template changes.
  1. Version the template id. Publish a new template id or version rather than editing a deployed template in place.
  2. Diff the web templates. Compare the old and new web template JSON and list added, removed and renamed paths. Renamed nodes change FLAT keys, because keys are derived from names.
  3. Regenerate or update the form. Schema-driven forms regenerate; hand-built forms need the path changes applied.
  4. Keep reading old data. Queries written against archetype paths keep working across template versions that use the same archetype, which is a core benefit of the two-level model. Queries that depend on template-specific structure need review.
  5. Roll out by template version, so a composition is always written with the form that matches its template.

Archetype-level changes are a bigger event than template changes. Our guide to archetype versioning in production covers major and minor archetype versions and what they mean for stored data and queries.

Clinical UX and accessibility for openEHR forms

A clinical form is only as good as the speed and accuracy with which a busy clinician can complete it, so layout, keyboard flow, units, error messages and accessibility deserve as much care as the data model. openEHR guarantees the data is well-formed; it does not make the form pleasant to use.

  • Follow WCAG 2.2. Label every field, associate errors with their fields, keep focus order logical and make every control usable by keyboard (W3C WCAG 2.2). The React example above uses labelled inputs, aria-invalid, per-field error messages and a polite live region for save status.
  • Show units next to every quantity, taken from the template, and never make the user type them.
  • Group by clinical task, not by archetype. A template's tree reflects the model; the screen should reflect the workflow. Use layout configuration to regroup.
  • Write errors in clinical language: "Systolic must be a whole number" beats "DV_QUANTITY magnitude constraint violated".
  • Design for interruption. Keep drafts locally and warn before navigating away from unsaved data.
  • Keep the language right. Web templates carry localised labels; render the labels for the user's language and set ctx/language to match.

Testing clinical forms built from templates

Test a template-driven form at three levels: unit tests for the mapping and validation logic, contract tests that commit real payloads to a test CDR, and end-to-end tests that fill in the form in a browser. The first two catch most problems before a clinician ever sees them.

LevelWhat it provesExample
UnitPaths and validation are derived correctly from the web templateGiven the stored web template JSON, collectFields returns vital_signs/blood_pressure:0/any_event:0/systolic; validateField rejects "abc" and a value above the range
ContractThe CDR accepts what the form producesCommit a generated FLAT payload to a test CDR and expect 201; commit one with a missing mandatory field and expect 422
Template driftA hand-built form still matches the templateSnapshot the web template in the repository and fail the build when a path the form uses disappears
End to endA person can complete the formDrive the form in a headless browser, check keyboard-only completion and screen reader labels
Round tripWhat was saved is what the query returnsCommit, then run the AQL pre-population query and compare values

Use synthetic patients and synthetic values in every test environment, never copies of real records.

Checklist: building a clinical form from an openEHR template

Use this checklist before a template-driven form goes to clinical users; each item maps to a section above.

openEHR clinical form go-live checklist: template versioning, web template, FLAT keys, validation from the template, units, alerts, context, error handling, accessibility, tests and the next template version.

Free to share: you can use this image in presentations or docs with a link to this page.

  1. The template is published with a versioned id, and the OPT is uploaded to the CDR.
  2. The form reads the web template from the CDR (Accept: application/openehr.wt+json) or from a stored snapshot that the build checks.
  3. Every field maps to exactly one FLAT key, derived from node ids, with indices on repeating nodes.
  4. Required fields come from min, repeats from max, ranges and precision from validation, codes from list.
  5. Units are taken from the template and shown, not typed.
  6. Clinical alerts are separate from data validation; valid but abnormal values are never rejected.
  7. ctx/ values (language, territory, composer, facility, time) are set correctly for the context.
  8. Commits handle 201, 400, 404, 412 and 422 explicitly, and POSTs are never blindly retried.
  9. Pre-populated values are shown as context unless the data is genuinely persistent.
  10. The form meets WCAG 2.2 for labels, errors, focus and keyboard use.
  11. Unit, contract, drift, end-to-end and round-trip tests run in CI with synthetic data.
  12. A plan exists for the next template version: diff, regenerate or update, roll out by version.

Building clinical forms or a whole application on openEHR? Our healthcare interoperability engineers design templates, form layers and CDR integrations, and our custom healthcare software development team builds the clinical front ends around them. Book an architecture review and we will look at your templates, your form approach and your CDR, and show where to start. If you are still choosing a repository, see the openEHR CDR comparison.

Ready to scale?

Talk to our healthcare engineering team about building, integrating, and shipping faster.

Frequently Asked Questions

What does "template entry: EHR composition type" mean in my GP record?

It is a technical label about how the entry was recorded. A composition is one unit of the record, such as a single consultation, and the composition type says which kind of unit it is. A template entry is data captured through a structured form. The label is not a diagnosis; ask your GP practice what a specific entry means for your care.

What is the difference between an openEHR archetype and a template?

An archetype defines everything that could be recorded about one clinical concept, such as blood pressure, and is shared internationally. A template selects and constrains archetypes for one specific use, such as a nursing observation round, and is what a clinical form is built from.

What is an openEHR web template?

A web template is a JSON representation of an Operational Template designed for user interfaces. It lists every node with a readable id, label, Reference Model type, cardinality, AQL path and, for fields people fill in, input types and constraints. You can request it from a CDR with GET /definition/template/adl1.4/{template_id} and Accept: application/openehr.wt+json.

What is the FLAT format in openEHR?

FLAT is a simplified JSON format for compositions defined in the openEHR Simplified Formats specification. Each value is stored under a key made of the template id, the node ids down the tree, an index on repeating nodes and a pipe suffix such as |magnitude or |unit. Its media type is application/openehr.wt.flat+json.

How do I validate an openEHR form?

Take the rules from the template: required fields from node min, repeats from node max, ranges and precision from each input's validation, allowed codes from its list and units from the unit input. Validate in the browser for fast feedback; the CDR validates again on commit and returns HTTP 422 when data breaks the template.

Can I build openEHR forms in React or Vue?

Yes. Any front-end framework can render a form from the web template and send a FLAT composition through the openEHR REST API. This guide includes a working TypeScript and React example for a blood pressure form.

How do I pre-fill a form with existing openEHR data?

Query the record with AQL through POST /query/aql, using the same archetype paths the form writes to. Show copied measurements as last-recorded context rather than silently filling a new observation, so no false readings are created.

What happens to my forms when a template changes?

Treat each template version as a separate form version: publish a new template version, diff the old and new web templates, update or regenerate the form, and roll out by version. Existing compositions stay readable, and queries on archetype paths keep working across template versions that use the same archetype.
Share