Skip to content

Repository files navigation

Kotlin FHIR

tests codegen fhir-model (R4, R4B, R5) FHIR R4 FHIR R4B FHIR R5 License

Kotlin FHIR is a lean and fast implementation of the HL7® FHIR® data model on Kotlin Multiplatform.

Key features

  • Lightweight & fast with a small footprint and zero bloat1
  • Clean & modern Kotlin code with minimalistic class definitions
  • Code generation2 from FHIR specifications for completeness and maintainability
  • JSON serialization3, no XML or Turtle dependencies
  • Multiplatform support across Mobile (Android, iOS), Server & Desktop (JVM, macOS, Linux), and Web (JavaScript, WebAssembly)
  • Support for multiple FHIR versions

Supported platforms

The library supports the following target platforms:

Target platform Gradle target Artifact suffix Support
Kotlin/JVM jvm -jvm
Kotlin/Wasm (JS) wasmJs -wasm-js
Kotlin/Wasm (WASI) wasmWasi -wasm-wasi
Kotlin/JS js -js
Android applications and libraries android -android

The library also supports the following Kotlin/Native targets:

Target platform Gradle target Artifact suffix Tier Support
macOS (ARM64) macosArm64 -macosarm64 1
iOS Simulator iosSimulatorArm64 -iossimulatorarm64 1
iOS Device iosArm64 -iosarm64 1
Linux (x64) linuxX64 -linuxx64 2
Linux (ARM64) linuxArm64 -linuxarm64 2
View Target Platform Artifact Matrix

Each library artifact is published with platform-specific variants. The table below shows the Maven Central release status for every artifact–platform combination:

Platform fhir-model
(R4 + R4B + R5)
fhir-model-r4 fhir-model-r4b fhir-model-r5
Root (KMP) fhir-model fhir-model-r4 fhir-model-r4b fhir-model-r5
JVM fhir-model-jvm fhir-model-r4-jvm fhir-model-r4b-jvm fhir-model-r5-jvm
Wasm-JS fhir-model-wasm-js fhir-model-r4-wasm-js fhir-model-r4b-wasm-js fhir-model-r5-wasm-js
Wasm-Wasi fhir-model-wasm-wasi fhir-model-r4-wasm-wasi fhir-model-r4b-wasm-wasi fhir-model-r5-wasm-wasi
JS fhir-model-js fhir-model-r4-js fhir-model-r4b-js fhir-model-r5-js
Android fhir-model-android fhir-model-r4-android fhir-model-r4b-android fhir-model-r5-android
macOS (ARM64) fhir-model-macosarm64 fhir-model-r4-macosarm64 fhir-model-r4b-macosarm64 fhir-model-r5-macosarm64
iOS Simulator (ARM64) fhir-model-iossimulatorarm64 fhir-model-r4-iossimulatorarm64 fhir-model-r4b-iossimulatorarm64 fhir-model-r5-iossimulatorarm64
iOS Device (ARM64) fhir-model-iosarm64 fhir-model-r4-iosarm64 fhir-model-r4b-iosarm64 fhir-model-r5-iosarm64
Linux (x64) fhir-model-linuxx64 fhir-model-r4-linuxx64 fhir-model-r4b-linuxx64 fhir-model-r5-linuxx64
Linux (ARM64) fhir-model-linuxarm64 fhir-model-r4-linuxarm64 fhir-model-r4b-linuxarm64 fhir-model-r5-linuxarm64

Data model

Mapping FHIR primitive data types to Kotlin

In FHIR, primitive data types (e.g. in R4) are defined using StructureDefinitions. For instance, the date type is defined in StructureDefinition-date.json. While primitive, these types may include an id and extensions, preventing direct mapping to Kotlin's primitive types. To resolve this issue, the library generates a distinct Kotlin class for each FHIR primitive data type, for example, the Date class in Date.kt file for the date type.

However, the actual values within these FHIR primitive data types defined using FHIRPath types (e.g. the integer.value element in StructureDefinition-integer.json has the FHIRPath type System.Integer) still need to be mapped to Kotlin types in the generated code. The mapping is as follows:

FHIRPath type fhir Kotlin data model type kotlin Kotlin wire type kotlin
System.Boolean kotlin.Boolean kotlin.Boolean
System.String kotlin.String kotlin.String
System.Integer kotlin.Int kotlin.Int
System.Long kotlin.Long kotlin.String
System.Decimal FhirDecimal FhirDecimal
System.Date FhirDate kotlin.String
System.Time kotlinx.datetime.LocalTime kotlinx.datetime.LocalTime
System.DateTime FhirDateTime kotlin.String

Note

