Repository: aehrc/pathling (language: python)

Issue:
Cache at the terminology service level
This change will move the caching to the terminology service level, while still respecting the ETag and Cache-Control directives in the responses.

This will improve the performance, as it will remove the need to parse the URLs and responses upon both cache hits and misses.


Discussion / hints:

Fix patch:
diff --git a/fhir-server/pom.xml b/fhir-server/pom.xml
index eaaf0f1f35..18346c9577 100644
--- a/fhir-server/pom.xml
+++ b/fhir-server/pom.xml
@@ -37,7 +37,7 @@
     <pathling.fhirServerDockerTag>latest</pathling.fhirServerDockerTag>
     <pathling.dockerArchitecture>arm64</pathling.dockerArchitecture>
     <jmh.version>1.35</jmh.version>
-    <pathling.dockerBaseImage>openjdk:11-jre-slim</pathling.dockerBaseImage>
+    <pathling.dockerBaseImage>amazoncorretto:11</pathling.dockerBaseImage>
   </properties>
 
   <dependencies>
@@ -347,10 +347,6 @@
           </execution>
         </executions>
       </plugin>
-      <plugin>
-        <groupId>org.apache.maven.plugins</groupId>
-        <artifactId>maven-source-plugin</artifactId>
-      </plugin>
       <plugin>
         <groupId>org.jacoco</groupId>
         <artifactId>jacoco-maven-plugin</artifactId>
diff --git a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/terminology/DesignationFunction.java b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/terminology/DesignationFunction.java
index c737416a76..c1c0e871fa 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/terminology/DesignationFunction.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/terminology/DesignationFunction.java
@@ -17,6 +17,10 @@
 
 package au.csiro.pathling.fhirpath.function.terminology;
 
+import static au.csiro.pathling.fhirpath.function.NamedFunction.expressionFromInput;
+import static au.csiro.pathling.sql.Terminology.designation;
+import static au.csiro.pathling.utilities.Preconditions.checkUserInput;
+
 import au.csiro.pathling.fhirpath.FhirPath;
 import au.csiro.pathling.fhirpath.element.ElementPath;
 import au.csiro.pathling.fhirpath.function.Arguments;
@@ -25,6 +29,10 @@
 import au.csiro.pathling.fhirpath.literal.CodingLiteralPath;
 import au.csiro.pathling.fhirpath.literal.StringLiteralPath;
 import au.csiro.pathling.fhirpath.parser.ParserContext;
+import java.util.List;
+import java.util.Optional;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
 import org.apache.commons.lang3.tuple.MutablePair;
 import org.apache.spark.sql.Column;
 import org.apache.spark.sql.Dataset;
@@ -32,15 +40,6 @@
 import org.hl7.fhir.r4.model.Coding;
 import org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType;
 import org.hl7.fhir.r4.model.StringType;
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
-import java.util.List;
-import java.util.Optional;
-
-import static au.csiro.pathling.fhirpath.function.NamedFunction.expressionFromInput;
-import static au.csiro.pathling.sql.Terminology.designation;
-import static au.csiro.pathling.sql.Terminology.property_of;
-import static au.csiro.pathling.utilities.Preconditions.checkUserInput;
 
 /**
  * This function returns the designations of a Coding.
diff --git a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/parser/ParserContext.java b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/parser/ParserContext.java
index e1b3b1ce2a..2b690ae375 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/parser/ParserContext.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/parser/ParserContext.java
@@ -17,10 +17,10 @@
 
 package au.csiro.pathling.fhirpath.parser;
 
-import au.csiro.pathling.terminology.TerminologyServiceFactory;
-import au.csiro.pathling.terminology.TerminologyService2;
 import au.csiro.pathling.fhirpath.FhirPath;
 import au.csiro.pathling.io.Database;
+import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import ca.uhn.fhir.context.FhirContext;
 import java.util.List;
 import java.util.Map;
@@ -72,7 +72,7 @@ public class ParserContext {
   private final Database database;
 
   /**
-   * A factory for creating new {@link TerminologyService2} objects, which is needed within blocks of
+   * A factory for creating new {@link TerminologyService} objects, which is needed within blocks of
    * code that are run in parallel. Will only be present if a terminology service has been
    * configured.
    */
@@ -107,7 +107,7 @@ public class ParserContext {
    * @param sparkSession a {@link SparkSession} that can be used to resolve Spark queries required
    * for this expression
    * @param database for retrieving data relating to resource references
-   * @param terminologyServiceFactory a factory for {@link TerminologyService2} objects, used for
+   * @param terminologyServiceFactory a factory for {@link TerminologyService} objects, used for
    * parallel processing
    * @param groupingColumns the list of columns to group on when aggregating
    * @param nodeIdColumns columns relating to the identity of resources and elements for different
diff --git a/fhir-server/src/main/resources/application.yml b/fhir-server/src/main/resources/application.yml
index 31a6777976..22d6078db3 100644
--- a/fhir-server/src/main/resources/application.yml
+++ b/fhir-server/src/main/resources/application.yml
@@ -27,12 +27,12 @@ pathling:
     # search results. It may be useful to turn this off for large datasets in memory-constrained
     # environments.
     cacheDatasets: true
-    
-    # When a table is updated, the number of partitions is checked. If the number exceeds this 
-    # threshold, the table will be repartitioned back to the default number of partitions. This 
+
+    # When a table is updated, the number of partitions is checked. If the number exceeds this
+    # threshold, the table will be repartitioned back to the default number of partitions. This
     # prevents large numbers of small updates causing poor subsequent query performance.
     compactionThreshold: 10
-  
+
   storage:
     # The base URL at which Pathling will look for data files, and where it will save data received
     # within import requests. Can be an Amazon S3 (s3://), HDFS (hdfs://) or filesystem (file://)
@@ -46,41 +46,49 @@ pathling:
     # Enables the use of terminology functions.
     enabled: true
 
-    # The endpoint of a FHIR terminology service (R4) that the server can use to resolve terminology 
+    # The endpoint of a FHIR terminology service (R4) that the server can use to resolve terminology
     # queries. The server listed here is suitable for testing purposes only.
     serverUrl: https://tx.ontoserver.csiro.au/fhir
-    
-    # Setting this option to true will enable additional logging of the details of requests between 
+
+    # Setting this option to true will enable additional logging of the details of requests between
     # the server and the terminology service.
     verboseLogging: false
-    
-    # Configuration relating to HTTP client 
+
+    # Configuration relating to HTTP client
     client:
       # The maximum period (in milliseconds) that the server should wait for incoming data from the
       # terminology service.
       socketTimeout: 60_000
-      
+
       maxConnectionsTotal: 32
-      
+
       maxConnectionsPerRoute: 16
-    
+
     cache:
+      # Set this to false to disable caching of terminology requests (not recommended).
       enabled: true
-      maxCacheEntries: 50_000
-      maxObjectSize: 64_000
+
+      # The type of storage to use for the cache. Can be "memory" or "disk".
       storageType: "memory"
-    
+
+      # Sets the maximum number of entries that will be held in memory. Only applicable when using
+      # the "memory" storage type.
+      maxEntries: 50_000
+
+      # The path on disk to use for the cache, required when a storage type of "disk" is specified.
+      # storagePath: /usr/share/cache
+
     # Configuration relating to authentication of requests to the terminology service.
     authentication:
       # Enables authenticated requests.
       enabled: false
-      
+
       # Authentication details for use with the client credentials grant.
       # tokenEndpoint: [token endpoint]
       # clientId: [client ID]
       # clientSecret: [client secret]
-      
-      # The minimum number of seconds that a token should have before expiry when deciding whether 
+
+      # The minimum number of seconds that a token should have before expiry when deciding whether
       # to send it with a terminology request.
       tokenExpiryTolerance: 120
 
@@ -106,14 +114,14 @@ pathling:
     # revokeUrl: [token revocation URL]
 
     ga4ghPassports:
-      # When GA4GH passport authentication is enabled, this option configures the identifier system 
+      # When GA4GH passport authentication is enabled, this option configures the identifier system
       # that is used to identify and control access to patient data.
       patientIdSystem: http://www.australiangenomics.org.au/id/study-number
-      
-      # When GA4GH passport authentication is enabled, this option configures the list of endpoints 
+
+      # When GA4GH passport authentication is enabled, this option configures the list of endpoints
       # that are allowed to issue visas.
-      allowedVisaIssuers: [ ]
-  
+      allowedVisaIssuers: []
+
   # This section configures HTTP caching response headers.
   httpCaching:
     # A list of values to return within the Vary header.
@@ -133,8 +141,8 @@ pathling:
   # This section configures the CORS functionality of the server.
   # For more information, see: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
   cors:
-    allowedOrigins: [ ]
-    allowedOriginPatterns: [ ]
+    allowedOrigins: []
+    allowedOriginPatterns: []
     allowedMethods:
       - OPTIONS
       - GET
@@ -162,9 +170,9 @@ pathling:
     # Enables support for FHIR extensions.
     enableExtensions: true
 
-    # The list of types that are encoded within open types, such as extensions. This default list 
-    # was taken from the data types that are common to extensions found in widely-used IGs, 
-    # such as the US and AU base profiles. In general, you will get the best query performance by 
+    # The list of types that are encoded within open types, such as extensions. This default list
+    # was taken from the data types that are common to extensions found in widely-used IGs,
+    # such as the US and AU base profiles. In general, you will get the best query performance by
     # encoding your data with the shortest possible list.
     openTypes:
       - boolean
diff --git a/lib/python/examples/designation.py b/lib/python/examples/designation.py
index 4bf89f65b6..13e3c1823d 100755
--- a/lib/python/examples/designation.py
+++ b/lib/python/examples/designation.py
@@ -27,7 +27,7 @@
 pc = PathlingContext.create()
 
 csv = pc.spark.read.options(header=True).csv(
-        f'file://{os.path.join(HERE, "data/csv/conditions.csv")}'
+    f'file://{os.path.join(HERE, "data/csv/conditions.csv")}'
 )
 
 # Obtain display name for snomed codes
@@ -35,6 +35,9 @@
 SNOMED_FULLY_SPECIFIED_NAME = Coding.of_snomed("900000000000003001")
 
 result = csv.withColumn(
-        "FULL_NAME",
-        designation(to_snomed_coding(csv.CODE), SNOMED_FULLY_SPECIFIED_NAME, "en").getItem(0))
+    "FULL_NAME",
+    designation(to_snomed_coding(csv.CODE), SNOMED_FULLY_SPECIFIED_NAME, "en").getItem(
+        0
+    ),
+)
 result.select("CODE", "DESCRIPTION", "FULL_NAME").show()
diff --git a/lib/python/pathling/coding.py b/lib/python/pathling/coding.py
index 96a5549978..3ee36abd52 100644
--- a/lib/python/pathling/coding.py
+++ b/lib/python/pathling/coding.py
@@ -1,12 +1,12 @@
 #  Copyright 2022 Commonwealth Scientific and Industrial Research
 #  Organisation (CSIRO) ABN 41 687 119 230.
-# 
+#
 #  Licensed under the Apache License, Version 2.0 (the "License");
 #  you may not use this file except in compliance with the License.
 #  You may obtain a copy of the License at
-# 
+#
 #      http://www.apache.org/licenses/LICENSE-2.0
-# 
+#
 #  Unless required by applicable law or agreed to in writing, software
 #  distributed under the License is distributed on an "AS IS" BASIS,
 #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -25,9 +25,14 @@ class Coding:
     See: https://hl7.org/fhir/R4/datatypes.html#Coding
     """
 
-    def __init__(self, system: str, code: str, version: Optional[str] = None,
-                 display: Optional[str] = None,
-                 user_selected: Optional[bool] = None):
+    def __init__(
+        self,
+        system: str,
+        code: str,
+        version: Optional[str] = None,
+        display: Optional[str] = None,
+        user_selected: Optional[bool] = None,
+    ):
         """
         :param system: a URI that identifies the code system
         :param code: the code
@@ -48,19 +53,29 @@ def to_literal(self):
         `translate`.
         :return: a Column containing a Coding struct
         """
-        id_column = lit(None).alias('id')
-        system_column = lit(self.system).alias('system')
-        version_column = lit(self.version).alias('version')
-        code_column = lit(self.code).alias('code')
-        display_column = lit(self.display).alias('display')
-        user_selected_column = lit(self.user_selected).alias('userSelected')
-        return struct(id_column, system_column, version_column, code_column, display_column,
-                      user_selected_column)
+        id_column = lit(None).alias("id")
+        system_column = lit(self.system).alias("system")
+        version_column = lit(self.version).alias("version")
+        code_column = lit(self.code).alias("code")
+        display_column = lit(self.display).alias("display")
+        user_selected_column = lit(self.user_selected).alias("userSelected")
+        return struct(
+            id_column,
+            system_column,
+            version_column,
+            code_column,
+            display_column,
+            user_selected_column,
+        )
 
     @classmethod
-    def of_snomed(cls, code: str, version: Optional[str] = None,
-                  display: Optional[str] = None,
-                  user_selected: Optional[bool] = None) -> "Coding":
+    def of_snomed(
+        cls,
+        code: str,
+        version: Optional[str] = None,
+        display: Optional[str] = None,
+        user_selected: Optional[bool] = None,
+    ) -> "Coding":
         """
         Creates a SNOMED Coding.
         :param code: the code