The System.Decimal type is mapped to FhirDecimal, which wraps the Kotlin Multiplatform BigNum library's BigDecimal for safe arithmetic calculations and the original string representation for preserving precision as required by the FHIR specification. See the notes section in Datatypes.

Note

The System.Date and System.DateTime types are mapped to sealed interfaces FhirDate and FhirDateTime specifically generated to handle partial dates in FHIR. They are implemented using LocalDate, LocalDateTime and UtcOffset classes in the kotlinx-datetime library.

Since all FHIR data types are defined using FHIRPath types in their StructureDefinitions, mapping FHIRPath types to Kotlin effectively covers all FHIR data types. For brevity, the full FHIR data type mapping to Kotlin is omitted here.

Note

The only exceptions are positiveInt and unsignedInt, which are mapped to kotlin.Int in the generated code despite the FHIRPath System.String type in the FHIR specification.

Mapping FHIR ValueSets to Kotlin

Kotlin enums classes are only generated for ValueSets that are explicitly referenced by elements in the FHIR data model via binding. Unreferenced value sets are excluded from code generation.

The generated enum classes implement the FhirEnum interface, with enum constants derived from the code property of expanded CodeSystem concepts in the expansion packages.

As with other primitive data types, wrapper classes are generated to hold the element's id and extensions alongside the enum value (T : FhirEnum):

FHIR concept fhir Kotlin concept kotlin
Bound ValueSet (e.g. administrative-gender) enum class implementing FhirEnum (e.g. AdministrativeGender)
Element with required binding (e.g. Patient.gender) Enumeration<T : FhirEnum> (e.g. Enumeration<AdministrativeGender>)
Element with extensible or preferred binding (e.g. Expression.language) ExtensibleEnumeration<T : FhirEnum> (e.g. ExtensibleEnumeration<ExpressionLanguage>)

How an element is typed depends on its binding strength:

  • required binding: instances may only carry codes from the value set, so the element is typed Enumeration<T> (e.g. Patient.gender is Enumeration<AdministrativeGender>). Deserialization enforces that the code belongs to the bound value set.
  • extensible and preferred bindings: instances may carry codes outside the value set, so the element is typed as ExtensibleEnumeration<T> (e.g. Expression.language), a sealed interface representing either a Predefined(value: T) enum constant or a Custom(code: String) outside the value set.
  • example bindings (and bindings without expansions): instances are free to use any code, so the element is typed as the open Code primitive.

Depending on their binding scope, generated enums are placed in one of two locations:

  • Shared enums (dev.ohs.fhir.model.<r4|r4b|r5>.terminologies): Generated for elements with a common binding (e.g. AdministrativeGender).
  • Local enums: Nested inside their parent class for elements with non-common bindings (e.g. HumanName.NameUse).

Enum constant names are derived from the codes defined in the ValueSet expansions. To comply with Kotlin naming conventions, codes are normalized into PascalCase valid identifiers (handling special characters, numeric prefixes, and FHIR URLs).

For the complete list of naming transformation rules and excluded value sets, see Enum Generation.

Mapping FHIR data structure to Kotlin

Similarly, for more complex data structures in FHIR such as complex data types and FHIR resources, the library maps each StructureDefinition JSON file to a dedicated Kotlin .kt file, each containing a Kotlin data class representing the StructureDefinition. BackboneElements in FHIR are represented as nested data classes since they are never reused outside the StructureDefinition. For each occurrence of a choice type (e.g. in R4), a single sealed interface is generated with a subclass for each type.

FHIR concept fhir Kotlin concept kotlin
StructureDefinition JSON file (e.g. StructureDefinition-Patient.json) Kotlin .kt file (e.g. Patient.kt)
StructureDefinition (e.g. Patient) Kotlin data class (e.g. data class Patient)
BackboneElement (e.g. Patient.contact) Nested Kotlin data class (e.g. data class Contact nested under Patient)
Choice of data types (e.g. Patient.deceased[x]) Sealed interface (e.g. sealed interface Deceased nested under Patient with subtypes Boolean and DateTime)

The generated FHIR resource classes are Kotlin data classes. They are compact and readable, with automatically generated methods: equals()/hashCode(), toString(), componentN() functions, and copy().

The use of sealed interfaces for choice of data types, combined with Kotlin's smart casts, eliminates boilerplate type checks and makes code cleaner, more type-safe, and easier to write. This is particularly true when used in when statements:

when (val multipleBirth = patient.multipleBirth) {
    is Patient.MultipleBirth.Boolean -> {
        // Smart cast to Boolean
        println("Whether patient is part of a multiple birth: ${multipleBirth.value.value}")
    }

    is Patient.MultipleBirth.Integer -> {
        // Smart cast to Integer
        println("Birth order: ${multipleBirth.value.value}")
    }

    null -> {
        // Do nothing
    }
}

The generated classes reflect the inheritance hierarchy defined by FHIR. For example, Patient inherits from DomainResource, which inherits from Resource.

Note

FHIR patterns (such as Event, Request, and Definition) are excluded from code generation because the specification permits conforming resources to alter element names, types, cardinalities, and value sets. For example, in the Event pattern, the occurrence time element is named effective[x] in Observation but performed[x] in Procedure, and each resource defines its own distinct status value set. These structural discrepancies make interface inheritance impractical in Kotlin.

Mapping FHIR search parameters to Kotlin

In FHIR, search parameters define how resources can be queried (e.g. in R4).

The library represents each search parameter as an instance of SearchParam<R, T>, where R is the resource type and T is the extracted value type. The SearchParam class carries metadata for the parameter (such as its name, type, and FHIRPath expression) alongside a strongly typed extractFrom() function to extract values directly from a resource.

These search parameters are organized by resource type into generated {Resource}SearchParams objects (e.g. PatientSearchParams) in the search subpackage of each FHIR version (e.g. dev.ohs.fhir.model.r4.search).

During code generation, the library parses each search parameter's FHIRPath expression against the resource model to resolve property paths, choice-type casts, and filters. It then generates the extractFrom() function as native Kotlin code without relying on a runtime FHIRPath engine. When an expression uses a pattern that is not yet supported by the code generator, extractFrom() throws a NotImplementedError.

To safely distinguish between them, each {Resource}SearchParams object provides two lists: all, containing all search parameters with supported extraction, and unsupported, containing those whose extraction is not yet implemented.

For a complete list of supported and unsupported FHIRPath patterns, see Search Parameter Patterns.

Serialization and deserialization

The Kotlin serialization library is used for JSON serialization/deserialization. All generated FHIR resource classes are marked with annotation @Serializable.

A particular challenge in the serialization/deserialization process is that FHIR primitive data types are represented by two JSON properties (e.g. in R4). As a result, the Kotlin data class of any FHIR resource or element containing primitive data types cannot be directly mapped to JSON.

To address this, the library generates a custom KSerializer per FHIR type (e.g. PatientSerializer). Each serializer defines a SerialDescriptor that maps both primitive values and companion extension properties (e.g. gender and _gender) to distinct descriptor elements in JSON.

Choice types (e.g. Patient.multipleBirth) are expanded into per-expansion keys on the same flat descriptor (multipleBirthBoolean, _multipleBirthBoolean, multipleBirthInteger, _multipleBirthInteger, …). On decode, each expansion key is read into a local and the sealed value is synthesized via the companion from(…) factory during model construction. This sidesteps the JVM constructor argument limit that would otherwise be hit on FHIR fields with many possible types (e.g., ElementDefinition.pattern) because each choice type expansion is an individual descriptor slot rather than a constructor parameter.

There are two ways to serialize a resource, and the caller picks which by the static type of the value handed to kotlinx. When the static type is the concrete class (i.e. json.encodeToString(patient)), kotlinx dispatches directly to PatientSerializer, whose descriptor includes resourceType at slot 0 and which writes it itself.

When the static type is Resource (i.e. json.encodeToString<Resource>(patient)), kotlinx routes through ResourcePolymorphicSerializer, which looks up the concrete subclass and delegates to PatientPolymorphicSerializer. On this path kotlinx-json itself injects resourceType as the class discriminator, so PatientPolymorphicSerializer's descriptor must omit resourceType.

graph TB
    A["Patient instance"] -->|"json.encodeToString(patient)"| PS["PatientSerializer<br/>writes resourceType + fields"]
    A -->|"json.encodeToString&ltResource&gt(patient)"| RPS["ResourcePolymorphicSerializer<br/>(AbstractPolymorphicSerializer)"]
    RPS -->|"byClass[Patient::class]"| PPS["PatientPolymorphicSerializer<br/>writes fields only"]
    RPS -.->|"kotlinx-json injects<br/>resourceType discriminator"| O
    PS --> O["JSON output<br/>{ resourceType, ... }"]
    PPS --> O
Loading

Figure 1: Polymorphic Serializer Routing

This parallel serialization approach is due to a mismatch in how Kotlinx serialization encodes class discriminators versus FHIR Standards. FHIR requires all Resource type classes to contain resourceType, but Kotlin only adds it when the underlying static inline Type is Resource.