diff --git a/lib/python/pathling/context.py b/lib/python/pathling/context.py
index e1657fe884..b840ebe3dc 100644
--- a/lib/python/pathling/context.py
+++ b/lib/python/pathling/context.py
@@ -69,7 +69,6 @@ def create(
         max_connections_total: Optional[int] = None,
         max_connections_per_route: Optional[int] = None,
         cache_max_entries: Optional[int] = None,
-        cache_max_object_size: Optional[int] = None,
         cache_storage_type: Optional[str] = StorageType.MEMORY,
         cache_storage_path: Optional[str] = None,
         token_endpoint: Optional[str] = None,
@@ -113,7 +112,6 @@ def create(
         :param max_connections_total: the maximum total number of connections for  http services.
         :param max_connections_per_route: the maximum number of connections per route for
         :param cache_max_entries: the maximum number of cached entries.
-        :param cache_max_object_size: the maximum size of cacheable responses, in bytes.
         :param cache_storage_type: the type of cache storage to use for http service. By default,
         uses transient in-memory cache. 'None' disables caching all together.
         :param cache_storage_path: the path on disk where the cache is stored when the storage
@@ -146,7 +144,6 @@ def create(
             .maxConnectionsTotal(max_connections_total)
             .maxConnectionsPerRoute(max_connections_per_route)
             .cacheMaxEntries(cache_max_entries)
-            .cacheMaxObjectSize(cache_max_object_size)
             .cacheStorageType(cache_storage_type)
             .cacheStoragePath(cache_storage_path)
             .tokenEndpoint(token_endpoint)
diff --git a/lib/python/pathling/etc.py b/lib/python/pathling/etc.py
index ba998ac4d2..f0490c64ed 100644
--- a/lib/python/pathling/etc.py
+++ b/lib/python/pathling/etc.py
@@ -1,12 +1,12 @@
 #  Copyright 2022 Commonwealth Scientific and Industrial Research
 #  Organisation (CSIRO) ABN 41 687 119 230.
-# 
+#
 #  Licensed under the Apache License, Version 2.0 (the "License");
 #  you may not use this file except in compliance with the License.
 #  You may obtain a copy of the License at
-# 
+#
 #      http://www.apache.org/licenses/LICENSE-2.0
-# 
+#
 #  Unless required by applicable law or agreed to in writing, software
 #  distributed under the License is distributed on an "AS IS" BASIS,
 #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -32,9 +32,13 @@ def find_jar(verbose: bool = False) -> str:
     verbose and print("Packaged jars dir: %s" % jars_dir)
     if not os.path.isdir(jars_dir):
         verbose and print(
-                "Package resource dir '%s' does not exist. Maybe a development install?" % jars_dir)
+            "Package resource dir '%s' does not exist. Maybe a development install?"
+            % jars_dir
+        )
         # then it can be a development install
-        project_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
+        project_dir = os.path.abspath(
+            os.path.join(os.path.dirname(__file__), os.pardir)
+        )
         verbose and print("Project dir is: %s" % project_dir)
         jars_dir = os.path.join(project_dir, "target", "dependency")
     jar_file = glob.glob(os.path.join(jars_dir, "library-api-*-all.jar"))
diff --git a/lib/python/pathling/functions.py b/lib/python/pathling/functions.py
index 7387bab392..a71e7731ac 100644
--- a/lib/python/pathling/functions.py
+++ b/lib/python/pathling/functions.py
@@ -1,12 +1,12 @@
 #  Copyright 2022 Commonwealth Scientific and Industrial Research
 #  Organisation (CSIRO) ABN 41 687 119 230.
-# 
+#
 #  Licensed under the Apache License, Version 2.0 (the "License");
 #  you may not use this file except in compliance with the License.
 #  You may obtain a copy of the License at
-# 
+#
 #      http://www.apache.org/licenses/LICENSE-2.0
-# 
+#
 #  Unless required by applicable law or agreed to in writing, software
 #  distributed under the License is distributed on an "AS IS" BASIS,
 #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -14,15 +14,17 @@
 #  limitations under the License.
 
 import urllib.parse
-
 from typing import Optional
+
 from pyspark.sql import Column
 from pyspark.sql.functions import lit, struct
 
 SNOMED_URI = "http://snomed.info/sct"
 
 
-def to_coding(coding_column: Column, system: str, version: Optional[str] = None) -> Column:
+def to_coding(
+    coding_column: Column, system: str, version: Optional[str] = None
+) -> Column:
     """
     Converts a Column containing codes into a Column that contains a Coding struct. The Coding
     struct Column can be used as an input to terminology functions such as `member_of` and
@@ -32,18 +34,24 @@ def to_coding(coding_column: Column, system: str, version: Optional[str] = None)
     :param version: the version of the code system
     :return: a Column containing a Coding struct
     """
-    id_column = lit(None).alias('id')
-    system_column = lit(system).alias('system')
-    version_column = lit(version).alias('version')
-    display_column = lit(None).alias('display')
-    user_selected_column = lit(None).alias('userSelected')
-    return struct(id_column, system_column, version_column, coding_column.alias("code"),
-                  display_column, user_selected_column)
+    id_column = lit(None).alias("id")
+    system_column = lit(system).alias("system")
+    version_column = lit(version).alias("version")
+    display_column = lit(None).alias("display")
+    user_selected_column = lit(None).alias("userSelected")
+    return struct(
+        id_column,
+        system_column,
+        version_column,
+        coding_column.alias("code"),
+        display_column,
+        user_selected_column,
+    )
 
 
 def to_snomed_coding(coding_column: Column, version: Optional[str] = None) -> Column:
     """
-    Converts a Column containing codes into a Column that contains a SNOMED Coding struct. The 
+    Converts a Column containing codes into a Column that contains a SNOMED Coding struct. The
     Coding
     struct Column can be used as an input to terminology functions such as `member_of` and
     `translate`.
@@ -56,7 +64,7 @@ def to_snomed_coding(coding_column: Column, version: Optional[str] = None) -> Co
 
 def to_ecl_value_set(ecl: str) -> str:
     """
-    Converts a SNOMED CT ECL expression into a FHIR ValueSet URI. Can be used with the `member_of` 
+    Converts a SNOMED CT ECL expression into a FHIR ValueSet URI. Can be used with the `member_of`
     function.
     :param ecl: the ECL expression
     :return: the ValueSet URI
diff --git a/lib/python/pathling/udfs.py b/lib/python/pathling/udfs.py
index 15161757f7..cd178140b7 100644
--- a/lib/python/pathling/udfs.py
+++ b/lib/python/pathling/udfs.py
@@ -1,24 +1,19 @@
 #  Copyright 2022 Commonwealth Scientific and Industrial Research
 #  Organisation (CSIRO) ABN 41 687 119 230.
-# 
+#
 #  Licensed under the Apache License, Version 2.0 (the "License");
 #  you may not use this file except in compliance with the License.
 #  You may obtain a copy of the License at
-# 
+#
 #      http://www.apache.org/licenses/LICENSE-2.0
-# 
+#
 #  Unless required by applicable law or agreed to in writing, software
 #  distributed under the License is distributed on an "AS IS" BASIS,
 #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 #  See the License for the specific language governing permissions and
 #  limitations under the License.
 
-from typing import (
-    Any,
-    Optional,
-    Union,
-    Collection
-)
+from typing import Any, Optional, Union, Collection
 
 from py4j.java_gateway import JavaObject
 from pyspark import SparkContext
@@ -35,14 +30,22 @@ def _coding_to_java_column(coding: Optional[CodingArg]) -> JavaObject:
     if coding is None:
         return _to_java_column(lit(None))
     else:
-        return _to_java_column(coding.to_literal() if isinstance(coding, Coding) else coding)
+        return _to_java_column(
+            coding.to_literal() if isinstance(coding, Coding) else coding
+        )
 
 
-def _ensure_collection(collection_or_value: Optional[Union[Any, Collection[Any]]]) -> Optional[
-    Collection[Any]]:
-    return collection_or_value if isinstance(collection_or_value, Collection) and not isinstance(
-            collection_or_value, str) else [
-        collection_or_value] if collection_or_value is not None else None
+def _ensure_collection(
+    collection_or_value: Optional[Union[Any, Collection[Any]]]
+) -> Optional[Collection[Any]]:
+    return (
+        collection_or_value
+        if isinstance(collection_or_value, Collection)
+        and not isinstance(collection_or_value, str)
+        else [collection_or_value]
+        if collection_or_value is not None
+        else None
+    )
 
 
 def _get_jvm() -> JavaObject:
@@ -76,6 +79,7 @@ class PropertyType:
     """
     Allowed property types.
     """
+
     STRING = "string"
     INTEGER = "integer"
     BOOLEAN = "boolean"
@@ -89,6 +93,7 @@ class Equivalence:
     """
     Concept Map Equivalences
     """
+
     RELATEDTO = "relatedto"
     EQUIVALENT = "equivalent"
     EQUAL = "equal"
@@ -103,11 +108,11 @@ class Equivalence:
 
 def member_of(coding: CodingArg, value_set_uri: str) -> Column:
     """
-    Takes a Coding or array of Codings column as its input. Returns the column which contains a 
-    Boolean value, indicating whether any of the input Codings is the member of the specified FHIR 
+    Takes a Coding or array of Codings column as its input. Returns the column which contains a
+    Boolean value, indicating whether any of the input Codings is the member of the specified FHIR
     ValueSet.
 
-    :param coding: a Column containing a struct representation of a Coding or an array of such 
+    :param coding: a Column containing a struct representation of a Coding or an array of such
         structs.
     :param value_set_uri: an identifier for a FHIR ValueSet
     :return: a Column containing the result of the operation.
@@ -115,107 +120,125 @@ def member_of(coding: CodingArg, value_set_uri: str) -> Column:
     return _invoke_udf("member_of", _coding_to_java_column(coding), value_set_uri)
 
 
-def translate(coding: CodingArg, concept_map_uri: str,
-              reverse: bool = False, equivalences: Optional[EquivalenceArg] = None,
-              target: Optional[str] = None) -> Column:
+def translate(
+    coding: CodingArg,
+    concept_map_uri: str,
+    reverse: bool = False,
+    equivalences: Optional[EquivalenceArg] = None,
+    target: Optional[str] = None,
+) -> Column:
     """
-    Takes a Coding column as input.  Returns the Column which contains an array of 
-    Coding value with translation targets from the specified FHIR ConceptMap. There 
-    may be more than one target concept for each input concept. Only the translation with 
+    Takes a Coding column as input.  Returns the Column which contains an array of
+    Coding value with translation targets from the specified FHIR ConceptMap. There
+    may be more than one target concept for each input concept. Only the translation with
     the specified equivalences are returned.
     See also :class:`Equivalence`.
-    
     :param coding: a Column containing a struct representation of a Coding
     :param concept_map_uri: an identifier for a FHIR ConceptMap
-    :param reverse: the direction to traverse the map - false results in "source to target" 
+    :param reverse: the direction to traverse the map - false results in "source to target"
         mappings, while true results in "target to source"
     :param equivalences: a value of a collection of values from the ConceptMapEquivalence ValueSet
-    :param target: identifies the value set in which a translation is sought.  If there's no 
+    :param target: identifies the value set in which a translation is sought.  If there's no
         target specified, the server should return all known translations.
     :return: a Column containing the result of the operation (an array of Coding structs).
     """
-    return _invoke_udf("translate",
-                       _coding_to_java_column(coding),
-                       concept_map_uri,
-                       reverse,
-                       _invoke_support_function("equivalenceCodesToEnum",
-                                                _ensure_collection(equivalences)),
-                       target)
+    return _invoke_udf(
+        "translate",
+        _coding_to_java_column(coding),
+        concept_map_uri,
+        reverse,
+        _invoke_support_function(
+            "equivalenceCodesToEnum", _ensure_collection(equivalences)
+        ),
+        target,
+    )
 
 
 def subsumes(left_coding: CodingArg, right_coding: CodingArg) -> Column:
     """
     Takes two Coding columns as input. Returns the Column, which contains a
         Boolean value, indicating whether the left Coding subsumes the right Coding.
-    
-    :param left_coding: a Column containing a struct representation of a Coding or an array of 
+
+    :param left_coding: a Column containing a struct representation of a Coding or an array of
         Codings.
-    :param right_coding: a Column containing a struct representation of a Coding or an array of 
+    :param right_coding: a Column containing a struct representation of a Coding or an array of
         Codings.
     :return: a Column containing the result of the operation (boolean).
     """
-    return _invoke_udf("subsumes", _coding_to_java_column(left_coding),
-                       _coding_to_java_column(right_coding))
+    return _invoke_udf(
+        "subsumes",
+        _coding_to_java_column(left_coding),
+        _coding_to_java_column(right_coding),
+    )
 
 
 def subsumed_by(left_coding: CodingArg, right_coding: CodingArg) -> Column:
     """
     Takes two Coding columns as input. Returns the Column, which contains a
         Boolean value, indicating whether the left Coding is subsumed by the right Coding.
-    
-    :param left_coding: a Column containing a struct representation of a Coding or an array of 
+
+    :param left_coding: a Column containing a struct representation of a Coding or an array of
         Codings.
-    :param right_coding: a Column containing a struct representation of a Coding or an array of 
+    :param right_coding: a Column containing a struct representation of a Coding or an array of
         Codings.
     :return: a Column containing the result of the operation (boolean).
     """
-    return _invoke_udf("subsumed_by", _coding_to_java_column(left_coding),
-                       _coding_to_java_column(right_coding))
+    return _invoke_udf(
+        "subsumed_by",
+        _coding_to_java_column(left_coding),
+        _coding_to_java_column(right_coding),
+    )
 
 
 def display(coding: CodingArg) -> Column:
     """
-    Takes a Coding column as its input. Returns the Column, which contains the canonical display 
-    name associated with the given code.    
+    Takes a Coding column as its input. Returns the Column, which contains the canonical display
+    name associated with the given code.
     :param coding: a Column containing a struct representation of a Coding.
     :return: a Column containing the result of the operation (String).
     """
     return _invoke_udf("display", _coding_to_java_column(coding))
 
 
-def property_of(coding: CodingArg, property_code: str,
-                property_type: str = PropertyType.STRING) -> Column:
+def property_of(
+    coding: CodingArg, property_code: str, property_type: str = PropertyType.STRING
+) -> Column:
     """
     Takes a Coding column as its input. Returns the Column, which contains the values of properties
     for this coding with specified names and types. The type of the result column depends on the
     types of the properties. Primitive FHIR types are mapped to their corresponding SQL primitives.
     Complex types are mapped to their corresponding structs. The allowed property types are: code |
-    Coding | string | integer | boolean | dateTime | decimal. 
+    Coding | string | integer | boolean | dateTime | decimal.
     See also :class:`PropertyType`.
-    
+
     :param coding: a Column containing a struct representation of a Coding
     :param property_code: the code of the property to retrieve.
     :param property_type: the type of the property to retrieve.
     :return: the Column containing the result of the operation (array of property values)
     """
-    return _invoke_udf("property_of", _coding_to_java_column(coding), property_code,
-                       property_type)
+    return _invoke_udf(
+        "property_of", _coding_to_java_column(coding), property_code, property_type
+    )
 
 
-def designation(coding: CodingArg, use: Optional[CodingArg] = None,
-                language: Optional[str] = None) -> Column:
+def designation(
+    coding: CodingArg, use: Optional[CodingArg] = None, language: Optional[str] = None
+) -> Column:
     """
     Takes a Coding column as its input. Returns the Column, which contains the values of
     designations (strings) for this coding for the specified use and language. If the language is
     not provided (is null) then all designations with the specified type are returned regardless of
     their language.
-    
+
     :param coding: a Column containing a struct representation of a Coding
     :param use: the code with the use of the designations
     :param language: the language of the designations
-    :return: the Column containing the result of the operation (array of strings with designation 
+    :return: the Column containing the result of the operation (array of strings with designation
     values)
     """
-    return _invoke_udf("designation", _coding_to_java_column(coding),
-                       _coding_to_java_column(use),
-                       language)
+    return _invoke_udf(
+        "designation",
+        _coding_to_java_column(coding),
+        _coding_to_java_column(use),
+        language,
+    )
diff --git a/library-api/src/main/java/au/csiro/pathling/library/PathlingContextConfiguration.java b/library-api/src/main/java/au/csiro/pathling/library/PathlingContextConfiguration.java
index 96bac342e0..952053f873 100644
--- a/library-api/src/main/java/au/csiro/pathling/library/PathlingContextConfiguration.java
+++ b/library-api/src/main/java/au/csiro/pathling/library/PathlingContextConfiguration.java
@@ -52,9 +52,7 @@ public class PathlingContextConfiguration {
   public static final Default<StorageType> DEFAULT_CACHE_STORAGE_TYPE = Default.of(
       HttpClientCachingConfiguration.DEFAULT_STORAGE_TYPE);
   public static final Default<Integer> DEFAULT_CACHE_MAX_ENTRIES = Default.of(
-      HttpClientCachingConfiguration.DEFAULT_MAX_CACHE_ENTRIES);
-  public static final Default<Long> DEFAULT_CACHE_MAX_OBJECT_SIZE = Default.of(
-      HttpClientCachingConfiguration.DEFAULT_MAX_OBJECT_SIZE);
+      HttpClientCachingConfiguration.DEFAULT_MAX_ENTRIES);
 
   @Nullable
   String fhirVersion;
@@ -83,16 +81,13 @@ public class PathlingContextConfiguration {
   @Nullable
   Integer maxConnectionsPerRoute;
 
-  @Nullable
-  Integer cacheMaxEntries;
-
-  @Nullable
-  Long cacheMaxObjectSize;
-
   @Nullable
   @Builder.Default
   String cacheStorageType = HttpClientCachingConfiguration.DEFAULT_STORAGE_TYPE.toString();
 
+  @Nullable
+  Integer cacheMaxEntries;
+
   @Nullable
   String cacheStoragePath;
 
@@ -147,7 +142,6 @@ HttpClientCachingConfiguration toCacheConfig() {
     config.setEnabled(enabled);
     if (enabled) {
       config.setMaxEntries(DEFAULT_CACHE_MAX_ENTRIES.resolve(getCacheMaxEntries()));
-      config.setMaxObjectSize(DEFAULT_CACHE_MAX_OBJECT_SIZE.resolve(getCacheMaxObjectSize()));
       final StorageType storageType = StorageType.fromCode(getCacheStorageType());
       config.setStorageType(DEFAULT_CACHE_STORAGE_TYPE.resolve(storageType));
       if (config.getStorageType().equals(StorageType.DISK)) {
diff --git a/site/docs/server/configuration.md b/site/docs/server/configuration.md
index c2b7be0946..4b1cb8eb4c 100644
--- a/site/docs/server/configuration.md
+++ b/site/docs/server/configuration.md
@@ -159,17 +159,19 @@ spark:
 - `pathling.terminology.client.socketTimeout` - (default: `60000`) The maximum
   period (in milliseconds) that the server should wait for incoming data from
   the terminology service.
-- `pathling.terminology.cache.enabled` - (default: `true`) Enables client-side 
-  caching of terminology responses.
-- `pathling.terminology.cache.maxEntries` - (default: `100000`) The maximum
-  number of responses that will be retained within the terminology cache.
-- `pathling.terminology.cache.maxObjectSize` - (default: `64000`) The maximum
-  size of a response that will be cached, in bytes.
-- `pathling.terminology.cache.storageType` - (default: `memory`) The type of 
-  storage to be used by the terminology cache. Valid values are `memory` and 
+- `pathling.terminology.cache.enabled` - (default: `true`) Set this to false to
+  disable caching of terminology requests (not recommended).
+- `pathling.terminology.cache.storageType` - (default: `memory`) The type of
+  storage to be used by the terminology cache. Valid values are `memory` and
   `disk`.
+- `pathling.terminology.cache.maxEntries` - (default: `50000`) Sets the maximum
+  number of entries that will be held in memory. Only applicable when using
+  the `memory` storage type.
 - `pathling.terminology.cache.storagePath` - The path at which to store cache
   data. Required if `pathling.terminology.cache.storageType` is set to `disk`.
+- `pathling.terminology.cache.defaultExpiry` - (default: `600`) The amount
+  of time (in seconds) that a response from the terminology server should be 
+  cached if the server does not specify an expiry.
 - `pathling.terminology.authentication.enabled` - (default: `false`) Enables
   authentication for requests to the terminology service.
 - `pathling.terminology.authentication.tokenEndpoint`, 
diff --git a/terminology/src/main/java/au/csiro/pathling/caching/BaseCachingFactory.java b/terminology/src/main/java/au/csiro/pathling/caching/BaseCachingFactory.java
deleted file mode 100644
index 0f507d10a9..0000000000
--- a/terminology/src/main/java/au/csiro/pathling/caching/BaseCachingFactory.java
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * Copyright 2022 Commonwealth Scientific and Industrial Research
- * Organisation (CSIRO) ABN 41 687 119 230.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package au.csiro.pathling.caching;
-
-import au.csiro.pathling.config.HttpClientCachingConfiguration;
-import javax.annotation.Nonnull;
-import org.apache.http.impl.client.cache.CacheConfig;
-import org.apache.http.impl.client.cache.CachingHttpClientBuilder;
-import org.apache.http.impl.client.cache.CachingHttpClients;
-
-
-/**
- * Base class for implementing {@link CachingFactory}.
- */
-public abstract class BaseCachingFactory implements CachingFactory {
-
-  @Nonnull
-  protected final HttpClientCachingConfiguration config;
-
-  public BaseCachingFactory(@Nonnull final HttpClientCachingConfiguration config) {
-    this.config = config;
-  }
-
-  @Nonnull
-  @Override
-  public CachingHttpClientBuilder create(@Nonnull final CacheConfig cacheConfig) {
-    final CachingHttpClientBuilder builder = CachingHttpClients.custom()
-        .setCacheConfig(cacheConfig);
-    return configure(builder);
-  }
-
-  /**
-   * Configures the specific caching mechanism in the {@link CachingHttpClientBuilder}.
-   *
-   * @param builder the builder to configure.
-   * @return the {@link CachingHttpClientBuilder} with configured caching.
-   */
-  @Nonnull
-  protected abstract CachingHttpClientBuilder configure(
-      @Nonnull final CachingHttpClientBuilder builder);
-}
diff --git a/terminology/src/main/java/au/csiro/pathling/caching/CachingFactories.java b/terminology/src/main/java/au/csiro/pathling/caching/CachingFactories.java
deleted file mode 100644
index 55a27fec7a..0000000000
--- a/terminology/src/main/java/au/csiro/pathling/caching/CachingFactories.java
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- * Copyright 2022 Commonwealth Scientific and Industrial Research
- * Organisation (CSIRO) ABN 41 687 119 230.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package au.csiro.pathling.caching;
-
-import static au.csiro.pathling.utilities.Preconditions.check;
-import static au.csiro.pathling.utilities.Preconditions.checkNotNull;
-
-import au.csiro.pathling.config.HttpClientCachingConfiguration;
-import au.csiro.pathling.config.HttpClientCachingConfiguration.StorageType;
-import javax.annotation.Nonnull;
-import org.apache.http.impl.client.cache.CachingHttpClientBuilder;
-
-/**
- * The implementation of standard types of caching mechanisms.
- */
-public final class CachingFactories {
-
-  /**
-   * A factory that can build a {@link CachingHttpClientBuilder} that uses an in-memory cache.
-   */
-  private static class MemoryCachingFactory extends BaseCachingFactory {
-
-    public MemoryCachingFactory(@Nonnull final HttpClientCachingConfiguration config) {
-      super(config);
-    }
-
-    @Nonnull
-    @Override
-    protected CachingHttpClientBuilder configure(@Nonnull final CachingHttpClientBuilder builder) {
-      return builder.setHttpCacheStorage(new InfinispanInMemoryStorage());
-    }
-  }
-
-  /**
-   * A factory that can build a {@link CachingHttpClientBuilder} that uses a disk cache.
-   */
-  private static class DiskCachingFactory extends BaseCachingFactory {
-
-    public DiskCachingFactory(@Nonnull final HttpClientCachingConfiguration config) {
-      super(config);
-      check(config.getStorageType().equals(HttpClientCachingConfiguration.StorageType.DISK));
-    }
-
-    @Nonnull
-    @Override
-    protected CachingHttpClientBuilder configure(@Nonnull final CachingHttpClientBuilder builder) {
-      checkNotNull(config.getStoragePath());
-      return builder.setHttpCacheStorage(new InfinispanPersistentStorage(config.getStoragePath()));
-    }
-
-  }
-
-  private CachingFactories() {
-    // utility class
-  }
-
-  /**
-   * Returns a {@link CachingFactory} based upon the {@link HttpClientCachingConfiguration}.
-   *
-   * @param config the configuration for the caching mechanism
-   * @return a {@link CachingFactory} for the specified configuration
-   */
-  @Nonnull
-  public static CachingFactory of(@Nonnull final HttpClientCachingConfiguration config) {
-    if (config.getStorageType().equals(StorageType.MEMORY)) {
-      return new MemoryCachingFactory(config);
-    } else if (config.getStorageType().equals(StorageType.DISK)) {
-      return new DiskCachingFactory(config);
-    } else {
-      throw new AssertionError(
-          "HTTP cache configuration encountered with invalid storage type: " + config);
-    }
-  }
-
-}
diff --git a/terminology/src/main/java/au/csiro/pathling/caching/InfinispanPersistentStorage.java b/terminology/src/main/java/au/csiro/pathling/caching/InfinispanPersistentStorage.java
deleted file mode 100644
index 230930f7c6..0000000000
--- a/terminology/src/main/java/au/csiro/pathling/caching/InfinispanPersistentStorage.java
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * Copyright 2022 Commonwealth Scientific and Industrial Research
- * Organisation (CSIRO) ABN 41 687 119 230.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package au.csiro.pathling.caching;
-
-import java.nio.file.Path;
-import javax.annotation.Nonnull;
-import org.apache.http.client.cache.HttpCacheStorage;
-import org.infinispan.commons.marshall.JavaSerializationMarshaller;
-import org.infinispan.configuration.cache.ConfigurationBuilder;
-import org.infinispan.configuration.global.GlobalConfiguration;
-import org.infinispan.configuration.global.GlobalConfigurationBuilder;
-import org.infinispan.manager.DefaultCacheManager;
-
-/**
- * An abstract {@link HttpCacheStorage} implementation that uses embedded Infinispan with persistent
- * storage. Requires a storage path, which it uses to store cache data and indexes.
- *
- * @author John Grimes
- */
-public class InfinispanPersistentStorage extends InfinispanStorage {
-
-  private static final String DATA_DIRECTORY = "data";
-  private static final String INDEX_DIRECTORY = "index";
-
-  public InfinispanPersistentStorage(@Nonnull final String storagePath) {
-    final GlobalConfigurationBuilder globalConfigBuilder = new GlobalConfigurationBuilder();
-    globalConfigBuilder.serialization()
-        .marshaller(new JavaSerializationMarshaller())
-        .allowList()
-        .addRegexp(".*");
-    final GlobalConfiguration globalConfig = globalConfigBuilder.build();
-    final String dataLocation = Path.of(storagePath, DATA_DIRECTORY).toString();
-    final String indexLocation = Path.of(storagePath, INDEX_DIRECTORY).toString();
-
-    cacheManager = new DefaultCacheManager(globalConfig);
-    cacheManager.defineConfiguration(CACHE_NAME,
-        new ConfigurationBuilder()
-            .persistence()
-            .addSoftIndexFileStore()
-            .dataLocation(dataLocation)
-            .indexLocation(indexLocation)
-            .build());
-    cache = cacheManager.getCache(CACHE_NAME);
-  }
-
-}
diff --git a/terminology/src/main/java/au/csiro/pathling/caching/InfinispanStorage.java b/terminology/src/main/java/au/csiro/pathling/caching/InfinispanStorage.java
deleted file mode 100644
index 49e1c31516..0000000000
--- a/terminology/src/main/java/au/csiro/pathling/caching/InfinispanStorage.java
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * Copyright 2022 Commonwealth Scientific and Industrial Research
- * Organisation (CSIRO) ABN 41 687 119 230.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package au.csiro.pathling.caching;
-
-import java.io.Closeable;
-import java.io.IOException;
-import javax.annotation.Nonnull;
-import org.apache.http.client.cache.HttpCacheEntry;
-import org.apache.http.client.cache.HttpCacheStorage;
-import org.apache.http.client.cache.HttpCacheUpdateCallback;
-import org.infinispan.Cache;
-import org.infinispan.manager.DefaultCacheManager;
-
-/**
- * An abstract {@link HttpCacheStorage} implementation that uses embedded Infinispan as the
- * underlying storage. Subclasses need to initialize the cache and cache manager.
- *
- * @author John Grimes
- */
-public abstract class InfinispanStorage implements HttpCacheStorage, Closeable {
-
-  protected static final String CACHE_NAME = "terminology";
-
-  @Nonnull
-  protected DefaultCacheManager cacheManager;
-
-  @Nonnull
-  protected Cache<String, HttpCacheEntry> cache;
-
-  @Override
-  public void putEntry(final String key, final HttpCacheEntry entry) {
-    cache.put(key, entry);
-  }
-
-  @Override
-  public HttpCacheEntry getEntry(final String key) {
-    return cache.get(key);
-  }
-
-  @Override
-  public void removeEntry(final String key) {
-    cache.remove(key);
-  }
-
-  @Override
-  public void updateEntry(final String key, final HttpCacheUpdateCallback callback) {
-    cache.compute(key, (k, v) -> {
-      try {
-        return callback.update(v);
-      } catch (final IOException e) {
-        throw new RuntimeException(e);
-      }
-    });
-  }
-
-  @Override
-  public void close() throws IOException {
-    cacheManager.close();
-  }
-
-}
diff --git a/terminology/src/main/java/au/csiro/pathling/config/HttpClientCachingConfiguration.java b/terminology/src/main/java/au/csiro/pathling/config/HttpClientCachingConfiguration.java
index 46a37f45a2..c7d60038cd 100644
--- a/terminology/src/main/java/au/csiro/pathling/config/HttpClientCachingConfiguration.java
+++ b/terminology/src/main/java/au/csiro/pathling/config/HttpClientCachingConfiguration.java
@@ -38,7 +38,6 @@
 import lombok.Builder;
 import lombok.Data;
 import lombok.NoArgsConstructor;
-import org.apache.http.impl.client.cache.CacheConfig;
 
 /**
  * Represents configuration relating to HTTP client caching.
@@ -53,9 +52,9 @@ public class HttpClientCachingConfiguration implements Serializable {
   private static final long serialVersionUID = -3030386957343963899L;
 
   public static final boolean DEFAULT_ENABLED = true;
-  public static final int DEFAULT_MAX_CACHE_ENTRIES = 100_000;
-  public static final long DEFAULT_MAX_OBJECT_SIZE = 64_000L;
   public static final StorageType DEFAULT_STORAGE_TYPE = StorageType.MEMORY;
+  public static final int DEFAULT_MAX_ENTRIES = 50_000;
+  public static final int DEFAULT_EXPIRY = 600;
 
   /**
    * Enables client side caching of REST requests.
@@ -65,32 +64,37 @@ public class HttpClientCachingConfiguration implements Serializable {
   private boolean enabled = DEFAULT_ENABLED;
 
   /**
-   * Sets the maximum number of entries the cache will retain.
-   * <p>
-   * See also: {@link CacheConfig.Builder#setMaxCacheEntries(int)}
+   * The type of storage to use for the cache.
+   *
+   * @see StorageType
    */
   @NotNull
-  @Min(0)
   @Builder.Default
-  private int maxEntries = DEFAULT_MAX_CACHE_ENTRIES;
+  private StorageType storageType = DEFAULT_STORAGE_TYPE;
 
   /**
-   * Sets the maximum size of a cacheable response, in bytes.
-   * <p>
-   * See also: {@link CacheConfig.Builder#setMaxObjectSize(long)}
+   * Sets the maximum number of entries that will be held in memory.
    */
-  @Min(0)
-  @NotNull
-  @Builder.Default
-  private long maxObjectSize = DEFAULT_MAX_OBJECT_SIZE;
-
   @NotNull
+  @Min(0)
   @Builder.Default
-  private StorageType storageType = DEFAULT_STORAGE_TYPE;
+  private int maxEntries = DEFAULT_MAX_ENTRIES;
 
+  /**
+   * The path on disk to use for the cache, required when {@link StorageType#DISK} is specified.
+   */
   @Nullable
   private String storagePath;
 
+  /**
+   * The default expiry time for cache entries (in seconds), used when the server does not provide
+   * an expiry value.
+   */
+  @Min(0)
+  @NotNull
+  @Builder.Default
+  private int defaultExpiry = DEFAULT_EXPIRY;
+
   /**
    * Represents the type of storage used by the cache.
    */
diff --git a/terminology/src/main/java/au/csiro/pathling/fhir/DefaultTerminologyClient.java b/terminology/src/main/java/au/csiro/pathling/fhir/DefaultTerminologyClient.java
new file mode 100644
index 0000000000..4b2f1bb287
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/fhir/DefaultTerminologyClient.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.fhir;
+
+import ca.uhn.fhir.rest.client.api.IGenericClient;
+import ca.uhn.fhir.rest.gclient.IOperationUntypedWithInput;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import org.hl7.fhir.r4.model.BooleanType;
+import org.hl7.fhir.r4.model.CodeSystem;
+import org.hl7.fhir.r4.model.CodeType;
+import org.hl7.fhir.r4.model.ConceptMap;
+import org.hl7.fhir.r4.model.Parameters;
+import org.hl7.fhir.r4.model.StringType;
+import org.hl7.fhir.r4.model.UriType;
+import org.hl7.fhir.r4.model.ValueSet;
+
+/**
+ * An implementation of {@link TerminologyClient} that uses cacheable GET requests.
+ */
+class DefaultTerminologyClient implements TerminologyClient {
+
+  @Nonnull
+  final IGenericClient fhirClient;
+
+  DefaultTerminologyClient(@Nonnull final IGenericClient fhirClient) {
+    this.fhirClient = fhirClient;
+  }
+
+  @Nonnull
+  @Override
+  public Parameters validateCode(@Nonnull final UriType url, @Nonnull final UriType system,
+      @Nullable final StringType version, @Nonnull final CodeType code) {
+    return buildValidateCode(url, system, version, code).execute();
+  }
+
+  @Nonnull
+  @Override
+  public IOperationUntypedWithInput<Parameters> buildValidateCode(@Nonnull final UriType url,
+      @Nonnull final UriType system, @Nullable final StringType version,
+      @Nonnull final CodeType code) {
+    final Parameters params = new Parameters();
+    params.addParameter().setName("url").setValue(url);
+    params.addParameter().setName("system").setValue(system);
+    params.addParameter().setName("code").setValue(code);
+    if (version != null) {
+      params.addParameter().setName("systemVersion").setValue(version);
+    }
+
+    return fhirClient.operation()
+        .onType(ValueSet.class)
+        .named("$validate-code")
+        .withParameters(params)
+        .useHttpGet();
+  }
+
+  @Nonnull
+  @Override
+  public Parameters translate(@Nonnull final UriType url, @Nonnull final UriType system,
+      @Nullable final StringType version, @Nonnull final CodeType code,
+      @Nullable final BooleanType reverse, @Nullable final UriType target) {
+    return buildTranslate(url, system, version, code, reverse, target).execute();
+  }
+
+  @Nonnull
+  @Override
+  public IOperationUntypedWithInput<Parameters> buildTranslate(@Nonnull final UriType url,
+      @Nonnull final UriType system, @Nullable final StringType version,
+      @Nonnull final CodeType code, @Nullable final BooleanType reverse,
+      @Nullable final UriType target) {
+    final Parameters params = new Parameters();
+    params.addParameter().setName("url").setValue(url);
+    params.addParameter().setName("system").setValue(system);
+    params.addParameter().setName("code").setValue(code);
+    if (version != null) {
+      params.addParameter().setName("version").setValue(version);
+    }
+    if (reverse != null) {
+      params.addParameter().setName("reverse").setValue(reverse);
+    }
+    if (target != null) {
+      params.addParameter().setName("target").setValue(target);
+    }
+    return fhirClient.operation()
+        .onType(ConceptMap.class)
+        .named("$translate")
+        .withParameters(params)
+        .useHttpGet();
+  }
+
+  @Nonnull
+  @Override
+  public Parameters subsumes(@Nonnull final CodeType codeA, @Nonnull final CodeType codeB,
+      @Nonnull final UriType system, @Nullable final StringType version) {
+    return buildSubsumes(codeA, codeB, system, version).execute();
+  }
+
+  @Nonnull
+  @Override
+  public IOperationUntypedWithInput<Parameters> buildSubsumes(@Nonnull final CodeType codeA,
+      @Nonnull final CodeType codeB, @Nonnull final UriType system,
+      @Nullable final StringType version) {
+    final Parameters params = new Parameters();
+    params.addParameter().setName("codeA").setValue(codeA);
+    params.addParameter().setName("codeB").setValue(codeB);
+    params.addParameter().setName("system").setValue(system);
+    if (version != null) {
+      params.addParameter().setName("version").setValue(version);
+    }
+    return fhirClient.operation()
+        .onType(CodeSystem.class)
+        .named("$subsumes")
+        .withParameters(params)
+        .useHttpGet();
+  }
+
+  @Nonnull
+  @Override
+  public Parameters lookup(@Nonnull final UriType system,
+      @Nullable final StringType version, @Nonnull final CodeType code,
+      @Nullable final CodeType property) {
+    return buildLookup(system, version, code, property).execute();
+  }
+
+  @Nonnull
+  @Override
+  public IOperationUntypedWithInput<Parameters> buildLookup(@Nonnull final UriType system,
+      @Nullable final StringType version,
+      @Nonnull final CodeType code, @Nullable final CodeType property) {
+    final Parameters params = new Parameters();
+    params.addParameter().setName("system").setValue(system);
+    params.addParameter().setName("code").setValue(code);
+    if (version != null) {
+      params.addParameter().setName("version").setValue(version);
+    }
+    if (property != null) {
+      params.addParameter().setName("property").setValue(property);
+    }
+    return fhirClient.operation()
+        .onType(CodeSystem.class)
+        .named("$lookup")
+        .withParameters(params)
+        .useHttpGet();
+  }
+
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/fhir/ParametersUtils.java b/terminology/src/main/java/au/csiro/pathling/fhir/ParametersUtils.java
index b8c4594e84..be2f1d1df8 100644
--- a/terminology/src/main/java/au/csiro/pathling/fhir/ParametersUtils.java
+++ b/terminology/src/main/java/au/csiro/pathling/fhir/ParametersUtils.java
@@ -56,7 +56,7 @@ private static void setProperty(@Nonnull final Object bean, @Nonnull final Strin
       final PropertyDescriptor descriptor = PropertyUtils.getPropertyDescriptor(bean,
           name);
       if (descriptor != null) {
-        Object currentValue = descriptor.getReadMethod().invoke(bean);
+        final Object currentValue = descriptor.getReadMethod().invoke(bean);
         if (currentValue == null) {
           if (List.class.isAssignableFrom(descriptor.getPropertyType())) {
             final List<Object> newList = new ArrayList<>();
@@ -91,7 +91,7 @@ private static void setProperty(@Nonnull final Object bean, @Nonnull final Strin
   public static <T> T partsToBean(@Nonnull final ParametersParameterComponent component,
       @Nonnull final Supplier<T> supplier) {
     final T result = supplier.get();
-    for (ParametersParameterComponent p : component.getPart()) {
+    for (final ParametersParameterComponent p : component.getPart()) {
       if (p.hasValue()) {
         setProperty(result, p.getName(), p.getValue());
       } else if (p.hasPart()) {
@@ -161,6 +161,9 @@ public static Stream<MatchPart> toMatchParts(final @Nonnull Parameters parameter
 
   /**
    * Object representation of the 'property' part from $lookup results.
+   * <p>
+   * The fields of this class must match the names of the properties in the response to the lookup
+   * operation.
    */
   @Data
   @NoArgsConstructor
@@ -173,6 +176,7 @@ public static class PropertyPart {
     @Nullable
     Type value;
 
+    @SuppressWarnings("SpellCheckingInspection")
     @Nullable
     List<PropertyPart> subproperty;
   }
diff --git a/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient2.java b/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient.java
similarity index 53%
rename from terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient2.java
rename to terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient.java
index 97a66b2dd2..1eb5fa9202 100644
--- a/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient2.java
+++ b/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient.java
@@ -27,13 +27,13 @@
 import ca.uhn.fhir.rest.client.api.IRestfulClientFactory;
 import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum;
 import ca.uhn.fhir.rest.client.interceptor.LoggingInterceptor;
+import ca.uhn.fhir.rest.gclient.IOperationUntypedWithInput;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 import org.apache.http.client.HttpClient;
 import org.hl7.fhir.r4.model.BooleanType;
 import org.hl7.fhir.r4.model.CodeSystem;
 import org.hl7.fhir.r4.model.CodeType;
-import org.hl7.fhir.r4.model.ConceptMap;
 import org.hl7.fhir.r4.model.Parameters;
 import org.hl7.fhir.r4.model.StringType;
 import org.hl7.fhir.r4.model.UriType;
@@ -44,13 +44,18 @@
 /**
  * The client interface to FHIR terminology operations.
  */
-public interface TerminologyClient2 {
+public interface TerminologyClient {
 
-  Logger log = LoggerFactory.getLogger(TerminologyClient2.class);
+  Logger log = LoggerFactory.getLogger(TerminologyClient.class);
 
   /**
+   * @param url the URL of the value set to validate against
+   * @param system the system of the code to validate
+   * @param version the version of the code system to validate against
+   * @param code the code to validate
+   * @return a {@link Parameters} resource
    * @see <a
-   * href="https://www.hl7.org/fhir/R4/operation-codesystem-validate-code.html">CodeSystem/$validate-code</a>
+   * href="https://www.hl7.org/fhir/R4/valueset-operation-validate-code.html">ValueSet/$validate-code</a>
    */
   @Operation(name = "$validate-code", type = ValueSet.class, idempotent = true)
   @Nonnull
@@ -62,6 +67,28 @@ Parameters validateCode(
   );
 
   /**
+   * Builds a validate code operation that can be customized and executed later.
+   *
+   * @param url the URL of the value set to validate against
+   * @param system the system of the code to validate
+   * @param version the version of the code system to validate against
+   * @param code the code to validate
+   * @return an {@link IOperationUntypedWithInput} that can be customized and executed later
+   * @see <a
+   * href="https://www.hl7.org/fhir/R4/valueset-operation-validate-code.html">ValueSet/$validate-code</a>
+   */
+  IOperationUntypedWithInput<Parameters> buildValidateCode(@Nonnull UriType url,
+      @Nonnull UriType system,
+      @Nullable StringType version, @Nonnull CodeType code);
+
+  /**
+   * @param url the URL of the concept map to use for translation
+   * @param system the system of the code to translate
+   * @param version the version of the code system to translate from
+   * @param code the code to translate
+   * @param reverse if true, the translation will be reversed
+   * @param target the URL of the value set within which the translation is sought
+   * @return a {@link Parameters} resource
    * @see <a
    * href="https://www.hl7.org/fhir/R4/operation-conceptmap-translate.html">ConceptMap/$translate</a>
    */
@@ -73,10 +100,34 @@ Parameters translate(
       @Nullable @OperationParam(name = "version") StringType version,
       @Nonnull @OperationParam(name = "code") CodeType code,
       @Nullable @OperationParam(name = "reverse") BooleanType reverse,
-      @Nullable @OperationParam(name = "system") UriType target
+      @Nullable @OperationParam(name = "target") UriType target
   );
 
   /**
+   * Builds a translate operation that can be customized and executed later.
+   *
+   * @param url the URL of the concept map to use for translation
+   * @param system the system of the code to translate
+   * @param version the version of the code system to translate from
+   * @param code the code to translate
+   * @param reverse if true, the translation will be reversed
+   * @param target the URL of the value set within which the translation is sought
+   * @return an {@link IOperationUntypedWithInput} that can be customized and executed later
+   * @see <a
+   * href="https://www.hl7.org/fhir/R4/operation-conceptmap-translate.html">ConceptMap/$translate</a>
+   */
+  @Nonnull
+  IOperationUntypedWithInput<Parameters> buildTranslate(@Nonnull UriType url,
+      @Nonnull UriType system,
+      @Nullable StringType version, @Nonnull CodeType code, @Nullable BooleanType reverse,
+      @Nullable UriType target);
+
+  /**
+   * @param codeA the code that will be tested to check if it subsumes codeB
+   * @param codeB the code that will be tested to check if it is subsumed by codeA
+   * @param system the system of the codes being tested
+   * @param version the version of the code system that the codes are from
+   * @return a {@link Parameters} resource
    * @see <a
    * href="https://www.hl7.org/fhir/R4/codesystem-operation-subsumes.html">CodeSystem/$subsumes</a>
    */
@@ -90,7 +141,28 @@ Parameters subsumes(
   );
 
   /**
-   * See: <a href="https://www.hl7.org/fhir/R4/codesystem-operation-lookup.html">CodeSystem/$lookup</a>
+   * Builds a subsumes operation that can be customized and executed later.
+   *
+   * @param codeA the code that will be tested to check if it subsumes codeB
+   * @param codeB the code that will be tested to check if it is subsumed by codeA
+   * @param system the system of the codes being tested
+   * @param version the version of the code system that the codes are from
+   * @return an {@link IOperationUntypedWithInput} that can be customized and executed later
+   * @see <a
+   * href="https://www.hl7.org/fhir/R4/codesystem-operation-subsumes.html">CodeSystem/$subsumes</a>
+   */
+  @Nonnull
+  IOperationUntypedWithInput<Parameters> buildSubsumes(@Nonnull CodeType codeA,
+      @Nonnull CodeType codeB, @Nonnull UriType system, @Nullable StringType version);
+
+  /**
+   * @param system the system of the code
+   * @param version the version of the code system
+   * @param code the code to lookup
+   * @param property the property or properties to be returned in the response
+   * @return a {@link Parameters} resource
+   * @see <a
+   * href="https://www.hl7.org/fhir/R4/codesystem-operation-lookup.html">CodeSystem/$lookup</a>
    */
   @Operation(name = "$lookup", type = CodeSystem.class, idempotent = true)
   @Nonnull
@@ -101,6 +173,20 @@ Parameters lookup(
       @Nullable @OperationParam(name = "property") CodeType property
   );
 
+  /**
+   * Builds a lookup operation that can be customized and executed later.
+   *
+   * @param system the system of the code
+   * @param version the version of the code system
+   * @param code the code to lookup
+   * @param property the property or properties to be returned in the response
+   * @return an {@link IOperationUntypedWithInput} that can be customized and executed later
+   * @see <a
+   * href="https://www.hl7.org/fhir/R4/codesystem-operation-lookup.html">CodeSystem/$lookup</a>
+   */
+  @Nonnull
+  IOperationUntypedWithInput<Parameters> buildLookup(@Nonnull UriType system,
+      @Nullable StringType version, @Nonnull CodeType code, @Nullable CodeType property);
 
   /**
    * Builds a new terminology client.
@@ -111,10 +197,9 @@ Parameters lookup(
    * responses.
    * @param authConfig the authentication to use for the server.
    * @param httpClient the {@link HttpClient} instance to use for HTTP request.
-   * @return the new instance of {@link TerminologyClient2}.
+   * @return the new instance of {@link TerminologyClient}.
    */
-
-  static TerminologyClient2 build(@Nonnull final FhirContext fhirContext,
+  static TerminologyClient build(@Nonnull final FhirContext fhirContext,
       @Nonnull final String terminologyServerUrl,
       final boolean verboseRequestLogging, @Nonnull final TerminologyAuthConfiguration authConfig,
       @Nonnull final HttpClient httpClient) {
@@ -148,105 +233,7 @@ static TerminologyClient2 build(@Nonnull final FhirContext fhirContext,
           authConfig.getScope(), authConfig.getTokenExpiryTolerance());
       genericClient.registerInterceptor(clientAuthInterceptor);
     }
-    return new TerminologyClient2Impl(genericClient);
-  }
-}
-
-class TerminologyClient2Impl implements TerminologyClient2 {
-
-  @Nonnull
-  final IGenericClient fhirClient;
-
-  TerminologyClient2Impl(@Nonnull final IGenericClient fhirClient) {
-    this.fhirClient = fhirClient;
-  }
-
-  @Nonnull
-  @Override
-  public Parameters validateCode(@Nonnull final UriType url, @Nonnull final UriType system,
-      @Nullable final StringType version, @Nonnull final CodeType code) {
-    final Parameters params = new Parameters();
-    params.addParameter().setName("url").setValue(url);
-    params.addParameter().setName("system").setValue(system);
-    params.addParameter().setName("code").setValue(code);
-    if (version != null) {
-      params.addParameter().setName("systemVersion").setValue(version);
-    }
-
-    return fhirClient.operation()
-        .onType(ValueSet.class)
-        .named("$validate-code")
-        .withParameters(params)
-        .useHttpGet()
-        .execute();
-  }
-
-  @Nonnull
-  @Override
-  public Parameters translate(@Nonnull final UriType url, @Nonnull final UriType system,
-      @Nullable final StringType version, @Nonnull final CodeType code,
-      @Nullable final BooleanType reverse,
-      @Nullable final UriType target) {
-    final Parameters params = new Parameters();
-    params.addParameter().setName("url").setValue(url);
-    params.addParameter().setName("system").setValue(system);
-    params.addParameter().setName("code").setValue(code);
-    if (version != null) {
-      params.addParameter().setName("version").setValue(version);
-    }
-    if (reverse != null) {
-      params.addParameter().setName("reverse").setValue(reverse);
-    }
-    if (target != null) {
-      params.addParameter().setName("target").setValue(target);
-    }
-    return fhirClient.operation()
-        .onType(ConceptMap.class)
-        .named("$translate")
-        .withParameters(params)
-        .useHttpGet()
-        .execute();
-  }
-
-  @Nonnull
-  @Override
-  public Parameters subsumes(@Nonnull final CodeType codeA, @Nonnull final CodeType codeB,
-      @Nonnull final UriType system, @Nullable final StringType version) {
-    final Parameters params = new Parameters();
-    params.addParameter().setName("codeA").setValue(codeA);
-    params.addParameter().setName("codeB").setValue(codeB);
-    params.addParameter().setName("system").setValue(system);
-    if (version != null) {
-      params.addParameter().setName("version").setValue(version);
-    }
-    return fhirClient.operation()
-        .onType(CodeSystem.class)
-        .named("$subsumes")
-        .withParameters(params)
-        .useHttpGet()
-        .execute();
-  }
-
-  @Nonnull
-  @Override
-  public Parameters lookup(@Nonnull final UriType system,
-      @Nullable final StringType version, @Nonnull final CodeType code,
-      @Nullable final CodeType property) {
-    final Parameters params = new Parameters();
-    params.addParameter().setName("system").setValue(system);
-    params.addParameter().setName("code").setValue(code);
-    if (version != null) {
-      params.addParameter().setName("version").setValue(version);
-    }
-    if (property != null) {
-      params.addParameter().setName("property").setValue(property);
-    }
-    return fhirClient.operation()
-        .onType(CodeSystem.class)
-        .named("$lookup")
-        .withParameters(params)
-        .useHttpGet()
-        .execute();
+    return new DefaultTerminologyClient(genericClient);
   }
 
 }
diff --git a/terminology/src/main/java/au/csiro/pathling/fhirpath/CodingHelpers.java b/terminology/src/main/java/au/csiro/pathling/fhirpath/CodingHelpers.java
index bedac4472c..18479a74d7 100644
--- a/terminology/src/main/java/au/csiro/pathling/fhirpath/CodingHelpers.java
+++ b/terminology/src/main/java/au/csiro/pathling/fhirpath/CodingHelpers.java
@@ -1,7 +1,7 @@
 package au.csiro.pathling.fhirpath;
 
-import org.hl7.fhir.r4.model.Coding;
 import javax.annotation.Nullable;
+import org.hl7.fhir.r4.model.Coding;
 
 /**
  * Helper functions for codings.
diff --git a/terminology/src/main/java/au/csiro/pathling/fhirpath/encoding/CodingEncoding.java b/terminology/src/main/java/au/csiro/pathling/fhirpath/encoding/CodingEncoding.java
index 2ce04bef99..1376479c6e 100644
--- a/terminology/src/main/java/au/csiro/pathling/fhirpath/encoding/CodingEncoding.java
+++ b/terminology/src/main/java/au/csiro/pathling/fhirpath/encoding/CodingEncoding.java
@@ -17,6 +17,10 @@
 
 package au.csiro.pathling.fhirpath.encoding;
 
+import static java.util.Objects.isNull;
+import static java.util.Objects.nonNull;
+import static org.apache.spark.sql.functions.lit;
+
 import java.util.List;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
@@ -31,11 +35,6 @@
 import org.apache.spark.sql.types.StructType;
 import org.hl7.fhir.r4.model.Coding;
 
-import static java.util.Objects.isNull;
-import static java.util.Objects.nonNull;
-import static org.apache.spark.sql.functions.lit;
-import static org.apache.spark.sql.functions.when;
-
 
 /**
  * Object decoders/encoders for {@link Coding} and collections of it.
diff --git a/terminology/src/main/java/au/csiro/pathling/fhirpath/encoding/ImmutableCoding.java b/terminology/src/main/java/au/csiro/pathling/fhirpath/encoding/ImmutableCoding.java
index d1fe6bd2b2..16abdd3d92 100644
--- a/terminology/src/main/java/au/csiro/pathling/fhirpath/encoding/ImmutableCoding.java
+++ b/terminology/src/main/java/au/csiro/pathling/fhirpath/encoding/ImmutableCoding.java
@@ -18,6 +18,7 @@
 package au.csiro.pathling.fhirpath.encoding;
 
 import java.io.Serializable;
+import java.util.Objects;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 import lombok.AllArgsConstructor;
@@ -62,7 +63,7 @@ public Coding toCoding() {
         ? new BooleanType(userSelected)
         : null);
   }
-  
+
   /**
    * Conversion from a fhir Coding.
    *
@@ -101,5 +102,22 @@ public static ImmutableCoding empty() {
     return ImmutableCoding.of(null, null, null, null, null);
   }
 
+  @Override
+  public boolean equals(final Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    final ImmutableCoding that = (ImmutableCoding) o;
+    return Objects.equals(system, that.system) && Objects.equals(version,
+        that.version) && Objects.equals(code, that.code);
+  }
 
+  @Override
+  public int hashCode() {
+    return Objects.hash(system, version, code);
+  }
+  
 }
diff --git a/terminology/src/main/java/au/csiro/pathling/sql/Terminology.java b/terminology/src/main/java/au/csiro/pathling/sql/Terminology.java
index 1dbb8e910b..df6f75e7e0 100644
--- a/terminology/src/main/java/au/csiro/pathling/sql/Terminology.java
+++ b/terminology/src/main/java/au/csiro/pathling/sql/Terminology.java
@@ -57,6 +57,10 @@ static Column member_of(@Nonnull final Column coding, @Nonnull final Column valu
     return call_udf(MemberOfUdf.FUNCTION_NAME, coding, valueSetUrl);
   }
 
+  // TODO: consider the order of target and equivalences
+  // TODO: consider other forms of passing equivalences (i.e collection of enum types)
+  // TODO: add overloaded methods for default arguments.
+
   /**
    * Takes a Coding or an array of Codings column as its input.  Returns the Column which contains
    * an array of Coding value with translation targets from the specified FHIR ConceptMap. There may
@@ -231,4 +235,3 @@ static Column designation(@Nonnull final Column coding) {
     return designation(coding, null);
   }
 }
-
diff --git a/terminology/src/main/java/au/csiro/pathling/sql/udf/DesignationUdf.java b/terminology/src/main/java/au/csiro/pathling/sql/udf/DesignationUdf.java
index f34b624e8f..810869abda 100644
--- a/terminology/src/main/java/au/csiro/pathling/sql/udf/DesignationUdf.java
+++ b/terminology/src/main/java/au/csiro/pathling/sql/udf/DesignationUdf.java
@@ -6,8 +6,8 @@
 import static java.util.Objects.isNull;
 import static java.util.Objects.nonNull;
 
-import au.csiro.pathling.terminology.TerminologyService2;
-import au.csiro.pathling.terminology.TerminologyService2.Designation;
+import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyService.Designation;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
@@ -58,7 +58,7 @@ protected String[] doCall(@Nullable final Coding coding,
     if (!isValidCoding(coding) || (nonNull(use) && !isValidCoding(use))) {
       return EMPTY_RESULT;
     }
-    final TerminologyService2 terminologyService = terminologyServiceFactory.buildService2();
+    final TerminologyService terminologyService = terminologyServiceFactory.build();
     return terminologyService.lookup(coding, DESIGNATION_PROPERTY_CODE).stream()
         .filter(Designation.class::isInstance)
         .map(Designation.class::cast)
diff --git a/terminology/src/main/java/au/csiro/pathling/sql/udf/DisplayUdf.java b/terminology/src/main/java/au/csiro/pathling/sql/udf/DisplayUdf.java
index a993651f44..2c20545c61 100644
--- a/terminology/src/main/java/au/csiro/pathling/sql/udf/DisplayUdf.java
+++ b/terminology/src/main/java/au/csiro/pathling/sql/udf/DisplayUdf.java
@@ -2,11 +2,10 @@
 
 import static au.csiro.pathling.fhirpath.encoding.CodingEncoding.decode;
 import static au.csiro.pathling.sql.udf.TerminologyUdfHelpers.isValidCoding;
-import static java.util.Objects.nonNull;
 
-import au.csiro.pathling.terminology.TerminologyService2;
-import au.csiro.pathling.terminology.TerminologyService2.Property;
-import au.csiro.pathling.terminology.TerminologyService2.PropertyOrDesignation;
+import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyService.Property;
+import au.csiro.pathling.terminology.TerminologyService.PropertyOrDesignation;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import java.util.List;
 import java.util.Optional;
@@ -53,7 +52,7 @@ protected String doCall(@Nullable final Coding coding) {
     if (coding == null || !isValidCoding(coding)) {
       return null;
     }
-    final TerminologyService2 terminologyService = terminologyServiceFactory.buildService2();
+    final TerminologyService terminologyService = terminologyServiceFactory.build();
     final List<PropertyOrDesignation> result = terminologyService.lookup(
         coding, DISPLAY_PROPERTY_CODE);
 
diff --git a/terminology/src/main/java/au/csiro/pathling/sql/udf/MemberOfUdf.java b/terminology/src/main/java/au/csiro/pathling/sql/udf/MemberOfUdf.java
index 317a6c6848..92546e4009 100644
--- a/terminology/src/main/java/au/csiro/pathling/sql/udf/MemberOfUdf.java
+++ b/terminology/src/main/java/au/csiro/pathling/sql/udf/MemberOfUdf.java
@@ -20,7 +20,7 @@
 import static au.csiro.pathling.sql.udf.TerminologyUdfHelpers.decodeOneOrMany;
 import static au.csiro.pathling.sql.udf.TerminologyUdfHelpers.validCodings;
 
-import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import java.util.stream.Stream;
 import javax.annotation.Nonnull;
@@ -69,7 +69,7 @@ protected Boolean doCall(@Nullable final Stream<Coding> codings, @Nullable final
     if (url == null || codings == null) {
       return null;
     }
-    final TerminologyService2 terminologyService = terminologyServiceFactory.buildService2();
+    final TerminologyService terminologyService = terminologyServiceFactory.build();
     return validCodings(codings)
         .anyMatch(coding -> terminologyService.validateCode(url, coding));
   }
diff --git a/terminology/src/main/java/au/csiro/pathling/sql/udf/PropertyUdf.java b/terminology/src/main/java/au/csiro/pathling/sql/udf/PropertyUdf.java
index b97b852be4..673b5ed16e 100644
--- a/terminology/src/main/java/au/csiro/pathling/sql/udf/PropertyUdf.java
+++ b/terminology/src/main/java/au/csiro/pathling/sql/udf/PropertyUdf.java
@@ -15,9 +15,9 @@
 import au.csiro.pathling.encoders.datatypes.DecimalCustomCoder;
 import au.csiro.pathling.errors.InvalidUserInputError;
 import au.csiro.pathling.fhirpath.encoding.CodingEncoding;
-import au.csiro.pathling.terminology.TerminologyService2;
-import au.csiro.pathling.terminology.TerminologyService2.Property;
-import au.csiro.pathling.terminology.TerminologyService2.PropertyOrDesignation;
+import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyService.Property;
+import au.csiro.pathling.terminology.TerminologyService.PropertyOrDesignation;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import ca.uhn.fhir.model.api.annotation.DatatypeDef;
 import com.google.common.collect.ImmutableSet;
@@ -124,7 +124,7 @@ protected Object[] doCall(@Nullable final Coding coding, @Nullable final String
     if (propertyCode == null || !isValidCoding(coding)) {
       return null;
     }
-    final TerminologyService2 terminologyService = terminologyServiceFactory.buildService2();
+    final TerminologyService terminologyService = terminologyServiceFactory.build();
     final List<PropertyOrDesignation> result = terminologyService.lookup(
         requireNonNull(coding), propertyCode);
 
@@ -139,7 +139,7 @@ protected Object[] doCall(@Nullable final Coding coding, @Nullable final String
   }
 
   @Nullable
-  private Object[] encodeArray(@Nullable Object[] objectRows) {
+  private Object[] encodeArray(@Nullable final Object[] objectRows) {
     if (nonNull(objectRows) && CODING.equals(propertyType)) {
       return Stream.of(objectRows)
           .map(Coding.class::cast)
@@ -195,7 +195,7 @@ public static PropertyUdf forClass(
    * @return the name of the UDF.
    */
   @Nonnull
-  public static String getNameForType(FHIRDefinedType propertyType) {
+  public static String getNameForType(final FHIRDefinedType propertyType) {
     if (!ALLOWED_FHIR_TYPES.contains(propertyType)) {
       throw new InvalidUserInputError(
           String.format("Type: '%s' is not supported for 'property' udf", propertyType.toCode()));
diff --git a/terminology/src/main/java/au/csiro/pathling/sql/udf/SubsumesUdf.java b/terminology/src/main/java/au/csiro/pathling/sql/udf/SubsumesUdf.java
index c0140407f0..9d77425943 100644
--- a/terminology/src/main/java/au/csiro/pathling/sql/udf/SubsumesUdf.java
+++ b/terminology/src/main/java/au/csiro/pathling/sql/udf/SubsumesUdf.java
@@ -23,7 +23,7 @@
 import static org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome.SUBSUMEDBY;
 import static org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome.SUBSUMES;
 
-import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import java.util.List;
 import java.util.stream.Collectors;
@@ -78,7 +78,7 @@ protected Boolean doCall(@Nullable final Stream<Coding> codingsA,
                                      ? inverted
                                      : PARAM_INVERTED_DEFAULT;
 
-    final TerminologyService2 terminologyService = terminologyServiceFactory.buildService2();
+    final TerminologyService terminologyService = terminologyServiceFactory.build();
 
     // does any of the input codings subsume any of the output codings (within the same system)
     final List<Coding> validCodingsB = validCodings(codingsB)
diff --git a/terminology/src/main/java/au/csiro/pathling/sql/udf/TerminologyUdfRegistrar.java b/terminology/src/main/java/au/csiro/pathling/sql/udf/TerminologyUdfRegistrar.java
index aef36ab2d3..33f4c3d03f 100644
--- a/terminology/src/main/java/au/csiro/pathling/sql/udf/TerminologyUdfRegistrar.java
+++ b/terminology/src/main/java/au/csiro/pathling/sql/udf/TerminologyUdfRegistrar.java
@@ -18,22 +18,18 @@
 package au.csiro.pathling.sql.udf;
 
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
+import com.google.common.collect.ImmutableList;
 import java.util.Collections;
 import java.util.List;
 import javax.annotation.Nonnull;
-import com.google.common.collect.ImmutableList;
 import org.apache.spark.sql.SparkSession;
-import org.hl7.fhir.r4.model.CodeType;
-import org.hl7.fhir.r4.model.Coding;
-import org.hl7.fhir.r4.model.IntegerType;
-import org.hl7.fhir.r4.model.StringType;
 
 /**
  * The {@link SqlFunctionRegistrar} for terminology UDFs.
  */
 public class TerminologyUdfRegistrar extends SqlFunctionRegistrar {
 
-  public TerminologyUdfRegistrar(@Nonnull TerminologyServiceFactory tsf) {
+  public TerminologyUdfRegistrar(@Nonnull final TerminologyServiceFactory tsf) {
     super(List.of(
             new DisplayUdf(tsf)),
         ImmutableList.<SqlFunction2<?, ?, ?>>builder()
diff --git a/terminology/src/main/java/au/csiro/pathling/sql/udf/TranslateUdf.java b/terminology/src/main/java/au/csiro/pathling/sql/udf/TranslateUdf.java
index 8f49aea105..6377c0e64a 100644
--- a/terminology/src/main/java/au/csiro/pathling/sql/udf/TranslateUdf.java
+++ b/terminology/src/main/java/au/csiro/pathling/sql/udf/TranslateUdf.java
@@ -9,9 +9,16 @@
 import au.csiro.pathling.errors.InvalidUserInputError;
 import au.csiro.pathling.fhirpath.encoding.CodingEncoding;
 import au.csiro.pathling.fhirpath.encoding.ImmutableCoding;
-import au.csiro.pathling.terminology.TerminologyService2;
-import au.csiro.pathling.terminology.TerminologyService2.Translation;
+import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyService.Translation;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
+import au.csiro.pathling.utilities.Strings;
+import com.google.common.collect.ImmutableSet;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
 import com.google.common.collect.ImmutableSet;
 import java.util.Objects;
 import java.util.Set;
@@ -100,7 +107,7 @@ protected Stream<Coding> doCall(@Nullable final Stream<Coding> codings,
       return Stream.empty();
     }
 
-    final TerminologyService2 terminologyService = terminologyServiceFactory.buildService2();
+    final TerminologyService terminologyService = terminologyServiceFactory.build();
     return validCodings(codings)
         .flatMap(coding ->
             terminologyService.translate(coding, conceptMapUri, resolvedReverse, target).stream())
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/BaseTerminologyService.java b/terminology/src/main/java/au/csiro/pathling/terminology/BaseTerminologyService.java
new file mode 100644
index 0000000000..f6b513f874
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/BaseTerminologyService.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.terminology;
+
+import static java.util.Objects.nonNull;
+
+import au.csiro.pathling.fhir.TerminologyClient;
+import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
+import java.io.Closeable;
+import java.io.IOException;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Common functionality for all implementations of {@link TerminologyService}.
+ *
+ * @author John Grimes
+ */
+public abstract class BaseTerminologyService implements TerminologyService, Closeable {
+
+  @Nonnull
+  protected final TerminologyClient terminologyClient;
+
+  @Nullable
+  protected final Closeable toClose;
+
+  public BaseTerminologyService(@Nonnull final TerminologyClient terminologyClient,
+      @Nullable final Closeable toClose) {
+    this.terminologyClient = terminologyClient;
+    this.toClose = toClose;
+  }
+
+  /**
+   * This method allows us to be tolerant to invalid inputs to terminology operations, which produce
+   * 400-series errors from the terminology server. A result can be provided which will be returned
+   * in the event of such an error.
+   *
+   * @param <ResultType> the type of the result
+   * @param e the exception that was thrown
+   * @param invalidInputReturnValue the value to return in the event of an invalid input
+   * @return the result, which may be the fallback
+   */
+  @Nullable
+  public static <ResultType> ResultType handleError(@Nonnull final BaseServerResponseException e,
+      @Nullable final ResultType invalidInputReturnValue) {
+    if (e.getStatusCode() / 100 == 4) {
+      return invalidInputReturnValue;
+    } else {
+      throw e;
+    }
+  }
+
+  @Override
+  public void close() throws IOException {
+    if (nonNull(toClose)) {
+      toClose.close();
+    }
+  }
+
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService.java b/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService.java
new file mode 100644
index 0000000000..2701401340
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService.java
@@ -0,0 +1,97 @@
+package au.csiro.pathling.terminology;
+
+import static java.util.Objects.requireNonNull;
+
+import au.csiro.pathling.fhir.TerminologyClient;
+import au.csiro.pathling.fhirpath.encoding.ImmutableCoding;
+import au.csiro.pathling.terminology.lookup.LookupExecutor;
+import au.csiro.pathling.terminology.lookup.LookupParameters;
+import au.csiro.pathling.terminology.subsumes.SubsumesExecutor;
+import au.csiro.pathling.terminology.subsumes.SubsumesParameters;
+import au.csiro.pathling.terminology.translate.TranslateExecutor;
+import au.csiro.pathling.terminology.translate.TranslateParameters;
+import au.csiro.pathling.terminology.validatecode.ValidateCodeExecutor;
+import au.csiro.pathling.terminology.validatecode.ValidateCodeParameters;
+import ca.uhn.fhir.rest.gclient.IOperationUntypedWithInput;
+import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
+import java.io.Closeable;
+import java.util.List;
+import java.util.Optional;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import org.hl7.fhir.r4.model.Coding;
+import org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome;
+
+/**
+ * The default implementation of the TerminologyServiceFactory accessing the REST interface of FHIR
+ * compliant terminology server.
+ *
+ * @author Piotr Szul
+ * @author John Grimes
+ */
+public class DefaultTerminologyService extends BaseTerminologyService {
+
+  public DefaultTerminologyService(@Nonnull final TerminologyClient terminologyClient,
+      @Nullable final Closeable toClose) {
+    super(terminologyClient, toClose);
+  }
+
+  @Override
+  public boolean validateCode(@Nonnull final String valueSetUrl, @Nonnull final Coding coding) {
+    final ValidateCodeParameters parameters = new ValidateCodeParameters(valueSetUrl,
+        ImmutableCoding.of(coding));
+    final ValidateCodeExecutor executor = new ValidateCodeExecutor(terminologyClient, parameters);
+    return Boolean.TRUE.equals(execute(executor));
+  }
+
+  @Nonnull
+  @Override
+  public List<Translation> translate(@Nonnull final Coding coding,
+      @Nonnull final String conceptMapUrl, final boolean reverse, @Nullable final String target) {
+    final TranslateParameters parameters = new TranslateParameters(ImmutableCoding.of(coding),
+        conceptMapUrl, reverse, target);
+    final TranslateExecutor executor = new TranslateExecutor(terminologyClient, parameters);
+    return requireNonNull(execute(executor));
+  }
+
+  @Nonnull
+  @Override
+  public ConceptSubsumptionOutcome subsumes(@Nonnull final Coding codingA,
+      @Nonnull final Coding codingB) {
+    final SubsumesParameters parameters = new SubsumesParameters(
+        ImmutableCoding.of(codingA), ImmutableCoding.of(codingB));
+    final SubsumesExecutor executor = new SubsumesExecutor(terminologyClient, parameters);
+    return requireNonNull(execute(executor));
+  }
+
+  @Nonnull
+  @Override
+  public List<PropertyOrDesignation> lookup(@Nonnull final Coding coding,
+      @Nullable final String property) {
+    final LookupParameters parameters = new LookupParameters(ImmutableCoding.of(coding), property);
+    final LookupExecutor executor = new LookupExecutor(terminologyClient, parameters);
+    return requireNonNull(execute(executor));
+  }
+
+
+  @Nullable
+  private static <ResponseType, ResultType> ResultType execute(
+      @Nonnull final TerminologyOperation<ResponseType, ResultType> operation) {
+    final Optional<ResultType> invalidResult = operation.validate();
+    if (invalidResult.isPresent()) {
+      return invalidResult.get();
+    }
+
+    try {
+      final IOperationUntypedWithInput<ResponseType> request = operation.buildRequest();
+      final ResponseType response = request.execute();
+      return operation.extractResult(response);
+
+    } catch (final BaseServerResponseException e) {
+      // If the terminology server rejects the request as invalid, use false as the result.
+      final ResultType fallback = operation.invalidRequestFallback();
+      return handleError(e, fallback);
+    }
+  }
+
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService2.java b/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService2.java
deleted file mode 100644
index 6c028ce5ee..0000000000
--- a/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService2.java
+++ /dev/null
@@ -1,231 +0,0 @@
-package au.csiro.pathling.terminology;
-
-import static au.csiro.pathling.fhir.ParametersUtils.toBooleanResult;
-import static au.csiro.pathling.fhir.ParametersUtils.toMatchParts;
-import static au.csiro.pathling.fhir.ParametersUtils.toSubsumptionOutcome;
-import static au.csiro.pathling.fhirpath.CodingHelpers.codingEquals;
-import static java.util.Objects.isNull;
-import static java.util.Objects.nonNull;
-import static java.util.Objects.requireNonNull;
-import static org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome.NOTSUBSUMED;
-import static org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome.SUBSUMES;
-
-import au.csiro.pathling.fhir.ParametersUtils;
-import au.csiro.pathling.fhir.ParametersUtils.PropertyPart;
-import au.csiro.pathling.fhir.TerminologyClient2;
-import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
-import java.io.Closeable;
-import java.io.IOException;
-import java.util.Collections;
-import java.util.List;
-import java.util.Objects;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
-import org.hl7.fhir.r4.model.BooleanType;
-import org.hl7.fhir.r4.model.CodeType;
-import org.hl7.fhir.r4.model.Coding;
-import org.hl7.fhir.r4.model.Parameters;
-import org.hl7.fhir.r4.model.StringType;
-import org.hl7.fhir.r4.model.UriType;
-import org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence;
-import org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome;
-import org.hl7.fhir.r4.utils.CodingUtilities;
-
-/**
- * The default implementation of the TerminologyServiceFactory2 accessing the REST interface of FHIR
- * compliant terminology server.
- */
-public class DefaultTerminologyService2 implements TerminologyService2, Closeable {
-
-  @Nonnull
-  private final TerminologyClient2 terminologyClient;
-
-  @Nullable
-  private final Closeable toClose;
-
-  public DefaultTerminologyService2(@Nonnull final TerminologyClient2 terminologyClient,
-      @Nullable final Closeable toClose) {
-    this.terminologyClient = terminologyClient;
-    this.toClose = toClose;
-  }
-
-  @Nullable
-  private static <T> T optional(@Nonnull final Function<String, T> converter,
-      @Nullable final String value) {
-    return value != null
-           ? converter.apply(value)
-           : null;
-  }
-
-  @Nonnull
-  private static <T> T required(@Nonnull final Function<String, T> converter,
-      @Nullable final String value) {
-    return converter.apply(Objects.requireNonNull(value));
-  }
-
-  @Nonnull
-  private static List<Translation> toTranslations(final @Nonnull Parameters parameters) {
-    return toMatchParts(parameters)
-        .map(tp -> Translation.of(ConceptMapEquivalence.fromCode(tp.getEquivalence().getCode()),
-            tp.getConcept()))
-        .collect(Collectors.toUnmodifiableList());
-  }
-
-
-  @Override
-  public boolean validateCode(@Nonnull final String codeSystemUrl, @Nonnull final Coding coding) {
-    if (isNull(coding.getSystem()) || isNull(coding.getCode())) {
-      return false;
-    }
-
-    try {
-      return toBooleanResult(terminologyClient.validateCode(
-          required(UriType::new, codeSystemUrl), required(UriType::new, coding.getSystem()),
-          optional(StringType::new, coding.getVersion()),
-          required(CodeType::new, coding.getCode())
-      ));
-    } catch (final BaseServerResponseException e) {
-      return handleError(e, false);
-    }
-  }
-
-  @Nonnull
-  @Override
-  public List<Translation> translate(@Nonnull final Coding coding,
-      @Nonnull final String conceptMapUrl,
-      final boolean reverse,
-      @Nullable final String target) {
-
-    if (isNull(coding.getSystem()) || isNull(coding.getCode())) {
-      return Collections.emptyList();
-    }
-
-    try {
-      return toTranslations(terminologyClient.translate(
-          required(UriType::new, conceptMapUrl),
-          required(UriType::new, coding.getSystem()),
-          optional(StringType::new, coding.getVersion()),
-          required(CodeType::new, coding.getCode()),
-          new BooleanType(reverse),
-          optional(UriType::new, target)
-      ));
-    } catch (final BaseServerResponseException e) {
-      return handleError(e, Collections.emptyList());
-    }
-  }
-
-  @Nonnull
-  @Override
-  public ConceptSubsumptionOutcome subsumes(@Nonnull final Coding codingA,
-      @Nonnull final Coding codingB) {
-
-    if (codingA.getSystem() == null || !codingA.getSystem().equals(codingB.getSystem())) {
-      return NOTSUBSUMED;
-    }
-
-    if (codingA.getCode() == null || codingA.getCode() == null) {
-      return NOTSUBSUMED;
-    }
-
-    // TODO: Check how that should work with versions (e.g. how should we treat the case of null version with non null version)
-    // if both version are present then ten need to be equal
-    if (!(codingA.getVersion() == null || codingB.getVersion() == null || codingA.getVersion()
-        .equals(codingB.getVersion()))) {
-      return NOTSUBSUMED;
-    }
-    
-    if (codingEquals(codingA, codingB)) {
-      return SUBSUMES;
-    }
-    
-    final String resolvedSystem = codingA.getSystem();
-    final String resolvedVersion = codingA.getVersion() != null
-                                   ? codingA.getVersion()
-                                   : codingB.getVersion();
-    
-    try {
-      return toSubsumptionOutcome(terminologyClient.subsumes(
-          required(CodeType::new, codingA.getCode()),
-          required(CodeType::new, codingB.getCode()),
-          required(UriType::new, resolvedSystem),
-          optional(StringType::new, resolvedVersion)
-      ));
-    } catch (final BaseServerResponseException e) {
-      return handleError(e, NOTSUBSUMED);
-    }
-  }
-
-
-  @Nonnull
-  private static List<PropertyOrDesignation> toPropertiesAndDesignations(
-      @Nonnull final Parameters parameters,
-      @Nullable final String propertyCode) {
-
-    return (Designation.PROPERTY_CODE.equals(propertyCode))
-           ? toDesignations(parameters)
-           : toProperties(parameters, propertyCode);
-  }
-
-  @Nonnull
-  private static List<PropertyOrDesignation> toDesignations(Parameters parameters) {
-    return ParametersUtils.toDesignations(parameters)
-        .map(Designation::ofPart)
-        .collect(Collectors.toUnmodifiableList());
-  }
-
-  @Nonnull
-  private static List<PropertyOrDesignation> toProperties(
-      @Nonnull Parameters parameters, @Nullable String propertyCode) {
-    return ParametersUtils.toProperties(parameters)
-        .flatMap(part -> nonNull(part.getSubproperty())
-                         ? part.getSubproperty().stream()
-                         : Stream.of(part))
-        .map(part -> Property.of(part.getCode().getValue(), requireNonNull(part.getValue())))
-        .filter(property -> Objects.isNull(propertyCode) || propertyCode.equals(property.getCode()))
-        .collect(Collectors.toUnmodifiableList());
-  }
-
-  @Nonnull
-  @Override
-  public List<PropertyOrDesignation> lookup(@Nonnull final Coding coding,
-      @Nullable final String property) {
-
-    if (isNull(coding.getSystem()) || isNull(coding.getCode())) {
-      return Collections.emptyList();
-    }
-    try {
-      return toPropertiesAndDesignations(terminologyClient.lookup(
-          required(UriType::new, coding.getSystem()),
-          optional(StringType::new, coding.getVersion()),
-          required(CodeType::new, coding.getCode()),
-          optional(CodeType::new, property)), property);
-    } catch (final BaseServerResponseException e) {
-      return handleError(e, Collections.emptyList());
-    }
-  }
-
-  @Override
-  public void close() throws IOException {
-    if (nonNull(toClose)) {
-      toClose.close();
-    }
-  }
-
-  /**
-   * This method allows us to be tolerant to invalid inputs to terminology operations (which produce
-   * a 400-series error from the terminology server. A result can be provided which will be returned
-   * in the event of such an error.
-   */
-  @Nullable
-  private static <T> T handleError(@Nonnull final BaseServerResponseException e,
-      @Nullable final T invalidInputReturnValue) {
-    if (e.getStatusCode() / 100 == 4) {
-      return invalidInputReturnValue;
-    } else {
-      throw e;
-    }
-  }
-}
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyServiceFactory.java b/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyServiceFactory.java
index 7110d7715b..3a93795e6d 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyServiceFactory.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyServiceFactory.java
@@ -2,12 +2,14 @@
 
 import static java.util.Objects.nonNull;
 
-import au.csiro.pathling.caching.CachingFactories;
 import au.csiro.pathling.config.HttpClientCachingConfiguration;
+import au.csiro.pathling.config.HttpClientCachingConfiguration.StorageType;
 import au.csiro.pathling.config.HttpClientConfiguration;
 import au.csiro.pathling.config.TerminologyAuthConfiguration;
 import au.csiro.pathling.encoders.FhirEncoders;
-import au.csiro.pathling.fhir.TerminologyClient2;
+import au.csiro.pathling.fhir.TerminologyClient;
+import au.csiro.pathling.terminology.caching.InMemoryCachingTerminologyService;
+import au.csiro.pathling.terminology.caching.PersistentCachingTerminologyService;
 import au.csiro.pathling.utilities.ObjectHolder;
 import ca.uhn.fhir.context.FhirContext;
 import ca.uhn.fhir.context.FhirVersionEnum;
@@ -20,15 +22,13 @@
 import org.apache.http.client.config.RequestConfig;
 import org.apache.http.impl.client.CloseableHttpClient;
 import org.apache.http.impl.client.DefaultHttpRequestRetryHandler;
-import org.apache.http.impl.client.HttpClientBuilder;
 import org.apache.http.impl.client.HttpClients;
-import org.apache.http.impl.client.cache.CacheConfig;
 import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
 
 
 /**
- * Default implementation of {@link TerminologyServiceFactory} providing {@link TerminologyService2}
- * implemented using {@link TerminologyClient2} with given configuration.
+ * Default implementation of {@link TerminologyServiceFactory}, providing the appropriate
+ * implementation of {@link TerminologyService} based upon the given configuration.
  *
  * @author John Grimes
  * @author Piotr Szul
@@ -42,8 +42,8 @@ public class DefaultTerminologyServiceFactory implements TerminologyServiceFacto
   private static final long serialVersionUID = 2837933007972812597L;
 
   @Nonnull
-  private static final ObjectHolder<DefaultTerminologyServiceFactory, TerminologyService2> terminologyServiceHolder2 = ObjectHolder.singleton(
-      DefaultTerminologyServiceFactory::createService2);
+  private static final ObjectHolder<DefaultTerminologyServiceFactory, TerminologyService> terminologyServiceHolder = ObjectHolder.singleton(
+      DefaultTerminologyServiceFactory::createService);
 
 
   @Nonnull
@@ -65,7 +65,7 @@ public class DefaultTerminologyServiceFactory implements TerminologyServiceFacto
 
   public static synchronized void reset() {
     log.info("Resetting terminology services");
-    terminologyServiceHolder2.reset();
+    terminologyServiceHolder.reset();
   }
 
   @Deprecated
@@ -105,48 +105,47 @@ public DefaultTerminologyServiceFactory(@Nonnull final FhirVersionEnum fhirVersi
 
   @Nonnull
   @Override
-  public TerminologyService2 buildService2() {
-    return terminologyServiceHolder2.getOrCreate(this);
+  public TerminologyService build() {
+    return terminologyServiceHolder.getOrCreate(this);
   }
 
   @Nonnull
-  private DefaultTerminologyService2 createService2() {
+  private TerminologyService createService() {
     final FhirContext fhirContext = FhirEncoders.contextFor(fhirVersion);
-    final CloseableHttpClient httpClient = buildHttpClient(clientConfig,
-        cacheConfig);
-    final TerminologyClient2 terminologyClient = TerminologyClient2.build(
-        fhirContext, terminologyServerUrl, verboseRequestLogging, authConfig,
-        httpClient);
-    return new DefaultTerminologyService2(terminologyClient, httpClient);
+    final CloseableHttpClient httpClient = buildHttpClient(clientConfig);
+    final TerminologyClient terminologyClient = TerminologyClient.build(fhirContext,
+        terminologyServerUrl, verboseRequestLogging, authConfig, httpClient);
+
+    if (cacheConfig.isEnabled() && cacheConfig.getStorageType().equals(StorageType.DISK)) {
+      // If caching is enabled and storage type is disk, use a persistent caching terminology 
+      // service implementation.
+      return new PersistentCachingTerminologyService(terminologyClient, httpClient, cacheConfig);
+    } else if (cacheConfig.isEnabled() && cacheConfig.getStorageType().equals(StorageType.MEMORY)) {
+      // If caching is enabled and storage type is memory, use an in-memory caching terminology
+      // service implementation.
+      return new InMemoryCachingTerminologyService(terminologyClient, httpClient, cacheConfig);
+    } else {
+      // If caching is disabled, use a terminology service implementation that does not cache.
+      return new DefaultTerminologyService(terminologyClient, httpClient);
+    }
   }
 
   private static CloseableHttpClient buildHttpClient(
-      @Nonnull final HttpClientConfiguration clientConfig,
-      @Nonnull final HttpClientCachingConfiguration pathlingCacheConfig) {
+      @Nonnull final HttpClientConfiguration clientConfig) {
     final PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
     connectionManager.setMaxTotal(clientConfig.getMaxConnectionsTotal());
     connectionManager.setDefaultMaxPerRoute(clientConfig.getMaxConnectionsPerRoute());
-    final HttpClientBuilder clientBuilder;
-    if (pathlingCacheConfig.isEnabled()) {
-      final CacheConfig httpClientCacheConfig = CacheConfig.custom()
-          .setMaxCacheEntries(pathlingCacheConfig.getMaxEntries())
-          .setMaxObjectSize(pathlingCacheConfig.getMaxObjectSize())
-          .build();
-      clientBuilder = CachingFactories.of(pathlingCacheConfig)
-          .create(httpClientCacheConfig);
-    } else {
-      clientBuilder = HttpClients.custom();
-    }
 
     final RequestConfig defaultRequestConfig = RequestConfig.custom()
         .setSocketTimeout(clientConfig.getSocketTimeout())
         .build();
 
-    return clientBuilder
+    return HttpClients.custom()
         .setDefaultRequestConfig(defaultRequestConfig)
         .setConnectionManager(connectionManager)
         .setConnectionManagerShared(false)
         .setRetryHandler(new DefaultHttpRequestRetryHandler(1, true))
         .build();
   }
+
 }
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/ResponseNotModifiedError.java b/terminology/src/main/java/au/csiro/pathling/terminology/ResponseNotModifiedError.java
new file mode 100644
index 0000000000..87d03fe2fb
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/ResponseNotModifiedError.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.terminology;
+
+public class ResponseNotModifiedError extends Exception {
+
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyOperation.java b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyOperation.java
new file mode 100644
index 0000000000..9214b02a41
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyOperation.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.terminology;
+
+import ca.uhn.fhir.rest.gclient.IOperationUntypedWithInput;
+import java.util.Optional;
+import javax.annotation.Nonnull;
+
+
+/**
+ * An abstraction of a terminology operation that describes how to validate, execute and extract a
+ * result from the response.
+ *
+ * @param <ResponseType> The type of the response returned by the terminology client
+ * @param <ResultType> The type of the final result that is extracted from the response
+ * @author John Grimes
+ */
+public interface TerminologyOperation<ResponseType, ResultType> {
+
+  /**
+   * Validates the parameters for this operation.
+   *
+   * @return An empty {@link Optional} if the parameters are valid, or an {@link Optional}
+   * containing the result to be returned to the user if the parameters are invalid.
+   */
+  @Nonnull
+  Optional<ResultType> validate();
+
+  /**
+   * Builds a request for this operation, ready to send to the terminology server.
+   *
+   * @return A request for this operation that has not been executed yet, and can still be modified
+   * (e.g. headers)
+   */
+  @Nonnull
+  IOperationUntypedWithInput<ResponseType> buildRequest();
+
+  /**
+   * Extracts the result from the response from the terminology server.
+   *
+   * @param response the response from the terminology server
+   * @return the extracted result
+   */
+  @Nonnull
+  ResultType extractResult(@Nonnull final ResponseType response);
+
+  /**
+   * @return the result that should be returned to the user if the terminology server returns a 400
+   * series response
+   */
+  @Nonnull
+  ResultType invalidRequestFallback();
+
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyParameters.java b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyParameters.java
new file mode 100644
index 0000000000..25bc628554
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyParameters.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.terminology;
+
+import java.io.Serializable;
+import java.util.Objects;
+import java.util.function.Function;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Represents parameters that are passed to a terminology operation.
+ * <p>
+ * Must be {@link Serializable} so that it can be used as a cache key.
+ *
+ * @author John Grimes
+ */
+public interface TerminologyParameters extends Serializable {
+
+  @Nullable
+  static <T> T optional(@Nonnull final Function<String, T> converter,
+      @Nullable final String value) {
+    return value != null
+           ? converter.apply(value)
+           : null;
+  }
+
+  @Nonnull
+  static <T> T required(@Nonnull final Function<String, T> converter,
+      @Nullable final String value) {
+    return converter.apply(Objects.requireNonNull(value));
+  }
+
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyResult.java b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyResult.java
new file mode 100644
index 0000000000..1d4d0d50fe
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyResult.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.terminology;
+
+import java.io.Serializable;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import lombok.Value;
+
+/**
+ * Represents the result of a terminology operation, along with metadata that can be used for
+ * caching.
+ *
+ * @param <ResultType> The type of the final result that is extracted from the response
+ * @author John Grimes
+ */
+@Value
+public class TerminologyResult<ResultType extends Serializable> implements Serializable {
+
+  private static final long serialVersionUID = -8569345179387329139L;
+
+  /**
+   * The value of the cache entry.
+   */
+  @Nullable
+  ResultType data;
+
+  /**
+   * The ETag returned in the response that generated this value.
+   */
+  @Nullable
+  String eTag;
+
+  /**
+   * The expiry time in milliseconds since the epoch.
+   */
+  @Nullable
+  Long expires;
+
+  /**
+   * Set to true if the response was a 304 Not Modified.
+   */
+  @Nonnull
+  Boolean notModified;
+
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyService2.java b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyService.java
similarity index 75%
rename from terminology/src/main/java/au/csiro/pathling/terminology/TerminologyService2.java
rename to terminology/src/main/java/au/csiro/pathling/terminology/TerminologyService.java
index c4d7dbcd66..cc9425e07f 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyService2.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyService.java
@@ -18,54 +18,78 @@
 package au.csiro.pathling.terminology;
 
 import au.csiro.pathling.fhir.ParametersUtils.DesignationPart;
+import au.csiro.pathling.fhirpath.encoding.ImmutableCoding;
+import java.io.Serializable;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
 import lombok.Value;
 import org.hl7.fhir.r4.model.Coding;
 import org.hl7.fhir.r4.model.StringType;
 import org.hl7.fhir.r4.model.Type;
 import org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence;
 import org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome;
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
-import java.util.List;
-import java.util.Objects;
-import java.util.Optional;
-
-import static java.util.Objects.nonNull;
 
 /**
  * Abstraction layer for the terminology related operations.
  *
  * @author Piotr Szul
  */
-public interface TerminologyService2 {
+public interface TerminologyService {
 
   /**
    * Represent a single translation of a code.
    */
   @Value(staticConstructor = "of")
-  class Translation {
+  class Translation implements Serializable {
+
+    private static final long serialVersionUID = -7551505530196865478L;
 
     @Nonnull
     ConceptMapEquivalence equivalence;
 
     @Nonnull
     Coding concept;
+
+    @Override
+    public boolean equals(final Object o) {
+      // We override this method because Coding does not have a sane equals method.
+      if (this == o) {
+        return true;
+      }
+      if (o == null || getClass() != o.getClass()) {
+        return false;
+      }
+      final Translation that = (Translation) o;
+      return equivalence == that.equivalence && ImmutableCoding.of(concept)
+          .equals(ImmutableCoding.of(that.concept));
+    }
+
+    @Override
+    public int hashCode() {
+      // We override this method because Coding does not have a sane hashCode method.
+      return Objects.hash(equivalence, ImmutableCoding.of(concept));
+    }
+
   }
 
   /**
-   * Validates that a coded value is in the code system. Abstracts the FHIR <a
-   * href="https://www.hl7.org/fhir/R4/operation-codesystem-validate-code.html">CodeSystem/$validate-code</a>
+   * Validates that a coded value is in a value set. Abstracts the FHIR <a
+   * href="https://www.hl7.org/fhir/R4/valueset-operation-validate-code.html">ValueSet/$validate-code</a>
    * operation.
    *
-   * @param codeSystemUrl the URL of the code system.
-   * @param coding the coding to test.
-   * @return true if the coding is valid.
+   * @param valueSetUrl the URL of the value set to validate against
+   * @param coding the coding to test
+   * @return true if the coding is a valid member of the value set
    */
-  boolean validateCode(@Nonnull String codeSystemUrl, @Nonnull Coding coding);
+  boolean validateCode(@Nonnull String valueSetUrl, @Nonnull Coding coding);
 
   /**
    * Translates a code from one value set to another, based on the existing concept map. Abstracts
-   * the FHIR <a href="https://www.hl7.org/fhir/R4/operation-conceptmap-translate.html">ConceptMap/$translate</a>
+   * the FHIR <a
+   * href="https://www.hl7.org/fhir/R4/operation-conceptmap-translate.html">ConceptMap/$translate</a>
    * operation.
    *
    * @param coding the code to translate.
@@ -84,7 +108,8 @@ List<Translation> translate(@Nonnull Coding coding,
 
   /**
    * Tests the subsumption relationship between two codings given the semantics of subsumption in
-   * the underlying code system. Abstracts the <a href="https://www.hl7.org/fhir/R4/codesystem-operation-subsumes.html">CodeSystem/$subsumes</a>
+   * the underlying code system. Abstracts the <a
+   * href="https://www.hl7.org/fhir/R4/codesystem-operation-subsumes.html">CodeSystem/$subsumes</a>
    * operation.
    *
    * @param codingA the left code to be tested.
@@ -95,21 +120,35 @@ List<Translation> translate(@Nonnull Coding coding,
   @Nonnull
   ConceptSubsumptionOutcome subsumes(@Nonnull Coding codingA, @Nonnull Coding codingB);
 
+  /**
+   * Gets additional details about the concept, including designations and properties. Abstracts
+   * the
+   * <a href="https://www.hl7.org/fhir/R4/codesystem-operation-lookup.html">CodeSystem/$lookup</a>
+   * operation.
+   *
+   * @param coding the coding to lookup.
+   * @param propertyCode the code of the propertyCode to lookup. If not null only the properties
+   * with matching codes are returned.
+   * @return the list of properties and/or designations.
+   */
+  @Nonnull
+  List<PropertyOrDesignation> lookup(@Nonnull Coding coding, @Nullable String propertyCode);
 
   /**
    * Common interface for properties and designations
    */
-  interface PropertyOrDesignation {
+  interface PropertyOrDesignation extends Serializable {
     // marker interface
   }
 
-
   /**
    * The representation of the property of a concept.
    */
   @Value(staticConstructor = "of")
   class Property implements PropertyOrDesignation {
 
+    private static final long serialVersionUID = 8827691056493768863L;
+
     @Nonnull
     String code;
     @Nonnull
@@ -141,7 +180,7 @@ public boolean equals(final Object o) {
         return false;
       }
 
-      Property property = (Property) o;
+      final Property property = (Property) o;
 
       if (!code.equals(property.code)) {
         return false;
@@ -157,6 +196,8 @@ public boolean equals(final Object o) {
   @Value(staticConstructor = "of")
   class Designation implements PropertyOrDesignation {
 
+    private static final long serialVersionUID = -809107979219801186L;
+
     /**
      * The code of the designation properties
      */
@@ -187,7 +228,7 @@ public boolean equals(final Object o) {
         return false;
       }
 
-      Designation that = (Designation) o;
+      final Designation that = (Designation) o;
 
       if (use != null
           ? !use.equalsDeep(that.use)
@@ -208,18 +249,4 @@ public static Designation ofPart(@Nonnull final DesignationPart part) {
     }
   }
 
-  /**
-   * Gets additional details about the concept, including designations and properties. Abstracts
-   * the
-   * <a href="https://www.hl7.org/fhir/R4/codesystem-operation-lookup.html">CodeSystem/$lookup</a>
-   * operation.
-   *
-   * @param coding the coding to lookup.
-   * @param propertyCode the code of the propertyCode to lookup. If not null only the properties
-   * with matching codes are returned.
-   * @return the list of properties and/or designations.
-   */
-  @Nonnull
-  List<PropertyOrDesignation> lookup(@Nonnull Coding coding, @Nullable String propertyCode);
-
 }
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyServiceFactory.java b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyServiceFactory.java
index afb7fb9936..38a4f710e3 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyServiceFactory.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyServiceFactory.java
@@ -21,17 +21,21 @@
 import javax.annotation.Nonnull;
 
 /**
- * Represents something that creates a {@link TerminologyService2}.
+ * Represents something that creates a {@link TerminologyService}.
  * <p>
- * Used for code that runs on Spark workers.
+ * Used for code that runs on Spark workers, providing a {@link Serializable} class that is capable
+ * of building a service that can be used on the worker.
+ *
+ * @author John Grimes
+ * @author Piotr Szul
  */
 public interface TerminologyServiceFactory extends Serializable {
 
   /**
    * Builds a new instance.
    *
-   * @return a shiny new TerminologyService2 instance
+   * @return a shiny new TerminologyService instance
    */
   @Nonnull
-  TerminologyService2 buildService2();
+  TerminologyService build();
 }
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/caching/CacheableListCollector.java b/terminology/src/main/java/au/csiro/pathling/terminology/caching/CacheableListCollector.java
new file mode 100644
index 0000000000..f260468b19
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/caching/CacheableListCollector.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.terminology.caching;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.Set;
+import java.util.function.BiConsumer;
+import java.util.function.BinaryOperator;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import java.util.stream.Collector;
+
+/**
+ * A collector that collects a stream of items into a list.
+ * <p>
+ * This is the same as {@link java.util.stream.Collectors#toList()} except that it returns an
+ * {@link ArrayList} instead of a generic {@link java.util.List}.
+ * <p>
+ * We need this because the list implementation needs to be constrained to ensure that it is
+ * serializable for persistent caching purposes.
+ */
+public class CacheableListCollector<T> implements Collector<T, ArrayList<T>, ArrayList<T>> {
+
+  @Override
+  public Supplier<ArrayList<T>> supplier() {
+    return ArrayList::new;
+  }
+
+  @Override
+  public BiConsumer<ArrayList<T>, T> accumulator() {
+    return ArrayList::add;
+  }
+
+  @Override
+  public BinaryOperator<ArrayList<T>> combiner() {
+    return (left, right) -> {
+      left.addAll(right);
+      return left;
+    };
+  }
+
+  @Override
+  public Function<ArrayList<T>, ArrayList<T>> finisher() {
+    return i -> (ArrayList<T>) i;
+  }
+
+  @Override
+  public Set<Characteristics> characteristics() {
+    return Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.IDENTITY_FINISH));
+  }
+
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/caching/CachingTerminologyService.java b/terminology/src/main/java/au/csiro/pathling/terminology/caching/CachingTerminologyService.java
new file mode 100644
index 0000000000..9c15876417
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/caching/CachingTerminologyService.java
@@ -0,0 +1,289 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.terminology.caching;
+
+import static au.csiro.pathling.utilities.Preconditions.checkPresent;
+
+import au.csiro.pathling.config.HttpClientCachingConfiguration;
+import au.csiro.pathling.fhir.TerminologyClient;
+import au.csiro.pathling.fhirpath.encoding.ImmutableCoding;
+import au.csiro.pathling.terminology.BaseTerminologyService;
+import au.csiro.pathling.terminology.TerminologyOperation;
+import au.csiro.pathling.terminology.TerminologyParameters;
+import au.csiro.pathling.terminology.TerminologyResult;
+import au.csiro.pathling.terminology.lookup.LookupExecutor;
+import au.csiro.pathling.terminology.lookup.LookupParameters;
+import au.csiro.pathling.terminology.subsumes.SubsumesExecutor;
+import au.csiro.pathling.terminology.subsumes.SubsumesParameters;
+import au.csiro.pathling.terminology.translate.TranslateExecutor;
+import au.csiro.pathling.terminology.translate.TranslateParameters;
+import au.csiro.pathling.terminology.validatecode.ValidateCodeExecutor;
+import au.csiro.pathling.terminology.validatecode.ValidateCodeParameters;
+import ca.uhn.fhir.rest.api.MethodOutcome;
+import ca.uhn.fhir.rest.gclient.IOperationUntypedWithInput;
+import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
+import ca.uhn.fhir.rest.server.exceptions.NotModifiedException;
+import java.io.Closeable;
+import java.io.Serializable;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.ws.rs.core.CacheControl;
+import org.hl7.fhir.r4.model.Coding;
+import org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome;
+import org.infinispan.Cache;
+import org.infinispan.manager.EmbeddedCacheManager;
+
+/**
+ * A terminology service that uses embedded Infinispan to cache the results of the underlying
+ * terminology service operations.
+ *
+ * @author John Grimes
+ */
+public abstract class CachingTerminologyService extends BaseTerminologyService {
+
+  public static final String VALIDATE_CODE_CACHE_NAME = "validate-code";
+  public static final String SUBSUMES_CACHE_NAME = "subsumes";
+  public static final String TRANSLATE_CACHE_NAME = "translate";
+  public static final String LOOKUP_CACHE_NAME = "lookup";
+  public static final String ETAG_HEADER_NAME = "etag";
+  public static final String IF_NONE_MATCH_HEADER_NAME = "if-none-match";
+  public static final String CACHE_CONTROL_HEADER_NAME = "cache-control";
+
+  @Nonnull
+  protected final HttpClientCachingConfiguration configuration;
+
+  @Nonnull
+  protected final EmbeddedCacheManager cacheManager;
+
+  @Nonnull
+  protected final Cache<Integer, TerminologyResult<Boolean>> validateCodeCache;
+
+  @Nonnull
+  protected final Cache<Integer, TerminologyResult<ConceptSubsumptionOutcome>> subsumesCache;
+
+  @Nonnull
+  protected final Cache<Integer, TerminologyResult<ArrayList<Translation>>> translateCache;
+
+  @Nonnull
+  protected final Cache<Integer, TerminologyResult<ArrayList<PropertyOrDesignation>>> lookupCache;
+
+  @SuppressWarnings("unchecked")
+  public CachingTerminologyService(@Nonnull final TerminologyClient terminologyClient,
+      @Nullable final Closeable toClose,
+      @Nonnull final HttpClientCachingConfiguration configuration) {
+    super(terminologyClient, toClose);
+    this.configuration = configuration;
+    cacheManager = buildCacheManager();
+    validateCodeCache = (Cache<Integer, TerminologyResult<Boolean>>) buildCache(cacheManager,
+        VALIDATE_CODE_CACHE_NAME);
+    subsumesCache = (Cache<Integer, TerminologyResult<ConceptSubsumptionOutcome>>) buildCache(
+        cacheManager, SUBSUMES_CACHE_NAME);
+    translateCache = (Cache<Integer, TerminologyResult<ArrayList<Translation>>>) buildCache(
+        cacheManager, TRANSLATE_CACHE_NAME);
+    lookupCache = (Cache<Integer, TerminologyResult<ArrayList<PropertyOrDesignation>>>) buildCache(
+        cacheManager, LOOKUP_CACHE_NAME);
+  }
+
+  @Override
+  public boolean validateCode(@Nonnull final String valueSetUrl, @Nonnull final Coding coding) {
+    final ValidateCodeParameters parameters = new ValidateCodeParameters(valueSetUrl,
+        ImmutableCoding.of(coding));
+    final ValidateCodeExecutor executor = new ValidateCodeExecutor(terminologyClient, parameters);
+    return getFromCache(validateCodeCache, parameters, executor);
+  }
+
+  @Nonnull
+  @Override
+  public List<Translation> translate(@Nonnull final Coding coding,
+      @Nonnull final String conceptMapUrl,
+      final boolean reverse, @Nullable final String target) {
+    final TranslateParameters parameters = new TranslateParameters(ImmutableCoding.of(coding),
+        conceptMapUrl, reverse, target);
+    final TranslateExecutor executor = new TranslateExecutor(terminologyClient, parameters);
+    return getFromCache(translateCache, parameters, executor);
+  }
+
+  @Nonnull
+  @Override
+  public ConceptSubsumptionOutcome subsumes(@Nonnull final Coding codingA,
+      @Nonnull final Coding codingB) {
+    final SubsumesParameters parameters = new SubsumesParameters(
+        ImmutableCoding.of(codingA), ImmutableCoding.of(codingB));
+    final SubsumesExecutor executor = new SubsumesExecutor(terminologyClient, parameters);
+    return getFromCache(subsumesCache, parameters, executor);
+  }
+
+  @Nonnull
+  @Override
+  public List<PropertyOrDesignation> lookup(@Nonnull final Coding coding,
+      @Nullable final String property) {
+    final LookupParameters parameters = new LookupParameters(ImmutableCoding.of(coding), property);
+    final LookupExecutor executor = new LookupExecutor(terminologyClient, parameters);
+    return getFromCache(lookupCache, parameters, executor);
+  }
+
+  /**
+   * Gets the result of an operation from the cache, or fetches a new result if the cache is empty
+   * or expired.
+   *
+   * @param cache The cache being used for this operation
+   * @param parameters The parameters for the operation
+   * @param operation A {@link TerminologyOperation} that provides the behavior specific to the type
+   * of operation
+   * @param <ParametersType> The type of the parameters that are input to the operation
+   * @param <ResponseType> The type of the response returned by the terminology client
+   * @param <ResultType> The type of the final result that is extracted from the response
+   * @return The operation result
+   */
+  private <ParametersType extends TerminologyParameters, ResponseType, ResultType extends Serializable> ResultType getFromCache(
+      @Nonnull final Cache<Integer, TerminologyResult<ResultType>> cache,
+      @Nonnull final ParametersType parameters,
+      @Nonnull final TerminologyOperation<ResponseType, ResultType> operation
+  ) {
+    final int key = parameters.hashCode();
+    final TerminologyResult<ResultType> cached = cache.get(key);
+
+    if (cached == null) {
+      // Cache miss.
+      final TerminologyResult<ResultType> result = fetch(operation, Optional.empty());
+      cache.put(key, result);
+      return result.getData();
+    } else {
+      final boolean expired =
+          cached.getExpires() != null && System.currentTimeMillis() > cached.getExpires();
+      return cache.compute(key,
+          (k, v) -> expired
+                    // Cache hit, but the entry is expired and needs to be revalidated.
+                    ? fetch(operation, Optional.ofNullable(v))
+                    // Cache hit, and the entry is still valid.
+                    : v).getData();
+    }
+  }
+
+  /**
+   * Fetches an operation result, or revalidates it if the cached result is still valid.
+   *
+   * @param operation A {@link TerminologyOperation} that provides the behavior specific to the type
+   * of operation
+   * @param cached A previously cached value
+   * @param <ResponseType> The type of the response returned by the terminology client
+   * @param <ResultType> The type of the final result that is extracted from the response
+   * @return The operation result
+   */
+  private <ResponseType, ResultType extends Serializable> TerminologyResult<ResultType> fetch(
+      @Nonnull final TerminologyOperation<ResponseType, ResultType> operation,
+      @Nonnull final Optional<TerminologyResult<ResultType>> cached) {
+    final Optional<ResultType> invalidResult = operation.validate();
+    if (invalidResult.isPresent()) {
+      // If the parameters fail validation, cache the invalid result forever.
+      return new TerminologyResult<>(invalidResult.get(), null, null, false);
+    }
+
+    final IOperationUntypedWithInput<ResponseType> request = operation.buildRequest();
+
+    // Add an If-None-Match header if the cached result is accompanied by an ETag.
+    cached.flatMap(c -> Optional.ofNullable(c.getETag()))
+        .ifPresent(eTag -> request.withAdditionalHeader(IF_NONE_MATCH_HEADER_NAME, eTag));
+
+    // We use a default expiry if the server does not provide one.
+    final long defaultExpires = secondsFromNow(configuration.getDefaultExpiry());
+
+    try {
+      final MethodOutcome outcome = request.returnMethodOutcome().execute();
+
+      // If the response was 200 OK, use the data from the fresh response.
+      @SuppressWarnings("unchecked") final ResultType result = operation.extractResult(
+          (ResponseType) outcome.getResource());
+      final Optional<String> newETag = getSingularHeader(outcome, ETAG_HEADER_NAME);
+      final Optional<Long> expires = getExpires(outcome);
+      return new TerminologyResult<>(
+          result, newETag.orElse(null), expires.orElse(defaultExpires), false);
+
+    } catch (final NotModifiedException e) {
+      // If the response was 304 Not Modified, use the data from the cached response and update 
+      // the ETag and expiry.
+      final Optional<String> newETag = getSingularHeader(e.getResponseHeaders(), ETAG_HEADER_NAME);
+      final Optional<Long> expires = getExpires(e.getResponseHeaders());
+      final TerminologyResult<ResultType> previous = checkPresent(cached);
+      return new TerminologyResult<>(previous.getData(), newETag.orElse(previous.getETag()),
+          expires.orElse(previous.getExpires()), false);
+
+    } catch (final BaseServerResponseException e) {
+      // If the terminology server rejects the request as invalid, cache the invalid result for the 
+      // amount of time instructed by the server. If there is no such instruction, cache it for the 
+      // configured default expiry.
+      final long expires = getExpires(e.getResponseHeaders()).orElse(
+          defaultExpires);
+      final TerminologyResult<ResultType> fallback = new TerminologyResult<>(
+          operation.invalidRequestFallback(), null, expires, false);
+      return handleError(e, fallback);
+    }
+  }
+
+  /**
+   * @return a new {@link EmbeddedCacheManager} instance appropriate for the specific implementation
+   */
+  protected abstract EmbeddedCacheManager buildCacheManager();
+
+  /**
+   * @param cacheManager the {@link EmbeddedCacheManager} to use to construct the cache
+   * @param cacheName a name for the cache
+   * @return a new {@link Cache} instance appropriate for the specific implementation
+   */
+  protected abstract Cache<Integer, ?> buildCache(@Nonnull final EmbeddedCacheManager cacheManager,
+      @Nonnull final String cacheName);
+
+  @SuppressWarnings("SameParameterValue")
+  @Nonnull
+  private static Optional<String> getSingularHeader(@Nonnull final MethodOutcome outcome,
+      @Nonnull final String headerName) {
+    return getSingularHeader(outcome.getResponseHeaders(), headerName);
+  }
+
+  @Nonnull
+  private static Optional<String> getSingularHeader(
+      @Nullable final Map<String, List<String>> headers,
+      @Nonnull final String headerName) {
+    return Optional.ofNullable(headers)
+        .map(rh -> rh.get(headerName))
+        .flatMap(h -> h.stream().findFirst());
+  }
+
+  @Nonnull
+  private static Optional<Long> getExpires(@Nonnull final MethodOutcome outcome) {
+    return getExpires(outcome.getResponseHeaders());
+  }
+
+  @Nonnull
+  private static Optional<Long> getExpires(@Nullable final Map<String, List<String>> headers) {
+    final Optional<Integer> maxAge = getSingularHeader(headers, CACHE_CONTROL_HEADER_NAME)
+        .map(CacheControl::valueOf)
+        .map(CacheControl::getMaxAge);
+    return maxAge.map(CachingTerminologyService::secondsFromNow);
+  }
+
+  private static long secondsFromNow(final int seconds) {
+    return Instant.now().plusSeconds(seconds).toEpochMilli();
+  }
+
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/caching/InMemoryCachingTerminologyService.java b/terminology/src/main/java/au/csiro/pathling/terminology/caching/InMemoryCachingTerminologyService.java
new file mode 100644
index 0000000000..f38638c60d
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/caching/InMemoryCachingTerminologyService.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.terminology.caching;
+
+import au.csiro.pathling.config.HttpClientCachingConfiguration;
+import au.csiro.pathling.fhir.TerminologyClient;
+import java.io.Closeable;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import org.infinispan.Cache;
+import org.infinispan.configuration.cache.Configuration;
+import org.infinispan.configuration.cache.ConfigurationBuilder;
+import org.infinispan.eviction.EvictionStrategy;
+import org.infinispan.manager.DefaultCacheManager;
+import org.infinispan.manager.EmbeddedCacheManager;
+
+/**
+ * A terminology service that uses embedded Infinispan to cache results in-memory.
+ *
+ * @author John Grimes
+ */
+public class InMemoryCachingTerminologyService extends CachingTerminologyService {
+
+  public InMemoryCachingTerminologyService(@Nonnull final TerminologyClient terminologyClient,
+      @Nullable final Closeable toClose,
+      @Nonnull final HttpClientCachingConfiguration configuration) {
+    super(terminologyClient, toClose, configuration);
+  }
+
+  @Override
+  protected EmbeddedCacheManager buildCacheManager() {
+    return new DefaultCacheManager();
+  }
+
+  @Override
+  protected Cache<Integer, ?> buildCache(@Nonnull final EmbeddedCacheManager cacheManager,
+      @Nonnull final String cacheName) {
+    final Configuration cacheConfig = new ConfigurationBuilder()
+        .memory()
+        .maxCount(configuration.getMaxEntries())
+        .whenFull(EvictionStrategy.REMOVE)
+        .build();
+
+    cacheManager.defineConfiguration(cacheName, cacheConfig);
+    return cacheManager.getCache(cacheName);
+  }
+
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/caching/PersistentCachingTerminologyService.java b/terminology/src/main/java/au/csiro/pathling/terminology/caching/PersistentCachingTerminologyService.java
new file mode 100644
index 0000000000..a7f5939217
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/caching/PersistentCachingTerminologyService.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.terminology.caching;
+
+import static java.util.Objects.requireNonNull;
+
+import au.csiro.pathling.config.HttpClientCachingConfiguration;
+import au.csiro.pathling.fhir.TerminologyClient;
+import java.io.Closeable;
+import java.nio.file.Path;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import org.infinispan.Cache;
+import org.infinispan.commons.marshall.JavaSerializationMarshaller;
+import org.infinispan.configuration.cache.Configuration;
+import org.infinispan.configuration.cache.ConfigurationBuilder;
+import org.infinispan.configuration.global.GlobalConfiguration;
+import org.infinispan.configuration.global.GlobalConfigurationBuilder;
+import org.infinispan.eviction.EvictionStrategy;
+import org.infinispan.manager.DefaultCacheManager;
+import org.infinispan.manager.EmbeddedCacheManager;
+
+/**
+ * A terminology service that uses embedded Infinispan to cache results on disk. Requires
+ * configuration that includes a storage location, which is where cache data and indexes will be
+ * stored.
+ *
+ * @author John Grimes
+ */
+public class PersistentCachingTerminologyService extends CachingTerminologyService {
+
+  private static final String DATA_DIRECTORY = "data";
+  private static final String INDEX_DIRECTORY = "index";
+
+  public PersistentCachingTerminologyService(@Nonnull final TerminologyClient terminologyClient,
+      @Nullable final Closeable toClose,
+      @Nonnull final HttpClientCachingConfiguration configuration) {
+    super(terminologyClient, toClose, configuration);
+  }
+
+  @Override
+  protected EmbeddedCacheManager buildCacheManager() {
+    final GlobalConfigurationBuilder globalConfigBuilder = new GlobalConfigurationBuilder();
+    globalConfigBuilder.serialization()
+        .marshaller(new JavaSerializationMarshaller())
+        .allowList()
+        .addRegexp(".*");
+    final GlobalConfiguration globalConfig = globalConfigBuilder.build();
+    return new DefaultCacheManager(globalConfig);
+  }
+
+  @Override
+  protected Cache<Integer, ?> buildCache(@Nonnull final EmbeddedCacheManager cacheManager,
+      @Nonnull final String cacheName) {
+    final String storagePath = configuration.getStoragePath();
+    final String dataLocation = Path.of(requireNonNull(storagePath), DATA_DIRECTORY).toString();
+    final String indexLocation = Path.of(storagePath, INDEX_DIRECTORY).toString();
+
+    final Configuration cacheConfig = new ConfigurationBuilder()
+        .memory()
+        .maxCount(configuration.getMaxEntries())
+        .whenFull(EvictionStrategy.REMOVE)
+        .persistence()
+        .addSoftIndexFileStore()
+        .dataLocation(dataLocation)
+        .indexLocation(indexLocation)
+        .build();
+
+    cacheManager.defineConfiguration(cacheName, cacheConfig);
+    return cacheManager.getCache(cacheName);
+  }
+
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/lookup/LookupExecutor.java b/terminology/src/main/java/au/csiro/pathling/terminology/lookup/LookupExecutor.java
new file mode 100644
index 0000000000..cd61b9b258
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/lookup/LookupExecutor.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.terminology.lookup;
+
+import static java.util.Objects.isNull;
+import static java.util.Objects.nonNull;
+import static java.util.Objects.requireNonNull;
+
+import au.csiro.pathling.fhir.ParametersUtils;
+import au.csiro.pathling.fhir.TerminologyClient;
+import au.csiro.pathling.fhirpath.encoding.ImmutableCoding;
+import au.csiro.pathling.terminology.TerminologyOperation;
+import au.csiro.pathling.terminology.TerminologyParameters;
+import au.csiro.pathling.terminology.TerminologyService.Designation;
+import au.csiro.pathling.terminology.TerminologyService.Property;
+import au.csiro.pathling.terminology.TerminologyService.PropertyOrDesignation;
+import au.csiro.pathling.terminology.caching.CacheableListCollector;
+import ca.uhn.fhir.rest.gclient.IOperationUntypedWithInput;
+import java.util.ArrayList;
+import java.util.Optional;
+import java.util.stream.Stream;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import org.hl7.fhir.r4.model.CodeType;
+import org.hl7.fhir.r4.model.Parameters;
+import org.hl7.fhir.r4.model.StringType;
+import org.hl7.fhir.r4.model.UriType;
+
+/**
+ * An implementation of {@link TerminologyOperation} for the lookup operation.
+ *
+ * @author John Grimes
+ * @see <a
+ * href="https://www.hl7.org/fhir/R4/codesystem-operation-lookup.html">CodeSystem/$lookup</a>
+ */
+public class LookupExecutor implements
+    TerminologyOperation<Parameters, ArrayList<PropertyOrDesignation>> {
+
+  @Nonnull
+  private final TerminologyClient terminologyClient;
+
+  @Nonnull
+  private final LookupParameters parameters;
+
+  public LookupExecutor(@Nonnull final TerminologyClient terminologyClient,
+      @Nonnull final LookupParameters parameters) {
+    this.terminologyClient = terminologyClient;
+    this.parameters = parameters;
+  }
+
+  @Override
+  @Nonnull
+  public Optional<ArrayList<PropertyOrDesignation>> validate() {
+    final ImmutableCoding coding = parameters.getCoding();
+
+    // If the system or the code of the coding is null, the result is an empty list.
+    if (isNull(coding.getSystem()) || isNull(coding.getCode())) {
+      return Optional.of(new ArrayList<>());
+    } else {
+      return Optional.empty();
+    }
+  }
+
+  @Override
+  @Nonnull
+  public IOperationUntypedWithInput<Parameters> buildRequest() {
+    final ImmutableCoding coding = parameters.getCoding();
+    final String property = parameters.getProperty();
+   
+    return terminologyClient.buildLookup(
+        TerminologyParameters.required(UriType::new, coding.getSystem()),
+        TerminologyParameters.optional(StringType::new, coding.getVersion()),
+        TerminologyParameters.required(CodeType::new, coding.getCode()),
+        TerminologyParameters.optional(CodeType::new, property)
+    );
+  }
+
+  @Override
+  @Nonnull
+  public ArrayList<PropertyOrDesignation> extractResult(@Nonnull final Parameters response) {
+    return toPropertiesAndDesignations(response, parameters.getProperty());
+  }
+
+  @Override
+  @Nonnull
+  public ArrayList<PropertyOrDesignation> invalidRequestFallback() {
+    return new ArrayList<>();
+  }
+
+  @Nonnull
+  private static ArrayList<PropertyOrDesignation> toPropertiesAndDesignations(
+      @Nonnull final Parameters parameters,
+      @Nullable final String propertyCode) {
+
+    return (Designation.PROPERTY_CODE.equals(propertyCode))
+           ? toDesignations(parameters)
+           : toProperties(parameters, propertyCode);
+  }
+
+  @Nonnull
+  private static ArrayList<PropertyOrDesignation> toDesignations(
+      @Nonnull final Parameters parameters) {
+    return ParametersUtils.toDesignations(parameters)
+        .map(Designation::ofPart)
+        .collect(new CacheableListCollector<>());
+  }
+
+  @Nonnull
+  private static ArrayList<PropertyOrDesignation> toProperties(
+      @Nonnull final Parameters parameters, @Nullable final String propertyCode) {
+    return ParametersUtils.toProperties(parameters)
+        .flatMap(part -> nonNull(part.getSubproperty())
+                         ? part.getSubproperty().stream()
+                         : Stream.of(part))
+        .map(part -> Property.of(part.getCode().getValue(), requireNonNull(part.getValue())))
+        .filter(property -> isNull(propertyCode) || propertyCode.equals(property.getCode()))
+        .collect(new CacheableListCollector<>());
+  }
+
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/caching/CachingFactory.java b/terminology/src/main/java/au/csiro/pathling/terminology/lookup/LookupParameters.java
similarity index 50%
rename from terminology/src/main/java/au/csiro/pathling/caching/CachingFactory.java
rename to terminology/src/main/java/au/csiro/pathling/terminology/lookup/LookupParameters.java
index 05ebc01851..bfbbd8c88b 100644
--- a/terminology/src/main/java/au/csiro/pathling/caching/CachingFactory.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/lookup/LookupParameters.java
@@ -15,24 +15,25 @@
  * limitations under the License.
  */
 
-package au.csiro.pathling.caching;
+package au.csiro.pathling.terminology.lookup;
 
-import javax.annotation.Nonnull;
-import org.apache.http.impl.client.cache.CacheConfig;
-import org.apache.http.impl.client.cache.CachingHttpClientBuilder;
+import au.csiro.pathling.fhirpath.encoding.ImmutableCoding;
+import au.csiro.pathling.terminology.TerminologyParameters;
+import lombok.Value;
 
 /**
- * A factory class for creating {@link CachingHttpClientBuilder} instance, pre-configured for with
- * some caching implementation.
+ * Represents the input parameters to the lookup operation.
+ *
+ * @author John Grimes
+ * @see <a
+ * href="https://www.hl7.org/fhir/R4/codesystem-operation-lookup.html">CodeSystem/$lookup</a>
  */
-public interface CachingFactory {
+@Value
+public class LookupParameters implements TerminologyParameters {
+
+  private static final long serialVersionUID = 3514974005203890618L;
+
+  ImmutableCoding coding;
+  String property;
 
-  /**
-   * Creates a {@link CachingHttpClientBuilder}, initialized with the provided {@link CacheConfig}.
-   *
-   * @param cacheConfig the cache config to use
-   * @return the pre-configured {@link CachingHttpClientBuilder}
-   */
-  @Nonnull
-  CachingHttpClientBuilder create(@Nonnull CacheConfig cacheConfig);
 }
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/mock/MockTerminologyService2.java b/terminology/src/main/java/au/csiro/pathling/terminology/mock/MockTerminologyService.java
similarity index 96%
rename from terminology/src/main/java/au/csiro/pathling/terminology/mock/MockTerminologyService2.java
rename to terminology/src/main/java/au/csiro/pathling/terminology/mock/MockTerminologyService.java
index 72c1da234c..901c660d09 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/mock/MockTerminologyService2.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/mock/MockTerminologyService.java
@@ -17,18 +17,12 @@
 
 package au.csiro.pathling.terminology.mock;
 
-import au.csiro.pathling.terminology.TerminologyService2;
+import static java.util.Objects.isNull;
+import static org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence.EQUIVALENT;
+import static org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence.RELATEDTO;
+
+import au.csiro.pathling.terminology.TerminologyService;
 import com.google.common.collect.ImmutableMap;
-import lombok.AllArgsConstructor;
-import lombok.Value;
-import org.apache.commons.lang3.tuple.Pair;
-import org.hl7.fhir.r4.model.BooleanType;
-import org.hl7.fhir.r4.model.CodeType;
-import org.hl7.fhir.r4.model.Coding;
-import org.hl7.fhir.r4.model.StringType;
-import org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome;
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -37,12 +31,18 @@
 import java.util.Set;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import lombok.AllArgsConstructor;
+import lombok.Value;
+import org.apache.commons.lang3.tuple.Pair;
+import org.hl7.fhir.r4.model.BooleanType;
+import org.hl7.fhir.r4.model.CodeType;
+import org.hl7.fhir.r4.model.Coding;
+import org.hl7.fhir.r4.model.StringType;
+import org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome;
 
-import static java.util.Objects.isNull;
-import static org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence.EQUIVALENT;
-import static org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence.RELATEDTO;
-
-public class MockTerminologyService2 implements TerminologyService2 {
+public class MockTerminologyService implements TerminologyService {
 
   @Value
   @AllArgsConstructor
@@ -73,7 +73,8 @@ static class ConceptMap {
       this.invertedMappings = invertedMappings;
     }
 
-    public List<Translation> translate(Coding coding, boolean reverse, final String target) {
+    public List<Translation> translate(final Coding coding, final boolean reverse,
+        final String target) {
       return (reverse
               ? invertedMappings
               : mappings)
@@ -104,7 +105,7 @@ boolean contains(@Nonnull final Coding coding) {
   private final Map<String, ConceptMap> conceptMap = new HashMap<>();
   private final Set<Pair<SystemAndCode, SystemAndCode>> subsumes = new HashSet<>();
 
-  public MockTerminologyService2() {
+  public MockTerminologyService() {
     valueSets.put("http://snomed.info/sct?fhir_vs=refset/723264001",
         new ValueSet(new Coding("http://snomed.info/sct", "368529001", null)));
     valueSets.put("http://loinc.org/vs/LP14885-5",
@@ -211,4 +212,3 @@ public List<PropertyOrDesignation> lookup(@Nonnull final Coding coding,
     }
   }
 }
-
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/mock/MockTerminologyServiceFactory.java b/terminology/src/main/java/au/csiro/pathling/terminology/mock/MockTerminologyServiceFactory.java
index 2096ca2c45..a7b5ef78fd 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/mock/MockTerminologyServiceFactory.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/mock/MockTerminologyServiceFactory.java
@@ -17,20 +17,20 @@
 
 package au.csiro.pathling.terminology.mock;
 
-import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.utilities.ObjectHolder;
 import javax.annotation.Nonnull;
 
 public class MockTerminologyServiceFactory implements TerminologyServiceFactory {
 
-  private final static ObjectHolder<String, TerminologyService2> service = ObjectHolder.singleton(
-      c -> new MockTerminologyService2());
+  private final static ObjectHolder<String, TerminologyService> service = ObjectHolder.singleton(
+      c -> new MockTerminologyService());
   private static final long serialVersionUID = 7662506865030951736L;
-  
+
   @Nonnull
   @Override
-  public TerminologyService2 buildService2() {
+  public TerminologyService build() {
     return service.getOrCreate("mock");
   }
 }
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/subsumes/SubsumesExecutor.java b/terminology/src/main/java/au/csiro/pathling/terminology/subsumes/SubsumesExecutor.java
new file mode 100644
index 0000000000..bf8925e77a
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/subsumes/SubsumesExecutor.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.terminology.subsumes;
+
+import static au.csiro.pathling.fhir.ParametersUtils.toSubsumptionOutcome;
+import static au.csiro.pathling.fhirpath.CodingHelpers.codingEquals;
+import static au.csiro.pathling.terminology.TerminologyParameters.required;
+import static org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome.NOTSUBSUMED;
+
+import au.csiro.pathling.fhir.TerminologyClient;
+import au.csiro.pathling.fhirpath.encoding.ImmutableCoding;
+import au.csiro.pathling.terminology.TerminologyOperation;
+import au.csiro.pathling.terminology.TerminologyParameters;
+import ca.uhn.fhir.rest.gclient.IOperationUntypedWithInput;
+import java.util.Optional;
+import javax.annotation.Nonnull;
+import org.hl7.fhir.r4.model.CodeType;
+import org.hl7.fhir.r4.model.Parameters;
+import org.hl7.fhir.r4.model.StringType;
+import org.hl7.fhir.r4.model.UriType;
+import org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome;
+
+/**
+ * An implementation of {@link TerminologyOperation} for the subsumes operation.
+ *
+ * @author John Grimes
+ * @see <a
+ * href="https://www.hl7.org/fhir/R4/codesystem-operation-subsumes.html">CodeSystem/$subsumes</a>
+ */
+public class SubsumesExecutor implements
+    TerminologyOperation<Parameters, ConceptSubsumptionOutcome> {
+
+  @Nonnull
+  private final TerminologyClient terminologyClient;
+
+  @Nonnull
+  private final SubsumesParameters parameters;
+
+  public SubsumesExecutor(@Nonnull final TerminologyClient terminologyClient,
+      @Nonnull final SubsumesParameters parameters) {
+    this.terminologyClient = terminologyClient;
+    this.parameters = parameters;
+  }
+
+  @Override
+  @Nonnull
+  public Optional<ConceptSubsumptionOutcome> validate() {
+    final ImmutableCoding codingA = parameters.getCodingA();
+    final ImmutableCoding codingB = parameters.getCodingB();
+
+    // If either of the systems are null, or they don't match, the result is not subsumed.
+    if (codingA.getSystem() == null || !codingA.getSystem().equals(codingB.getSystem())) {
+      return Optional.of(NOTSUBSUMED);
+    }
+
+    // If either code is null, the result is not subsumed.
+    if (codingA.getCode() == null || codingB.getCode() == null) {
+      return Optional.of(NOTSUBSUMED);
+    }
+
+    // If both versions are present, they must be equal.
+    if (!(codingA.getVersion() == null || codingB.getVersion() == null || codingA.getVersion()
+        .equals(codingB.getVersion()))) {
+      return Optional.of(NOTSUBSUMED);
+    }
+
+    // If both codings are obviously equal, we don't need to consult the terminology server.
+    if (codingEquals(codingA.toCoding(), codingB.toCoding())) {
+      return Optional.of(ConceptSubsumptionOutcome.SUBSUMES);
+    }
+
+    return Optional.empty();
+  }
+
+  @Override
+  @Nonnull
+  public IOperationUntypedWithInput<Parameters> buildRequest() {
+    final ImmutableCoding codingA = parameters.getCodingA();
+    final ImmutableCoding codingB = parameters.getCodingB();
+    final String resolvedSystem = codingA.getSystem();
+    final String resolvedVersion = codingA.getVersion() != null
+                                   ? codingA.getVersion()
+                                   : codingB.getVersion();
+
+    return terminologyClient.buildSubsumes(
+        required(CodeType::new, codingA.getCode()),
+        required(CodeType::new, codingB.getCode()),
+        required(UriType::new, resolvedSystem),
+        TerminologyParameters.optional(StringType::new, resolvedVersion)
+    );
+  }
+
+  @Override
+  @Nonnull
+  public ConceptSubsumptionOutcome extractResult(@Nonnull final Parameters response) {
+    return toSubsumptionOutcome(response);
+  }
+
+  @Override
+  @Nonnull
+  public ConceptSubsumptionOutcome invalidRequestFallback() {
+    return NOTSUBSUMED;
+  }
+
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/caching/InfinispanInMemoryStorage.java b/terminology/src/main/java/au/csiro/pathling/terminology/subsumes/SubsumesParameters.java
similarity index 53%
rename from terminology/src/main/java/au/csiro/pathling/caching/InfinispanInMemoryStorage.java
rename to terminology/src/main/java/au/csiro/pathling/terminology/subsumes/SubsumesParameters.java
index 2407363bbe..0a9b70043e 100644
--- a/terminology/src/main/java/au/csiro/pathling/caching/InfinispanInMemoryStorage.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/subsumes/SubsumesParameters.java
@@ -15,25 +15,25 @@
  * limitations under the License.
  */
 
-package au.csiro.pathling.caching;
+package au.csiro.pathling.terminology.subsumes;
 
-import org.apache.http.client.cache.HttpCacheStorage;
-import org.infinispan.configuration.cache.ConfigurationBuilder;
-import org.infinispan.manager.DefaultCacheManager;
+import au.csiro.pathling.fhirpath.encoding.ImmutableCoding;
+import au.csiro.pathling.terminology.TerminologyParameters;
+import lombok.Value;
 
 /**
- * An abstract {@link HttpCacheStorage} implementation that uses embedded Infinispan with in-memory
- * storage.
+ * Represents the input parameters to the subsumes operation.
  *
  * @author John Grimes
+ * @see <a
+ * href="https://www.hl7.org/fhir/R4/codesystem-operation-subsumes.html">CodeSystem/$subsumes</a>
  */
-public class InfinispanInMemoryStorage extends InfinispanStorage {
+@Value
+public class SubsumesParameters implements TerminologyParameters {
 
-  public InfinispanInMemoryStorage() {
-    super();
-    cacheManager = new DefaultCacheManager();
-    cacheManager.defineConfiguration(CACHE_NAME, new ConfigurationBuilder().build());
-    cache = cacheManager.getCache(CACHE_NAME);
-  }
+  private static final long serialVersionUID = 4690140629959363634L;
+
+  ImmutableCoding codingA;
+  ImmutableCoding codingB;
 
 }
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/translate/TranslateExecutor.java b/terminology/src/main/java/au/csiro/pathling/terminology/translate/TranslateExecutor.java
new file mode 100644
index 0000000000..7d9cc530a4
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/translate/TranslateExecutor.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.terminology.translate;
+
+import static au.csiro.pathling.fhir.ParametersUtils.toMatchParts;
+import static au.csiro.pathling.terminology.TerminologyParameters.optional;
+import static au.csiro.pathling.terminology.TerminologyParameters.required;
+import static java.util.Objects.isNull;
+
+import au.csiro.pathling.fhir.TerminologyClient;
+import au.csiro.pathling.fhirpath.encoding.ImmutableCoding;
+import au.csiro.pathling.terminology.TerminologyOperation;
+import au.csiro.pathling.terminology.TerminologyService.Translation;
+import au.csiro.pathling.terminology.caching.CacheableListCollector;
+import ca.uhn.fhir.rest.gclient.IOperationUntypedWithInput;
+import java.util.ArrayList;
+import java.util.Optional;
+import javax.annotation.Nonnull;
+import org.hl7.fhir.r4.model.BooleanType;
+import org.hl7.fhir.r4.model.CodeType;
+import org.hl7.fhir.r4.model.Parameters;
+import org.hl7.fhir.r4.model.StringType;
+import org.hl7.fhir.r4.model.UriType;
+import org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence;
+
+/**
+ * An implementation of {@link TerminologyOperation} for the translate operation.
+ *
+ * @author John Grimes
+ * @see <a
+ * href="https://www.hl7.org/fhir/R4/conceptmap-operation-translate.html">ConceptMap/$translate</a>
+ */
+public class TranslateExecutor implements
+    TerminologyOperation<Parameters, ArrayList<Translation>> {
+
+  @Nonnull
+  private final TerminologyClient terminologyClient;
+
+  @Nonnull
+  private final TranslateParameters parameters;
+
+  public TranslateExecutor(@Nonnull final TerminologyClient terminologyClient,
+      @Nonnull final TranslateParameters parameters) {
+    this.terminologyClient = terminologyClient;
+    this.parameters = parameters;
+  }
+
+  @Override
+  @Nonnull
+  public Optional<ArrayList<Translation>> validate() {
+    final ImmutableCoding coding = parameters.getCoding();
+
+    // If the system or the code of the coding is null, the result is an empty list.
+    if (isNull(coding.getSystem()) || isNull(coding.getCode())) {
+      return Optional.of(new ArrayList<>());
+    } else {
+      return Optional.empty();
+    }
+  }
+
+  @Override
+  @Nonnull
+  public IOperationUntypedWithInput<Parameters> buildRequest() {
+    final String conceptMapUrl = parameters.getConceptMapUrl();
+    final ImmutableCoding coding = parameters.getCoding();
+    final boolean reverse = parameters.isReverse();
+    final String target = parameters.getTarget();
+   
+    return terminologyClient.buildTranslate(
+        required(UriType::new, conceptMapUrl),
+        required(UriType::new, coding.getSystem()),
+        optional(StringType::new, coding.getVersion()),
+        required(CodeType::new, coding.getCode()),
+        new BooleanType(reverse),
+        optional(UriType::new, target)
+    );
+  }
+
+  @Override
+  @Nonnull
+  public ArrayList<Translation> extractResult(@Nonnull final Parameters response) {
+    return toTranslations(response);
+  }
+
+  @Override
+  @Nonnull
+  public ArrayList<Translation> invalidRequestFallback() {
+    return new ArrayList<>();
+  }
+
+  @Nonnull
+  private static ArrayList<Translation> toTranslations(final @Nonnull Parameters parameters) {
+    return toMatchParts(parameters)
+        .map(tp -> Translation.of(ConceptMapEquivalence.fromCode(tp.getEquivalence().getCode()),
+            tp.getConcept()))
+        .collect(new CacheableListCollector<>());
+  }
+
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/translate/TranslateParameters.java b/terminology/src/main/java/au/csiro/pathling/terminology/translate/TranslateParameters.java
new file mode 100644
index 0000000000..1b2d1acd1f
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/translate/TranslateParameters.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.terminology.translate;
+
+import au.csiro.pathling.fhirpath.encoding.ImmutableCoding;
+import au.csiro.pathling.terminology.TerminologyParameters;
+import lombok.Value;
+
+/**
+ * Represents the input parameters to the translate operation.
+ *
+ * @author John Grimes
+ * @see <a
+ * href="https://www.hl7.org/fhir/R4/codesystem-operation-translate.html">CodeSystem/$translate</a>
+ */
+@Value
+public class TranslateParameters implements TerminologyParameters {
+
+  private static final long serialVersionUID = 3514974005203890618L;
+
+  ImmutableCoding coding;
+  String conceptMapUrl;
+  boolean reverse;
+  String target;
+
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/validatecode/ValidateCodeExecutor.java b/terminology/src/main/java/au/csiro/pathling/terminology/validatecode/ValidateCodeExecutor.java
new file mode 100644
index 0000000000..db58b30a4d
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/validatecode/ValidateCodeExecutor.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.terminology.validatecode;
+
+import static au.csiro.pathling.fhir.ParametersUtils.toBooleanResult;
+import static au.csiro.pathling.terminology.TerminologyParameters.optional;
+import static au.csiro.pathling.terminology.TerminologyParameters.required;
+import static java.util.Objects.isNull;
+
+import au.csiro.pathling.fhir.TerminologyClient;
+import au.csiro.pathling.fhirpath.encoding.ImmutableCoding;
+import au.csiro.pathling.terminology.TerminologyOperation;
+import ca.uhn.fhir.rest.gclient.IOperationUntypedWithInput;
+import java.util.Optional;
+import javax.annotation.Nonnull;
+import org.hl7.fhir.r4.model.CodeType;
+import org.hl7.fhir.r4.model.Parameters;
+import org.hl7.fhir.r4.model.StringType;
+import org.hl7.fhir.r4.model.UriType;
+
+/**
+ * An implementation of {@link TerminologyOperation} for the validate-code operation.
+ *
+ * @author John Grimes
+ * @see <a
+ * href="https://www.hl7.org/fhir/R4/valueset-operation-validate-code.html">ValueSet/$validate-code</a>
+ */
+public class ValidateCodeExecutor implements
+    TerminologyOperation<Parameters, Boolean> {
+
+  @Nonnull
+  private final TerminologyClient terminologyClient;
+
+  @Nonnull
+  private final ValidateCodeParameters parameters;
+
+  public ValidateCodeExecutor(@Nonnull final TerminologyClient terminologyClient,
+      @Nonnull final ValidateCodeParameters parameters) {
+    this.terminologyClient = terminologyClient;
+    this.parameters = parameters;
+  }
+
+  @Override
+  @Nonnull
+  public Optional<Boolean> validate() {
+    final ImmutableCoding coding = parameters.getCoding();
+    if (isNull(coding.getSystem()) || isNull(coding.getCode())) {
+      return Optional.of(false);
+    } else {
+      return Optional.empty();
+    }
+  }
+
+  @Override
+  @Nonnull
+  public IOperationUntypedWithInput<Parameters> buildRequest() {
+    final String codeSystemUrl = parameters.getValueSetUrl();
+    final ImmutableCoding coding = parameters.getCoding();
+    return terminologyClient.buildValidateCode(
+        required(UriType::new, codeSystemUrl), required(UriType::new, coding.getSystem()),
+        optional(StringType::new, coding.getVersion()),
+        required(CodeType::new, coding.getCode())
+    );
+  }
+
+  @Override
+  @Nonnull
+  public Boolean extractResult(@Nonnull final Parameters response) {
+    return toBooleanResult(response);
+  }
+
+  @Override
+  @Nonnull
+  public Boolean invalidRequestFallback() {
+    return false;
+  }
+
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/validatecode/ValidateCodeParameters.java b/terminology/src/main/java/au/csiro/pathling/terminology/validatecode/ValidateCodeParameters.java
new file mode 100644
index 0000000000..8ddbe652a3
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/validatecode/ValidateCodeParameters.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.terminology.validatecode;
+
+import au.csiro.pathling.fhirpath.encoding.ImmutableCoding;
+import au.csiro.pathling.terminology.TerminologyParameters;
+import lombok.Value;
+
+/**
+ * Represents the input parameters to the validate-code operation.
+ *
+ * @author John Grimes
+ * @see <a
+ * href="https://www.hl7.org/fhir/R4/valueset-operation-validate-code.html">ValueSet/$validate-code</a>
+ */
+@Value
+public class ValidateCodeParameters implements TerminologyParameters {
+
+  private static final long serialVersionUID = -4527968941195449678L;
+
+  String valueSetUrl;
+  ImmutableCoding coding;
+
+}


Test patch:
diff --git a/fhir-server/src/test/java/au/csiro/pathling/aggregate/AggregateExecutorTest.java b/fhir-server/src/test/java/au/csiro/pathling/aggregate/AggregateExecutorTest.java
index f4a2c52fa7..0d77dd69a2 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/aggregate/AggregateExecutorTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/aggregate/AggregateExecutorTest.java
@@ -26,7 +26,7 @@
 import au.csiro.pathling.encoders.FhirEncoders;
 import au.csiro.pathling.io.Database;
 import au.csiro.pathling.search.SearchExecutor;
-import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.SharedMocks;
 import au.csiro.pathling.test.helpers.TestHelpers;
@@ -59,7 +59,7 @@ abstract class AggregateExecutorTest {
   SparkSession spark;
 
   @Autowired
-  TerminologyService2 terminologyService2;
+  TerminologyService terminologyService;
 
   @Autowired
   TerminologyServiceFactory terminologyServiceFactory;
diff --git a/fhir-server/src/test/java/au/csiro/pathling/aggregate/AggregateQueryTest.java b/fhir-server/src/test/java/au/csiro/pathling/aggregate/AggregateQueryTest.java
index f13a361b3f..fdc29b4e69 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/aggregate/AggregateQueryTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/aggregate/AggregateQueryTest.java
@@ -269,7 +269,7 @@ void queryWithMemberOf() {
 
     final String valueSetUrl = "http://snomed.info/sct?fhir_vs=refset/32570521000036109";
 
-    TerminologyServiceHelpers.setupValidate(terminologyService2)
+    TerminologyServiceHelpers.setupValidate(terminologyService)
         .withValueSet(valueSetUrl,
             CD_SNOMED_403190006, CD_SNOMED_284551006);
 
@@ -519,7 +519,7 @@ void queryWithNonSingularBooleanGrouping() {
     // Not a real subsumption - just works for this use case.
     // http://snomed.info/sct|284551006 -- subsumes --> http://snomed.info/sct|40055000
 
-    TerminologyServiceHelpers.setupSubsumes(terminologyService2)
+    TerminologyServiceHelpers.setupSubsumes(terminologyService)
         .withSubsumes(CD_SNOMED_284551006,
             TerminologyHelpers.CD_SNOMED_40055000);
 
diff --git a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/DisplayFunctionTest.java b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/DisplayFunctionTest.java
index bfc44ffa7d..7f43aa36f0 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/DisplayFunctionTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/DisplayFunctionTest.java
@@ -35,7 +35,7 @@
 import au.csiro.pathling.fhirpath.element.ElementPath;
 import au.csiro.pathling.fhirpath.literal.StringLiteralPath;
 import au.csiro.pathling.fhirpath.parser.ParserContext;
-import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.SharedMocks;
 import au.csiro.pathling.test.builders.DatasetBuilder;
@@ -74,7 +74,7 @@ class DisplayFunctionTest {
   TerminologyServiceFactory terminologyServiceFactory;
 
   @Autowired
-  TerminologyService2 terminologyService;
+  TerminologyService terminologyService;
 
   @BeforeEach
   void setUp() {
@@ -163,7 +163,7 @@ void throwsErrorIfTerminologyServiceNotConfigured() {
         "Attempt to call terminology function display when terminology service has not been configured",
         error.getMessage());
   }
-  
+
   @Test
   void inputMustNotContainArguments() {
     final ElementPath input = new ElementPathBuilder(spark).build();
diff --git a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/DesignationFunctionTest.java b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/DesignationFunctionTest.java
index 26fef77a76..6e4758a657 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/DesignationFunctionTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/DesignationFunctionTest.java
@@ -37,10 +37,9 @@
 import au.csiro.pathling.fhirpath.literal.CodingLiteral;
 import au.csiro.pathling.fhirpath.literal.CodingLiteralPath;
 import au.csiro.pathling.fhirpath.literal.IntegerLiteralPath;
-import au.csiro.pathling.fhirpath.literal.LiteralPath;
 import au.csiro.pathling.fhirpath.literal.StringLiteralPath;
 import au.csiro.pathling.fhirpath.parser.ParserContext;
-import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.AbstractTerminologyTestBase;
 import au.csiro.pathling.test.builders.DatasetBuilder;
@@ -63,7 +62,6 @@
 import org.apache.spark.sql.types.DataTypes;
 import org.hl7.fhir.r4.model.Coding;
 import org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType;
-import org.hl7.fhir.r4.model.Type;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
@@ -87,7 +85,7 @@ class DesignationFunctionTest extends AbstractTerminologyTestBase {
   TerminologyServiceFactory terminologyServiceFactory;
 
   @Autowired
-  TerminologyService2 terminologyService;
+  TerminologyService terminologyService;
 
   @BeforeEach
   void setUp() {
diff --git a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/MemberOfFunctionTest.java b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/MemberOfFunctionTest.java
index cf24cf2674..7f8eef3038 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/MemberOfFunctionTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/MemberOfFunctionTest.java
@@ -37,8 +37,6 @@
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 
 import au.csiro.pathling.errors.InvalidUserInputError;
-import au.csiro.pathling.terminology.TerminologyService2;
-import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.FhirPath;
 import au.csiro.pathling.fhirpath.element.BooleanPath;
 import au.csiro.pathling.fhirpath.element.CodingPath;
@@ -48,6 +46,8 @@
 import au.csiro.pathling.fhirpath.literal.IntegerLiteralPath;
 import au.csiro.pathling.fhirpath.literal.StringLiteralPath;
 import au.csiro.pathling.fhirpath.parser.ParserContext;
+import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.SharedMocks;
 import au.csiro.pathling.test.builders.DatasetBuilder;
 import au.csiro.pathling.test.builders.ElementPathBuilder;
@@ -88,7 +88,7 @@ class MemberOfFunctionTest {
   TerminologyServiceFactory terminologyServiceFactory;
 
   @Autowired
-  TerminologyService2 terminologyService;
+  TerminologyService terminologyService;
 
   @BeforeEach
   void setUp() {
diff --git a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/PropertyFunctionTest.java b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/PropertyFunctionTest.java
index 3a8762ee44..18385f0636 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/PropertyFunctionTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/PropertyFunctionTest.java
@@ -37,7 +37,7 @@
 import au.csiro.pathling.fhirpath.literal.IntegerLiteralPath;
 import au.csiro.pathling.fhirpath.literal.StringLiteralPath;
 import au.csiro.pathling.fhirpath.parser.ParserContext;
-import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.AbstractTerminologyTestBase;
 import au.csiro.pathling.test.builders.DatasetBuilder;
@@ -85,7 +85,7 @@ class PropertyFunctionTest extends AbstractTerminologyTestBase {
   TerminologyServiceFactory terminologyServiceFactory;
 
   @Autowired
-  TerminologyService2 terminologyService;
+  TerminologyService terminologyService;
 
   @BeforeEach
   void setUp() {
@@ -145,7 +145,7 @@ private void checkPropertyOfCoding(final String propertyCode,
 
     // Invoke the function.
     final FhirPath result = NamedFunction.getInstance("property").invoke(propertyInput);
-    
+
     // Check the result.
     assertThat(result)
         .hasExpression(
@@ -170,6 +170,7 @@ private void checkPropertyOfCoding(final String propertyCode,
     }
   }
 
+  @SuppressWarnings("unused")
   @ParameterizedTest
   @MethodSource("propertyParameters")
   public void propertyAOfCoding(final String propertyType, final DataType resultDataType,
@@ -191,6 +192,7 @@ public void propertyAOfCoding(final String propertyType, final DataType resultDa
         expectedResult);
   }
 
+  @SuppressWarnings("unused")
   @ParameterizedTest
   @MethodSource("propertyParameters")
   public void propertyBOfCoding(final String propertyType, final DataType resultDataType,
diff --git a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/SubsumesFunctionTest.java b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/SubsumesFunctionTest.java
index 65d8ff2a8b..05753b353c 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/SubsumesFunctionTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/SubsumesFunctionTest.java
@@ -38,7 +38,7 @@
 import au.csiro.pathling.fhirpath.literal.CodingLiteralPath;
 import au.csiro.pathling.fhirpath.literal.StringLiteralPath;
 import au.csiro.pathling.fhirpath.parser.ParserContext;
-import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.SharedMocks;
 import au.csiro.pathling.test.assertions.DatasetAssert;
@@ -99,7 +99,7 @@ class SubsumesFunctionTest {
   FhirContext fhirContext;
 
   @Autowired
-  TerminologyService2 terminologyService;
+  TerminologyService terminologyService;
 
   @Autowired
   TerminologyServiceFactory terminologyServiceFactory;
diff --git a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/TranslateFunctionTest.java b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/TranslateFunctionTest.java
index d2e8a96eef..d1e9a1e95d 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/TranslateFunctionTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/terminology/TranslateFunctionTest.java
@@ -47,8 +47,8 @@
 import au.csiro.pathling.fhirpath.literal.IntegerLiteralPath;
 import au.csiro.pathling.fhirpath.literal.StringLiteralPath;
 import au.csiro.pathling.fhirpath.parser.ParserContext;
-import au.csiro.pathling.terminology.TerminologyService2;
-import au.csiro.pathling.terminology.TerminologyService2.Translation;
+import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyService.Translation;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.builders.DatasetBuilder;
 import au.csiro.pathling.test.builders.ElementPathBuilder;
@@ -110,7 +110,7 @@ class TranslateFunctionTest {
   TerminologyServiceFactory terminologyServiceFactory;
 
   @Autowired
-  TerminologyService2 terminologyService;
+  TerminologyService terminologyService;
 
   @BeforeEach
   void setUp() {
diff --git a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/parser/AbstractParserTest.java b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/parser/AbstractParserTest.java
index a8468458eb..a3eeaab176 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/parser/AbstractParserTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/parser/AbstractParserTest.java
@@ -21,10 +21,10 @@
 import static org.mockito.Mockito.when;
 
 import au.csiro.pathling.encoders.FhirEncoders;
-import au.csiro.pathling.terminology.TerminologyService2;
-import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.ResourcePath;
 import au.csiro.pathling.io.Database;
+import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.SharedMocks;
 import au.csiro.pathling.test.TimingExtension;
 import au.csiro.pathling.test.assertions.FhirPathAssertion;
@@ -54,9 +54,9 @@ public class AbstractParserTest {
 
   @Autowired
   FhirContext fhirContext;
-  
+
   @Autowired
-  TerminologyService2 terminologyService2;
+  TerminologyService terminologyService;
 
   @Autowired
   FhirEncoders fhirEncoders;
diff --git a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/parser/ParserTest.java b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/parser/ParserTest.java
index c2cce7499d..95968eede9 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/parser/ParserTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/parser/ParserTest.java
@@ -72,7 +72,7 @@ private <T extends Throwable> T assertThrows(final Class<T> errorType, final Str
 
   private TranslateExpectations setupMockTranslationFor_195662009_444814009(
       final String conceptMapUrl) {
-    return TerminologyServiceHelpers.setupTranslate(terminologyService2)
+    return TerminologyServiceHelpers.setupTranslate(terminologyService)
         .withMockTranslations(CD_SNOMED_195662009,
             conceptMapUrl, "uuid:test-system", 3)
         .withMockTranslations(CD_SNOMED_444814009,
@@ -85,13 +85,13 @@ private void setupMockTranslationFor_195662009_444814009() {
   }
 
   private void setupMockDisplayFor_195662009_444814009() {
-    TerminologyServiceHelpers.setupLookup(terminologyService2)
+    TerminologyServiceHelpers.setupLookup(terminologyService)
         .withDisplay(CD_SNOMED_195662009)
         .withDisplay(CD_SNOMED_444814009);
   }
 
   private void setupMockPropertiesFor_195662009_444814009() {
-    TerminologyServiceHelpers.setupLookup(terminologyService2)
+    TerminologyServiceHelpers.setupLookup(terminologyService)
         .withProperty(CD_SNOMED_195662009, "child", CD_SNOMED_40055000, CD_SNOMED_403190006)
         .withProperty(CD_SNOMED_444814009, "child", CD_SNOMED_284551006)
         .withDesignation(CD_SNOMED_195662009, CD_SNOMED_900000000000003001, "en",
@@ -292,7 +292,7 @@ void testCount() {
   @Test
   void testSubsumesAndSubsumedBy() {
 
-    setupSubsumes(terminologyService2);
+    setupSubsumes(terminologyService);
     // Viral sinusitis (disorder) = http://snomed.info/sct|444814009 not in (PATIENT_ID_2b36c1e2,
     // PATIENT_ID_bbd33563, PATIENT_ID_7001ad9c)
     // Chronic sinusitis (disorder) = http://snomed.info/sct|40055000 in (PATIENT_ID_7001ad9c)
@@ -316,7 +316,7 @@ void testSubsumesAndSubsumedBy() {
         .selectOrderedResult()
         .hasRows(spark, "responses/ParserTest/testSubsumesAndSubsumedBy-subsumes-self.csv");
 
-    setupSubsumes(terminologyService2).withSubsumes(
+    setupSubsumes(terminologyService).withSubsumes(
         CD_SNOMED_444814009, CD_SNOMED_40055000);
     assertThatResultOf(
         "reverseResolve(Condition.subject).code.subsumes(http://snomed.info/sct|40055000)")
@@ -352,7 +352,7 @@ void testWhereWithContainsOperator() {
   @Test
   void testWhereWithSubsumes() {
 
-    setupSubsumes(terminologyService2).withSubsumes(CD_SNOMED_284551006, CD_SNOMED_40055000);
+    setupSubsumes(terminologyService).withSubsumes(CD_SNOMED_284551006, CD_SNOMED_40055000);
 
     assertThatResultOf(
         "where($this.reverseResolve(Condition.subject).code"
@@ -367,7 +367,7 @@ void testWhereWithSubsumes() {
   @Test
   void testWhereWithMemberOf() {
 
-    TerminologyServiceHelpers.setupValidate(terminologyService2)
+    TerminologyServiceHelpers.setupValidate(terminologyService)
         .withValueSet("http://snomed.info/sct?fhir_vs=refset/32570521000036109",
             CD_SNOMED_403190006, CD_SNOMED_284551006);
     assertThatResultOf(
diff --git a/fhir-server/src/test/java/au/csiro/pathling/search/SearchExecutorTest.java b/fhir-server/src/test/java/au/csiro/pathling/search/SearchExecutorTest.java
index 5b39e37c71..03f4bcaa65 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/search/SearchExecutorTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/search/SearchExecutorTest.java
@@ -25,7 +25,7 @@
 import au.csiro.pathling.config.Configuration;
 import au.csiro.pathling.encoders.FhirEncoders;
 import au.csiro.pathling.errors.InvalidUserInputError;
-import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.SharedMocks;
 import au.csiro.pathling.test.helpers.TerminologyServiceHelpers;
@@ -82,7 +82,7 @@ class SearchExecutorTest {
   TerminologyServiceFactory terminologyServiceFactory;
 
   @Autowired
-  TerminologyService2 terminologyService2;
+  TerminologyService terminologyService;
 
   @BeforeEach
   void setUp() {
@@ -102,7 +102,7 @@ void simpleSearchWithMemberOf() {
     final ValueSet valueSet = (ValueSet) jsonParser.parseResource(getResourceAsStream(
         "txResponses/SearchExecutorTest/simpleSearchWithMemberOf.ValueSet.json"));
 
-    TerminologyServiceHelpers.setupValidate(terminologyService2)
+    TerminologyServiceHelpers.setupValidate(terminologyService)
         .fromValueSet(
             "http://snomed.info/sct?fhir_vs=ecl/^ 32570581000036105 : << 263502005 = << 90734009",
             valueSet);
diff --git a/fhir-server/src/test/java/au/csiro/pathling/sql/udf/TerminologyUdsfTest.java b/fhir-server/src/test/java/au/csiro/pathling/sql/udf/TerminologyUdfTest.java
similarity index 99%
rename from fhir-server/src/test/java/au/csiro/pathling/sql/udf/TerminologyUdsfTest.java
rename to fhir-server/src/test/java/au/csiro/pathling/sql/udf/TerminologyUdfTest.java
index 636a58de18..e71db4485b 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/sql/udf/TerminologyUdsfTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/sql/udf/TerminologyUdfTest.java
@@ -17,8 +17,8 @@
 import au.csiro.pathling.errors.InvalidUserInputError;
 import au.csiro.pathling.fhirpath.encoding.CodingEncoding;
 import au.csiro.pathling.sql.Terminology;
-import au.csiro.pathling.terminology.TerminologyService2;
-import au.csiro.pathling.terminology.TerminologyService2.Translation;
+import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyService.Translation;
 import au.csiro.pathling.test.AbstractTerminologyTestBase;
 import au.csiro.pathling.test.SharedMocks;
 import au.csiro.pathling.test.assertions.DatasetAssert;
@@ -54,13 +54,13 @@
 
 @Tag("UnitTest")
 @SpringBootTest
-public class TerminologyUdsfTest extends AbstractTerminologyTestBase {
+public class TerminologyUdfTest extends AbstractTerminologyTestBase {
 
   @Autowired
   private SparkSession spark;
 
   @Autowired
-  TerminologyService2 terminologyService;
+  TerminologyService terminologyService;
 
   @Autowired
   IParser jsonParser;
@@ -660,4 +660,3 @@ public void testDesignationWithNoUse() {
   }
 
 }
- 
diff --git a/fhir-server/src/test/java/au/csiro/pathling/test/UnitTestDependencies.java b/fhir-server/src/test/java/au/csiro/pathling/test/UnitTestDependencies.java
index ab40fd9f7b..ed1f666e3c 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/test/UnitTestDependencies.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/UnitTestDependencies.java
@@ -22,10 +22,10 @@
 import au.csiro.pathling.config.Configuration;
 import au.csiro.pathling.encoders.FhirEncoders;
 import au.csiro.pathling.encoders.terminology.ucum.Ucum;
-import au.csiro.pathling.terminology.TerminologyService2;
-import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.spark.Spark;
 import au.csiro.pathling.spark.SparkConfigurer;
+import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.stubs.TestTerminologyServiceFactory;
 import ca.uhn.fhir.context.FhirContext;
 import ca.uhn.fhir.parser.IParser;
@@ -85,12 +85,12 @@ static IParser jsonParser(@Nonnull final FhirContext fhirContext) {
   static FhirEncoders fhirEncoders() {
     return FhirEncoders.forR4().getOrCreate();
   }
-  
+
   @Bean
   @ConditionalOnMissingBean
   @Nonnull
-  static TerminologyService2 terminologyService2() {
-    return SharedMocks.getOrCreate(TerminologyService2.class);
+  static TerminologyService terminologyService() {
+    return SharedMocks.getOrCreate(TerminologyService.class);
   }
 
   @Bean
diff --git a/fhir-server/src/test/java/au/csiro/pathling/test/assertions/Assertions.java b/fhir-server/src/test/java/au/csiro/pathling/test/assertions/Assertions.java
index d519b98d5d..1d9882155d 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/test/assertions/Assertions.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/assertions/Assertions.java
@@ -18,7 +18,6 @@
 package au.csiro.pathling.test.assertions;
 
 import static au.csiro.pathling.test.TestResources.getResourceAsUrl;
-import static org.junit.jupiter.api.Assertions.fail;
 
 import au.csiro.pathling.fhirpath.FhirPath;
 import au.csiro.pathling.fhirpath.ResourcePath;
@@ -64,6 +63,7 @@ public static DatasetAssert assertThat(@Nonnull final Dataset<Row> rowDataset) {
     return new DatasetAssert(rowDataset);
   }
 
+  @SuppressWarnings("unused")
   public static void assertMatches(@Nonnull final String expectedRegex,
       @Nonnull final String actualString) {
     if (!Pattern.matches(expectedRegex, actualString)) {
diff --git a/fhir-server/src/test/java/au/csiro/pathling/test/assertions/DatasetAssert.java b/fhir-server/src/test/java/au/csiro/pathling/test/assertions/DatasetAssert.java
index 56503ab3bf..8cfb0ef421 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/test/assertions/DatasetAssert.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/assertions/DatasetAssert.java
@@ -20,8 +20,6 @@
 import static au.csiro.pathling.test.assertions.Assertions.assertDatasetAgainstCsv;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.junit.jupiter.api.Assertions.fail;
 
 import au.csiro.pathling.config.Configuration;
 import au.csiro.pathling.config.StorageConfiguration;
@@ -125,7 +123,7 @@ public DatasetAssert rowsAreAllNotEqual(@Nonnull final List<Row> expected) {
     return this;
   }
 
-  @SuppressWarnings("UnusedReturnValue")
+  @SuppressWarnings({"UnusedReturnValue", "unused"})
   @Nonnull
   public DatasetAssert rowsAreAllNotEqual(@Nonnull final Dataset<Row> expected) {
     return rowsAreAllNotEqual(expected.collectAsList());
diff --git a/fhir-server/src/test/java/au/csiro/pathling/test/integration/TerminologyService2IntegrationTest.java b/fhir-server/src/test/java/au/csiro/pathling/test/integration/TerminologyServiceIntegrationTest.java
similarity index 95%
rename from fhir-server/src/test/java/au/csiro/pathling/test/integration/TerminologyService2IntegrationTest.java
rename to fhir-server/src/test/java/au/csiro/pathling/test/integration/TerminologyServiceIntegrationTest.java
index f07c7729ae..76c66ed6c2 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/test/integration/TerminologyService2IntegrationTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/integration/TerminologyServiceIntegrationTest.java
@@ -47,11 +47,11 @@
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyService.Designation;
+import au.csiro.pathling.terminology.TerminologyService.Property;
+import au.csiro.pathling.terminology.TerminologyService.Translation;
 import au.csiro.pathling.io.Database;
-import au.csiro.pathling.terminology.TerminologyService2;
-import au.csiro.pathling.terminology.TerminologyService2.Designation;
-import au.csiro.pathling.terminology.TerminologyService2.Property;
-import au.csiro.pathling.terminology.TerminologyService2.Translation;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import com.github.tomakehurst.wiremock.recording.RecordSpecBuilder;
 import java.util.Collections;
@@ -80,7 +80,7 @@
 @Tag("Tranche2")
 @Slf4j
 @ActiveProfiles({"core", "server", "integration-test"})
-class TerminologyService2IntegrationTest extends WireMockTest {
+class TerminologyServiceIntegrationTest extends WireMockTest {
 
   private static final String SNOMED_VERSION_UNKN = "http://snomed.info/sct/32506021000036107/version/19000101";
   private final static Coding CD_SNOMED_403190006_VERSION_UNKN = newVersionedCoding(
@@ -106,13 +106,13 @@ class TerminologyService2IntegrationTest extends WireMockTest {
   @Value("${pathling.test.recording.terminologyServerUrl}")
   String recordingTxServerUrl;
 
-  private TerminologyService2 terminologyService;
+  private TerminologyService terminologyService;
 
   @BeforeEach
   @Override
   void setUp() {
     super.setUp();
-    terminologyService = terminologyServiceFactory.buildService2();
+    terminologyService = terminologyServiceFactory.build();
     if (isRecordMode()) {
       wireMockServer.resetAll();
       log.warn("Proxying all request to: {}", recordingTxServerUrl);
@@ -134,7 +134,7 @@ void tearDown() {
   @Test
   void testCorrectlyTranslatesKnownAndUnknownSystems() {
 
-    List<Translation> result = terminologyService.translate(
+    final List<Translation> result = terminologyService.translate(
         CD_SNOMED_72940011000036107, CM_HIST_ASSOCIATIONS, false, null);
 
     assertEquals(1, result.size());
@@ -157,7 +157,7 @@ void testCorrectlyTranslatesKnownAndUnknownSystems() {
   @Test
   void testCorrectlyTranslatesInReverse() {
 
-    List<Translation> result = terminologyService.translate(
+    final List<Translation> result = terminologyService.translate(
         CD_SNOMED_720471000168102_VER2021, CM_HIST_ASSOCIATIONS, true, null);
 
     assertEquals(1, result.size());
@@ -171,7 +171,7 @@ void testTranslatesWithTargetAndMultipleResults() {
     final Coding input = new Coding(AUTOMAP_INPUT_URI, "shortness of breath", null);
     final String target = "http://snomed.info/sct?fhir_vs=ecl/(%3C%3C%2064572001%20%7CDisease%7C%20OR%20%3C%3C%20404684003%20%7CClinical%20finding%7C)";
 
-    List<Translation> result = terminologyService.translate(
+    final List<Translation> result = terminologyService.translate(
         input, CM_AUTOMAP_DEFAULT, false, target);
     // TODO: Why this one has xsct? But this version cannot be used in input codings?
     final String version = "http://snomed.info/xsct/32506021000036107/version/20220930";
@@ -300,7 +300,7 @@ void testLookupNamedProperties() {
   }
 
   @Test
-  void testLookupSubproperties() {
+  void testLookupSubProperties() {
     assertEquals(
         List.of(
             Property.of("260686004", new CodeType("129304002"))
diff --git a/fhir-server/src/test/java/au/csiro/pathling/test/stubs/TestTerminologyServiceFactory.java b/fhir-server/src/test/java/au/csiro/pathling/test/stubs/TestTerminologyServiceFactory.java
index d633e80e16..1dc4077e1e 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/test/stubs/TestTerminologyServiceFactory.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/stubs/TestTerminologyServiceFactory.java
@@ -17,7 +17,7 @@
 
 package au.csiro.pathling.test.stubs;
 
-import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.SharedMocks;
 import javax.annotation.Nonnull;
@@ -28,11 +28,11 @@ public class TestTerminologyServiceFactory implements TerminologyServiceFactory
 
   public TestTerminologyServiceFactory() {
   }
-  
+
   @Nonnull
   @Override
-  public TerminologyService2 buildService2() {
-    return SharedMocks.getOrCreate(TerminologyService2.class);
+  public TerminologyService build() {
+    return SharedMocks.getOrCreate(TerminologyService.class);
   }
 
 }
diff --git a/lib/python/tests/test_udfs.py b/lib/python/tests/test_udfs.py
index c869b8a450..3b6fe111ee 100644
--- a/lib/python/tests/test_udfs.py
+++ b/lib/python/tests/test_udfs.py
@@ -1,12 +1,12 @@
 #  Copyright 2022 Commonwealth Scientific and Industrial Research
 #  Organisation (CSIRO) ABN 41 687 119 230.
-# 
+#
 #  Licensed under the Apache License, Version 2.0 (the "License");
 #  you may not use this file except in compliance with the License.
 #  You may obtain a copy of the License at
-# 
+#
 #      http://www.apache.org/licenses/LICENSE-2.0
-# 
+#
 #  Unless required by applicable law or agreed to in writing, software
 #  distributed under the License is distributed on an "AS IS" BASIS,
 #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -19,20 +19,36 @@
 
 from pyspark.sql import SparkSession, DataFrame
 from pyspark.sql.functions import lit
-from pyspark.sql.types import StructType, StringType, StructField, BooleanType, Row, ArrayType, \
-    IntegerType
+from pyspark.sql.types import (
+    StructType,
+    StringType,
+    StructField,
+    BooleanType,
+    Row,
+    ArrayType,
+    IntegerType,
+)
 from pytest import fixture
 
 from pathling import PathlingContext
 from pathling.coding import Coding
-from pathling.functions import SNOMED_URI, to_snomed_coding
 from pathling.etc import find_jar as find_pathling_jar
-from pathling.udfs import member_of, subsumes, subsumed_by, translate, display, PropertyType, \
-    Equivalence, \
-    property_of, designation
+from pathling.functions import SNOMED_URI, to_snomed_coding
+from pathling.udfs import (
+    member_of,
+    subsumes,
+    subsumed_by,
+    translate,
+    display,
+    PropertyType,
+    Equivalence,
+    property_of,
+    designation,
+)
 
 PROJECT_DIR = os.path.abspath(
-        os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, os.pardir))
+    os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, os.pardir)
+)
 
 
 @fixture(scope="module")
@@ -42,17 +58,18 @@ def spark(request):
     testing session.
     """
 
-    gateway_log = logging.getLogger('java_gateway')
+    gateway_log = logging.getLogger("java_gateway")
     gateway_log.setLevel(logging.ERROR)
 
     # Get the shaded JAR for testing purposes.
-    spark = SparkSession.builder \
-        .appName('pathling-test') \
-        .master('local[1]') \
-        .config('spark.jars', find_pathling_jar()) \
-        .config('spark.sql.warehouse.dir', mkdtemp()) \
-        .config('spark.driver.memory', '4g') \
+    spark = (
+        SparkSession.builder.appName("pathling-test")
+        .master("local[1]")
+        .config("spark.jars", find_pathling_jar())
+        .config("spark.sql.warehouse.dir", mkdtemp())
+        .config("spark.driver.memory", "4g")
         .getOrCreate()
+    )
 
     request.addfinalizer(lambda: spark.stop())
     PathlingContext.create(spark, mock_terminology=True)
@@ -60,15 +77,17 @@ def spark(request):
     return spark
 
 
-CODING_TYPE = StructType([
-    StructField("id", StringType(), True),
-    StructField("system", StringType(), True),
-    StructField("version", StringType(), True),
-    StructField("code", StringType(), True),
-    StructField("display", StringType(), True),
-    StructField("userSelected", BooleanType(), True),
-    StructField("_fid", IntegerType(), True),
-])
+CODING_TYPE = StructType(
+    [
+        StructField("id", StringType(), True),
+        StructField("system", StringType(), True),
+        StructField("version", StringType(), True),
+        StructField("code", StringType(), True),
+        StructField("display", StringType(), True),
+        StructField("userSelected", BooleanType(), True),
+        StructField("_fid", IntegerType(), True),
+    ]
+)
 
 LOINC_URI = "http://loinc.org"
 
@@ -88,76 +107,98 @@ def loinc_coding_row(code: str):
 
 def test_member_of(spark: SparkSession):
     df = spark.createDataFrame(
-            [
-                ("code-1", snomed_coding_row("368529001")),
-                ("code-2", loinc_coding_row("55915-3")),
-                ("code-3", None),
-            ],
-            schema=StructType([StructField("id", StringType()),
-                               StructField("code", CODING_TYPE)])
+        [
+            ("code-1", snomed_coding_row("368529001")),
+            ("code-2", loinc_coding_row("55915-3")),
+            ("code-3", None),
+        ],
+        schema=StructType(
+            [StructField("id", StringType()), StructField("code", CODING_TYPE)]
+        ),
     )
 
     result_df_col = df.select(
-            df["id"],
-            member_of("code",
-                      "http://snomed.info/sct?fhir_vs=refset/723264001").alias(
-                    "is_member"))
+        df["id"],
+        member_of("code", "http://snomed.info/sct?fhir_vs=refset/723264001").alias(
+            "is_member"
+        ),
+    )
 
-    assert result_df_col.collect() == [Result("code-1", True), Result("code-2", False),
-                                       Result("code-3", None)]
+    assert result_df_col.collect() == [
+        Result("code-1", True),
+        Result("code-2", False),
+        Result("code-3", None),
+    ]
 
     result_df_str = df.select(
-            "id",
-            member_of("code",
-                      "http://loinc.org/vs/LP14885-5").alias(
-                    "is_member"))
+        "id", member_of("code", "http://loinc.org/vs/LP14885-5").alias("is_member")
+    )
 
-    assert result_df_str.collect() == [Result("code-1", False), Result("code-2", True),
-                                       Result("code-3", None)]
+    assert result_df_str.collect() == [
+        Result("code-1", False),
+        Result("code-2", True),
+        Result("code-3", None),
+    ]
 
     result_df_coding = df.limit(1).select(
-            "id",
-            member_of(Coding(SNOMED_URI, "368529001"),
-                      "http://snomed.info/sct?fhir_vs=refset/723264001").alias(
-                    "result"))
+        "id",
+        member_of(
+            Coding(SNOMED_URI, "368529001"),
+            "http://snomed.info/sct?fhir_vs=refset/723264001",
+        ).alias("result"),
+    )
 
     assert result_df_coding.collect() == [Result("code-1", True)]
 
 
 def test_member_of_array(spark: SparkSession):
     df = spark.createDataFrame(
+        [
+            (
+                "code-1",
+                [snomed_coding_row("368529001"), snomed_coding_row("368529002")],
+            ),
+            ("code-2", [loinc_coding_row("55915-3"), loinc_coding_row("55915-4")]),
+            ("code-3", None),
+        ],
+        schema=StructType(
             [
-                ("code-1", [snomed_coding_row("368529001"), snomed_coding_row("368529002")]),
-                ("code-2", [loinc_coding_row("55915-3"), loinc_coding_row("55915-4")]),
-                ("code-3", None),
-            ],
-            schema=StructType([StructField("id", StringType()),
-                               StructField("code", ArrayType(CODING_TYPE))])
+                StructField("id", StringType()),
+                StructField("code", ArrayType(CODING_TYPE)),
+            ]
+        ),
     )
 
     result_df_col = df.select(
-            df["id"],
-            member_of("code",
-                      "http://snomed.info/sct?fhir_vs=refset/723264001").alias(
-                    "is_member"))
+        df["id"],
+        member_of("code", "http://snomed.info/sct?fhir_vs=refset/723264001").alias(
+            "is_member"
+        ),
+    )
 
-    assert result_df_col.collect() == [Result("code-1", True), Result("code-2", False),
-                                       Result("code-3", None)]
+    assert result_df_col.collect() == [
+        Result("code-1", True),
+        Result("code-2", False),
+        Result("code-3", None),
+    ]
 
     result_df_str = df.select(
-            "id",
-            member_of("code",
-                      "http://loinc.org/vs/LP14885-5").alias(
-                    "is_member"))
+        "id", member_of("code", "http://loinc.org/vs/LP14885-5").alias("is_member")
+    )
 
-    assert result_df_str.collect() == [Result("code-1", False), Result("code-2", True),
-                                       Result("code-3", None)]
+    assert result_df_str.collect() == [
+        Result("code-1", False),
+        Result("code-2", True),
+        Result("code-3", None),
+    ]
 
     result_df_coding = df.limit(1).select(
-            "id",
-            member_of(Coding(SNOMED_URI, "368529001"),
-                      "http://snomed.info/sct?fhir_vs=refset/723264001").alias(
-                    "result"))
+        "id",
+        member_of(
+            Coding(SNOMED_URI, "368529001"),
+            "http://snomed.info/sct?fhir_vs=refset/723264001",
+        ).alias("result"),
+    )
 
     assert result_df_coding.collect() == [Result("code-1", True)]
 