graph LR
    A["**Patient JSON**
    {
    #nbsp;#nbsp;gender: ...
    #nbsp;#nbsp;_gender: ...
    #nbsp;#nbsp;deceasedBoolean: ...
    #nbsp;#nbsp;deceasedDateTime: ...
    #nbsp;#nbsp;multipleBirthBoolean: ...
    #nbsp;#nbsp;_multipleBirthBoolean: ...
    #nbsp;#nbsp;multipleBirthInteger: ...
    #nbsp;#nbsp;contact: [...]
    }
    "]
    E["**Patient object**
    gender: Code
    deceased: Patient.Deceased
    #nbsp;#nbsp;↳ .Boolean | .DateTime
    multipleBirth: Patient.MultipleBirth
    #nbsp;#nbsp;↳ .Boolean | .Integer
    contact: List&lt;Patient.Contact&gt;
    "]

    subgraph PS["PatientSerializer  (descriptorOffset = 1)"]
      direction TB
      Desc["**descriptor**
      0 → resourceType
      ...
      16 → gender / 17 → _gender
      20 → deceasedBoolean / 21 → _deceasedBoolean
      22 → deceasedDateTime / 23 → _deceasedDateTime
      26 → multipleBirthBoolean / 27 → _multipleBirthBoolean
      28 → multipleBirthInteger / 29 → _multipleBirthInteger
      31 → contact / 32 → communication / 35 → link"]

      Loop["**while** (true) {
      #nbsp;#nbsp;val i = decoder.decodeElementIndex(descriptor)
      #nbsp;#nbsp;if (i == DECODE_DONE) break
      #nbsp;#nbsp;**when** (i - descriptorOffset) {
      #nbsp;#nbsp;#nbsp;#nbsp;-1 → resourceType discarded
      #nbsp;#nbsp;#nbsp;#nbsp;0..33 → per-key wire locals
      #nbsp;#nbsp;}
      }"]

      Loop -- "JSON key → i lookup" --> Desc
      Desc -. "return i" .-> Loop
      Loop -- "when(16/17) gender, when(20..23) deceased expansions, when(26..29) multipleBirth expansions, ..." --> Locals[per-key locals]
      Locals -- "MultipleBirth.from(boolean, _boolean, integer, _integer)" --> Seal[sealed values synthesized]
      Locals -- "Deceased.from(boolean, _boolean, dateTime, _dateTime)" --> Seal
      Locals -- "PatientContact / Communication / LinkSerializer.deserialize" --> BB[backbone elements]
    end

    A --> PS
    Seal --> E
    BB --> E
    Locals --> E

    style A text-align:left
    style E text-align:left
    style Desc text-align:left
    style Loop text-align:left
    style PS stroke-dasharray: 5 5
Loading

Figure 2: Deserialization of a Patient JSON

Implementation

Overview

The Kotlin FHIR library uses a Gradle binary plugin to automate the generation of Kotlin code directly from FHIR specification. This plugin uses kotlinx.serialization library to parse and load FHIR resource StructureDefinitions into an in-memory representation, and then uses KotlinPoet to generate corresponding class definitions for each FHIR resource type. Finally, these generated Kotlin classes are compiled into JVM, Wasm, JS, Native, and Android targets, enabling their use across various platforms.

graph LR
    subgraph Gradle binary plugin
        A(FHIR spec<br>in JSON) -- kotlinx.serialization --> B(instances of<br>StructureDefinition<br>Kotlin data class<br>)
        B -- KotlinPoet --> C[generated FHIR Resource classes]
    end
    C -- compiler --> D[Kotlin/JVM]
    C -- compiler --> E[Kotlin/Wasm]
    C -- compiler --> F[KotlinJS]
    C -- compiler --> G[Kotlin/Native]
    C -- compiler --> H[Android]
Loading

Figure 3: Architecture diagram

Definitions

Kotlin code is generated for StructureDefinitions in the following FHIR packages:

Note

The following are NOT included in the generated code:

  • Logical StructureDefinitions, such as Definition, Request, and Event in R4
  • Profiles StructureDefinitions
  • Constraints (e.g. in R4) and bindings (e.g. in R4) in StructureDefinitions are not represented in the generated code
  • CapabilityStatements, CodeSystems, ConceptMaps, NamingSystems, OperationDefinitions, and ValueSets

FHIR codegen

To put all this together, the FHIR codegen in the Gradle binary plugin4 generates, for each FHIR resource type:

  • the model class (the primary class) in the root package e.g. dev.ohs.fhir.model.r4, and
  • a custom KSerializer per type (e.g. PatientSerializer, plus one per BackboneElement) in the serializer package e.g. dev.ohs.fhir.model.r4.serializers. Resource types additionally get a thin XPolymorphicSerializer (descriptor without resourceType) used by ResourcePolymorphicSerializer for class-discriminator dispatch.

using ModelFileSpecGenerator and SerializerFileSpecGenerator, respectively. Each generated serializer encodes and decodes sequentially via kotlinx's CompositeEncoder / CompositeDecoder over the flat FHIR JSON wire shape.

Additionally, the schema package in the FHIR codegen contains the schema for structure definitions and helper functions for processing them, and the primitives package contains code to generate special data classes and serializers for primitive data types as mentioned earlier.

User Guide

Adding the library dependency to your project

To use the Kotlin FHIR model in your project, first make sure mavenCentral()5 is listed in your repositories:

// build.gradle.kts
repositories {
    // Other repositories such as gradlePluginPortal() and google()
    mavenCentral()
}

Then choose the appropriate artifact for your project:

  1. FHIR version — depend on only the version(s) you need: fhir-model-r4, fhir-model-r4b, fhir-model-r5, or fhir-model for all three.
  2. Target platform — choose the setup that matches your project type (see sections below).

Kotlin Multiplatform Projects

For Kotlin Multiplatform projects, add the dependency to the shared commonMain source set within the kotlin block of the module's build.gradle.kts file (e.g., composeApp/build.gradle.kts or shared/build.gradle.kts). This makes the library available across all platforms in your project.

// e.g., composeApp/build.gradle.kts or shared/build.gradle.kts
kotlin {
    sourceSets {
        commonMain.dependencies {
            // Use only the FHIR version(s) you need:
            implementation("dev.ohs.fhir:fhir-model-r4:1.0.0-rc03")

            // Or include all versions at once:
            // implementation("dev.ohs.fhir:fhir-model:1.0.0-rc03")
        }
    }
}

Android projects

For Android projects, add the dependency to the dependency block in the Android module's build.gradle.kts file (e.g., app/build.gradle.kts).

// e.g., app/build.gradle.kts
dependencies {
    implementation("dev.ohs.fhir:fhir-model-r4:1.0.0-rc03")
}

Java and Kotlin JVM projects

For JVM-only projects (Java or Kotlin), add the dependency to your build configuration depending on the build system you use:

Gradle:

// e.g., build.gradle.kts
dependencies {
    // Gradle's variant-aware resolution automatically fetches the JVM target variant
    implementation("dev.ohs.fhir:fhir-model-r4:1.0.0-rc03")
}

Maven:

<!-- e.g., pom.xml -->
<dependency>
    <groupId>dev.ohs.fhir</groupId>
    <artifactId>fhir-model-r4-jvm</artifactId>
    <version>1.0.0-rc03</version>
</dependency>

Working with FHIR resources

The generated Kotlin classes for FHIR resources are organized in version-specific packages: dev.ohs.fhir.model.<FHIR_VERSION> where <FHIR_VERSION>∈ {r4, r4b, r5}.

For example:

  • dev.ohs.fhir.model.r4
  • dev.ohs.fhir.model.r4b
  • dev.ohs.fhir.model.r5

Within each package, you'll find the corresponding Kotlin classes for all FHIR resources of that version. For example, the Patient class generated for FHIR R4 can be found in the dev.ohs.fhir.model.r4 package.

Creating FHIR resources

To create a new instance of a FHIR resource, use the generated data class constructors directly with named arguments. Since all optional fields have default values, you only need to specify the properties you actually use.

For example:

import dev.ohs.fhir.model.r4.Date
import dev.ohs.fhir.model.r4.FhirDate
import dev.ohs.fhir.model.r4.HumanName
import dev.ohs.fhir.model.r4.Patient
import dev.ohs.fhir.model.r4.String as FhirString

fun main() {
    val patient = Patient(
        id = "patient-01",
        name = listOf(
            HumanName(
                given = listOf(FhirString(value = "John"))
            )
        ),
        birthDate = Date(value = FhirDate.fromString("2000-01-01"))
    )
}

Tip

Import the FHIR String type with an alias (e.g. import dev.ohs.fhir.model.r4.String as FhirString) to avoid clashing with kotlin.String.

Alternatively, you can use the nested Builder classes to create resources:

import dev.ohs.fhir.model.r4.Date
import dev.ohs.fhir.model.r4.FhirDate
import dev.ohs.fhir.model.r4.HumanName
import dev.ohs.fhir.model.r4.Patient
import dev.ohs.fhir.model.r4.String as FhirString

fun main() {
    val patient = Patient.Builder()
        .apply {
            id = "patient-01"
            name.add(
                HumanName.Builder().apply {
                    given.add(FhirString.Builder().apply { value = "John" })
                }
            )
            birthDate = Date.Builder().apply { value = FhirDate.fromString("2000-01-01") }
        }
        .build()
}

Modifying FHIR resources

All generated FHIR classes are immutable Kotlin data classes. To modify a resource, use copy() with named arguments:

val updated = patient.copy(
    id = "patient-02",
    birthDate = Date(value = FhirDate.fromString("1990-06-15"))
)

For deeper mutations (e.g. appending to lists or modifying nested elements), use toBuilder() to avoid nesting copy() multiple times:

val updated = patient.toBuilder().apply {
    name.add(
        HumanName.Builder().apply {
            given.add(FhirString.Builder().apply { value = "Jane" })
        }
    )
}.build()

Working with FHIR primitives

To handle FHIR-specific semantics, FHIR primitives like decimal, date, and dateTime map to specialized Kotlin helper classes instead of Kotlin standard library types.

Working with decimals (FhirDecimal)

The FHIR decimal type is mapped to FhirDecimal class, which implements BigNumber<FhirDecimal> and Comparable<Any> and wraps a Kotlin Multiplatform BigDecimal for safe arbitrary-precision arithmetic calculations.

To instantiate and use FhirDecimal:

import dev.ohs.fhir.model.r4.FhirDecimal

// Create FhirDecimal instances
val value1 = FhirDecimal.fromString("1.50") // Preserves exact "1.50"
val value2 = FhirDecimal.fromInt(10)

// Perform math operations using standard operators
val sum = value1 + value2 // 11.50

// Access the underlying BigDecimal representation
val bigDecimalValue = value1.asBigDecimal()

// Access the string representation
val rawString = value1.toString() // "1.50"

Working with partial dates (FhirDate and FhirDateTime)

FhirDate and FhirDateTime are sealed interfaces representing FHIR date and dateTime data types, supporting partial dates and date-times. Specifically, FhirDate supports Year, YearMonth, or full Date precision, while FhirDateTime additionally supports full DateTime precision (with a required UTC offset).

Here is how you parse and construct them in code:

import dev.ohs.fhir.model.r4.FhirDate
import dev.ohs.fhir.model.r4.FhirDateTime
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.YearMonth

// Use constructors
val dateYear = FhirDate.Year(1985)
val dateYearMonth = FhirDate.YearMonth(YearMonth(1985, 3))
val dateFull = FhirDate.Date(LocalDate(1985, 3, 15))

val dateTimeYear = FhirDateTime.Year(1985)
val dateTimeYearMonth = FhirDateTime.YearMonth(YearMonth(1985, 3))
val dateTimeDate = FhirDateTime.Date(LocalDate(1985, 3, 15))
val dateTimeFull = FhirDateTime.DateTime(
    dateTime = LocalDateTime(1985, 3, 15, 13, 0, 0),
    utcOffset = UtcOffset(hours = 1)
)

// Parse from strings
val dateYearFromString = FhirDate.fromString("1985")
val dateYearMonthFromString = FhirDate.fromString("1985-03")
val dateFullFromString = FhirDate.fromString("1985-03-15")

val dateTimeYearFromString = FhirDateTime.fromString("1985")
val dateTimeYearMonthFromString = FhirDateTime.fromString("1985-03")
val dateTimeDateFromString = FhirDateTime.fromString("1985-03-15")
val dateTimeFullFromString = FhirDateTime.fromString("1985-03-15T13:00:00+01:00")
Pattern Matching

Because FhirDate and FhirDateTime are sealed interfaces, you can use exhaustive when expressions to safely destructure and handle each precision level:

fun describeDate(date: FhirDate): String = when (date) {
    is FhirDate.Year -> "Year: ${date.value}"
    is FhirDate.YearMonth -> "Year-Month: ${date.value}"
    is FhirDate.Date -> "Full Date: ${date.date}"
}

fun describeDateTime(dateTime: FhirDateTime): String = when (dateTime) {
    is FhirDateTime.Year -> "Year: ${dateTime.value}"
    is FhirDateTime.YearMonth -> "Year-Month: ${dateTime.value}"
    is FhirDateTime.Date -> "Date: ${dateTime.date}"
    is FhirDateTime.DateTime -> "Date-Time: ${dateTime.dateTime} with offset ${dateTime.utcOffset}"
}

Working with search parameters

You can extract search parameter values from resources using the parameters in the generated {Resource}SearchParams objects.

To extract a specific parameter:

import dev.ohs.fhir.model.r4.search.PatientSearchParams

val birthDates: List<Date> = PatientSearchParams.birthdate.extractFrom(patient)

Alternatively, use the fluent extract() extension function on the resource object itself:

import dev.ohs.fhir.model.r4.search.extract

val birthDates: List<Date> = patient.extract(PatientSearchParams.birthdate)

To iterate over all supported parameters for a given resource type (e.g. to build a search index):

import dev.ohs.fhir.model.r4.search.PatientSearchParams

PatientSearchParams.all.forEach { searchParam ->
    val values = searchParam.extractFrom(patient)
    // ...
}

Serialization and deserialization

Each generated FHIR resource class has its own generated serializer (marked by the @Serializable annotation). To encode and decode FHIR resources, use kotlinx.serialization's Json object, which is thread-safe and can be shared across threads and coroutines:

Configuration

import kotlinx.serialization.json.Json

val json = Json {
    // Format JSON with indentation for readability
    prettyPrint = true

    // Ignore unrecognized JSON keys during deserialization
    ignoreUnknownKeys = true

    // Tolerate relaxed JSON syntax (e.g. unquoted keys or strings)
    isLenient = true
}

Note

Do not use useArrayPolymorphism or namingStrategy as they produce JSON that is not FHIR compliant. Options such as explicitNulls, encodeDefaults, useAlternativeNames, and classDiscriminator have no effect because field encoding and null handling are handled directly by the generated serializers.

Serialization

To serialize a FHIR resource to a JSON string, use encodeToString():

import kotlinx.serialization.encodeToString

val serializedPatient = json.encodeToString(patient)

Deserialization

import dev.ohs.fhir.model.r4.OperationOutcome
import dev.ohs.fhir.model.r4.Patient
import dev.ohs.fhir.model.r4.Resource
import kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromString

val patientJson = """
    {
      "resourceType": "Patient",
      "id": "example",
      "name": [
        {
          "use": "official",
          "family": "Doe",
          "given": ["Jane"]
        }
      ],
      "gender": "female",
      "birthDate": "1985-03-15"
    }
""".trimIndent()

try {
    // Deserialize to a specific type when you know the resource type
    val patient = json.decodeFromString<Patient>(patientJson)

    // Deserialize to Resource when the type is unknown
    val resource = json.decodeFromString<Resource>(patientJson)
    when (resource) {
        is OperationOutcome -> { /* handle operation outcome */ }
        is Patient -> { /* handle patient */ }
        else -> { /* other resource types */ }
    }
} catch (e: SerializationException) {
    // Handle malformed JSON, invalid data, or unknown resource types
}

Non-JSON Serializers

The generated models can be serialized to and deserialized from any format supported by kotlinx.serialization, but only JSON is extensively tested.

Note

Compatibility between serialized Protocol Buffers from this library and Google's FHIR Protos has not been tested.

Developer Guide

This section is for developers who want to contribute to the library.

Running the codegen locally

You can run the codegen locally to generate FHIR models for all supported FHIR versions at once, or for a specific FHIR version:

# Generate models for all FHIR versions (R4, R4B, R5) at once:
./gradlew codegen

# Generate models for a specific FHIR version (r4, r4b, or r5):
./gradlew :fhir-model-<FHIR_VERSION>:codegen

This will sync all generated code into each module's src/commonMain/kotlin directory and apply consistent formatting using the spotless plugin.

Verifying generated code

Before pushing changes, you can verify that the committed FHIR models are up-to-date with the codegen:

./gradlew verifyCodegen

This task regenerates the FHIR models and checks if the output differs from the committed code in Git. If this task fails, it means there are changes in the generated output that need to be committed.

Note

The library is designed for use as a dependency. Directly copying generated code into your project is generally discouraged as it can lead to maintenance issues and conflicts with future updates.

Testing

Tests are organized into two categories:

Example-based tests

These tests validate the library against the full set of official HL7 FHIR example resources (~500 MB of JSON, ~10 000 resources across three FHIR versions):

For each JSON example of a FHIR resource in the referenced packages, three categories of tests are executed:

  1. Equality test:
    • First instance: Deserialize the JSON into a FHIR resource object.
    • Second instance: Deserialize the same JSON into another FHIR resource object.
    • Verification: The two objects are structurally equal (using == operator).
  2. Serialization round-trip test:
    • Deserialization: Deserialize the JSON into a FHIR resource object.
    • Serialization: Serialize the object back into JSON.
    • Verification: The regenerated JSON is compared character by character6 with the original JSON.
  3. Builder round-trip test:
    • Deserialization: Deserialize the JSON into a resource object.
    • Conversion to builder: Convert the object into a builder using toBuilder() function.
    • Conversion to resource: Build a new FHIR resource object using build() function
    • Verification: The reconstructed object from the builder is equal to the original object.

Unit tests

These tests use inline test data, run across all platforms, and are parameterized across FHIR versions (R4, R4B, R5):

  • Primitive types:
    • FhirDateTest: Date parsing and formatting (YYYY, YYYY-MM, YYYY-MM-DD).
    • FhirDateTimeTest: DateTime parsing, timezone validation, and millisecond precision.
    • FhirDecimalTest: Lexical precision, scientific notation, BigDecimal conversions, and arithmetic.
    • ExtensibleEnumerationTest: Sealed hierarchy (Predefined / Custom) and companion factory resolution for extensible value set bindings.
  • Serialization:
    • PolymorphicSerializationTest: resourceType discriminator handling.
    • EnumerationSerializationTest: Enum element extensions (_field) and missing-value lists.
    • SerializationExceptionTest: Missing required property validation.
    • IndexOrderingTest: Serializer descriptor slot indexing and field tag alignment in ProtoBuf.
    • JsonConfigurationTest: Behavior with custom Json { ... } configurations.
  • Search parameters:
    • SearchParamTest: Value extraction for choice types, filtered contexts, and type expressions.

Platform coverage and CI

The CI pipeline runs tests across six platform targets on every push and pull request. Unit tests run on all platforms, while example-based tests only run on JVM and Android since they require loading HL7 example packages from the local filesystem.

Platform Gradle task CI runner Example-based tests Unit tests
JVM jvmTest ubuntu-latest
Android testAndroidHostTest ubuntu-latest
Wasm JS (Browser) wasmJsBrowserTest ubuntu-latest
Wasm WASI (Node) wasmWasiNodeTest ubuntu-latest
JS (Browser) jsBrowserTest ubuntu-latest
iOS (Simulator) iosSimulatorArm64Test macos-latest

Note

Android tests run as host (JVM) unit tests via the Android Kotlin Multiplatform library plugin's androidHostTest compilation — there is no separate debug/release unit-test variant.

To run tests locally, use any Gradle task from the table above (e.g. ./gradlew jvmTest), or ./gradlew check to run all targets.

Publishing

To publish a new release, first update mavenVersion in gradle.properties to the new version. Then follow one of the methods below:

Maven Local

To publish artifacts to your local Maven repository (~/.m2/repository) for local development and testing, run:

./gradlew publishToMavenLocal

Maven Central

Publishing to Maven Central requires two sets of credentials:

  1. Maven Central credentials: your Sonatype portal username and password tokens.
  2. GPG signing: a GPG key and its passphrase, used to sign all published artifacts.

See the Kotlin Multiplatform Publishing Guide and the Maven Central Publishing Guide for more information on how to set up these credentials.

Publishing to Maven Central manually

For manual publishing, store the credentials in the global ~/.gradle/gradle.properties (not the project's gradle.properties) so they are never committed to the repository:

# Maven Central Credentials
mavenCentralUsername=YOUR_USERNAME_TOKEN
mavenCentralPassword=YOUR_PASSWORD_TOKEN

# GPG Signing (file-based)
signing.keyId=YOUR_KEY_ID
signing.password=YOUR_KEY_PASSWORD
signing.secretKeyRingFile=/path/to/secring.gpg

Then run:

./gradlew publishToMavenCentral
Publishing to Maven Central using GitHub Actions

The project includes a GitHub Actions workflow that publishes to Maven Central when a new GitHub release (or pre-release) is created.

The workflow requires the following GitHub organization or repository secrets (already set up):

Secret Description
MAVEN_CENTRAL_USERNAME Same as mavenCentralUsername
MAVEN_CENTRAL_PASSWORD Same as mavenCentralPassword
GPG_KEY_CONTENTS Needs to be exported using the command gpg --armor --export-secret-keys YOUR_KEY_ID
SIGNING_PASSWORD Same as signing.password

Acknowledgements

Thanks to Yigit Boyar for helping bootstrap this project and generously sharing his expertise in Kotlin Multiplatform and Gradle.

Footnotes

  1. No dependencies on logging, XML, or networking libraries or any platform-specific dependencies. Only essential Kotlin Multiplatform dependencies are included, e.g., kotlinx.serialization, kotlinx.datetime, and Kotlin Multiplatform BigNum.

  2. Using KotlinPoet.

  3. It is also possible to serialize to other formats kotlinx.serialization supports, such as protocol buffers. However, there is no XML or Turtle support.

  4. The codegen is structured as a Gradle composite build (includeBuild) rather than buildSrc because it needs the kotlinx-serialization compiler plugin (to deserialize FHIR spec JSON) and runtime dependencies (bignum, kotlinx-datetime, KotlinPoet) that buildSrc cannot cleanly support.

  5. Early versions of this library (up to 1.0.0-beta02) were published under the group ID com.google.fhir on Google Maven.

  6. There are several exceptions. The FHIR specification allows for some variability in data representation, which may lead to differences between the original and newly serialized JSON. For example, non-standard JSON property ordering, additional trailing zeros in datetime and time, and the use of +00:00 instead of Z for zero UTC offset. The serialization process normalizes these variations, resulting in potentially different JSON output. However, in all of these cases, semantic equivalence is maintained.

About

Kotlin FHIR is a lean and fast implementation of the HL7® FHIR® data model on Kotlin Multiplatform.

Topics

Resources

Code of conduct

Stars

68 stars

Watchers

9 watching

Forks

Releases

Packages

Used by

Contributors

Languages