@@ -165,142 +206,179 @@ def test_member_of_array(spark: SparkSession):
 @fixture(scope="module")
 def subsumption_df(spark: SparkSession):
     return spark.createDataFrame(
+        [
+            ("id-1", snomed_coding_row("107963000"), [snomed_coding_row("63816008")]),
+            (
+                "id-2",
+                loinc_coding_row("55915-3"),
+                [snomed_coding_row("63816008"), loinc_coding_row("55914-3")],
+            ),
+            ("id-3", None, [snomed_coding_row("107963000")]),
+        ],
+        schema=StructType(
             [
-                ("id-1", snomed_coding_row("107963000"), [snomed_coding_row("63816008")]),
-                ("id-2", loinc_coding_row("55915-3"),
-                 [snomed_coding_row("63816008"), loinc_coding_row("55914-3")]),
-                ("id-3", None, [snomed_coding_row("107963000")]),
-            ],
-            schema=StructType([StructField("id", StringType()),
-                               StructField("codeA", CODING_TYPE),
-                               StructField("codeB", ArrayType(CODING_TYPE))
-                               ])
+                StructField("id", StringType()),
+                StructField("codeA", CODING_TYPE),
+                StructField("codeB", ArrayType(CODING_TYPE)),
+            ]
+        ),
     )
 
 
 def test_subsumes(subsumption_df):
-    result_df = subsumption_df.select("id",
-                                      subsumes(subsumption_df["codeA"], "codeB").alias("result"))
+    result_df = subsumption_df.select(
+        "id", subsumes(subsumption_df["codeA"], "codeB").alias("result")
+    )
     assert result_df.collect() == [
         Result("id-1", True),
         Result("id-2", False),
-        Result("id-3", None)
+        Result("id-3", None),
     ]
 
-    result_df = subsumption_df.select("id", subsumes("codeA", Coding(SNOMED_URI, "63816008")).alias(
-            "result"))
+    result_df = subsumption_df.select(
+        "id", subsumes("codeA", Coding(SNOMED_URI, "63816008")).alias("result")
+    )
     assert result_df.collect() == [
         Result("id-1", True),
         Result("id-2", False),
-        Result("id-3", None)
+        Result("id-3", None),
     ]
 
-    result_df = subsumption_df.select("id",
-                                      subsumes(Coding(LOINC_URI, "55914-3"),
-                                               subsumption_df["codeB"]).alias(
-                                              "result"))
+    result_df = subsumption_df.select(
+        "id",
+        subsumes(Coding(LOINC_URI, "55914-3"), subsumption_df["codeB"]).alias("result"),
+    )
     assert result_df.collect() == [
         Result("id-1", False),
         Result("id-2", True),
-        Result("id-3", False)
+        Result("id-3", False),
     ]
 
 
 def test_subsumed_by(subsumption_df):
-    result_df = subsumption_df.select("id",
-                                      subsumed_by(subsumption_df["codeB"], "codeA").alias("result"))
+    result_df = subsumption_df.select(
+        "id", subsumed_by(subsumption_df["codeB"], "codeA").alias("result")
+    )
     assert result_df.collect() == [
         Result("id-1", True),
         Result("id-2", False),
-        Result("id-3", None)
+        Result("id-3", None),
     ]
 
-    result_df = subsumption_df.select("id",
-                                      subsumed_by(Coding(SNOMED_URI, "63816008"), "codeA").alias(
-                                              "result"))
+    result_df = subsumption_df.select(
+        "id", subsumed_by(Coding(SNOMED_URI, "63816008"), "codeA").alias("result")
+    )
     assert result_df.collect() == [
         Result("id-1", True),
         Result("id-2", False),
-        Result("id-3", None)
+        Result("id-3", None),
     ]
 
-    result_df = subsumption_df.select("id",
-                                      subsumed_by(subsumption_df["codeB"],
-                                                  Coding(LOINC_URI, "55914-3")).alias("result"))
+    result_df = subsumption_df.select(
+        "id",
+        subsumed_by(subsumption_df["codeB"], Coding(LOINC_URI, "55914-3")).alias(
+            "result"
+        ),
+    )
     assert result_df.collect() == [
         Result("id-1", False),
         Result("id-2", True),
-        Result("id-3", False)
+        Result("id-3", False),
     ]
 
 
 def test_translate(spark: SparkSession):
     df = spark.createDataFrame(
-            [
-                ("id-1", snomed_coding_row("368529001")),
-                ("id-2", loinc_coding_row("55915-3")),
-                ("id-3", None),
-            ],
-            schema=StructType([StructField("id", StringType()),
-                               StructField("code", CODING_TYPE)])
+        [
+            ("id-1", snomed_coding_row("368529001")),
+            ("id-2", loinc_coding_row("55915-3")),
+            ("id-3", None),
+        ],
+        schema=StructType(
+            [StructField("id", StringType()), StructField("code", CODING_TYPE)]
+        ),
     )
 
-    result_df = df.select("id", translate(
-            df["code"], "http://snomed.info/sct?fhir_cm=100").alias("result"))
+    result_df = df.select(
+        "id",
+        translate(df["code"], "http://snomed.info/sct?fhir_cm=100").alias("result"),
+    )
 
     assert result_df.collect() == [
         Result("id-1", [snomed_coding_row("368529002")]),
         Result("id-2", []),
         Result("id-3", None),
-    ];
+    ]
 
-    result_df = df.select("id", translate(
-            "code", "http://snomed.info/sct?fhir_cm=100",
-            equivalences={Equivalence.EQUIVALENT, Equivalence.RELATEDTO}).alias("result"))
+    result_df = df.select(
+        "id",
+        translate(
+            "code",
+            "http://snomed.info/sct?fhir_cm=100",
+            equivalences={Equivalence.EQUIVALENT, Equivalence.RELATEDTO},
+        ).alias("result"),
+    )
     assert result_df.collect() == [
         Result("id-1", [snomed_coding_row("368529002"), loinc_coding_row("55916-3")]),
         Result("id-2", []),
         Result("id-3", None),
-    ];
+    ]
 
-    result_df = df.select("id", translate(
-            "code", "http://snomed.info/sct?fhir_cm=100",
-            equivalences={"equivalent", "relatedto"}, target=LOINC_URI).alias("result"))
+    result_df = df.select(
+        "id",
+        translate(
+            "code",
+            "http://snomed.info/sct?fhir_cm=100",
+            equivalences={"equivalent", "relatedto"},
+            target=LOINC_URI,
+        ).alias("result"),
+    )
     assert result_df.collect() == [
         Result("id-1", [loinc_coding_row("55916-3")]),
         Result("id-2", []),
         Result("id-3", None),
-    ];
+    ]
 
-    result_df = df.select("id", translate(
-            "code", "http://snomed.info/sct?fhir_cm=200",
-            equivalences=[Equivalence.EQUIVALENT, Equivalence.RELATEDTO]).alias("result"))
+    result_df = df.select(
+        "id",
+        translate(
+            "code",
+            "http://snomed.info/sct?fhir_cm=200",
+            equivalences=[Equivalence.EQUIVALENT, Equivalence.RELATEDTO],
+        ).alias("result"),
+    )
     assert result_df.collect() == [
         Result("id-1", []),
         Result("id-2", []),
         Result("id-3", None),
-    ];
+    ]
 
-    result_df = df.limit(1).select("id", translate(
-            Coding(LOINC_URI, "55915-3"), "http://snomed.info/sct?fhir_cm=200",
+    result_df = df.limit(1).select(
+        "id",
+        translate(
+            Coding(LOINC_URI, "55915-3"),
+            "http://snomed.info/sct?fhir_cm=200",
             reverse=True,
-            equivalences=Equivalence.RELATEDTO).alias("result"))
+            equivalences=Equivalence.RELATEDTO,
+        ).alias("result"),
+    )
 
     assert result_df.collect() == [
         Result("id-1", [snomed_coding_row("368529002")]),
-    ];
+    ]
 
 
 @fixture(scope="module")
 def property_df(spark: SparkSession):
     return spark.createDataFrame(
-            [
-                ("id-1", snomed_coding_row("439319006")),
-                ("id-2", loinc_coding_row("55915-3")),
-                ("id-3", None),
-            ],
-            schema=StructType([StructField("id", StringType()),
-                               StructField("code", CODING_TYPE)])
+        [
+            ("id-1", snomed_coding_row("439319006")),
+            ("id-2", loinc_coding_row("55915-3")),
+            ("id-3", None),
+        ],
+        schema=StructType(
+            [StructField("id", StringType()), StructField("code", CODING_TYPE)]
+        ),
     )
 
 
@@ -317,81 +395,104 @@ def test_display(property_df: DataFrame):
     result_df = property_df.select("id", display(property_df["code"]).alias("result"))
     assert result_df.collect() == expected_result
 
-    result_df = property_df.limit(1) \
-        .select("id", display(Coding(SNOMED_URI, "439319006")).alias("result"))
+    result_df = property_df.limit(1).select(
+        "id", display(Coding(SNOMED_URI, "439319006")).alias("result")
+    )
     assert result_df.collect() == [
         Result("id-1", "Screening for phenothiazine in serum")
-    ];
+    ]
 
 
 def test_property_of(property_df: DataFrame):
-    result_df = property_df \
-        .select("id", property_of("code", "parent", PropertyType.CODE).alias("result"))
+    result_df = property_df.select(
+        "id", property_of("code", "parent", PropertyType.CODE).alias("result")
+    )
     assert result_df.collect() == [
         Result("id-1", ["785673007", "74754006"]),
         Result("id-2", []),
         Result("id-3", None),
     ]
 
-    result_df = property_df \
-        .select("id",
-                property_of(property_df["code"], "inactive", PropertyType.BOOLEAN).alias("result"))
+    result_df = property_df.select(
+        "id",
+        property_of(property_df["code"], "inactive", PropertyType.BOOLEAN).alias(
+            "result"
+        ),
+    )
     assert result_df.collect() == [
         Result("id-1", []),
         Result("id-2", [False]),
         Result("id-3", None),
     ]
 
-    result_df = property_df.limit(1) \
-        .select("id",
-                property_of(Coding(SNOMED_URI, "439319006"), "display").alias("result"))
+    result_df = property_df.limit(1).select(
+        "id", property_of(Coding(SNOMED_URI, "439319006"), "display").alias("result")
+    )
     assert result_df.collect() == [
         Result("id-1", ["Screening for phenothiazine in serum"])
-    ];
+    ]
 
 
-USE_DISPLAY = Coding("http://terminology.hl7.org/CodeSystem/designation-usage",
-                     "display")
+USE_DISPLAY = Coding(
+    "http://terminology.hl7.org/CodeSystem/designation-usage", "display"
+)
 
 
 def test_designation(property_df: DataFrame):
-    result_df = property_df \
-        .select("id", designation("code").alias("result"))
+    result_df = property_df.select("id", designation("code").alias("result"))
 
     assert result_df.collect() == [
-        Result("id-1", ["Screening for phenothiazine in serum",
-                        "Screening for phenothiazine in serum (procedure)"]),
-        Result("id-2",
-               ["Beta 2 globulin [Mass/volume] in Cerebral spinal fluid by Electrophoresis",
+        Result(
+            "id-1",
+            [
+                "Screening for phenothiazine in serum",
+                "Screening for phenothiazine in serum (procedure)",
+            ],
+        ),
+        Result(
+            "id-2",
+            [
+                "Beta 2 globulin [Mass/volume] in Cerebral spinal fluid by Electrophoresis",
                 "Bêta-2 globulines [Masse/Volume] Liquide céphalorachidien",
-                "Beta 2 globulin:MCnc:Pt:CSF:Qn:Electrophoresis"]),
-
+                "Beta 2 globulin:MCnc:Pt:CSF:Qn:Electrophoresis",
+            ],
+        ),
         Result("id-3", None),
     ]
 
-    result_df = property_df \
-        .select("id", designation("code", USE_DISPLAY).alias("result"))
+    result_df = property_df.select(
+        "id", designation("code", USE_DISPLAY).alias("result")
+    )
 
     assert result_df.collect() == [
         Result("id-1", ["Screening for phenothiazine in serum"]),
-        Result("id-2",
-               ["Beta 2 globulin [Mass/volume] in Cerebral spinal fluid by Electrophoresis",
-                "Bêta-2 globulines [Masse/Volume] Liquide céphalorachidien"]),
+        Result(
+            "id-2",
+            [
+                "Beta 2 globulin [Mass/volume] in Cerebral spinal fluid by Electrophoresis",
+                "Bêta-2 globulines [Masse/Volume] Liquide céphalorachidien",
+            ],
+        ),
         Result("id-3", None),
     ]
 
-    result_df = property_df \
-        .select("id", designation(property_df["code"], USE_DISPLAY, "fr-FR").alias("result"))
+    result_df = property_df.select(
+        "id", designation(property_df["code"], USE_DISPLAY, "fr-FR").alias("result")
+    )
     assert result_df.collect() == [
         Result("id-1", []),
         Result("id-2", ["Bêta-2 globulines [Masse/Volume] Liquide céphalorachidien"]),
         Result("id-3", None),
     ]
 
-    result_df = property_df.limit(1) \
-        .select("id", designation(Coding(SNOMED_URI, "439319006"),
-                                  to_snomed_coding(lit("900000000000003001")),
-                                  "en").alias("result"))
+    result_df = property_df.limit(1).select(
+        "id",
+        designation(
+            Coding(SNOMED_URI, "439319006"),
+            to_snomed_coding(lit("900000000000003001")),
+            "en",
+        ).alias("result"),
+    )
     assert result_df.collect() == [
         Result("id-1", ["Screening for phenothiazine in serum (procedure)"])
     ]
diff --git a/library-api/src/test/java/au/csiro/pathling/library/PathlingContextTest.java b/library-api/src/test/java/au/csiro/pathling/library/PathlingContextTest.java
index a75f38a3ab..f4348dbba2 100644
--- a/library-api/src/test/java/au/csiro/pathling/library/PathlingContextTest.java
+++ b/library-api/src/test/java/au/csiro/pathling/library/PathlingContextTest.java
@@ -36,8 +36,8 @@
 import au.csiro.pathling.encoders.FhirEncoders;
 import au.csiro.pathling.fhirpath.encoding.CodingEncoding;
 import au.csiro.pathling.terminology.DefaultTerminologyServiceFactory;
-import au.csiro.pathling.terminology.TerminologyService2;
-import au.csiro.pathling.terminology.TerminologyService2.Translation;
+import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyService.Translation;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.SchemaAsserts;
 import ca.uhn.fhir.context.FhirVersionEnum;
@@ -89,16 +89,16 @@ public static void tearDownAll() {
   }
 
   private TerminologyServiceFactory terminologyServiceFactory;
-  private TerminologyService2 terminologyService;
+  private TerminologyService terminologyService;
 
   @BeforeEach
   public void setUp() {
     // setup terminology mocks
     terminologyServiceFactory = mock(
         TerminologyServiceFactory.class, withSettings().serializable());
-    terminologyService = mock(TerminologyService2.class,
+    terminologyService = mock(TerminologyService.class,
         withSettings().serializable());
-    when(terminologyServiceFactory.buildService2()).thenReturn(terminologyService);
+    when(terminologyServiceFactory.build()).thenReturn(terminologyService);
 
     DefaultTerminologyServiceFactory.reset();
   }
@@ -350,7 +350,7 @@ void testBuildContextWithTerminologyDefaults() {
 
     final TerminologyServiceFactory actualServiceFactory = pathlingContext.getTerminologyServiceFactory();
     assertEquals(expectedFactory, actualServiceFactory);
-    final TerminologyService2 terminologyService = actualServiceFactory.buildService2();
+    final TerminologyService terminologyService = actualServiceFactory.build();
     assertNotNull(terminologyService);
   }
 
@@ -371,7 +371,7 @@ void testBuildContextWithTerminologyNoCache() {
 
     final TerminologyServiceFactory actualServiceFactory = pathlingContext.getTerminologyServiceFactory();
     assertEquals(expectedFactory, actualServiceFactory);
-    final TerminologyService2 terminologyService = actualServiceFactory.buildService2();
+    final TerminologyService terminologyService = actualServiceFactory.build();
     assertNotNull(terminologyService);
   }
 
@@ -389,7 +389,7 @@ void testBuildContextWithCustomizedTerminology() throws IOException {
     final int socketTimeout = 123;
 
     final int cacheMaxEntries = 1233;
-    final long cacheMaxObjectSize = 4453L;
+    final String cacheMaxSize = "10MB";
     final StorageType cacheStorageType = StorageType.DISK;
     final File tempDirectory = Files.createTempDirectory("pathling-cache").toFile();
     tempDirectory.deleteOnExit();
@@ -402,7 +402,6 @@ void testBuildContextWithCustomizedTerminology() throws IOException {
         .maxConnectionsTotal(maxConnectionsTotal)
         .maxConnectionsPerRoute(maxConnectionsPerRoute)
         .cacheMaxEntries(cacheMaxEntries)
-        .cacheMaxObjectSize(cacheMaxObjectSize)
         .cacheStorageType(cacheStorageType.toString())
         .cacheStoragePath(cacheStoragePath)
         .tokenEndpoint(tokenEndpoint)
@@ -423,7 +422,6 @@ void testBuildContextWithCustomizedTerminology() throws IOException {
             .build(),
         HttpClientCachingConfiguration.builder()
             .maxEntries(cacheMaxEntries)
-            .maxObjectSize(cacheMaxObjectSize)
             .storageType(cacheStorageType)
             .storagePath(cacheStoragePath)
             .build(),
@@ -439,7 +437,7 @@ void testBuildContextWithCustomizedTerminology() throws IOException {
 
     final TerminologyServiceFactory actualServiceFactory = pathlingContext.getTerminologyServiceFactory();
     assertEquals(expectedFactory, actualServiceFactory);
-    final TerminologyService2 terminologyService = actualServiceFactory.buildService2();
+    final TerminologyService terminologyService = actualServiceFactory.build();
     assertNotNull(terminologyService);
   }
 
diff --git a/terminology/src/test/java/au/csiro/pathling/caching/CachingFactoriesTest.java b/terminology/src/test/java/au/csiro/pathling/caching/CachingFactoriesTest.java
deleted file mode 100644
index 32bab20404..0000000000
--- a/terminology/src/test/java/au/csiro/pathling/caching/CachingFactoriesTest.java
+++ /dev/null
@@ -1,56 +0,0 @@
-package au.csiro.pathling.caching;
-
-import static org.apache.commons.lang.reflect.FieldUtils.readField;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertInstanceOf;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-
-import au.csiro.pathling.config.HttpClientCachingConfiguration;
-import au.csiro.pathling.config.HttpClientCachingConfiguration.StorageType;
-import java.io.File;
-import java.nio.file.Files;
-import org.apache.http.impl.client.cache.CacheConfig;
-import org.apache.http.impl.client.cache.CachingHttpClientBuilder;
-import org.junit.jupiter.api.Test;
-
-public class CachingFactoriesTest {
-
-  private static final CacheConfig HTTP_CLIENT_CACHE_CONFIG = CacheConfig.custom()
-      .setMaxCacheEntries(100000)
-      .build();
-
-  @Test
-  @SuppressWarnings("resource")
-  public void testMemoryStorageFactory() throws Exception {
-    final HttpClientCachingConfiguration cacheConfig = HttpClientCachingConfiguration.defaults();
-    cacheConfig.setStorageType(StorageType.MEMORY);
-    final CachingHttpClientBuilder clientBuilder = CachingFactories.of(cacheConfig)
-        .create(HTTP_CLIENT_CACHE_CONFIG);
-    assertEquals(HTTP_CLIENT_CACHE_CONFIG, readField(clientBuilder, "cacheConfig", true));
-    assertInstanceOf(InfinispanInMemoryStorage.class, readField(clientBuilder, "storage", true));
-  }
-
-
-  @Test
-  @SuppressWarnings("resource")
-  public void testDiskStorageFactory() throws Exception {
-    final HttpClientCachingConfiguration cacheConfig = HttpClientCachingConfiguration.defaults();
-    cacheConfig.setStorageType(StorageType.DISK);
-    final File tempDirectory = Files.createTempDirectory("pathling-cache").toFile();
-    tempDirectory.deleteOnExit();
-    cacheConfig.setStoragePath(tempDirectory.getAbsolutePath());
-    final CachingHttpClientBuilder clientBuilder = CachingFactories.of(cacheConfig)
-        .create(HTTP_CLIENT_CACHE_CONFIG);
-    assertEquals(HTTP_CLIENT_CACHE_CONFIG, readField(clientBuilder, "cacheConfig", true));
-    assertInstanceOf(InfinispanPersistentStorage.class, readField(clientBuilder, "storage", true));
-  }
-
-  @Test
-  public void testDiskStorageFactoryRequiresStoragePath() {
-    final HttpClientCachingConfiguration cacheConfig = HttpClientCachingConfiguration.defaults();
-    cacheConfig.setStorageType(StorageType.DISK);
-    assertThrows(NullPointerException.class,
-        () -> CachingFactories.of(cacheConfig).create(HTTP_CLIENT_CACHE_CONFIG));
-  }
-
-}
diff --git a/terminology/src/test/java/au/csiro/pathling/fhirpath/CodingHelpersTest.java b/terminology/src/test/java/au/csiro/pathling/fhirpath/CodingHelpersTest.java
index 1be11df21f..dd2cb0d287 100644
--- a/terminology/src/test/java/au/csiro/pathling/fhirpath/CodingHelpersTest.java
+++ b/terminology/src/test/java/au/csiro/pathling/fhirpath/CodingHelpersTest.java
@@ -1,18 +1,18 @@
 package au.csiro.pathling.fhirpath;
 
-import org.hl7.fhir.r4.model.Coding;
-import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
-import javax.validation.constraints.Null;
-
-import static org.junit.jupiter.api.Assertions.*;
+import org.hl7.fhir.r4.model.Coding;
+import org.junit.jupiter.api.Test;
 
 class CodingHelpersTest {
-  
+
   @Nonnull
-  static Coding newCoding(@Nullable final String system, @Nullable final String code, @Nullable final String version) {
+  static Coding newCoding(@Nullable final String system, @Nullable final String code,
+      @Nullable final String version) {
     return new Coding(system, code, null).setVersion(version);
   }
 
@@ -32,23 +32,23 @@ static void assertCodingNotEq(@Nullable final Coding left, @Nullable final Codin
     assertFalse(CodingHelpers.codingEquals(left, right));
     assertFalse(CodingHelpers.codingEquals(right, left));
   }
-  
+
   @Test
   void codingEquals() {
     assertCodingEq(null, null);
-    assertCodingEq(newCoding("s1", "c1", "v1"),newCoding("s1", "c1", "v1"));
-    assertCodingEq(newCoding("s1", "c1"),newCoding("s1", "c1", "v1"));
-    assertCodingEq(newCoding("s1", "c1"),newCoding("s1", "c1"));
-    assertCodingEq(newCoding("s1", null),newCoding("s1", null));
-    assertCodingEq(newCoding(null, null),newCoding(null, null));
+    assertCodingEq(newCoding("s1", "c1", "v1"), newCoding("s1", "c1", "v1"));
+    assertCodingEq(newCoding("s1", "c1"), newCoding("s1", "c1", "v1"));
+    assertCodingEq(newCoding("s1", "c1"), newCoding("s1", "c1"));
+    assertCodingEq(newCoding("s1", null), newCoding("s1", null));
+    assertCodingEq(newCoding(null, null), newCoding(null, null));
   }
 
   @Test
   void codingNotEquals() {
-    assertCodingNotEq(newCoding("s1", "c1", "v1"),newCoding("s1", "c1", "v2"));
-    assertCodingNotEq(newCoding("s1", "c1", "v1"),newCoding("s1", "c2"));
-    assertCodingNotEq(newCoding("s1", "c1", "v1"),newCoding("s1", null));
-    assertCodingNotEq(newCoding("s1", "c1"),newCoding("s2", "c1"));
-    assertCodingNotEq(newCoding("s1", "c1"),newCoding(null, "c1"));
+    assertCodingNotEq(newCoding("s1", "c1", "v1"), newCoding("s1", "c1", "v2"));
+    assertCodingNotEq(newCoding("s1", "c1", "v1"), newCoding("s1", "c2"));
+    assertCodingNotEq(newCoding("s1", "c1", "v1"), newCoding("s1", null));
+    assertCodingNotEq(newCoding("s1", "c1"), newCoding("s2", "c1"));
+    assertCodingNotEq(newCoding("s1", "c1"), newCoding(null, "c1"));
   }
 }
diff --git a/terminology/src/test/java/au/csiro/pathling/sql/udf/DesignationUdfTest.java b/terminology/src/test/java/au/csiro/pathling/sql/udf/DesignationUdfTest.java
index 8b7733bd7e..155bbfc5f9 100644
--- a/terminology/src/test/java/au/csiro/pathling/sql/udf/DesignationUdfTest.java
+++ b/terminology/src/test/java/au/csiro/pathling/sql/udf/DesignationUdfTest.java
@@ -28,7 +28,7 @@
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 
-import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.AbstractTerminologyTestBase;
 import org.junit.jupiter.api.BeforeEach;
@@ -39,21 +39,21 @@ public class DesignationUdfTest extends AbstractTerminologyTestBase {
   private static final String[] EMPTY = new String[0];
 
   private DesignationUdf designationUdf;
-  private TerminologyService2 terminologyService2;
+  private TerminologyService terminologyService;
 
   @BeforeEach
   void setUp() {
-    terminologyService2 = mock(TerminologyService2.class);
+    terminologyService = mock(TerminologyService.class);
     final TerminologyServiceFactory terminologyServiceFactory = mock(
         TerminologyServiceFactory.class);
-    when(terminologyServiceFactory.buildService2()).thenReturn(terminologyService2);
+    when(terminologyServiceFactory.build()).thenReturn(terminologyService);
     designationUdf = new DesignationUdf(terminologyServiceFactory);
   }
 
   @Test
   public void testReturnsNullWhenNullCoding() {
     assertNull(designationUdf.call(null, encode(CODING_D), null));
-    verifyNoMoreInteractions(terminologyService2);
+    verifyNoMoreInteractions(terminologyService);
   }
 
 
@@ -62,7 +62,7 @@ public void testResultEmptyWhenInvalidUse() {
     assertArrayEquals(EMPTY, designationUdf.call(encode(CODING_A), encode(INVALID_CODING_0), null));
     assertArrayEquals(EMPTY, designationUdf.call(encode(CODING_B), encode(INVALID_CODING_1), "en"));
     assertArrayEquals(EMPTY, designationUdf.call(encode(CODING_C), encode(INVALID_CODING_2), "fr"));
-    verifyNoMoreInteractions(terminologyService2);
+    verifyNoMoreInteractions(terminologyService);
   }
 
   @Test
@@ -71,14 +71,14 @@ public void testResultEmptyIfNoDesignations() {
     assertArrayEquals(EMPTY,
         designationUdf.call(encode(CODING_BB_VERSION1), encode(CODING_E), "en"));
 
-    verify(terminologyService2).lookup(deepEq(CODING_A), eq("designation"));
-    verify(terminologyService2).lookup(deepEq(CODING_BB_VERSION1), eq("designation"));
-    verifyNoMoreInteractions(terminologyService2);
+    verify(terminologyService).lookup(deepEq(CODING_A), eq("designation"));
+    verify(terminologyService).lookup(deepEq(CODING_BB_VERSION1), eq("designation"));
+    verifyNoMoreInteractions(terminologyService);
   }
 
   @Test
   public void testReturnsCorrectDesignationsWithUseAndLanguage() {
-    setupLookup(terminologyService2)
+    setupLookup(terminologyService)
         .withDesignation(CODING_A, CODING_C, null, "A_C_??")
         .withDesignation(CODING_A, CODING_C, "en", "A_C_en")
         .withDesignation(CODING_A, CODING_D, "en", "A_D_en")
@@ -96,7 +96,7 @@ public void testReturnsCorrectDesignationsWithUseAndLanguage() {
 
   @Test
   public void testReturnsCorrectDesignationsWithNoUseOrNoLanguage() {
-    setupLookup(terminologyService2)
+    setupLookup(terminologyService)
         .withDesignation(CODING_AA_VERSION1, null, "en", "AA1_?_en")
         .withDesignation(CODING_AA_VERSION1, CODING_D, "en", "AA1_D_en")
         .withDesignation(CODING_AA_VERSION1, CODING_E, "en", "AA1_E_en")
@@ -115,5 +115,3 @@ public void testReturnsCorrectDesignationsWithNoUseOrNoLanguage() {
   }
 
 }
-
- 
diff --git a/terminology/src/test/java/au/csiro/pathling/sql/udf/DisplayUdfTest.java b/terminology/src/test/java/au/csiro/pathling/sql/udf/DisplayUdfTest.java
index 1884884777..95ecc2d657 100644
--- a/terminology/src/test/java/au/csiro/pathling/sql/udf/DisplayUdfTest.java
+++ b/terminology/src/test/java/au/csiro/pathling/sql/udf/DisplayUdfTest.java
@@ -7,7 +7,7 @@
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 
-import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.AbstractTerminologyTestBase;
 import au.csiro.pathling.test.helpers.TerminologyServiceHelpers;
@@ -21,22 +21,22 @@ public class DisplayUdfTest extends AbstractTerminologyTestBase {
   private static final String DISPLAY_NAME_B = "Test Display Name B";
 
   private DisplayUdf displayUdf;
-  private TerminologyService2 terminologyService2;
+  private TerminologyService terminologyService;
 
 
   @BeforeEach
   void setUp() {
-    terminologyService2 = mock(TerminologyService2.class);
+    terminologyService = mock(TerminologyService.class);
     final TerminologyServiceFactory terminologyServiceFactory = mock(
         TerminologyServiceFactory.class);
-    when(terminologyServiceFactory.buildService2()).thenReturn(terminologyService2);
+    when(terminologyServiceFactory.build()).thenReturn(terminologyService);
     displayUdf = new DisplayUdf(terminologyServiceFactory);
   }
 
   @Test
   void testNullCoding() {
     assertNull(displayUdf.call(null));
-    verifyNoMoreInteractions(terminologyService2);
+    verifyNoMoreInteractions(terminologyService);
   }
 
   @Test
@@ -44,13 +44,13 @@ void testInvalidCodings() {
     assertNull(displayUdf.call(encode(INVALID_CODING_0)));
     assertNull(displayUdf.call(encode(INVALID_CODING_1)));
     assertNull(displayUdf.call(encode(INVALID_CODING_2)));
-    verifyNoMoreInteractions(terminologyService2);
+    verifyNoMoreInteractions(terminologyService);
   }
 
 
   @Test
   void testGetsDisplayName() {
-    TerminologyServiceHelpers.setupLookup(terminologyService2)
+    TerminologyServiceHelpers.setupLookup(terminologyService)
         .withDisplay(CODING_A, DISPLAY_NAME_A)
         .withDisplay(CODING_BB_VERSION1, DISPLAY_NAME_B);
 
diff --git a/terminology/src/test/java/au/csiro/pathling/sql/udf/MemberOfUdfTest.java b/terminology/src/test/java/au/csiro/pathling/sql/udf/MemberOfUdfTest.java
index 504bf5a37a..3debfb89e7 100644
--- a/terminology/src/test/java/au/csiro/pathling/sql/udf/MemberOfUdfTest.java
+++ b/terminology/src/test/java/au/csiro/pathling/sql/udf/MemberOfUdfTest.java
@@ -12,7 +12,7 @@
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 
-import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.AbstractTerminologyTestBase;
 import au.csiro.pathling.test.helpers.TerminologyServiceHelpers;
@@ -26,17 +26,17 @@ public class MemberOfUdfTest extends AbstractTerminologyTestBase {
   private static final String VALUE_SET_URL_AB = "uuid:vsAB";
 
   private MemberOfUdf memberUdf;
-  private TerminologyService2 terminologyService2;
-  
+  private TerminologyService terminologyService;
+
   @BeforeEach
   void setUp() {
-    terminologyService2 = mock(TerminologyService2.class);
+    terminologyService = mock(TerminologyService.class);
     final TerminologyServiceFactory terminologyServiceFactory = mock(
         TerminologyServiceFactory.class);
-    when(terminologyServiceFactory.buildService2()).thenReturn(terminologyService2);
+    when(terminologyServiceFactory.build()).thenReturn(terminologyService);
     memberUdf = new MemberOfUdf(terminologyServiceFactory);
 
-    TerminologyServiceHelpers.setupValidate(terminologyService2)
+    TerminologyServiceHelpers.setupValidate(terminologyService)
         .withValueSet(VALUE_SET_URL_A, CODING_A)
         .withValueSet(VALUE_SET_URL_AB, CODING_A, CODING_B);
   }
@@ -56,13 +56,13 @@ void testInvalidAndNullCodings() {
     assertFalse(
         memberUdf.call(encodeMany(INVALID_CODING_0, INVALID_CODING_1, INVALID_CODING_2, null),
             VALUE_SET_URL_A));
-    verifyNoMoreInteractions(terminologyService2);
+    verifyNoMoreInteractions(terminologyService);
   }
 
   @Test
   void testInvalidCoding() {
     assertFalse(memberUdf.call(encode(INVALID_CODING_0), VALUE_SET_URL_A));
-    verifyNoMoreInteractions(terminologyService2);
+    verifyNoMoreInteractions(terminologyService);
   }
 
   @Test
@@ -91,7 +91,7 @@ void testCodingsBelongsToValueSet() {
   @Test
   void testEarlyExitWhenMatchingCodingFound() {
     assertTrue(memberUdf.call(encodeMany(CODING_A, CODING_B), VALUE_SET_URL_AB));
-    verify(terminologyService2).validateCode(eq(VALUE_SET_URL_AB), deepEq(CODING_A));
-    verifyNoMoreInteractions(terminologyService2);
+    verify(terminologyService).validateCode(eq(VALUE_SET_URL_AB), deepEq(CODING_A));
+    verifyNoMoreInteractions(terminologyService);
   }
 }
diff --git a/terminology/src/test/java/au/csiro/pathling/sql/udf/PropertyUdfTest.java b/terminology/src/test/java/au/csiro/pathling/sql/udf/PropertyUdfTest.java
index 7e80aa256a..b227cfe023 100644
--- a/terminology/src/test/java/au/csiro/pathling/sql/udf/PropertyUdfTest.java
+++ b/terminology/src/test/java/au/csiro/pathling/sql/udf/PropertyUdfTest.java
@@ -24,13 +24,12 @@
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.mockito.Mockito.eq;
-import static org.mockito.Mockito.isNull;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 
-import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.AbstractTerminologyTestBase;
 import com.google.common.collect.ImmutableMap;
@@ -60,7 +59,7 @@ public class PropertyUdfTest extends AbstractTerminologyTestBase {
   public static final int RANGE_TWO_TO = 4;
 
   @Value(staticConstructor = "of")
-  static class Values<FT extends Type, T> {
+  static class Values<T> {
 
     Type[] fhirValues;
     T[] objectValues;
@@ -90,14 +89,14 @@ Object[] objectRangeTwo() {
     }
 
     @SafeVarargs
-    public static <T, FT extends Type> Values<FT, T> ofPrimitive(
-        Function<T, FT> constructor,
+    public static <T, FT extends Type> Values<T> ofPrimitive(
+        final Function<T, FT> constructor,
         final T... values) {
       return of(Stream.of(values).map(constructor).toArray(Type[]::new), values);
     }
   }
 
-  private static final Map<Class<?>, Values<?, ?>> TEST_VALUES = ImmutableMap.of(
+  private static final Map<Class<?>, Values<?>> TEST_VALUES = ImmutableMap.of(
       // The last value is repeated for purpose for testing that
       // duplicate values are allowed in the result
       StringType.class, ofPrimitive(StringType::new,
@@ -113,74 +112,74 @@ DateTimeType.class, ofPrimitive(DateTimeType::new, "2001-01-01",
           asArray(CODING_A, CODING_BB_VERSION1, CODING_C, CODING_C))
   );
 
-  private static Stream<Values<?, ?>> allTestValues() {
+  private static Stream<Values<?>> allTestValues() {
     return TEST_VALUES.values().stream();
   }
 
   private PropertyUdf propertyUdf;
-  private TerminologyService2 terminologyService2;
+  private TerminologyService terminologyService;
   private TerminologyServiceFactory terminologyServiceFactory;
 
   @BeforeEach
   public void setUp() {
-    terminologyService2 = mock(TerminologyService2.class);
+    terminologyService = mock(TerminologyService.class);
     terminologyServiceFactory = mock(TerminologyServiceFactory.class);
-    when(terminologyServiceFactory.buildService2()).thenReturn(terminologyService2);
+    when(terminologyServiceFactory.build()).thenReturn(terminologyService);
   }
 
   public static Stream<Class<?>> inputs() {
     return TEST_VALUES.keySet().stream();
   }
 
-  private void setupUdf(Class<? extends Type> udfClass) {
+  private void setupUdf(final Class<? extends Type> udfClass) {
     propertyUdf = PropertyUdf.forClass(terminologyServiceFactory, udfClass);
   }
 
   @ParameterizedTest
   @MethodSource("inputs")
-  public void testReturnsNullWhenNullCoding(Class<? extends Type> udfClass) {
+  public void testReturnsNullWhenNullCoding(final Class<? extends Type> udfClass) {
     setupUdf(udfClass);
     assertNull(propertyUdf.call(null, "display"));
-    verifyNoMoreInteractions(terminologyService2);
+    verifyNoMoreInteractions(terminologyService);
   }
 
   @ParameterizedTest
   @MethodSource("inputs")
-  public void testReturnsNullWhenInvalidCodings(Class<? extends Type> udfClass) {
+  public void testReturnsNullWhenInvalidCodings(final Class<? extends Type> udfClass) {
     setupUdf(udfClass);
     assertNull(propertyUdf.call(encode(INVALID_CODING_0), "display"));
     assertNull(propertyUdf.call(encode(INVALID_CODING_1), "name"));
     assertNull(propertyUdf.call(encode(INVALID_CODING_2), "code"));
-    verifyNoMoreInteractions(terminologyService2);
+    verifyNoMoreInteractions(terminologyService);
   }
 
   @ParameterizedTest
   @MethodSource("inputs")
-  public void testReturnsNullWhenNullPropertyCode(Class<? extends Type> udfClass) {
+  public void testReturnsNullWhenNullPropertyCode(final Class<? extends Type> udfClass) {
     setupUdf(udfClass);
     assertNull(propertyUdf.call(encode(CODING_A), null));
-    verifyNoMoreInteractions(terminologyService2);
+    verifyNoMoreInteractions(terminologyService);
   }
 
   @ParameterizedTest
   @MethodSource("inputs")
-  public void testReturnsEmptyArrayWhenNonExisingProperty(Class<? extends Type> udfClass) {
+  public void testReturnsEmptyArrayWhenNonExisingProperty(final Class<? extends Type> udfClass) {
     setupUdf(udfClass);
     assertArrayEquals(new String[RANGE_ONE_FROM],
         propertyUdf.call(encode(CODING_A), "unknownProperty_0"));
     assertArrayEquals(new String[RANGE_ONE_FROM],
         propertyUdf.call(encode(CODING_BB_VERSION1), "unknownProperty_1"));
 
-    verify(terminologyService2).lookup(deepEq(CODING_A), eq("unknownProperty_0"));
-    verify(terminologyService2).lookup(deepEq(CODING_BB_VERSION1), eq("unknownProperty_1"));
-    verifyNoMoreInteractions(terminologyService2);
+    verify(terminologyService).lookup(deepEq(CODING_A), eq("unknownProperty_0"));
+    verify(terminologyService).lookup(deepEq(CODING_BB_VERSION1), eq("unknownProperty_1"));
+    verifyNoMoreInteractions(terminologyService);
   }
 
   @ParameterizedTest
   @MethodSource("inputs")
   public void testReturnsASingleValueForKnownProperty(final Class<? extends Type> udfClass) {
     setupUdf(udfClass);
-    setupLookup(terminologyService2)
+    setupLookup(terminologyService)
         .withProperty(CODING_A, "property_0",
             allTestValues().map(v -> v.fhirValueAt(0)).toArray(Type[]::new))
         .withProperty(CODING_BB_VERSION1, "property_1",
@@ -201,7 +200,7 @@ public void testReturnsASingleValueForKnownProperty(final Class<? extends Type>
   @MethodSource("inputs")
   public void testReturnsManyValueForKnownProperty(final Class<? extends Type> udfClass) {
     setupUdf(udfClass);
-    setupLookup(terminologyService2)
+    setupLookup(terminologyService)
         .withProperty(CODING_AA_VERSION1, "property_a",
             allTestValues().flatMap(Values::fhirRangeOne).toArray(Type[]::new))
         .withProperty(CODING_B, "property_b",
@@ -216,5 +215,3 @@ public void testReturnsManyValueForKnownProperty(final Class<? extends Type> udf
         propertyUdf.call(encode(CODING_B), "property_b"));
   }
 }
-
- 
diff --git a/terminology/src/test/java/au/csiro/pathling/sql/udf/SubsumesUdfTest.java b/terminology/src/test/java/au/csiro/pathling/sql/udf/SubsumesUdfTest.java
index f4d355ae27..adb268622f 100644
--- a/terminology/src/test/java/au/csiro/pathling/sql/udf/SubsumesUdfTest.java
+++ b/terminology/src/test/java/au/csiro/pathling/sql/udf/SubsumesUdfTest.java
@@ -1,12 +1,5 @@
 package au.csiro.pathling.sql.udf;
 
-import au.csiro.pathling.terminology.TerminologyService2;
-import au.csiro.pathling.terminology.TerminologyServiceFactory;
-import au.csiro.pathling.test.AbstractTerminologyTestBase;
-import au.csiro.pathling.test.helpers.TerminologyServiceHelpers;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
 import static au.csiro.pathling.fhirpath.encoding.CodingEncoding.encode;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
@@ -15,20 +8,27 @@
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 
+import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
+import au.csiro.pathling.test.AbstractTerminologyTestBase;
+import au.csiro.pathling.test.helpers.TerminologyServiceHelpers;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
 @SuppressWarnings("ConstantConditions")
 public class SubsumesUdfTest extends AbstractTerminologyTestBase {
 
   private SubsumesUdf subsumesUdf;
-  private TerminologyService2 terminologyService2;
+  private TerminologyService terminologyService;
 
   @BeforeEach
   void setUp() {
-    terminologyService2 = mock(TerminologyService2.class);
+    terminologyService = mock(TerminologyService.class);
     final TerminologyServiceFactory terminologyServiceFactory = mock(
         TerminologyServiceFactory.class);
-    when(terminologyServiceFactory.buildService2()).thenReturn(terminologyService2);
+    when(terminologyServiceFactory.build()).thenReturn(terminologyService);
 
-    TerminologyServiceHelpers.setupSubsumes(terminologyService2)
+    TerminologyServiceHelpers.setupSubsumes(terminologyService)
         .withSubsumes(CODING_AA, CODING_AB)
         .withSubsumes(CODING_BA, CODING_BB);
     subsumesUdf = new SubsumesUdf(terminologyServiceFactory);
@@ -39,7 +39,7 @@ void testNullCodings() {
     assertNull(subsumesUdf.call(null, null, null));
     assertNull(subsumesUdf.call(encode(CODING_A), null, true));
     assertNull(subsumesUdf.call(null, encode(CODING_B), false));
-    verifyNoMoreInteractions(terminologyService2);
+    verifyNoMoreInteractions(terminologyService);
   }
 
   @Test
@@ -47,7 +47,7 @@ void testInvalidCodings() {
     assertFalse(subsumesUdf.call(encode(INVALID_CODING_0), encode(INVALID_CODING_1), null));
     assertFalse(subsumesUdf.call(encode(INVALID_CODING_1), encode(INVALID_CODING_2), false));
     assertFalse(subsumesUdf.call(encode(INVALID_CODING_2), encode(INVALID_CODING_0), true));
-    verifyNoMoreInteractions(terminologyService2);
+    verifyNoMoreInteractions(terminologyService);
   }
 
   @Test
@@ -56,7 +56,7 @@ void testNullAndInvalidCodings() {
         encodeMany(null, INVALID_CODING_1, INVALID_CODING_2), false));
     assertFalse(subsumesUdf.call(encodeMany(null, INVALID_CODING_1, INVALID_CODING_2),
         encodeMany(null, INVALID_CODING_0), true));
-    verifyNoMoreInteractions(terminologyService2);
+    verifyNoMoreInteractions(terminologyService);
   }
 
 
diff --git a/terminology/src/test/java/au/csiro/pathling/sql/udf/TranslateOfUdfTest.java b/terminology/src/test/java/au/csiro/pathling/sql/udf/TranslateOfUdfTest.java
index 24b7970cdb..2cdb4da174 100644
--- a/terminology/src/test/java/au/csiro/pathling/sql/udf/TranslateOfUdfTest.java
+++ b/terminology/src/test/java/au/csiro/pathling/sql/udf/TranslateOfUdfTest.java
@@ -1,8 +1,20 @@
 package au.csiro.pathling.sql.udf;
 
+import static au.csiro.pathling.fhirpath.encoding.CodingEncoding.encode;
+import static org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence.EQUIVALENT;
+import static org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence.NARROWER;
+import static org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence.RELATEDTO;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
 import au.csiro.pathling.errors.InvalidUserInputError;
-import au.csiro.pathling.terminology.TerminologyService2;
-import au.csiro.pathling.terminology.TerminologyService2.Translation;
+import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyService.Translation;
 import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.AbstractTerminologyTestBase;
 import au.csiro.pathling.test.helpers.TerminologyServiceHelpers;
@@ -11,13 +23,6 @@
 import org.junit.jupiter.api.Test;
 import scala.collection.mutable.WrappedArray;
 
-import static au.csiro.pathling.fhirpath.encoding.CodingEncoding.encode;
-import static org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence.EQUIVALENT;
-import static org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence.NARROWER;
-import static org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence.RELATEDTO;
-import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.Mockito.*;
-
 public class TranslateOfUdfTest extends AbstractTerminologyTestBase {
 
   private static final String CONCEPT_MAP_A = "uuid:caA";
@@ -26,19 +31,19 @@ public class TranslateOfUdfTest extends AbstractTerminologyTestBase {
   private static final Row[] NO_TRANSLATIONS = new Row[]{};
 
   private TranslateUdf translateUdf;
-  private TerminologyService2 terminologyService2;
+  private TerminologyService terminologyService;
 
   @SafeVarargs
-  private static <T> WrappedArray<T> newWrappedArray(T... args) {
+  private static <T> WrappedArray<T> newWrappedArray(final T... args) {
     return WrappedArray.make(args);
   }
 
   @BeforeEach
   void setUp() {
-    terminologyService2 = mock(TerminologyService2.class);
+    terminologyService = mock(TerminologyService.class);
     final TerminologyServiceFactory terminologyServiceFactory = mock(
         TerminologyServiceFactory.class);
-    when(terminologyServiceFactory.buildService2()).thenReturn(terminologyService2);
+    when(terminologyServiceFactory.build()).thenReturn(terminologyService);
     translateUdf = new TranslateUdf(terminologyServiceFactory);
 
   }
@@ -52,7 +57,7 @@ void testNullCodings() {
   @Test
   void testTranslatesCodingWithDefaults() {
 
-    TerminologyServiceHelpers.setupTranslate(terminologyService2)
+    TerminologyServiceHelpers.setupTranslate(terminologyService)
         .withTranslations(CODING_AA, CONCEPT_MAP_A,
             Translation.of(EQUIVALENT, CODING_BB),
             Translation.of(RELATEDTO, CODING_AB));
@@ -64,7 +69,7 @@ void testTranslatesCodingWithDefaults() {
   @Test
   void testTranslatesCodingsUniqueResults() {
 
-    TerminologyServiceHelpers.setupTranslate(terminologyService2)
+    TerminologyServiceHelpers.setupTranslate(terminologyService)
         .withTranslations(CODING_AA_VERSION1, CONCEPT_MAP_B, true, SYSTEM_B,
             Translation.of(EQUIVALENT, CODING_AA),
             Translation.of(NARROWER, CODING_BB),
@@ -85,7 +90,7 @@ void testInvalidAndNullCodings() {
     assertArrayEquals(NO_TRANSLATIONS,
         translateUdf.call(encodeMany(INVALID_CODING_0, INVALID_CODING_1, INVALID_CODING_2, null),
             "uuid:url", true, null, null));
-    verifyNoMoreInteractions(terminologyService2);
+    verifyNoMoreInteractions(terminologyService);
   }
 
   @Test
@@ -95,13 +100,13 @@ void testThrowsInputErrorWhenInvalidEquivalence() {
             newWrappedArray("invalid"),
             null));
     assertEquals("Unknown ConceptMapEquivalence code 'invalid'", ex.getMessage());
-    verifyNoMoreInteractions(terminologyService2);
+    verifyNoMoreInteractions(terminologyService);
   }
 
   @Test
   void testToleratesNullAndBlankEquivalencesAndEmptyEquivalencesList() {
 
-    TerminologyServiceHelpers.setupTranslate(terminologyService2)
+    TerminologyServiceHelpers.setupTranslate(terminologyService)
         .withTranslations(CODING_AA, CONCEPT_MAP_A,
             Translation.of(EQUIVALENT, CODING_BB),
             Translation.of(RELATEDTO, CODING_AB));
@@ -113,7 +118,7 @@ void testToleratesNullAndBlankEquivalencesAndEmptyEquivalencesList() {
     assertArrayEquals(NO_TRANSLATIONS,
         translateUdf.call(encode(CODING_AA), CONCEPT_MAP_A, false, newWrappedArray(),
             null));
-    verifyNoMoreInteractions(terminologyService2);
+    verifyNoMoreInteractions(terminologyService);
   }
 
 }
diff --git a/terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyService2Test.java b/terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyServiceTest.java
similarity index 77%
rename from terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyService2Test.java
rename to terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyServiceTest.java
index 033b2d3aca..d7058b0ab8 100644
--- a/terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyService2Test.java
+++ b/terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyServiceTest.java
@@ -20,7 +20,7 @@
 import static au.csiro.pathling.terminology.PropertiesParametersBuilder.standardProperties;
 import static au.csiro.pathling.test.helpers.FhirMatchers.deepEq;
 import static au.csiro.pathling.test.helpers.TerminologyServiceHelpers.OUTCOME_EQUIVALENT;
-import static au.csiro.pathling.test.helpers.TerminologyServiceHelpers.OUTCOME_SUBSUMEDBY;
+import static au.csiro.pathling.test.helpers.TerminologyServiceHelpers.OUTCOME_SUBSUMED_BY;
 import static au.csiro.pathling.test.helpers.TerminologyServiceHelpers.OUTCOME_SUBSUMES;
 import static au.csiro.pathling.test.helpers.TerminologyServiceHelpers.RESULT_FALSE;
 import static au.csiro.pathling.test.helpers.TerminologyServiceHelpers.RESULT_TRUE;
@@ -32,21 +32,23 @@
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 
-import au.csiro.pathling.fhir.TerminologyClient2;
-import au.csiro.pathling.terminology.TerminologyService2.Designation;
-import au.csiro.pathling.terminology.TerminologyService2.Property;
-import au.csiro.pathling.terminology.TerminologyService2.Translation;
+import au.csiro.pathling.fhir.TerminologyClient;
+import au.csiro.pathling.terminology.TerminologyService.Designation;
+import au.csiro.pathling.terminology.TerminologyService.Property;
+import au.csiro.pathling.terminology.TerminologyService.Translation;
 import au.csiro.pathling.test.AbstractTerminologyTestBase;
+import ca.uhn.fhir.rest.gclient.IOperationUntypedWithInput;
+import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 import javax.annotation.Nonnull;
-import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
 import org.hl7.fhir.r4.model.BooleanType;
 import org.hl7.fhir.r4.model.CodeType;
 import org.hl7.fhir.r4.model.Coding;
@@ -59,7 +61,7 @@
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
-public class DefaultTerminologyService2Test extends AbstractTerminologyTestBase {
+public class DefaultTerminologyServiceTest extends AbstractTerminologyTestBase {
 
   private static final String VALUE_SET_X = "uuid:valueSetX";
   private static final String VALUE_SET_Y = "uuid:valueSetY";
@@ -69,7 +71,7 @@ public class DefaultTerminologyService2Test extends AbstractTerminologyTestBase
 
   private static final List<Translation> EMPTY_TRANSLATION = Collections.emptyList();
 
-  public static final Coding USE_PREFFERED_FOR_LANG = new Coding(
+  public static final Coding USE_PREFERRED_FOR_LANG = new Coding(
       "http://terminology.hl7.org/CodeSystem/hl7TermMaintInfra",
       "preferredForLanguage", "Preferred For Language"
   );
@@ -79,7 +81,7 @@ private static Parameters translation(@Nonnull final Translation... entries) {
     final Parameters translateResponse = new Parameters()
         .setParameter("result", true);
 
-    for (Translation entry : entries) {
+    for (final Translation entry : entries) {
       final ParametersParameterComponent matchParameter1 = translateResponse.addParameter()
           .setName("match");
       matchParameter1.addPart().setName("equivalence")
@@ -90,35 +92,39 @@ private static Parameters translation(@Nonnull final Translation... entries) {
   }
 
 
-  private TerminologyClient2 terminologClient;
-  private DefaultTerminologyService2 terminologyService;
+  private TerminologyClient terminologyClient;
+  private DefaultTerminologyService terminologyService;
 
   @BeforeEach
   void setUp() {
-    terminologClient = mock(TerminologyClient2.class);
-    terminologyService = new DefaultTerminologyService2(
-        terminologClient, null);
+    terminologyClient = mock(TerminologyClient.class);
+    terminologyService = new DefaultTerminologyService(
+        terminologyClient, null);
   }
 
   @Test
   public void testValidateCodingTrue() {
-    when(terminologClient.validateCode(
+    final IOperationUntypedWithInput<Parameters> request = mockRequest(
+        RESULT_TRUE);
+    when(terminologyClient.buildValidateCode(
         deepEq(new UriType(VALUE_SET_X)),
         deepEq(new UriType(SYSTEM_A)),
         isNull(),
         deepEq(new CodeType(CODE_A))
-    )).thenReturn(RESULT_TRUE);
+    )).thenReturn(request);
     assertTrue(terminologyService.validateCode(VALUE_SET_X, CODING_AA));
   }
 
   @Test
   public void testValidateVersionedCodingFalse() {
-    when(terminologClient.validateCode(
+    final IOperationUntypedWithInput<Parameters> request = mockRequest(
+        RESULT_FALSE);
+    when(terminologyClient.buildValidateCode(
         deepEq(new UriType(VALUE_SET_Y)),
         deepEq(new UriType(SYSTEM_B)),
         deepEq(new StringType(VERSION_1)),
         deepEq(new CodeType(CODE_B))
-    )).thenReturn(RESULT_FALSE);
+    )).thenReturn(request);
     assertFalse(terminologyService.validateCode(VALUE_SET_Y, CODING_BB_VERSION1));
   }
 
@@ -131,58 +137,66 @@ public void testValidateInvalidCodings() {
 
   @Test
   public void testSubsumesNoVersion() {
-    when(terminologClient.subsumes(
+    final IOperationUntypedWithInput<Parameters> request = mockRequest(
+        OUTCOME_SUBSUMES);
+    when(terminologyClient.buildSubsumes(
         deepEq(new CodeType(CODE_A)),
         deepEq(new CodeType(CODE_B)),
         deepEq(new UriType(SYSTEM_A)),
         isNull()
-    )).thenReturn(OUTCOME_SUBSUMES);
+    )).thenReturn(request);
     assertEquals(SUBSUMES, terminologyService.subsumes(CODING_AA, CODING_AB));
   }
 
   @Test
   public void testSubsumesLeftVersion() {
-    when(terminologClient.subsumes(
+    final IOperationUntypedWithInput<Parameters> request = mockRequest(
+        OUTCOME_EQUIVALENT);
+    when(terminologyClient.buildSubsumes(
         deepEq(new CodeType(CODE_A)),
         deepEq(new CodeType(CODE_B)),
         deepEq(new UriType(SYSTEM_A)),
         deepEq(new StringType(VERSION_1))
-    )).thenReturn(OUTCOME_EQUIVALENT);
+    )).thenReturn(request);
     assertEquals(EQUIVALENT, terminologyService.subsumes(CODING_AA_VERSION1, CODING_AB));
   }
 
   @Test
   public void testSubsumesRightVersion() {
-    when(terminologClient.subsumes(
+    final IOperationUntypedWithInput<Parameters> request = mockRequest(
+        OUTCOME_SUBSUMED_BY);
+    when(terminologyClient.buildSubsumes(
         deepEq(new CodeType(CODE_A)),
         deepEq(new CodeType(CODE_B)),
         deepEq(new UriType(SYSTEM_A)),
         deepEq(new StringType(VERSION_2))
-    )).thenReturn(OUTCOME_SUBSUMEDBY);
+    )).thenReturn(request);
     assertEquals(SUBSUMEDBY, terminologyService.subsumes(CODING_AA, CODING_AB_VERSION2));
   }
 
   @Test
   public void testSubsumesBothVersionTheSame() {
-    when(terminologClient.subsumes(
+    final IOperationUntypedWithInput<Parameters> request = mockRequest(
+        OUTCOME_EQUIVALENT);
+    when(terminologyClient.buildSubsumes(
         deepEq(new CodeType(CODE_A)),
         deepEq(new CodeType(CODE_B)),
         deepEq(new UriType(SYSTEM_A)),
         deepEq(new StringType(VERSION_1))
-    )).thenReturn(OUTCOME_EQUIVALENT);
+    )).thenReturn(request);
     assertEquals(EQUIVALENT, terminologyService.subsumes(CODING_AA_VERSION1, CODING_AB_VERSION1));
   }
 
   @Test
   public void testSubsumesDifferentVersions() {
     assertEquals(NOTSUBSUMED, terminologyService.subsumes(CODING_AA_VERSION1, CODING_AB_VERSION2));
-    verifyNoMoreInteractions(terminologClient);
+    verifyNoMoreInteractions(terminologyClient);
   }
 
   @Test
   public void testSubsumesDifferentSystems() {
     assertEquals(NOTSUBSUMED, terminologyService.subsumes(CODING_AA, CODING_BB_VERSION1));
-    verifyNoMoreInteractions(terminologClient);
+    verifyNoMoreInteractions(terminologyClient);
   }
 
   @Test
@@ -199,7 +213,7 @@ public void testSubsumesInvalidCodings() {
     assertEquals(NOTSUBSUMED, terminologyService.subsumes(INVALID_CODING_0, INVALID_CODING_1));
     assertEquals(NOTSUBSUMED, terminologyService.subsumes(INVALID_CODING_1, INVALID_CODING_2));
     assertEquals(NOTSUBSUMED, terminologyService.subsumes(INVALID_CODING_2, INVALID_CODING_0));
-    verifyNoMoreInteractions(terminologClient);
+    verifyNoMoreInteractions(terminologyClient);
   }
 
 
@@ -209,21 +223,22 @@ public void testSubsumesEqualCodingsLocally() {
     assertEquals(SUBSUMES, terminologyService.subsumes(CODING_AA, CODING_AA_VERSION1));
     assertEquals(SUBSUMES, terminologyService.subsumes(CODING_AB_VERSION1, CODING_AB));
     assertEquals(SUBSUMES, terminologyService.subsumes(CODING_AB_VERSION2, CODING_AB_VERSION2));
-    verifyNoMoreInteractions(terminologClient);
+    verifyNoMoreInteractions(terminologyClient);
   }
-  
-  
+
+
   @Test
   public void testTranslatesVersionedCodingWithDefaults() {
-
-    when(terminologClient.translate(
+    final IOperationUntypedWithInput<Parameters> request = mockRequest(
+        RESULT_FALSE);
+    when(terminologyClient.buildTranslate(
         deepEq(new UriType(CONCEPT_MAP_0)),
         deepEq(new UriType(SYSTEM_A)),
         deepEq(new StringType(VERSION_1)),
         deepEq(new CodeType(CODE_A)),
         deepEq(new BooleanType(false)),
         isNull()
-    )).thenReturn(RESULT_FALSE);
+    )).thenReturn(request);
 
     assertEquals(EMPTY_TRANSLATION,
         terminologyService.translate(CODING_AA_VERSION1, CONCEPT_MAP_0, false, null));
@@ -238,15 +253,16 @@ public void testTranslatesUnversionedCoding() {
         Translation.of(ConceptMapEquivalence.SUBSUMES, CODING_AA_VERSION1),
         Translation.of(ConceptMapEquivalence.NARROWER, CODING_AB_VERSION1)
     );
+    final IOperationUntypedWithInput<Parameters> request = mockRequest(translationResponse);
 
-    when(terminologClient.translate(
+    when(terminologyClient.buildTranslate(
         deepEq(new UriType(CONCEPT_MAP_1)),
         deepEq(new UriType(SYSTEM_B)),
         isNull(),
         deepEq(new CodeType(CODE_B)),
         deepEq(new BooleanType(true)),
         deepEq(new UriType(SYSTEM_A))
-    )).thenReturn(translationResponse);
+    )).thenReturn(request);
 
     assertEquals(
         List.of(
@@ -267,7 +283,7 @@ public void testTranslatesInvalidsCoding() {
         terminologyService.translate(INVALID_CODING_1, CONCEPT_MAP_0, true, null));
     assertEquals(EMPTY_TRANSLATION,
         terminologyService.translate(INVALID_CODING_2, CONCEPT_MAP_0, false, SYSTEM_B));
-    verifyNoMoreInteractions(terminologClient);
+    verifyNoMoreInteractions(terminologyClient);
   }
 
   @Test
@@ -278,18 +294,20 @@ public void testLooksUpInvalidCoding() {
         terminologyService.lookup(INVALID_CODING_1, "display"));
     assertEquals(Collections.emptyList(),
         terminologyService.lookup(INVALID_CODING_2, "designation"));
-    verifyNoMoreInteractions(terminologClient);
+    verifyNoMoreInteractions(terminologyClient);
   }
 
   @Test
   public void testLooksUpStandardProperty() {
 
-    when(terminologClient.lookup(
+    final IOperationUntypedWithInput<Parameters> request = mockRequest(
+        standardProperties(CODING_A).build());
+    when(terminologyClient.buildLookup(
         deepEq(new UriType(SYSTEM_A)),
         isNull(),
         deepEq(new CodeType(CODE_A)),
         deepEq(new CodeType("display"))))
-        .thenReturn(standardProperties(CODING_A).build());
+        .thenReturn(request);
 
     assertEquals(List.of(Property.of("display", new StringType(CODING_AA.getDisplay()))),
         terminologyService.lookup(CODING_AA, "display"));
@@ -302,17 +320,18 @@ public void testLooksNamedProperties() {
         .withProperty("property_A", new IntegerType(333))
         .withProperty("property_A", new CodeType("code_value_a"))
         .withProperty("property_A", new BooleanType(true))
-        // adding some unexpected elemnts to make they are excluded
+        // Adding some unexpected elements to make they are excluded.
         .withProperty("property_B", "string_value_b")
-        .withDesignation("Coding BB", USE_PREFFERED_FOR_LANG, "en")
+        .withDesignation("Coding BB", USE_PREFERRED_FOR_LANG, "en")
         .build();
 
-    when(terminologClient.lookup(
+    final IOperationUntypedWithInput<Parameters> request = mockRequest(response);
+    when(terminologyClient.buildLookup(
         deepEq(new UriType(SYSTEM_B)),
         deepEq(new StringType(VERSION_1)),
         deepEq(new CodeType(CODE_B)),
         deepEq(new CodeType("property_A"))
-    )).thenReturn(response);
+    )).thenReturn(request);
 
     assertEquals(List.of(
             Property.of("property_A", new StringType("string_value_a")),
@@ -324,7 +343,7 @@ public void testLooksNamedProperties() {
   }
 
   @Test
-  public void testLooksUpSupProperties() {
+  public void testLookupSubProperties() {
     final Parameters response = standardProperties(CODING_C)
         .withPropertyGroup("group_C")
         .withSubProperty("property_C", new StringType("string_value_c"))
@@ -332,25 +351,27 @@ public void testLooksUpSupProperties() {
         .withSubProperty("property_D", new StringType("string_value_D"))
         .build();
 
-    when(terminologClient.lookup(
+    final IOperationUntypedWithInput<Parameters> request = mockRequest(
+        response);
+    when(terminologyClient.buildLookup(
         deepEq(new UriType(SYSTEM_C)),
         isNull(),
         deepEq(new CodeType(CODE_C)),
         any()
-    )).thenReturn(response);
+    )).thenReturn(request);
 
     assertEquals(List.of(
             Property.of("property_C", new StringType("string_value_c")),
             Property.of("property_C", new CodeType("code_value_c"))
         ),
         terminologyService.lookup(CODING_C, "property_C"));
-    // does not include grouping property in the results
+    // Does not include grouping property in the results.
     assertEquals(Collections.emptyList(),
         terminologyService.lookup(CODING_C, "group_C"));
   }
-  
+
   @Test
-  public void testLooksUpDesignations() {
+  public void testLookupDesignations() {
     final Parameters response = standardProperties(CODING_A)
         .withProperty("property_A", "value_A")
         .withDesignation("designation_D_X", CODING_D, "lang_X")
@@ -360,12 +381,14 @@ public void testLooksUpDesignations() {
         .withDesignation("designation_?_?", Optional.empty(), Optional.empty())
         .build();
 
-    when(terminologClient.lookup(
+    final IOperationUntypedWithInput<Parameters> request = mockRequest(
+        response);
+    when(terminologyClient.buildLookup(
         deepEq(new UriType(SYSTEM_A)),
         isNull(),
         deepEq(new CodeType(CODE_A)),
         deepEq(new CodeType("designation"))
-    )).thenReturn(response);
+    )).thenReturn(request);
 
     assertEquals(List.of(
             Designation.of(CODING_D, "lang_X", "designation_D_X"),
@@ -384,12 +407,14 @@ public void testLooksUpDesignationsForVersionedCodingAndUse() {
         .withDesignation("designation_AB2_Z", CODING_AB_VERSION2, "lang_Z")
         .build();
 
-    when(terminologClient.lookup(
+    final IOperationUntypedWithInput<Parameters> request = mockRequest(
+        response);
+    when(terminologyClient.buildLookup(
         deepEq(new UriType(SYSTEM_B)),
         deepEq(new StringType(VERSION_1)),
         deepEq(new CodeType(CODE_B)),
         deepEq(new CodeType("designation"))
-    )).thenReturn(response);
+    )).thenReturn(request);
 
     assertEquals(List.of(
             Designation.of(CODING_AB_VERSION2, "lang_Z", "designation_AB2_Z")
@@ -399,10 +424,19 @@ public void testLooksUpDesignationsForVersionedCodingAndUse() {
 
   @Test
   public void testLookupHandles404Exceptions() {
-    when(terminologClient.lookup(any(), any(), any(), any()
+    when(terminologyClient.buildLookup(any(), any(), any(), any()
     )).thenThrow(BaseServerResponseException.newInstance(404, "Resource Not Found"));
 
     assertEquals(Collections.emptyList(),
         terminologyService.lookup(CODING_C, "property_A"));
   }
+
+  @SuppressWarnings("unchecked")
+  <ResponseType> IOperationUntypedWithInput<ResponseType> mockRequest(final ResponseType response) {
+    final IOperationUntypedWithInput<ResponseType> request = (IOperationUntypedWithInput<ResponseType>) mock(
+        IOperationUntypedWithInput.class);
+    when(request.withAdditionalHeader(anyString(), anyString())).thenReturn(request);
+    when(request.execute()).thenReturn(response);
+    return request;
+  }
 }
diff --git a/terminology/src/test/java/au/csiro/pathling/terminology/ParameterComparabilityTest.java b/terminology/src/test/java/au/csiro/pathling/terminology/ParameterComparabilityTest.java
new file mode 100644
index 0000000000..377bfc9945
--- /dev/null
+++ b/terminology/src/test/java/au/csiro/pathling/terminology/ParameterComparabilityTest.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.terminology;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+
+import au.csiro.pathling.fhirpath.encoding.ImmutableCoding;
+import au.csiro.pathling.terminology.validatecode.ValidateCodeParameters;
+import org.hl7.fhir.r4.model.Coding;
+import org.junit.jupiter.api.Test;
+
+public class ParameterComparabilityTest {
+
+  @Test
+  void validateCode() {
+    final ValidateCodeParameters parameters1 = new ValidateCodeParameters(
+        "http://snomed.info/sct?fhir_vs",
+        ImmutableCoding.of("http://snomed.info/sct", "1032491000168100",
+            "Atomoxetine (GxP) 100 mg capsule, 28"));
+
+    // These parameters have a different display in the coding.
+    final ValidateCodeParameters parameters2 = new ValidateCodeParameters(
+        "http://snomed.info/sct?fhir_vs",
+        ImmutableCoding.of("http://snomed.info/sct", "1032491000168100",
+            "Atomoxetine"));
+
+    // These parameters have a different version in the coding.
+    final Coding coding3 = new Coding("http://snomed.info/sct", "1032491000168100",
+        "Atomoxetine (GxP) 100 mg capsule, 28");
+    coding3.setVersion("http://snomed.info/sct/32506021000036107/version/20220930");
+    final ValidateCodeParameters parameters3 = new ValidateCodeParameters(
+        "http://snomed.info/sct?fhir_vs",
+        ImmutableCoding.of(coding3));
+
+    // These parameters have a different user selected in the coding.
+    final Coding coding4 = new Coding("http://snomed.info/sct", "1032491000168100",
+        "Atomoxetine (GxP) 100 mg capsule, 28");
+    coding3.setUserSelected(true);
+    final ValidateCodeParameters parameters4 = new ValidateCodeParameters(
+        "http://snomed.info/sct?fhir_vs",
+        ImmutableCoding.of(coding4));
+
+    // These parameters have a different value set URL.
+    final ValidateCodeParameters parameters5 = new ValidateCodeParameters(
+        "http://aehrc.csiro.au/some-other-value-set",
+        ImmutableCoding.of("http://snomed.info/sct", "1032491000168100",
+            "Atomoxetine (GxP) 100 mg capsule, 28"));
+
+    assertEquals(parameters1, parameters2);
+    assertEquals(parameters1.hashCode(), parameters2.hashCode());
+
+    assertNotEquals(parameters1, parameters3);
+    assertNotEquals(parameters1.hashCode(), parameters3.hashCode());
+
+    assertEquals(parameters1, parameters4);
+    assertEquals(parameters1.hashCode(), parameters4.hashCode());
+
+    assertNotEquals(parameters1, parameters5);
+    assertNotEquals(parameters1.hashCode(), parameters5.hashCode());
+  }
+  
+}
diff --git a/terminology/src/test/java/au/csiro/pathling/terminology/caching/CachingTerminologyServiceTest.java b/terminology/src/test/java/au/csiro/pathling/terminology/caching/CachingTerminologyServiceTest.java
new file mode 100644
index 0000000000..7cd3f35076
--- /dev/null
+++ b/terminology/src/test/java/au/csiro/pathling/terminology/caching/CachingTerminologyServiceTest.java
@@ -0,0 +1,282 @@
+/*
+ * Copyright 2022 Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package au.csiro.pathling.terminology.caching;
+
+import static au.csiro.pathling.test.helpers.TerminologyHelpers.LOINC_URI;
+import static au.csiro.pathling.test.helpers.TerminologyHelpers.SNOMED_URI;
+import static com.github.tomakehurst.wiremock.client.WireMock.anyRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl;
+import static com.github.tomakehurst.wiremock.client.WireMock.matching;
+import static com.github.tomakehurst.wiremock.client.WireMock.proxyAllTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.verify;
+import static java.util.Objects.requireNonNull;
+import static org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome.NOTSUBSUMED;
+import static org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome.SUBSUMEDBY;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import au.csiro.pathling.config.HttpClientCachingConfiguration;
+import au.csiro.pathling.config.HttpClientCachingConfiguration.StorageType;
+import au.csiro.pathling.config.HttpClientConfiguration;
+import au.csiro.pathling.config.TerminologyAuthConfiguration;
+import au.csiro.pathling.terminology.DefaultTerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyService.Property;
+import au.csiro.pathling.terminology.TerminologyService.Translation;
+import ca.uhn.fhir.context.FhirVersionEnum;
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+import com.github.tomakehurst.wiremock.recording.RecordSpecBuilder;
+import java.util.List;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+import javax.annotation.Nonnull;
+import lombok.Value;
+import lombok.extern.slf4j.Slf4j;
+import org.hl7.fhir.r4.model.BooleanType;
+import org.hl7.fhir.r4.model.Coding;
+import org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+@Slf4j
+@TestInstance(Lifecycle.PER_CLASS)
+@TestMethodOrder(OrderAnnotation.class)
+class CachingTerminologyServiceTest {
+
+  static final int WIREMOCK_PORT = 4072;
+  static final Coding T2D_CODING = new Coding(SNOMED_URI, "44054006",
+      "Type 2 diabetes mellitus");
+  static final Coding CHLOROQUINE_POISONING_CODING = new Coding(SNOMED_URI, "45110008",
+      "Chloroquine poisoning");
+  static final Coding DIABETES_CODING = new Coding(SNOMED_URI, "73211009",
+      "Diabetes mellitus");
+  static final Coding INACTIVE_CHLOROQUINE_POISONING_CODING = new Coding(SNOMED_URI,
+      "291446002", null);
+  static final String SAME_AS_CONCEPT_MAP_URL = SNOMED_URI + "?fhir_cm=900000000000527005";
+  static final String ALL_CODES_VALUE_SET_URL = SNOMED_URI + "?fhir_vs";
+  static final String NON_EXISTENT_VALUE_SET = "urn:test:non-existent-value-set";
+  static final Coding LOINC_CODING = new Coding(LOINC_URI, "2939-7", null);
+
+  static WireMockServer wireMockServer;
+  static DefaultTerminologyServiceFactory terminologyServiceFactory;
+
+  TerminologyService terminologyService;
+
+  @Value
+  static class TestParameters {
+
+    /**
+     * A display name for the test.
+     */
+    @Nonnull
+    String name;
+
+    /**
+     * A function to execute the terminology service operation.
+     */
+    @Nonnull
+    Supplier<Object> supplier;
+
+    /**
+     * The expected result of the operation.
+     */
+    @Nonnull
+    Object expectedResult;
+
+    /**
+     * Whether the request was intended to be invalid.
+     */
+    @Nonnull
+    boolean badRequest;
+
+    @Nonnull
+    boolean invalidParameters;
+
+    @Override
+    public String toString() {
+      return name;
+    }
+
+  }
+
+  Stream<TestParameters> parameters() {
+    return Stream.of(
+        // Validate code - check that "Type 2 diabetes mellitus" is in the set of all SNOMED codes.
+        new TestParameters("validate-code", () -> terminologyService.validateCode(
+            ALL_CODES_VALUE_SET_URL, T2D_CODING), true, false, false),
+
+        // Translate - translate "Chloroquine poisoning of undetermined intent" to the code that 
+        // replaces it (within the SAME AS historical association reference set).
+        new TestParameters("translate",
+            () -> terminologyService.translate(INACTIVE_CHLOROQUINE_POISONING_CODING,
+                SAME_AS_CONCEPT_MAP_URL, false, null),
+            List.of(Translation.of(ConceptMapEquivalence.EQUAL, CHLOROQUINE_POISONING_CODING)),
+            false, false),
+
+        // Subsumes - check that "Type 2 diabetes mellitus" is subsumed by "Diabetes mellitus".
+        new TestParameters("subsumes",
+            () -> terminologyService.subsumes(T2D_CODING, DIABETES_CODING), SUBSUMEDBY, false,
+            false),
+
+        // Lookup - get the "inactive" property for "Chloroquine poisoning of undetermined intent".
+        new TestParameters("lookup",
+            () -> terminologyService.lookup(INACTIVE_CHLOROQUINE_POISONING_CODING, "inactive"),
+            List.of(Property.of("inactive", new BooleanType(true))), false, false),
+
+        // Invalid validate code request (not found from the server).
+        new TestParameters("invalid validate-code", () -> terminologyService.validateCode(
+            NON_EXISTENT_VALUE_SET, CHLOROQUINE_POISONING_CODING), false, true, false),
+
+        // Invalid subsumes request (parameters that do not pass validation).
+        new TestParameters("invalid subsumes",
+            () -> terminologyService.subsumes(LOINC_CODING, DIABETES_CODING), NOTSUBSUMED, false,
+            true)
+    );
+  }
+
+  @BeforeAll
+  static void beforeAll() {
+    wireMockServer = new WireMockServer(
+        new WireMockConfiguration().port(WIREMOCK_PORT)
+            .usingFilesUnderDirectory("src/test/resources/wiremock/CachingTerminologyServiceTest"));
+    WireMock.configureFor("localhost", WIREMOCK_PORT);
+    wireMockServer.start();
+
+    final HttpClientConfiguration clientConfig = HttpClientConfiguration.builder()
+        .socketTimeout(5_000)
+        .maxConnectionsTotal(1)
+        .maxConnectionsPerRoute(1)
+        .build();
+    final HttpClientCachingConfiguration cacheConfig = HttpClientCachingConfiguration.builder()
+        .enabled(true)
+        .maxEntries(1_000)
+        .storageType(StorageType.MEMORY)
+        .build();
+    final TerminologyAuthConfiguration authConfig = TerminologyAuthConfiguration.builder()
+        .enabled(false)
+        .build();
+
+    terminologyServiceFactory = new DefaultTerminologyServiceFactory(FhirVersionEnum.R4,
+        "http://localhost:" + WIREMOCK_PORT + "/fhir",
+        false, clientConfig, cacheConfig, authConfig);
+  }
+
+  @BeforeEach
+  void setUp() {
+    terminologyService = terminologyServiceFactory.build();
+    if (isRecordMode()) {
+      log.warn("Proxying all request to: {}", recordingTxServerUrl());
+      stubFor(proxyAllTo(recordingTxServerUrl()));
+    }
+  }
+
+  @ParameterizedTest
+  @MethodSource("parameters")
+  @Order(1)
+  void coldRequest(@Nonnull final TestParameters parameters) {
+    // Execute the request 2 times.
+    for (int i = 0; i < 2; i++) {
+      // Execute the request.
+      final Object result = requireNonNull(parameters.getSupplier().get());
+      // Assert that the result equals the expected result.
+      assertEquals(parameters.getExpectedResult(), result);
+    }
+
+    // Verify that the request was only made once.
+    verify(parameters.isInvalidParameters()
+           // If the request was invalid, then the request should not have been made.
+           ? 0
+           : 1, anyRequestedFor(anyUrl()));
+  }
+
+  @ParameterizedTest
+  @MethodSource("parameters")
+  @Order(2)
+  void revalidationOfExpiredResult(@Nonnull final TestParameters parameters)
+      throws InterruptedException {
+    if (isRecordMode()) {
+      stubFor(proxyAllTo(recordingTxServerUrl()).inScenario("revalidationOfExpiredResult"));
+    }
+
+    // Execute the request 2 times.
+    for (int i = 0; i < 2; i++) {
+      // Execute the request.
+      final Object result = requireNonNull(parameters.getSupplier().get());
+      // Assert that the result equals the expected result.
+      assertEquals(parameters.getExpectedResult(), result);
+
+      if (!parameters.isBadRequest()) {
+        // Wait until the 1-second max age has passed.
+        Thread.sleep(2_000);
+      }
+    }
+
+    if (parameters.isInvalidParameters()) {
+      // If the parameters were invalid, no request should have been made.
+      verify(0, anyRequestedFor(anyUrl()));
+    } else if (parameters.isBadRequest()) {
+      // If the request was invalid, verify that the request was only made once (i.e. it was 
+      // cached as a bad request, which has an expiry longer than 1 second).
+      verify(1, anyRequestedFor(anyUrl()));
+    } else {
+      // If the request was not invalid, verify that two requests were made. The second operation 
+      // should have been run past the max age, triggering a revalidation request.
+      verify(2, anyRequestedFor(anyUrl()));
+      verify(1, anyRequestedFor(anyUrl())
+          .withHeader("If-None-Match", matching(".*")));
+    }
+  }
+
+  @AfterEach
+  void tearDown() {
+    if (isRecordMode()) {
+      log.warn("Recording snapshots to: {}", wireMockServer.getOptions().filesRoot());
+      final RecordSpecBuilder specBuilder = new RecordSpecBuilder()
+          .matchRequestBodyWithEqualToJson(true, false)
+          .captureHeader("If-None-Match");
+      wireMockServer.snapshotRecord(specBuilder);
+    }
+    wireMockServer.resetRequests();
+    DefaultTerminologyServiceFactory.reset();
+  }
+
+  @AfterAll
+  static void afterAll() {
+    wireMockServer.stop();
+  }
+
+  static boolean isRecordMode() {
+    return Boolean.parseBoolean(System.getProperty("pathling.test.recording.enabled", "false"));
+  }
+
+  static String recordingTxServerUrl() {
+    return requireNonNull(System.getProperty("pathling.test.recording.txServerUrl"));
+  }
+
+}
diff --git a/terminology/src/test/java/au/csiro/pathling/test/helpers/TerminologyServiceHelpers.java b/terminology/src/test/java/au/csiro/pathling/test/helpers/TerminologyServiceHelpers.java
index dac53ba961..4bc8b6c221 100644
--- a/terminology/src/test/java/au/csiro/pathling/test/helpers/TerminologyServiceHelpers.java
+++ b/terminology/src/test/java/au/csiro/pathling/test/helpers/TerminologyServiceHelpers.java
@@ -1,8 +1,12 @@
 package au.csiro.pathling.test.helpers;
 
+import static au.csiro.pathling.fhirpath.CodingHelpers.codingEquals;
 import static au.csiro.pathling.test.helpers.FhirMatchers.codingEq;
 import static au.csiro.pathling.test.helpers.FhirMatchers.deepEq;
-import static au.csiro.pathling.fhirpath.CodingHelpers.codingEquals;
+import static org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome.EQUIVALENT;
+import static org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome.NOTSUBSUMED;
+import static org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome.SUBSUMEDBY;
+import static org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome.SUBSUMES;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.eq;
@@ -12,11 +16,11 @@
 import static org.mockito.Mockito.when;
 
 import au.csiro.pathling.fhirpath.encoding.ImmutableCoding;
-import au.csiro.pathling.terminology.TerminologyService2;
-import au.csiro.pathling.terminology.TerminologyService2.Designation;
-import au.csiro.pathling.terminology.TerminologyService2.Property;
-import au.csiro.pathling.terminology.TerminologyService2.PropertyOrDesignation;
-import au.csiro.pathling.terminology.TerminologyService2.Translation;
+import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyService.Designation;
+import au.csiro.pathling.terminology.TerminologyService.Property;
+import au.csiro.pathling.terminology.TerminologyService.PropertyOrDesignation;
+import au.csiro.pathling.terminology.TerminologyService.Translation;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -34,15 +38,9 @@
 import org.hl7.fhir.r4.model.ValueSet;
 import org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence;
 import org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome;
-import org.junit.jupiter.api.BeforeEach;
 import org.mockito.invocation.InvocationOnMock;
 import org.mockito.stubbing.Answer;
 
-import static org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome.EQUIVALENT;
-import static org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome.NOTSUBSUMED;
-import static org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome.SUBSUMES;
-import static org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome.SUBSUMEDBY;
-
 public class TerminologyServiceHelpers {
 
   public final static Parameters RESULT_TRUE = new Parameters().setParameter("result", true);
@@ -52,15 +50,15 @@ public class TerminologyServiceHelpers {
       EQUIVALENT.toCode());
   public final static Parameters OUTCOME_SUBSUMES = new Parameters().setParameter("outcome",
       SUBSUMES.toCode());
-  public final static Parameters OUTCOME_SUBSUMEDBY = new Parameters().setParameter("outcome",
+  public final static Parameters OUTCOME_SUBSUMED_BY = new Parameters().setParameter("outcome",
       SUBSUMEDBY.toCode());
 
   public static class ValidateExpectations {
 
     @Nonnull
-    private final TerminologyService2 mockService;
+    private final TerminologyService mockService;
 
-    ValidateExpectations(@Nonnull final TerminologyService2 mockService) {
+    ValidateExpectations(@Nonnull final TerminologyService mockService) {
       this.mockService = mockService;
       clearInvocations(mockService);
       when(mockService.validateCode(any(), any())).thenReturn(false);
@@ -69,7 +67,7 @@ public static class ValidateExpectations {
     @Nonnull
     public ValidateExpectations withValueSet(@Nonnull final String valueSetUrl,
         @Nonnull final Coding... codings) {
-      for (Coding coding : codings) {
+      for (final Coding coding : codings) {
         when(mockService.validateCode(eq(valueSetUrl), codingEq(coding))).thenReturn(true);
       }
       return this;
@@ -90,9 +88,9 @@ public ValidateExpectations fromValueSet(@Nonnull final String valueSetUri,
   public static class TranslateExpectations {
 
     @Nonnull
-    private final TerminologyService2 mockService;
+    private final TerminologyService mockService;
 
-    TranslateExpectations(@Nonnull final TerminologyService2 mockService) {
+    TranslateExpectations(@Nonnull final TerminologyService mockService) {
       this.mockService = mockService;
       clearInvocations(mockService);
       when(mockService.translate(any(), any(), anyBoolean(), isNull())).thenReturn(
@@ -143,7 +141,7 @@ public TranslateExpectations withTranslations(
   public static class SubsumesExpectations {
 
     @Nonnull
-    private final TerminologyService2 mockService;
+    private final TerminologyService mockService;
 
     private static class DefaultAnswer implements Answer<ConceptSubsumptionOutcome> {
 
@@ -160,7 +158,7 @@ public ConceptSubsumptionOutcome answer(final InvocationOnMock invocationOnMock)
       }
     }
 
-    public SubsumesExpectations(@Nonnull final TerminologyService2 mockService) {
+    public SubsumesExpectations(@Nonnull final TerminologyService mockService) {
       this.mockService = mockService;
       clearInvocations(mockService);
       doAnswer(new DefaultAnswer()).when(mockService).subsumes(any(), any());
@@ -178,11 +176,11 @@ public SubsumesExpectations withSubsumes(@Nonnull final Coding codingA,
 
   public static class LookupExpectations {
 
-    private final TerminologyService2 mockService;
     private final Map<ImmutableCoding, List<PropertyOrDesignation>> designationsOfCoding = new HashMap<>();
 
+    private final TerminologyService mockService;
 
-    public LookupExpectations(final TerminologyService2 mockService) {
+    public LookupExpectations(final TerminologyService mockService) {
       this.mockService = mockService;
       clearInvocations(mockService);
       when(mockService.lookup(any(), any())).thenReturn(Collections.emptyList());
@@ -205,7 +203,7 @@ public LookupExpectations withDisplay(@Nonnull final Coding coding) {
     @SafeVarargs
     @Nonnull
     public final <T extends Type> LookupExpectations withProperty(@Nonnull final Coding coding,
-        @Nonnull final String propertyCode, T... values) {
+        @Nonnull final String propertyCode, final T... values) {
       when(mockService.lookup(deepEq(coding), eq(propertyCode))).thenReturn(
           Stream.of(values)
               .map(v -> Property.of(propertyCode, v))
@@ -234,25 +232,25 @@ public void done() {
   }
 
   @Nonnull
-  public static ValidateExpectations setupValidate(@Nonnull final TerminologyService2 mockService) {
+  public static ValidateExpectations setupValidate(@Nonnull final TerminologyService mockService) {
     return new ValidateExpectations(mockService);
   }
 
 
   @Nonnull
   public static TranslateExpectations setupTranslate(
-      @Nonnull final TerminologyService2 mockService) {
+      @Nonnull final TerminologyService mockService) {
     return new TranslateExpectations(mockService);
   }
 
 
   @Nonnull
   public static SubsumesExpectations setupSubsumes(
-      @Nonnull final TerminologyService2 mockService) {
+      @Nonnull final TerminologyService mockService) {
     return new SubsumesExpectations(mockService);
   }
 
-  public static LookupExpectations setupLookup(@Nonnull final TerminologyService2 mockService) {
+  public static LookupExpectations setupLookup(@Nonnull final TerminologyService mockService) {
     return new LookupExpectations(mockService);
   }
 
diff --git a/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/.gitkeep b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_codesystem_lookup-6e28f05d-8770-4c9b-ac2d-d7383a08d3f1.json b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_codesystem_lookup-6e28f05d-8770-4c9b-ac2d-d7383a08d3f1.json
new file mode 100644
index 0000000000..0ca4cb2ab8
--- /dev/null
+++ b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_codesystem_lookup-6e28f05d-8770-4c9b-ac2d-d7383a08d3f1.json
@@ -0,0 +1,31 @@
+{
+  "id" : "6e28f05d-8770-4c9b-ac2d-d7383a08d3f1",
+  "name" : "fhir_codesystem_lookup",
+  "request" : {
+    "url" : "/fhir/CodeSystem/$lookup?system=http%3A%2F%2Fsnomed.info%2Fsct&code=291446002&property=inactive",
+    "method" : "GET",
+    "headers" : {
+      "If-None-Match" : {
+        "equalTo" : "W/\"1667879415644\""
+      }
+    }
+  },
+  "response" : {
+    "status" : 304,
+    "headers" : {
+      "X-Request-Id" : "d32c3f8b-b7e4-478d-b3a1-d6933b496d0c",
+      "ETag" : "W/\"1667879415644\"",
+      "X-Powered-By" : "HAPI FHIR 6.1.3 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "Cache-Control" : "max-age=1",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "X-Frame-Options" : "DENY",
+      "Date" : "Thu, 22 Dec 2022 06:08:17 GMT",
+      "Keep-Alive" : "timeout=60"
+    }
+  },
+  "uuid" : "6e28f05d-8770-4c9b-ac2d-d7383a08d3f1",
+  "persistent" : true,
+  "insertionIndex" : 25
+}
\ No newline at end of file
diff --git a/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_codesystem_lookup-b0231d9f-7040-4cfb-8cc2-2c2819f1a4a8.json b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_codesystem_lookup-b0231d9f-7040-4cfb-8cc2-2c2819f1a4a8.json
new file mode 100644
index 0000000000..4f1b5e0530
--- /dev/null
+++ b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_codesystem_lookup-b0231d9f-7040-4cfb-8cc2-2c2819f1a4a8.json
@@ -0,0 +1,28 @@
+{
+  "id" : "b0231d9f-7040-4cfb-8cc2-2c2819f1a4a8",
+  "name" : "fhir_codesystem_lookup",
+  "request" : {
+    "url" : "/fhir/CodeSystem/$lookup?system=http%3A%2F%2Fsnomed.info%2Fsct&code=291446002&property=inactive",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"code\",\"valueCode\":\"291446002\"},{\"name\":\"display\",\"valueString\":\"Chloroquine poisoning of undetermined intent\"},{\"name\":\"name\",\"valueString\":\"SNOMED Clinical Terms Australian extension\"},{\"name\":\"property\",\"part\":[{\"name\":\"code\",\"valueCode\":\"inactive\"},{\"name\":\"value\",\"valueBoolean\":true},{\"name\":\"valueBoolean\",\"valueBoolean\":true}]}]}",
+    "headers" : {
+      "X-Request-Id" : "MAUvTTVSNJA3QPjc",
+      "ETag" : "W/\"1667879415644\"",
+      "Cache-Control" : "max-age=1",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.1.3 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "X-Frame-Options" : "DENY",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "Date" : "Thu, 22 Dec 2022 06:08:14 GMT",
+      "Keep-Alive" : "timeout=60"
+    }
+  },
+  "uuid" : "b0231d9f-7040-4cfb-8cc2-2c2819f1a4a8",
+  "persistent" : true,
+  "insertionIndex" : 24
+}
\ No newline at end of file
diff --git a/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_codesystem_lookup-fdba6f65-e406-4ade-91c6-41577faacfe5.json b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_codesystem_lookup-fdba6f65-e406-4ade-91c6-41577faacfe5.json
new file mode 100644
index 0000000000..591b4c7874
--- /dev/null
+++ b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_codesystem_lookup-fdba6f65-e406-4ade-91c6-41577faacfe5.json
@@ -0,0 +1,28 @@
+{
+  "id" : "fdba6f65-e406-4ade-91c6-41577faacfe5",
+  "name" : "fhir_codesystem_lookup",
+  "request" : {
+    "url" : "/fhir/CodeSystem/$lookup?system=http%3A%2F%2Fsnomed.info%2Fsct&code=291446002&property=inactive",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"code\",\"valueCode\":\"291446002\"},{\"name\":\"display\",\"valueString\":\"Chloroquine poisoning of undetermined intent\"},{\"name\":\"name\",\"valueString\":\"SNOMED Clinical Terms Australian extension\"},{\"name\":\"property\",\"part\":[{\"name\":\"code\",\"valueCode\":\"inactive\"},{\"name\":\"value\",\"valueBoolean\":true},{\"name\":\"valueBoolean\",\"valueBoolean\":true}]}]}",
+    "headers" : {
+      "X-Request-Id" : "27Sk9f3rIX4hjinY",
+      "ETag" : "W/\"1667879415644\"",
+      "Cache-Control" : "max-age=1",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.1.3 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "X-Frame-Options" : "DENY",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "Date" : "Thu, 22 Dec 2022 06:08:01 GMT",
+      "Keep-Alive" : "timeout=60"
+    }
+  },
+  "uuid" : "fdba6f65-e406-4ade-91c6-41577faacfe5",
+  "persistent" : true,
+  "insertionIndex" : 7
+}
\ No newline at end of file
diff --git a/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_codesystem_subsumes-ad05fc26-d5c9-4b96-ae2d-992ea3004214.json b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_codesystem_subsumes-ad05fc26-d5c9-4b96-ae2d-992ea3004214.json
new file mode 100644
index 0000000000..5713118a7a
--- /dev/null
+++ b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_codesystem_subsumes-ad05fc26-d5c9-4b96-ae2d-992ea3004214.json
@@ -0,0 +1,28 @@
+{
+  "id" : "ad05fc26-d5c9-4b96-ae2d-992ea3004214",
+  "name" : "fhir_codesystem_subsumes",
+  "request" : {
+    "url" : "/fhir/CodeSystem/$subsumes?codeA=44054006&codeB=73211009&system=http%3A%2F%2Fsnomed.info%2Fsct",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"outcome\",\"valueCode\":\"subsumed-by\"}]}",
+    "headers" : {
+      "X-Request-Id" : "0OGiLzglZ6uFQvN5",
+      "ETag" : "W/\"1667879415644\"",
+      "Cache-Control" : "max-age=1",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.1.3 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "X-Frame-Options" : "DENY",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "Date" : "Thu, 22 Dec 2022 06:08:10 GMT",
+      "Keep-Alive" : "timeout=60"
+    }
+  },
+  "uuid" : "ad05fc26-d5c9-4b96-ae2d-992ea3004214",
+  "persistent" : true,
+  "insertionIndex" : 20
+}
\ No newline at end of file
diff --git a/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_codesystem_subsumes-f14718fd-fd78-4982-a850-da75817738fe.json b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_codesystem_subsumes-f14718fd-fd78-4982-a850-da75817738fe.json
new file mode 100644
index 0000000000..c208745839
--- /dev/null
+++ b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_codesystem_subsumes-f14718fd-fd78-4982-a850-da75817738fe.json
@@ -0,0 +1,31 @@
+{
+  "id" : "f14718fd-fd78-4982-a850-da75817738fe",
+  "name" : "fhir_codesystem_subsumes",
+  "request" : {
+    "url" : "/fhir/CodeSystem/$subsumes?codeA=44054006&codeB=73211009&system=http%3A%2F%2Fsnomed.info%2Fsct",
+    "method" : "GET",
+    "headers" : {
+      "If-None-Match" : {
+        "equalTo" : "W/\"1667879415644\""
+      }
+    }
+  },
+  "response" : {
+    "status" : 304,
+    "headers" : {
+      "X-Request-Id" : "7e8c86ce-97be-4ac9-8b63-6a05ca9383c5",
+      "ETag" : "W/\"1667879415644\"",
+      "X-Powered-By" : "HAPI FHIR 6.1.3 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "Cache-Control" : "max-age=1",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "X-Frame-Options" : "DENY",
+      "Date" : "Thu, 22 Dec 2022 06:08:12 GMT",
+      "Keep-Alive" : "timeout=60"
+    }
+  },
+  "uuid" : "f14718fd-fd78-4982-a850-da75817738fe",
+  "persistent" : true,
+  "insertionIndex" : 21
+}
\ No newline at end of file
diff --git a/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_codesystem_subsumes-f70ebc2e-18c4-4b9c-a889-fc823979312b.json b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_codesystem_subsumes-f70ebc2e-18c4-4b9c-a889-fc823979312b.json
new file mode 100644
index 0000000000..ba4dc7041c
--- /dev/null
+++ b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_codesystem_subsumes-f70ebc2e-18c4-4b9c-a889-fc823979312b.json
@@ -0,0 +1,28 @@
+{
+  "id" : "f70ebc2e-18c4-4b9c-a889-fc823979312b",
+  "name" : "fhir_codesystem_subsumes",
+  "request" : {
+    "url" : "/fhir/CodeSystem/$subsumes?codeA=44054006&codeB=73211009&system=http%3A%2F%2Fsnomed.info%2Fsct",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"outcome\",\"valueCode\":\"subsumed-by\"}]}",
+    "headers" : {
+      "X-Request-Id" : "1mDQx3VMuzdVKdlD",
+      "ETag" : "W/\"1667879415644\"",
+      "Cache-Control" : "max-age=1",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.1.3 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "X-Frame-Options" : "DENY",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "Date" : "Thu, 22 Dec 2022 06:08:01 GMT",
+      "Keep-Alive" : "timeout=60"
+    }
+  },
+  "uuid" : "f70ebc2e-18c4-4b9c-a889-fc823979312b",
+  "persistent" : true,
+  "insertionIndex" : 5
+}
\ No newline at end of file
diff --git a/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_conceptmap_translate-0ec1870f-4438-453b-bfe4-a79f3b0332d0.json b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_conceptmap_translate-0ec1870f-4438-453b-bfe4-a79f3b0332d0.json
new file mode 100644
index 0000000000..49701b35ab
--- /dev/null
+++ b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_conceptmap_translate-0ec1870f-4438-453b-bfe4-a79f3b0332d0.json
@@ -0,0 +1,28 @@
+{
+  "id" : "0ec1870f-4438-453b-bfe4-a79f3b0332d0",
+  "name" : "fhir_conceptmap_translate",
+  "request" : {
+    "url" : "/fhir/ConceptMap/$translate?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_cm%3D900000000000527005&system=http%3A%2F%2Fsnomed.info%2Fsct&code=291446002&reverse=false",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"result\",\"valueBoolean\":true},{\"name\":\"match\",\"part\":[{\"name\":\"equivalence\",\"valueCode\":\"equal\"},{\"name\":\"concept\",\"valueCoding\":{\"system\":\"http://snomed.info/sct\",\"code\":\"45110008\",\"display\":\"Chloroquine poisoning\"}},{\"name\":\"source\",\"valueString\":\"http://snomed.info/sct/32506021000036107/version/20221031?fhir_cm=900000000000527005\"}]}]}",
+    "headers" : {
+      "X-Request-Id" : "GikPESprHoUr952d",
+      "ETag" : "W/\"1658184692798\"",
+      "Cache-Control" : "max-age=1",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.1.3 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "X-Frame-Options" : "DENY",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "Date" : "Thu, 22 Dec 2022 06:08:01 GMT",
+      "Keep-Alive" : "timeout=60"
+    }
+  },
+  "uuid" : "0ec1870f-4438-453b-bfe4-a79f3b0332d0",
+  "persistent" : true,
+  "insertionIndex" : 3
+}
\ No newline at end of file
diff --git a/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_conceptmap_translate-3fd11f0e-89b7-464f-9aee-dada0c572640.json b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_conceptmap_translate-3fd11f0e-89b7-464f-9aee-dada0c572640.json
new file mode 100644
index 0000000000..d16440e30c
--- /dev/null
+++ b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_conceptmap_translate-3fd11f0e-89b7-464f-9aee-dada0c572640.json
@@ -0,0 +1,28 @@
+{
+  "id" : "3fd11f0e-89b7-464f-9aee-dada0c572640",
+  "name" : "fhir_conceptmap_translate",
+  "request" : {
+    "url" : "/fhir/ConceptMap/$translate?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_cm%3D900000000000527005&system=http%3A%2F%2Fsnomed.info%2Fsct&code=291446002&reverse=false",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"result\",\"valueBoolean\":true},{\"name\":\"match\",\"part\":[{\"name\":\"equivalence\",\"valueCode\":\"equal\"},{\"name\":\"concept\",\"valueCoding\":{\"system\":\"http://snomed.info/sct\",\"code\":\"45110008\",\"display\":\"Chloroquine poisoning\"}},{\"name\":\"source\",\"valueString\":\"http://snomed.info/sct/32506021000036107/version/20221031?fhir_cm=900000000000527005\"}]}]}",
+    "headers" : {
+      "X-Request-Id" : "xFMMppmXK6rwhBgt",
+      "ETag" : "W/\"1658184692798\"",
+      "Cache-Control" : "max-age=1",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.1.3 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "X-Frame-Options" : "DENY",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "Date" : "Thu, 22 Dec 2022 06:08:06 GMT",
+      "Keep-Alive" : "timeout=60"
+    }
+  },
+  "uuid" : "3fd11f0e-89b7-464f-9aee-dada0c572640",
+  "persistent" : true,
+  "insertionIndex" : 16
+}
\ No newline at end of file
diff --git a/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_conceptmap_translate-d5d35e9a-91e5-4b0b-b816-c7a20a1757cf.json b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_conceptmap_translate-d5d35e9a-91e5-4b0b-b816-c7a20a1757cf.json
new file mode 100644
index 0000000000..df2cb5bbf9
--- /dev/null
+++ b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_conceptmap_translate-d5d35e9a-91e5-4b0b-b816-c7a20a1757cf.json
@@ -0,0 +1,31 @@
+{
+  "id" : "d5d35e9a-91e5-4b0b-b816-c7a20a1757cf",
+  "name" : "fhir_conceptmap_translate",
+  "request" : {
+    "url" : "/fhir/ConceptMap/$translate?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_cm%3D900000000000527005&system=http%3A%2F%2Fsnomed.info%2Fsct&code=291446002&reverse=false",
+    "method" : "GET",
+    "headers" : {
+      "If-None-Match" : {
+        "equalTo" : "W/\"1658184692798\""
+      }
+    }
+  },
+  "response" : {
+    "status" : 304,
+    "headers" : {
+      "X-Request-Id" : "8598748b-8197-4ec7-8d02-8c9b5b13356b",
+      "ETag" : "W/\"1658184692798\"",
+      "X-Powered-By" : "HAPI FHIR 6.1.3 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "Cache-Control" : "max-age=1",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "X-Frame-Options" : "DENY",
+      "Date" : "Thu, 22 Dec 2022 06:08:08 GMT",
+      "Keep-Alive" : "timeout=60"
+    }
+  },
+  "uuid" : "d5d35e9a-91e5-4b0b-b816-c7a20a1757cf",
+  "persistent" : true,
+  "insertionIndex" : 17
+}
\ No newline at end of file
diff --git a/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_valueset_validate-code-34b3ee19-98a8-48db-8ad0-0cfa4084dd26.json b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_valueset_validate-code-34b3ee19-98a8-48db-8ad0-0cfa4084dd26.json
new file mode 100644
index 0000000000..1d0b0f9b24
--- /dev/null
+++ b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_valueset_validate-code-34b3ee19-98a8-48db-8ad0-0cfa4084dd26.json
@@ -0,0 +1,31 @@
+{
+  "id" : "34b3ee19-98a8-48db-8ad0-0cfa4084dd26",
+  "name" : "fhir_valueset_validate-code",
+  "request" : {
+    "url" : "/fhir/ValueSet/$validate-code?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_vs&system=http%3A%2F%2Fsnomed.info%2Fsct&code=44054006",
+    "method" : "GET",
+    "headers" : {
+      "If-None-Match" : {
+        "equalTo" : "W/\"-1667438239788\""
+      }
+    }
+  },
+  "response" : {
+    "status" : 304,
+    "headers" : {
+      "X-Request-Id" : "7656a7f5-4c0e-42e9-ab9b-72c409437ed2",
+      "ETag" : "W/\"-1667438239788\"",
+      "X-Powered-By" : "HAPI FHIR 6.1.3 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "Cache-Control" : "max-age=1",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "X-Frame-Options" : "DENY",
+      "Date" : "Thu, 22 Dec 2022 06:08:04 GMT",
+      "Keep-Alive" : "timeout=60"
+    }
+  },
+  "uuid" : "34b3ee19-98a8-48db-8ad0-0cfa4084dd26",
+  "persistent" : true,
+  "insertionIndex" : 13
+}
\ No newline at end of file
diff --git a/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_valueset_validate-code-3fdc6d2a-e9b4-4bba-895b-8abcf1ede7a1.json b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_valueset_validate-code-3fdc6d2a-e9b4-4bba-895b-8abcf1ede7a1.json
new file mode 100644
index 0000000000..66b3e7e5e0
--- /dev/null
+++ b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_valueset_validate-code-3fdc6d2a-e9b4-4bba-895b-8abcf1ede7a1.json
@@ -0,0 +1,28 @@
+{
+  "id" : "3fdc6d2a-e9b4-4bba-895b-8abcf1ede7a1",
+  "name" : "fhir_valueset_validate-code",
+  "request" : {
+    "url" : "/fhir/ValueSet/$validate-code?url=urn%3Atest%3Anon-existent-value-set&system=http%3A%2F%2Fsnomed.info%2Fsct&code=45110008",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 404,
+    "body" : "{\"resourceType\":\"OperationOutcome\",\"issue\":[{\"severity\":\"error\",\"code\":\"not-found\",\"diagnostics\":\"[b21f7e9e-db8e-4c76-b277-093427a40f19]: Could not find value set urn:test:non-existent-value-set and version null. If this is an implicit value set please make sure the url is correct. Implicit values sets for different code systems are specified in https://www.hl7.org/fhir/terminologies-systems.html.\"}]}",
+    "headers" : {
+      "ETag" : "W/\"1666505719745\"",
+      "X-Request-Id" : "Grj8G4LQhpKWiKwG",
+      "X-Powered-By" : "HAPI FHIR 6.1.3 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "Cache-Control" : "max-age=1",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "X-Frame-Options" : "DENY",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "Date" : "Thu, 22 Dec 2022 06:08:01 GMT",
+      "Keep-Alive" : "timeout=60"
+    }
+  },
+  "uuid" : "3fdc6d2a-e9b4-4bba-895b-8abcf1ede7a1",
+  "persistent" : true,
+  "insertionIndex" : 9
+}
\ No newline at end of file
diff --git a/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_valueset_validate-code-531ee482-7700-45fd-8836-bb91c064302f.json b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_valueset_validate-code-531ee482-7700-45fd-8836-bb91c064302f.json
new file mode 100644
index 0000000000..2f63d1fb6a
--- /dev/null
+++ b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_valueset_validate-code-531ee482-7700-45fd-8836-bb91c064302f.json
@@ -0,0 +1,28 @@
+{
+  "id" : "531ee482-7700-45fd-8836-bb91c064302f",
+  "name" : "fhir_valueset_validate-code",
+  "request" : {
+    "url" : "/fhir/ValueSet/$validate-code?url=urn%3Atest%3Anon-existent-value-set&system=http%3A%2F%2Fsnomed.info%2Fsct&code=45110008",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 404,
+    "body" : "{\"resourceType\":\"OperationOutcome\",\"issue\":[{\"severity\":\"error\",\"code\":\"not-found\",\"diagnostics\":\"[c306d47b-80ed-4e5e-99e5-7ed68468eca6]: Could not find value set urn:test:non-existent-value-set and version null. If this is an implicit value set please make sure the url is correct. Implicit values sets for different code systems are specified in https://www.hl7.org/fhir/terminologies-systems.html.\"}]}",
+    "headers" : {
+      "ETag" : "W/\"1666505719745\"",
+      "X-Request-Id" : "PCWqoU7EVSIu1fWU",
+      "X-Powered-By" : "HAPI FHIR 6.1.3 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "Cache-Control" : "max-age=1",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "X-Frame-Options" : "DENY",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "Date" : "Thu, 22 Dec 2022 06:08:19 GMT",
+      "Keep-Alive" : "timeout=60"
+    }
+  },
+  "uuid" : "531ee482-7700-45fd-8836-bb91c064302f",
+  "persistent" : true,
+  "insertionIndex" : 28
+}
\ No newline at end of file
diff --git a/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_valueset_validate-code-f5268c90-749f-412c-928d-058d7d8b3df1.json b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_valueset_validate-code-f5268c90-749f-412c-928d-058d7d8b3df1.json
new file mode 100644
index 0000000000..014bfa8c90
--- /dev/null
+++ b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_valueset_validate-code-f5268c90-749f-412c-928d-058d7d8b3df1.json
@@ -0,0 +1,28 @@
+{
+  "id" : "f5268c90-749f-412c-928d-058d7d8b3df1",
+  "name" : "fhir_valueset_validate-code",
+  "request" : {
+    "url" : "/fhir/ValueSet/$validate-code?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_vs&system=http%3A%2F%2Fsnomed.info%2Fsct&code=44054006",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"result\",\"valueBoolean\":true},{\"name\":\"code\",\"valueCode\":\"44054006\"},{\"name\":\"system\",\"valueUri\":\"http://snomed.info/sct\"},{\"name\":\"version\",\"valueString\":\"http://snomed.info/sct/32506021000036107/version/20221031\"},{\"name\":\"display\",\"valueString\":\"Type 2 diabetes mellitus\"}]}",
+    "headers" : {
+      "X-Request-Id" : "DMYJeRdLCp0rYncp",
+      "ETag" : "W/\"-1667438239788\"",
+      "Cache-Control" : "max-age=1",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.1.3 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "X-Frame-Options" : "DENY",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "Date" : "Thu, 22 Dec 2022 06:08:01 GMT",
+      "Keep-Alive" : "timeout=60"
+    }
+  },
+  "uuid" : "f5268c90-749f-412c-928d-058d7d8b3df1",
+  "persistent" : true,
+  "insertionIndex" : 1
+}
\ No newline at end of file
diff --git a/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_valueset_validate-code-f673b010-7344-4624-a06d-2f7d94705c16.json b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_valueset_validate-code-f673b010-7344-4624-a06d-2f7d94705c16.json
new file mode 100644
index 0000000000..e74bcf27ab
--- /dev/null
+++ b/terminology/src/test/resources/wiremock/CachingTerminologyServiceTest/mappings/fhir_valueset_validate-code-f673b010-7344-4624-a06d-2f7d94705c16.json
@@ -0,0 +1,28 @@
+{
+  "id" : "f673b010-7344-4624-a06d-2f7d94705c16",
+  "name" : "fhir_valueset_validate-code",
+  "request" : {
+    "url" : "/fhir/ValueSet/$validate-code?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_vs&system=http%3A%2F%2Fsnomed.info%2Fsct&code=44054006",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"result\",\"valueBoolean\":true},{\"name\":\"code\",\"valueCode\":\"44054006\"},{\"name\":\"system\",\"valueUri\":\"http://snomed.info/sct\"},{\"name\":\"version\",\"valueString\":\"http://snomed.info/sct/32506021000036107/version/20221031\"},{\"name\":\"display\",\"valueString\":\"Type 2 diabetes mellitus\"}]}",
+    "headers" : {
+      "X-Request-Id" : "xWkQ37qhTnSb9pYM",
+      "ETag" : "W/\"-1667438239788\"",
+      "Cache-Control" : "max-age=1",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.1.3 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "X-Frame-Options" : "DENY",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "Date" : "Thu, 22 Dec 2022 06:08:01 GMT",
+      "Keep-Alive" : "timeout=60"
+    }
+  },
+  "uuid" : "f673b010-7344-4624-a06d-2f7d94705c16",
+  "persistent" : true,
+  "insertionIndex" : 12
+}
\ No newline at end of file