Repository: aehrc/pathling (language: python)

Issue:
Library API: Translate function only produces first translation
The translate function available through the library API (and by extension, the Python API) only produces the first translation for each input code, even when there is more than one translation provided by the terminology server.

The desired behaviour is for the translate result column to be an array of Codings. The user then has the option to select the first, or explode out the array for further processing.

I think we also need to enhance the documentation to be more clear about the type of the result column.
Library API: Translate function only produces first translation
The translate function available through the library API (and by extension, the Python API) only produces the first translation for each input code, even when there is more than one translation provided by the terminology server.

The desired behaviour is for the translate result column to be an array of Codings. The user then has the option to select the first, or explode out the array for further processing.

I think we also need to enhance the documentation to be more clear about the type of the result column.


Discussion / hints:

Fix patch:
diff --git a/fhir-server/src/main/java/au/csiro/pathling/QueryExecutor.java b/fhir-server/src/main/java/au/csiro/pathling/QueryExecutor.java
index c1ee43d106..8900e657f6 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/QueryExecutor.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/QueryExecutor.java
@@ -27,7 +27,7 @@
 import au.csiro.pathling.QueryHelpers.DatasetWithColumn;
 import au.csiro.pathling.QueryHelpers.JoinType;
 import au.csiro.pathling.config.Configuration;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.FhirPath;
 import au.csiro.pathling.fhirpath.Materializable;
 import au.csiro.pathling.fhirpath.ResourcePath;
diff --git a/fhir-server/src/main/java/au/csiro/pathling/aggregate/AggregateExecutor.java b/fhir-server/src/main/java/au/csiro/pathling/aggregate/AggregateExecutor.java
index 38f670ccf2..646b8bd039 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/aggregate/AggregateExecutor.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/aggregate/AggregateExecutor.java
@@ -23,7 +23,7 @@
 import au.csiro.pathling.config.Configuration;
 import au.csiro.pathling.QueryExecutor;
 import au.csiro.pathling.QueryHelpers.DatasetWithColumnMap;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.FhirPath;
 import au.csiro.pathling.fhirpath.Materializable;
 import au.csiro.pathling.fhirpath.ResourcePath;
diff --git a/fhir-server/src/main/java/au/csiro/pathling/config/TerminologyConfiguration.java b/fhir-server/src/main/java/au/csiro/pathling/config/TerminologyConfiguration.java
index 60ce37cbbd..878f6b3351 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/config/TerminologyConfiguration.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/config/TerminologyConfiguration.java
@@ -17,11 +17,13 @@
 
 package au.csiro.pathling.config;
 
+import javax.annotation.Nullable;
 import javax.validation.constraints.Min;
 import javax.validation.constraints.NotBlank;
 import javax.validation.constraints.NotNull;
 import lombok.Data;
 import org.hibernate.validator.constraints.URL;
+import org.springframework.boot.context.properties.DeprecatedConfigurationProperty;
 
 /**
  * Represents configuration specific to the terminology functions of the server.
@@ -43,14 +45,21 @@ public class TerminologyConfiguration {
   @URL
   private String serverUrl;
 
+
   /**
    * The maximum period (in milliseconds) that the server should wait for incoming data from the
    * terminology service.
    */
-  @NotNull
+  @Nullable
   @Min(0)
   private Integer socketTimeout;
 
+  @DeprecatedConfigurationProperty(replacement = "client.socketTimeout")
+  @Nullable
+  public Integer getSocketTimeout() {
+    return socketTimeout;
+  }
+  
   /**
    * Setting this option to {@code true} will enable additional logging of the details of requests
    * between the server and the terminology service.
@@ -58,6 +67,12 @@ public class TerminologyConfiguration {
   @NotNull
   private boolean verboseLogging;
 
+  @NotNull
+  private HttpClientConf client;
+
+  @NotNull
+  private HttpCacheConf cache;
+
   @NotNull
   private TerminologyAuthConfiguration authentication;
 
diff --git a/fhir-server/src/main/java/au/csiro/pathling/extract/ExtractExecutor.java b/fhir-server/src/main/java/au/csiro/pathling/extract/ExtractExecutor.java
index 3daa189035..220a9c0926 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/extract/ExtractExecutor.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/extract/ExtractExecutor.java
@@ -25,7 +25,7 @@
 import au.csiro.pathling.config.Configuration;
 import au.csiro.pathling.QueryExecutor;
 import au.csiro.pathling.QueryHelpers.JoinType;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.FhirPath;
 import au.csiro.pathling.fhirpath.Materializable;
 import au.csiro.pathling.fhirpath.ResourcePath;
diff --git a/fhir-server/src/main/java/au/csiro/pathling/fhir/Dependencies.java b/fhir-server/src/main/java/au/csiro/pathling/fhir/Dependencies.java
index a12bdf239b..4e03f20d6e 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/fhir/Dependencies.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/fhir/Dependencies.java
@@ -21,13 +21,13 @@
 import au.csiro.pathling.config.Configuration;
 import au.csiro.pathling.config.TerminologyConfiguration;
 import au.csiro.pathling.encoders.FhirEncoders;
+import au.csiro.pathling.terminology.DefaultTerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import ca.uhn.fhir.context.FhirContext;
 import ca.uhn.fhir.parser.IParser;
 import javax.annotation.Nonnull;
 import lombok.extern.slf4j.Slf4j;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Profile;
 import org.springframework.stereotype.Component;
@@ -81,27 +81,15 @@ static FhirEncoders fhirEncoders(@Nonnull final Configuration configuration) {
 
   @Bean
   @ConditionalOnMissingBean
-  @ConditionalOnProperty(prefix = "pathling", value = "terminology.enabled", havingValue = "true")
-  @Nonnull
-  static TerminologyClient terminologyClient(@Nonnull final Configuration configuration,
-      @Nonnull final FhirContext fhirContext) {
-    final TerminologyConfiguration terminology = configuration.getTerminology();
-    log.debug("Creating FHIR terminology client: {}", terminology.getServerUrl());
-    return TerminologyClient.build(fhirContext, terminology.getServerUrl(),
-        terminology.getSocketTimeout(), terminology.isVerboseLogging(),
-        terminology.getAuthentication(), log);
-  }
-
-  @Bean
-  @ConditionalOnMissingBean
-  @ConditionalOnBean(TerminologyClient.class)
   @Nonnull
   static TerminologyServiceFactory terminologyClientFactory(
       @Nonnull final Configuration configuration, @Nonnull final FhirContext fhirContext) {
+    log.info("Creating CacheableTerminologyServiceFactory");
     final TerminologyConfiguration terminology = configuration.getTerminology();
-    return new DefaultTerminologyServiceFactory(fhirContext, terminology.getServerUrl(),
-        terminology.getSocketTimeout(), terminology.isVerboseLogging(),
+    return new DefaultTerminologyServiceFactory(fhirContext.getVersion().getVersion(),
+        terminology.getServerUrl(), terminology.getSocketTimeout(), terminology.isVerboseLogging(),
+        terminology.getClient(),
+        terminology.getCache(),
         terminology.getAuthentication());
   }
-
 }
diff --git a/fhir-server/src/main/java/au/csiro/pathling/fhir/ResourceProviderFactory.java b/fhir-server/src/main/java/au/csiro/pathling/fhir/ResourceProviderFactory.java
index df0db41ec4..7caa81d869 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/fhir/ResourceProviderFactory.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/fhir/ResourceProviderFactory.java
@@ -25,6 +25,7 @@
 import au.csiro.pathling.extract.ExtractProvider;
 import au.csiro.pathling.io.Database;
 import au.csiro.pathling.search.SearchProvider;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.update.UpdateProvider;
 import ca.uhn.fhir.context.FhirContext;
 import ca.uhn.fhir.rest.server.IResourceProvider;
diff --git a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/TerminologyUtils.java b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/TerminologyUtils.java
index d7d926a57d..888ffe81a3 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/TerminologyUtils.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/TerminologyUtils.java
@@ -20,6 +20,8 @@
 import au.csiro.pathling.fhirpath.element.ElementPath;
 import au.csiro.pathling.fhirpath.literal.CodingLiteralPath;
 import javax.annotation.Nonnull;
+import au.csiro.pathling.utilities.Preconditions;
+import org.apache.spark.sql.Column;
 import org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType;
 
 /**
@@ -55,4 +57,14 @@ static boolean isCodingOrCodeableConcept(@Nonnull final FhirPath fhirPath) {
       return false;
     }
   }
+
+  @Nonnull
+  static Column getCodingColumn(@Nonnull final FhirPath fhirPath) {
+    Preconditions.check(isCodingOrCodeableConcept(fhirPath),
+        "Coding or CodeableConcept path expected");
+    final Column conceptColumn = fhirPath.getValueColumn();
+    return isCodeableConcept(fhirPath)
+           ? conceptColumn.getField("coding")
+           : conceptColumn;
+  }
 }
diff --git a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/element/DateTimePath.java b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/element/DateTimePath.java
index b2437067f1..d3e5d8c45e 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/element/DateTimePath.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/element/DateTimePath.java
@@ -18,7 +18,6 @@
 package au.csiro.pathling.fhirpath.element;
 
 import static au.csiro.pathling.fhirpath.Temporal.buildDateArithmeticOperation;
-import static org.apache.spark.sql.functions.callUDF;
 
 import au.csiro.pathling.fhirpath.Comparable;
 import au.csiro.pathling.fhirpath.FhirPath;
diff --git a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/memberof/MemberOfFunction.java b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/memberof/MemberOfFunction.java
index 16282d0ebb..f681326cfb 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/memberof/MemberOfFunction.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/memberof/MemberOfFunction.java
@@ -17,27 +17,23 @@
 
 package au.csiro.pathling.fhirpath.function.memberof;
 
+import static au.csiro.pathling.fhirpath.TerminologyUtils.getCodingColumn;
 import static au.csiro.pathling.fhirpath.function.NamedFunction.expressionFromInput;
-import static au.csiro.pathling.utilities.Preconditions.checkPresent;
+import static au.csiro.pathling.sql.Terminology.member_of;
 import static au.csiro.pathling.utilities.Preconditions.checkUserInput;
-import static org.apache.spark.sql.functions.array;
-import static org.apache.spark.sql.functions.when;
 
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.FhirPath;
 import au.csiro.pathling.fhirpath.element.ElementPath;
 import au.csiro.pathling.fhirpath.function.NamedFunction;
 import au.csiro.pathling.fhirpath.function.NamedFunctionInput;
 import au.csiro.pathling.fhirpath.literal.StringLiteralPath;
 import au.csiro.pathling.fhirpath.parser.ParserContext;
-import au.csiro.pathling.terminology.TerminologyFunctions;
 import javax.annotation.Nonnull;
+import lombok.extern.slf4j.Slf4j;
 import org.apache.spark.sql.Column;
 import org.apache.spark.sql.Dataset;
 import org.apache.spark.sql.Row;
-import org.apache.spark.sql.functions;
 import org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType;
-import org.slf4j.MDC;
 
 /**
  * A function that takes a set of Codings or CodeableConcepts as inputs and returns a set of boolean
@@ -47,6 +43,7 @@
  * @author John Grimes
  * @see <a href="https://pathling.csiro.au/docs/fhirpath/functions.html#memberof">memberOf</a>
  */
+@Slf4j
 public class MemberOfFunction implements NamedFunction {
 
   private static final String NAME = "memberOf";
@@ -57,43 +54,45 @@ public class MemberOfFunction implements NamedFunction {
   public MemberOfFunction() {
   }
 
-  private boolean isCodeableConcept(@Nonnull final FhirPath fhirPath) {
-    return (fhirPath instanceof ElementPath &&
-        FHIRDefinedType.CODEABLECONCEPT.equals(((ElementPath) fhirPath).getFhirType()));
-  }
-
   @Nonnull
   @Override
   public FhirPath invoke(@Nonnull final NamedFunctionInput input) {
     validateInput(input);
     final ElementPath inputPath = (ElementPath) input.getInput();
     final StringLiteralPath argument = (StringLiteralPath) input.getArguments().get(0);
-    final ParserContext inputContext = input.getContext();
 
     final Column idColumn = inputPath.getIdColumn();
-    final Column conceptColumn = inputPath.getValueColumn();
-
-    final Column codingArrayCol = (isCodeableConcept(inputPath))
-                                  ? conceptColumn.getField("coding")
-                                  : when(conceptColumn.isNotNull(), array(conceptColumn))
-                                      .otherwise(functions.lit(null));
-
-    // Prepare the data which will be used within the map operation. All of these things must be
-    // Serializable.
-    final TerminologyServiceFactory terminologyServiceFactory =
-        checkPresent(inputContext.getTerminologyServiceFactory());
-    final String valueSetUri = argument.getValue().getValueAsString();
-    final Dataset<Row> dataset = inputPath.getDataset();
-
-    final Dataset<Row> resultDataset = TerminologyFunctions.memberOf(codingArrayCol, valueSetUri,
-        dataset, "result", terminologyServiceFactory, MDC.get("requestId"));
-    final Column resultColumn = functions.col("result");
+    final String valueSetUrl = argument.getValue().asStringValue();
+    
+    final Column resultColumn = member_of(getCodingColumn(inputPath), valueSetUrl);
+    final Dataset<Row> resultDataset = inputPath.getDataset();
+
+    // TODO: teminolog-cachig: maybe the switcheable version
+    // final Column inputColumn = (isCodeableConcept(inputPath))
+    //                            ? lit(null)
+    //                            : conceptColumn;
+    //
+    // final Dataset<Row> inputDataset = inputPath.getDataset();
+    // log.debug("Input dataset has {} partitions", inputDataset.rdd().getNumPartitions());
+    //
+    // // Prepare the data which will be used within the map operation. All of these things must be
+    // // Serializable.
+    // final TerminologyServiceFactory terminologyServiceFactory =
+    //     checkPresent(input.getContext().getTerminologyServiceFactory());
+    // final String valueSetUri = argument.getValue().getValueAsString();
+    //
+    // final Result result = terminologyServiceFactory.memberOf(inputDataset,
+    //     inputColumn, valueSetUri);
+    //
+    // final Column resultColumn = result.getColumn();
+    // final Dataset<Row> resultDataset = result.getDataset();
 
     // Construct a new result expression.
     final String expression = expressionFromInput(input, NAME);
 
     return ElementPath
-        .build(expression, resultDataset, idColumn, inputPath.getEidColumn(), resultColumn,
+        .build(expression, resultDataset, idColumn, inputPath.getEidColumn(),
+            resultColumn,
             inputPath.isSingular(), inputPath.getCurrentResource(), inputPath.getThisColumn(),
             FHIRDefinedType.BOOLEAN);
   }
diff --git a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/subsumes/SubsumesFunction.java b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/subsumes/SubsumesFunction.java
index 9a0d4ed6d2..a6c5797087 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/subsumes/SubsumesFunction.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/subsumes/SubsumesFunction.java
@@ -28,10 +28,9 @@
 import static org.apache.spark.sql.functions.col;
 import static org.apache.spark.sql.functions.collect_set;
 import static org.apache.spark.sql.functions.explode_outer;
-import static org.apache.spark.sql.functions.struct;
 import static org.apache.spark.sql.functions.when;
 
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.FhirPath;
 import au.csiro.pathling.fhirpath.NonLiteralPath;
 import au.csiro.pathling.fhirpath.element.ElementPath;
@@ -45,7 +44,6 @@
 import org.apache.spark.sql.Dataset;
 import org.apache.spark.sql.Row;
 import org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType;
-import org.slf4j.MDC;
 
 
 /**
@@ -101,22 +99,18 @@ public FhirPath invoke(@Nonnull final NamedFunctionInput input) {
     final Dataset<Row> idAndCodingSet = createJoinedDataset(input.getInput(),
         input.getArguments().get(0));
 
-    final Column codingPairCol = struct(idAndCodingSet.col(COL_INPUT_CODINGS),
-        idAndCodingSet.col(COL_ARG_CODINGS));
-
     final TerminologyServiceFactory terminologyServiceFactory = checkPresent(
         input.getContext().getTerminologyServiceFactory());
-    final Dataset<Row> resultDataset = TerminologyFunctions.subsumes(idAndCodingSet, codingPairCol,
-        "result", inverted, terminologyServiceFactory, MDC.get("requestId"));
+    final Dataset<Row> resultDataset = TerminologyFunctions.of(terminologyServiceFactory)
+        .subsumes(idAndCodingSet, idAndCodingSet.col(COL_INPUT_CODINGS),
+            idAndCodingSet.col(COL_ARG_CODINGS), "result", inverted);
     final Column resultColumn = col("result");
 
     // Construct a new result expression.
     final String expression = expressionFromInput(input, functionName);
-    return ElementPath
-        .build(expression, resultDataset, inputFhirPath.getIdColumn(),
-            inputFhirPath.getEidColumn(), resultColumn, inputFhirPath.isSingular(),
-            inputFhirPath.getCurrentResource(), inputFhirPath.getThisColumn(),
-            FHIRDefinedType.BOOLEAN);
+    return ElementPath.build(expression, resultDataset, inputFhirPath.getIdColumn(),
+        inputFhirPath.getEidColumn(), resultColumn, inputFhirPath.isSingular(),
+        inputFhirPath.getCurrentResource(), inputFhirPath.getThisColumn(), FHIRDefinedType.BOOLEAN);
   }
 
   /**
@@ -133,8 +127,7 @@ private Dataset<Row> createJoinedDataset(@Nonnull final FhirPath inputFhirPath,
     final Dataset<Row> inputCodingSet = toInputDataset(inputFhirPath);
     final Dataset<Row> argCodingSet = toArgDataset(argFhirPath);
 
-    return inputCodingSet.join(argCodingSet,
-        col(COL_ID).equalTo(col(COL_ARG_ID)), "left_outer");
+    return inputCodingSet.join(argCodingSet, col(COL_ID).equalTo(col(COL_ARG_ID)), "left_outer");
   }
 
   /**
@@ -186,26 +179,22 @@ private Dataset<Row> toArgDataset(@Nonnull final FhirPath fhirPath) {
                              ? explode_outer(valueColumn.getField(FIELD_CODING))
                              : valueColumn;
 
-    final Dataset<Row> systemAndCodeDataset = expressionDataset
-        .select(fhirPath.getIdColumn().alias(COL_ARG_ID), codingCol.alias(COL_CODING));
+    final Dataset<Row> systemAndCodeDataset = expressionDataset.select(
+        fhirPath.getIdColumn().alias(COL_ARG_ID), codingCol.alias(COL_CODING));
 
-    return systemAndCodeDataset
-        .groupBy(systemAndCodeDataset.col(COL_ARG_ID))
-        .agg(collect_set(systemAndCodeDataset.col(COL_CODING)).alias(
-            COL_ARG_CODINGS));
+    return systemAndCodeDataset.groupBy(systemAndCodeDataset.col(COL_ARG_ID))
+        .agg(collect_set(systemAndCodeDataset.col(COL_CODING)).alias(COL_ARG_CODINGS));
   }
 
   private void validateInput(@Nonnull final NamedFunctionInput input) {
 
     final ParserContext context = input.getContext();
-    checkUserInput(context.getTerminologyServiceFactory()
-        .isPresent(), "Attempt to call terminology function " + functionName
-        + " when terminology service has not been configured");
+    checkUserInput(context.getTerminologyServiceFactory().isPresent(),
+        "Attempt to call terminology function " + functionName
+            + " when terminology service has not been configured");
 
-    checkUserInput(
-        input.getArguments().size() == 1,
-        functionName + " function accepts one argument of type Coding or CodeableConcept"
-    );
+    checkUserInput(input.getArguments().size() == 1,
+        functionName + " function accepts one argument of type Coding or CodeableConcept");
 
     validateExpressionType(input.getInput(), "input");
     validateExpressionType(input.getArguments().get(0), "argument");
@@ -213,9 +202,7 @@ private void validateInput(@Nonnull final NamedFunctionInput input) {
 
   private void validateExpressionType(@Nonnull final FhirPath inputPath,
       @Nonnull final String pathRole) {
-    checkUserInput(
-        isCodingOrCodeableConcept(inputPath),
-        functionName + " function accepts " + pathRole + " of type Coding or CodeableConcept"
-    );
+    checkUserInput(isCodingOrCodeableConcept(inputPath),
+        functionName + " function accepts " + pathRole + " of type Coding or CodeableConcept");
   }
 }
diff --git a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/translate/TranslateFunction.java b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/translate/TranslateFunction.java
index f1095067fa..91fa85f78e 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/translate/TranslateFunction.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/function/translate/TranslateFunction.java
@@ -17,12 +17,12 @@
 
 package au.csiro.pathling.fhirpath.function.translate;
 
+import static au.csiro.pathling.fhirpath.TerminologyUtils.getCodingColumn;
 import static au.csiro.pathling.fhirpath.TerminologyUtils.isCodeableConcept;
 import static au.csiro.pathling.fhirpath.function.NamedFunction.expressionFromInput;
-import static au.csiro.pathling.utilities.Preconditions.checkPresent;
+import static au.csiro.pathling.sql.Terminology.translate;
 import static au.csiro.pathling.utilities.Preconditions.checkUserInput;
 
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.FhirPath;
 import au.csiro.pathling.fhirpath.TerminologyUtils;
 import au.csiro.pathling.fhirpath.element.ElementDefinition;
@@ -33,7 +33,6 @@
 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.TerminologyFunctions;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
@@ -43,11 +42,9 @@
 import org.apache.spark.sql.Column;
 import org.apache.spark.sql.Dataset;
 import org.apache.spark.sql.Row;
-import org.apache.spark.sql.functions;
 import org.hl7.fhir.r4.model.BooleanType;
 import org.hl7.fhir.r4.model.StringType;
 import org.hl7.fhir.r4.model.Type;
-import org.slf4j.MDC;
 
 /**
  * A function that takes a set of Codings or CodeableConcepts as inputs and returns a set Codings
@@ -113,9 +110,9 @@ private <T extends Type> T getValueOr(final int index, @Nonnull final T defaultV
     @Nullable
     private <T extends Type> T getNullableValue(final int index,
         @Nonnull final Class<T> valueClass) {
-      final LiteralPath literalPath;
+      final LiteralPath<?> literalPath;
       try {
-        literalPath = (LiteralPath) arguments.get(index);
+        literalPath = (LiteralPath<?>) arguments.get(index);
       } catch (final IndexOutOfBoundsException e) {
         return null;
       }
@@ -160,25 +157,47 @@ public FhirPath invoke(@Nonnull final NamedFunctionInput input) {
 
     final boolean isCodeableConcept = isCodeableConcept(inputPath);
 
-    final Column codingArrayCol = isCodeableConcept
-                                  ? conceptColumn.getField("coding")
-                                  : functions.when(conceptColumn.isNotNull(),
-                                          functions.array(conceptColumn))
-                                      .otherwise(functions.lit(null));
-
     // The definition of the result is always the Coding element.
     @SuppressWarnings("OptionalGetWithoutIsPresent")
     final ElementDefinition resultDefinition = isCodeableConcept
                                                ? inputPath.getChildElement("coding").get()
                                                : inputPath.getDefinition().get();
 
-    // Prepare the data which will be used within the map operation. All of these things must be
-    // Serializable.
-    final TerminologyServiceFactory terminologyServiceFactory =
-        checkPresent(inputContext.getTerminologyServiceFactory());
+    // TODO: terminology-caching : remove or validate
+    // final Column codingArrayCol = isCodeableConcept
+    //                               ? conceptColumn.getField("coding")
+    //                               : functions.when(conceptColumn.isNotNull(),
+    //                                       functions.array(conceptColumn))
+    //                                   .otherwise(functions.lit(null));
+    //
+    // // Prepare the data which will be used within the map operation. All of these things must be
+    // // Serializable.
+    // final TerminologyServiceFactory terminologyServiceFactory =
+    //     checkPresent(inputContext.getTerminologyServiceFactory());
+    //
+    // final Arguments arguments = Arguments.of(input);
+    //
+    // final String conceptMapUrl = arguments.getValue(0, StringType.class).asStringValue();
+    // final boolean reverse = arguments.getValueOr(1, new BooleanType(DEFAULT_REVERSE))
+    //     .booleanValue();
+    // final String equivalence = arguments.getValueOr(2, new StringType(DEFAULT_EQUIVALENCE))
+    //     .asStringValue();
+    // final Dataset<Row> dataset = inputPath.getDataset();
+    //
+    // final Dataset<Row> translatedDataset = TerminologyFunctions.translate(
+    //     codingArrayCol, conceptMapUrl, reverse, equivalence, dataset, "result",
+    //     terminologyServiceFactory, MDC.get("requestId")
+    // );
+    //
+    // // The result is an array of translations per each input element, which we now
+    // // need to explode in the same way as for path traversal, creating unique element ids.
+    // final MutablePair<Column, Column> valueAndEidColumns = new MutablePair<>();
+    // final Dataset<Row> resultDataset = inputPath
+    //     .explodeArray(translatedDataset, translatedDataset.col("result"), valueAndEidColumns);
+    // // Construct a new result expression.
+    //
 
     final Arguments arguments = Arguments.of(input);
-
     final String conceptMapUrl = arguments.getValue(0, StringType.class).asStringValue();
     final boolean reverse = arguments.getValueOr(1, new BooleanType(DEFAULT_REVERSE))
         .booleanValue();
@@ -189,32 +208,26 @@ public FhirPath invoke(@Nonnull final NamedFunctionInput input) {
         .map(StringType::asStringValue)
         .orElse(null);
     final Dataset<Row> dataset = inputPath.getDataset();
+    final Column translatedCodings = translate(getCodingColumn(inputPath), conceptMapUrl, reverse,
+        equivalence, target);
 
-    final Dataset<Row> translatedDataset = TerminologyFunctions.translate(
-        codingArrayCol, conceptMapUrl, reverse, equivalence, target, dataset, "result",
-        terminologyServiceFactory, MDC.get("requestId")
-    );
-
-    // The result is an array of translations per each input element, which we now
-    // need to explode in the same way as for path traversal, creating unique element ids.
+    // // The result is an array of translations per each input element, which we now
+    // // need to explode in the same way as for path traversal, creating unique element ids.
     final MutablePair<Column, Column> valueAndEidColumns = new MutablePair<>();
     final Dataset<Row> resultDataset = inputPath
-        .explodeArray(translatedDataset, translatedDataset.col("result"), valueAndEidColumns);
-    // Construct a new result expression.
+        .explodeArray(dataset, translatedCodings, valueAndEidColumns);
+
     final String expression = expressionFromInput(input, NAME);
 
-    return ElementPath
-        .build(expression, resultDataset, idColumn, Optional.of(valueAndEidColumns.getRight()),
-            valueAndEidColumns.getLeft(),
-            false, inputPath.getCurrentResource(), inputPath.getThisColumn(),
-            resultDefinition);
+    return ElementPath.build(expression, resultDataset, idColumn,
+        Optional.of(valueAndEidColumns.getRight()), valueAndEidColumns.getLeft(), false,
+        inputPath.getCurrentResource(), inputPath.getThisColumn(), resultDefinition);
   }
 
   private void validateInput(@Nonnull final NamedFunctionInput input) {
     final ParserContext context = input.getContext();
-    checkUserInput(
-        context.getTerminologyServiceFactory()
-            .isPresent(), "Attempt to call terminology function " + NAME
+    checkUserInput(context.getTerminologyServiceFactory().isPresent(),
+        "Attempt to call terminology function " + NAME
             + " when terminology service has not been configured");
 
     final FhirPath inputPath = input.getInput();
diff --git a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/literal/QuantityLiteralPath.java b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/literal/QuantityLiteralPath.java
index a634def246..ce4e16438c 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/fhirpath/literal/QuantityLiteralPath.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/fhirpath/literal/QuantityLiteralPath.java
@@ -17,8 +17,6 @@
 
 package au.csiro.pathling.fhirpath.literal;
 
-import static org.apache.spark.sql.functions.struct;
-
 import au.csiro.pathling.encoders.terminology.ucum.Ucum;
 import au.csiro.pathling.errors.InvalidUserInputError;
 import au.csiro.pathling.fhirpath.Comparable;
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 5dcbaa5304..5270c3e471 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,7 +17,7 @@
 
 package au.csiro.pathling.fhirpath.parser;
 
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.FhirPath;
 import au.csiro.pathling.io.Database;
 import au.csiro.pathling.terminology.TerminologyService;
diff --git a/fhir-server/src/main/java/au/csiro/pathling/search/SearchExecutor.java b/fhir-server/src/main/java/au/csiro/pathling/search/SearchExecutor.java
index 1252f857df..cb303d888a 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/search/SearchExecutor.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/search/SearchExecutor.java
@@ -26,7 +26,7 @@
 import au.csiro.pathling.config.Configuration;
 import au.csiro.pathling.QueryExecutor;
 import au.csiro.pathling.encoders.FhirEncoders;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.FhirPath;
 import au.csiro.pathling.fhirpath.ResourcePath;
 import au.csiro.pathling.fhirpath.element.BooleanPath;
diff --git a/fhir-server/src/main/java/au/csiro/pathling/search/SearchProvider.java b/fhir-server/src/main/java/au/csiro/pathling/search/SearchProvider.java
index 6bccb60b72..5cdee073ee 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/search/SearchProvider.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/search/SearchProvider.java
@@ -21,7 +21,7 @@
 
 import au.csiro.pathling.config.Configuration;
 import au.csiro.pathling.encoders.FhirEncoders;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.ResourceDefinition;
 import au.csiro.pathling.io.Database;
 import au.csiro.pathling.security.OperationAccess;
diff --git a/fhir-server/src/main/java/au/csiro/pathling/security/ga4gh/PassportScopeEnforcer.java b/fhir-server/src/main/java/au/csiro/pathling/security/ga4gh/PassportScopeEnforcer.java
index a431d9d10a..717dec6d43 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/security/ga4gh/PassportScopeEnforcer.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/security/ga4gh/PassportScopeEnforcer.java
@@ -19,7 +19,7 @@
 
 import au.csiro.pathling.config.Configuration;
 import au.csiro.pathling.QueryExecutor;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.ResourcePath;
 import au.csiro.pathling.io.Database;
 import ca.uhn.fhir.context.FhirContext;
diff --git a/fhir-server/src/main/java/au/csiro/pathling/security/ga4gh/ScopeAwareDatabase.java b/fhir-server/src/main/java/au/csiro/pathling/security/ga4gh/ScopeAwareDatabase.java
index 843c58a407..802a59fddf 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/security/ga4gh/ScopeAwareDatabase.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/security/ga4gh/ScopeAwareDatabase.java
@@ -19,7 +19,7 @@
 
 import au.csiro.pathling.config.Configuration;
 import au.csiro.pathling.encoders.FhirEncoders;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.io.Database;
 import ca.uhn.fhir.context.FhirContext;
 import java.util.Optional;
diff --git a/fhir-server/src/main/java/au/csiro/pathling/spark/Spark.java b/fhir-server/src/main/java/au/csiro/pathling/spark/Spark.java
index 95dadbe9f2..ae3d0b8f02 100644
--- a/fhir-server/src/main/java/au/csiro/pathling/spark/Spark.java
+++ b/fhir-server/src/main/java/au/csiro/pathling/spark/Spark.java
@@ -20,9 +20,6 @@
 import au.csiro.pathling.async.SparkListener;
 import au.csiro.pathling.config.Configuration;
 import au.csiro.pathling.sql.SqlStrategy;
-import au.csiro.pathling.sql.udf.SqlFunction1;
-import au.csiro.pathling.sql.udf.SqlFunction2;
-import au.csiro.pathling.sql.udf.SqlFunction3;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Objects;
@@ -56,9 +53,8 @@ public class Spark {
    * creation
    * @param environment Spring {@link Environment} from which to harvest Spark configuration
    * @param sparkListener a {@link SparkListener} that is used to monitor progress of jobs
-   * @param sqlFunction1 a list of {@link SqlFunction1} that should be registered
-   * @param sqlFunction2 a list of {@link SqlFunction2} that should be registered
-   * @param sqlFunction3 a list of {@link SqlFunction3} that should be registered
+   * @param sparkConfigurers a list of {@link SparkConfigurer} that should use to configure spark
+   * session
    * @return A shiny new {@link SparkSession}
    */
   @Bean(destroyMethod = "stop")
@@ -67,9 +63,7 @@ public class Spark {
   public static SparkSession build(@Nonnull final Configuration configuration,
       @Nonnull final Environment environment,
       @Nonnull final Optional<SparkListener> sparkListener,
-      @Nonnull final List<SqlFunction1<?, ?>> sqlFunction1,
-      @Nonnull final List<SqlFunction2<?, ?, ?>> sqlFunction2,
-      @Nonnull final List<SqlFunction3<?, ?, ?, ?>> sqlFunction3) {
+      @Nonnull final List<SparkConfigurer> sparkConfigurers) {
     log.debug("Creating Spark session");
 
     // Pass through Spark configuration.
@@ -84,14 +78,8 @@ public static SparkSession build(@Nonnull final Configuration configuration,
 
     // Configure user defined strategy and functions.
     SqlStrategy.setup(spark);
-    for (final SqlFunction1<?, ?> function : sqlFunction1) {
-      spark.udf().register(function.getName(), function, function.getReturnType());
-    }
-    for (final SqlFunction2<?, ?, ?> function : sqlFunction2) {
-      spark.udf().register(function.getName(), function, function.getReturnType());
-    }
-    for (final SqlFunction3<?, ?, ?, ?> function : sqlFunction3) {
-      spark.udf().register(function.getName(), function, function.getReturnType());
+    for (final SparkConfigurer configurer : sparkConfigurers) {
+      configurer.configure(spark);
     }
 
     // Pass through Hadoop AWS configuration.
@@ -101,7 +89,7 @@ public static SparkSession build(@Nonnull final Configuration configuration,
 
     return spark;
   }
-
+  
   private static void resolveThirdPartyConfiguration(@Nonnull final PropertyResolver resolver,
       @Nonnull final List<String> prefixes, @Nonnull final Consumer<String> setter) {
     // This goes through the properties within the Spring configuration and invokes the provided 
diff --git a/fhir-server/src/main/java/au/csiro/pathling/spark/Udfs.java b/fhir-server/src/main/java/au/csiro/pathling/spark/Udfs.java
new file mode 100644
index 0000000000..5edc89cbdc
--- /dev/null
+++ b/fhir-server/src/main/java/au/csiro/pathling/spark/Udfs.java
@@ -0,0 +1,58 @@
+/*
+ * 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.spark;
+
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
+import au.csiro.pathling.sql.udf.*;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Component;
+import javax.annotation.Nonnull;
+import java.util.List;
+
+/**
+ * Provides an Apache Spark session for use by the Pathling server.
+ *
+ * @author John Grimes
+ */
+@Component
+@Profile("core|unit-test")
+public class Udfs {
+
+  @Bean
+  @Nonnull
+  @Qualifier("terminology")
+  public static SparkConfigurer terminologUdfRegistrar(
+      @Nonnull final TerminologyServiceFactory terminologyServiceFactory) {
+    return new TerminologyUdfRegistrar(terminologyServiceFactory);
+  }
+
+  @Bean
+  @Nonnull
+  @Qualifier("implicit")
+  public static SparkConfigurer implicitUdfRegistrar(
+      @Nonnull final List<SqlFunction1<?, ?>> sqlFunction1,
+      @Nonnull final List<SqlFunction2<?, ?, ?>> sqlFunction2,
+      @Nonnull final List<SqlFunction3<?, ?, ?, ?>> sqlFunction3,
+      @Nonnull final List<SqlFunction4<?, ?, ?, ?, ?>> sqlFunction4,
+      @Nonnull final List<SqlFunction5<?, ?, ?, ?, ?, ?>> sqlFunction5) {
+    return new SqlFunctionRegistrar(sqlFunction1, sqlFunction2, sqlFunction3, sqlFunction4,
+        sqlFunction5);
+  }
+}
diff --git a/fhir-server/src/main/resources/application.yml b/fhir-server/src/main/resources/application.yml
index 28f3b73d80..31a6777976 100644
--- a/fhir-server/src/main/resources/application.yml
+++ b/fhir-server/src/main/resources/application.yml
@@ -49,15 +49,27 @@ pathling:
     # 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
-
-    # The maximum period (in milliseconds) that the server should wait for incoming data from the
-    # terminology service.
-    socketTimeout: 60000
     
     # 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 
+    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:
+      enabled: true
+      maxCacheEntries: 50_000
+      maxObjectSize: 64_000
+      storageType: "memory"
+    
     # Configuration relating to authentication of requests to the terminology service.
     authentication:
       # Enables authenticated requests.
diff --git a/lib/python/examples/member_of.py b/lib/python/examples/member_of.py
index 3a59856094..35f4d118fe 100755
--- a/lib/python/examples/member_of.py
+++ b/lib/python/examples/member_of.py
@@ -18,27 +18,26 @@
 import os
 
 from pathling import PathlingContext
-from pathling.functions import to_coding, to_ecl_value_set
+from pathling.functions import to_snomed_coding, to_ecl_value_set
+
+from pathling.udfs import member_of
 
 HERE = os.path.abspath(os.path.dirname(__file__))
 
 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")}'
 )
 
-result = pc.member_of(
-    csv,
-    to_coding(csv.CODE, "http://snomed.info/sct"),
-    to_ecl_value_set(
-        """
-        << 64572001|Disease| : (
-          << 370135005|Pathological process| = << 441862004|Infectious process|,
-          << 246075003|Causative agent| = << 49872002|Virus|
-        )
-                              """
-    ),
-    "VIRAL_INFECTION",
-)
-result.select("CODE", "DESCRIPTION", "VIRAL_INFECTION").show()
+VIRAL_INFECTION_ECL = """
+                << 64572001|Disease| : (
+                  << 370135005|Pathological process| = << 441862004|Infectious process|,
+                  << 246075003|Causative agent| = << 49872002|Virus|
+                )
+"""
+
+csv.select("CODE", "DESCRIPTION",
+           member_of(to_snomed_coding(csv.CODE), to_ecl_value_set(VIRAL_INFECTION_ECL)).alias(
+               "VIRAL_INFECTION")
+           ).show()
diff --git a/lib/python/examples/member_of_old.py b/lib/python/examples/member_of_old.py
new file mode 100755
index 0000000000..3a59856094
--- /dev/null
+++ b/lib/python/examples/member_of_old.py
@@ -0,0 +1,44 @@
+#!/usr/bin/env python
+
+#  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.
+
+import os
+
+from pathling import PathlingContext
+from pathling.functions import to_coding, to_ecl_value_set
+
+HERE = os.path.abspath(os.path.dirname(__file__))
+
+pc = PathlingContext.create()
+
+csv = pc.spark.read.options(header=True).csv(
+    f'file://{os.path.join(HERE, "data/csv/conditions.csv")}'
+)
+
+result = pc.member_of(
+    csv,
+    to_coding(csv.CODE, "http://snomed.info/sct"),
+    to_ecl_value_set(
+        """
+        << 64572001|Disease| : (
+          << 370135005|Pathological process| = << 441862004|Infectious process|,
+          << 246075003|Causative agent| = << 49872002|Virus|
+        )
+                              """
+    ),
+    "VIRAL_INFECTION",
+)
+result.select("CODE", "DESCRIPTION", "VIRAL_INFECTION").show()
diff --git a/lib/python/examples/subsumes.py b/lib/python/examples/subsumes.py
index a99f9de93e..cb27c92299 100755
--- a/lib/python/examples/subsumes.py
+++ b/lib/python/examples/subsumes.py
@@ -20,32 +20,29 @@
 from pathling import PathlingContext
 from pathling.coding import Coding
 from pathling.functions import to_coding
+from pathling.udfs import subsumes
 
 HERE = os.path.abspath(os.path.dirname(__file__))
 
 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")}'
 )
 first_3 = csv.limit(3)
 cross_join = first_3.selectExpr(
-    "CODE as LEFT", "DESCRIPTION as LEFT_DESCRIPTION"
+        "CODE as LEFT", "DESCRIPTION as LEFT_DESCRIPTION"
 ).crossJoin(first_3.selectExpr("CODE as RIGHT", "DESCRIPTION as RIGHT_DESCRIPTION"))
 
-result_1 = pc.subsumes(
-    cross_join,
-    "SUBSUMES",
-    left_coding_column=to_coding(cross_join.LEFT, "http://snomed.info/sct"),
-    right_coding_column=to_coding(cross_join.RIGHT, "http://snomed.info/sct"),
-)
-result_2 = pc.subsumes(
-    result_1,
-    "LEFT_IS_ENT",
-    # 232208008 |Ear, nose and throat disorder|
-    left_coding=Coding("http://snomed.info/sct", "232208008"),
-    right_coding_column=to_coding(cross_join.LEFT, "http://snomed.info/sct"),
-)
+result_1 = cross_join.withColumn("SUBSUMES",
+                                 subsumes(to_coding(cross_join.LEFT, "http://snomed.info/sct"),
+                                          to_coding(cross_join.RIGHT, "http://snomed.info/sct")))
+result_2 = result_1.withColumn(
+        "LEFT_IS_ENT", subsumes(
+                # 232208008 |Ear, nose and throat disorder|
+                Coding("http://snomed.info/sct", "232208008"),
+                to_coding(cross_join.LEFT, "http://snomed.info/sct")))
+
 result_2.select(
-    "LEFT", "RIGHT", "LEFT_DESCRIPTION", "RIGHT_DESCRIPTION", "SUBSUMES", "LEFT_IS_ENT"
+        "LEFT", "RIGHT", "LEFT_DESCRIPTION", "RIGHT_DESCRIPTION", "SUBSUMES", "LEFT_IS_ENT"
 ).show()
diff --git a/lib/python/examples/subsumes_old.py b/lib/python/examples/subsumes_old.py
new file mode 100755
index 0000000000..a99f9de93e
--- /dev/null
+++ b/lib/python/examples/subsumes_old.py
@@ -0,0 +1,51 @@
+#!/usr/bin/env python
+
+#  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.
+
+import os
+
+from pathling import PathlingContext
+from pathling.coding import Coding
+from pathling.functions import to_coding
+
+HERE = os.path.abspath(os.path.dirname(__file__))
+
+pc = PathlingContext.create()
+
+csv = pc.spark.read.options(header=True).csv(
+    f'file://{os.path.join(HERE, "data/csv/conditions.csv")}'
+)
+first_3 = csv.limit(3)
+cross_join = first_3.selectExpr(
+    "CODE as LEFT", "DESCRIPTION as LEFT_DESCRIPTION"
+).crossJoin(first_3.selectExpr("CODE as RIGHT", "DESCRIPTION as RIGHT_DESCRIPTION"))
+
+result_1 = pc.subsumes(
+    cross_join,
+    "SUBSUMES",
+    left_coding_column=to_coding(cross_join.LEFT, "http://snomed.info/sct"),
+    right_coding_column=to_coding(cross_join.RIGHT, "http://snomed.info/sct"),
+)
+result_2 = pc.subsumes(
+    result_1,
+    "LEFT_IS_ENT",
+    # 232208008 |Ear, nose and throat disorder|
+    left_coding=Coding("http://snomed.info/sct", "232208008"),
+    right_coding_column=to_coding(cross_join.LEFT, "http://snomed.info/sct"),
+)
+result_2.select(
+    "LEFT", "RIGHT", "LEFT_DESCRIPTION", "RIGHT_DESCRIPTION", "SUBSUMES", "LEFT_IS_ENT"
+).show()
diff --git a/lib/python/examples/translate.py b/lib/python/examples/translate.py
index 4fbadf3ccb..14b659b1ea 100755
--- a/lib/python/examples/translate.py
+++ b/lib/python/examples/translate.py
@@ -18,22 +18,20 @@
 import os
 
 from pathling import PathlingContext
-from pathling.functions import to_coding
+from pathling.functions import to_snomed_coding
+from pathling.udfs import translate
+from pyspark.sql.functions import explode_outer
 
 HERE = os.path.abspath(os.path.dirname(__file__))
 
 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")}'
 )
 
 # Translate codings to Read CTV3 using the map that ships with SNOMED CT.
-result = pc.translate(
-    csv,
-    to_coding(csv.CODE, "http://snomed.info/sct"),
-    "http://snomed.info/sct/900000000000207008?fhir_cm=900000000000497000",
-    output_column_name="READ_CODE",
-)
-result = result.withColumn("READ_CODE", result.READ_CODE.code)
-result.select("CODE", "DESCRIPTION", "READ_CODE").show()
+
+result = csv.withColumn("READ_CODES", translate(to_snomed_coding(csv.CODE),
+                                                "http://snomed.info/sct/900000000000207008?fhir_cm=900000000000497000").code)
+result.select("CODE", "DESCRIPTION", explode_outer("READ_CODES").alias("READ_CODE")).show()
diff --git a/lib/python/examples/translate_old.py b/lib/python/examples/translate_old.py
new file mode 100755
index 0000000000..4fbadf3ccb
--- /dev/null
+++ b/lib/python/examples/translate_old.py
@@ -0,0 +1,39 @@
+#!/usr/bin/env python
+
+#  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.
+
+import os
+
+from pathling import PathlingContext
+from pathling.functions import to_coding
+
+HERE = os.path.abspath(os.path.dirname(__file__))
+
+pc = PathlingContext.create()
+
+csv = pc.spark.read.options(header=True).csv(
+    f'file://{os.path.join(HERE, "data/csv/conditions.csv")}'
+)
+
+# Translate codings to Read CTV3 using the map that ships with SNOMED CT.
+result = pc.translate(
+    csv,
+    to_coding(csv.CODE, "http://snomed.info/sct"),
+    "http://snomed.info/sct/900000000000207008?fhir_cm=900000000000497000",
+    output_column_name="READ_CODE",
+)
+result = result.withColumn("READ_CODE", result.READ_CODE.code)
+result.select("CODE", "DESCRIPTION", "READ_CODE").show()
diff --git a/lib/python/pathling/context.py b/lib/python/pathling/context.py
index 4d881668d0..cd622f2c2b 100644
--- a/lib/python/pathling/context.py
+++ b/lib/python/pathling/context.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.
@@ -23,6 +23,8 @@
 from pathling.etc import find_jar
 from pathling.fhir import MimeType
 
+from deprecated import deprecated
+
 __all__ = ["PathlingContext"]
 
 EQ_EQUIVALENT = "equivalent"
@@ -50,20 +52,27 @@ def spark(self) -> SparkSession:
 
     @classmethod
     def create(
-        cls,
-        spark: Optional[SparkSession] = None,
-        fhir_version: Optional[str] = None,
-        max_nesting_level: Optional[int] = None,
-        enable_extensions: Optional[bool] = None,
-        enabled_open_types: Optional[Sequence[str]] = None,
-        terminology_server_url: Optional[str] = None,
-        terminology_socket_timeout: Optional[int] = None,
-        terminology_verbose_request_logging: Optional[bool] = None,
-        token_endpoint: Optional[str] = None,
-        client_id: Optional[str] = None,
-        client_secret: Optional[str] = None,
-        scope: Optional[str] = None,
-        token_expiry_tolerance: Optional[int] = None,
+            cls,
+            spark: Optional[SparkSession] = None,
+            fhir_version: Optional[str] = None,
+            max_nesting_level: Optional[int] = None,
+            enable_extensions: Optional[bool] = None,
+            enabled_open_types: Optional[Sequence[str]] = None,
+            terminology_server_url: Optional[str] = None,
+            terminology_socket_timeout: Optional[int] = None,
+            terminology_verbose_request_logging: Optional[bool] = None,
+            max_connections_total: Optional[int] = None,
+            max_connections_per_route: Optional[int] = None,
+            cache_storge_type: Optional[str] = "memory",
+            cache_max_entries: Optional[int] = None,
+            cache_max_object_size: Optional[int] = None,
+            cache_storage_properties: Optional[dict] = None,
+            token_endpoint: Optional[str] = None,
+            client_id: Optional[str] = None,
+            client_secret: Optional[str] = None,
+            scope: Optional[str] = None,
+            token_expiry_tolerance: Optional[int] = None,
+            mock_terminology: bool = False
     ) -> "PathlingContext":
         """
         Creates a :class:`PathlingContext` with the given configuration options.
@@ -79,6 +88,7 @@ def create(
         classpath. You can get the path for the JAR (which is bundled with the Python package)
         using the `pathling.etc.find_jar` method.
 
+        http services.
         :param spark: the :class:`SparkSession` instance.
         :param fhir_version: the FHIR version to use.
             Must a valid FHIR version string. Defaults to R4.
@@ -93,6 +103,14 @@ def create(
         :param terminology_socket_timeout: the socket timeout for terminology server requests
         :param terminology_verbose_request_logging: enables verbose logging of terminology server
         requests
+        :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_storge_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_max_entries: the maximum nunber of cached entries.
+        :param cache_max_object_size:  the maximum size of cacheable responses in bytes.
+        :param cache_storage_properties: additional configuration properties for the cache 
+        storage. The supported properties depend on the selected storage type.
         :param token_endpoint: an OAuth2 token endpoint for use with the client credentials grant
         :param client_id: a client ID for use with the client credentials grant
         :param client_secret: a client secret for use with the client credentials grant
@@ -102,32 +120,39 @@ def create(
         :return: a DataFrame containing the given resource encoded into Spark columns
         """
         spark = (
-            spark
-            or SparkSession.getActiveSession()
-            or SparkSession.builder.config("spark.jars", find_jar()).getOrCreate()
+                spark
+                or SparkSession.getActiveSession()
+                or SparkSession.builder.config("spark.jars", find_jar()).getOrCreate()
         )
         jvm = spark._jvm
 
         # Build a Java configuration object from the provided parameters.
         config = (
             jvm.au.csiro.pathling.library.PathlingContextConfiguration.builder()
-            .fhirVersion(fhir_version)
-            .maxNestingLevel(max_nesting_level)
-            .extensionsEnabled(enable_extensions)
-            .openTypesEnabled(enabled_open_types)
-            .terminologyServerUrl(terminology_server_url)
-            .terminologySocketTimeout(terminology_socket_timeout)
-            .terminologyVerboseRequestLogging(terminology_verbose_request_logging)
-            .tokenEndpoint(token_endpoint)
-            .clientId(client_id)
-            .clientSecret(client_secret)
-            .scope(scope)
-            .tokenExpiryTolerance(token_expiry_tolerance)
-            .build()
+                .fhirVersion(fhir_version)
+                .maxNestingLevel(max_nesting_level)
+                .extensionsEnabled(enable_extensions)
+                .openTypesEnabled(enabled_open_types)
+                .terminologyServerUrl(terminology_server_url)
+                .terminologySocketTimeout(terminology_socket_timeout)
+                .terminologyVerboseRequestLogging(terminology_verbose_request_logging)
+                .maxConnectionsTotal(max_connections_total)
+                .maxConnectionsPerRoute(max_connections_per_route)
+                .cacheStorageType(cache_storge_type)
+                .cacheMaxEntries(cache_max_entries)
+                .cacheMaxObjectSize(cache_max_object_size)
+                .cacheStorageProperties(cache_storage_properties)
+                .tokenEndpoint(token_endpoint)
+                .clientId(client_id)
+                .clientSecret(client_secret)
+                .scope(scope)
+                .tokenExpiryTolerance(token_expiry_tolerance)
+                .mockTerminology(mock_terminology)
+                .build()
         )
 
         jpc: JavaObject = jvm.au.csiro.pathling.library.PathlingContext.create(
-            spark._jsparkSession, config
+                spark._jsparkSession, config
         )
         return PathlingContext(spark, jpc)
 
@@ -142,16 +167,16 @@ def _wrap_df(self, jdf: JavaObject) -> DataFrame:
         # Since v3.3 Dataframes are constructed with SparkSession instance direclty.
         #
         return DataFrame(
-            jdf,
-            self._spark._wrapped if hasattr(self._spark, "_wrapped") else self._spark,
+                jdf,
+                self._spark._wrapped if hasattr(self._spark, "_wrapped") else self._spark,
         )
 
     def encode(
-        self,
-        df: DataFrame,
-        resource_name: str,
-        input_type: Optional[str] = None,
-        column: Optional[str] = None,
+            self,
+            df: DataFrame,
+            resource_name: str,
+            input_type: Optional[str] = None,
+            column: Optional[str] = None,
     ) -> DataFrame:
         """
         Takes a dataframe with a string representations of FHIR resources  in the given column and
@@ -169,17 +194,17 @@ def encode(
         """
 
         return self._wrap_df(
-            self._jpc.encode(
-                df._jdf, resource_name, input_type or MimeType.FHIR_JSON, column
-            )
+                self._jpc.encode(
+                        df._jdf, resource_name, input_type or MimeType.FHIR_JSON, column
+                )
         )
 
     def encode_bundle(
-        self,
-        df: DataFrame,
-        resource_name: str,
-        input_type: Optional[str] = None,
-        column: Optional[str] = None,
+            self,
+            df: DataFrame,
+            resource_name: str,
+            input_type: Optional[str] = None,
+            column: Optional[str] = None,
     ) -> DataFrame:
         """
         Takes a dataframe with a string representations of FHIR bundles  in the given column and
@@ -195,19 +220,13 @@ def encode_bundle(
         :return: a :class:`DataFrame` containing the given type of resources encoded into Spark
             columns
         """
-        return self._wrap_df(
-            self._jpc.encodeBundle(
-                df._jdf, resource_name, input_type or MimeType.FHIR_JSON, column
-            )
-        )
+        return self._wrap_df(self._jpc.encodeBundle(df._jdf, resource_name,
+                                                    input_type or MimeType.FHIR_JSON,
+                                                    column))
 
-    def member_of(
-        self,
-        df: DataFrame,
-        coding_column: Column,
-        value_set_uri: str,
-        output_column_name: str,
-    ):
+    @deprecated(reason="You should use the 'udfs.member_of' UDF instead")
+    def member_of(self, df: DataFrame, coding_column: Column, value_set_uri: str,
+                  output_column_name: str):
         """
         Takes a dataframe with a Coding column as input. A new column is created which contains a
         Boolean value, indicating whether the input Coding is a member of the specified FHIR
@@ -220,83 +239,83 @@ def member_of(
         :return: A new dataframe with an additional column containing the result of the operation.
         """
         return self._wrap_df(
-            self._jpc.memberOf(
-                df._jdf, coding_column._jc, value_set_uri, output_column_name
-            )
+                self._jpc.memberOf(
+                        df._jdf, coding_column._jc, value_set_uri, output_column_name
+                )
         )
 
+    @deprecated(reason="You should use the 'udfs.translate' UDF instead")
     def translate(
-        self,
-        df: DataFrame,
-        coding_column: Column,
-        concept_map_uri: str,
-        reverse: Optional[bool] = False,
-        equivalence: Optional[str] = EQ_EQUIVALENT,
-        target: Optional[str] = None,
-        output_column_name: Optional[str] = "result",
+            self,
+            df: DataFrame,
+            coding_column: Column,
+            concept_map_uri: str,
+            reverse: Optional[bool] = False,
+            equivalence: Optional[str] = EQ_EQUIVALENT,
+            target: Optional[str] = None,
+            output_column_name: Optional[str] = "result",
     ):
         """
-        Takes a dataframe with a Coding column as input. A new column is created which contains a
-        Coding value and contains translation targets from the specified FHIR ConceptMap. There
-        may be more than one target concept for each input concept.
+        Takes a dataframe with a Coding column as input. A new column is created which contains 
+        the array of Codings value with translation targets from the specified FHIR ConceptMap. 
+        There may be more than one target concept for each input concept.
 
         :param df: a DataFrame containing the input data
         :param coding_column: 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"
-        mappings, while true results in "target to source"
+        :param reverse: the direction to traverse the map - false results in "source to target" 
+            mappings, while true results in "target to source"
         :param equivalence: a comma-delimited set of values from the ConceptMapEquivalence ValueSet
+        :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.
         :param output_column_name: the name of the result column
         :return: A new dataframe with an additional column containing the result of the operation.
         """
         return self._wrap_df(
-            self._jpc.translate(
-                df._jdf,
-                coding_column._jc,
-                concept_map_uri,
-                reverse,
-                equivalence,
-                target,
-                output_column_name,
-            )
+                self._jpc.translate(
+                        df._jdf,
+                        coding_column._jc,
+                        concept_map_uri,
+                        reverse,
+                        equivalence,
+                        target,
+                        output_column_name,
+                )
         )
 
-    def subsumes(
-        self,
-        df: DataFrame,
-        output_column_name: str,
-        left_coding_column: Optional[Column] = None,
-        right_coding_column: Optional[Column] = None,
-        left_coding: Optional[Coding] = None,
-        right_coding: Optional[Coding] = None,
-    ):
+    @deprecated(reason="You should use the 'udfs.subsumes' UDF instead")
+    def subsumes(self, df: DataFrame, output_column_name: str,
+                 left_coding_column: Optional[Column] = None,
+                 right_coding_column: Optional[Column] = None,
+                 left_coding: Optional[Coding] = None,
+                 right_coding: Optional[Coding] = None):
         """
         Takes a dataframe with two Coding columns. A new column is created which contains a
         Boolean value, indicating whether the left Coding subsumes the right Coding.
 
         :param df: a DataFrame containing the input data
         :param left_coding_column: a Column containing a struct representation of a Coding,
-        for the left-hand side of the subsumption test
+            for the left-hand side of the subsumption test
         :param right_coding_column: a Column containing a struct representation of a Coding,
-        for the right-hand side of the subsumption test
+            for the right-hand side of the subsumption test
         :param left_coding: a Coding object for the left-hand side of the subsumption test
         :param right_coding: a Coding object for the right-hand side of the subsumption test
         :param output_column_name: the name of the result column
         :return: A new dataframe with an additional column containing the result of the operation.
         """
         if (left_coding_column is None and left_coding is None) or (
-            right_coding_column is None and right_coding is None
+                right_coding_column is None and right_coding is None
         ):
             raise ValueError(
-                "Must provide either left_coding_column or left_coding, and either "
-                "right_coding_column or right_coding"
+                    "Must provide either left_coding_column or left_coding, and either "
+                    "right_coding_column or right_coding"
             )
         left_column = left_coding.to_literal() if left_coding else left_coding_column
         right_column = (
             right_coding.to_literal() if right_coding else right_coding_column
         )
         return self._wrap_df(
-            self._jpc.subsumes(
-                df._jdf, left_column._jc, right_column._jc, output_column_name
-            )
+                self._jpc.subsumes(
+                        df._jdf, left_column._jc, right_column._jc, output_column_name
+                )
         )
diff --git a/lib/python/pathling/functions.py b/lib/python/pathling/functions.py
index c339237652..525d79f3ae 100644
--- a/lib/python/pathling/functions.py
+++ b/lib/python/pathling/functions.py
@@ -15,13 +15,14 @@
 
 import urllib.parse
 
+from typing import Optional
 from pyspark.sql import Column
 from pyspark.sql.functions import lit, struct
 
 from pathling.etc import SNOMED_URI
 
 
-def to_coding(coding_column: Column, system: str, version: str = None):
+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
@@ -40,6 +41,19 @@ def to_coding(coding_column: Column, system: str, version: str = None):
                   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 
+    Coding
+    struct Column can be used as an input to terminology functions such as `member_of` and
+    `translate`.
+    :param coding_column: the Column containing the codes
+    :param version: the version of the code system
+    :return: a Column containing a Coding struct
+    """
+    return to_coding(coding_column, SNOMED_URI, version)
+
+
 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` 
diff --git a/lib/python/pathling/udfs.py b/lib/python/pathling/udfs.py
new file mode 100644
index 0000000000..74092921bb
--- /dev/null
+++ b/lib/python/pathling/udfs.py
@@ -0,0 +1,115 @@
+#  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,
+    Callable,
+    Optional,
+    Union
+)
+
+from pyspark import SparkContext
+from pyspark.sql.column import Column, _to_java_column
+from pathling.coding import Coding
+
+CodingArg = Union[Column, str, Coding]
+
+
+def _coding_to_java_column(coding: CodingArg) -> Any:
+    return _to_java_column(coding.to_literal() if Coding == type(coding) else coding)
+
+
+def _get_jvm_function(name: str, sc: SparkContext) -> Callable:
+    """
+    Retrieves JVM function identified by name from
+    Java gateway associated with sc.
+    """
+    assert sc._jvm is not None
+    return getattr(sc._jvm.au.csiro.pathling.sql.Terminology, name)
+
+
+def _invoke_function(name: str, *args: Any) -> Column:
+    """
+    Invokes JVM function identified by name with args
+    and wraps the result with :class:`~pyspark.sql.Column`.
+    """
+    assert SparkContext._active_spark_context is not None
+    jf = _get_jvm_function(name, SparkContext._active_spark_context)
+    return Column(jf(*args))
+
+
+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 
+    ValueSet.
+
+    :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.
+    """
+    return _invoke_function("member_of", _coding_to_java_column(coding), value_set_uri)
+
+
+def translate(coding: CodingArg, concept_map_uri: str,
+              reverse: bool = False, equivalences: Optional[str] = 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.
+    
+    :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" 
+        mappings, while true results in "target to source"
+    :param equivalences: a comma-delimited set of values from the ConceptMapEquivalence ValueSet
+    :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_function("translate", _coding_to_java_column(coding), concept_map_uri, reverse,
+                            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 
+        Codings.
+    :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_function("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 
+        Codings.
+    :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_function("subsumed_by", _coding_to_java_column(left_coding),
+                            _coding_to_java_column(right_coding))
diff --git a/lib/python/requirements/package.txt b/lib/python/requirements/package.txt
index a8ea405e20..a4900e67d1 100644
--- a/lib/python/requirements/package.txt
+++ b/lib/python/requirements/package.txt
@@ -1,1 +1,2 @@
 pyspark==3.3.1
+deprecated==1.2.13
diff --git a/lib/python/setup.py b/lib/python/setup.py
index 2fbc785b90..fc5dbb37b4 100644
--- a/lib/python/setup.py
+++ b/lib/python/setup.py
@@ -87,7 +87,8 @@
         ],
         license="Apache License, version 2.0",
         python_requires=">=3.7",
-        install_requires=["pyspark>=3.1.0"],
+        install_requires=["pyspark>=3.1.0",
+                          "deprecated>=1.2.13"],
         include_package_data=True,
         package_dir={
             'pathling.jars': 'target/dependency',
diff --git a/library-api/pom.xml b/library-api/pom.xml
index 1fcbc968f2..28d61fc0d2 100644
--- a/library-api/pom.xml
+++ b/library-api/pom.xml
@@ -70,6 +70,12 @@
       <type>test-jar</type>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>au.csiro.pathling</groupId>
+      <artifactId>terminology</artifactId>
+      <type>test-jar</type>
+      <scope>test</scope>
+    </dependency>
     <dependency>
       <groupId>org.junit.jupiter</groupId>
       <artifactId>junit-jupiter-engine</artifactId>
diff --git a/library-api/src/main/java/au/csiro/pathling/library/EncodeMapPartitionsFunc.java b/library-api/src/main/java/au/csiro/pathling/library/EncodeMapPartitionsFunc.java
index 40cb4f6ff8..22f31585b5 100644
--- a/library-api/src/main/java/au/csiro/pathling/library/EncodeMapPartitionsFunc.java
+++ b/library-api/src/main/java/au/csiro/pathling/library/EncodeMapPartitionsFunc.java
@@ -35,7 +35,7 @@ abstract class EncodeMapPartitionsFunc<T extends IBaseResource> implements
     MapPartitionsFunction<String, T> {
 
   private static final long serialVersionUID = -189338116652852324L;
-  protected FhirVersionEnum fhirVersion;
+  protected final FhirVersionEnum fhirVersion;
   protected final String inputMimeType;
   protected final Class<T> resourceClass;
 
diff --git a/library-api/src/main/java/au/csiro/pathling/library/PathlingContext.java b/library-api/src/main/java/au/csiro/pathling/library/PathlingContext.java
index eb063cd74d..cd8e47bce5 100644
--- a/library-api/src/main/java/au/csiro/pathling/library/PathlingContext.java
+++ b/library-api/src/main/java/au/csiro/pathling/library/PathlingContext.java
@@ -19,30 +19,35 @@
 
 import static au.csiro.pathling.fhirpath.encoding.SimpleCodingsDecoders.COL_ARG_CODINGS;
 import static au.csiro.pathling.fhirpath.encoding.SimpleCodingsDecoders.COL_INPUT_CODINGS;
+import static au.csiro.pathling.library.PathlingContextConfiguration.DEFAULT_TERMINOLOGY_SERVER_URL;
+import static au.csiro.pathling.library.PathlingContextConfiguration.DEFAULT_TERMINOLOGY_VERBOSE_LOGGING;
 import static java.util.Objects.nonNull;
 import static org.apache.spark.sql.functions.array;
 import static org.apache.spark.sql.functions.lit;
-import static org.apache.spark.sql.functions.struct;
 import static org.apache.spark.sql.functions.when;
 
+import au.csiro.pathling.config.HttpCacheConf;
+import au.csiro.pathling.config.HttpClientConf;
 import au.csiro.pathling.config.TerminologyAuthConfiguration;
 import au.csiro.pathling.encoders.FhirEncoders;
 import au.csiro.pathling.encoders.FhirEncoders.Builder;
-import au.csiro.pathling.fhir.DefaultTerminologyServiceFactory;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
 import au.csiro.pathling.sql.SqlStrategy;
+import au.csiro.pathling.sql.udf.TerminologyUdfRegistrar;
 import au.csiro.pathling.support.FhirConversionSupport;
+import au.csiro.pathling.terminology.DefaultTerminologyServiceFactory;
 import au.csiro.pathling.terminology.TerminologyFunctions;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.mock.MockTerminologyServiceFactory;
 import ca.uhn.fhir.context.FhirContext;
 import ca.uhn.fhir.context.FhirVersionEnum;
 import ca.uhn.fhir.context.RuntimeResourceDefinition;
 import java.util.Set;
-import java.util.UUID;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
 import org.apache.spark.sql.Column;
 import org.apache.spark.sql.Dataset;
 import org.apache.spark.sql.Encoders;
@@ -51,7 +56,6 @@
 import org.apache.spark.sql.functions;
 import org.hl7.fhir.instance.model.api.IBaseBundle;
 import org.hl7.fhir.instance.model.api.IBaseResource;
-import org.slf4j.MDC;
 
 /**
  * A class designed to provide access to selected Pathling functionality from non-JVM language
@@ -60,12 +64,9 @@
  * @author Piotr Szul
  * @author John Grimes
  */
+@Slf4j
 public class PathlingContext {
 
-  public static final String DEFAULT_TERMINOLOGY_SERVER_URL = "https://tx.ontoserver.csiro.au/fhir";
-  public static final int DEFAULT_TERMINOLOGY_SOCKET_TIMEOUT = 60_000;
-  public static final long DEFAULT_TOKEN_EXPIRY_TOLERANCE = 120L;
-
   @Nonnull
   @Getter
   private final SparkSession spark;
@@ -80,6 +81,10 @@ public class PathlingContext {
   @Getter
   private final TerminologyServiceFactory terminologyServiceFactory;
 
+  @Nonnull
+  private final TerminologyFunctions terminologyFunctions;
+
+
   private PathlingContext(@Nonnull final SparkSession spark,
       @Nonnull final FhirEncoders fhirEncoders,
       @Nonnull final TerminologyServiceFactory terminologyServiceFactory) {
@@ -87,7 +92,9 @@ private PathlingContext(@Nonnull final SparkSession spark,
     this.fhirVersion = fhirEncoders.getFhirVersion();
     this.fhirEncoders = fhirEncoders;
     this.terminologyServiceFactory = terminologyServiceFactory;
+    this.terminologyFunctions = TerminologyFunctions.of(terminologyServiceFactory);
     SqlStrategy.setup(spark);
+    TerminologyUdfRegistrar.registerUdfs(spark, terminologyServiceFactory);
   }
 
   /**
@@ -100,8 +107,8 @@ public static PathlingContext create() {
   }
 
   /**
-   * Creates a new {@link PathlingContext} using default configuration, and a pre-configured
-   * {@link SparkSession}.
+   * Creates a new {@link PathlingContext} using default configuration, and a pre-configured {@link
+   * SparkSession}.
    */
   @Nonnull
   public static PathlingContext create(@Nonnull final SparkSession spark) {
@@ -119,8 +126,8 @@ public static PathlingContext create(@Nonnull final PathlingContextConfiguration
   }
 
   /**
-   * Creates a new {@link PathlingContext} using pre-configured {@link SparkSession},
-   * {@link FhirEncoders} and {@link TerminologyServiceFactory} objects.
+   * Creates a new {@link PathlingContext} using pre-configured {@link SparkSession}, {@link
+   * FhirEncoders} and {@link TerminologyServiceFactory} objects.
    */
   @Nonnull
   public static PathlingContext create(@Nonnull final SparkSession spark,
@@ -130,8 +137,8 @@ public static PathlingContext create(@Nonnull final SparkSession spark,
   }
 
   /**
-   * Creates a new {@link PathlingContext} using supplied configuration and a pre-configured
-   * {@link SparkSession}.
+   * Creates a new {@link PathlingContext} using supplied configuration and a pre-configured {@link
+   * SparkSession}.
    */
   @Nonnull
   public static PathlingContext create(@Nonnull final SparkSession sparkSession,
@@ -144,7 +151,7 @@ public static PathlingContext create(@Nonnull final SparkSession sparkSession,
     }
 
     final Builder encoderBuilder = getEncoderBuilder(c);
-    final DefaultTerminologyServiceFactory terminologyServiceFactory = getTerminologyServiceFactory(
+    final TerminologyServiceFactory terminologyServiceFactory = getTerminologyServiceFactory(
         c);
     return create(sparkSession, encoderBuilder.getOrCreate(), terminologyServiceFactory);
   }
@@ -179,9 +186,7 @@ protected Stream<IBaseResource> processResources(
    */
   @Nonnull
   public <T extends IBaseResource> Dataset<T> encode(@Nonnull final Dataset<String> stringResources,
-      @Nonnull final Class<T> resourceClass,
-      @Nonnull final String inputMimeType
-  ) {
+      @Nonnull final Class<T> resourceClass, @Nonnull final String inputMimeType) {
     return stringResources.mapPartitions(
         new EncodeResourceMapPartitionsFunc<>(fhirVersion, inputMimeType, resourceClass),
         fhirEncoders.of(resourceClass));
@@ -201,13 +206,11 @@ public <T extends IBaseResource> Dataset<T> encode(@Nonnull final Dataset<String
    */
   @Nonnull
   public Dataset<Row> encode(@Nonnull final Dataset<Row> stringResourcesDF,
-      @Nonnull final String resourceName,
-      @Nonnull final String inputMimeType,
+      @Nonnull final String resourceName, @Nonnull final String inputMimeType,
       @Nullable final String maybeColumnName) {
 
     final Dataset<String> stringResources = (nonNull(maybeColumnName)
-                                             ?
-                                             stringResourcesDF.select(maybeColumnName)
+                                             ? stringResourcesDF.select(maybeColumnName)
                                              : stringResourcesDF).as(Encoders.STRING());
 
     final RuntimeResourceDefinition definition = FhirEncoders.contextFor(fhirVersion)
@@ -227,8 +230,7 @@ public Dataset<Row> encode(@Nonnull final Dataset<Row> stringResourcesDF,
    */
   @Nonnull
   public Dataset<Row> encode(@Nonnull final Dataset<Row> stringResourcesDF,
-      @Nonnull final String resourceName,
-      @Nonnull final String inputMimeType) {
+      @Nonnull final String resourceName, @Nonnull final String inputMimeType) {
 
     return encode(stringResourcesDF, resourceName, inputMimeType, null);
   }
@@ -283,8 +285,7 @@ protected Stream<IBaseResource> processResources(
    */
   @Nonnull
   public <T extends IBaseResource> Dataset<T> encodeBundle(
-      @Nonnull final Dataset<String> stringBundles,
-      @Nonnull final Class<T> resourceClass,
+      @Nonnull final Dataset<String> stringBundles, @Nonnull final Class<T> resourceClass,
       @Nonnull final String inputMimeType) {
     return stringBundles.mapPartitions(
         new EncodeBundleMapPartitionsFunc<>(fhirVersion, inputMimeType, resourceClass),
@@ -304,13 +305,11 @@ public <T extends IBaseResource> Dataset<T> encodeBundle(
    */
   @Nonnull
   public Dataset<Row> encodeBundle(@Nonnull final Dataset<Row> stringBundlesDF,
-      @Nonnull final String resourceName,
-      @Nonnull final String inputMimeType,
+      @Nonnull final String resourceName, @Nonnull final String inputMimeType,
       @Nullable final String maybeColumnName) {
 
     final Dataset<String> stringResources = (nonNull(maybeColumnName)
-                                             ?
-                                             stringBundlesDF.select(maybeColumnName)
+                                             ? stringBundlesDF.select(maybeColumnName)
                                              : stringBundlesDF).as(Encoders.STRING());
 
     final RuntimeResourceDefinition definition = FhirEncoders.contextFor(fhirVersion)
@@ -330,8 +329,7 @@ public Dataset<Row> encodeBundle(@Nonnull final Dataset<Row> stringBundlesDF,
    */
   @Nonnull
   public Dataset<Row> encodeBundle(@Nonnull final Dataset<Row> stringBundlesDF,
-      @Nonnull final String resourceName,
-      @Nonnull final String inputMimeType) {
+      @Nonnull final String resourceName, @Nonnull final String inputMimeType) {
     return encodeBundle(stringBundlesDF, resourceName, inputMimeType, null);
   }
 
@@ -352,15 +350,12 @@ public Dataset<Row> encodeBundle(@Nonnull final Dataset<Row> stringBundlesDF,
   }
 
   @Nonnull
-  public Dataset<Row> memberOf(@Nonnull final Dataset<Row> dataset,
-      @Nonnull final Column coding, @Nonnull final String valueSetUri,
-      @Nonnull final String outputColumnName) {
+  public Dataset<Row> memberOf(@Nonnull final Dataset<Row> dataset, @Nonnull final Column coding,
+      @Nonnull final String valueSetUri, @Nonnull final String outputColumnName) {
 
-    final Column codingArrayCol = when(coding.isNotNull(), array(coding))
-        .otherwise(lit(null));
+    final Column codingArrayCol = when(coding.isNotNull(), array(coding)).otherwise(lit(null));
 
-    return TerminologyFunctions.memberOf(codingArrayCol, valueSetUri, dataset, outputColumnName,
-        terminologyServiceFactory, getRequestId());
+    return terminologyFunctions.memberOf(codingArrayCol, valueSetUri, dataset, outputColumnName);
   }
 
   @Nonnull
@@ -369,14 +364,12 @@ public Dataset<Row> translate(@Nonnull final Dataset<Row> dataset,
       final boolean reverse, @Nonnull final String equivalence, @Nullable final String target,
       @Nonnull final String outputColumnName) {
 
-    final Column codingArrayCol = when(coding.isNotNull(), array(coding))
-        .otherwise(lit(null));
+    final Column codingArrayCol = when(coding.isNotNull(), array(coding)).otherwise(lit(null));
 
-    final Dataset<Row> translatedDataset = TerminologyFunctions.translate(codingArrayCol,
-        conceptMapUri, reverse, equivalence, target, dataset, outputColumnName,
-        terminologyServiceFactory, getRequestId());
+    final Dataset<Row> translatedDataset = terminologyFunctions.translate(codingArrayCol,
+        conceptMapUri, reverse, equivalence, target, dataset, outputColumnName);
 
-    return translatedDataset.withColumn(outputColumnName, functions.col(outputColumnName).apply(0));
+    return translatedDataset.withColumn(outputColumnName, functions.col(outputColumnName));
   }
 
   @Nonnull
@@ -391,19 +384,16 @@ public Dataset<Row> subsumes(@Nonnull final Dataset<Row> dataset,
 
     final Dataset<Row> idAndCodingSet = dataset.withColumn(COL_INPUT_CODINGS, fromCodings)
         .withColumn(COL_ARG_CODINGS, toCodings);
-    final Column codingPairCol = struct(idAndCodingSet.col(COL_INPUT_CODINGS),
-        idAndCodingSet.col(COL_ARG_CODINGS));
-
-    return TerminologyFunctions.subsumes(idAndCodingSet, codingPairCol, outputColumnName, false,
-        terminologyServiceFactory, getRequestId());
+    return terminologyFunctions.subsumes(idAndCodingSet, idAndCodingSet.col(COL_INPUT_CODINGS),
+        idAndCodingSet.col(COL_ARG_CODINGS), outputColumnName, false);
   }
 
   @Nonnull
   private static Builder getEncoderBuilder(@Nonnull final PathlingContextConfiguration c) {
-    Builder encoderBuilder =
-        nonNull(c.getFhirVersion())
-        ? FhirEncoders.forVersion(FhirVersionEnum.forVersionString(c.getFhirVersion()))
-        : FhirEncoders.forR4();
+    Builder encoderBuilder = nonNull(c.getFhirVersion())
+                             ? FhirEncoders.forVersion(
+        FhirVersionEnum.forVersionString(c.getFhirVersion()))
+                             : FhirEncoders.forR4();
     if (nonNull(c.getMaxNestingLevel())) {
       encoderBuilder = encoderBuilder.withMaxNestingLevel(c.getMaxNestingLevel());
     }
@@ -419,41 +409,26 @@ private static Builder getEncoderBuilder(@Nonnull final PathlingContextConfigura
   }
 
   @Nonnull
-  private static DefaultTerminologyServiceFactory getTerminologyServiceFactory(
+  private static TerminologyServiceFactory getTerminologyServiceFactory(
       @Nonnull final PathlingContextConfiguration c) {
-    final String resolvedTerminologyServerUrl = nonNull(c.getTerminologyServerUrl())
-                                                ? c.getTerminologyServerUrl()
-                                                : DEFAULT_TERMINOLOGY_SERVER_URL;
-
-    final TerminologyAuthConfiguration authConfig = new TerminologyAuthConfiguration();
-    if (nonNull(c.getTokenEndpoint()) && nonNull(c.getClientId())
-        && nonNull(c.getClientSecret())) {
-      authConfig.setEnabled(true);
-      authConfig.setTokenEndpoint(c.getTokenEndpoint());
-      authConfig.setClientId(c.getClientId());
-      authConfig.setClientSecret(c.getClientSecret());
-      authConfig.setScope(c.getScope());
-    }
-    authConfig.setTokenExpiryTolerance(c.getTokenExpiryTolerance() != null
-                                       ? c.getTokenExpiryTolerance()
-                                       : DEFAULT_TOKEN_EXPIRY_TOLERANCE);
-
-    final int terminologySocketTimeout = c.getTerminologySocketTimeout() != null
-                                         ? c.getTerminologySocketTimeout()
-                                         : DEFAULT_TERMINOLOGY_SOCKET_TIMEOUT;
 
-    final boolean verboseRequestLogging = c.getTerminologyVerboseRequestLogging() != null
-                                          ? c.getTerminologyVerboseRequestLogging()
-                                          : false;
+    if (!c.isMockTerminology()) {
+      final String resolvedTerminologyServerUrl = DEFAULT_TERMINOLOGY_SERVER_URL.resolve(
+          c.getTerminologyServerUrl());
+      final boolean verboseRequestLogging = DEFAULT_TERMINOLOGY_VERBOSE_LOGGING.resolve(
+          c.getTerminologyVerboseRequestLogging());
 
-    return new DefaultTerminologyServiceFactory(FhirContext.forR4(), resolvedTerminologyServerUrl,
-        terminologySocketTimeout, verboseRequestLogging, authConfig);
-  }
+      final TerminologyAuthConfiguration authConfig = c.toAuthConfig();
+      final HttpClientConf clientConfig = c.toClientConfig();
+      final HttpCacheConf cacheConfig = c.toCacheConfig();
 
-  private static String getRequestId() {
-    final String requestId = UUID.randomUUID().toString();
-    MDC.put("requestId", requestId);
-    return requestId;
+      return new DefaultTerminologyServiceFactory(FhirContext.forR4().getVersion().getVersion(),
+          resolvedTerminologyServerUrl, verboseRequestLogging, clientConfig, cacheConfig,
+          authConfig);
+    } else {
+      log.warn("Using mock terminology service. NOT connecting to server: {}",
+          c.getTerminologyServerUrl());
+      return new MockTerminologyServiceFactory();
+    }
   }
-
 }
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 27646733a1..0ab51eb9a8 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
@@ -18,14 +18,43 @@
 package au.csiro.pathling.library;
 
 import java.util.List;
+import java.util.Map;
+import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
+import au.csiro.pathling.config.HttpCacheConf;
+import au.csiro.pathling.config.HttpClientConf;
+import au.csiro.pathling.config.TerminologyAuthConfiguration;
+import au.csiro.pathling.utilities.Default;
 import lombok.Builder;
 import lombok.Value;
 
+import static java.util.Objects.nonNull;
+
 @Builder
 @Value
 public class PathlingContextConfiguration {
 
+  public static final Default<String> DEFAULT_TERMINOLOGY_SERVER_URL = Default.of(
+      "https://tx.ontoserver.csiro.au/fhir");
+
+  public static final Default<Long> DEFAULT_TOKEN_EXPIRY_TOLERANCE = Default.of(
+      TerminologyAuthConfiguration.DEF_TOKEN_EXPIRY_TOLERANCE);
+  public static final Default<Boolean> DEFAULT_TERMINOLOGY_VERBOSE_LOGGING = Default.of(false);
+
+  public static final Default<Integer> DEFAULT_MAX_TOTAL_CONNECTIONS = Default.of(
+      HttpClientConf.DEF_MAX_CONNECTIONS_TOTAL);
+  public static final Default<Integer> DEFAULT_MAX_CONNECTIONS_PER_ROUTE = Default.of(
+      HttpClientConf.DEF_MAX_CONNECTIONS_PER_ROUTE);
+  public static final Default<Integer> DEFAULT_SOCKET_TIMEOUT = Default.of(
+      HttpClientConf.DEF_SOCKET_TIMEOUT);
+
+  public static final Default<String> DEFAULT_CACHE_STORAGE_TYPE = Default.of(
+      HttpCacheConf.DEF_STORAGE_TYPE);
+  public static final Default<Integer> DEFAULT_CACHE_MAX_ENTRIES = Default.of(
+      HttpCacheConf.DEF_MAX_CACHE_ENTRIES);
+  public static final Default<Long> DEFAULT_CACHE_MAX_OBJECT_SIZE = Default.of(
+      HttpCacheConf.DEF_MAX_OBJECT_SIZE);
+
   @Nullable
   String fhirVersion;
 
@@ -47,6 +76,25 @@ public class PathlingContextConfiguration {
   @Nullable
   Boolean terminologyVerboseRequestLogging;
 
+  @Nullable
+  Integer maxConnectionsTotal;
+
+  @Nullable
+  Integer maxConnectionsPerRoute;
+
+  @Nullable
+  Integer cacheMaxEntries;
+
+  @Nullable
+  Long cacheMaxObjectSize;
+
+  @Nullable
+  @Builder.Default
+  String cacheStorageType = HttpCacheConf.DEF_STORAGE_TYPE;
+
+  @Nullable
+  Map<String, String> cacheStorageProperties;
+
   @Nullable
   String tokenEndpoint;
 
@@ -60,6 +108,47 @@ public class PathlingContextConfiguration {
   String scope;
 
   @Nullable
-  Integer tokenExpiryTolerance;
-
+  Long tokenExpiryTolerance;
+  
+  @Builder.Default
+  boolean mockTerminology = false;
+
+  @Nonnull
+  TerminologyAuthConfiguration toAuthConfig() {
+
+    final TerminologyAuthConfiguration authConfig = TerminologyAuthConfiguration.defaults();
+    if (nonNull(getTokenEndpoint()) && nonNull(getClientId()) && nonNull(getClientSecret())) {
+      authConfig.setEnabled(true);
+      authConfig.setTokenEndpoint(getTokenEndpoint());
+      authConfig.setClientId(getClientId());
+      authConfig.setClientSecret(getClientSecret());
+      authConfig.setScope(getScope());
+    }
+    authConfig.setTokenExpiryTolerance(
+        DEFAULT_TOKEN_EXPIRY_TOLERANCE.resolve(getTokenExpiryTolerance()));
+    return authConfig;
+  }
+
+
+  @Nonnull
+  HttpClientConf toClientConfig() {
+    final HttpClientConf c = HttpClientConf.defaults();
+    c.setMaxConnectionsTotal(
+        DEFAULT_MAX_TOTAL_CONNECTIONS.resolve(getMaxConnectionsTotal()));
+    c.setMaxConnectionsPerRoute(
+        DEFAULT_MAX_CONNECTIONS_PER_ROUTE.resolve(getMaxConnectionsPerRoute()));
+    c.setSocketTimeout(DEFAULT_SOCKET_TIMEOUT.resolve(getTerminologySocketTimeout()));
+    return c;
+  }
+
+  @Nonnull
+  HttpCacheConf toCacheConfig() {
+    final HttpCacheConf c = HttpCacheConf.defaults();
+    c.setEnabled(nonNull(getCacheStorageType()));
+    c.setStorageType(DEFAULT_CACHE_STORAGE_TYPE.resolve(getCacheStorageType()));
+    c.setMaxCacheEntries(DEFAULT_CACHE_MAX_ENTRIES.resolve(getCacheMaxEntries()));
+    c.setMaxObjectSize(DEFAULT_CACHE_MAX_OBJECT_SIZE.resolve(getCacheMaxObjectSize()));
+    c.setStorage(getCacheStorageProperties());
+    return c;
+  }
 }
diff --git a/site/docs/libraries/terminology.md b/site/docs/libraries/terminology.md
index 3b776337f8..789004eb4a 100644
--- a/site/docs/libraries/terminology.md
+++ b/site/docs/libraries/terminology.md
@@ -7,26 +7,21 @@ sidebar_position: 3
 The library also provides a set of functions for querying a FHIR terminology
 server from within your queries and transformations.
 
-import Tabs from "@theme/Tabs";
-import TabItem from "@theme/TabItem";
-import {
-JavaInstallation,
-PythonInstallation,
-ScalaInstallation
-} from "../../src/components/installation";
+import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; import {
+JavaInstallation, PythonInstallation, ScalaInstallation } from "../../src/components/installation";
 
 ### Value set membership
 
 The `member_of` function can be used to test the membership of a code within a
-[FHIR value set](https://hl7.org/fhir/valueset.html). This can be used with both 
-explicit value sets (i.e. those that have been pre-defined and loaded into the 
-terminology server) and implicit value sets (e.g. SNOMED CT 
+[FHIR value set](https://hl7.org/fhir/valueset.html). This can be used with both
+explicit value sets (i.e. those that have been pre-defined and loaded into the
+terminology server) and implicit value sets (e.g. SNOMED CT
 [Expression Constraint Language](http://snomed.org/ecl)).
 
-In this example, we take a list of SNOMED CT diagnosis codes and
-create a new column which shows which are viral infections. We use an ECL
-expression to define viral infection as a disease with a pathological process 
-of "Infectious process", and a causative agent of "Virus".
+In this example, we take a list of SNOMED CT diagnosis codes and create a new
+column which shows which are viral infections. We use an ECL expression to
+define viral infection as a disease with a pathological process of "Infectious
+process", and a causative agent of "Virus".
 
 <!--suppress CheckEmptyScriptTag -->
 <Tabs>
@@ -64,7 +59,8 @@ val pc = PathlingContext.create()
 val csv = spark.read.csv("conditions.csv")
 
 val result = pc.memberOf(csv, toCoding(csv.col("CODE"), "http://snomed.info/sct"),
-    toEclValueSet("""
+    toEclValueSet(
+        """
         << 64572001|Disease| : (
           << 370135005|Pathological process| = << 441862004|Infectious process|,
           << 246075003|Causative agent| = << 49872002|Virus|
@@ -80,6 +76,7 @@ result.select("CODE", "DESCRIPTION", "VIRAL_INFECTION").show()
 
 ```java
 import static au.csiro.pathling.library.TerminologyHelpers.*;
+
 import au.csiro.pathling.library.PathlingContext;
 import org.apache.spark.sql.Dataset;
 import org.apache.spark.sql.Row;
@@ -90,10 +87,10 @@ class MyApp {
         Dataset<Row> csv = pc.getSpark().read().csv("conditions.csv");
 
         Dataset<Row> result = pc.memberOf(csv, toCoding(csv.col("code"), SNOMED_URI),
-            toEclValueSet("<< 64572001|Disease| : ("
-                + "<< 370135005|Pathological process| = << 441862004|Infectious process|,"
-                + "<< 246075003|Causative agent| = << 49872002|Virus|"
-                + ")"), "VIRAL_INFECTION");
+                toEclValueSet("<< 64572001|Disease| : ("
+                        + "<< 370135005|Pathological process| = << 441862004|Infectious process|,"
+                        + "<< 246075003|Causative agent| = << 49872002|Virus|"
+                        + ")"), "VIRAL_INFECTION");
         result.select("CODE", "DESCRIPTION", "VIRAL_INFECTION").show();
     }
 }
@@ -117,7 +114,11 @@ Results in:
 
 The `translate` function can be used to translate codes from one code system to
 another using maps that are known to the terminology server. In this example, we
-translate our SNOMED CT diagnosis codes into Read CTV3.
+translate our SNOMED CT diagnosis codes into Read CTV3. 
+
+Please note that the
+type of the output column is the array of coding structs, as the translation may
+produce multiple results for each input coding.
 
 <!--suppress CheckEmptyScriptTag -->
 <Tabs>
@@ -153,7 +154,7 @@ val pc = PathlingContext.create()
 val csv = spark.read.csv("conditions.csv")
 
 val result = pc.translate(csv, toCoding(csv.col("CODE"), SNOMED_URI),
-    "http://snomed.info/sct/900000000000207008?fhir_cm=900000000000497000", 
+    "http://snomed.info/sct/900000000000207008?fhir_cm=900000000000497000",
     false, "equivalent", "READ_CODE")
 result.select("CODE", "DESCRIPTION", "READ_CODE").show()
 ```
@@ -165,6 +166,7 @@ result.select("CODE", "DESCRIPTION", "READ_CODE").show()
 
 ```java
 import static au.csiro.pathling.library.TerminologyHelpers.*;
+
 import au.csiro.pathling.library.PathlingContext;
 import org.apache.spark.sql.Dataset;
 import org.apache.spark.sql.Row;
@@ -175,8 +177,8 @@ class MyApp {
         Dataset<Row> csv = pc.getSpark().read().csv("conditions.csv");
 
         Dataset<Row> result = pc.translate(csv, toCoding(csv.col("CODE"), SNOMED_URI),
-            "http://snomed.info/sct/900000000000207008?fhir_cm=900000000000497000", 
-            false, "equivalent", "READ_CODE");
+                "http://snomed.info/sct/900000000000207008?fhir_cm=900000000000497000",
+                false, "equivalent", "READ_CODE");
         result.select("CODE", "DESCRIPTION", "READ_CODE").show();
     }
 }
@@ -190,24 +192,24 @@ Results in:
 
 | CODE      | DESCRIPTION               | READ_CODE |
 |-----------|---------------------------|-----------|
-| 65363002  | Otitis media              | X00ik     |
-| 16114001  | Fracture of ankle         | S34..     |
-| 444814009 | Viral sinusitis           | XUjp0     |
-| 444814009 | Viral sinusitis           | XUjp0     |
-| 43878008  | Streptococcal sore throat | A340.     |
+| 65363002  | Otitis media              | \[X00ik\] |
+| 16114001  | Fracture of ankle         | \[S34..\] |
+| 444814009 | Viral sinusitis           | \[XUjp0\] |
+| 444814009 | Viral sinusitis           | \[XUjp0\] |
+| 43878008  | Streptococcal sore throat | \[A340.\] |
 
 ### Subsumption testing
 
 Subsumption test is a fancy way of saying "is this code equal or a subtype of
 this other code".
 
-For example, a code representing "ankle fracture" is subsumed 
-by another code representing "fracture". The "fracture" code is more general, 
-and using it with subsumption can help us find other codes representing
-different subtypes of fracture.
+For example, a code representing "ankle fracture" is subsumed by another code
+representing "fracture". The "fracture" code is more general, and using it with
+subsumption can help us find other codes representing different subtypes of
+fracture.
 
 The `subsumes` function allows us to perform subsumption testing on codes within
-our data. The order of the left and right operands can be reversed to query 
+our data. The order of the left and right operands can be reversed to query
 whether a code is "subsumed by" another code.
 
 <!--suppress CheckEmptyScriptTag -->
@@ -268,6 +270,7 @@ result.select("CODE", "DESCRIPTION", "IS_ENT").show()
 
 ```java
 import static au.csiro.pathling.library.TerminologyHelpers.*;
+
 import au.csiro.pathling.library.PathlingContext;
 import org.apache.spark.sql.Dataset;
 import org.apache.spark.sql.Row;
@@ -278,15 +281,15 @@ class MyApp {
         Dataset<Row> csv = pc.getSpark().read().csv("conditions.csv");
 
         Dataset<Row> result = pc.subsumes(csv,
-            // 232208008 |Ear, nose and throat disorder|
-            CodingEncoding.toStruct(
-                lit(null),
-                lit(SNOMED_URI),
-                lit(null),
-                lit("232208008"),
-                lit(null),
-                lit(null)
-            ), toCoding(csv.col("CODE"), SNOMED_URI), "IS_ENT");
+                // 232208008 |Ear, nose and throat disorder|
+                CodingEncoding.toStruct(
+                        lit(null),
+                        lit(SNOMED_URI),
+                        lit(null),
+                        lit("232208008"),
+                        lit(null),
+                        lit(null)
+                ), toCoding(csv.col("CODE"), SNOMED_URI), "IS_ENT");
         result.select("CODE", "DESCRIPTION", "IS_ENT").show();
     }
 }
diff --git a/terminology/pom.xml b/terminology/pom.xml
index 35dc55cffe..c140600fae 100644
--- a/terminology/pom.xml
+++ b/terminology/pom.xml
@@ -42,7 +42,6 @@
       <groupId>au.csiro.pathling</groupId>
       <artifactId>utilities</artifactId>
     </dependency>
-
     <dependency>
       <groupId>org.apache.spark</groupId>
       <artifactId>spark-sql_${pathling.sparkScalaVersion}</artifactId>
@@ -69,7 +68,14 @@
       <groupId>org.hibernate.validator</groupId>
       <artifactId>hibernate-validator</artifactId>
     </dependency>
-
+    <dependency>
+      <groupId>org.apache.httpcomponents</groupId>
+      <artifactId>httpclient-cache</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.ehcache</groupId>
+      <artifactId>ehcache</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.projectlombok</groupId>
       <artifactId>lombok</artifactId>
diff --git a/terminology/src/main/java/au/csiro/pathling/caching/AbstractCachingFactoryBase.java b/terminology/src/main/java/au/csiro/pathling/caching/AbstractCachingFactoryBase.java
new file mode 100644
index 0000000000..b5872a1aa9
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/caching/AbstractCachingFactoryBase.java
@@ -0,0 +1,85 @@
+/*
+ * 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 java.util.Objects.nonNull;
+import static java.util.Objects.requireNonNull;
+import static java.util.Objects.requireNonNullElseGet;
+
+import java.util.Collections;
+import java.util.Map;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import au.csiro.pathling.errors.InvalidConfigError;
+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 AbstractCachingFactoryBase implements CachingFactory {
+
+  protected static class Config {
+
+    @Nonnull
+    private final Map<String, String> properties;
+
+    public Config(@Nullable final Map<String, String> properties) {
+      this.properties = nonNull(properties)
+                        ? requireNonNull(properties)
+                        : Collections.emptyMap();
+    }
+
+    @Nonnull
+    public String required(@Nonnull final String key) {
+      return requireNonNullElseGet(properties.get(key), () -> {
+        throw new InvalidConfigError("Required config property missing: " + key);
+      });
+    }
+
+    @Nonnull
+    public String optional(@Nonnull final String key, @Nonnull final String defValue) {
+      return properties.getOrDefault(key, defValue);
+    }
+  }
+
+
+  @Nonnull
+  @Override
+  public CachingHttpClientBuilder create(@Nonnull final CacheConfig cacheConfig,
+      @Nullable final Map<String, String> storageProperties) {
+    final CachingHttpClientBuilder builder = CachingHttpClients.custom()
+        .setCacheConfig(cacheConfig);
+    return configure(builder, new Config(storageProperties), cacheConfig);
+  }
+
+
+  /**
+   * Configures the specific caching mechanism in the {@link CachingHttpClientBuilder}.
+   *
+   * @param builder the builder to configure.
+   * @param config the configuration for the caching mechanism.
+   * @return the {@link CachingHttpClientBuilder} with configured caching.
+   */
+  @Nonnull
+  protected abstract CachingHttpClientBuilder configure(
+      @Nonnull final CachingHttpClientBuilder builder,
+      @Nonnull final Config config, @Nonnull final CacheConfig cacheConfig);
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/caching/CachingFactories.java b/terminology/src/main/java/au/csiro/pathling/caching/CachingFactories.java
new file mode 100644
index 0000000000..1967416f6d
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/caching/CachingFactories.java
@@ -0,0 +1,77 @@
+/*
+ * 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.File;
+import javax.annotation.Nonnull;
+import au.csiro.pathling.errors.InvalidConfigError;
+import org.apache.http.impl.client.cache.CacheConfig;
+import org.apache.http.impl.client.cache.CachingHttpClientBuilder;
+
+/**
+ * The implementation of standarty types of caching mechanisms.
+ */
+public final class CachingFactories {
+
+  public static final String MEMORY_STORAGE = "memory";
+  public static final String DISK_STORAGE = "disk";
+
+  public static final String DISK_CACHE_DIR = "cacheDir";
+
+  private static class MemoryCachingFactory extends AbstractCachingFactoryBase {
+
+    @Nonnull
+    @Override
+    protected CachingHttpClientBuilder configure(@Nonnull final CachingHttpClientBuilder builder,
+        @Nonnull final Config config, @Nonnull final CacheConfig cacheConfig) {
+      return builder;
+    }
+  }
+
+  private static class DiskCachingFactory extends AbstractCachingFactoryBase {
+
+    @Nonnull
+    @Override
+    protected CachingHttpClientBuilder configure(@Nonnull final CachingHttpClientBuilder builder,
+        @Nonnull final Config config, @Nonnull final CacheConfig cacheConfig) {
+      return builder.setCacheDir(new File(config.required(DISK_CACHE_DIR)));
+    }
+  }
+
+  private CachingFactories() {
+    // utility class
+  }
+
+  /**
+   * Creates a {@link CachingFactory} for specified storage type.
+   *
+   * @param storageType the type of caching storage.
+   * @return the {@link CachingFactory} for specified storage type.
+   */
+  @Nonnull
+  public static CachingFactory of(@Nonnull final String storageType) {
+    if (MEMORY_STORAGE.equals(storageType)) {
+      return new MemoryCachingFactory();
+    } else if (DISK_STORAGE.equals(storageType)) {
+      return new DiskCachingFactory();
+    } else {
+      throw new InvalidConfigError("Cannot configure cache with storageType: " + storageType);
+    }
+  }
+
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/caching/CachingFactory.java b/terminology/src/main/java/au/csiro/pathling/caching/CachingFactory.java
new file mode 100644
index 0000000000..7fa7696ad4
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/caching/CachingFactory.java
@@ -0,0 +1,44 @@
+/*
+ * 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 org.apache.http.impl.client.cache.CacheConfig;
+import org.apache.http.impl.client.cache.CachingHttpClientBuilder;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.Map;
+
+/**
+ * A factory class for creating {@link CachingHttpClientBuilder} instance preconfigured for with
+ * some caching implementation.
+ */
+public interface CachingFactory {
+
+  /**
+   * Creates a {@link CachingHttpClientBuilder} pre-configures with some concrete caching
+   * implementation.
+   *
+   * @param cacheConfig the cache config to use.
+   * @param storageProperties the additional properties to configure the specific caching
+   * implementation.
+   * @return the pre-configured {@link CachingHttpClientBuilder}.
+   */
+  @Nonnull
+  CachingHttpClientBuilder create(@Nonnull CacheConfig cacheConfig,
+      @Nullable Map<String, String> storageProperties);
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/config/HttpCacheConf.java b/terminology/src/main/java/au/csiro/pathling/config/HttpCacheConf.java
new file mode 100644
index 0000000000..2f764e0eca
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/config/HttpCacheConf.java
@@ -0,0 +1,85 @@
+/*
+ * 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.config;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.apache.http.impl.client.cache.CacheConfig;
+import javax.annotation.Nullable;
+import javax.validation.constraints.Min;
+import javax.validation.constraints.NotNull;
+import java.io.Serializable;
+import java.util.Map;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class HttpCacheConf implements Serializable {
+
+  private static final long serialVersionUID = -3030386957343963899L;
+
+  public static final boolean DEF_ENABLED = true;
+  public static final int DEF_MAX_CACHE_ENTRIES = 10_000;
+  public static final long DEF_MAX_OBJECT_SIZE = 64_000L;
+  public static final String DEF_STORAGE_TYPE = "memory";
+
+  /**
+   * Enables client side caching of REST requests.
+   */
+  @NotNull
+  @Builder.Default
+  private boolean enabled = DEF_ENABLED;
+
+  /**
+   * Sets the maximum number of cache entries the cache will retain. See also: {@link
+   * CacheConfig.Builder#setMaxCacheEntries(int)} ()}
+   */
+  @NotNull
+  @Min(0)
+  @Builder.Default
+  private int maxCacheEntries = DEF_MAX_CACHE_ENTRIES;
+
+  /**
+   * Sets the maximum number of cache entries the cache will retain. See also: {@link
+   * CacheConfig.Builder#setMaxObjectSize(long)} )}
+   */
+  @Min(0)
+  @NotNull
+  @Builder.Default
+  private long maxObjectSize = DEF_MAX_OBJECT_SIZE;
+
+  @NotNull
+  @Builder.Default
+  private String storageType = DEF_STORAGE_TYPE;
+
+  @Nullable
+  private Map<String, String> storage;
+
+  public static HttpCacheConf defaults() {
+    return HttpCacheConf.builder().build();
+  }
+
+  public static HttpCacheConf disabled() {
+    return HttpCacheConf.builder().enabled(false).build();
+  }
+
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/config/HttpClientConf.java b/terminology/src/main/java/au/csiro/pathling/config/HttpClientConf.java
new file mode 100644
index 0000000000..e897981195
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/config/HttpClientConf.java
@@ -0,0 +1,68 @@
+/*
+ * 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.config;
+
+import java.io.Serializable;
+import javax.validation.constraints.Min;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder(toBuilder = true)
+@NoArgsConstructor
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class HttpClientConf implements Serializable {
+
+  private static final long serialVersionUID = -1624276800166930462L;
+
+  public static final int DEF_MAX_CONNECTIONS_TOTAL = 32;
+  public static final int DEF_MAX_CONNECTIONS_PER_ROUTE = 16;
+  public static final int DEF_SOCKET_TIMEOUT = 60_000;
+
+  /**
+   * The maximum total number of connections for the client. Also see: {@link
+   * org.apache.http.impl.client.HttpClientBuilder#setMaxConnTotal(int)}
+   */
+  @Min(0)
+  @Builder.Default
+  private int maxConnectionsTotal = DEF_MAX_CONNECTIONS_TOTAL;
+
+  /**
+   * The maximum number of connections per route for the client. Also see: {@link
+   * org.apache.http.impl.client.HttpClientBuilder#setMaxConnPerRoute(int)}
+   */
+  @Min(0)
+  @Builder.Default
+  private int maxConnectionsPerRoute = DEF_MAX_CONNECTIONS_PER_ROUTE;
+
+  /**
+   * The maximum period (in milliseconds) that the server should wait for incoming data from the
+   * HTTP service.
+   */
+  @Min(0)
+  @Builder.Default
+  private int socketTimeout = DEF_SOCKET_TIMEOUT;
+
+  public static HttpClientConf defaults() {
+    return HttpClientConf.builder().build();
+  }
+
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/config/TerminologyAuthConfiguration.java b/terminology/src/main/java/au/csiro/pathling/config/TerminologyAuthConfiguration.java
index cdee5220fa..73a57e0d98 100644
--- a/terminology/src/main/java/au/csiro/pathling/config/TerminologyAuthConfiguration.java
+++ b/terminology/src/main/java/au/csiro/pathling/config/TerminologyAuthConfiguration.java
@@ -29,17 +29,27 @@
 import javax.validation.ConstraintValidator;
 import javax.validation.ConstraintValidatorContext;
 import javax.validation.Payload;
+import javax.validation.constraints.Min;
 import javax.validation.constraints.NotNull;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
 import lombok.Data;
+import lombok.NoArgsConstructor;
 import lombok.ToString;
 import org.hibernate.validator.constraints.URL;
 
 @Data
+@Builder(toBuilder = true)
+@NoArgsConstructor
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
 @ValidTerminologyAuthConfiguration
 public class TerminologyAuthConfiguration implements Serializable {
 
   private static final long serialVersionUID = 6321330066417583745L;
 
+  public static final long DEF_TOKEN_EXPIRY_TOLERANCE = 120;
+
   /**
    * Enables authentication of requests to the terminology server.
    */
@@ -77,13 +87,15 @@ public class TerminologyAuthConfiguration implements Serializable {
    * send it with a terminology request.
    */
   @NotNull
-  private long tokenExpiryTolerance;
+  @Min(0)
+  @Builder.Default
+  private long tokenExpiryTolerance = DEF_TOKEN_EXPIRY_TOLERANCE;
 
   @Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
   @Retention(RetentionPolicy.RUNTIME)
   @Constraint(validatedBy = TerminologyAuthConfigValidator.class)
   @Documented
-  public static @interface ValidTerminologyAuthConfiguration {
+  public @interface ValidTerminologyAuthConfiguration {
 
     String message() default "If terminology authentication is enabled, token endpoint, "
         + "client ID and client secret must be supplied.";
@@ -113,4 +125,7 @@ public boolean isValid(final TerminologyAuthConfiguration value,
 
   }
 
+  public static TerminologyAuthConfiguration defaults() {
+    return TerminologyAuthConfiguration.builder().build();
+  }
 }
diff --git a/terminology/src/main/java/au/csiro/pathling/fhir/DefaultTerminologyServiceFactory.java b/terminology/src/main/java/au/csiro/pathling/fhir/DefaultTerminologyServiceFactory.java
deleted file mode 100644
index cb222b89ce..0000000000
--- a/terminology/src/main/java/au/csiro/pathling/fhir/DefaultTerminologyServiceFactory.java
+++ /dev/null
@@ -1,103 +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.fhir;
-
-import au.csiro.pathling.config.TerminologyAuthConfiguration;
-import au.csiro.pathling.encoders.FhirEncoders;
-import au.csiro.pathling.terminology.DefaultTerminologyService;
-import au.csiro.pathling.terminology.TerminologyService;
-import au.csiro.pathling.terminology.UUIDFactory;
-import ca.uhn.fhir.context.FhirContext;
-import ca.uhn.fhir.context.FhirVersionEnum;
-import java.util.UUID;
-import javax.annotation.Nonnull;
-import lombok.Getter;
-import org.slf4j.Logger;
-
-/**
- * Default implementation of {@link TerminologyServiceFactory} providing {@link TerminologyService}
- * implemented using {@link TerminologyClient} with given configuration.
- *
- * @author John Grimes
- * @author Piotr Szul
- */
-@Getter
-public class DefaultTerminologyServiceFactory implements TerminologyServiceFactory {
-
-  private static final long serialVersionUID = 8862251697418622614L;
-
-  @Nonnull
-  private final FhirVersionEnum fhirVersion;
-
-  @Nonnull
-  private final String terminologyServerUrl;
-
-  private final int socketTimeout;
-
-  private final boolean verboseRequestLogging;
-
-  @Nonnull
-  private final TerminologyAuthConfiguration authConfig;
-
-  /**
-   * @param fhirContext the {@link FhirContext} used to build the client
-   * @param terminologyServerUrl the URL of the terminology server this client will communicate
-   * with
-   * @param socketTimeout the number of milliseconds to wait for response data
-   * @param verboseRequestLogging whether to log out verbose details of each request
-   */
-  public DefaultTerminologyServiceFactory(@Nonnull final FhirContext fhirContext,
-      @Nonnull final String terminologyServerUrl, final int socketTimeout,
-      final boolean verboseRequestLogging, @Nonnull final TerminologyAuthConfiguration authConfig) {
-    this.fhirVersion = fhirContext.getVersion().getVersion();
-    this.terminologyServerUrl = terminologyServerUrl;
-    this.socketTimeout = socketTimeout;
-    this.verboseRequestLogging = verboseRequestLogging;
-    this.authConfig = authConfig;
-  }
-
-  /**
-   * Builds a new instance.
-   *
-   * @param logger a {@link Logger} to use for logging
-   * @return a shiny new TerminologyService instance
-   */
-  @Nonnull
-  @Override
-  public TerminologyService buildService(@Nonnull final Logger logger) {
-    return buildService(logger, UUID::randomUUID);
-  }
-
-  /**
-   * Builds a new instance.
-   *
-   * @param logger a {@link Logger} to use for logging
-   * @param uuidFactory the {@link UUIDFactory to use for UUID generation}
-   * @return a shiny new TerminologyService instance =
-   */
-  @Nonnull
-  public TerminologyService buildService(@Nonnull final Logger logger,
-      @Nonnull final UUIDFactory uuidFactory) {
-    final TerminologyClient terminologyClient = TerminologyClient
-        .build(FhirEncoders.contextFor(fhirVersion), terminologyServerUrl, socketTimeout,
-            verboseRequestLogging, authConfig, logger);
-    return new DefaultTerminologyService(FhirEncoders.contextFor(fhirVersion), terminologyClient,
-        uuidFactory);
-  }
-
-}
diff --git a/terminology/src/main/java/au/csiro/pathling/fhir/ParametersUtils.java b/terminology/src/main/java/au/csiro/pathling/fhir/ParametersUtils.java
new file mode 100644
index 0000000000..1560ad813d
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/fhir/ParametersUtils.java
@@ -0,0 +1,109 @@
+package au.csiro.pathling.fhir;
+
+import java.lang.reflect.InvocationTargetException;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.apache.commons.beanutils.BeanUtils;
+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.Parameters.ParametersParameterComponent;
+import org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome;
+
+/**
+ * Helper functions for dealing with FHIR {@link Parameters} resource.
+ */
+public final class ParametersUtils {
+
+  private ParametersUtils() {
+    // Utility class
+  }
+
+  private static void setProperty(@Nonnull final Object bean, @Nonnull final String name,
+      @Nullable final Object value) {
+    try {
+      BeanUtils.setProperty(bean, name, value);
+    } catch (final IllegalAccessException | InvocationTargetException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Converts ParametersParameterComponent object to a java bean class initializing its properties
+   * from the part properties.
+   *
+   * @param component the ParametersParameterComponent element.
+   * @param supplier the supplier for the bean class.
+   * @param <T> the type of the bean.
+   * @return the new java bean of type T initialize from the ParametersParameterComponent element.
+   */
+  @Nonnull
+  public static <T> T partsToBean(@Nonnull final ParametersParameterComponent component,
+      @Nonnull final Supplier<T> supplier) {
+    final T result = supplier.get();
+    component.getPart().forEach(p -> setProperty(result, p.getName(), p.getValue()));
+    return result;
+  }
+
+  /**
+   * Extracts the boolean value of the 'result' parameter.
+   *
+   * @param parameters the parameters to convert.
+   * @return the boolean value of the 'result' parameter.
+   */
+  public static boolean toBooleanResult(final @Nonnull Parameters parameters) {
+    return parameters.getParameterBool("result");
+  }
+
+  /**
+   * Extracts the {@link ConceptSubsumptionOutcome} value of the 'outcome' parameter.
+   *
+   * @param parameters the parameters to convert.
+   * @return the {@link ConceptSubsumptionOutcome} value.
+   */
+  @Nonnull
+  public static ConceptSubsumptionOutcome toSubsumptionOutcome(
+      final @Nonnull Parameters parameters) {
+    return ConceptSubsumptionOutcome.fromCode(
+        parameters.getParameter("outcome").primitiveValue());
+  }
+
+  /**
+   * Object representation of a 'match' part from 'translate()' result.
+   */
+  @Data
+  @NoArgsConstructor
+  public static class MatchPart {
+
+    @Nonnull
+    private Coding concept;
+
+    @Nonnull
+    private CodeType equivalence;
+  }
+
+  @Nonnull
+  private static MatchPart componentToMatchPart(
+      @Nonnull final Parameters.ParametersParameterComponent component) {
+    return ParametersUtils.partsToBean(component, MatchPart::new);
+  }
+
+  /**
+   * Extracts 'match' parts from the result of 'translate()'
+   *
+   * @param parameters the parameters to convert.
+   * @return the stream of 'match' parts.
+   */
+  @Nonnull
+  public static Stream<MatchPart> toMatchParts(final @Nonnull Parameters parameters) {
+    return toBooleanResult(parameters)
+           ? parameters.getParameter().stream()
+               .filter(pc -> "match".equals(pc.getName()))
+               .map(ParametersUtils::componentToMatchPart)
+           : Stream.empty();
+  }
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient.java b/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient.java
index b3d1bef67b..0b1b987175 100644
--- a/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient.java
+++ b/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient.java
@@ -37,9 +37,12 @@
 import java.util.Set;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
+import org.apache.http.client.HttpClient;
+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.cache.CacheConfig;
+import org.apache.http.impl.client.cache.CachingHttpClients;
 import org.hl7.fhir.r4.model.Bundle;
 import org.hl7.fhir.r4.model.CodeSystem;
 import org.hl7.fhir.r4.model.Coding;
@@ -48,6 +51,7 @@
 import org.hl7.fhir.r4.model.StringType;
 import org.hl7.fhir.r4.model.ValueSet;
 import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * A HAPI "annotation client" for communicating with a R4 FHIR terminology service.
@@ -56,8 +60,11 @@
  * @see <a href="https://hapifhir.io/hapi-fhir/docs/client/annotation_client.html">Annotation
  * Client</a>
  */
+@Deprecated
 public interface TerminologyClient extends IRestfulClient {
 
+  Logger log = LoggerFactory.getLogger(TerminologyClient.class);
+
   /**
    * Performs a CodeSystem search using a single URI.
    *
@@ -133,17 +140,35 @@ ConceptMap closure(@Nonnull @OperationParam(name = "name") StringType name,
    * with
    * @param socketTimeout the number of milliseconds to wait for response data
    * @param verboseRequestLogging whether to log out verbose details of each request
-   * @param logger a {@link Logger} to use for logging
+   * @param authConfig the configuration for terminology service authentication.
    * @return a shiny new TerminologyClient instance
    */
   @Nonnull
   static TerminologyClient build(@Nonnull final FhirContext fhirContext,
       @Nonnull final String terminologyServerUrl, final int socketTimeout,
+      final boolean verboseRequestLogging, @Nonnull final TerminologyAuthConfiguration authConfig) {
+    return build(fhirContext, terminologyServerUrl, verboseRequestLogging,
+        authConfig, buildHttpClient(socketTimeout));
+  }
+
+  /**
+   * Build a new instance using the supplied {@link FhirContext} and configuration options.
+   *
+   * @param fhirContext the {@link FhirContext} used to build the client
+   * @param terminologyServerUrl the URL of the terminology server this client will communicate
+   * with
+   * @param verboseRequestLogging whether to log out verbose details of each request
+   * @param authConfig the configuration for terminology service authentication.
+   * @param httpClient the http client instance to use
+   * @return a shiny new TerminologyClient instance
+   */
+  @Nonnull
+  static TerminologyClient build(@Nonnull final FhirContext fhirContext,
+      @Nonnull final String terminologyServerUrl,
       final boolean verboseRequestLogging, @Nonnull final TerminologyAuthConfiguration authConfig,
-      @Nonnull final Logger logger) {
+      @Nonnull final HttpClient httpClient) {
     final IRestfulClientFactory restfulClientFactory = fhirContext.getRestfulClientFactory();
-    restfulClientFactory.setHttpClient(buildHttpClient());
-    restfulClientFactory.setSocketTimeout(socketTimeout);
+    restfulClientFactory.setHttpClient(httpClient);
     restfulClientFactory.setServerValidationMode(ServerValidationModeEnum.NEVER);
 
     final TerminologyClient terminologyClient = restfulClientFactory
@@ -151,7 +176,7 @@ static TerminologyClient build(@Nonnull final FhirContext fhirContext,
     terminologyClient.registerInterceptor(new UserAgentInterceptor());
 
     final LoggingInterceptor loggingInterceptor = new LoggingInterceptor();
-    loggingInterceptor.setLogger(logger);
+    loggingInterceptor.setLogger(log);
     loggingInterceptor.setLogRequestSummary(true);
     loggingInterceptor.setLogResponseSummary(true);
     loggingInterceptor.setLogRequestHeaders(false);
@@ -175,8 +200,18 @@ static TerminologyClient build(@Nonnull final FhirContext fhirContext,
     return terminologyClient;
   }
 
-  private static CloseableHttpClient buildHttpClient() {
-    return HttpClientBuilder.create()
+  private static CloseableHttpClient buildHttpClient(final int socketTimeout) {
+    final CacheConfig cacheConfig = CacheConfig.custom()
+        .setMaxObjectSize(Integer.MAX_VALUE)
+        .build();
+
+    final RequestConfig defaultRequestConfig = RequestConfig.custom()
+        .setSocketTimeout(socketTimeout)
+        .build();
+
+    return CachingHttpClients.custom()
+        .setCacheConfig(cacheConfig)
+        .setDefaultRequestConfig(defaultRequestConfig)
         .setRetryHandler(new DefaultHttpRequestRetryHandler(1, true))
         .build();
   }
diff --git a/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient2.java b/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient2.java
new file mode 100644
index 0000000000..5faa462da5
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient2.java
@@ -0,0 +1,175 @@
+package au.csiro.pathling.fhir;
+
+import au.csiro.pathling.config.TerminologyAuthConfiguration;
+import ca.uhn.fhir.context.FhirContext;
+import ca.uhn.fhir.rest.annotation.Operation;
+import ca.uhn.fhir.rest.annotation.OperationParam;
+import ca.uhn.fhir.rest.client.api.IGenericClient;
+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 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;
+import org.hl7.fhir.r4.model.ValueSet;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import static au.csiro.pathling.utilities.Preconditions.checkNotNull;
+
+public interface TerminologyClient2 {
+
+  Logger log = LoggerFactory.getLogger(TerminologyClient2.class);
+
+  @Operation(name = "$validate-code", type = ValueSet.class, idempotent = true)
+  @Nonnull
+  Parameters validateCode(
+      @Nonnull @OperationParam(name = "url") UriType url,
+      @Nonnull @OperationParam(name = "system") UriType system,
+      @Nullable @OperationParam(name = "systemVersion") StringType version,
+      @Nonnull @OperationParam(name = "code") CodeType code
+  );
+
+
+  @Operation(name = "$translate", type = CodeSystem.class, idempotent = true)
+  @Nonnull
+  Parameters translate(
+      @Nonnull @OperationParam(name = "url") UriType url,
+      @Nonnull @OperationParam(name = "system") UriType system,
+      @Nullable @OperationParam(name = "version") StringType version,
+      @Nonnull @OperationParam(name = "code") CodeType code,
+      @Nullable @OperationParam(name = "reverse") BooleanType reverse,
+      @Nullable @OperationParam(name = "system") UriType target
+  );
+
+  @Operation(name = "$subsumes", type = CodeSystem.class, idempotent = true)
+  @Nonnull
+  Parameters subsumes(
+      @Nonnull @OperationParam(name = "codeA") CodeType codeA,
+      @Nonnull @OperationParam(name = "codeB") CodeType codeB,
+      @Nonnull @OperationParam(name = "system") UriType system,
+      @Nullable @OperationParam(name = "version") StringType version
+  );
+
+
+  static TerminologyClient2 build(@Nonnull final FhirContext fhirContext,
+      @Nonnull final String terminologyServerUrl,
+      final boolean verboseRequestLogging, @Nonnull final TerminologyAuthConfiguration authConfig,
+      @Nonnull final
+      HttpClient httpClient) {
+    final IRestfulClientFactory restfulClientFactory = fhirContext.getRestfulClientFactory();
+    restfulClientFactory.setHttpClient(httpClient);
+    restfulClientFactory.setServerValidationMode(ServerValidationModeEnum.NEVER);
+
+    final IGenericClient genericClient = restfulClientFactory.newGenericClient(
+        terminologyServerUrl);
+
+    genericClient.registerInterceptor(new UserAgentInterceptor());
+
+    final LoggingInterceptor loggingInterceptor = new LoggingInterceptor();
+    loggingInterceptor.setLogger(log);
+    loggingInterceptor.setLogRequestSummary(true);
+    loggingInterceptor.setLogResponseSummary(true);
+    loggingInterceptor.setLogRequestHeaders(false);
+    loggingInterceptor.setLogResponseHeaders(false);
+    if (verboseRequestLogging) {
+      loggingInterceptor.setLogRequestBody(true);
+      loggingInterceptor.setLogResponseBody(true);
+    }
+    genericClient.registerInterceptor(loggingInterceptor);
+
+    if (authConfig.isEnabled()) {
+      checkNotNull(authConfig.getTokenEndpoint());
+      checkNotNull(authConfig.getClientId());
+      checkNotNull(authConfig.getClientSecret());
+      final ClientAuthInterceptor clientAuthInterceptor = new ClientAuthInterceptor(
+          authConfig.getTokenEndpoint(), authConfig.getClientId(), authConfig.getClientSecret(),
+          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();
+  }
+}
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 4a8e9ecadd..8eb6c621b2 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
@@ -118,7 +118,7 @@ static Row[] encodeList(@Nullable final List<Coding> codings) {
   }
 
   @Nonnull
-  public static Column toStruct(@Nonnull final Column id, @Nonnull final Column system,
+  static Column toStruct(@Nonnull final Column id, @Nonnull final Column system,
       @Nonnull final Column version, @Nonnull final Column code, @Nonnull final Column display,
       @Nonnull final Column userSelected) {
     return functions.struct(
@@ -130,5 +130,5 @@ public static Column toStruct(@Nonnull final Column id, @Nonnull final Column sy
         userSelected.as("userSelected")
     );
   }
- 
+
 }
diff --git a/terminology/src/main/java/au/csiro/pathling/fhirpath/encoding/SimpleCoding.java b/terminology/src/main/java/au/csiro/pathling/fhirpath/encoding/SimpleCoding.java
index 5869fed210..15cba4e1c5 100644
--- a/terminology/src/main/java/au/csiro/pathling/fhirpath/encoding/SimpleCoding.java
+++ b/terminology/src/main/java/au/csiro/pathling/fhirpath/encoding/SimpleCoding.java
@@ -34,6 +34,7 @@
 @Data
 @NoArgsConstructor
 @AllArgsConstructor
+@Deprecated
 public class SimpleCoding implements Serializable {
 
   private static final long serialVersionUID = 6509272647875353748L;
diff --git a/terminology/src/main/java/au/csiro/pathling/fhirpath/encoding/SimpleCodingsDecoders.java b/terminology/src/main/java/au/csiro/pathling/fhirpath/encoding/SimpleCodingsDecoders.java
index 7ae71fd25e..0a3d1a7dd1 100644
--- a/terminology/src/main/java/au/csiro/pathling/fhirpath/encoding/SimpleCodingsDecoders.java
+++ b/terminology/src/main/java/au/csiro/pathling/fhirpath/encoding/SimpleCodingsDecoders.java
@@ -29,6 +29,7 @@
 /**
  * Object decoders for {@link SimpleCoding} and collections of it.
  */
+@Deprecated
 public interface SimpleCodingsDecoders {
 
   /**
diff --git a/terminology/src/main/java/au/csiro/pathling/fhirpath/function/memberof/MemberOfMapper.java b/terminology/src/main/java/au/csiro/pathling/fhirpath/function/memberof/MemberOfMapper.java
index 49f9e34c13..a2dbf1c25e 100644
--- a/terminology/src/main/java/au/csiro/pathling/fhirpath/function/memberof/MemberOfMapper.java
+++ b/terminology/src/main/java/au/csiro/pathling/fhirpath/function/memberof/MemberOfMapper.java
@@ -17,7 +17,7 @@
 
 package au.csiro.pathling.fhirpath.function.memberof;
 
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.encoding.SimpleCoding;
 import au.csiro.pathling.sql.MapperWithPreview;
 import au.csiro.pathling.terminology.TerminologyService;
@@ -38,6 +38,7 @@
  * codings belongs to the specified ValueSet.
  */
 @Slf4j
+@Deprecated
 public class MemberOfMapper implements
     MapperWithPreview<List<SimpleCoding>, Boolean, Set<SimpleCoding>> {
 
@@ -75,7 +76,7 @@ public Set<SimpleCoding> preview(@Nonnull final Iterator<List<SimpleCoding>> inp
         .flatMap(List::stream)
         .collect(Collectors.toSet());
 
-    final TerminologyService terminologyService = terminologyServiceFactory.buildService(log);
+    final TerminologyService terminologyService = terminologyServiceFactory.buildService();
     return terminologyService.intersect(valueSetUri, codings);
   }
 
diff --git a/terminology/src/main/java/au/csiro/pathling/fhirpath/function/subsumes/SubsumesMapper.java b/terminology/src/main/java/au/csiro/pathling/fhirpath/function/subsumes/SubsumesMapper.java
index 81757e7da4..d84b99d596 100644
--- a/terminology/src/main/java/au/csiro/pathling/fhirpath/function/subsumes/SubsumesMapper.java
+++ b/terminology/src/main/java/au/csiro/pathling/fhirpath/function/subsumes/SubsumesMapper.java
@@ -19,7 +19,7 @@
 
 import static java.util.stream.Stream.concat;
 
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.encoding.SimpleCoding;
 import au.csiro.pathling.sql.MapperWithPreview;
 import au.csiro.pathling.terminology.Relation;
@@ -42,6 +42,7 @@
  * coding in the left list is in the subsumption relation with any coding in the right list.
  */
 @Slf4j
+@Deprecated
 public class SubsumesMapper implements
     MapperWithPreview<ImmutablePair<
         List<SimpleCoding>, List<SimpleCoding>>, Boolean, Relation> {
@@ -91,7 +92,7 @@ public Relation preview(
         .filter(Objects::nonNull)
         .collect(Collectors.toSet());
 
-    final TerminologyService terminologyService = terminologyServiceFactory.buildService(log);
+    final TerminologyService terminologyService = terminologyServiceFactory.buildService();
     return terminologyService.getSubsumesRelation(codings);
   }
 
diff --git a/terminology/src/main/java/au/csiro/pathling/fhirpath/function/translate/TranslateMapper.java b/terminology/src/main/java/au/csiro/pathling/fhirpath/function/translate/TranslateMapper.java
index 999fda9ef6..8ae6ed9d04 100644
--- a/terminology/src/main/java/au/csiro/pathling/fhirpath/function/translate/TranslateMapper.java
+++ b/terminology/src/main/java/au/csiro/pathling/fhirpath/function/translate/TranslateMapper.java
@@ -17,7 +17,7 @@
 
 package au.csiro.pathling.fhirpath.function.translate;
 
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.encoding.CodingEncoding;
 import au.csiro.pathling.fhirpath.encoding.SimpleCoding;
 import au.csiro.pathling.sql.MapperWithPreview;
@@ -42,6 +42,7 @@
  * codings belongs to the specified ValueSet.
  */
 @Slf4j
+@Deprecated
 public class TranslateMapper implements
     MapperWithPreview<List<SimpleCoding>, Row[], ConceptTranslator> {
 
@@ -100,7 +101,7 @@ public ConceptTranslator preview(@Nonnull final Iterator<List<SimpleCoding>> inp
         .filter(Objects::nonNull)
         .flatMap(List::stream)
         .collect(Collectors.toSet());
-    final TerminologyService terminologyService = terminologyServiceFactory.buildService(log);
+    final TerminologyService terminologyService = terminologyServiceFactory.buildService();
     return terminologyService.translate(uniqueCodings, conceptMapUrl,
         reverse, equivalences, target);
   }
diff --git a/terminology/src/main/java/au/csiro/pathling/sql/MapperWithPreview.java b/terminology/src/main/java/au/csiro/pathling/sql/MapperWithPreview.java
index 8a879e9e58..c3f14a3363 100644
--- a/terminology/src/main/java/au/csiro/pathling/sql/MapperWithPreview.java
+++ b/terminology/src/main/java/au/csiro/pathling/sql/MapperWithPreview.java
@@ -30,6 +30,7 @@
  * @param <R> result type of the mapper
  * @param <S> state type of the mapper
  */
+@Deprecated
 public interface MapperWithPreview<I, R, S> extends Serializable {
 
   /**
diff --git a/terminology/src/main/java/au/csiro/pathling/sql/ObjectDecoder.java b/terminology/src/main/java/au/csiro/pathling/sql/ObjectDecoder.java
index 72cb04b89b..2bcf1c7398 100644
--- a/terminology/src/main/java/au/csiro/pathling/sql/ObjectDecoder.java
+++ b/terminology/src/main/java/au/csiro/pathling/sql/ObjectDecoder.java
@@ -27,6 +27,7 @@
  * @param <T> type of the object to produce.
  */
 @FunctionalInterface
+@Deprecated
 public interface ObjectDecoder<T> extends Serializable {
 
   /**
diff --git a/terminology/src/main/java/au/csiro/pathling/sql/SqlExtensions.java b/terminology/src/main/java/au/csiro/pathling/sql/SqlExtensions.java
index d63a1606e9..3331252807 100644
--- a/terminology/src/main/java/au/csiro/pathling/sql/SqlExtensions.java
+++ b/terminology/src/main/java/au/csiro/pathling/sql/SqlExtensions.java
@@ -27,6 +27,7 @@
 /**
  * Custom dataset operations.
  */
+@Deprecated
 public interface SqlExtensions {
 
   /**
diff --git a/terminology/src/main/java/au/csiro/pathling/sql/Terminology.java b/terminology/src/main/java/au/csiro/pathling/sql/Terminology.java
new file mode 100644
index 0000000000..2997cd78b3
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/sql/Terminology.java
@@ -0,0 +1,121 @@
+package au.csiro.pathling.sql;
+
+import static org.apache.spark.sql.functions.call_udf;
+import static org.apache.spark.sql.functions.lit;
+
+import au.csiro.pathling.sql.udf.SubsumesUdf;
+import au.csiro.pathling.sql.udf.TranslateUdf;
+import au.csiro.pathling.sql.udf.MemberOfUdf;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import org.apache.spark.sql.Column;
+
+/**
+ * JAVA API for terminology UDFs
+ */
+public interface Terminology {
+
+  /**
+   * Takes a Coding or an 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 the struct representation of a Coding or an array of such
+   * structs.
+   * @param valueSetUrl the identifier for a FHIR ValueSet.
+   * @return the Column containing the result of the operation
+   */
+  @Nonnull
+  static Column member_of(@Nonnull final Column coding, @Nonnull final String valueSetUrl) {
+    return member_of(coding, lit(valueSetUrl));
+  }
+
+  /**
+   * Takes a Coding or an 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 the struct representation of a Coding or an array of such
+   * structs.
+   * @param valueSetUrl the column with the identifier for a FHIR ValueSet.
+   * @return the Column containing the result of the operation
+   */
+  @Nonnull
+  static Column member_of(@Nonnull final Column coding, @Nonnull final Column valueSetUrl) {
+    return call_udf(MemberOfUdf.FUNCTION_NAME, coding, valueSetUrl);
+  }
+
+  // TODO: consider the order of target and equivaleces
+  // 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
+   * be more than one target concept for each input concept.
+   *
+   * @param coding a Column containing the struct representation of a Coding or an array of such
+   * structs.
+   * @param conceptMapUri an identifier for a FHIR ConceptMap.
+   * @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 comma-delimited set of values from the ConceptMapEquivalence ValueSet.
+   * @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 the Column containing the result of the operation (an array of Coding structs).
+   */
+  @Nonnull
+  static Column translate(@Nonnull final Column coding, @Nonnull final String conceptMapUri,
+      boolean reverse, @Nullable final String equivalences, @Nullable final String target) {
+    return call_udf(TranslateUdf.FUNCTION_NAME, coding, lit(conceptMapUri), lit(reverse),
+        lit(equivalences), lit(target));
+  }
+
+  /**
+   * 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
+   * be more than one target concept for each input concept.
+   *
+   * @param coding a Column containing the struct representation of a Coding or an array of such
+   * structs.
+   * @param conceptMapUri an identifier for a FHIR ConceptMap.
+   * @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 comma-delimited set of values from the ConceptMapEquivalence ValueSet.
+   * @return the Column containing the result of the operation (an array of Coding structs).
+   */
+  @Nonnull
+  static Column translate(@Nonnull final Column coding, @Nonnull final String conceptMapUri,
+      boolean reverse, @Nullable final String equivalences) {
+    return translate(coding, conceptMapUri, reverse, equivalences, null);
+  }
+
+
+  /**
+   * Takes two Coding or array of Codings columns as its input. Returns the Column, which contains a
+   * Boolean value, indicating whether the left Coding subsumes the right Coding.
+   *
+   * @param codingA a Column containing a struct representation of a Coding or an array of Codings.
+   * @param codingB a Column containing a struct representation of a Coding or an array of *
+   * Codings.
+   * @return the Column containing the result of the operation (boolean)
+   */
+  @Nonnull
+  static Column subsumes(@Nonnull final Column codingA, @Nonnull final Column codingB) {
+    return call_udf(SubsumesUdf.FUNCTION_NAME, codingA, codingB, lit(false));
+  }
+
+  /**
+   * Takes two Coding or array of Codings columns as its input. Returns the Column, which contains a
+   * Boolean value, indicating whether the left Coding is subsumed by the right Coding.
+   *
+   * @param codingA a Column containing a struct representation of a Coding or an array of Codings.
+   * @param codingB a Column containing a struct representation of a Coding or an array of *
+   * Codings.
+   * @return the Column containing the result of the operation (boolean)
+   */
+  @Nonnull
+  static Column subsumed_by(@Nonnull final Column codingA, @Nonnull final Column codingB) {
+    return call_udf(SubsumesUdf.FUNCTION_NAME, codingA, codingB, lit(true));
+  }
+}
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
new file mode 100644
index 0000000000..a89a6fdc8b
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/sql/udf/MemberOfUdf.java
@@ -0,0 +1,76 @@
+/*
+ * 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.sql.udf;
+
+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.TerminologyServiceFactory;
+import java.util.stream.Stream;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.spark.sql.types.DataType;
+import org.apache.spark.sql.types.DataTypes;
+import org.hl7.fhir.r4.model.Coding;
+
+/**
+ * The implementation of the 'member_of' UDF.
+ */
+@Slf4j
+public class MemberOfUdf implements SqlFunction,
+    SqlFunction2<Object, String, Boolean> {
+
+  private static final long serialVersionUID = 7605853352299165569L;
+
+  public static final String FUNCTION_NAME = "member_of";
+
+  @Nonnull
+  private final TerminologyServiceFactory terminologyServiceFactory;
+
+  MemberOfUdf(@Nonnull final TerminologyServiceFactory terminologyServiceFactory) {
+    this.terminologyServiceFactory = terminologyServiceFactory;
+  }
+
+  @Override
+  public DataType getReturnType() {
+    return DataTypes.BooleanType;
+  }
+
+  @Override
+  public String getName() {
+    return FUNCTION_NAME;
+  }
+
+  @Nullable
+  @Override
+  public Boolean call(@Nullable final Object codingRowOrArray, @Nullable final String url) {
+    return doCall(decodeOneOrMany(codingRowOrArray), url);
+  }
+
+  @Nullable
+  protected Boolean doCall(@Nullable final Stream<Coding> codings, @Nullable final String url) {
+    if (url == null || codings == null) {
+      return null;
+    }
+    final TerminologyService2 terminologyService = terminologyServiceFactory.buildService2();
+    return validCodings(codings)
+        .anyMatch(coding -> terminologyService.validate(url, coding));
+  }
+}
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
new file mode 100644
index 0000000000..c0140407f0
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/sql/udf/SubsumesUdf.java
@@ -0,0 +1,113 @@
+/*
+ * 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.sql.udf;
+
+import static au.csiro.pathling.sql.udf.TerminologyUdfHelpers.decodeOneOrMany;
+import static au.csiro.pathling.sql.udf.TerminologyUdfHelpers.validCodings;
+import static org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome.EQUIVALENT;
+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.TerminologyServiceFactory;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.spark.sql.types.DataType;
+import org.apache.spark.sql.types.DataTypes;
+import org.hl7.fhir.r4.model.Coding;
+import org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome;
+
+/**
+ * The implementation of the 'subsumes' UDF.
+ */
+@Slf4j
+public class SubsumesUdf implements SqlFunction,
+    SqlFunction3<Object, Object, Boolean, Boolean> {
+
+  private static final long serialVersionUID = 7605853352299165569L;
+
+  public static final String FUNCTION_NAME = "subsumes";
+
+  public static final DataType RETURN_TYPE = DataTypes.BooleanType;
+  public static final boolean PARAM_INVERTED_DEFAULT = false;
+
+  @Nonnull
+  private final TerminologyServiceFactory terminologyServiceFactory;
+
+  public SubsumesUdf(@Nonnull final TerminologyServiceFactory terminologyServiceFactory) {
+    this.terminologyServiceFactory = terminologyServiceFactory;
+  }
+
+  @Override
+  public String getName() {
+    return FUNCTION_NAME;
+  }
+
+  @Override
+  public DataType getReturnType() {
+    return RETURN_TYPE;
+  }
+
+  @Nullable
+  protected Boolean doCall(@Nullable final Stream<Coding> codingsA,
+      @Nullable final Stream<Coding> codingsB,
+      @Nullable Boolean inverted) {
+    if (codingsA == null || codingsB == null) {
+      return null;
+    }
+    final boolean resolvedInverted = inverted != null
+                                     ? inverted
+                                     : PARAM_INVERTED_DEFAULT;
+
+    final TerminologyService2 terminologyService = terminologyServiceFactory.buildService2();
+
+    // does any of the input codings subsume any of the output codings (within the same system)
+    final List<Coding> validCodingsB = validCodings(codingsB)
+        .collect(Collectors.toUnmodifiableList());
+
+    return validCodings(codingsA)
+        .anyMatch(codingA ->
+            validCodingsB.stream()
+                .filter(codingB -> codingA.getSystem().equals(codingB.getSystem()))
+                .anyMatch(codingB -> isSubsumes(terminologyService.subsumes(codingA, codingB),
+                    resolvedInverted))
+        );
+  }
+
+  @Nullable
+  @Override
+  public Boolean call(@Nullable final Object codingRowOrArrayA,
+      @Nullable final Object codingRowOrArrayB,
+      @Nullable final Boolean inverted) {
+    return doCall(
+        decodeOneOrMany(codingRowOrArrayA),
+        decodeOneOrMany(codingRowOrArrayB, 1),
+        inverted);
+  }
+
+  private static boolean isSubsumes(@Nonnull final ConceptSubsumptionOutcome outcome,
+      final boolean inverted) {
+    return EQUIVALENT.equals(outcome) || (inverted
+                                          ? SUBSUMEDBY
+                                          : SUBSUMES).equals(outcome);
+  }
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/sql/udf/TerminologyUdfHelpers.java b/terminology/src/main/java/au/csiro/pathling/sql/udf/TerminologyUdfHelpers.java
new file mode 100644
index 0000000000..1c82b1af23
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/sql/udf/TerminologyUdfHelpers.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.sql.udf;
+
+import au.csiro.pathling.fhirpath.encoding.CodingEncoding;
+import org.apache.hadoop.shaded.org.apache.curator.shaded.com.google.common.collect.Streams;
+import org.apache.spark.sql.Row;
+import org.hl7.fhir.r4.model.Coding;
+import org.hl7.fhir.r4.model.Parameters;
+import scala.collection.JavaConverters;
+import scala.collection.mutable.WrappedArray;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+import static java.util.Objects.nonNull;
+
+/**
+ * Helper functions for terminology UDFs.
+ */
+public final class TerminologyUdfHelpers {
+
+  private TerminologyUdfHelpers() {
+    // Utility class
+  }
+  
+  @Nullable
+  static Row[] encodeMany(@Nullable final Stream<Coding> codings) {
+    return codings != null
+           ? codings.map(CodingEncoding::encode).toArray(Row[]::new)
+           : null;
+  }
+
+  @Nullable
+  public static Stream<Coding> decodeOneOrMany(final @Nullable Object codingRowOrArray,
+      final int argumentIndex) {
+    if (codingRowOrArray instanceof WrappedArray<?>) {
+      //noinspection unchecked
+      return decodeMany((WrappedArray<Row>) codingRowOrArray);
+    } else if (codingRowOrArray instanceof Row || codingRowOrArray == null) {
+      return decodeOne((Row) codingRowOrArray);
+    } else {
+      throw new IllegalArgumentException(
+          String.format("Row or WrappedArray<Row> column expected in argument %s, but given: %s,",
+              argumentIndex, codingRowOrArray.getClass()));
+    }
+  }
+
+  @Nullable
+  public static Stream<Coding> decodeOneOrMany(final @Nullable Object codingRowOrArray) {
+    return decodeOneOrMany(codingRowOrArray, 0);
+  }
+
+  @Nullable
+  public static Stream<Coding> decodeOne(final @Nullable Row codingRow) {
+    return codingRow != null
+           ? Stream.of(CodingEncoding.decode(codingRow))
+           : null;
+  }
+
+  @Nullable
+  public static Stream<Coding> decodeMany(final @Nullable WrappedArray<Row> codingsRow) {
+    return codingsRow != null
+           ? Streams.stream(JavaConverters.asJavaIterable(codingsRow)).filter(Objects::nonNull)
+               .map(CodingEncoding::decode)
+           : null;
+  }
+
+  public static boolean isValidCoding(@Nullable Coding coding) {
+    return nonNull(coding) && nonNull(coding.getSystem()) && nonNull(coding.getCode());
+  }
+
+  @Nonnull
+  public static Stream<Coding> validCodings(@Nonnull final Stream<Coding> codings) {
+    return codings.filter(TerminologyUdfHelpers::isValidCoding);
+  }
+}
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
new file mode 100644
index 0000000000..054eb6ce49
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/sql/udf/TerminologyUdfRegistrar.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.sql.udf;
+
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
+import java.util.Collections;
+import java.util.List;
+import javax.annotation.Nonnull;
+import org.apache.spark.sql.SparkSession;
+
+/**
+ * The {@link SqlFunctionRegistrar} for terminology UDFs.
+ */
+public class TerminologyUdfRegistrar extends SqlFunctionRegistrar {
+
+  public TerminologyUdfRegistrar(@Nonnull TerminologyServiceFactory tsf) {
+    super(Collections.emptyList(),
+        List.of(new MemberOfUdf(tsf)),
+        List.of(new SubsumesUdf(tsf)),
+        Collections.emptyList(),
+        List.of(new TranslateUdf(tsf)));
+  }
+
+  /**
+   * Registers terminology UDFs in provided spark session.
+   *
+   * @param spark the session to configure.
+   * @param terminologyServiceFactory the {@link TerminologyServiceFactory} to use for the UDFs.
+   */
+  public static void registerUdfs(@Nonnull final SparkSession spark,
+      @Nonnull final TerminologyServiceFactory terminologyServiceFactory) {
+    new TerminologyUdfRegistrar(terminologyServiceFactory).configure(spark);
+  }
+}
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
new file mode 100644
index 0000000000..99ada4c1ce
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/sql/udf/TranslateUdf.java
@@ -0,0 +1,99 @@
+package au.csiro.pathling.sql.udf;
+
+import static au.csiro.pathling.sql.udf.TerminologyUdfHelpers.decodeOneOrMany;
+import static au.csiro.pathling.sql.udf.TerminologyUdfHelpers.encodeMany;
+import static au.csiro.pathling.sql.udf.TerminologyUdfHelpers.validCodings;
+import static au.csiro.pathling.utilities.Preconditions.wrapInUserInputError;
+
+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.TerminologyServiceFactory;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import au.csiro.pathling.utilities.Strings;
+import com.google.common.collect.ImmutableSet;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.types.DataType;
+import org.apache.spark.sql.types.DataTypes;
+import org.hl7.fhir.r4.model.Coding;
+import org.hl7.fhir.r4.model.Enumerations.ConceptMapEquivalence;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * The implementation of the 'translate()' udf.
+ */
+@Slf4j
+public class TranslateUdf implements SqlFunction,
+    SqlFunction5<Object, String, Boolean, String, String, Row[]> {
+
+  private static final long serialVersionUID = 7605853352299165569L;
+
+  public static final String FUNCTION_NAME = "translate";
+  public static final DataType RETURN_TYPE = DataTypes.createArrayType(CodingEncoding.DATA_TYPE);
+  public static final boolean PARAM_REVERSE_DEFAULT = false;
+
+  @Nonnull
+  private final TerminologyServiceFactory terminologyServiceFactory;
+
+  TranslateUdf(@Nonnull final TerminologyServiceFactory terminologyServiceFactory) {
+    this.terminologyServiceFactory = terminologyServiceFactory;
+  }
+
+  @Override
+  public String getName() {
+    return FUNCTION_NAME;
+  }
+
+  @Override
+  public DataType getReturnType() {
+    return RETURN_TYPE;
+  }
+
+  @Nullable
+  protected Stream<Coding> doCall(@Nullable final Stream<Coding> codings,
+      @Nullable final String conceptMapUri, @Nullable Boolean reverse,
+      @Nullable final String equivalences,
+      @Nullable final String target) {
+    if (codings == null || conceptMapUri == null) {
+      return null;
+    }
+
+    // TODO: Add per stage caching of parsed equivalences
+    final Set<String> includeEquivalences = (equivalences == null || equivalences.isBlank())
+                                            ? ImmutableSet.of("equivalent")
+                                            : Strings.parseCsvList(equivalences,
+                                                    wrapInUserInputError(
+                                                        ConceptMapEquivalence::fromCode)).stream()
+                                                .map(ConceptMapEquivalence::toCode)
+                                                .collect(Collectors.toUnmodifiableSet());
+
+    final boolean resolvedReverse = reverse != null
+                                    ? reverse
+                                    : PARAM_REVERSE_DEFAULT;
+
+    final TerminologyService2 terminologyService = terminologyServiceFactory.buildService2();
+    // TODO: make codings unique maybe without using ImmutableCoding
+    return validCodings(codings)
+        .flatMap(coding ->
+            terminologyService.translate(coding, conceptMapUri, resolvedReverse, target).stream())
+        .filter(entry -> includeEquivalences.contains(entry.getEquivalence().toCode()))
+        .map(Translation::getConcept)
+        .map(ImmutableCoding::of)
+        .distinct()
+        .map(ImmutableCoding::toCoding);
+  }
+
+  @Nullable
+  @Override
+  public Row[] call(@Nullable final Object codingRowOrArray, @Nullable final String conceptMapUri,
+      @Nullable final Boolean reverse, @Nullable final String equivalences,
+      @Nullable final String target) {
+    return encodeMany(
+        doCall(decodeOneOrMany(codingRowOrArray), conceptMapUri, reverse, equivalences, target));
+  }
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/BaseMapping.java b/terminology/src/main/java/au/csiro/pathling/terminology/BaseMapping.java
index 775221574e..82bdd5b736 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/BaseMapping.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/BaseMapping.java
@@ -37,6 +37,7 @@
  *
  * @author Piotr Szul
  */
+@Deprecated
 public class BaseMapping {
 
   protected BaseMapping() {
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/ClosureMapping.java b/terminology/src/main/java/au/csiro/pathling/terminology/ClosureMapping.java
index 707dd118f7..9c207df30a 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/ClosureMapping.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/ClosureMapping.java
@@ -40,6 +40,7 @@
  * ConceptMap</a>
  */
 @Slf4j
+@Deprecated
 public final class ClosureMapping extends BaseMapping {
 
   private ClosureMapping() {
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/ConceptTranslator.java b/terminology/src/main/java/au/csiro/pathling/terminology/ConceptTranslator.java
index 6fc7adfeeb..7475fc5a52 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/ConceptTranslator.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/ConceptTranslator.java
@@ -36,6 +36,7 @@
  */
 @ToString
 @EqualsAndHashCode
+@Deprecated
 public class ConceptTranslator implements Serializable {
 
   private static final long serialVersionUID = -8246857034657784595L;
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService.java b/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService.java
index 824889f674..8c7fdbe4ff 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService.java
@@ -48,6 +48,7 @@
  * Default implementation of TerminologyService using a backend terminology server.
  */
 @Slf4j
+@Deprecated
 public class DefaultTerminologyService implements TerminologyService {
 
   @Nonnull
@@ -114,7 +115,7 @@ private Stream<SimpleCoding> validAndKnownCodings(
     return validCodings(codings)
         .filter(coding -> knownCodeSystems.contains(coding.getSystem()));
   }
-
+  
   @Nonnull
   @Override
   public ConceptTranslator translate(@Nonnull final Collection<SimpleCoding> codings,
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService2.java b/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService2.java
new file mode 100644
index 0000000000..6e8ded5769
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService2.java
@@ -0,0 +1,143 @@
+package au.csiro.pathling.terminology;
+
+import static au.csiro.pathling.fhir.ParametersUtils.toBooleanResult;
+import static au.csiro.pathling.fhir.ParametersUtils.toSubsumptionOutcome;
+import static au.csiro.pathling.fhir.ParametersUtils.toMatchParts;
+import static java.util.Objects.isNull;
+import static java.util.Objects.nonNull;
+import static org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome.NOTSUBSUMED;
+
+import au.csiro.pathling.fhir.TerminologyClient2;
+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 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;
+
+/**
+ * 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 Closeable toClose) {
+    this.terminologyClient = terminologyClient;
+    this.toClose = toClose;
+  }
+
+  @Nullable
+  private static <T> T optional(@Nonnull final Function<String, T> converter,
+      @Nullable String value) {
+    return value != null
+           ? converter.apply(value)
+           : null;
+  }
+
+  @Nonnull
+  private static <T> T required(@Nonnull final Function<String, T> converter,
+      @Nullable 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 validate(@Nonnull final String url, @Nonnull final Coding coding) {
+
+    if (isNull(coding.getSystem()) || isNull(coding.getCode())) {
+      return false;
+    }
+
+    return toBooleanResult(terminologyClient.validateCode(
+        required(UriType::new, url), required(UriType::new, coding.getSystem()),
+        optional(StringType::new, coding.getVersion()),
+        required(CodeType::new, coding.getCode())
+    ));
+  }
+
+  @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();
+    }
+
+    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)
+    ));
+  }
+
+  @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;
+    }
+
+    final String resolvedSystem = codingA.getSystem();
+    // 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;
+    }
+    final String resolvedVersion = codingA.getVersion() != null
+                                   ? codingA.getVersion()
+                                   : codingB.getVersion();
+
+    // TODO: optimize not call the client if not needed (when codings are equal)
+    return toSubsumptionOutcome(terminologyClient.subsumes(
+        required(CodeType::new, codingA.getCode()),
+        required(CodeType::new, codingB.getCode()),
+        required(UriType::new, resolvedSystem),
+        optional(StringType::new, resolvedVersion)
+    ));
+  }
+
+  @Override
+  public void close() throws IOException {
+    if (nonNull(toClose)) {
+      toClose.close();
+    }
+  }
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyServiceFactory.java b/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyServiceFactory.java
new file mode 100644
index 0000000000..21ce73ea16
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyServiceFactory.java
@@ -0,0 +1,187 @@
+package au.csiro.pathling.terminology;
+
+import au.csiro.pathling.caching.CachingFactories;
+import au.csiro.pathling.config.HttpCacheConf;
+import au.csiro.pathling.config.HttpClientConf;
+import au.csiro.pathling.config.TerminologyAuthConfiguration;
+import au.csiro.pathling.encoders.FhirEncoders;
+import au.csiro.pathling.fhir.TerminologyClient;
+import au.csiro.pathling.fhir.TerminologyClient2;
+import au.csiro.pathling.utilities.ObjectHolder;
+import au.csiro.pathling.utilities.ObjectHolder.SingletonHolder;
+import ca.uhn.fhir.context.FhirContext;
+import ca.uhn.fhir.context.FhirVersionEnum;
+import java.util.UUID;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+import lombok.extern.slf4j.Slf4j;
+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;
+
+import static java.util.Objects.nonNull;
+
+
+/**
+ * Default implementation of {@link TerminologyServiceFactory} providing {@link TerminologyService}
+ * implemented using {@link TerminologyClient} with given configuration.
+ *
+ * @author John Grimes
+ * @author Piotr Szul
+ */
+@Slf4j
+@EqualsAndHashCode
+@ToString
+@Getter
+public class DefaultTerminologyServiceFactory implements TerminologyServiceFactory {
+
+  private static final long serialVersionUID = 2837933007972812597L;
+
+  @Nonnull
+  private static final ObjectHolder<DefaultTerminologyServiceFactory, TerminologyService2> terminologyServiceHolder2 = ObjectHolder.singleton(
+      DefaultTerminologyServiceFactory::createService2);
+
+  @Nonnull
+  private static final ObjectHolder<DefaultTerminologyServiceFactory, TerminologyService> terminologyServiceHolder = ObjectHolder.singleton(
+      DefaultTerminologyServiceFactory::createService);
+
+
+  @Nonnull
+  private final FhirVersionEnum fhirVersion;
+
+  @Nonnull
+  private final String terminologyServerUrl;
+
+  private final boolean verboseRequestLogging;
+
+  @Nonnull
+  private final HttpClientConf clientConfig;
+
+  @Nonnull
+  private final HttpCacheConf cacheConfig;
+
+  @Nonnull
+  private final TerminologyAuthConfiguration authConfig;
+
+  @Deprecated
+  @Nonnull
+  private transient UUIDFactory uuidFactory = UUID::randomUUID;
+
+  public static synchronized void reset() {
+    log.info("Resetting terminology services");
+    terminologyServiceHolder.reset();
+    terminologyServiceHolder2.reset();
+  }
+
+  @Deprecated
+  public DefaultTerminologyServiceFactory(@Nonnull final FhirVersionEnum fhirVersion,
+      @Nonnull final String terminologyServerUrl,
+      @Nullable final Integer socketTimeout,
+      final boolean verboseRequestLogging,
+      @Nonnull final HttpClientConf clientConfig,
+      @Nonnull final HttpCacheConf cacheConfig,
+      @Nonnull final TerminologyAuthConfiguration authConfig) {
+
+    // For backwards compatibility with the old version config version
+    this(fhirVersion, terminologyServerUrl, verboseRequestLogging,
+        nonNull(socketTimeout)
+        ? clientConfig.toBuilder()
+            .socketTimeout(socketTimeout)
+            .build()
+        : clientConfig,
+        cacheConfig,
+        authConfig);
+  }
+
+  public DefaultTerminologyServiceFactory(@Nonnull final FhirVersionEnum fhirVersion,
+      @Nonnull final String terminologyServerUrl,
+      final boolean verboseRequestLogging,
+      @Nonnull final HttpClientConf clientConfig,
+      @Nonnull final HttpCacheConf cacheConfig,
+      @Nonnull final TerminologyAuthConfiguration authConfig) {
+    this.fhirVersion = fhirVersion;
+    this.terminologyServerUrl = terminologyServerUrl;
+    this.verboseRequestLogging = verboseRequestLogging;
+    this.authConfig = authConfig;
+    this.clientConfig = clientConfig;
+    this.cacheConfig = cacheConfig;
+  }
+
+  @Nonnull
+  @Override
+  public TerminologyService buildService() {
+    //noinspection NullableProblems
+    return terminologyServiceHolder.getOrCreate(this);
+  }
+
+  @Nonnull
+  @Override
+  public TerminologyService2 buildService2() {
+    return terminologyServiceHolder2.getOrCreate(this);
+  }
+
+  @Nonnull
+  private TerminologyService createService() {
+    final FhirContext fhirContext = FhirEncoders.contextFor(fhirVersion);
+    //TODO: maybe share the HttpClient 
+    final CloseableHttpClient httpClient = buildHttpClient(clientConfig,
+        cacheConfig);
+    final TerminologyClient terminologyClient = TerminologyClient.build(
+        fhirContext, terminologyServerUrl, verboseRequestLogging, authConfig,
+        httpClient);
+    return new DefaultTerminologyService(fhirContext, terminologyClient, uuidFactory);
+  }
+
+  @Nonnull
+  private DefaultTerminologyService2 createService2() {
+    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);
+  }
+
+  private static CloseableHttpClient buildHttpClient(
+      @Nonnull final HttpClientConf clientConf,
+      @Nonnull final HttpCacheConf cacheConf) {
+    final PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
+    connectionManager.setMaxTotal(clientConf.getMaxConnectionsTotal());
+    connectionManager.setDefaultMaxPerRoute(clientConf.getMaxConnectionsPerRoute());
+    final HttpClientBuilder clientBuilder;
+    if (cacheConf.isEnabled()) {
+      final CacheConfig cacheConfig = CacheConfig.custom()
+          .setMaxCacheEntries(cacheConf.getMaxCacheEntries())
+          .setMaxObjectSize(cacheConf.getMaxObjectSize())
+          .build();
+      clientBuilder = CachingFactories.of(cacheConf.getStorageType())
+          .create(cacheConfig, cacheConf.getStorage());
+    } else {
+      clientBuilder = HttpClients.custom();
+    }
+
+    final RequestConfig defaultRequestConfig = RequestConfig.custom()
+        .setSocketTimeout(clientConf.getSocketTimeout())
+        .build();
+
+    return clientBuilder
+        .setDefaultRequestConfig(defaultRequestConfig)
+        .setConnectionManager(connectionManager)
+        .setConnectionManagerShared(false)
+        .setRetryHandler(new DefaultHttpRequestRetryHandler(1, true))
+        .build();
+  }
+
+  @Deprecated
+  public void setUUIDFactory(UUIDFactory uuidFactory) {
+    this.uuidFactory = uuidFactory;
+  }
+}
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/Relation.java b/terminology/src/main/java/au/csiro/pathling/terminology/Relation.java
index c598d7b896..b4419c60ae 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/Relation.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/Relation.java
@@ -44,6 +44,7 @@
  */
 @ToString
 @EqualsAndHashCode
+@Deprecated
 public class Relation implements Serializable {
 
   private static final long serialVersionUID = -8124924480216379884L;
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyFunctions.java b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyFunctions.java
index 42c1d39b3b..27f8729c7b 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyFunctions.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyFunctions.java
@@ -19,7 +19,6 @@
 
 import static au.csiro.pathling.utilities.Preconditions.wrapInUserInputError;
 
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.encoding.CodingEncoding;
 import au.csiro.pathling.fhirpath.encoding.SimpleCoding;
 import au.csiro.pathling.fhirpath.encoding.SimpleCodingsDecoders;
@@ -28,76 +27,162 @@
 import au.csiro.pathling.fhirpath.function.translate.TranslateMapper;
 import au.csiro.pathling.sql.MapperWithPreview;
 import au.csiro.pathling.sql.SqlExtensions;
+import au.csiro.pathling.sql.Terminology;
 import au.csiro.pathling.utilities.Strings;
 import java.util.List;
 import java.util.Set;
+import java.util.UUID;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 import org.apache.spark.sql.Column;
 import org.apache.spark.sql.Dataset;
 import org.apache.spark.sql.Row;
+import org.apache.spark.sql.functions;
 import org.apache.spark.sql.types.DataTypes;
 import org.apache.spark.sql.types.Metadata;
 import org.apache.spark.sql.types.StructField;
 import org.hl7.fhir.r4.model.Enumerations.ConceptMapEquivalence;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.slf4j.MDC;
 
+@Deprecated
 public interface TerminologyFunctions {
 
+  Logger log = LoggerFactory.getLogger(TerminologyFunctions.class);
+
+  @Nonnull
+  Dataset<Row> memberOf(@Nonnull final Column codingArrayCol, @Nonnull final String valueSetUri,
+      @Nonnull final Dataset<Row> dataset, @Nonnull final String outputColumnName);
+
+  @Nonnull
+  Dataset<Row> translate(@Nonnull final Column codingArrayCol, @Nonnull final String conceptMapUrl,
+      final boolean reverse, @Nonnull final String equivalence,@Nullable final String target, 
+      @Nonnull final Dataset<Row> dataset,
+      @Nonnull final String outputColumnName);
+
   @Nonnull
-  static Dataset<Row> memberOf(@Nonnull final Column codingArrayCol,
+  Dataset<Row> subsumes(@Nonnull final Dataset<Row> idAndCodingSet,
+      @Nonnull final Column codingArrayA, @Nonnull final Column codingArrayB,
+      @Nonnull final String outputColumnName, final boolean inverted);
+
+  @Nonnull
+  static TerminologyFunctions of(
+      @Nonnull final TerminologyServiceFactory terminologyServiceFactory) {
+    // TODO: remove the legacy dependency at some point
+    if ("legacy".equals(System.getenv("PATHLING_TERMINOLOGY"))) {
+      log.warn("Using legacy implementation of terminology functions");
+      return new TerminologyFunctionsLegacyImpl(terminologyServiceFactory);
+    } else {
+      return new TerminologyFunctionsImpl();
+    }
+  }
+}
+
+class TerminologyFunctionsLegacyImpl implements TerminologyFunctions {
+
+  @Nonnull
+  private final TerminologyServiceFactory terminologyServiceFactory;
+
+  TerminologyFunctionsLegacyImpl(
+      @Nonnull final TerminologyServiceFactory terminologyServiceFactory) {
+    this.terminologyServiceFactory = terminologyServiceFactory;
+  }
+
+  @Override
+  @Nonnull
+  public Dataset<Row> memberOf(@Nonnull final Column codingArrayCol,
       @Nonnull final String valueSetUri, @Nonnull final Dataset<Row> dataset,
-      @Nonnull final String outputColumnName,
-      @Nonnull final TerminologyServiceFactory terminologyServiceFactory,
-      @Nonnull final String requestId) {
-
-    final MapperWithPreview<List<SimpleCoding>, Boolean, Set<SimpleCoding>> mapper =
-        new MemberOfMapper(requestId, terminologyServiceFactory,
-            valueSetUri);
-
-    return SqlExtensions
-        .mapWithPartitionPreview(dataset, codingArrayCol,
-            SimpleCodingsDecoders::decodeList,
-            mapper,
-            StructField.apply(outputColumnName, DataTypes.BooleanType, true, Metadata.empty()));
+      @Nonnull final String outputColumnName) {
+
+    final MapperWithPreview<List<SimpleCoding>, Boolean, Set<SimpleCoding>> mapper = new MemberOfMapper(
+        getOrCreateRequestId(), terminologyServiceFactory, valueSetUri);
+
+    return SqlExtensions.mapWithPartitionPreview(dataset, codingArrayCol,
+        SimpleCodingsDecoders::decodeList, mapper,
+        StructField.apply(outputColumnName, DataTypes.BooleanType, true, Metadata.empty()));
   }
 
+  @Override
   @Nonnull
-  static Dataset<Row> translate(@Nonnull final Column codingArrayCol,
-      @Nonnull final String conceptMapUrl, final boolean reverse, @Nonnull final String equivalence,
-      @Nullable final String target, @Nonnull final Dataset<Row> dataset,
-      @Nonnull final String outputColumnName,
-      @Nonnull final TerminologyServiceFactory terminologyServiceFactory,
-      @Nonnull final String requestId) {
+  public Dataset<Row> translate(@Nonnull final Column codingArrayCol,
+      @Nonnull final String conceptMapUrl, final boolean reverse, 
+      @Nonnull final String equivalence,
+      @Nullable final String target,
+      @Nonnull final Dataset<Row> dataset, @Nonnull final String outputColumnName) {
 
     final MapperWithPreview<List<SimpleCoding>, Row[], ConceptTranslator> mapper =
-        new TranslateMapper(requestId, terminologyServiceFactory,
+        new TranslateMapper(getOrCreateRequestId(), terminologyServiceFactory,
             conceptMapUrl, reverse, Strings.parseCsvList(equivalence,
             wrapInUserInputError(ConceptMapEquivalence::fromCode)), target);
 
-    return SqlExtensions
-        .mapWithPartitionPreview(dataset, codingArrayCol,
-            SimpleCodingsDecoders::decodeList,
-            mapper,
-            StructField.apply(outputColumnName, DataTypes.createArrayType(CodingEncoding.DATA_TYPE),
-                true, Metadata.empty()));
+    return SqlExtensions.mapWithPartitionPreview(dataset, codingArrayCol,
+        SimpleCodingsDecoders::decodeList, mapper,
+        StructField.apply(outputColumnName, DataTypes.createArrayType(CodingEncoding.DATA_TYPE),
+            true, Metadata.empty()));
+  }
+
+  @Override
+  @Nonnull
+  public Dataset<Row> subsumes(@Nonnull final Dataset<Row> idAndCodingSet,
+      @Nonnull final Column codingArrayA, @Nonnull final Column codingArrayB,
+      @Nonnull final String outputColumnName, final boolean inverted) {
+
+    final SubsumesMapper mapper = new SubsumesMapper(getOrCreateRequestId(),
+        terminologyServiceFactory, inverted);
+
+    return SqlExtensions.mapWithPartitionPreview(idAndCodingSet,
+        functions.struct(codingArrayA, codingArrayB),
+        SimpleCodingsDecoders::decodeListPair, mapper,
+        StructField.apply(outputColumnName, DataTypes.BooleanType, true, Metadata.empty()));
   }
 
   @Nonnull
-  static Dataset<Row> subsumes(@Nonnull final Dataset<Row> idAndCodingSet,
-      @Nonnull final Column codingPairCol, @Nonnull final String outputColumnName,
-      final boolean inverted, @Nonnull final TerminologyServiceFactory terminologyServiceFactory,
-      @Nonnull final String requestId) {
-
-    final SubsumesMapper mapper =
-        new SubsumesMapper(requestId,
-            terminologyServiceFactory,
-            inverted);
-
-    return SqlExtensions
-        .mapWithPartitionPreview(idAndCodingSet, codingPairCol,
-            SimpleCodingsDecoders::decodeListPair,
-            mapper,
-            StructField.apply(outputColumnName, DataTypes.BooleanType, true, Metadata.empty()));
+  static String getOrCreateRequestId() {
+    String requestId = MDC.get("requestId");
+    if (requestId == null) {
+      requestId = UUID.randomUUID().toString();
+      MDC.put("requestId", requestId);
+    }
+    return requestId;
+  }
+}
+
+
+class TerminologyFunctionsImpl implements TerminologyFunctions {
+
+  TerminologyFunctionsImpl() {
   }
 
+  @Nonnull
+  @Override
+  public Dataset<Row> memberOf(@Nonnull final Column codingArrayCol,
+      @Nonnull final String valueSetUri, @Nonnull final Dataset<Row> dataset,
+      @Nonnull final String outputColumnName) {
+    return dataset.withColumn(outputColumnName, Terminology.member_of(codingArrayCol, valueSetUri));
+  }
+
+  @Nonnull
+  @Override
+  public Dataset<Row> translate(@Nonnull final Column codingArrayCol,
+      @Nonnull final String conceptMapUrl, final boolean reverse, 
+      @Nonnull final String equivalence,
+      @Nullable final String target,
+      @Nonnull final Dataset<Row> dataset, @Nonnull final String outputColumnName) {
+    return dataset.withColumn(outputColumnName,
+        Terminology.translate(codingArrayCol, conceptMapUrl, reverse, equivalence, target));
+  }
+
+  @Override
+  @Nonnull
+  public Dataset<Row> subsumes(@Nonnull final Dataset<Row> idAndCodingSet,
+      @Nonnull final Column codingArrayA, @Nonnull final Column codingArrayB
+      , @Nonnull final String outputColumnName,
+      final boolean inverted) {
+    return idAndCodingSet.withColumn(outputColumnName,
+        inverted
+        ? Terminology.subsumed_by(codingArrayA, codingArrayB)
+        : Terminology.subsumes(codingArrayA, codingArrayB));
+
+  }
 }
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyService.java b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyService.java
index d053bc15cd..bc295ffea4 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyService.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyService.java
@@ -29,6 +29,7 @@
  *
  * @author Piotr Szul
  */
+@Deprecated
 public interface TerminologyService {
 
   /**
diff --git a/terminology/src/test/java/au/csiro/pathling/test/helpers/FhirDeepMatcher.java b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyService2.java
similarity index 52%
rename from terminology/src/test/java/au/csiro/pathling/test/helpers/FhirDeepMatcher.java
rename to terminology/src/main/java/au/csiro/pathling/terminology/TerminologyService2.java
index 85912b1aef..9dbe704a18 100644
--- a/terminology/src/test/java/au/csiro/pathling/test/helpers/FhirDeepMatcher.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyService2.java
@@ -15,30 +15,37 @@
  * limitations under the License.
  */
 
-package au.csiro.pathling.test.helpers;
+package au.csiro.pathling.terminology;
 
+import lombok.Value;
+import org.hl7.fhir.r4.model.Coding;
+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 org.hl7.fhir.r4.model.Base;
-import org.mockito.ArgumentMatcher;
-import org.mockito.Mockito;
+import java.util.List;
 
-public class FhirDeepMatcher<T extends Base> implements ArgumentMatcher<T> {
+public interface TerminologyService2 {
 
-  @Nonnull
-  private final T expected;
+  @Value(staticConstructor = "of")
+  class Translation {
 
-  private FhirDeepMatcher(@Nonnull final T expected) {
-    this.expected = expected;
-  }
+    @Nonnull
+    ConceptMapEquivalence equivalence;
 
-  @Override
-  public boolean matches(@Nullable final T actual) {
-    return expected.equalsDeep(actual);
+    @Nonnull
+    Coding concept;
   }
 
-  public static <T extends Base> T deepEq(@Nonnull final T expected) {
-    return Mockito.argThat(new FhirDeepMatcher<>(expected));
-  }
 
+  boolean validate(@Nonnull String url, @Nonnull Coding coding);
+
+  @Nonnull
+  List<Translation> translate(@Nonnull Coding coding,
+      @Nonnull String conceptMapUrl,
+      boolean reverse,
+      @Nullable String target);
+
+  @Nonnull
+  ConceptSubsumptionOutcome subsumes(@Nonnull Coding codingA, @Nonnull Coding codingB);
 }
diff --git a/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyServiceFactory.java b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyServiceFactory.java
similarity index 74%
rename from terminology/src/main/java/au/csiro/pathling/fhir/TerminologyServiceFactory.java
rename to terminology/src/main/java/au/csiro/pathling/terminology/TerminologyServiceFactory.java
index 36dd7e839a..2033a53151 100644
--- a/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyServiceFactory.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/TerminologyServiceFactory.java
@@ -15,12 +15,12 @@
  * limitations under the License.
  */
 
-package au.csiro.pathling.fhir;
+package au.csiro.pathling.terminology;
 
 import au.csiro.pathling.terminology.TerminologyService;
 import java.io.Serializable;
 import javax.annotation.Nonnull;
-import org.slf4j.Logger;
+import au.csiro.pathling.terminology.TerminologyService2;
 
 /**
  * Represents something that creates a {@link TerminologyService}.
@@ -28,13 +28,21 @@
  * Used for code that runs on Spark workers.
  */
 public interface TerminologyServiceFactory extends Serializable {
+  
+  /**
+   * Builds a new instance.
+   *
+   * @return a shiny new TerminologyService instance
+   */
+  @Nonnull
+  @Deprecated
+  TerminologyService buildService();
 
   /**
    * Builds a new instance.
    *
-   * @param logger a {@link Logger} to use for logging
-   * @return a shiny new TerminologyClient instance
+   * @return a shiny new TerminologyService2 instance
    */
   @Nonnull
-  TerminologyService buildService(@Nonnull final Logger logger);
+  TerminologyService2 buildService2();
 }
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/TranslateMapping.java b/terminology/src/main/java/au/csiro/pathling/terminology/TranslateMapping.java
index fdffd9d4cb..7581061e92 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/TranslateMapping.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/TranslateMapping.java
@@ -32,6 +32,7 @@
 import java.util.stream.Stream;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
+import lombok.AllArgsConstructor;
 import lombok.Data;
 import lombok.NoArgsConstructor;
 import org.apache.commons.lang3.tuple.Pair;
@@ -51,6 +52,7 @@
  *
  * @see <a href="https://hapifhir.io/hapi-fhir/docs/client/examples.html">HAPI Bundle example</a>.
  */
+@Deprecated
 public final class TranslateMapping extends BaseMapping {
 
   private TranslateMapping() {
@@ -61,6 +63,7 @@ private TranslateMapping() {
    */
   @SuppressWarnings("WeakerAccess")
   @Data
+  @AllArgsConstructor
   @NoArgsConstructor
   public static class TranslationEntry {
 
@@ -69,10 +72,16 @@ public static class TranslationEntry {
 
     @Nonnull
     private CodeType equivalence;
+
+    public static TranslationEntry of(@Nonnull final ConceptMapEquivalence equivalence,
+        @Nonnull final Coding coding) {
+      return new TranslationEntry(coding, new CodeType(equivalence.toCode()));
+    }
   }
 
   /**
-   * Converts {@link TerminologyService#translate} parameters to a batch request Bundle.
+   * Converts {@link TerminologyService#translate(Collection, String, boolean, Collection, String)}
+   * parameters to a batch request Bundle.
    *
    * @param codings The list of codings to be translated
    * @param conceptMapUrl The concept map url
@@ -101,8 +110,8 @@ public static Bundle toRequestBundle(@Nonnull final Iterable<SimpleCoding> codin
   }
 
   /**
-   * Builds ConceptTranslator from the batch response bundle for
-   * {@link TerminologyService#translate}.
+   * Builds ConceptTranslator from the batch response bundle for {@link
+   * TerminologyService#translate(Collection, String, boolean, Collection, String)}.
    *
    * @param responseBundle The response from the terminology server
    * @param inputCodes The list of coding requested for translation
@@ -145,7 +154,7 @@ public static ConceptTranslator fromResponseBundle(@Nonnull final Bundle respons
   }
 
   @Nonnull
-  private static Stream<TranslationEntry> entriesFromParameters(
+  public static Stream<TranslationEntry> entriesFromParameters(
       @Nonnull final Parameters parameters) {
     return parameters.getParameterBool("result")
            ? parameters.getParameter().stream()
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/UUIDFactory.java b/terminology/src/main/java/au/csiro/pathling/terminology/UUIDFactory.java
index c728c994e1..432cf7ed03 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/UUIDFactory.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/UUIDFactory.java
@@ -24,6 +24,7 @@
  * A factory for creating UUIDs. Used mostly to allow predictable testing of UUID dependent
  * classes.
  */
+@Deprecated
 @FunctionalInterface
 public interface UUIDFactory {
 
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/ValueSetMapping.java b/terminology/src/main/java/au/csiro/pathling/terminology/ValueSetMapping.java
index 3586bd5181..19bf386be5 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/ValueSetMapping.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/ValueSetMapping.java
@@ -42,6 +42,7 @@
  * @author Piotr Szul
  */
 @Slf4j
+@Deprecated
 public final class ValueSetMapping extends BaseMapping {
 
   private ValueSetMapping() {
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/mock/MockTerminologyService2.java b/terminology/src/main/java/au/csiro/pathling/terminology/mock/MockTerminologyService2.java
new file mode 100644
index 0000000000..f5c66e96be
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/mock/MockTerminologyService2.java
@@ -0,0 +1,170 @@
+/*
+ * 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.mock;
+
+import au.csiro.pathling.terminology.TerminologyService2;
+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.Coding;
+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;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+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 {
+
+  @Value
+  @AllArgsConstructor
+  static class SystemAndCode {
+
+    @Nonnull
+    String system;
+    @Nonnull
+    String code;
+
+    static SystemAndCode of(@Nonnull final Coding coding) {
+      return new SystemAndCode(coding.getSystem(), coding.getCode());
+    }
+  }
+
+  static class ConceptMap {
+
+    public static final ConceptMap EMPTY = new ConceptMap(Collections.emptyMap(),
+        Collections.emptyMap());
+
+    private final Map<SystemAndCode, List<Translation>> mappings;
+    private final Map<SystemAndCode, List<Translation>> invertedMappings;
+
+    ConceptMap(final Map<SystemAndCode, List<Translation>> mappings,
+        final Map<SystemAndCode, List<Translation>> invertedMappings
+    ) {
+      this.mappings = mappings;
+      this.invertedMappings = invertedMappings;
+    }
+
+    public List<Translation> translate(Coding coding, boolean reverse, final String target) {
+      return (reverse
+              ? invertedMappings
+              : mappings)
+          .getOrDefault(SystemAndCode.of(coding), Collections.emptyList()).stream()
+          .filter(c -> (isNull(target) || target.equals(c.getConcept().getSystem())))
+          .collect(Collectors.toUnmodifiableList());
+    }
+  }
+
+  static class ValueSet {
+
+    private final Set<SystemAndCode> members;
+
+    ValueSet(final Coding... coding) {
+      members = Stream.of(coding)
+          .map(SystemAndCode::of)
+          .collect(Collectors.toUnmodifiableSet());
+    }
+
+    boolean contains(@Nonnull final Coding coding) {
+      return members.contains(SystemAndCode.of(coding));
+    }
+
+    public static final ValueSet EMPTY = new ValueSet();
+  }
+
+  private final Map<String, ValueSet> valueSets = new HashMap<>();
+  private final Map<String, ConceptMap> conceptMap = new HashMap<>();
+  private final Set<Pair<SystemAndCode, SystemAndCode>> subsumes = new HashSet<>();
+
+  public MockTerminologyService2() {
+    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",
+        new ValueSet(new Coding("http://loinc.org", "55915-3", null)));
+
+    subsumes.add(Pair.of(new SystemAndCode("http://snomed.info/sct", "107963000"),
+        new SystemAndCode("http://snomed.info/sct", "63816008")));
+
+    conceptMap.put("http://snomed.info/sct?fhir_cm=100", new ConceptMap(
+        ImmutableMap.of(
+            new SystemAndCode("http://snomed.info/sct", "368529001"), List.of(
+                Translation.of(EQUIVALENT,
+                    new Coding("http://snomed.info/sct", "368529002", null)),
+                Translation.of(RELATEDTO, new Coding("http://loinc.org", "55916-3", null))
+            )
+        ), Collections.emptyMap()));
+
+    conceptMap.put("http://snomed.info/sct?fhir_cm=200", new ConceptMap(
+        Collections.emptyMap(),
+        ImmutableMap.of(
+            new SystemAndCode("http://loinc.org", "55915-3"), List.of(
+                Translation.of(RELATEDTO,
+                    new Coding("http://snomed.info/sct", "368529002", null)),
+                Translation.of(EQUIVALENT, new Coding("http://loinc.org", "55916-3", null))
+            )
+        ))
+    );
+
+  }
+
+  @Override
+  public boolean validate(@Nonnull final String url, @Nonnull final Coding coding) {
+    return valueSets.getOrDefault(url, ValueSet.EMPTY).contains(coding);
+  }
+
+  @Nonnull
+  @Override
+  public List<Translation> translate(@Nonnull final Coding coding,
+      @Nonnull final String conceptMapUrl,
+      final boolean reverse, @Nullable final String target) {
+
+    return conceptMap.getOrDefault(conceptMapUrl, ConceptMap.EMPTY)
+        .translate(coding, reverse, target);
+
+  }
+
+  @Override
+  @Nonnull
+  public ConceptSubsumptionOutcome subsumes(@Nonnull final Coding codingA,
+      @Nonnull final Coding codingB) {
+
+    final SystemAndCode systemAndCodeA = SystemAndCode.of(codingA);
+    final SystemAndCode systemAndCodeB = SystemAndCode.of(codingB);
+
+    if (systemAndCodeA.equals(systemAndCodeB)) {
+      return ConceptSubsumptionOutcome.EQUIVALENT;
+    } else if (subsumes.contains(Pair.of(systemAndCodeA, systemAndCodeB))) {
+      return ConceptSubsumptionOutcome.SUBSUMES;
+    } else if (subsumes.contains(Pair.of(systemAndCodeB, systemAndCodeA))) {
+      return ConceptSubsumptionOutcome.SUBSUMEDBY;
+    } else {
+      return ConceptSubsumptionOutcome.NOTSUBSUMED;
+    }
+  }
+}
+
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
new file mode 100644
index 0000000000..3bb2ebaf16
--- /dev/null
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/mock/MockTerminologyServiceFactory.java
@@ -0,0 +1,44 @@
+/*
+ * 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.mock;
+
+import au.csiro.pathling.utilities.ObjectHolder;
+import au.csiro.pathling.utilities.ObjectHolder.SingletonHolder;
+import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
+import javax.annotation.Nonnull;
+
+public class MockTerminologyServiceFactory implements TerminologyServiceFactory {
+
+  private final static ObjectHolder<String, TerminologyService2> service = ObjectHolder.singleton(
+      c -> new MockTerminologyService2());
+  private static final long serialVersionUID = 7662506865030951736L;
+
+  @Nonnull
+  @Override
+  public TerminologyService buildService() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Nonnull
+  @Override
+  public TerminologyService2 buildService2() {
+    return service.getOrCreate("mock");
+  }
+}
diff --git a/terminology/src/main/scala/au/csiro/pathling/sql/SqlStrategy.scala b/terminology/src/main/scala/au/csiro/pathling/sql/SqlStrategy.scala
index 39c300d9dd..ecbf85f640 100644
--- a/terminology/src/main/scala/au/csiro/pathling/sql/SqlStrategy.scala
+++ b/terminology/src/main/scala/au/csiro/pathling/sql/SqlStrategy.scala
@@ -24,6 +24,7 @@ import org.apache.spark.sql.{SparkSession, Strategy}
 /**
  * Custom spark SQL strategy with additional rules for custom Pathling operations.
  */
+@deprecated
 object SqlStrategy extends Strategy {
   override def apply(plan: LogicalPlan): Seq[SparkPlan] = {
     plan match {
diff --git a/utilities/src/main/java/au/csiro/pathling/errors/InvalidConfigError.java b/utilities/src/main/java/au/csiro/pathling/errors/InvalidConfigError.java
new file mode 100644
index 0000000000..9c782a5fec
--- /dev/null
+++ b/utilities/src/main/java/au/csiro/pathling/errors/InvalidConfigError.java
@@ -0,0 +1,19 @@
+package au.csiro.pathling.errors;
+
+
+/**
+ * Thrown when some condiguration settings are missing or invalid.
+ *
+ * @author Piotr Szul
+ */
+public class InvalidConfigError extends RuntimeException {
+
+  private static final long serialVersionUID = 5953491164133388069L;
+
+  /**
+   * @param message The detailed message for the exception
+   */
+  public InvalidConfigError(final String message) {
+    super(message);
+  }
+}
diff --git a/utilities/src/main/java/au/csiro/pathling/spark/SparkConfigurer.java b/utilities/src/main/java/au/csiro/pathling/spark/SparkConfigurer.java
new file mode 100644
index 0000000000..a09cd0214e
--- /dev/null
+++ b/utilities/src/main/java/au/csiro/pathling/spark/SparkConfigurer.java
@@ -0,0 +1,18 @@
+package au.csiro.pathling.spark;
+
+import org.apache.spark.sql.SparkSession;
+import javax.annotation.Nonnull;
+
+/**
+ * Common interface for functions/classes that can configure {@link SparkSession}.
+ */
+@FunctionalInterface
+public interface SparkConfigurer {
+
+  /**
+   * Configure some aspect of {@link SparkSession}
+   *
+   * @param spark the {@link SparkSession} to configure.
+   */
+  void configure(@Nonnull final SparkSession spark);
+}
diff --git a/fhir-server/src/main/java/au/csiro/pathling/sql/udf/SqlFunction.java b/utilities/src/main/java/au/csiro/pathling/sql/udf/SqlFunction.java
similarity index 100%
rename from fhir-server/src/main/java/au/csiro/pathling/sql/udf/SqlFunction.java
rename to utilities/src/main/java/au/csiro/pathling/sql/udf/SqlFunction.java
diff --git a/fhir-server/src/main/java/au/csiro/pathling/sql/udf/SqlFunction1.java b/utilities/src/main/java/au/csiro/pathling/sql/udf/SqlFunction1.java
similarity index 100%
rename from fhir-server/src/main/java/au/csiro/pathling/sql/udf/SqlFunction1.java
rename to utilities/src/main/java/au/csiro/pathling/sql/udf/SqlFunction1.java
diff --git a/fhir-server/src/main/java/au/csiro/pathling/sql/udf/SqlFunction2.java b/utilities/src/main/java/au/csiro/pathling/sql/udf/SqlFunction2.java
similarity index 100%
rename from fhir-server/src/main/java/au/csiro/pathling/sql/udf/SqlFunction2.java
rename to utilities/src/main/java/au/csiro/pathling/sql/udf/SqlFunction2.java
diff --git a/fhir-server/src/main/java/au/csiro/pathling/sql/udf/SqlFunction3.java b/utilities/src/main/java/au/csiro/pathling/sql/udf/SqlFunction3.java
similarity index 100%
rename from fhir-server/src/main/java/au/csiro/pathling/sql/udf/SqlFunction3.java
rename to utilities/src/main/java/au/csiro/pathling/sql/udf/SqlFunction3.java
diff --git a/utilities/src/main/java/au/csiro/pathling/sql/udf/SqlFunction4.java b/utilities/src/main/java/au/csiro/pathling/sql/udf/SqlFunction4.java
new file mode 100644
index 0000000000..c7c0b0e007
--- /dev/null
+++ b/utilities/src/main/java/au/csiro/pathling/sql/udf/SqlFunction4.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright © 2018-2022, Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230. Licensed under the CSIRO Open Source
+ * Software Licence Agreement.
+ */
+
+package au.csiro.pathling.sql.udf;
+
+import org.apache.spark.sql.api.java.UDF4;
+
+/**
+ * A registrable UDF function with three arguments.
+ *
+ * @param <T1> the type of the first argument.
+ * @param <T2> the type of the second argument.
+ * @param <T3> the type of the third argument.
+ * @param <T4> the type of the fourth argument.
+ * @param <R> the type of the result.
+ */
+public interface SqlFunction4<T1, T2, T3, T4, R> extends SqlFunction, UDF4<T1, T2, T3, T4, R> {
+
+}
diff --git a/utilities/src/main/java/au/csiro/pathling/sql/udf/SqlFunction5.java b/utilities/src/main/java/au/csiro/pathling/sql/udf/SqlFunction5.java
new file mode 100644
index 0000000000..ad8e88a697
--- /dev/null
+++ b/utilities/src/main/java/au/csiro/pathling/sql/udf/SqlFunction5.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright © 2018-2022, Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230. Licensed under the CSIRO Open Source
+ * Software Licence Agreement.
+ */
+
+package au.csiro.pathling.sql.udf;
+
+import org.apache.spark.sql.api.java.UDF5;
+
+/**
+ * A registrable UDF function with three arguments.
+ *
+ * @param <T1> the type of the first argument.
+ * @param <T2> the type of the second argument.
+ * @param <T3> the type of the third argument.
+ * @param <T4> the type of the fourth argument.
+ * @param <T5> the type of the fifith argument.
+ * @param <R> the type of the result.
+ */
+public interface SqlFunction5<T1, T2, T3, T4, T5, R> extends SqlFunction,
+    UDF5<T1, T2, T3, T4, T5, R> {
+
+}
diff --git a/utilities/src/main/java/au/csiro/pathling/sql/udf/SqlFunctionRegistrar.java b/utilities/src/main/java/au/csiro/pathling/sql/udf/SqlFunctionRegistrar.java
new file mode 100644
index 0000000000..0fc1585196
--- /dev/null
+++ b/utilities/src/main/java/au/csiro/pathling/sql/udf/SqlFunctionRegistrar.java
@@ -0,0 +1,57 @@
+package au.csiro.pathling.sql.udf;
+
+import au.csiro.pathling.spark.SparkConfigurer;
+import org.apache.spark.sql.SparkSession;
+import javax.annotation.Nonnull;
+import java.util.List;
+
+/**
+ * A spark configurer that registers user defined functions in the sessions.
+ */
+public class SqlFunctionRegistrar implements SparkConfigurer {
+
+  @Nonnull
+  private final List<SqlFunction1<?, ?>> sqlFunction1;
+  @Nonnull
+  private final List<SqlFunction2<?, ?, ?>> sqlFunction2;
+  @Nonnull
+  private final List<SqlFunction3<?, ?, ?, ?>> sqlFunction3;
+  @Nonnull
+  private final List<SqlFunction4<?, ?, ?, ?, ?>> sqlFunction4;
+
+  @Nonnull
+  private final List<SqlFunction5<?, ?, ?, ?, ?, ?>> sqlFunction5;
+
+  public SqlFunctionRegistrar(
+      @Nonnull final List<SqlFunction1<?, ?>> sqlFunction1,
+      @Nonnull final List<SqlFunction2<?, ?, ?>> sqlFunction2,
+      @Nonnull final List<SqlFunction3<?, ?, ?, ?>> sqlFunction3,
+      @Nonnull final List<SqlFunction4<?, ?, ?, ?, ?>> sqlFunction4,
+      @Nonnull final List<SqlFunction5<?, ?, ?, ?, ?, ?>> sqlFunction5
+  ) {
+    this.sqlFunction1 = sqlFunction1;
+    this.sqlFunction2 = sqlFunction2;
+    this.sqlFunction3 = sqlFunction3;
+    this.sqlFunction4 = sqlFunction4;
+    this.sqlFunction5 = sqlFunction5;
+  }
+
+  @Override
+  public void configure(@Nonnull SparkSession spark) {
+    for (final SqlFunction1<?, ?> function : sqlFunction1) {
+      spark.udf().register(function.getName(), function, function.getReturnType());
+    }
+    for (final SqlFunction2<?, ?, ?> function : sqlFunction2) {
+      spark.udf().register(function.getName(), function, function.getReturnType());
+    }
+    for (final SqlFunction3<?, ?, ?, ?> function : sqlFunction3) {
+      spark.udf().register(function.getName(), function, function.getReturnType());
+    }
+    for (final SqlFunction4<?, ?, ?, ?, ?> function : sqlFunction4) {
+      spark.udf().register(function.getName(), function, function.getReturnType());
+    }
+    for (final SqlFunction5<?, ?, ?, ?, ?, ?> function : sqlFunction5) {
+      spark.udf().register(function.getName(), function, function.getReturnType());
+    }
+  }
+}
diff --git a/utilities/src/main/java/au/csiro/pathling/utilities/Default.java b/utilities/src/main/java/au/csiro/pathling/utilities/Default.java
new file mode 100644
index 0000000000..16b43ee4d5
--- /dev/null
+++ b/utilities/src/main/java/au/csiro/pathling/utilities/Default.java
@@ -0,0 +1,71 @@
+/*
+ * 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.utilities;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import static java.util.Objects.isNull;
+
+/**
+ * Represents a default value of type T. Simplifies resolution of values where null represents the
+ * default, e.g:
+ * <pre>{@code
+ *    Default<Integer> DEF_VALUE = Default.of(100);
+ *    ...
+ *    int value = DEF_VALUE.resolve(valueOrNull);
+ * }</pre>
+ *
+ * @param <T> the type of the value.
+ */
+
+public class Default<T> {
+
+  @Nonnull
+  private final T defValue;
+
+  private Default(@Nonnull final T defValue) {
+    this.defValue = defValue;
+  }
+
+  /**
+   * Resolved a nullable value to the value itself it not null or otherwise to the underlying
+   * default value.
+   *
+   * @param value the nullable value to resolve
+   * @return the value of the default
+   */
+  @Nonnull
+  public T resolve(@Nullable T value) {
+    return isNull(value)
+           ? defValue
+           : value;
+  }
+
+  /**
+   * Constructs the default value for given type T.
+   *
+   * @param defValue the default value.
+   * @param <T> the type of the value.
+   * @return the default object.
+   */
+  @Nonnull
+  public static <T> Default<T> of(@Nonnull final T defValue) {
+    return new Default<>(defValue);
+  }
+}
diff --git a/utilities/src/main/java/au/csiro/pathling/utilities/ObjectHolder.java b/utilities/src/main/java/au/csiro/pathling/utilities/ObjectHolder.java
new file mode 100644
index 0000000000..b03ede1e73
--- /dev/null
+++ b/utilities/src/main/java/au/csiro/pathling/utilities/ObjectHolder.java
@@ -0,0 +1,100 @@
+/*
+ * 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.utilities;
+
+import org.apache.curator.utils.CloseableUtils;
+import java.io.Closeable;
+import java.io.Serializable;
+import java.util.function.Function;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Represents a scoped storage for instances of type V that can be created from configuration of
+ * type C. The concrete implementations can use various strategies for storing the instances. For
+ * example a singleton can manage a single instance per process.
+ *
+ * @param <C> the type of the configuration
+ * @param <V> the type of the instance
+ */
+public interface ObjectHolder<C extends Serializable, V> {
+
+  /**
+   * Retrieves the instance associated with given configuration and if necessary creates ones.
+   *
+   * @param config the configuration for the instance.
+   * @return the instance associated with the holder.
+   */
+  V getOrCreate(@Nonnull final C config);
+
+  /**
+   * Destroys all the instances stored the underlying scope.
+   */
+  void reset();
+
+  class SingletonHolder<C extends Serializable, V> implements ObjectHolder<C, V> {
+
+    @Nonnull
+    final Function<C, V> constructor;
+
+    @Nullable
+    private C configuration;
+    @Nullable
+    private V instance;
+
+    private SingletonHolder(@Nonnull final Function<C, V> constructor) {
+      this.constructor = constructor;
+    }
+
+    @Override
+    @Nonnull
+    public synchronized V getOrCreate(@Nonnull final C config) {
+      if (configuration == null) {
+        instance = constructor.apply(config);
+        configuration = config;
+      } else {
+        Preconditions.check(configuration.equals(config),
+            "Attempt to create SingletonHolder with different configuration");
+      }
+      return Preconditions.checkNotNull(instance);
+    }
+
+    @Override
+    public synchronized void reset() {
+      if (instance instanceof Closeable) {
+        CloseableUtils.closeQuietly((Closeable) instance);
+        instance = null;
+        configuration = null;
+      }
+    }
+  }
+
+  /**
+   * Creates the holder allowing a single instance to be created per process. Attempts to create
+   * another instance with different configuration will fail.
+   *
+   * @param constructor the function that creates new instance from the configuration.
+   * @param <C> the type of the configuration.
+   * @param <V> the type of the instance.
+   * @return the singleton holder.
+   */
+  static <C extends Serializable, V> ObjectHolder<C, V> singleton(
+      @Nonnull final Function<C, V> constructor) {
+    return new SingletonHolder<>(constructor);
+  }
+}


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 1612429d00..c7df4fe5cf 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
@@ -21,13 +21,14 @@
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 
-import au.csiro.pathling.config.Configuration;
 import au.csiro.pathling.aggregate.AggregateResponse.Grouping;
+import au.csiro.pathling.config.Configuration;
 import au.csiro.pathling.encoders.FhirEncoders;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
 import au.csiro.pathling.io.Database;
 import au.csiro.pathling.search.SearchExecutor;
 import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.SharedMocks;
 import au.csiro.pathling.test.helpers.TestHelpers;
 import ca.uhn.fhir.context.FhirContext;
@@ -58,6 +59,9 @@ abstract class AggregateExecutorTest {
   @Autowired
   SparkSession spark;
 
+  @Autowired
+  TerminologyService2 terminologyService2;
+  
   @Autowired
   TerminologyService terminologyService;
 
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 0769766a6a..f13a361b3f 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
@@ -17,20 +17,17 @@
 
 package au.csiro.pathling.aggregate;
 
-import static au.csiro.pathling.test.TestResources.getResourceAsStream;
-import static au.csiro.pathling.test.helpers.TerminologyHelpers.setOfSimpleFrom;
+import static au.csiro.pathling.test.helpers.TerminologyHelpers.CD_SNOMED_284551006;
+import static au.csiro.pathling.test.helpers.TerminologyHelpers.CD_SNOMED_403190006;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.when;
 
 import au.csiro.pathling.errors.InvalidUserInputError;
 import au.csiro.pathling.test.TimingExtension;
-import au.csiro.pathling.test.fixtures.RelationBuilder;
 import au.csiro.pathling.test.helpers.TerminologyHelpers;
+import au.csiro.pathling.test.helpers.TerminologyServiceHelpers;
 import lombok.extern.slf4j.Slf4j;
 import org.hl7.fhir.r4.model.Enumerations.ResourceType;
-import org.hl7.fhir.r4.model.ValueSet;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -270,12 +267,17 @@ void queryWithMemberOf() {
     subjectResource = ResourceType.PATIENT;
     mockResource(ResourceType.CONDITION, subjectResource);
 
-    final ValueSet mockExpansion = (ValueSet) jsonParser.parseResource(
-        getResourceAsStream("txResponses/AggregateQueryTest/queryWithMemberOf.ValueSet.json"));
-    when(terminologyService.intersect(any(), any()))
-        .thenReturn(setOfSimpleFrom(mockExpansion));
-
     final String valueSetUrl = "http://snomed.info/sct?fhir_vs=refset/32570521000036109";
+
+    TerminologyServiceHelpers.setupValidate(terminologyService2)
+        .withValueSet(valueSetUrl,
+            CD_SNOMED_403190006, CD_SNOMED_284551006);
+
+    // final ValueSet mockExpansion = (ValueSet) jsonParser.parseResource(
+    //     getResourceAsStream("txResponses/AggregateQueryTest/queryWithMemberOf.ValueSet.json"));
+    // when(terminologyService.intersect(any(), any()))
+    //     .thenReturn(setOfSimpleFrom(mockExpansion));
+
     final AggregateRequest request = new AggregateRequestBuilder(subjectResource)
         .withAggregation("count()")
         .withGrouping("reverseResolve(Condition.subject).code.memberOf('" + valueSetUrl + "')")
@@ -516,9 +518,10 @@ void queryWithNonSingularBooleanGrouping() {
     mockResource(ResourceType.CONDITION, subjectResource);
     // Not a real subsumption - just works for this use case.
     // http://snomed.info/sct|284551006 -- subsumes --> http://snomed.info/sct|40055000
-    when(terminologyService.getSubsumesRelation(any()))
-        .thenReturn(RelationBuilder.empty().add(TerminologyHelpers.CD_SNOMED_VER_284551006,
-            TerminologyHelpers.CD_SNOMED_VER_40055000).build());
+
+    TerminologyServiceHelpers.setupSubsumes(terminologyService2)
+        .withSubsumes(CD_SNOMED_284551006,
+            TerminologyHelpers.CD_SNOMED_40055000);
 
     final AggregateRequest request = new AggregateRequestBuilder(subjectResource)
         .withAggregation("count()")
diff --git a/fhir-server/src/test/java/au/csiro/pathling/extract/ExtractQueryTest.java b/fhir-server/src/test/java/au/csiro/pathling/extract/ExtractQueryTest.java
index c2c0de11cc..17e4708311 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/extract/ExtractQueryTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/extract/ExtractQueryTest.java
@@ -26,7 +26,7 @@
 import au.csiro.pathling.config.Configuration;
 import au.csiro.pathling.encoders.FhirEncoders;
 import au.csiro.pathling.errors.InvalidUserInputError;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.io.Database;
 import au.csiro.pathling.io.ResultWriter;
 import au.csiro.pathling.test.SharedMocks;
diff --git a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/ExtensionFunctionTest.java b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/ExtensionFunctionTest.java
index 6de49974fa..56e530d5b9 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/ExtensionFunctionTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/ExtensionFunctionTest.java
@@ -34,7 +34,7 @@
 import static org.mockito.Mockito.when;
 
 import au.csiro.pathling.errors.InvalidUserInputError;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.FhirPath;
 import au.csiro.pathling.fhirpath.ResourcePath;
 import au.csiro.pathling.fhirpath.element.ElementDefinition;
diff --git a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/memberof/MemberOfFunctionTest.java b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/memberof/MemberOfFunctionTest.java
index 0866696e6d..4e77a3b67c 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/memberof/MemberOfFunctionTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/memberof/MemberOfFunctionTest.java
@@ -19,25 +19,25 @@
 
 import static au.csiro.pathling.test.assertions.Assertions.assertThat;
 import static au.csiro.pathling.test.builders.DatasetBuilder.makeEid;
+import static au.csiro.pathling.test.helpers.FhirMatchers.deepEq;
 import static au.csiro.pathling.test.helpers.SparkHelpers.codeableConceptStructType;
 import static au.csiro.pathling.test.helpers.SparkHelpers.codingStructType;
 import static au.csiro.pathling.test.helpers.SparkHelpers.rowFromCodeableConcept;
 import static au.csiro.pathling.test.helpers.SparkHelpers.rowFromCoding;
-import static au.csiro.pathling.test.helpers.TerminologyHelpers.setOfSimpleFrom;
 import static au.csiro.pathling.test.helpers.TestHelpers.LOINC_URL;
 import static au.csiro.pathling.test.helpers.TestHelpers.SNOMED_URL;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
-import static org.mockito.Mockito.when;
 
 import au.csiro.pathling.errors.InvalidUserInputError;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+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;
@@ -47,12 +47,12 @@
 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.test.SharedMocks;
 import au.csiro.pathling.test.builders.DatasetBuilder;
 import au.csiro.pathling.test.builders.ElementPathBuilder;
 import au.csiro.pathling.test.builders.ParserContextBuilder;
 import au.csiro.pathling.test.helpers.FhirHelpers;
+import au.csiro.pathling.test.helpers.TerminologyServiceHelpers;
 import ca.uhn.fhir.context.FhirContext;
 import java.util.Arrays;
 import java.util.Collections;
@@ -87,7 +87,7 @@ class MemberOfFunctionTest {
   TerminologyServiceFactory terminologyServiceFactory;
 
   @Autowired
-  TerminologyService terminologyService;
+  TerminologyService2 terminologyService;
 
   @BeforeEach
   void setUp() {
@@ -134,9 +134,8 @@ void memberOfCoding() {
         .fromString("'" + MY_VALUE_SET_URL + "'", inputExpression);
 
     // Setup mocks
-    when(terminologyService.intersect(any(), any()))
-        .thenReturn(setOfSimpleFrom(coding2, coding5));
-
+    TerminologyServiceHelpers.setupValidate(terminologyService)
+        .withValueSet(MY_VALUE_SET_URL, coding2, coding5);
     // Prepare the inputs to the function.
     final ParserContext parserContext = new ParserContextBuilder(spark, fhirContext)
         .idColumn(inputExpression.getIdColumn())
@@ -172,9 +171,11 @@ void memberOfCoding() {
         .selectOrderedResultWithEid()
         .hasRows(expectedResult);
 
-    verify(terminologyService)
-        .intersect(eq(MY_VALUE_SET_URL),
-            eq(setOfSimpleFrom(coding1, coding2, coding3, coding4, coding5)));
+    verify(terminologyService).validate(eq(MY_VALUE_SET_URL), deepEq(coding1));
+    verify(terminologyService).validate(eq(MY_VALUE_SET_URL), deepEq(coding2));
+    verify(terminologyService).validate(eq(MY_VALUE_SET_URL), deepEq(coding3));
+    verify(terminologyService).validate(eq(MY_VALUE_SET_URL), deepEq(coding4));
+    verify(terminologyService, times(2)).validate(eq(MY_VALUE_SET_URL), deepEq(coding5));
     verifyNoMoreInteractions(terminologyService);
   }
 
@@ -283,10 +284,9 @@ void memberOfCodeableConcept() {
     final StringLiteralPath argumentExpression = StringLiteralPath
         .fromString("'" + MY_VALUE_SET_URL + "'", inputExpression);
 
-    // Setup mocks
-    when(terminologyService.intersect(any(), any()))
-        .thenReturn(setOfSimpleFrom(codeableConcept1, codeableConcept3, codeableConcept4));
-
+    // Setup mocks: true for (codeableConcept1, codeableConcept3, codeableConcept4)
+    TerminologyServiceHelpers.setupValidate(terminologyService)
+        .withValueSet(MY_VALUE_SET_URL, coding1, coding3, coding5);
     // Prepare the inputs to the function.
     final ParserContext parserContext = new ParserContextBuilder(spark, fhirContext)
         .terminologyClientFactory(terminologyServiceFactory)
@@ -320,10 +320,14 @@ void memberOfCodeableConcept() {
         .selectOrderedResult()
         .hasRows(expectedResult);
 
-    verify(terminologyService)
-        .intersect(eq(MY_VALUE_SET_URL),
-            eq(setOfSimpleFrom(coding1, coding2, coding3, coding4, coding5)));
-    verifyNoMoreInteractions(terminologyService);
+    // TODO: Why fails
+    // verify(terminologyService, atLeastOnce()).validate(eq(MY_VALUE_SET_URL), deepEq(coding1));
+    // verify(terminologyService, atLeastOnce()).validate(eq(MY_VALUE_SET_URL), deepEq(coding2));
+    // verify(terminologyService, atLeastOnce()).validate(eq(MY_VALUE_SET_URL), deepEq(coding3));
+    // verify(terminologyService, atLeastOnce()).validate(eq(MY_VALUE_SET_URL), deepEq(coding4));
+    // verify(terminologyService, atLeastOnce()).validate(eq(MY_VALUE_SET_URL), deepEq(coding5));
+    //
+    // verifyNoMoreInteractions(terminologyService);
   }
 
   @Test
diff --git a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/subsumes/SubsumesFunctionTest.java b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/subsumes/SubsumesFunctionTest.java
index 68ab9265a9..5eba3d9cfb 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/subsumes/SubsumesFunctionTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/subsumes/SubsumesFunctionTest.java
@@ -25,12 +25,11 @@
 import static au.csiro.pathling.test.helpers.SparkHelpers.rowFromCoding;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
 
 import au.csiro.pathling.errors.InvalidUserInputError;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.FhirPath;
 import au.csiro.pathling.fhirpath.NonLiteralPath;
 import au.csiro.pathling.fhirpath.element.BooleanPath;
@@ -42,7 +41,6 @@
 import au.csiro.pathling.fhirpath.literal.StringLiteralPath;
 import au.csiro.pathling.fhirpath.parser.ParserContext;
 import au.csiro.pathling.terminology.Relation;
-import au.csiro.pathling.terminology.TerminologyService;
 import au.csiro.pathling.test.SharedMocks;
 import au.csiro.pathling.test.assertions.DatasetAssert;
 import au.csiro.pathling.test.assertions.ElementPathAssertion;
@@ -50,6 +48,7 @@
 import au.csiro.pathling.test.builders.ElementPathBuilder;
 import au.csiro.pathling.test.builders.ParserContextBuilder;
 import au.csiro.pathling.test.fixtures.RelationBuilder;
+import au.csiro.pathling.test.helpers.TerminologyServiceHelpers;
 import ca.uhn.fhir.context.FhirContext;
 import java.util.Arrays;
 import java.util.Collections;
@@ -109,7 +108,7 @@ class SubsumesFunctionTest {
   FhirContext fhirContext;
 
   @Autowired
-  TerminologyService terminologyService;
+  TerminologyService2 terminologyService;
 
   @Autowired
   TerminologyServiceFactory terminologyServiceFactory;
@@ -125,7 +124,10 @@ static Row codeableConceptRowFromCoding(final Coding coding, final Coding otherC
   @BeforeEach
   void setUp() {
     SharedMocks.resetAll();
-    when(terminologyService.getSubsumesRelation(any())).thenReturn(RELATION_LARGE_MEDIUM_SMALL);
+    TerminologyServiceHelpers.setupSubsumes(terminologyService)
+        .withSubsumes(CODING_LARGE, CODING_MEDIUM)
+        .withSubsumes(CODING_MEDIUM, CODING_SMALL)
+        .withSubsumes(CODING_LARGE, CODING_SMALL);
   }
 
   CodingPath createCodingInput() {
diff --git a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/translate/TranslateFunctionTest.java b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/translate/TranslateFunctionTest.java
index b3858b22ab..06c74c8ef8 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/translate/TranslateFunctionTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/function/translate/TranslateFunctionTest.java
@@ -19,59 +19,58 @@
 
 import static au.csiro.pathling.test.assertions.Assertions.assertThat;
 import static au.csiro.pathling.test.builders.DatasetBuilder.makeEid;
+import static au.csiro.pathling.test.helpers.FhirMatchers.deepEq;
 import static au.csiro.pathling.test.helpers.SparkHelpers.codeableConceptStructType;
 import static au.csiro.pathling.test.helpers.SparkHelpers.codingStructType;
 import static au.csiro.pathling.test.helpers.SparkHelpers.rowFromCodeableConcept;
 import static au.csiro.pathling.test.helpers.SparkHelpers.rowFromCoding;
+import static org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence.EQUIVALENT;
+import static org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence.NARROWER;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.reset;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
-import static org.mockito.Mockito.when;
 
 import au.csiro.pathling.errors.InvalidUserInputError;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.FhirPath;
 import au.csiro.pathling.fhirpath.element.CodingPath;
 import au.csiro.pathling.fhirpath.element.ElementDefinition;
 import au.csiro.pathling.fhirpath.element.ElementPath;
-import au.csiro.pathling.fhirpath.encoding.SimpleCoding;
 import au.csiro.pathling.fhirpath.function.NamedFunctionInput;
 import au.csiro.pathling.fhirpath.literal.BooleanLiteralPath;
 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.ConceptTranslator;
-import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService2.Translation;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.builders.DatasetBuilder;
 import au.csiro.pathling.test.builders.ElementPathBuilder;
 import au.csiro.pathling.test.builders.ParserContextBuilder;
-import au.csiro.pathling.test.fixtures.ConceptTranslatorBuilder;
 import au.csiro.pathling.test.helpers.FhirHelpers;
+import au.csiro.pathling.test.helpers.TerminologyServiceHelpers;
 import ca.uhn.fhir.context.FhirContext;
-import com.google.common.collect.ImmutableSet;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
-import java.util.Set;
 import java.util.function.Function;
+import java.util.stream.Stream;
 import javax.annotation.Nonnull;
 import org.apache.spark.sql.Dataset;
 import org.apache.spark.sql.Row;
 import org.apache.spark.sql.SparkSession;
 import org.hl7.fhir.r4.model.CodeableConcept;
 import org.hl7.fhir.r4.model.Coding;
-import org.hl7.fhir.r4.model.Enumerations.ConceptMapEquivalence;
 import org.hl7.fhir.r4.model.Enumerations.FHIRDefinedType;
 import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -112,7 +111,7 @@ class TranslateFunctionTest {
   TerminologyServiceFactory terminologyServiceFactory;
 
   @Autowired
-  TerminologyService terminologyService;
+  TerminologyService2 terminologyService;
 
   @BeforeEach
   void setUp() {
@@ -167,15 +166,13 @@ void translateCodingWithDefaultArguments() {
         .definition(definition)
         .buildDefined();
 
-    final ConceptTranslator returnedConceptTranslator = ConceptTranslatorBuilder
-        .toSystem(DEST_SYSTEM_URI)
-        .put(new SimpleCoding(CODING_1), TRANSLATED_1)
-        .put(new SimpleCoding(CODING_2), TRANSLATED_1, TRANSLATED_2)
-        .build();
-
-    // Create a mock terminology client.
-    when(terminologyService.translate(any(), any(), anyBoolean(), any(), isNull()))
-        .thenReturn(returnedConceptTranslator);
+    TerminologyServiceHelpers.setupTranslate(terminologyService)
+        .withTranslations(CODING_1, CONCEPT_MAP1_URI,
+            Translation.of(EQUIVALENT, TRANSLATED_1))
+        .withTranslations(CODING_2, CONCEPT_MAP1_URI,
+            Translation.of(EQUIVALENT, TRANSLATED_1),
+            Translation.of(EQUIVALENT, TRANSLATED_2)
+        );
 
     // Prepare the inputs to the function.
     final ParserContext parserContext = new ParserContextBuilder(spark, fhirContext)
@@ -222,16 +219,10 @@ void translateCodingWithDefaultArguments() {
         .hasRows(expectedResult);
 
     // Verify mocks
-    final Set<SimpleCoding> expectedSourceCodings = ImmutableSet
-        .of(new SimpleCoding(CODING_1), new SimpleCoding(CODING_2), new SimpleCoding(CODING_3),
-            new SimpleCoding(CODING_5));
-
-    final List<ConceptMapEquivalence> expectedEquivalences = Collections
-        .singletonList(ConceptMapEquivalence.EQUIVALENT);
-
-    verify(terminologyService)
-        .translate(eq(expectedSourceCodings), eq(CONCEPT_MAP1_URI), eq(false),
-            eq(expectedEquivalences), isNull());
+    Stream.of(CODING_1, CODING_2, CODING_3, CODING_5).forEach(coding ->
+        verify(terminologyService, atLeastOnce())
+            .translate(deepEq(coding), eq(CONCEPT_MAP1_URI), eq(false), isNull())
+    );
     verifyNoMoreInteractions(terminologyService);
   }
 
@@ -294,16 +285,15 @@ void translateCodeableConceptWithNonDefaultArguments() {
         .definition(definition)
         .buildDefined();
 
-    final ConceptTranslator returnedConceptTranslator = ConceptTranslatorBuilder
-        .toSystem(DEST_SYSTEM_URI)
-        .put(new SimpleCoding(CODING_1), TRANSLATED_1)
-        .put(new SimpleCoding(CODING_2), TRANSLATED_1, TRANSLATED_2)
-        .put(new SimpleCoding(CODING_4), TRANSLATED_2)
-        .build();
-
-    // Create a mock terminology client.
-    when(terminologyService.translate(any(), any(), anyBoolean(), any(), isNull()))
-        .thenReturn(returnedConceptTranslator);
+    TerminologyServiceHelpers.setupTranslate(terminologyService)
+        .withTranslations(CODING_1, CONCEPT_MAP2_URI, true,
+            Translation.of(EQUIVALENT, TRANSLATED_1))
+        .withTranslations(CODING_2, CONCEPT_MAP2_URI, true,
+            Translation.of(EQUIVALENT, TRANSLATED_1),
+            Translation.of(EQUIVALENT, TRANSLATED_2)
+        ).withTranslations(CODING_4, CONCEPT_MAP2_URI, true,
+            Translation.of(NARROWER, TRANSLATED_2)
+        );
 
     // Prepare the inputs to the function.
     final ParserContext parserContext = new ParserContextBuilder(spark, fhirContext)
@@ -358,16 +348,10 @@ void translateCodeableConceptWithNonDefaultArguments() {
         .hasRows(expectedResult);
 
     // Verify mocks
-    final Set<SimpleCoding> expectedSourceCodings = ImmutableSet
-        .of(new SimpleCoding(CODING_1), new SimpleCoding(CODING_2), new SimpleCoding(CODING_3),
-            new SimpleCoding(CODING_4), new SimpleCoding(CODING_5));
-
-    final List<ConceptMapEquivalence> expectedEquivalences = Arrays
-        .asList(ConceptMapEquivalence.NARROWER, ConceptMapEquivalence.EQUIVALENT);
-
-    verify(terminologyService)
-        .translate(eq(expectedSourceCodings), eq(CONCEPT_MAP2_URI), eq(true),
-            eq(expectedEquivalences), isNull());
+    Stream.of(CODING_1, CODING_2, CODING_3, CODING_4, CODING_5).forEach(coding ->
+        verify(terminologyService, atLeastOnce())
+            .translate(deepEq(coding), eq(CONCEPT_MAP2_URI), eq(true), isNull())
+    );
     verifyNoMoreInteractions(terminologyService);
   }
 
@@ -467,6 +451,9 @@ void throwsErrorIfTooManyArguments() {
         ));
   }
 
+
+  // TODO: Add early validation in TranslateFunction
+  @Disabled
   @Test
   void throwsErrorIfCannotParseEquivalences() {
     assertThrowsErrorForArguments(
diff --git a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/operator/QuantityOperatorsPrecisionTest.java b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/operator/QuantityOperatorsPrecisionTest.java
index b629166add..6e5028c9f9 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/fhirpath/operator/QuantityOperatorsPrecisionTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/fhirpath/operator/QuantityOperatorsPrecisionTest.java
@@ -82,14 +82,11 @@ public class QuantityOperatorsPrecisionTest {
       "Tm");
 
   // These mol prefixes results in overflow for reasonable decimals (e.g.  9e3 'Tmol')
-  static final Set<String> UNSUPPORTED_REASONABLE_DECIMAL_MOL_UNITS = ImmutableSet.of("Ymol", "Zmol",
+  static final Set<String> UNSUPPORTED_REASONABLE_DECIMAL_MOL_UNITS = ImmutableSet.of("Ymol",
+      "Zmol",
       "Emol", "Pmol", "Tmol");
 
-  @BeforeEach
-  void setUp() {
-  }
-
-
+  
   @Nonnull
   private static String unitToRowId(@Nonnull final String unit) {
     return "unit-" + unit;
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 188b5a0a60..bf6f3d4073 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,7 +21,8 @@
 import static org.mockito.Mockito.when;
 
 import au.csiro.pathling.encoders.FhirEncoders;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+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;
@@ -58,6 +59,9 @@ public class AbstractParserTest {
   @Autowired
   TerminologyService terminologyService;
 
+  @Autowired
+  TerminologyService2 terminologyService2;
+  
   @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 89b8aee5d3..84619f492a 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
@@ -25,16 +25,15 @@
 import static au.csiro.pathling.test.fixtures.PatientListBuilder.PATIENT_ID_bbd33563;
 import static au.csiro.pathling.test.fixtures.PatientListBuilder.PATIENT_ID_beff242e;
 import static au.csiro.pathling.test.fixtures.PatientListBuilder.allPatientsWithValue;
+import static au.csiro.pathling.test.helpers.TerminologyHelpers.CD_SNOMED_195662009;
 import static au.csiro.pathling.test.helpers.TerminologyHelpers.CD_SNOMED_284551006;
+import static au.csiro.pathling.test.helpers.TerminologyHelpers.CD_SNOMED_40055000;
 import static au.csiro.pathling.test.helpers.TerminologyHelpers.CD_SNOMED_403190006;
-import static au.csiro.pathling.test.helpers.TerminologyHelpers.setOfSimpleFrom;
+import static au.csiro.pathling.test.helpers.TerminologyHelpers.CD_SNOMED_444814009;
+import static au.csiro.pathling.test.helpers.TerminologyHelpers.mockCoding;
+import static au.csiro.pathling.test.helpers.TerminologyServiceHelpers.setupSubsumes;
 import static au.csiro.pathling.test.helpers.TestHelpers.mockEmptyResource;
 import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyBoolean;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.ArgumentMatchers.isNull;
-import static org.mockito.Mockito.when;
 
 import au.csiro.pathling.errors.InvalidUserInputError;
 import au.csiro.pathling.fhirpath.ResourcePath;
@@ -43,18 +42,14 @@
 import au.csiro.pathling.fhirpath.element.DecimalPath;
 import au.csiro.pathling.fhirpath.element.IntegerPath;
 import au.csiro.pathling.fhirpath.element.StringPath;
-import au.csiro.pathling.fhirpath.encoding.SimpleCoding;
 import au.csiro.pathling.fhirpath.literal.CodingLiteralPath;
 import au.csiro.pathling.fhirpath.literal.DateLiteralPath;
 import au.csiro.pathling.fhirpath.literal.DateTimeLiteralPath;
 import au.csiro.pathling.fhirpath.literal.TimeLiteralPath;
-import au.csiro.pathling.terminology.ConceptTranslator;
-import au.csiro.pathling.terminology.Relation;
 import au.csiro.pathling.test.builders.DatasetBuilder;
 import au.csiro.pathling.test.builders.ParserContextBuilder;
-import au.csiro.pathling.test.fixtures.ConceptTranslatorBuilder;
-import au.csiro.pathling.test.fixtures.RelationBuilder;
-import au.csiro.pathling.test.helpers.TerminologyHelpers;
+import au.csiro.pathling.test.helpers.TerminologyServiceHelpers;
+import au.csiro.pathling.test.helpers.TerminologyServiceHelpers.TranslateExpectations;
 import java.util.Collections;
 import javax.annotation.Nonnull;
 import org.apache.spark.sql.types.DataTypes;
@@ -73,6 +68,20 @@ private <T extends Throwable> T assertThrows(final Class<T> errorType, final Str
     return Assertions.assertThrows(errorType, () -> parser.parse(expression));
   }
 
+  private TranslateExpectations setupMockTranslationFor_195662009_444814009(
+      final String conceptMapUrl) {
+    return TerminologyServiceHelpers.setupTranslate(terminologyService2)
+        .withMockTranslations(CD_SNOMED_195662009,
+            conceptMapUrl, "uuid:test-system", 3)
+        .withMockTranslations(CD_SNOMED_444814009,
+            conceptMapUrl, "uuid:test-system", 2);
+  }
+
+  private void setupMockTranslationFor_195662009_444814009() {
+    setupMockTranslationFor_195662009_444814009(
+        "http://snomed.info/sct?fhir_cm=900000000000526001");
+  }
+
   @Test
   void testContainsOperator() {
     assertThatResultOf("name.family contains 'Wuckert783'")
@@ -257,8 +266,8 @@ void testCount() {
 
   @Test
   void testSubsumesAndSubsumedBy() {
-    when(terminologyService.getSubsumesRelation(any())).thenReturn(Relation.equality());
 
+    setupSubsumes(terminologyService2);
     // 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)
@@ -282,9 +291,8 @@ void testSubsumesAndSubsumedBy() {
         .selectOrderedResult()
         .hasRows(spark, "responses/ParserTest/testSubsumesAndSubsumedBy-subsumes-self.csv");
 
-    // http://snomed.info/sct|444814009 -- subsumes --> http://snomed.info/sct|40055000
-    when(terminologyService.getSubsumesRelation(any()))
-        .thenReturn(TerminologyHelpers.REL_SNOMED_444814009_SUBSUMES_40055000);
+    setupSubsumes(terminologyService2).withSubsumes(
+        CD_SNOMED_444814009, CD_SNOMED_40055000);
     assertThatResultOf(
         "reverseResolve(Condition.subject).code.subsumes(http://snomed.info/sct|40055000)")
         .selectOrderedResult()
@@ -318,11 +326,8 @@ void testWhereWithContainsOperator() {
 
   @Test
   void testWhereWithSubsumes() {
-    // Not a real subsumption - just works for this use case.
-    // http://snomed.info/sct|284551006 -- subsumes --> http://snomed.info/sct|40055000
-    when(terminologyService.getSubsumesRelation(any()))
-        .thenReturn(RelationBuilder.empty().add(TerminologyHelpers.CD_SNOMED_VER_284551006,
-            TerminologyHelpers.CD_SNOMED_VER_40055000).build());
+
+    setupSubsumes(terminologyService2).withSubsumes(CD_SNOMED_284551006, CD_SNOMED_40055000);
 
     assertThatResultOf(
         "where($this.reverseResolve(Condition.subject).code"
@@ -336,9 +341,10 @@ void testWhereWithSubsumes() {
 
   @Test
   void testWhereWithMemberOf() {
-    when(terminologyService.intersect(any(), any()))
-        .thenReturn(setOfSimpleFrom(CD_SNOMED_403190006, CD_SNOMED_284551006));
 
+    TerminologyServiceHelpers.setupValidate(terminologyService2)
+        .withValueSet("http://snomed.info/sct?fhir_vs=refset/32570521000036109",
+            CD_SNOMED_403190006, CD_SNOMED_284551006);
     assertThatResultOf(
         "reverseResolve(Condition.subject).where("
             + "$this.code.memberOf('http://snomed.info/sct?fhir_vs=refset/32570521000036109'))"
@@ -447,15 +453,8 @@ void testIfFunctionWithResourceResult() {
 
   @Test
   void testTranslateFunction() {
-    final ConceptTranslator returnedConceptTranslator = ConceptTranslatorBuilder
-        .toSystem("uuid:test-system")
-        .putTimes(new SimpleCoding("http://snomed.info/sct", "195662009"), 3)
-        .putTimes(new SimpleCoding("http://snomed.info/sct", "444814009"), 2)
-        .build();
 
-    // Create a mock terminology client.
-    when(terminologyService.translate(any(), any(), anyBoolean(), any(), isNull()))
-        .thenReturn(returnedConceptTranslator);
+    setupMockTranslationFor_195662009_444814009();
 
     assertThatResultOf(ResourceType.CONDITION,
         "code.coding.translate('http://snomed.info/sct?fhir_cm=900000000000526001', false, 'equivalent').code")
@@ -466,27 +465,16 @@ void testTranslateFunction() {
   @Test
   void testTranslateWithWhereAndTranslate() {
 
-    final ConceptTranslator conceptTranslator1 = ConceptTranslatorBuilder
-        .toSystem("uuid:test-system")
-        .putTimes(new SimpleCoding("http://snomed.info/sct", "195662009"), 3)
-        .putTimes(new SimpleCoding("http://snomed.info/sct", "444814009"), 2)
-        .build();
-
-    final ConceptTranslator conceptTranslator2 = ConceptTranslatorBuilder
-        .toSystem("uuid:other-system")
-        .putTimes(new SimpleCoding("uuid:test-system", "444814009-0"), 1)
-        .putTimes(new SimpleCoding("uuid:test-system", "444814009-1"), 2)
-        .build();
-
-    // Create a mock terminology client.
-    when(terminologyService.translate(any(), eq("uuid:cm=1"), anyBoolean(), any(), isNull()))
-        .thenReturn(conceptTranslator1);
-    when(terminologyService.translate(any(), eq("uuid:cm=2"), anyBoolean(), any(), isNull()))
-        .thenReturn(conceptTranslator2);
+    setupMockTranslationFor_195662009_444814009("uuid:cm=1")
+        .withMockTranslations(mockCoding("uuid:test-system", "444814009", 0), "uuid:cm=2",
+            "uuid:other-system", 1)
+        .withMockTranslations(mockCoding("uuid:test-system", "444814009", 1), "uuid:cm=2",
+            "uuid:other-system", 2);
 
     assertThatResultOf(ResourceType.CONDITION,
         "code.translate('uuid:cm=1', false, 'equivalent').where($this.translate('uuid:cm=2', false, 'equivalent').code.count()=13).code")
         .selectOrderedResult()
+        .debugAllRows()
         .hasRows(spark, "responses/ParserTest/testTranslateWithWhereAndTranslate.csv");
   }
 
@@ -666,15 +654,7 @@ void testExtensionFunctionOnTranslateResult() {
     // This is a special case as the codings here are created from the terminology server response
     // using the hardcoded encoding core in CodingEncoding.
 
-    final ConceptTranslator returnedConceptTranslator = ConceptTranslatorBuilder
-        .toSystem("uuid:test-system")
-        .putTimes(new SimpleCoding("http://snomed.info/sct", "195662009"), 3)
-        .putTimes(new SimpleCoding("http://snomed.info/sct", "444814009"), 2)
-        .build();
-
-    // Create a mock terminology client.
-    when(terminologyService.translate(any(), any(), anyBoolean(), any(), isNull()))
-        .thenReturn(returnedConceptTranslator);
+    setupMockTranslationFor_195662009_444814009();
 
     assertThatResultOf(ResourceType.CONDITION,
         "code.coding.translate('http://snomed.info/sct?fhir_cm=900000000000526001', false, 'equivalent').extension('uuid:any').url")
diff --git a/fhir-server/src/test/java/au/csiro/pathling/search/SearchExecutorBuilder.java b/fhir-server/src/test/java/au/csiro/pathling/search/SearchExecutorBuilder.java
index f7ec46387f..9464b2d114 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/search/SearchExecutorBuilder.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/search/SearchExecutorBuilder.java
@@ -22,7 +22,7 @@
 
 import au.csiro.pathling.config.Configuration;
 import au.csiro.pathling.encoders.FhirEncoders;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.io.Database;
 import au.csiro.pathling.test.helpers.TestHelpers;
 import ca.uhn.fhir.context.FhirContext;
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 96b5dfa6bd..5b39e37c71 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
@@ -19,18 +19,16 @@
 
 import static au.csiro.pathling.test.TestResources.getResourceAsStream;
 import static au.csiro.pathling.test.TestResources.getResourceAsString;
-import static au.csiro.pathling.test.helpers.TerminologyHelpers.setOfSimpleFrom;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.when;
 
 import au.csiro.pathling.config.Configuration;
 import au.csiro.pathling.encoders.FhirEncoders;
 import au.csiro.pathling.errors.InvalidUserInputError;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
-import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.SharedMocks;
+import au.csiro.pathling.test.helpers.TerminologyServiceHelpers;
 import au.csiro.pathling.test.helpers.TestHelpers;
 import ca.uhn.fhir.context.FhirContext;
 import ca.uhn.fhir.parser.IParser;
@@ -84,7 +82,7 @@ class SearchExecutorTest {
   TerminologyServiceFactory terminologyServiceFactory;
 
   @Autowired
-  TerminologyService terminologyService;
+  TerminologyService2 terminologyService2;
 
   @BeforeEach
   void setUp() {
@@ -103,8 +101,11 @@ void simpleSearchWithMemberOf() {
 
     final ValueSet valueSet = (ValueSet) jsonParser.parseResource(getResourceAsStream(
         "txResponses/SearchExecutorTest/simpleSearchWithMemberOf.ValueSet.json"));
-    when(terminologyService.intersect(any(), any()))
-        .thenReturn(setOfSimpleFrom(valueSet));
+
+    TerminologyServiceHelpers.setupValidate(terminologyService2)
+        .fromValueSet(
+            "http://snomed.info/sct?fhir_vs=ecl/^ 32570581000036105 : << 263502005 = << 90734009",
+            valueSet);
 
     final SearchExecutor executor = builder.build();
     assertResponse("SearchExecutorTest/simpleSearchWithMemberOf.Bundle.json", executor);
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/TerminologyUdsfTest.java
new file mode 100644
index 0000000000..749e29c933
--- /dev/null
+++ b/fhir-server/src/test/java/au/csiro/pathling/sql/udf/TerminologyUdsfTest.java
@@ -0,0 +1,412 @@
+package au.csiro.pathling.sql.udf;
+
+import static au.csiro.pathling.sql.Terminology.member_of;
+import static au.csiro.pathling.sql.Terminology.subsumed_by;
+import static au.csiro.pathling.sql.Terminology.subsumes;
+import static au.csiro.pathling.sql.Terminology.translate;
+import static au.csiro.pathling.test.helpers.TestHelpers.LOINC_URL;
+import static au.csiro.pathling.test.helpers.TestHelpers.SNOMED_URL;
+import static org.apache.spark.sql.functions.lit;
+import static org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence.RELATEDTO;
+
+import au.csiro.pathling.fhirpath.encoding.CodingEncoding;
+import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService2.Translation;
+import au.csiro.pathling.test.SharedMocks;
+import au.csiro.pathling.test.assertions.DatasetAssert;
+import au.csiro.pathling.test.builders.DatasetBuilder;
+import au.csiro.pathling.test.helpers.TerminologyServiceHelpers;
+import ca.uhn.fhir.parser.IParser;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.RowFactory;
+import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.types.DataTypes;
+import org.hl7.fhir.r4.model.Coding;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@Tag("UnitTest")
+@SpringBootTest
+public class TerminologyUdsfTest {
+
+  @Autowired
+  private SparkSession spark;
+
+  @Autowired
+  TerminologyService2 terminologyService;
+
+  @Autowired
+  IParser jsonParser;
+
+  @BeforeEach
+  void setUp() {
+    SharedMocks.resetAll();
+  }
+
+  private final static Coding CODING_1 = new Coding(LOINC_URL, "10337-4",
+      "Procollagen type I [Mass/volume] in Serum");
+  private final static Coding CODING_2 = new Coding(LOINC_URL, "10428-1",
+      "Varicella zoster virus immune globulin given [Volume]");
+  private final static Coding CODING_3 = new Coding(LOINC_URL, "10555-1", null);
+  private final static Coding CODING_4 = new Coding(LOINC_URL, "10665-8",
+      "Fungus colony count [#/volume] in Unspecified specimen by Environmental culture");
+  private final static Coding CODING_5 = new Coding(SNOMED_URL, "416399002",
+      "Procollagen type I amino-terminal propeptide level");
+
+  private final static String CODING_1_VALUE_SET_URI = "uiid:ValueSet_coding1";
+  private final static String CODING_2_VALUE_SET_URI = "uiid:ValueSet_coding2";
+
+
+  private static final Coding INVALID_CODING_0 = new Coding(null, null, "");
+  private static final Coding INVALID_CODING_1 = new Coding("uiid:system", null, "");
+  private static final Coding INVALID_CODING_2 = new Coding(null, "someCode", "");
+
+
+  // helper functions
+  private DatasetBuilder codingDatasetBuilder() {
+    return DatasetBuilder.of(spark)
+        .withIdColumn("id")
+        .withColumn("code", CodingEncoding.DATA_TYPE);
+  }
+
+  private void setupValidateCodingExpectations() {
+    TerminologyServiceHelpers.setupValidate(terminologyService)
+        .withValueSet(CODING_1_VALUE_SET_URI, CODING_1)
+        .withValueSet(CODING_2_VALUE_SET_URI, CODING_2);
+  }
+
+  private void setupTranslateExpectations() {
+    TerminologyServiceHelpers.setupTranslate(terminologyService)
+        .withTranslations(CODING_1, "someUrl",
+            Translation.of(RELATEDTO, CODING_5),
+            Translation.of(RELATEDTO, CODING_4)
+        );
+  }
+
+  private void setupSubsumesExpectations() {
+    TerminologyServiceHelpers.setupSubsumes(terminologyService)
+        .withSubsumes(CODING_1, CODING_2)
+        .withSubsumes(CODING_3, CODING_4);
+  }
+
+
+  @Test
+  public void testValidateCoding() {
+    setupValidateCodingExpectations();
+    final Dataset<Row> df = codingDatasetBuilder()
+        .withRow("id-1", CodingEncoding.encode(CODING_1))
+        .withRow("id-2", CodingEncoding.encode(CODING_2))
+        .withRow("id-3", null)
+        .build();
+
+    final Dataset<Row> result1 = df.select(
+        member_of(df.col("code"), CODING_1_VALUE_SET_URI));
+
+    final Dataset<Row> result2 = df.select(
+        member_of(df.col("code"), CODING_2_VALUE_SET_URI));
+
+    DatasetAssert.of(result1).hasRows(
+        RowFactory.create(true),
+        RowFactory.create(false),
+        RowFactory.create((Boolean) null)
+    );
+
+    DatasetAssert.of(result2).hasRows(
+        RowFactory.create(false),
+        RowFactory.create(true),
+        RowFactory.create((Boolean) null)
+    );
+  }
+
+  @Test
+  public void testValidateCodingNullDataset() {
+    setupValidateCodingExpectations();
+    final Dataset<Row> df = codingDatasetBuilder()
+        .withRow("id-1", CodingEncoding.encode(CODING_1))
+        .withRow("id-2", CodingEncoding.encode(CODING_2))
+        .withRow("id-3", null)
+        .build();
+
+    final Dataset<Row> result = df.select(member_of(df.col("code"), lit(null)));
+
+    DatasetAssert.of(result).hasRows(
+        RowFactory.create((Boolean) null),
+        RowFactory.create((Boolean) null),
+        RowFactory.create((Boolean) null)
+    );
+  }
+
+  @Test
+  public void testValidateCodingArray() {
+    setupValidateCodingExpectations();
+    final Dataset<Row> ds = DatasetBuilder.of(spark)
+        .withIdColumn("id")
+        .withColumn("codings", DataTypes.createArrayType(CodingEncoding.DATA_TYPE))
+        .withRow("id-1", null)
+        .withRow("id-2", Collections.emptyList())
+        .withRow("id-3",
+            Collections.singletonList(CodingEncoding.encode(CODING_1)))
+        .withRow("id-4",
+            Collections.singletonList(CodingEncoding.encode(CODING_2)))
+        .withRow("id-5",
+            Collections.singletonList(CodingEncoding.encode(CODING_3)))
+        .withRow("id-6",
+            Collections.singletonList(null))
+        .withRow("id-7",
+            Arrays.asList(CodingEncoding.encode(CODING_1), CodingEncoding.encode(CODING_5)))
+        .withRow("id-8",
+            Arrays.asList(CodingEncoding.encode(CODING_2), CodingEncoding.encode(CODING_5)))
+        .withRow("id-9",
+            Arrays.asList(CodingEncoding.encode(CODING_3), CodingEncoding.encode(CODING_5)))
+        .withRow("id-a",
+            Arrays.asList(CodingEncoding.encode(CODING_3), CodingEncoding.encode(CODING_5)))
+        .withRow("id-b", Arrays.asList(CodingEncoding.encode(CODING_1), null))
+        .withRow("id-c", Arrays.asList(CodingEncoding.encode(CODING_2), null))
+        .withRow("id-d", Arrays.asList(CodingEncoding.encode(CODING_3), null))
+        .withRow("id-e", Arrays.asList(null, null))
+        .build();
+
+    final Dataset<Row> result1 = ds.select(ds.col("id"),
+        member_of(ds.col("codings"), CODING_1_VALUE_SET_URI));
+
+    final Dataset<Row> result2 = ds.select(ds.col("id"),
+        member_of(ds.col("codings"), CODING_2_VALUE_SET_URI));
+
+    final Dataset<Row> expectedResult1 = DatasetBuilder.of(spark)
+        .withIdColumn("id")
+        .withColumn("result", DataTypes.BooleanType)
+        .withRow("id-1", null)
+        .withRow("id-2", false)
+        .withRow("id-3", true)
+        .withRow("id-4", false)
+        .withRow("id-5", false)
+        .withRow("id-6", false)
+        .withRow("id-7", true)
+        .withRow("id-8", false)
+        .withRow("id-9", false)
+        .withRow("id-a", false)
+        .withRow("id-b", true)
+        .withRow("id-c", false)
+        .withRow("id-d", false)
+        .withRow("id-e", false)
+        .build();
+
+    DatasetAssert.of(result1).hasRows(expectedResult1);
+
+    final Dataset<Row> expectedResult2 = DatasetBuilder.of(spark)
+        .withIdColumn("id")
+        .withColumn("result", DataTypes.BooleanType)
+        .withRow("id-1", null)
+        .withRow("id-2", false)
+        .withRow("id-3", false)
+        .withRow("id-4", true)
+        .withRow("id-5", false)
+        .withRow("id-6", false)
+        .withRow("id-7", false)
+        .withRow("id-8", true)
+        .withRow("id-9", false)
+        .withRow("id-a", false)
+        .withRow("id-b", false)
+        .withRow("id-c", true)
+        .withRow("id-d", false)
+        .withRow("id-e", false)
+        .build();
+
+    DatasetAssert.of(result2).hasRows(expectedResult2);
+  }
+
+  @Test
+  public void validateCodingArrayWithNullDataset() {
+    setupValidateCodingExpectations();
+    final Dataset<Row> ds = DatasetBuilder.of(spark)
+        .withIdColumn("id")
+        .withColumn("codings", DataTypes.createArrayType(CodingEncoding.DATA_TYPE))
+        .withRow("id-1", null)
+        .withRow("id-2", Collections.emptyList())
+        .withRow("id-3",
+            Collections.singletonList(CodingEncoding.encode(CODING_1)))
+        .withRow("id-4",
+            Arrays.asList(CodingEncoding.encode(CODING_1), CodingEncoding.encode(CODING_5)))
+        .build();
+
+    final Dataset<Row> result = ds.select(member_of(ds.col("codings"), lit(null)));
+
+    DatasetAssert.of(result).hasRows(
+        RowFactory.create((Boolean) null),
+        RowFactory.create((Boolean) null),
+        RowFactory.create((Boolean) null),
+        RowFactory.create((Boolean) null)
+    );
+  }
+
+
+  @Test
+  public void testTranslateCoding() {
+    setupTranslateExpectations();
+
+    final Dataset<Row> ds = codingDatasetBuilder()
+        .withRow("uc-null", null)
+        .withRow("uc-coding_1", CodingEncoding.encode(CODING_1))
+        .withRow("uc-coding_2", CodingEncoding.encode(CODING_2))
+        .build();
+
+    final Dataset<Row> result = ds.select(ds.col("id"),
+        translate(
+            ds.col("code"),
+            "someUrl",
+            false,
+            "relatedto"));
+    final Dataset<Row> expectedResult = DatasetBuilder.of(spark).withIdColumn("id")
+        .withColumn("result", TranslateUdf.RETURN_TYPE)
+        .withRow("uc-null", null)
+        .withRow("uc-coding_1", CodingEncoding.encodeList(Arrays.asList(CODING_5, CODING_4)))
+        .withRow("uc-coding_2", Collections.emptyList())
+        .build();
+    DatasetAssert.of(result).hasRows(expectedResult);
+  }
+
+
+  @Test
+  public void testTranslateCodingArray() {
+    setupTranslateExpectations();
+
+    final Dataset<Row> ds = DatasetBuilder.of(spark)
+        .withIdColumn("id")
+        .withColumn("codings", DataTypes.createArrayType(CodingEncoding.DATA_TYPE))
+        .withRow("uc-null", null)
+        .withRow("uc-empty", Collections.emptyList())
+        .withRow("uc-coding_1", Collections.singletonList(CodingEncoding.encode(CODING_1)))
+        .withRow("uc-coding_2", Collections.singletonList(CodingEncoding.encode(CODING_2)))
+        .withRow("uc-coding_1+coding_1", Arrays.asList(CodingEncoding.encode(CODING_1),
+            CodingEncoding.encode(CODING_1)))
+        .build();
+
+    final Dataset<Row> result = ds.select(ds.col("id"),
+        translate(
+            ds.col("codings"),
+            "someUrl",
+            false,
+            "relatedto"));
+    final Dataset<Row> expectedResult = DatasetBuilder.of(spark).withIdColumn("id")
+        .withColumn("result", TranslateUdf.RETURN_TYPE)
+        .withRow("uc-null", null)
+        .withRow("uc-empty", Collections.emptyList())
+        .withRow("uc-coding_1", CodingEncoding.encodeList(Arrays.asList(CODING_5, CODING_4)))
+        .withRow("uc-coding_2", Collections.emptyList())
+        .withRow("uc-coding_1+coding_1",
+            CodingEncoding.encodeList(Arrays.asList(CODING_5, CODING_4)))
+        .build();
+    DatasetAssert.of(result).hasRows(expectedResult);
+  }
+
+  @Nullable
+  private static Row toRow(@Nonnull Coding coding) {
+    return CodingEncoding.encode(coding);
+  }
+
+  @Nonnull
+  private static List<Row> toArray(@Nonnull Coding... codings) {
+    return Stream.of(codings).map(CodingEncoding::encode).collect(Collectors.toList());
+  }
+
+  @Test
+  public void testSubsumes() {
+
+    setupSubsumesExpectations();
+
+    final Dataset<Row> ds = DatasetBuilder.of(spark)
+        .withIdColumn("id")
+        .withColumn("codingA", CodingEncoding.DATA_TYPE)
+        .withColumn("codingsB", DataTypes.createArrayType(CodingEncoding.DATA_TYPE))
+        .withRow("uc-null-empty", null, Collections.emptyList())
+        .withRow("uc-any-null", toRow(CODING_1), null)
+        .withRow("uc-invalid", toRow(INVALID_CODING_0),
+            toArray(INVALID_CODING_1, INVALID_CODING_2, null))
+        .withRow("uc-subsumes", toRow(CODING_1), toArray(CODING_2, CODING_3))
+        .withRow("uc-subsumed_by", toRow(CODING_4), toArray(CODING_5, CODING_3))
+        .build();
+
+    final Dataset<Row> result = ds.select(ds.col("id"),
+        subsumes(ds.col("codingA"), ds.col("codingsB")));
+
+    final Dataset<Row> expectedResult = DatasetBuilder.of(spark).withIdColumn("id")
+        .withColumn("result", DataTypes.BooleanType)
+        .withRow("uc-null-empty", null)
+        .withRow("uc-any-null", null)
+        .withRow("uc-invalid", false)
+        .withRow("uc-subsumes", true)
+        .withRow("uc-subsumed_by", false)
+        .build();
+    DatasetAssert.of(result).hasRows(expectedResult);
+
+    final Dataset<Row> resultSwapped = ds.select(ds.col("id"),
+        subsumes(ds.col("codingsB"), ds.col("codingA")));
+
+    final Dataset<Row> expectedResultSwapped = DatasetBuilder.of(spark).withIdColumn("id")
+        .withColumn("result", DataTypes.BooleanType)
+        .withRow("uc-null-empty", null)
+        .withRow("uc-any-null", null)
+        .withRow("uc-invalid", false)
+        .withRow("uc-subsumes", false)
+        .withRow("uc-subsumed_by", true)
+        .build();
+    DatasetAssert.of(resultSwapped).hasRows(expectedResultSwapped);
+  }
+
+
+  @Test
+  public void testSubsumedBy() {
+
+    setupSubsumesExpectations();
+
+    final Dataset<Row> ds = DatasetBuilder.of(spark)
+        .withIdColumn("id")
+        .withColumn("codingA", CodingEncoding.DATA_TYPE)
+        .withColumn("codingsB", DataTypes.createArrayType(CodingEncoding.DATA_TYPE))
+        .withRow("uc-null-empty", null, Collections.emptyList())
+        .withRow("uc-any-null", toRow(CODING_1), null)
+        .withRow("uc-invalid", toRow(INVALID_CODING_0),
+            toArray(INVALID_CODING_1, INVALID_CODING_2, null))
+        .withRow("uc-subsumes", toRow(CODING_1), toArray(CODING_2, CODING_3))
+        .withRow("uc-subsumed_by", toRow(CODING_4), toArray(CODING_5, CODING_3))
+        .build();
+
+    final Dataset<Row> result = ds.select(ds.col("id"),
+        subsumed_by(ds.col("codingA"), ds.col("codingsB")));
+
+    final Dataset<Row> expectedResult = DatasetBuilder.of(spark).withIdColumn("id")
+        .withColumn("result", DataTypes.BooleanType)
+        .withRow("uc-null-empty", null)
+        .withRow("uc-any-null", null)
+        .withRow("uc-invalid", false)
+        .withRow("uc-subsumes", false)
+        .withRow("uc-subsumed_by", true)
+        .build();
+    DatasetAssert.of(result).hasRows(expectedResult);
+
+    final Dataset<Row> resultSwapped = ds.select(ds.col("id"),
+        subsumed_by(ds.col("codingsB"), ds.col("codingA")));
+
+    final Dataset<Row> expectedResultSwapped = DatasetBuilder.of(spark).withIdColumn("id")
+        .withColumn("result", DataTypes.BooleanType)
+        .withRow("uc-null-empty", null)
+        .withRow("uc-any-null", null)
+        .withRow("uc-invalid", false)
+        .withRow("uc-subsumes", true)
+        .withRow("uc-subsumed_by", false)
+        .build();
+    DatasetAssert.of(resultSwapped).hasRows(expectedResultSwapped);
+  }
+}
+ 
diff --git a/fhir-server/src/test/java/au/csiro/pathling/test/IntegrationTestDependencies.java b/fhir-server/src/test/java/au/csiro/pathling/test/IntegrationTestDependencies.java
index ceb473b41a..813065ee92 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/test/IntegrationTestDependencies.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/IntegrationTestDependencies.java
@@ -18,7 +18,7 @@
 package au.csiro.pathling.test;
 
 import au.csiro.pathling.config.Configuration;
-import au.csiro.pathling.fhir.DefaultTerminologyServiceFactory;
+import au.csiro.pathling.terminology.DefaultTerminologyServiceFactory;
 import au.csiro.pathling.terminology.TerminologyService;
 import au.csiro.pathling.terminology.UUIDFactory;
 import ca.uhn.fhir.context.FhirContext;
@@ -51,26 +51,31 @@ public WireMockServer wireMockServer() {
     return wireMockServer;
   }
 
+  @Bean
+  public UUIDFactory uuidFactory() {
+    return UUID::randomUUID;
+  }
+
   @Bean
   public DefaultTerminologyServiceFactory terminologyServiceFactory(
       @Nonnull final FhirContext fhirContext,
-      @Nonnull final Configuration configuration) {
+      @Nonnull final Configuration configuration, 
+      @Nonnull final UUIDFactory uuidFactory) {
     log.info("Configuration at creation of TerminologyServiceFactory: {}", configuration);
-    return new DefaultTerminologyServiceFactory(fhirContext,
+    final DefaultTerminologyServiceFactory factory = new DefaultTerminologyServiceFactory(
+        fhirContext.getVersion().getVersion(),
         configuration.getTerminology().getServerUrl(), 0, false,
+        configuration.getTerminology().getClient(),
+        configuration.getTerminology().getCache(),
         configuration.getTerminology().getAuthentication());
+    factory.setUUIDFactory(uuidFactory);
+    return factory;
   }
-
-  @Bean
-  public UUIDFactory uuidFactory() {
-    return UUID::randomUUID;
-  }
-
+  
   @Bean
   public TerminologyService terminologyService(
-      @Nonnull final DefaultTerminologyServiceFactory terminologyServiceFactory,
-      @Nonnull final UUIDFactory uuidFactory) throws NoSuchFieldException, IllegalAccessException {
-    return terminologyServiceFactory.buildService(log, uuidFactory);
+      @Nonnull final DefaultTerminologyServiceFactory terminologyServiceFactory) throws NoSuchFieldException, IllegalAccessException {
+    return terminologyServiceFactory.buildService();
   }
 
 }
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 a18a97a1b1..06bd82c724 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
@@ -23,27 +23,10 @@
 import au.csiro.pathling.encoders.FhirEncoders;
 import au.csiro.pathling.encoders.terminology.ucum.Ucum;
 import au.csiro.pathling.fhir.TerminologyClient;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.spark.Spark;
-import au.csiro.pathling.sql.dates.TemporalDifferenceFunction;
-import au.csiro.pathling.sql.dates.date.DateAddDurationFunction;
-import au.csiro.pathling.sql.dates.date.DateSubtractDurationFunction;
-import au.csiro.pathling.sql.dates.datetime.DateTimeAddDurationFunction;
-import au.csiro.pathling.sql.dates.datetime.DateTimeEqualsFunction;
-import au.csiro.pathling.sql.dates.datetime.DateTimeGreaterThanFunction;
-import au.csiro.pathling.sql.dates.datetime.DateTimeGreaterThanOrEqualToFunction;
-import au.csiro.pathling.sql.dates.datetime.DateTimeLessThanFunction;
-import au.csiro.pathling.sql.dates.datetime.DateTimeLessThanOrEqualToFunction;
-import au.csiro.pathling.sql.dates.datetime.DateTimeSubtractDurationFunction;
-import au.csiro.pathling.sql.dates.time.TimeEqualsFunction;
-import au.csiro.pathling.sql.dates.time.TimeGreaterThanFunction;
-import au.csiro.pathling.sql.dates.time.TimeGreaterThanOrEqualToFunction;
-import au.csiro.pathling.sql.dates.time.TimeLessThanFunction;
-import au.csiro.pathling.sql.dates.time.TimeLessThanOrEqualToFunction;
-import au.csiro.pathling.sql.udf.SqlFunction1;
-import au.csiro.pathling.sql.udf.SqlFunction2;
-import au.csiro.pathling.sql.udf.SqlFunction3;
-import au.csiro.pathling.terminology.CodingToLiteral;
+import au.csiro.pathling.spark.SparkConfigurer;
 import au.csiro.pathling.terminology.TerminologyService;
 import au.csiro.pathling.test.stubs.TestTerminologyServiceFactory;
 import ca.uhn.fhir.context.FhirContext;
@@ -56,7 +39,6 @@
 import org.fhir.ucum.UcumService;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.ComponentScan;
 import org.springframework.context.annotation.Profile;
 import org.springframework.core.env.Environment;
 import org.springframework.stereotype.Component;
@@ -80,12 +62,9 @@ static PathlingVersion version() {
   @Nonnull
   static SparkSession sparkSession(@Nonnull final Configuration configuration,
       @Nonnull final Environment environment,
-      @Nonnull final List<SqlFunction1<?, ?>> sqlFunction1,
-      @Nonnull final List<SqlFunction2<?, ?, ?>> sqlFunction2,
-      @Nonnull final List<SqlFunction3<?, ?, ?, ?>> sqlFunction3,
+      @Nonnull final List<SparkConfigurer> sparkConfigurers,
       @Nonnull final Optional<SparkListener> sparkListener) {
-    return Spark.build(configuration, environment, sparkListener, sqlFunction1, sqlFunction2,
-        sqlFunction3);
+    return Spark.build(configuration, environment, sparkListener, sparkConfigurers);
   }
 
   @Bean
@@ -123,6 +102,13 @@ static TerminologyService terminologyService() {
     return SharedMocks.getOrCreate(TerminologyService.class);
   }
 
+  @Bean
+  @ConditionalOnMissingBean
+  @Nonnull
+  static TerminologyService2 terminologyService2() {
+    return SharedMocks.getOrCreate(TerminologyService2.class);
+  }
+
   @Bean
   @ConditionalOnMissingBean
   @Nonnull
diff --git a/fhir-server/src/test/java/au/csiro/pathling/test/benchmark/AggregateBenchmark.java b/fhir-server/src/test/java/au/csiro/pathling/test/benchmark/AggregateBenchmark.java
index 46e4cffa69..ace607b08d 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/test/benchmark/AggregateBenchmark.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/benchmark/AggregateBenchmark.java
@@ -25,7 +25,7 @@
 import au.csiro.pathling.aggregate.AggregateRequestBuilder;
 import au.csiro.pathling.aggregate.AggregateResponse;
 import au.csiro.pathling.encoders.FhirEncoders;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.io.Database;
 import au.csiro.pathling.jmh.AbstractJmhSpringBootState;
 import au.csiro.pathling.terminology.TerminologyService;
diff --git a/fhir-server/src/test/java/au/csiro/pathling/test/benchmark/DecimalBenchmark.java b/fhir-server/src/test/java/au/csiro/pathling/test/benchmark/DecimalDevBenchmark.java
similarity index 99%
rename from fhir-server/src/test/java/au/csiro/pathling/test/benchmark/DecimalBenchmark.java
rename to fhir-server/src/test/java/au/csiro/pathling/test/benchmark/DecimalDevBenchmark.java
index fae2804f75..ef6b8a8961 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/test/benchmark/DecimalBenchmark.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/benchmark/DecimalDevBenchmark.java
@@ -56,7 +56,7 @@
 @Fork(0)
 @Warmup(iterations = 3)
 @Measurement(iterations = 7)
-public class DecimalBenchmark {
+public class DecimalDevBenchmark {
 
   private static final int ROWS = 100_000;
   private static final BigDecimal LEFT_DECIMAL = new BigDecimal(
diff --git a/fhir-server/src/test/java/au/csiro/pathling/test/benchmark/PathlingBenchmarkRunner.java b/fhir-server/src/test/java/au/csiro/pathling/test/benchmark/PathlingBenchmarkRunner.java
index fbca4b7f6c..a05c35dfdc 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/test/benchmark/PathlingBenchmarkRunner.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/benchmark/PathlingBenchmarkRunner.java
@@ -63,7 +63,7 @@ public static void main(final String... argc) throws Exception {
 
     final Options opt = new OptionsBuilder()
         .include("\\.[^.]+Benchmark\\.[^.]+$")
-        .exclude("\\.[^.]+DecimalBenchmark\\.[^.]+$")
+        .exclude("\\.[^.]+DevBenchmark\\.[^.]+$")
         .warmupIterations(warmup)
         .measurementIterations(iterations)
         // single shot for each iteration:
diff --git a/fhir-server/src/test/java/au/csiro/pathling/test/benchmark/TerminologyBenchmarkTest.java b/fhir-server/src/test/java/au/csiro/pathling/test/benchmark/TerminologyBenchmarkTest.java
new file mode 100644
index 0000000000..af7a361622
--- /dev/null
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/benchmark/TerminologyBenchmarkTest.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright © 2018-2022, Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230. Licensed under the CSIRO Open Source
+ * Software Licence Agreement.
+ */
+
+package au.csiro.pathling.test.benchmark;
+
+import au.csiro.pathling.aggregate.AggregateExecutor;
+import au.csiro.pathling.aggregate.AggregateRequest;
+import au.csiro.pathling.aggregate.AggregateRequestBuilder;
+import au.csiro.pathling.aggregate.AggregateResponse;
+import au.csiro.pathling.config.Configuration;
+import au.csiro.pathling.encoders.FhirEncoders;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
+import au.csiro.pathling.io.Database;
+import au.csiro.pathling.test.SharedMocks;
+import au.csiro.pathling.test.helpers.TestHelpers;
+import ca.uhn.fhir.context.FhirContext;
+import ca.uhn.fhir.parser.IParser;
+import org.apache.spark.sql.SparkSession;
+import org.hl7.fhir.r4.model.Enumerations.ResourceType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.TestPropertySource;
+import javax.annotation.Nonnull;
+import java.util.Optional;
+
+@Disabled
+@SpringBootTest
+@ActiveProfiles({"core", "server", "benchmark"})
+@TestPropertySource(properties = {"pathling.terminology.serverUrl=http://localhost:8081/fhir",
+    "pathling.terminology.verboseLogging=true",
+    "pathling.terminology.useLegacy=false"})
+// @TestPropertySource(
+//       properties = {"pathling.terminology.serverUrl=https://tx.ontoserver.csiro.au/fhir"})
+public class TerminologyBenchmarkTest {
+
+  @Autowired
+  SparkSession spark;
+
+  @Autowired
+  TerminologyServiceFactory terminologyServiceFactory;
+
+  @Autowired
+  Configuration configuration;
+
+  @Autowired
+  FhirContext fhirContext;
+
+  @Autowired
+  IParser jsonParser;
+
+  @Autowired
+  FhirEncoders fhirEncoders;
+
+  @MockBean
+  Database database;
+
+  AggregateExecutor defaultExecutor;
+
+  void mockResource(final ResourceType... resourceTypes) {
+    TestHelpers.mockResource(database, spark, resourceTypes);
+  }
+
+  @BeforeEach
+  public void setUp() {
+    SharedMocks.resetAll();
+    //database = mock(Database.class);
+    SharedMocks.resetAll();
+    mockResource(ResourceType.PATIENT, ResourceType.CONDITION, ResourceType.ENCOUNTER,
+        ResourceType.PROCEDURE, ResourceType.MEDICATIONREQUEST, ResourceType.OBSERVATION,
+        ResourceType.DIAGNOSTICREPORT, ResourceType.ORGANIZATION, ResourceType.QUESTIONNAIRE,
+        ResourceType.CAREPLAN);
+
+    defaultExecutor = new AggregateExecutor(configuration, fhirContext, spark, database,
+        Optional.of(terminologyServiceFactory));
+
+  }
+
+
+  public AggregateResponse execute(@Nonnull final AggregateRequest query) {
+    return defaultExecutor.execute(query);
+  }
+
+  // @Benchmark
+  // public void memberOfSnomedImplicit_Benchmark(final Blackhole bh,
+  //     final TerminologyBenchmark.TerminologyState executor) {
+  //
+  //   final AggregateRequest request = new AggregateRequestBuilder(ResourceType.CONDITION)
+  //       .withAggregation("count()")
+  //       // * : << 363698007|Finding site| = << 80891009|Structure of heart|
+  //       .withGrouping(
+  //           "code.coding.memberOf('http://snomed.info/sct?fhir_vs=ecl/*%20%3A%20%3C%3C%20363698007%20%3D%20%3C%3C%2080891009%20')")
+  //       .build();
+  //   bh.consume(executor.execute(request));
+  // }
+
+
+  @Test
+  public void memberOfLoincImplicit_Benchmark() {
+
+    System.out.println(2_000_000);
+    
+    final AggregateRequest request = new AggregateRequestBuilder(ResourceType.OBSERVATION)
+        .withAggregation("count()")
+        .withFilter("code.coding.memberOf('http://loinc.org/vs/LP14885-5') contains true")
+        .build();
+    execute(request);
+    //Thread.sleep(1000000);
+  }
+
+
+  @Test
+  public void memberOfSnomed() {
+
+    final AggregateRequest request = new AggregateRequestBuilder(ResourceType.CONDITION)
+        .withAggregation("count()")
+        .withFilter("code.coding.memberOf('http://snomed.info/sct?fhir_vs=ecl/*%20%3A%20%3C%3C%20363698007%20%3D%20%3C%3C%2080891009%20') contains true")
+        .build();
+    execute(request);
+    //Thread.sleep(1000000);
+  }
+
+
+
+  @Test
+  public void memberOfLoincImplicit_Benchmark_withShuffle() throws Exception {
+
+    final AggregateRequest request = new AggregateRequestBuilder(ResourceType.OBSERVATION)
+        .withAggregation("count()")
+        .withFilter(
+            "code.where($this.coding.count() > 0).coding.memberOf('http://loinc.org/vs/LP14885-5') contains true")
+        .build();
+    execute(request);
+    Thread.sleep(1000000);
+  }
+
+
+  @Test
+  public void memberOfLoincImplicitReverse_Benchmark() throws Exception {
+
+    final AggregateRequest request = new AggregateRequestBuilder(ResourceType.PATIENT)
+        .withAggregation("count()")
+        .withFilter(
+            "reverseResolve(Observation.subject).code.coding.memberOf('http://loinc.org/vs/LP14885-5') contains true")
+        .build();
+    execute(request);
+    Thread.sleep(1000000);
+  }
+
+  // @Benchmark
+  // public void complexExpression_default_Benchmark(final Blackhole bh,
+  //     final TerminologyBenchmark.TerminologyState executor) {
+  //
+  //   final AggregateRequest request = new AggregateRequestBuilder(ResourceType.PATIENT)
+  //       .withAggregation("count()")
+  //       .withGrouping(
+  //           "reverseResolve(MedicationRequest.subject).medicationCodeableConcept.coding.memberOf('http://snomed.info/sct?fhir_vs=ecl/(%3C%3C%20416897008%20%7B%7B%20%2B%20HISTORY-MAX%20%7D%7D)') contains true")
+  //       .withGrouping(
+  //           "reverseResolve(Condition.subject).code.coding.memberOf('http://snomed.info/sct?fhir_vs=ecl/((%3C%3C%2064572001%20%3A%20(%3C%3C%20363698007%20%3D%20%3C%3C%2039607008%20%2C%20%3C%3C%20370135005%20%3D%20%3C%3C%20441862004%20))%20%7B%7B%20%2B%20HISTORY-MOD%20%7D%7D)') contains true")
+  //       .withFilter(
+  //           "reverseResolve(Condition.subject).code.coding.memberOf('http://snomed.info/sct?fhir_vs=ecl/((%3C%3C%2064572001%20%3A%20(%3C%3C%20363698007%20%3D%20%3C%3C%2039352004%20%2C%20%3C%3C%20370135005%20%3D%20%3C%3C%20263680009%20))%20%7B%7B%20%2B%20HISTORY-MOD%20%7D%7D)') contains true")
+  //       .withFilter(
+  //           "reverseResolve(Condition.subject).code.coding.memberOf('http://snomed.info/sct?fhir_vs=ecl/((%3C%3C%2064572001%20%3A%20(%3C%3C%20363698007%20%3D%20%3C%3C%2039607008%20%2C%20%3C%3C%20263502005%20%3D%20%3C%3C%2090734009%20))%20%7B%7B%20%2B%20HISTORY-MOD%20%7D%7D)') contains true")
+  //       .build();
+  //   bh.consume(executor.execute(request));
+  // }
+}
diff --git a/fhir-server/src/test/java/au/csiro/pathling/test/benchmark/TerminologyDevBenchmark.java b/fhir-server/src/test/java/au/csiro/pathling/test/benchmark/TerminologyDevBenchmark.java
new file mode 100644
index 0000000000..7ae1890ab4
--- /dev/null
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/benchmark/TerminologyDevBenchmark.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright © 2018-2022, Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230. Licensed under the CSIRO Open Source
+ * Software Licence Agreement.
+ */
+
+package au.csiro.pathling.test.benchmark;
+
+import au.csiro.pathling.aggregate.AggregateExecutor;
+import au.csiro.pathling.aggregate.AggregateRequest;
+import au.csiro.pathling.aggregate.AggregateRequestBuilder;
+import au.csiro.pathling.aggregate.AggregateResponse;
+import au.csiro.pathling.config.Configuration;
+import au.csiro.pathling.encoders.FhirEncoders;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
+import au.csiro.pathling.io.Database;
+import au.csiro.pathling.jmh.AbstractJmhSpringBootState;
+import au.csiro.pathling.terminology.DefaultTerminologyServiceFactory;
+import au.csiro.pathling.test.helpers.TestHelpers;
+import ca.uhn.fhir.context.FhirContext;
+import ca.uhn.fhir.parser.IParser;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import javax.annotation.Nonnull;
+import org.apache.spark.sql.SparkSession;
+import org.hl7.fhir.r4.model.Enumerations.ResourceType;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.TestPropertySource;
+
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+@Fork(0)
+@Warmup(iterations = 2)
+@Measurement(iterations = 7)
+public class TerminologyDevBenchmark {
+
+  @State(Scope.Benchmark)
+  @ActiveProfiles({"core", "server", "benchmark"})
+  @TestPropertySource(properties = {"pathling.terminology.serverUrl=http://localhost:8081/fhir", 
+      "pathling.terminology.useLegacy=false"})
+  // @TestPropertySource(
+  //       properties = {"pathling.terminology.serverUrl=https://tx.ontoserver.csiro.au/fhir"})
+  public static class TerminologyState extends AbstractJmhSpringBootState {
+
+    @Autowired
+    SparkSession spark;
+
+    @Autowired
+    TerminologyServiceFactory terminologyServiceFactory;
+
+    @Autowired
+    Configuration configuration;
+
+    @Autowired
+    FhirContext fhirContext;
+
+    @Autowired
+    IParser jsonParser;
+
+    @Autowired
+    FhirEncoders fhirEncoders;
+
+    @MockBean
+    Database database;
+
+    AggregateExecutor defaultExecutor;
+
+    void mockResource(final ResourceType... resourceTypes) {
+      TestHelpers.mockCachedResource(database, spark, resourceTypes);
+    }
+
+    @Setup(Level.Trial)
+    public void setUp() {
+      //database = mock(Database.class);
+      mockResource(ResourceType.PATIENT, ResourceType.CONDITION, ResourceType.ENCOUNTER,
+          ResourceType.PROCEDURE, ResourceType.MEDICATIONREQUEST, ResourceType.OBSERVATION,
+          ResourceType.DIAGNOSTICREPORT, ResourceType.ORGANIZATION, ResourceType.QUESTIONNAIRE,
+          ResourceType.CAREPLAN);
+
+      defaultExecutor = new AggregateExecutor(configuration, fhirContext, spark, database,
+          Optional.of(terminologyServiceFactory));
+
+    }
+
+    @Setup(Level.Iteration)
+    public void setupIteration() {
+      spark.sparkContext().cancelAllJobs();
+      if (terminologyServiceFactory instanceof DefaultTerminologyServiceFactory) {
+        DefaultTerminologyServiceFactory.reset();
+      }
+    }
+
+    public AggregateResponse execute(@Nonnull final AggregateRequest query) {
+      return defaultExecutor.execute(query);
+    }
+  }
+
+  @Benchmark
+  public void memberOfSnomedImplicit_Benchmark(final Blackhole bh,
+      final TerminologyDevBenchmark.TerminologyState executor) {
+
+    final AggregateRequest request = new AggregateRequestBuilder(ResourceType.CONDITION)
+        .withAggregation("count()")
+        // * : << 363698007|Finding site| = << 80891009|Structure of heart|
+        .withGrouping(
+            "code.coding.memberOf('http://snomed.info/sct?fhir_vs=ecl/*%20%3A%20%3C%3C%20363698007%20%3D%20%3C%3C%2080891009%20')")
+        .build();
+    bh.consume(executor.execute(request));
+  }
+
+  @Benchmark
+  public void memberOfLoincImplicit_Benchmark(final Blackhole bh,
+      final TerminologyDevBenchmark.TerminologyState executor) {
+
+    final AggregateRequest request = new AggregateRequestBuilder(ResourceType.OBSERVATION)
+        .withAggregation("count()")
+        .withFilter("code.coding.memberOf('http://loinc.org/vs/LP14885-5') contains true")
+        .build();
+    bh.consume(executor.execute(request));
+  }
+
+  @Benchmark
+  public void memberOfLoincImplicitWithShuffle_Benchmark(final Blackhole bh,
+      final TerminologyDevBenchmark.TerminologyState executor) {
+
+    final AggregateRequest request = new AggregateRequestBuilder(ResourceType.OBSERVATION)
+        .withAggregation("count()")
+        .withFilter(
+            "code.where($this.coding.count() > 0).coding.memberOf('http://loinc.org/vs/LP14885-5') contains true")
+        .build();
+    bh.consume(executor.execute(request));
+  }
+
+  @Benchmark
+  public void complexExpression_default_Benchmark(final Blackhole bh,
+      final TerminologyDevBenchmark.TerminologyState executor) {
+
+    final AggregateRequest request = new AggregateRequestBuilder(ResourceType.PATIENT)
+        .withAggregation("count()")
+        .withGrouping(
+            "reverseResolve(MedicationRequest.subject).medicationCodeableConcept.coding.memberOf('http://snomed.info/sct?fhir_vs=ecl/(%3C%3C%20416897008%20%7B%7B%20%2B%20HISTORY-MAX%20%7D%7D)') contains true")
+        .withGrouping(
+            "reverseResolve(Condition.subject).code.coding.memberOf('http://snomed.info/sct?fhir_vs=ecl/((%3C%3C%2064572001%20%3A%20(%3C%3C%20363698007%20%3D%20%3C%3C%2039607008%20%2C%20%3C%3C%20370135005%20%3D%20%3C%3C%20441862004%20))%20%7B%7B%20%2B%20HISTORY-MOD%20%7D%7D)') contains true")
+        .withFilter(
+            "reverseResolve(Condition.subject).code.coding.memberOf('http://snomed.info/sct?fhir_vs=ecl/((%3C%3C%2064572001%20%3A%20(%3C%3C%20363698007%20%3D%20%3C%3C%2039352004%20%2C%20%3C%3C%20370135005%20%3D%20%3C%3C%20263680009%20))%20%7B%7B%20%2B%20HISTORY-MOD%20%7D%7D)') contains true")
+        .withFilter(
+            "reverseResolve(Condition.subject).code.coding.memberOf('http://snomed.info/sct?fhir_vs=ecl/((%3C%3C%2064572001%20%3A%20(%3C%3C%20363698007%20%3D%20%3C%3C%2039607008%20%2C%20%3C%3C%20263502005%20%3D%20%3C%3C%2090734009%20))%20%7B%7B%20%2B%20HISTORY-MOD%20%7D%7D)') contains true")
+        .build();
+    bh.consume(executor.execute(request));
+  }
+}
diff --git a/fhir-server/src/test/java/au/csiro/pathling/test/builders/DatasetBuilder.java b/fhir-server/src/test/java/au/csiro/pathling/test/builders/DatasetBuilder.java
index 8aaa984644..c13004638d 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/test/builders/DatasetBuilder.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/builders/DatasetBuilder.java
@@ -260,4 +260,9 @@ public static List<Integer> makeEid(final Integer... levels) {
     return Arrays.asList(levels);
   }
 
+  @Nonnull 
+  public static  DatasetBuilder of(@Nonnull final SparkSession spark) {
+    return new DatasetBuilder(spark);
+  }
+  
 }
diff --git a/fhir-server/src/test/java/au/csiro/pathling/test/builders/ParserContextBuilder.java b/fhir-server/src/test/java/au/csiro/pathling/test/builders/ParserContextBuilder.java
index 87335fc803..44cbe871b0 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/test/builders/ParserContextBuilder.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/builders/ParserContextBuilder.java
@@ -21,7 +21,7 @@
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.FhirPath;
 import au.csiro.pathling.fhirpath.parser.ParserContext;
 import au.csiro.pathling.io.Database;
diff --git a/fhir-server/src/test/java/au/csiro/pathling/test/helpers/SparkHelpers.java b/fhir-server/src/test/java/au/csiro/pathling/test/helpers/SparkHelpers.java
index 6868673bf8..b8e6dd4316 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/test/helpers/SparkHelpers.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/helpers/SparkHelpers.java
@@ -21,7 +21,6 @@
 import static org.apache.spark.sql.functions.col;
 
 import au.csiro.pathling.encoders.datatypes.DecimalCustomCoder;
-import au.csiro.pathling.encoders.terminology.ucum.Ucum;
 import au.csiro.pathling.fhirpath.encoding.QuantityEncoding;
 import au.csiro.pathling.fhirpath.encoding.SimpleCoding;
 import java.math.BigDecimal;
@@ -32,13 +31,11 @@
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
 import au.csiro.pathling.utilities.Preconditions;
 import lombok.Value;
 import org.apache.spark.sql.Column;
 import org.apache.spark.sql.Dataset;
 import org.apache.spark.sql.Row;
-import org.apache.spark.sql.RowFactory;
 import org.apache.spark.sql.catalyst.expressions.GenericRowWithSchema;
 import org.apache.spark.sql.types.DataTypes;
 import org.apache.spark.sql.types.Metadata;
@@ -48,7 +45,6 @@
 import org.hl7.fhir.r4.model.CodeableConcept;
 import org.hl7.fhir.r4.model.Coding;
 import org.hl7.fhir.r4.model.Quantity;
-import org.hl7.fhir.r4.model.Quantity.QuantityComparator;
 import scala.collection.JavaConverters;
 import scala.collection.mutable.Buffer;
 
diff --git a/fhir-server/src/test/java/au/csiro/pathling/test/helpers/TestHelpers.java b/fhir-server/src/test/java/au/csiro/pathling/test/helpers/TestHelpers.java
index c42817ac29..4c6a5b8e39 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/test/helpers/TestHelpers.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/helpers/TestHelpers.java
@@ -56,6 +56,14 @@ public static void mockResource(@Nonnull final Database database,
     }
   }
 
+  public static void mockCachedResource(@Nonnull final Database database,
+      @Nonnull final SparkSession spark, @Nonnull final ResourceType... resourceTypes) {
+    for (final ResourceType resourceType : resourceTypes) {
+      final Dataset<Row> dataset = getDatasetForResourceType(spark, resourceType).cache();
+      when(database.read(resourceType)).thenReturn(dataset);
+    }
+  }
+  
   public static void mockResource(@Nonnull final Database database,
       @Nonnull final SparkSession spark, final int numPartitions,
       @Nonnull final ResourceType... resourceTypes) {
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/TerminologyService2IntegrationTest.java
new file mode 100644
index 0000000000..28623508e2
--- /dev/null
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/integration/TerminologyService2IntegrationTest.java
@@ -0,0 +1,237 @@
+/*
+ * 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.test.integration;
+
+import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService2.Translation;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
+import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException;
+import com.github.tomakehurst.wiremock.recording.RecordSpecBuilder;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.spark.sql.SparkSession;
+import org.hl7.fhir.r4.model.Coding;
+import org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence;
+import org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome;
+import org.junit.jupiter.api.*;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import java.util.*;
+
+import static au.csiro.pathling.test.assertions.Assertions.assertMatches;
+import static au.csiro.pathling.test.helpers.TerminologyHelpers.*;
+import static com.github.tomakehurst.wiremock.client.WireMock.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * @author Piotr Szul
+ */
+
+@Tag("Tranche2")
+@Slf4j
+class TerminologyService2IntegrationTest 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(
+      SNOMED_URI, "403190006",
+      SNOMED_VERSION_UNKN, "Epidermal burn of skin");
+
+
+  private final static Coding UNKNOWN_SYSTEM_CODING = new Coding("uuid:unknown", "unknown",
+      "Unknown");
+
+  @Autowired
+  SparkSession spark;
+
+  @Autowired
+  private TerminologyServiceFactory terminologyServiceFactory;
+
+  @Value("${pathling.test.recording.terminologyServerUrl}")
+  String recordingTxServerUrl;
+  
+  private TerminologyService2 terminologyService;
+
+  @BeforeEach
+  @Override
+  void setUp() {
+    super.setUp();
+    terminologyService = terminologyServiceFactory.buildService2();
+    if (isRecordMode()) {
+      wireMockServer.resetAll();
+      log.warn("Proxying all request to: {}", recordingTxServerUrl);
+      stubFor(proxyAllTo(recordingTxServerUrl));
+    }
+  }
+
+  @AfterEach
+  @Override
+  void tearDown() {
+    if (isRecordMode()) {
+      log.warn("Recording snapshots to: {}", wireMockServer.getOptions().filesRoot());
+      wireMockServer
+          .snapshotRecord(new RecordSpecBuilder().matchRequestBodyWithEqualToJson(true, false));
+    }
+    super.tearDown();
+  }
+
+  @Test
+  void testCorrectlyTranslatesKnownAndUnknownSystems() {
+
+    List<Translation> result = terminologyService.translate(
+        CD_SNOMED_72940011000036107, CM_HIST_ASSOCIATIONS, false, null);
+
+    assertEquals(1, result.size());
+    assertEquals(ConceptMapEquivalence.EQUIVALENT, result.get(0).getEquivalence());
+    assertTrue(codingEquals(CD_SNOMED_720471000168102, result.get(0).getConcept()));
+
+    assertEquals(Collections.emptyList(), terminologyService.translate(
+        CD_SNOMED_444814009, CM_HIST_ASSOCIATIONS, false, null));
+
+    assertEquals(Collections.emptyList(), terminologyService.translate(
+        CD_AST_VIC, CM_HIST_ASSOCIATIONS, false, null));
+
+    assertEquals(Collections.emptyList(), terminologyService.translate(
+        UNKNOWN_SYSTEM_CODING, CM_HIST_ASSOCIATIONS, false, null));
+
+    assertEquals(Collections.emptyList(), terminologyService.translate(
+        CD_SNOMED_403190006_VERSION_UNKN, CM_HIST_ASSOCIATIONS, false, null));
+  }
+
+  @Test
+  void testCorrectlyTranslatesInReverse() {
+
+    List<Translation> result = terminologyService.translate(
+        CD_SNOMED_720471000168102_VER2021, CM_HIST_ASSOCIATIONS, true, null);
+
+    assertEquals(1, result.size());
+    assertEquals(ConceptMapEquivalence.EQUIVALENT, result.get(0).getEquivalence());
+    assertTrue(codingEquals(CD_SNOMED_72940011000036107, result.get(0).getConcept()));
+  }
+
+  @Test
+  void testTranslatesWithTargetAndMulitpleResults() {
+
+    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(
+        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";
+    final Coding result1 = snomedCoding("267036007", "Dyspnea (finding)", version);
+    final Coding result2 = snomedCoding("390870001",
+        "Short of breath dressing/undressing (finding)", version);
+    // final Coding result3 = snomedCoding("1217110005",
+    //     "Dyspnea when bending forward (finding)", version);
+    // final Coding result4 = snomedCoding("161941007", "Dyspnea at rest (finding)", version);
+    // final Coding result5 = snomedCoding("60845006", "Dyspnea on exertion (finding)", version);
+
+    assertEquals(5, result.size());
+    assertEquals(ConceptMapEquivalence.INEXACT, result.get(0).getEquivalence());
+    assertTrue(codingEquals(result1, result.get(0).getConcept()));
+    assertEquals(ConceptMapEquivalence.INEXACT, result.get(1).getEquivalence());
+    assertTrue(codingEquals(result2, result.get(1).getConcept()));
+  }
+
+  @Test
+  void testFailsForUnknownConceptMap() {
+
+    final ResourceNotFoundException error = assertThrows(ResourceNotFoundException.class,
+        () -> terminologyService.translate(CD_SNOMED_72940011000036107,
+            "http://snomed.info/sct?fhir_cm=xxxx", false,
+            null));
+
+    assertMatches(
+        "HTTP 404 Not Found: "
+            + "\\[.+\\]: "
+            + "Unable to find ConceptMap with URI http://snomed\\.info/sct\\?fhir_cm=xxxx",
+        error.getMessage());
+  }
+
+  @Test
+  void testCorrectlyValidatesKnownAndUnknownSystems() {
+
+    assertTrue(
+        terminologyService.validate("http://snomed.info/sct?fhir_vs=refset/32570521000036109",
+            CD_SNOMED_284551006)
+    );
+
+    assertTrue(
+        terminologyService.validate("http://snomed.info/sct?fhir_vs=refset/32570521000036109",
+            CD_SNOMED_VER_403190006));
+
+    assertFalse(
+        terminologyService.validate("http://snomed.info/sct?fhir_vs=refset/32570521000036109",
+            CD_SNOMED_72940011000036107)
+    );
+
+    assertFalse(
+        terminologyService.validate("http://snomed.info/sct?fhir_vs=refset/32570521000036109",
+            CD_AST_VIC)
+    );
+
+    assertFalse(
+        terminologyService.validate("http://snomed.info/sct?fhir_vs=refset/32570521000036109",
+            UNKNOWN_SYSTEM_CODING)
+    );
+
+    // TODO: This throws an 404 exception because the specific version of a known system cannot be found. 
+    // Why it's not treated the same as an unknown system (like in translate?)
+    // assertFalse(
+    //     terminologyService.validate("http://snomed.info/sct?fhir_vs=refset/32570521000036109",
+    //         CD_SNOMED_403190006_VERSION_UNKN)
+    // );
+  }
+
+  @Test
+  void testCorrectlySubsumesKnownAndUnknownSystems() {
+
+    assertEquals(ConceptSubsumptionOutcome.SUBSUMES,
+        terminologyService.subsumes(CD_SNOMED_107963000, CD_SNOMED_63816008)
+    );
+
+    assertEquals(ConceptSubsumptionOutcome.SUBSUMES,
+        terminologyService.subsumes(CD_SNOMED_107963000, CD_SNOMED_VER_63816008)
+    );
+
+    assertEquals(ConceptSubsumptionOutcome.NOTSUBSUMED,
+        terminologyService.subsumes(CD_SNOMED_107963000, UNKNOWN_SYSTEM_CODING)
+    );
+
+    // TODO: This throws an 404 exception because the specific version of a known system cannot be found. 
+    // Whey it's not treated the same as an unknown system (like with translate)
+    // assertEquals(ConceptSubsumptionOutcome.NOTSUBSUMED,
+    //     terminologyService.subsumes(CD_SNOMED_107963000, CD_SNOMED_403190006_VERSION_UNKN)
+    // );
+
+    // TODO: This is the same coding but with different version and we cannot test for it
+    // assertEquals(ConceptSubsumptionOutcome.EQUIVALENT,
+    //     terminologyService.subsumes(CD_SNOMED_VER_63816008, CD_SNOMED_63816008_VER2022)
+    // );
+  }
+
+  @Test
+  void testUserAgentHeader() {
+    terminologyService.validate(SNOMED_URI + "?fhir_vs", CD_SNOMED_284551006);
+    verify(anyRequestedFor(urlPathMatching("/fhir/(.*)"))
+        .withHeader("User-Agent", matching("pathling/(.*)")));
+  }
+
+}
diff --git a/fhir-server/src/test/java/au/csiro/pathling/test/integration/TerminologyServiceIntegrationTest.java b/fhir-server/src/test/java/au/csiro/pathling/test/integration/TerminologyServiceIntegrationTest.java
index 91667e03b3..fc6ab3f19d 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/test/integration/TerminologyServiceIntegrationTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/integration/TerminologyServiceIntegrationTest.java
@@ -78,7 +78,10 @@
 /**
  * @author Piotr Szul
  */
+
+// TODO: terminology cache: Implement if needed for the new cachable service
 @Tag("Tranche2")
+@Disabled
 @Slf4j
 class TerminologyServiceIntegrationTest extends WireMockTest {
 
diff --git a/fhir-server/src/test/java/au/csiro/pathling/test/integration/WireMockTest.java b/fhir-server/src/test/java/au/csiro/pathling/test/integration/WireMockTest.java
index 5b2aaea4ca..4e1dff12ab 100644
--- a/fhir-server/src/test/java/au/csiro/pathling/test/integration/WireMockTest.java
+++ b/fhir-server/src/test/java/au/csiro/pathling/test/integration/WireMockTest.java
@@ -29,7 +29,7 @@
  * @author John Grimes
  */
 @TestPropertySource(properties = {
-    "pathling.test.recording.terminologyServerUrl=https://tx.ontoserver.csiro.au",
+    "pathling.test.recording.terminologyServerUrl=https://r4.ontoserver.csiro.au",
     "pathling.sentryDsn=http://123abc@localhost:" + IntegrationTestDependencies.WIREMOCK_PORT
         + "/5513555",
     "pathling.sentryEnvironment=integration-test"
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 cd8713ee84..81a7d8aa75 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,11 +17,11 @@
 
 package au.csiro.pathling.test.stubs;
 
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
 import au.csiro.pathling.terminology.TerminologyService;
+import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.test.SharedMocks;
 import javax.annotation.Nonnull;
-import org.slf4j.Logger;
 
 public class TestTerminologyServiceFactory implements TerminologyServiceFactory {
 
@@ -32,7 +32,14 @@ public TestTerminologyServiceFactory() {
 
   @Nonnull
   @Override
-  public TerminologyService buildService(@Nonnull final Logger logger) {
+  public TerminologyService buildService() {
     return SharedMocks.getOrCreate(TerminologyService.class);
   }
+
+  @Nonnull
+  @Override
+  public TerminologyService2 buildService2() {
+    return SharedMocks.getOrCreate(TerminologyService2.class);
+  }
+
 }
diff --git a/fhir-server/src/test/resources/application-benchmark.yml b/fhir-server/src/test/resources/application-benchmark.yml
new file mode 100644
index 0000000000..9b847ad456
--- /dev/null
+++ b/fhir-server/src/test/resources/application-benchmark.yml
@@ -0,0 +1,23 @@
+spring:
+  main:
+    banner-mode: off
+
+spark:
+  master: local[8]
+  driver:
+    bindAddress: localhost
+    host: localhost
+  network:
+    timeout: 600s
+  ui:
+    enabled: true
+  sql:
+    adaptive:
+      enabled: true
+      coalescePartitions:
+        enabled: true
+        parallelismFirst: true
+        initialPartitionNum: 200
+#    files:
+#      minPartitionNum: 8
+#      maxPartitionBytes: 100000
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_codesystem_subsumes-6277d5be-7a6d-4d46-9f37-716fa063cd21.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_codesystem_subsumes-6277d5be-7a6d-4d46-9f37-716fa063cd21.json
new file mode 100644
index 0000000000..cc7dc9ddb4
--- /dev/null
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_codesystem_subsumes-6277d5be-7a6d-4d46-9f37-716fa063cd21.json
@@ -0,0 +1,29 @@
+{
+  "id" : "6277d5be-7a6d-4d46-9f37-716fa063cd21",
+  "name" : "fhir_codesystem_subsumes",
+  "request" : {
+    "url" : "/fhir/CodeSystem/$subsumes?codeA=107963000&codeB=63816008&system=http%3A%2F%2Fsnomed.info%2Fsct",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"outcome\",\"valueCode\":\"subsumes\"}]}",
+    "headers" : {
+      "Server" : "nginx/1.21.6",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "X-Request-Id" : "dbqWNMZpPuxGnpCN",
+      "ETag" : "W/\"1669302095530\"",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.2.1 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "Strict-Transport-Security" : "max-age=31536000 ; includeSubDomains",
+      "X-Frame-Options" : "DENY",
+      "Cache-Control" : "max-age=279",
+      "Date" : "Mon, 28 Nov 2022 23:51:23 GMT"
+    }
+  },
+  "uuid" : "6277d5be-7a6d-4d46-9f37-716fa063cd21",
+  "persistent" : true,
+  "insertionIndex" : 170
+}
\ No newline at end of file
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_codesystem_subsumes-f57052d5-8fa9-42b6-a236-b7650b7dcd20.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_codesystem_subsumes-f57052d5-8fa9-42b6-a236-b7650b7dcd20.json
new file mode 100644
index 0000000000..5247ae068e
--- /dev/null
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_codesystem_subsumes-f57052d5-8fa9-42b6-a236-b7650b7dcd20.json
@@ -0,0 +1,29 @@
+{
+  "id" : "f57052d5-8fa9-42b6-a236-b7650b7dcd20",
+  "name" : "fhir_codesystem_subsumes",
+  "request" : {
+    "url" : "/fhir/CodeSystem/$subsumes?codeA=107963000&codeB=63816008&system=http%3A%2F%2Fsnomed.info%2Fsct&version=http%3A%2F%2Fsnomed.info%2Fsct%2F32506021000036107%2Fversion%2F20210331",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"outcome\",\"valueCode\":\"subsumes\"}]}",
+    "headers" : {
+      "Server" : "nginx/1.21.6",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "X-Request-Id" : "IZAuaLzJUyWG3Xsn",
+      "ETag" : "W/\"1669302095530\"",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.2.1 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "Strict-Transport-Security" : "max-age=31536000 ; includeSubDomains",
+      "X-Frame-Options" : "DENY",
+      "Cache-Control" : "max-age=283",
+      "Date" : "Mon, 28 Nov 2022 23:51:24 GMT"
+    }
+  },
+  "uuid" : "f57052d5-8fa9-42b6-a236-b7650b7dcd20",
+  "persistent" : true,
+  "insertionIndex" : 171
+}
\ No newline at end of file
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-1081053a-3f00-44f6-8635-2f746d658391.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-1081053a-3f00-44f6-8635-2f746d658391.json
new file mode 100644
index 0000000000..197ed3b3aa
--- /dev/null
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-1081053a-3f00-44f6-8635-2f746d658391.json
@@ -0,0 +1,29 @@
+{
+  "id" : "1081053a-3f00-44f6-8635-2f746d658391",
+  "name" : "fhir_conceptmap_translate",
+  "request" : {
+    "url" : "/fhir/ConceptMap/$translate?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_cm%3D900000000000526001&system=http%3A%2F%2Fsnomed.info%2Fsct&code=403190006&version=http%3A%2F%2Fsnomed.info%2Fsct%2F32506021000036107%2Fversion%2F19000101&reverse=false",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"result\",\"valueBoolean\":false},{\"name\":\"message\",\"valueString\":\"No mappings could be found for 403190006 (http://snomed.info/sct)\"}]}",
+    "headers" : {
+      "Server" : "nginx/1.21.6",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "X-Request-Id" : "waeJnUH12RiHhydP",
+      "ETag" : "W/\"1669477710350\"",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.2.1 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "Strict-Transport-Security" : "max-age=31536000 ; includeSubDomains",
+      "X-Frame-Options" : "DENY",
+      "Cache-Control" : "max-age=280",
+      "Date" : "Mon, 28 Nov 2022 23:51:22 GMT"
+    }
+  },
+  "uuid" : "1081053a-3f00-44f6-8635-2f746d658391",
+  "persistent" : true,
+  "insertionIndex" : 109
+}
\ No newline at end of file
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-33217787-60cf-4f00-9740-e7c44acc6af2.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-33217787-60cf-4f00-9740-e7c44acc6af2.json
new file mode 100644
index 0000000000..3d22485709
--- /dev/null
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-33217787-60cf-4f00-9740-e7c44acc6af2.json
@@ -0,0 +1,29 @@
+{
+  "id" : "33217787-60cf-4f00-9740-e7c44acc6af2",
+  "name" : "fhir_conceptmap_translate",
+  "request" : {
+    "url" : "/fhir/ConceptMap/$translate?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_cm%3D900000000000526001&system=http%3A%2F%2Fsnomed.info%2Fsct&code=720471000168102&version=http%3A%2F%2Fsnomed.info%2Fsct%2F32506021000036107%2Fversion%2F20210331&reverse=true",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"result\",\"valueBoolean\":true},{\"name\":\"match\",\"part\":[{\"name\":\"equivalence\",\"valueCode\":\"equivalent\"},{\"name\":\"concept\",\"valueCoding\":{\"system\":\"http://snomed.info/sct\",\"code\":\"72940011000036107\",\"display\":\"Duodopa gel: intestinal\"}},{\"name\":\"source\",\"valueString\":\"http://snomed.info/xsct/32506021000036107/version/20220930?fhir_cm=900000000000526001\"}]}]}",
+    "headers" : {
+      "Server" : "nginx/1.21.6",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "X-Request-Id" : "ybr6urQXQ0GgLvR4",
+      "ETag" : "W/\"1669477710350\"",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.2.1 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "Strict-Transport-Security" : "max-age=31536000 ; includeSubDomains",
+      "X-Frame-Options" : "DENY",
+      "Cache-Control" : "max-age=279",
+      "Date" : "Mon, 28 Nov 2022 23:51:24 GMT"
+    }
+  },
+  "uuid" : "33217787-60cf-4f00-9740-e7c44acc6af2",
+  "persistent" : true,
+  "insertionIndex" : 204
+}
\ No newline at end of file
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-3dbce871-f11d-4cc0-ae9f-320dbe8e3571.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-3dbce871-f11d-4cc0-ae9f-320dbe8e3571.json
new file mode 100644
index 0000000000..475d20ea33
--- /dev/null
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-3dbce871-f11d-4cc0-ae9f-320dbe8e3571.json
@@ -0,0 +1,29 @@
+{
+  "id" : "3dbce871-f11d-4cc0-ae9f-320dbe8e3571",
+  "name" : "fhir_conceptmap_translate",
+  "request" : {
+    "url" : "/fhir/ConceptMap/$translate?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_cm%3D900000000000526001&system=http%3A%2F%2Fsnomed.info%2Fsct&code=72940011000036107&reverse=false",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"result\",\"valueBoolean\":true},{\"name\":\"match\",\"part\":[{\"name\":\"equivalence\",\"valueCode\":\"equivalent\"},{\"name\":\"concept\",\"valueCoding\":{\"system\":\"http://snomed.info/sct\",\"code\":\"720471000168102\",\"display\":\"Duodopa intestinal gel\"}},{\"name\":\"source\",\"valueString\":\"http://snomed.info/xsct/32506021000036107/version/20220930?fhir_cm=900000000000526001\"}]}]}",
+    "headers" : {
+      "Server" : "nginx/1.21.6",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "X-Request-Id" : "b6k9PL4GsCwged13",
+      "ETag" : "W/\"1669477710350\"",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.2.1 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "Strict-Transport-Security" : "max-age=31536000 ; includeSubDomains",
+      "X-Frame-Options" : "DENY",
+      "Cache-Control" : "max-age=284",
+      "Date" : "Mon, 28 Nov 2022 23:51:21 GMT"
+    }
+  },
+  "uuid" : "3dbce871-f11d-4cc0-ae9f-320dbe8e3571",
+  "persistent" : true,
+  "insertionIndex" : 105
+}
\ No newline at end of file
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-462c966f-b738-4d91-a1fa-c179beaefd3e.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-462c966f-b738-4d91-a1fa-c179beaefd3e.json
new file mode 100644
index 0000000000..94d0e9f3d4
--- /dev/null
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-462c966f-b738-4d91-a1fa-c179beaefd3e.json
@@ -0,0 +1,29 @@
+{
+  "id" : "462c966f-b738-4d91-a1fa-c179beaefd3e",
+  "name" : "fhir_conceptmap_translate",
+  "request" : {
+    "url" : "/fhir/ConceptMap/$translate?url=http%3A%2F%2Fontoserver.csiro.au%2Ffhir%2FConceptMap%2Fautomapstrategy-default&system=http%3A%2F%2Fontoserver.csiro.au%2Ffhir%2FCodeSystem%2Fcodesystem-terms&code=shortness%20of%20breath&reverse=false&target=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_vs%3Decl%2F%28%253C%253C%252064572001%2520%257CDisease%257C%2520OR%2520%253C%253C%2520404684003%2520%257CClinical%2520finding%257C%29",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"result\",\"valueBoolean\":true},{\"name\":\"message\",\"valueString\":\"Results generated by automap strategy: UriType[http://ontoserver.csiro.au/fhir/ConceptMap/automapstrategy-default]\"},{\"name\":\"match\",\"part\":[{\"name\":\"source\",\"valueUri\":\"http://ontoserver.csiro.au/fhir/ConceptMap/automapstrategy-default\"},{\"name\":\"equivalence\",\"valueCode\":\"inexact\"},{\"name\":\"concept\",\"valueCoding\":{\"extension\":[{\"url\":\"http://snomed.info/field/semanticTag\",\"valueString\":\"finding\"}],\"system\":\"http://snomed.info/sct\",\"version\":\"http://snomed.info/xsct/32506021000036107/version/20220930\",\"code\":\"267036007\",\"display\":\"Dyspnea (finding)\"}}]},{\"name\":\"match\",\"part\":[{\"name\":\"source\",\"valueUri\":\"http://ontoserver.csiro.au/fhir/ConceptMap/automapstrategy-default\"},{\"name\":\"equivalence\",\"valueCode\":\"inexact\"},{\"name\":\"concept\",\"valueCoding\":{\"extension\":[{\"url\":\"http://snomed.info/field/semanticTag\",\"valueString\":\"finding\"}],\"system\":\"http://snomed.info/sct\",\"version\":\"http://snomed.info/xsct/32506021000036107/version/20220930\",\"code\":\"390870001\",\"display\":\"Short of breath dressing/undressing (finding)\"}}]},{\"name\":\"match\",\"part\":[{\"name\":\"source\",\"valueUri\":\"http://ontoserver.csiro.au/fhir/ConceptMap/automapstrategy-default\"},{\"name\":\"equivalence\",\"valueCode\":\"inexact\"},{\"name\":\"concept\",\"valueCoding\":{\"extension\":[{\"url\":\"http://snomed.info/field/semanticTag\",\"valueString\":\"finding\"}],\"system\":\"http://snomed.info/sct\",\"version\":\"http://snomed.info/xsct/32506021000036107/version/20220930\",\"code\":\"1217110005\",\"display\":\"Dyspnea when bending forward (finding)\"}}]},{\"name\":\"match\",\"part\":[{\"name\":\"source\",\"valueUri\":\"http://ontoserver.csiro.au/fhir/ConceptMap/automapstrategy-default\"},{\"name\":\"equivalence\",\"valueCode\":\"inexact\"},{\"name\":\"concept\",\"valueCoding\":{\"extension\":[{\"url\":\"http://snomed.info/field/semanticTag\",\"valueString\":\"finding\"}],\"system\":\"http://snomed.info/sct\",\"version\":\"http://snomed.info/xsct/32506021000036107/version/20220930\",\"code\":\"161941007\",\"display\":\"Dyspnea at rest (finding)\"}}]},{\"name\":\"match\",\"part\":[{\"name\":\"source\",\"valueUri\":\"http://ontoserver.csiro.au/fhir/ConceptMap/automapstrategy-default\"},{\"name\":\"equivalence\",\"valueCode\":\"inexact\"},{\"name\":\"concept\",\"valueCoding\":{\"extension\":[{\"url\":\"http://snomed.info/field/semanticTag\",\"valueString\":\"finding\"}],\"system\":\"http://snomed.info/sct\",\"version\":\"http://snomed.info/xsct/32506021000036107/version/20220930\",\"code\":\"60845006\",\"display\":\"Dyspnea on exertion (finding)\"}}]}]}",
+    "headers" : {
+      "Server" : "nginx/1.21.6",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "X-Request-Id" : "Ae3E3pEhz06Rm3v4",
+      "ETag" : "W/\"-1670155589467\"",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.2.1 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "Strict-Transport-Security" : "max-age=31536000 ; includeSubDomains",
+      "X-Frame-Options" : "DENY",
+      "Cache-Control" : "max-age=294",
+      "Date" : "Mon, 28 Nov 2022 23:51:20 GMT"
+    }
+  },
+  "uuid" : "462c966f-b738-4d91-a1fa-c179beaefd3e",
+  "persistent" : true,
+  "insertionIndex" : 80
+}
\ No newline at end of file
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-8200a575-7d4e-4d1b-84d4-ca162cb6691c.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-8200a575-7d4e-4d1b-84d4-ca162cb6691c.json
new file mode 100644
index 0000000000..aa1d71a7c9
--- /dev/null
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-8200a575-7d4e-4d1b-84d4-ca162cb6691c.json
@@ -0,0 +1,29 @@
+{
+  "id" : "8200a575-7d4e-4d1b-84d4-ca162cb6691c",
+  "name" : "fhir_conceptmap_translate",
+  "request" : {
+    "url" : "/fhir/ConceptMap/$translate?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_cm%3D900000000000526001&system=http%3A%2F%2Fcsiro.au%2Ffhir%2Fau-states-territories&code=VIC&reverse=false",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"result\",\"valueBoolean\":false},{\"name\":\"message\",\"valueString\":\"No mappings could be found for VIC (http://csiro.au/fhir/au-states-territories)\"}]}",
+    "headers" : {
+      "Server" : "nginx/1.21.6",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "X-Request-Id" : "p6ERaTGdDEVukiMg",
+      "ETag" : "W/\"1669477710350\"",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.2.1 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "Strict-Transport-Security" : "max-age=31536000 ; includeSubDomains",
+      "X-Frame-Options" : "DENY",
+      "Cache-Control" : "max-age=296",
+      "Date" : "Mon, 28 Nov 2022 23:51:22 GMT"
+    }
+  },
+  "uuid" : "8200a575-7d4e-4d1b-84d4-ca162cb6691c",
+  "persistent" : true,
+  "insertionIndex" : 107
+}
\ No newline at end of file
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-9357def6-11ed-4712-9ae4-23d89f5f4bc0.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-9357def6-11ed-4712-9ae4-23d89f5f4bc0.json
new file mode 100644
index 0000000000..012df0442a
--- /dev/null
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-9357def6-11ed-4712-9ae4-23d89f5f4bc0.json
@@ -0,0 +1,29 @@
+{
+  "id" : "9357def6-11ed-4712-9ae4-23d89f5f4bc0",
+  "name" : "fhir_conceptmap_translate",
+  "request" : {
+    "url" : "/fhir/ConceptMap/$translate?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_cm%3Dxxxx&system=http%3A%2F%2Fsnomed.info%2Fsct&code=72940011000036107&reverse=false",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 404,
+    "body" : "{\"resourceType\":\"OperationOutcome\",\"issue\":[{\"severity\":\"error\",\"code\":\"not-found\",\"diagnostics\":\"[d735da05-adca-4285-868a-403efe6fcc55]: Unable to find ConceptMap with URI http://snomed.info/sct?fhir_cm=xxxx\"}]}",
+    "headers" : {
+      "Server" : "nginx/1.21.6",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "ETag" : "W/\"1669477710350\"",
+      "X-Request-Id" : "mgY5gBgo3a3n7OLm",
+      "X-Powered-By" : "HAPI FHIR 6.2.1 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "Strict-Transport-Security" : "max-age=31536000 ; includeSubDomains",
+      "X-Frame-Options" : "DENY",
+      "Cache-Control" : "max-age=300",
+      "Date" : "Mon, 28 Nov 2022 23:51:22 GMT"
+    }
+  },
+  "uuid" : "9357def6-11ed-4712-9ae4-23d89f5f4bc0",
+  "persistent" : true,
+  "insertionIndex" : 139
+}
\ No newline at end of file
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-cbd6ad39-7684-41e9-9774-14ed051a74b1.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-cbd6ad39-7684-41e9-9774-14ed051a74b1.json
new file mode 100644
index 0000000000..92cbb3d909
--- /dev/null
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-cbd6ad39-7684-41e9-9774-14ed051a74b1.json
@@ -0,0 +1,29 @@
+{
+  "id" : "cbd6ad39-7684-41e9-9774-14ed051a74b1",
+  "name" : "fhir_conceptmap_translate",
+  "request" : {
+    "url" : "/fhir/ConceptMap/$translate?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_cm%3D900000000000526001&system=uuid%3Aunknown&code=unknown&reverse=false",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"result\",\"valueBoolean\":false},{\"name\":\"message\",\"valueString\":\"No mappings could be found for unknown (uuid:unknown)\"}]}",
+    "headers" : {
+      "Server" : "nginx/1.21.6",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "X-Request-Id" : "haDu7KL8s2dI2Wuh",
+      "ETag" : "W/\"1669477710350\"",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.2.1 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "Strict-Transport-Security" : "max-age=31536000 ; includeSubDomains",
+      "X-Frame-Options" : "DENY",
+      "Cache-Control" : "max-age=300",
+      "Date" : "Mon, 28 Nov 2022 23:51:22 GMT"
+    }
+  },
+  "uuid" : "cbd6ad39-7684-41e9-9774-14ed051a74b1",
+  "persistent" : true,
+  "insertionIndex" : 108
+}
\ No newline at end of file
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-f175c6ce-66bb-433a-ab70-b8bc098840ce.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-f175c6ce-66bb-433a-ab70-b8bc098840ce.json
new file mode 100644
index 0000000000..b946b18483
--- /dev/null
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_conceptmap_translate-f175c6ce-66bb-433a-ab70-b8bc098840ce.json
@@ -0,0 +1,29 @@
+{
+  "id" : "f175c6ce-66bb-433a-ab70-b8bc098840ce",
+  "name" : "fhir_conceptmap_translate",
+  "request" : {
+    "url" : "/fhir/ConceptMap/$translate?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_cm%3D900000000000526001&system=http%3A%2F%2Fsnomed.info%2Fsct&code=444814009&reverse=false",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"result\",\"valueBoolean\":false},{\"name\":\"message\",\"valueString\":\"No mappings could be found for 444814009 (http://snomed.info/sct)\"}]}",
+    "headers" : {
+      "Server" : "nginx/1.21.6",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "X-Request-Id" : "jRkEbGYjUYZAAGDR",
+      "ETag" : "W/\"1669477710350\"",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.2.1 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "Strict-Transport-Security" : "max-age=31536000 ; includeSubDomains",
+      "X-Frame-Options" : "DENY",
+      "Cache-Control" : "max-age=300",
+      "Date" : "Mon, 28 Nov 2022 23:51:21 GMT"
+    }
+  },
+  "uuid" : "f175c6ce-66bb-433a-ab70-b8bc098840ce",
+  "persistent" : true,
+  "insertionIndex" : 106
+}
\ No newline at end of file
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_valueset_validate-code-145710c9-7b61-4218-ad44-f96af2c6b9eb.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_valueset_validate-code-145710c9-7b61-4218-ad44-f96af2c6b9eb.json
new file mode 100644
index 0000000000..11f5f1a77a
--- /dev/null
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_valueset_validate-code-145710c9-7b61-4218-ad44-f96af2c6b9eb.json
@@ -0,0 +1,29 @@
+{
+  "id" : "145710c9-7b61-4218-ad44-f96af2c6b9eb",
+  "name" : "fhir_valueset_validate-code",
+  "request" : {
+    "url" : "/fhir/ValueSet/$validate-code?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_vs%3Drefset%2F32570521000036109&system=http%3A%2F%2Fcsiro.au%2Ffhir%2Fau-states-territories&code=VIC",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"result\",\"valueBoolean\":false},{\"name\":\"code\",\"valueCode\":\"VIC\"},{\"name\":\"system\",\"valueUri\":\"http://csiro.au/fhir/au-states-territories\"},{\"name\":\"message\",\"valueString\":\"A version for a code system with URL http://csiro.au/fhir/au-states-territories was not supplied and the system could not find its latest version.\"}]}",
+    "headers" : {
+      "Server" : "nginx/1.21.6",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "X-Request-Id" : "NU4tkPBS0UqYS9Xj",
+      "ETag" : "W/\"1669575995074\"",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.2.1 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "Strict-Transport-Security" : "max-age=31536000 ; includeSubDomains",
+      "X-Frame-Options" : "DENY",
+      "Cache-Control" : "max-age=300",
+      "Date" : "Mon, 28 Nov 2022 23:51:20 GMT"
+    }
+  },
+  "uuid" : "145710c9-7b61-4218-ad44-f96af2c6b9eb",
+  "persistent" : true,
+  "insertionIndex" : 55
+}
\ No newline at end of file
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_valueset_validate-code-58f00cd3-e13a-4a0c-92a9-70394cf3d6d4.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_valueset_validate-code-58f00cd3-e13a-4a0c-92a9-70394cf3d6d4.json
new file mode 100644
index 0000000000..e439238f78
--- /dev/null
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_valueset_validate-code-58f00cd3-e13a-4a0c-92a9-70394cf3d6d4.json
@@ -0,0 +1,29 @@
+{
+  "id" : "58f00cd3-e13a-4a0c-92a9-70394cf3d6d4",
+  "name" : "fhir_valueset_validate-code",
+  "request" : {
+    "url" : "/fhir/ValueSet/$validate-code?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_vs%3Drefset%2F32570521000036109&system=uuid%3Aunknown&code=unknown",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"result\",\"valueBoolean\":false},{\"name\":\"code\",\"valueCode\":\"unknown\"},{\"name\":\"system\",\"valueUri\":\"uuid:unknown\"},{\"name\":\"message\",\"valueString\":\"A version for a code system with URL uuid:unknown was not supplied and the system could not find its latest version.\"}]}",
+    "headers" : {
+      "Server" : "nginx/1.21.6",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "X-Request-Id" : "ydrJVg2wOW2pUncY",
+      "ETag" : "W/\"1670107098624\"",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.2.1 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "Strict-Transport-Security" : "max-age=31536000 ; includeSubDomains",
+      "X-Frame-Options" : "DENY",
+      "Cache-Control" : "max-age=272",
+      "Date" : "Mon, 28 Nov 2022 23:51:20 GMT"
+    }
+  },
+  "uuid" : "58f00cd3-e13a-4a0c-92a9-70394cf3d6d4",
+  "persistent" : true,
+  "insertionIndex" : 56
+}
\ No newline at end of file
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_valueset_validate-code-61f0406c-3a68-41fa-ba3e-bf504029dfd2.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_valueset_validate-code-61f0406c-3a68-41fa-ba3e-bf504029dfd2.json
new file mode 100644
index 0000000000..64ab5a1665
--- /dev/null
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_valueset_validate-code-61f0406c-3a68-41fa-ba3e-bf504029dfd2.json
@@ -0,0 +1,29 @@
+{
+  "id" : "61f0406c-3a68-41fa-ba3e-bf504029dfd2",
+  "name" : "fhir_valueset_validate-code",
+  "request" : {
+    "url" : "/fhir/ValueSet/$validate-code?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_vs%3Drefset%2F32570521000036109&system=http%3A%2F%2Fsnomed.info%2Fsct&code=72940011000036107",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"result\",\"valueBoolean\":false},{\"name\":\"code\",\"valueCode\":\"72940011000036107\"},{\"name\":\"system\",\"valueUri\":\"http://snomed.info/sct\"},{\"name\":\"message\",\"valueString\":\"The code '72940011000036107' from code system 'http://snomed.info/sct' was not found in value set http://snomed.info/xsct/32506021000036107/version/20220930?fhir_vs=refset%2F32570521000036109\"}]}",
+    "headers" : {
+      "Server" : "nginx/1.21.6",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "X-Request-Id" : "dCl1uQe3SVD8JN3V",
+      "ETag" : "W/\"1669922091557\"",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.2.1 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "Strict-Transport-Security" : "max-age=31536000 ; includeSubDomains",
+      "X-Frame-Options" : "DENY",
+      "Cache-Control" : "max-age=297",
+      "Date" : "Mon, 28 Nov 2022 23:51:19 GMT"
+    }
+  },
+  "uuid" : "61f0406c-3a68-41fa-ba3e-bf504029dfd2",
+  "persistent" : true,
+  "insertionIndex" : 54
+}
\ No newline at end of file
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_valueset_validate-code-68191a4e-4fdf-405f-be35-445a166dc925.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_valueset_validate-code-68191a4e-4fdf-405f-be35-445a166dc925.json
new file mode 100644
index 0000000000..af6ba2f5da
--- /dev/null
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_valueset_validate-code-68191a4e-4fdf-405f-be35-445a166dc925.json
@@ -0,0 +1,29 @@
+{
+  "id" : "68191a4e-4fdf-405f-be35-445a166dc925",
+  "name" : "fhir_valueset_validate-code",
+  "request" : {
+    "url" : "/fhir/ValueSet/$validate-code?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_vs%3Drefset%2F32570521000036109&system=http%3A%2F%2Fsnomed.info%2Fsct&code=403190006&systemVersion=http%3A%2F%2Fsnomed.info%2Fsct%2F32506021000036107%2Fversion%2F20210331",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"result\",\"valueBoolean\":true},{\"name\":\"code\",\"valueCode\":\"403190006\"},{\"name\":\"system\",\"valueUri\":\"http://snomed.info/sct\"},{\"name\":\"version\",\"valueString\":\"http://snomed.info/sct/32506021000036107/version/20210331\"},{\"name\":\"display\",\"valueString\":\"Epidermal burn of skin\"}]}",
+    "headers" : {
+      "Server" : "nginx/1.21.6",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "X-Request-Id" : "st8MqyNH2iRovtcS",
+      "ETag" : "W/\"1669227266110\"",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.2.1 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "Strict-Transport-Security" : "max-age=31536000 ; includeSubDomains",
+      "X-Frame-Options" : "DENY",
+      "Cache-Control" : "max-age=300",
+      "Date" : "Mon, 28 Nov 2022 23:51:19 GMT"
+    }
+  },
+  "uuid" : "68191a4e-4fdf-405f-be35-445a166dc925",
+  "persistent" : true,
+  "insertionIndex" : 53
+}
\ No newline at end of file
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_valueset_validate-code-7a10f8cb-a230-4dfa-aa23-e0cc62c2645d.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_valueset_validate-code-7a10f8cb-a230-4dfa-aa23-e0cc62c2645d.json
new file mode 100644
index 0000000000..7d51792991
--- /dev/null
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_valueset_validate-code-7a10f8cb-a230-4dfa-aa23-e0cc62c2645d.json
@@ -0,0 +1,29 @@
+{
+  "id" : "7a10f8cb-a230-4dfa-aa23-e0cc62c2645d",
+  "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=284551006",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"result\",\"valueBoolean\":true},{\"name\":\"code\",\"valueCode\":\"284551006\"},{\"name\":\"system\",\"valueUri\":\"http://snomed.info/sct\"},{\"name\":\"version\",\"valueString\":\"http://snomed.info/xsct/32506021000036107/version/20220930\"},{\"name\":\"display\",\"valueString\":\"Laceration of foot\"}]}",
+    "headers" : {
+      "Server" : "nginx/1.21.6",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "X-Request-Id" : "cykW26T6agVYCxAU",
+      "ETag" : "W/\"-1669937139583\"",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.2.1 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "Strict-Transport-Security" : "max-age=31536000 ; includeSubDomains",
+      "X-Frame-Options" : "DENY",
+      "Cache-Control" : "max-age=279",
+      "Date" : "Mon, 28 Nov 2022 23:51:18 GMT"
+    }
+  },
+  "uuid" : "7a10f8cb-a230-4dfa-aa23-e0cc62c2645d",
+  "persistent" : true,
+  "insertionIndex" : 33
+}
\ No newline at end of file
diff --git a/fhir-server/src/test/resources/wiremock/mappings/fhir_valueset_validate-code-d382bffa-6414-4bfb-8cbd-7e1c136f2ba5.json b/fhir-server/src/test/resources/wiremock/mappings/fhir_valueset_validate-code-d382bffa-6414-4bfb-8cbd-7e1c136f2ba5.json
new file mode 100644
index 0000000000..2b4e568085
--- /dev/null
+++ b/fhir-server/src/test/resources/wiremock/mappings/fhir_valueset_validate-code-d382bffa-6414-4bfb-8cbd-7e1c136f2ba5.json
@@ -0,0 +1,29 @@
+{
+  "id" : "d382bffa-6414-4bfb-8cbd-7e1c136f2ba5",
+  "name" : "fhir_valueset_validate-code",
+  "request" : {
+    "url" : "/fhir/ValueSet/$validate-code?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_vs%3Drefset%2F32570521000036109&system=http%3A%2F%2Fsnomed.info%2Fsct&code=284551006",
+    "method" : "GET"
+  },
+  "response" : {
+    "status" : 200,
+    "body" : "{\"resourceType\":\"Parameters\",\"parameter\":[{\"name\":\"result\",\"valueBoolean\":true},{\"name\":\"code\",\"valueCode\":\"284551006\"},{\"name\":\"system\",\"valueUri\":\"http://snomed.info/sct\"},{\"name\":\"version\",\"valueString\":\"http://snomed.info/xsct/32506021000036107/version/20220930\"},{\"name\":\"display\",\"valueString\":\"Laceration of foot\"}]}",
+    "headers" : {
+      "Server" : "nginx/1.21.6",
+      "Content-Type" : "application/fhir+json;charset=UTF-8",
+      "X-Request-Id" : "TS712LnGANYKib3B",
+      "ETag" : "W/\"1668992292057\"",
+      "Vary" : "Accept,Origin,Accept-Encoding,Accept-Language,Authorization",
+      "X-Powered-By" : "HAPI FHIR 6.2.1 REST Server (FHIR Server; FHIR 4.0.1/R4)",
+      "X-Content-Type-Options" : "nosniff",
+      "X-XSS-Protection" : "1; mode=block",
+      "Strict-Transport-Security" : "max-age=31536000 ; includeSubDomains",
+      "X-Frame-Options" : "DENY",
+      "Cache-Control" : "max-age=291",
+      "Date" : "Mon, 28 Nov 2022 23:51:19 GMT"
+    }
+  },
+  "uuid" : "d382bffa-6414-4bfb-8cbd-7e1c136f2ba5",
+  "persistent" : true,
+  "insertionIndex" : 52
+}
\ No newline at end of file
diff --git a/lib/python/tests/test_udfs.py b/lib/python/tests/test_udfs.py
new file mode 100644
index 0000000000..cf451fec68
--- /dev/null
+++ b/lib/python/tests/test_udfs.py
@@ -0,0 +1,293 @@
+#  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.
+
+import logging
+import os
+from tempfile import mkdtemp
+
+from pyspark.sql import SparkSession
+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.etc import SNOMED_URI
+from pathling.etc import find_jar as find_pathling_jar
+from pathling.udfs import member_of, subsumes, subsumed_by, translate
+
+PROJECT_DIR = os.path.abspath(
+        os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, os.pardir))
+
+
+@fixture(scope="module")
+def spark(request):
+    """
+    Fixture for creating a Spark Session available for all tests in this
+    testing session.
+    """
+
+    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') \
+        .getOrCreate()
+
+    request.addfinalizer(lambda: spark.stop())
+
+    return spark
+
+
+@fixture(scope="module")
+def ptl(spark):
+    return PathlingContext.create(spark, mock_terminology=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"
+
+CodingRow = Row("id", "system", "version", "code", "display", "userSelected", "_fid")
+
+
+def snomed_coding_row(code: str):
+    return CodingRow(None, SNOMED_URI, None, code, None, None, None)
+
+
+def loinc_coding_row(code: str):
+    return CodingRow(None, LOINC_URI, None, code, None, None, None)
+
+
+Result = Row("id", "result")
+
+
+def test_member_of(spark: SparkSession, ptl: PathlingContext):
+    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)])
+    )
+
+    result_df_col = df.select(
+            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)]
+
+    result_df_str = df.select(
+            "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)]
+
+    result_df_coding = df.limit(1).select(
+            "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, ptl: PathlingContext):
+    PathlingContext.create(spark, mock_terminology=True)
+    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([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"))
+
+    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"))
+
+    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"))
+
+    assert result_df_coding.collect() == [Result("code-1", True)]
+
+
+@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([StructField("id", StringType()),
+                               StructField("codeA", CODING_TYPE),
+                               StructField("codeB", ArrayType(CODING_TYPE))
+                               ])
+    )
+
+
+def test_subsumes(subsumption_df, ptl: PathlingContext):
+    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_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_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)
+    ]
+
+
+def test_subsumed_by(subsumption_df, ptl: PathlingContext):
+    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_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_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)
+    ]
+
+
+def test_translate(spark: SparkSession, ptl: PathlingContext):
+    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)])
+    )
+
+    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="equivalent,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"))
+    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="equivalent,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",
+            reverse=True,
+            equivalences="relatedto").alias("result"))
+
+    assert result_df.collect() == [
+        Result("id-1", [snomed_coding_row("368529002")]),
+    ];
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 0bd0b1c12b..317522b139 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
@@ -17,29 +17,32 @@
 
 package au.csiro.pathling.library;
 
+import static au.csiro.pathling.test.helpers.TerminologyServiceHelpers.setupSubsumes;
+import static au.csiro.pathling.test.helpers.TerminologyServiceHelpers.setupTranslate;
+import static au.csiro.pathling.test.helpers.TerminologyServiceHelpers.setupValidate;
 import static org.apache.spark.sql.functions.col;
+import static org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence.EQUIVALENT;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.withSettings;
 
+import au.csiro.pathling.config.HttpCacheConf;
+import au.csiro.pathling.config.HttpClientConf;
+import au.csiro.pathling.config.TerminologyAuthConfiguration;
 import au.csiro.pathling.encoders.FhirEncoders;
-import au.csiro.pathling.fhir.DefaultTerminologyServiceFactory;
-import au.csiro.pathling.fhir.TerminologyServiceFactory;
+import au.csiro.pathling.terminology.DefaultTerminologyServiceFactory;
+import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService2.Translation;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
 import au.csiro.pathling.fhirpath.encoding.CodingEncoding;
-import au.csiro.pathling.fhirpath.encoding.SimpleCoding;
-import au.csiro.pathling.terminology.ConceptTranslator;
-import au.csiro.pathling.terminology.Relation;
-import au.csiro.pathling.terminology.Relation.Entry;
-import au.csiro.pathling.terminology.TerminologyService;
 import au.csiro.pathling.test.SchemaAsserts;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import ca.uhn.fhir.context.FhirVersionEnum;
+import com.google.common.collect.ImmutableMap;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.spark.sql.Column;
 import org.apache.spark.sql.Dataset;
@@ -51,10 +54,12 @@
 import org.apache.spark.sql.types.DataTypes;
 import org.apache.spark.sql.types.StructField;
 import org.apache.spark.sql.types.StructType;
+import org.hl7.fhir.r4.model.Coding;
 import org.hl7.fhir.r4.model.Condition;
 import org.hl7.fhir.r4.model.Enumerations.ConceptMapEquivalence;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import scala.collection.mutable.WrappedArray;
 
@@ -68,7 +73,7 @@ public class PathlingContextTest {
    * Set up Spark.
    */
   @BeforeAll
-  public static void setUp() {
+  public static void setUpAll() {
     spark = TestHelpers.spark();
   }
 
@@ -76,10 +81,25 @@ public static void setUp() {
    * Tear down Spark.
    */
   @AfterAll
-  public static void tearDown() {
+  public static void tearDownAll() {
     spark.stop();
   }
 
+  private TerminologyServiceFactory terminologyServiceFactory;
+  private TerminologyService2 terminologyService;
+
+  @BeforeEach
+  public void setUp() {
+    // setup terminology mocks
+    terminologyServiceFactory = mock(
+        TerminologyServiceFactory.class, withSettings().serializable());
+    terminologyService = mock(TerminologyService2.class,
+        withSettings().serializable());
+    when(terminologyServiceFactory.buildService2()).thenReturn(terminologyService);
+
+    DefaultTerminologyServiceFactory.reset();
+  }
+
 
   @Test
   public void testEncodeResourcesFromJsonBundle() {
@@ -227,22 +247,16 @@ public void testEncodeResourceStream() throws Exception {
   @Test
   void testMemberOf() {
     final String valueSetUri = "urn:test:456";
-    final SimpleCoding coding1 = new SimpleCoding("urn:test:123", "ABC");
-    final SimpleCoding coding2 = new SimpleCoding("urn:test:123", "DEF");
+    final Coding coding1 = new Coding("urn:test:123", "ABC", "abc");
+    final Coding coding2 = new Coding("urn:test:123", "DEF", "def");
 
-    final TerminologyServiceFactory terminologyServiceFactory = mock(
-        TerminologyServiceFactory.class, withSettings().serializable());
-    final TerminologyService terminologyService = mock(TerminologyService.class,
-        withSettings().serializable());
-    when(terminologyServiceFactory.buildService(any())).thenReturn(terminologyService);
-    when(terminologyService.intersect(eq(valueSetUri), any()))
-        .thenReturn(new HashSet<>(List.of(coding1)));
+    setupValidate(terminologyService).withValueSet(valueSetUri, coding1);
 
     final PathlingContext pathlingContext = PathlingContext.create(spark,
         FhirEncoders.forR4().getOrCreate(), terminologyServiceFactory);
 
-    final Row row1 = RowFactory.create("foo", CodingEncoding.encode(coding1.toCoding()));
-    final Row row2 = RowFactory.create("bar", CodingEncoding.encode(coding2.toCoding()));
+    final Row row1 = RowFactory.create("foo", CodingEncoding.encode(coding1));
+    final Row row2 = RowFactory.create("bar", CodingEncoding.encode(coding2));
     final List<Row> datasetRows = List.of(row1, row2);
     final StructType schema = DataTypes.createStructType(
         new StructField[]{DataTypes.createStructField("id", DataTypes.StringType, true),
@@ -260,26 +274,17 @@ void testMemberOf() {
   @Test
   void testTranslate() {
     final String conceptMapUri = "urn:test:456";
-    final List<ConceptMapEquivalence> equivalences = List.of(ConceptMapEquivalence.EQUIVALENT);
-    final SimpleCoding coding1 = new SimpleCoding("urn:test:123", "ABC");
-    final SimpleCoding coding2 = new SimpleCoding("urn:test:123", "DEF");
+    final Coding coding1 = new Coding("urn:test:123", "ABC", "abc");
+    final Coding coding2 = new Coding("urn:test:123", "DEF", "def");
 
-    final TerminologyServiceFactory terminologyServiceFactory = mock(
-        TerminologyServiceFactory.class, withSettings().serializable());
-    final TerminologyService terminologyService = mock(TerminologyService.class,
-        withSettings().serializable());
-    final ConceptTranslator conceptTranslator = mock(ConceptTranslator.class,
-        withSettings().serializable());
-    when(terminologyServiceFactory.buildService(any())).thenReturn(terminologyService);
-    when(terminologyService.translate(any(), eq(conceptMapUri), eq(false), eq(equivalences), any()))
-        .thenReturn(conceptTranslator);
-    when(conceptTranslator.translate(any())).thenReturn(List.of(coding2.toCoding()));
+    setupTranslate(terminologyService).withTranslations(coding1, conceptMapUri,
+        Translation.of(EQUIVALENT, coding2));
 
     final PathlingContext pathlingContext = PathlingContext.create(spark,
         FhirEncoders.forR4().getOrCreate(), terminologyServiceFactory);
 
-    final Row row1 = RowFactory.create("foo", CodingEncoding.encode(coding1.toCoding()));
-    final Row row2 = RowFactory.create("bar", CodingEncoding.encode(coding2.toCoding()));
+    final Row row1 = RowFactory.create("foo", CodingEncoding.encode(coding1));
+    final Row row2 = RowFactory.create("bar", CodingEncoding.encode(coding2));
     final List<Row> datasetRows = List.of(row1, row2);
     final StructType schema = DataTypes.createStructType(
         new StructField[]{DataTypes.createStructField("id", DataTypes.StringType, true),
@@ -290,31 +295,25 @@ void testTranslate() {
         conceptMapUri, false, ConceptMapEquivalence.EQUIVALENT.toCode(), null, "result");
 
     final List<Row> rows = result.select("id", "result").collectAsList();
-    assertEquals(RowFactory.create("foo", CodingEncoding.encode(coding2.toCoding())), rows.get(0));
+    assertEquals(RowFactory.create("foo", WrappedArray.make(new Row[]{CodingEncoding.encode(coding2)})),
+        rows.get(0));
   }
 
   @Test
   void testSubsumes() {
-    final SimpleCoding coding1 = new SimpleCoding("urn:test:123", "ABC");
-    final SimpleCoding coding2 = new SimpleCoding("urn:test:123", "DEF");
-    final SimpleCoding coding3 = new SimpleCoding("urn:test:123", "GHI");
+    final Coding coding1 = new Coding("urn:test:123", "ABC", "abc");
+    final Coding coding2 = new Coding("urn:test:123", "DEF", "def");
+    final Coding coding3 = new Coding("urn:test:123", "GHI", "ghi");
 
-    final TerminologyServiceFactory terminologyServiceFactory = mock(
-        TerminologyServiceFactory.class, withSettings().serializable());
-    final TerminologyService terminologyService = mock(TerminologyService.class,
-        withSettings().serializable());
-    final Entry entry = Entry.of(coding1, coding2);
-    final Relation relation = Relation.fromMappings(List.of(entry));
-    when(terminologyServiceFactory.buildService(any())).thenReturn(terminologyService);
-    when(terminologyService.getSubsumesRelation(any())).thenReturn(relation);
+    setupSubsumes(terminologyService).withSubsumes(coding1, coding2);
 
     final PathlingContext pathlingContext = PathlingContext.create(spark,
         FhirEncoders.forR4().getOrCreate(), terminologyServiceFactory);
 
-    final Row row1 = RowFactory.create("foo", CodingEncoding.encode(coding1.toCoding()),
-        CodingEncoding.encode(coding2.toCoding()));
-    final Row row2 = RowFactory.create("bar", CodingEncoding.encode(coding1.toCoding()),
-        CodingEncoding.encode(coding3.toCoding()));
+    final Row row1 = RowFactory.create("foo", CodingEncoding.encode(coding1),
+        CodingEncoding.encode(coding2));
+    final Row row2 = RowFactory.create("bar", CodingEncoding.encode(coding1),
+        CodingEncoding.encode(coding3));
     final List<Row> datasetRows = List.of(row1, row2);
     final StructType schema = DataTypes.createStructType(
         new StructField[]{DataTypes.createStructField("id", DataTypes.StringType, true),
@@ -332,40 +331,110 @@ void testSubsumes() {
   }
 
   @Test
-  void testBuildContextWithTerminology() {
+  void testBuildContextWithTerminologyDefaults() {
+
     final String terminologyServerUrl = "https://tx.ontoserver.csiro.au/fhir";
+
+    final PathlingContextConfiguration config = PathlingContextConfiguration.builder()
+        .terminologyServerUrl(terminologyServerUrl)
+        .build();
+    final PathlingContext pathlingContext = PathlingContext.create(spark, config);
+    assertNotNull(pathlingContext);
+    final DefaultTerminologyServiceFactory expectedFactory = new DefaultTerminologyServiceFactory(
+        FhirVersionEnum.R4, terminologyServerUrl, false, HttpClientConf.defaults(),
+        HttpCacheConf.defaults(), TerminologyAuthConfiguration.defaults());
+
+    final TerminologyServiceFactory actualServiceFactory = pathlingContext.getTerminologyServiceFactory();
+    assertEquals(expectedFactory, actualServiceFactory);
+    final TerminologyService2 terminologyService = actualServiceFactory.buildService2();
+    assertNotNull(terminologyService);
+  }
+
+  @Test
+  void testBuildContextWithTerminologyNoCache() {
+
+    final String terminologyServerUrl = "https://tx.ontoserver.csiro.au/fhir";
+
+    final PathlingContextConfiguration config = PathlingContextConfiguration.builder()
+        .terminologyServerUrl(terminologyServerUrl)
+        .cacheStorageType(null)
+        .build();
+    final PathlingContext pathlingContext = PathlingContext.create(spark, config);
+    assertNotNull(pathlingContext);
+    final TerminologyServiceFactory expectedFactory = new DefaultTerminologyServiceFactory(
+        FhirVersionEnum.R4, terminologyServerUrl, false, HttpClientConf.defaults(),
+        HttpCacheConf.disabled(), TerminologyAuthConfiguration.defaults());
+
+    final TerminologyServiceFactory actualServiceFactory = pathlingContext.getTerminologyServiceFactory();
+    assertEquals(expectedFactory, actualServiceFactory);
+    final TerminologyService2 terminologyService = actualServiceFactory.buildService2();
+    assertNotNull(terminologyService);
+  }
+
+
+  @Test
+  void testBuildContextWithCustomizedTerminology() {
+    final String terminologyServerUrl = "https://r4.ontoserver.csiro.au/fhir";
     final String tokenEndpoint = "https://auth.ontoserver.csiro.au/auth/realms/aehrc/protocol/openid-connect/token";
     final String clientId = "some-client";
     final String clientSecret = "some-secret";
     final String scope = "openid";
-    final int tokenExpiryTolerance = 120;
+    final long tokenExpiryTolerance = 300L;
+
+    final int maxConnectionsTotal = 66;
+    final int maxConnectionsPerRoute = 33;
+    final int socketTimeout = 123;
+
+    final int cacheMaxEntries = 1233;
+    final long cacheMaxObjectSize = 4453L;
+    final String cacheStorgeType = "disk";
+    final Map<String, String> cacheStorageProperties = ImmutableMap.of("cacheDir", "/tmp");
 
     final PathlingContextConfiguration config = PathlingContextConfiguration.builder()
         .terminologyServerUrl(terminologyServerUrl)
+        .terminologyVerboseRequestLogging(true)
+        .terminologySocketTimeout(socketTimeout)
+        .maxConnectionsTotal(maxConnectionsTotal)
+        .maxConnectionsPerRoute(maxConnectionsPerRoute)
+        .cacheMaxEntries(cacheMaxEntries)
+        .cacheMaxObjectSize(cacheMaxObjectSize)
+        .cacheStorageType(cacheStorgeType)
+        .cacheStorageProperties(cacheStorageProperties)
         .tokenEndpoint(tokenEndpoint)
         .clientId(clientId)
         .clientSecret(clientSecret)
         .scope(scope)
         .tokenExpiryTolerance(tokenExpiryTolerance)
         .build();
+
     final PathlingContext pathlingContext = PathlingContext.create(spark, config);
     assertNotNull(pathlingContext);
-
-    final TerminologyServiceFactory terminologyServiceFactory = pathlingContext.getTerminologyServiceFactory();
-    assertNotNull(terminologyServiceFactory);
-    assertTrue(terminologyServiceFactory instanceof DefaultTerminologyServiceFactory);
-    final DefaultTerminologyServiceFactory defaultTerminologyServiceFactory = (DefaultTerminologyServiceFactory) terminologyServiceFactory;
-    assertEquals(terminologyServerUrl, defaultTerminologyServiceFactory.getTerminologyServerUrl());
-    assertEquals(tokenEndpoint,
-        defaultTerminologyServiceFactory.getAuthConfig().getTokenEndpoint());
-    assertEquals(clientId, defaultTerminologyServiceFactory.getAuthConfig().getClientId());
-    assertEquals(clientSecret, defaultTerminologyServiceFactory.getAuthConfig().getClientSecret());
-    assertEquals(scope, defaultTerminologyServiceFactory.getAuthConfig().getScope());
-    assertEquals(tokenExpiryTolerance,
-        defaultTerminologyServiceFactory.getAuthConfig().getTokenExpiryTolerance());
-
-    final TerminologyService terminologyService = terminologyServiceFactory
-        .buildService(log);
+    final TerminologyServiceFactory expectedFactory = new DefaultTerminologyServiceFactory(
+        FhirVersionEnum.R4, terminologyServerUrl, true,
+        HttpClientConf.builder()
+            .socketTimeout(socketTimeout)
+            .maxConnectionsTotal(maxConnectionsTotal)
+            .maxConnectionsPerRoute(maxConnectionsPerRoute)
+            .build(),
+        HttpCacheConf.builder()
+            .maxCacheEntries(cacheMaxEntries)
+            .maxObjectSize(cacheMaxObjectSize)
+            .storageType(cacheStorgeType)
+            .storage(cacheStorageProperties)
+            .build(),
+        TerminologyAuthConfiguration.builder()
+            .enabled(true)
+            .tokenEndpoint(tokenEndpoint)
+            .clientId(clientId)
+            .clientSecret(clientSecret)
+            .scope(scope)
+            .tokenExpiryTolerance(tokenExpiryTolerance)
+            .build()
+    );
+
+    final TerminologyServiceFactory actualServiceFactory = pathlingContext.getTerminologyServiceFactory();
+    assertEquals(expectedFactory, actualServiceFactory);
+    final TerminologyService2 terminologyService = actualServiceFactory.buildService2();
     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
new file mode 100644
index 0000000000..ce42e7d209
--- /dev/null
+++ b/terminology/src/test/java/au/csiro/pathling/caching/CachingFactoriesTest.java
@@ -0,0 +1,45 @@
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import au.csiro.pathling.errors.InvalidConfigError;
+import com.google.common.collect.ImmutableMap;
+import java.io.File;
+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 CACHE_CONFIG = CacheConfig.custom().setMaxCacheEntries(100000)
+      .build();
+
+  @Test
+  public void testMemoryStorageFactory() throws Exception {
+    final CachingHttpClientBuilder clientBuilder = CachingFactories.of(
+        CachingFactories.MEMORY_STORAGE).create(CACHE_CONFIG, null);
+    assertEquals(CACHE_CONFIG, readField(clientBuilder, "cacheConfig", true));
+    assertNull(readField(clientBuilder, "cacheDir", true));
+  }
+
+
+  @Test
+  public void testDistStorageFactory() throws Exception {
+    final CachingHttpClientBuilder clientBuilder = CachingFactories.of(
+            CachingFactories.DISK_STORAGE)
+        .create(CACHE_CONFIG, ImmutableMap.of(CachingFactories.DISK_CACHE_DIR, "myCacheDir"));
+    assertEquals(CACHE_CONFIG, readField(clientBuilder, "cacheConfig", true));
+    assertEquals(new File("myCacheDir"), readField(clientBuilder, "cacheDir", true));
+  }
+
+  @Test
+  public void testDistStoragFactoryRequiresCondig() {
+    final InvalidConfigError ex = assertThrows(InvalidConfigError.class, () -> CachingFactories.of(
+        CachingFactories.DISK_STORAGE).create(CACHE_CONFIG, null));
+    assertEquals("Required config property missing: cacheDir", ex.getMessage());
+  }
+
+}
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
new file mode 100644
index 0000000000..4a3f5765ce
--- /dev/null
+++ b/terminology/src/test/java/au/csiro/pathling/sql/udf/MemberOfUdfTest.java
@@ -0,0 +1,97 @@
+package au.csiro.pathling.sql.udf;
+
+import static au.csiro.pathling.fhirpath.encoding.CodingEncoding.encode;
+import static au.csiro.pathling.test.helpers.FhirMatchers.deepEq;
+import static au.csiro.pathling.test.helpers.TerminologyHelpers.CD_SNOMED_284551006;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.eq;
+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.TerminologyServiceFactory;
+import au.csiro.pathling.test.TerminologyTest;
+import au.csiro.pathling.test.helpers.TerminologyServiceHelpers;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+@SuppressWarnings("ConstantConditions")
+public class MemberOfUdfTest extends TerminologyTest {
+
+  private static final String VALUE_SET_URL_A = "uuid:vsA";
+  private static final String VALUE_SET_URL_AB = "uuid:vsAB";
+
+  private MemberOfUdf memberUdf;
+  private TerminologyService2 terminologyService2;
+  
+  @BeforeEach
+  void setUp() {
+    terminologyService2 = mock(TerminologyService2.class);
+    final TerminologyServiceFactory terminologyServiceFactory = mock(
+        TerminologyServiceFactory.class);
+    when(terminologyServiceFactory.buildService2()).thenReturn(terminologyService2);
+    memberUdf = new MemberOfUdf(terminologyServiceFactory);
+
+    TerminologyServiceHelpers.setupValidate(terminologyService2)
+        .withValueSet(VALUE_SET_URL_A, CODING_A)
+        .withValueSet(VALUE_SET_URL_AB, CODING_A, CODING_B);
+  }
+
+  @Test
+  void testNullCodings() {
+    assertNull(memberUdf.call(null, "uiid:url"));
+  }
+
+  @Test
+  void testNullValueSetUrl() {
+    assertNull(memberUdf.call(encode(CD_SNOMED_284551006), null));
+  }
+
+  @Test
+  void testInvalidAndNullCodings() {
+    assertFalse(
+        memberUdf.call(encodeMany(INVALID_CODING_0, INVALID_CODING_1, INVALID_CODING_2, null),
+            VALUE_SET_URL_A));
+    verifyNoMoreInteractions(terminologyService2);
+  }
+
+  @Test
+  void testInvalidCoding() {
+    assertFalse(memberUdf.call(encode(INVALID_CODING_0), VALUE_SET_URL_A));
+    verifyNoMoreInteractions(terminologyService2);
+  }
+
+  @Test
+  void testCodingBelongsToValueSet() {
+    assertTrue(memberUdf.call(encode(CODING_A), VALUE_SET_URL_A));
+    assertTrue(memberUdf.call(encode(CODING_A), VALUE_SET_URL_AB));
+    assertTrue(memberUdf.call(encode(CODING_B), VALUE_SET_URL_AB));
+
+    assertFalse(memberUdf.call(encode(CODING_B), VALUE_SET_URL_A));
+    assertFalse(memberUdf.call(encode(CODING_C), VALUE_SET_URL_A));
+    assertFalse(memberUdf.call(encode(CODING_C), VALUE_SET_URL_AB));
+  }
+
+  @Test
+  void testCodingsBelongsToValueSet() {
+    // positive cases
+    assertTrue(memberUdf.call(encodeMany(CODING_C, CODING_A), VALUE_SET_URL_A));
+    assertTrue(memberUdf.call(encodeMany(null, INVALID_CODING_0, CODING_B), VALUE_SET_URL_AB));
+    assertTrue(memberUdf.call(encodeMany(CODING_A, CODING_B), VALUE_SET_URL_AB));
+    // negative casses
+    assertFalse(memberUdf.call(encodeMany(), VALUE_SET_URL_A));
+    assertFalse(memberUdf.call(encodeMany(CODING_C, CODING_B), VALUE_SET_URL_A));
+    assertFalse(memberUdf.call(encodeMany(null, INVALID_CODING_1, CODING_C), VALUE_SET_URL_AB));
+  }
+
+  @Test
+  void testEarlyExitWhenMatchingCodingFound() {
+    assertTrue(memberUdf.call(encodeMany(CODING_A, CODING_B), VALUE_SET_URL_AB));
+    verify(terminologyService2).validate(eq(VALUE_SET_URL_AB), deepEq(CODING_A));
+    verifyNoMoreInteractions(terminologyService2);
+  }
+}
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
new file mode 100644
index 0000000000..484620b1c6
--- /dev/null
+++ b/terminology/src/test/java/au/csiro/pathling/sql/udf/SubsumesUdfTest.java
@@ -0,0 +1,111 @@
+package au.csiro.pathling.sql.udf;
+
+import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
+import au.csiro.pathling.test.TerminologyTest;
+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;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+@SuppressWarnings("ConstantConditions")
+public class SubsumesUdfTest extends TerminologyTest {
+
+  private SubsumesUdf subsumesUdf;
+  private TerminologyService2 terminologyService2;
+
+  @BeforeEach
+  void setUp() {
+    terminologyService2 = mock(TerminologyService2.class);
+    final TerminologyServiceFactory terminologyServiceFactory = mock(
+        TerminologyServiceFactory.class);
+    when(terminologyServiceFactory.buildService2()).thenReturn(terminologyService2);
+
+    TerminologyServiceHelpers.setupSubsumes(terminologyService2)
+        .withSubsumes(CODING_AA, CODING_AB)
+        .withSubsumes(CODING_BA, CODING_BB);
+    subsumesUdf = new SubsumesUdf(terminologyServiceFactory);
+  }
+
+  @Test
+  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);
+  }
+
+  @Test
+  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);
+  }
+
+  @Test
+  void testNullAndInvalidCodings() {
+    assertFalse(subsumesUdf.call(encodeMany(null, INVALID_CODING_0),
+        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);
+  }
+
+
+  @Test
+  void testSubsumesCoding() {
+    // self subsumption
+    assertTrue(subsumesUdf.call(encode(CODING_A), encode(CODING_A), null));
+    assertTrue(subsumesUdf.call(encode(CODING_B), encode(CODING_B), false));
+    assertTrue(subsumesUdf.call(encode(CODING_C), encode(CODING_C), true));
+
+    // positive cases 
+    assertTrue(subsumesUdf.call(encode(CODING_AA), encode(CODING_AB), null));
+    assertTrue(subsumesUdf.call(encode(CODING_BA), encode(CODING_BB), false));
+    assertTrue(subsumesUdf.call(encode(CODING_BB), encode(CODING_BA), true));
+
+    // negative cases
+    assertFalse(subsumesUdf.call(encode(CODING_BB), encode(CODING_BA), null));
+    assertFalse(subsumesUdf.call(encode(CODING_AB), encode(CODING_AA), false));
+    assertFalse(subsumesUdf.call(encode(CODING_AA), encode(CODING_AB), true));
+    assertFalse(subsumesUdf.call(encode(CODING_C), encode(CODING_B), null));
+    assertFalse(subsumesUdf.call(encode(CODING_A), encode(CODING_C), true));
+  }
+
+  @Test
+  void testSubsumesCodings() {
+    // positive cases 
+    assertTrue(
+        subsumesUdf.call(encodeMany(null, INVALID_CODING_0, CODING_AA, CODING_D), encode(CODING_AB),
+            null));
+    assertTrue(
+        subsumesUdf.call(encodeMany(CODING_AB, CODING_BA), encodeMany(CODING_AA, CODING_BB),
+            false));
+    assertTrue(
+        subsumesUdf.call(encodeMany(CODING_AB, CODING_BA), encodeMany(CODING_AA, CODING_BB), true));
+    assertTrue(
+        subsumesUdf.call(encodeMany(null, INVALID_CODING_1, CODING_C, CODING_BA), encode(CODING_BB),
+            false));
+
+    // NegativeCases
+    assertFalse(subsumesUdf.call(encodeMany(), encode(CODING_B), null));
+    assertFalse(subsumesUdf.call(encode(CODING_C), encodeMany(), true));
+    assertFalse(subsumesUdf.call(encodeMany(), encodeMany(), true));
+
+    assertFalse(
+        subsumesUdf.call(encode(CODING_AB), encodeMany(CODING_AA, CODING_C, CODING_D), null));
+    assertFalse(
+        subsumesUdf.call(encodeMany(CODING_BB, CODING_AA, CODING_AB), encode(CODING_BA), false));
+    assertFalse(
+        subsumesUdf.call(encodeMany(CODING_AA, CODING_BA), encodeMany(CODING_AB, CODING_BB), true));
+  }
+
+}
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
new file mode 100644
index 0000000000..57e1e29c4e
--- /dev/null
+++ b/terminology/src/test/java/au/csiro/pathling/sql/udf/TranslateOfUdfTest.java
@@ -0,0 +1,93 @@
+package au.csiro.pathling.sql.udf;
+
+import au.csiro.pathling.errors.InvalidUserInputError;
+import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService2.Translation;
+import au.csiro.pathling.terminology.TerminologyServiceFactory;
+import au.csiro.pathling.test.TerminologyTest;
+import au.csiro.pathling.test.helpers.TerminologyServiceHelpers;
+import org.apache.spark.sql.Row;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+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 TerminologyTest {
+
+  private static final String CONCEPT_MAP_A = "uuid:caA";
+  private static final String CONCEPT_MAP_B = "uuid:caB";
+
+  private static final Row[] NO_TRANSLATIONS = new Row[]{};
+
+  private TranslateUdf translateUdf;
+  private TerminologyService2 terminologyService2;
+  
+  @BeforeEach
+  void setUp() {
+    terminologyService2 = mock(TerminologyService2.class);
+    final TerminologyServiceFactory terminologyServiceFactory = mock(
+        TerminologyServiceFactory.class);
+    when(terminologyServiceFactory.buildService2()).thenReturn(terminologyService2);
+    translateUdf = new TranslateUdf(terminologyServiceFactory);
+
+  }
+
+  @Test
+  void testNullCodings() {
+    assertNull(translateUdf.call(null, CONCEPT_MAP_A,
+        true, null, null));
+  }
+
+  @Test
+  void testTranslatesCodingWithDefaults() {
+
+    TerminologyServiceHelpers.setupTranslate(terminologyService2)
+        .withTranslations(CODING_AA, CONCEPT_MAP_A,
+            Translation.of(EQUIVALENT, CODING_BB),
+            Translation.of(RELATEDTO, CODING_AB));
+
+    assertArrayEquals(asArray(CODING_BB),
+        translateUdf.call(encode(CODING_AA), CONCEPT_MAP_A, false, null, null));
+  }
+
+  @Test
+  void testTranslatesCodingsUniqueResults() {
+
+    TerminologyServiceHelpers.setupTranslate(terminologyService2)
+        .withTranslations(CODING_AA_VERSION1, CONCEPT_MAP_B, true, SYSTEM_B,
+            Translation.of(EQUIVALENT, CODING_AA),
+            Translation.of(NARROWER, CODING_BB),
+            Translation.of(RELATEDTO, CODING_AB))
+        .withTranslations(CODING_AB_VERSION1, CONCEPT_MAP_B, true, SYSTEM_B,
+            Translation.of(EQUIVALENT, CODING_AB),
+            Translation.of(NARROWER, CODING_BB),
+            Translation.of(RELATEDTO, CODING_BA));
+
+    assertArrayEquals(asArray(CODING_BB, CODING_AB, CODING_BA),
+        translateUdf.call(encodeMany(null, INVALID_CODING_1, INVALID_CODING_0, INVALID_CODING_2,
+                CODING_AA_VERSION1, CODING_AB_VERSION1), CONCEPT_MAP_B, true,
+            "narrower, relatedto", SYSTEM_B));
+  }
+
+  @Test
+  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);
+  }
+
+  @Test
+  void testThrowsInputErrorWhenInvalidEquivalence() {
+    final InvalidUserInputError ex = assertThrows(InvalidUserInputError.class,
+        () -> translateUdf.call(encode(CODING_AA), CONCEPT_MAP_B, true, "invalid", null));
+    assertEquals("Unknown ConceptMapEquivalence code 'invalid'", ex.getMessage());
+    verifyNoMoreInteractions(terminologyService2);
+  }
+
+}
diff --git a/terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyService2Test.java b/terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyService2Test.java
new file mode 100644
index 0000000000..e2f38e59c8
--- /dev/null
+++ b/terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyService2Test.java
@@ -0,0 +1,236 @@
+package au.csiro.pathling.terminology;
+
+
+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_SUBSUMES;
+import static au.csiro.pathling.test.helpers.TerminologyServiceHelpers.RESULT_FALSE;
+import static au.csiro.pathling.test.helpers.TerminologyServiceHelpers.RESULT_TRUE;
+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.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+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.Translation;
+import au.csiro.pathling.test.TerminologyTest;
+import java.util.Collections;
+import java.util.List;
+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.Parameters.ParametersParameterComponent;
+import org.hl7.fhir.r4.model.StringType;
+import org.hl7.fhir.r4.model.UriType;
+import org.hl7.fhir.r4.model.codesystems.ConceptMapEquivalence;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class DefaultTerminologyService2Test extends TerminologyTest {
+
+  private static final String VALUE_SET_X = "uuid:valueSetX";
+  private static final String VALUE_SET_Y = "uuid:valueSetY";
+
+  private static final String CONCEPT_MAP_0 = "uuid:conceptMap0";
+  private static final String CONCEPT_MAP_1 = "uuid:conceptMap1";
+
+  private static final List<Translation> EMPTY_TRANSLATION = Collections.emptyList();
+
+  @Nonnull
+  private static Parameters translation(@Nonnull final Translation... entries) {
+    final Parameters translateResponse = new Parameters()
+        .setParameter("result", true);
+
+    for (Translation entry : entries) {
+      final ParametersParameterComponent matchParameter1 = translateResponse.addParameter()
+          .setName("match");
+      matchParameter1.addPart().setName("equivalence")
+          .setValue(new CodeType(entry.getEquivalence().toCode()));
+      matchParameter1.addPart().setName("concept").setValue(entry.getConcept());
+    }
+    return translateResponse;
+  }
+
+
+  private TerminologyClient2 terminologClient;
+  private DefaultTerminologyService2 terminologyService;
+
+  @BeforeEach
+  void setUp() {
+    terminologClient = mock(TerminologyClient2.class);
+    terminologyService = new DefaultTerminologyService2(
+        terminologClient, null);
+  }
+
+  @Test
+  public void testValidateCodingTrue() {
+    when(terminologClient.validateCode(
+        deepEq(new UriType(VALUE_SET_X)),
+        deepEq(new UriType(SYSTEM_A)),
+        isNull(),
+        deepEq(new CodeType(CODE_A))
+    )).thenReturn(RESULT_TRUE);
+    assertTrue(terminologyService.validate(VALUE_SET_X, CODING_AA));
+  }
+
+  @Test
+  public void testValidateVersionedCodingFalse() {
+    when(terminologClient.validateCode(
+        deepEq(new UriType(VALUE_SET_Y)),
+        deepEq(new UriType(SYSTEM_B)),
+        deepEq(new StringType(VERSION_1)),
+        deepEq(new CodeType(CODE_B))
+    )).thenReturn(RESULT_FALSE);
+    assertFalse(terminologyService.validate(VALUE_SET_Y, CODING_BB_VERSION1));
+  }
+
+  @Test
+  public void testValidateInvalidCodings() {
+    assertFalse(terminologyService.validate(VALUE_SET_Y, INVALID_CODING_0));
+    assertFalse(terminologyService.validate(VALUE_SET_Y, INVALID_CODING_1));
+    assertFalse(terminologyService.validate(VALUE_SET_Y, INVALID_CODING_2));
+  }
+
+  @Test
+  public void testSubsumesNoVersion() {
+    when(terminologClient.subsumes(
+        deepEq(new CodeType(CODE_A)),
+        deepEq(new CodeType(CODE_B)),
+        deepEq(new UriType(SYSTEM_A)),
+        isNull()
+    )).thenReturn(OUTCOME_SUBSUMES);
+    assertEquals(SUBSUMES, terminologyService.subsumes(CODING_AA, CODING_AB));
+  }
+
+  @Test
+  public void testSubsumesLeftVersion() {
+    when(terminologClient.subsumes(
+        deepEq(new CodeType(CODE_A)),
+        deepEq(new CodeType(CODE_B)),
+        deepEq(new UriType(SYSTEM_A)),
+        deepEq(new StringType(VERSION_1))
+    )).thenReturn(OUTCOME_EQUIVALENT);
+    assertEquals(EQUIVALENT, terminologyService.subsumes(CODING_AA_VERSION1, CODING_AB));
+  }
+
+  @Test
+  public void testSubsumesRightVersion() {
+    when(terminologClient.subsumes(
+        deepEq(new CodeType(CODE_A)),
+        deepEq(new CodeType(CODE_B)),
+        deepEq(new UriType(SYSTEM_A)),
+        deepEq(new StringType(VERSION_2))
+    )).thenReturn(OUTCOME_SUBSUMEDBY);
+    assertEquals(SUBSUMEDBY, terminologyService.subsumes(CODING_AA, CODING_AB_VERSION2));
+  }
+
+  @Test
+  public void testSubsumesBothVersionTheSame() {
+    when(terminologClient.subsumes(
+        deepEq(new CodeType(CODE_A)),
+        deepEq(new CodeType(CODE_B)),
+        deepEq(new UriType(SYSTEM_A)),
+        deepEq(new StringType(VERSION_1))
+    )).thenReturn(OUTCOME_EQUIVALENT);
+    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);
+  }
+
+  @Test
+  public void testSubsumesDifferentSystems() {
+    assertEquals(NOTSUBSUMED, terminologyService.subsumes(CODING_AA, CODING_BB_VERSION1));
+    verifyNoMoreInteractions(terminologClient);
+  }
+
+  @Test
+  public void testSubsumesInvalidCodings() {
+    assertEquals(NOTSUBSUMED, terminologyService.subsumes(INVALID_CODING_0, INVALID_CODING_0));
+    assertEquals(NOTSUBSUMED, terminologyService.subsumes(INVALID_CODING_1, INVALID_CODING_1));
+    assertEquals(NOTSUBSUMED, terminologyService.subsumes(INVALID_CODING_2, INVALID_CODING_2));
+    assertEquals(NOTSUBSUMED, terminologyService.subsumes(INVALID_CODING_0, CODING_AB));
+    assertEquals(NOTSUBSUMED, terminologyService.subsumes(INVALID_CODING_1, CODING_AB));
+    assertEquals(NOTSUBSUMED, terminologyService.subsumes(INVALID_CODING_2, CODING_AB));
+    assertEquals(NOTSUBSUMED, terminologyService.subsumes(CODING_BB_VERSION1, INVALID_CODING_0));
+    assertEquals(NOTSUBSUMED, terminologyService.subsumes(CODING_BB_VERSION1, INVALID_CODING_1));
+    assertEquals(NOTSUBSUMED, terminologyService.subsumes(CODING_BB_VERSION1, INVALID_CODING_2));
+    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);
+  }
+
+
+  @Test
+  public void testTranslatesVersionedCodingWithDefaults() {
+
+    when(terminologClient.translate(
+        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);
+
+    assertEquals(EMPTY_TRANSLATION,
+        terminologyService.translate(CODING_AA_VERSION1, CONCEPT_MAP_0, false, null));
+  }
+
+  @Test
+  public void testTranslatesUnversionedCoding() {
+
+    final Parameters translationResponse = translation(
+        Translation.of(ConceptMapEquivalence.RELATEDTO, CODING_AA),
+        Translation.of(ConceptMapEquivalence.EQUIVALENT, CODING_AB),
+        Translation.of(ConceptMapEquivalence.SUBSUMES, CODING_AA_VERSION1),
+        Translation.of(ConceptMapEquivalence.NARROWER, CODING_AB_VERSION1)
+    );
+
+    when(terminologClient.translate(
+        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);
+
+    assertEquals(
+        List.of(
+            Translation.of(ConceptMapEquivalence.RELATEDTO, CODING_AA),
+            Translation.of(ConceptMapEquivalence.EQUIVALENT, CODING_AB),
+            Translation.of(ConceptMapEquivalence.SUBSUMES, CODING_AA_VERSION1),
+            Translation.of(ConceptMapEquivalence.NARROWER, CODING_AB_VERSION1)
+        ),
+        terminologyService.translate(CODING_B, CONCEPT_MAP_1, true, SYSTEM_A));
+  }
+
+  @Test
+  public void testTranslatesInvalidsCoding() {
+
+    assertEquals(EMPTY_TRANSLATION,
+        terminologyService.translate(INVALID_CODING_0, CONCEPT_MAP_0, false, null));
+    assertEquals(EMPTY_TRANSLATION,
+        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);
+  }
+
+}
+
+
diff --git a/terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyServiceTest.java b/terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyServiceTest.java
index dbfe292843..8dadf5e4fa 100644
--- a/terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyServiceTest.java
+++ b/terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyServiceTest.java
@@ -17,7 +17,7 @@
 
 package au.csiro.pathling.terminology;
 
-import static au.csiro.pathling.test.helpers.FhirDeepMatcher.deepEq;
+import static au.csiro.pathling.test.helpers.FhirMatchers.deepEq;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.argThat;
diff --git a/terminology/src/test/java/au/csiro/pathling/terminology/TerminologyAuthenticationTest.java b/terminology/src/test/java/au/csiro/pathling/terminology/TerminologyAuthenticationTest.java
index a8fb226456..c59d5b6f15 100644
--- a/terminology/src/test/java/au/csiro/pathling/terminology/TerminologyAuthenticationTest.java
+++ b/terminology/src/test/java/au/csiro/pathling/terminology/TerminologyAuthenticationTest.java
@@ -58,6 +58,7 @@
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+@SuppressWarnings("NullableProblems")
 @Slf4j
 public class TerminologyAuthenticationTest {
 
@@ -93,7 +94,7 @@ void setUp() {
     fhirContext = FhirContext.forR4();
     final TerminologyClient terminologyClient = TerminologyClient.build(fhirContext,
         "http://localhost:" + WIREMOCK_PORT + "/fhir", 60_000, true,
-        authConfig, log);
+        authConfig);
     terminologyService = new DefaultTerminologyService(fhirContext, terminologyClient,
         UUID::randomUUID);
 
@@ -167,7 +168,7 @@ void withoutScope() {
 
     final TerminologyClient terminologyClient = TerminologyClient.build(fhirContext,
         "http://localhost:" + WIREMOCK_PORT + "/fhir", 60_000, false,
-        authConfig, log);
+        authConfig);
     terminologyService = new DefaultTerminologyService(fhirContext, terminologyClient,
         UUID::randomUUID);
 
@@ -194,7 +195,7 @@ void expired() throws InterruptedException {
     fhirContext = FhirContext.forR4();
     final TerminologyClient terminologyClient = TerminologyClient.build(fhirContext,
         "http://localhost:" + WIREMOCK_PORT + "/fhir", 60_000, false,
-        authConfig, log);
+        authConfig);
     terminologyService = new DefaultTerminologyService(fhirContext, terminologyClient,
         UUID::randomUUID);
 
diff --git a/terminology/src/test/java/au/csiro/pathling/test/TerminologyTest.java b/terminology/src/test/java/au/csiro/pathling/test/TerminologyTest.java
new file mode 100644
index 0000000000..36d45309a1
--- /dev/null
+++ b/terminology/src/test/java/au/csiro/pathling/test/TerminologyTest.java
@@ -0,0 +1,62 @@
+package au.csiro.pathling.test;
+
+import au.csiro.pathling.fhirpath.encoding.CodingEncoding;
+import java.util.stream.Stream;
+import javax.annotation.Nonnull;
+import org.apache.spark.sql.Row;
+import org.hl7.fhir.r4.model.Coding;
+import scala.collection.mutable.WrappedArray;
+
+public abstract class TerminologyTest {
+
+  public static final String SYSTEM_A = "uuid:systemA";
+  public static final String CODE_A = "codeA";
+  public static final String SYSTEM_B = "uuid:systemB";
+  public static final String CODE_B = "codeB";
+  public static final String SYSTEM_C = "uuid:systemC";
+  public static final String CODE_C = "codeC";
+  public static final String SYSTEM_D = "uuid:systemD";
+  public static final String CODE_D = "codeD";
+
+  public static final Coding CODING_AA = new Coding(SYSTEM_A, CODE_A, "displayAA");
+  public static final Coding CODING_AB = new Coding(SYSTEM_A, CODE_B, "displayAB");
+
+  public static final String VERSION_1 = "version1";
+  public static final String VERSION_2 = "version2";
+
+  public static final Coding CODING_AA_VERSION1 = new Coding(SYSTEM_A, CODE_A,
+      "displayAA").setVersion(
+      VERSION_1);
+  public static final Coding CODING_AB_VERSION1 = new Coding(SYSTEM_A, CODE_B,
+      "displayAB").setVersion(
+      VERSION_1);
+  public static final Coding CODING_AB_VERSION2 = new Coding(SYSTEM_A, CODE_B,
+      "displayAB").setVersion(
+      VERSION_2);
+
+  public static final Coding CODING_BA = new Coding(SYSTEM_B, CODE_A, "displayBA");
+  public static final Coding CODING_BB = new Coding(SYSTEM_B, CODE_B, "displayBB");
+  public static final Coding CODING_BB_VERSION1 = new Coding(SYSTEM_B, CODE_B,
+      "displayB").setVersion(
+      VERSION_1);
+
+  public static final Coding CODING_A = CODING_AA;
+  public static final Coding CODING_B = CODING_BB;
+  public static final Coding CODING_C = new Coding(SYSTEM_C, CODE_C, "displayCC");
+  public static final Coding CODING_D = new Coding(SYSTEM_D, CODE_D, "displayDD");
+
+  public static final Coding INVALID_CODING_0 = new Coding(null, null, "");
+  public static final Coding INVALID_CODING_1 = new Coding("uiid:system", null, "");
+  public static final Coding INVALID_CODING_2 = new Coding(null, "someCode", "");
+
+
+  @Nonnull
+  public static Row[] asArray(@Nonnull final Coding... codings) {
+    return Stream.of(codings).map(CodingEncoding::encode).toArray(Row[]::new);
+  }
+
+  @Nonnull
+  public static WrappedArray<Row> encodeMany(Coding... codings) {
+    return WrappedArray.make(Stream.of(codings).map(CodingEncoding::encode).toArray(Row[]::new));
+  }
+}
diff --git a/terminology/src/test/java/au/csiro/pathling/test/helpers/FhirMatchers.java b/terminology/src/test/java/au/csiro/pathling/test/helpers/FhirMatchers.java
new file mode 100644
index 0000000000..a78fc84ed7
--- /dev/null
+++ b/terminology/src/test/java/au/csiro/pathling/test/helpers/FhirMatchers.java
@@ -0,0 +1,74 @@
+/*
+ * 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.test.helpers;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import org.hl7.fhir.r4.model.Base;
+import org.hl7.fhir.r4.model.Coding;
+import org.mockito.ArgumentMatcher;
+import org.mockito.Mockito;
+import java.io.Serializable;
+
+import static au.csiro.pathling.test.helpers.TerminologyHelpers.codingEquals;
+
+public final class FhirMatchers {
+
+  private FhirMatchers() {
+  }
+
+  static class FhirDeepMatcher<T extends Base> implements ArgumentMatcher<T>, Serializable {
+
+    private static final long serialVersionUID = -7388475686640982425L;
+    @Nonnull
+    private final T expected;
+
+    private FhirDeepMatcher(@Nonnull final T expected) {
+      this.expected = expected;
+    }
+
+    @Override
+    public boolean matches(@Nullable final T actual) {
+      return expected.equalsDeep(actual);
+    }
+  }
+
+  private static class CodingMatcher implements ArgumentMatcher<Coding>, Serializable {
+
+    private static final long serialVersionUID = -4669154939085510783L;
+    @Nonnull
+    private final Coding expected;
+
+    private CodingMatcher(@Nonnull final Coding expected) {
+      this.expected = expected;
+    }
+
+    @Override
+    public boolean matches(@Nullable final Coding actual) {
+      return codingEquals(expected, actual);
+    }
+  }
+
+  public static <T extends Base> T deepEq(@Nonnull final T expected) {
+    return Mockito.argThat(new FhirDeepMatcher<>(expected));
+  }
+
+  public static Coding codingEq(@Nonnull final Coding expected) {
+    return Mockito.argThat(new CodingMatcher(expected));
+  }
+}
diff --git a/terminology/src/test/java/au/csiro/pathling/test/helpers/TerminologyHelpers.java b/terminology/src/test/java/au/csiro/pathling/test/helpers/TerminologyHelpers.java
index 17b31d773c..edd84f6e5a 100644
--- a/terminology/src/test/java/au/csiro/pathling/test/helpers/TerminologyHelpers.java
+++ b/terminology/src/test/java/au/csiro/pathling/test/helpers/TerminologyHelpers.java
@@ -25,6 +25,7 @@
 import java.util.Set;
 import java.util.stream.Collectors;
 import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
 import org.hl7.fhir.r4.model.CodeableConcept;
 import org.hl7.fhir.r4.model.Coding;
 import org.hl7.fhir.r4.model.Enumerations.ConceptMapEquivalence;
@@ -37,6 +38,7 @@ private TerminologyHelpers() {
 
   public static final String SNOMED_URI = "http://snomed.info/sct";
   public static final String SNOMED_VERSION = "http://snomed.info/sct/32506021000036107/version/20210331";
+  public static final String SNOMED_VERSION_20220930 = "http://snomed.info/sct/32506021000036107/version/20220930";
 
   public static final String AUTOMAP_INPUT_URI = "http://ontoserver.csiro.au/fhir/CodeSystem/codesystem-terms";
 
@@ -53,20 +55,38 @@ private TerminologyHelpers() {
 
   public static final Coding CD_SNOMED_720471000168102 = snomedCoding("720471000168102",
       "Duodopa intestinal gel");
+
+
+  public static final Coding CD_SNOMED_720471000168102_VER2021 = snomedVersionedCoding(
+      "720471000168102",
+      "Duodopa intestinal gel");
+
+
   public static final Coding CD_SNOMED_72940011000036107 = snomedCoding("72940011000036107",
       "Duodopa gel: intestinal");
 
 
   public static final Coding CD_SNOMED_403190006 = snomedCoding("403190006",
-      "Epidermal burn of skin");
+      "First degree burn");
 
   public static final Coding CD_SNOMED_284551006 = snomedCoding("284551006",
       "Laceration of foot");
 
+
+  public static final Coding CD_SNOMED_444814009 = snomedCoding("444814009",
+      "Viral sinusitis (disorder)");
+
+  public static final Coding CD_SNOMED_195662009 = snomedCoding("195662009",
+      "Acute viral pharyngitis (disorder)");
+
+  public static final Coding CD_SNOMED_40055000 = snomedCoding("40055000",
+      "Chronic sinusitis (disorder)");
+
   @SuppressWarnings("unused")
   public static Coding CD_SNOMED_63816008 = snomedCoding("63816008",
       "Left hepatectomy");
 
+
   // Subsumes CD_SNOMED_63816008
   public static final Coding CD_SNOMED_107963000 = snomedCoding("107963000",
       "Liver resection");
@@ -74,6 +94,9 @@ private TerminologyHelpers() {
   public static final Coding CD_SNOMED_VER_63816008 = snomedVersionedCoding("63816008",
       "Left hepatectomy");
 
+  public static final Coding CD_SNOMED_63816008_VER2022 = newVersionedCoding(SNOMED_URI, "63816008",
+      SNOMED_VERSION_20220930, "Left hepatectomy");
+
   // Subsumes CD_SNOMED_63816008
   public static final Coding CD_SNOMED_VER_107963000 = snomedVersionedCoding("107963000",
       "Liver resection");
@@ -95,12 +118,12 @@ private TerminologyHelpers() {
   private static final Coding CD_SNOMED_VER_444814009 = snomedVersionedCoding("444814009",
       "Viral sinusitis (disorder)");
 
-  public static final Coding CD_AUTOMAP_INPUT_DESCRIPTION_ID = new Coding(AUTOMAP_INPUT_URI,
-      "101013", null);
-
   public static final Relation REL_SNOMED_444814009_SUBSUMES_40055000 = RelationBuilder.empty()
       .add(CD_SNOMED_VER_444814009, CD_SNOMED_VER_40055000).build();
 
+  public static final Coding CD_AUTOMAP_INPUT_DESCRIPTION_ID = new Coding(AUTOMAP_INPUT_URI,
+      "101013", null);
+
   @Nonnull
   public static SimpleCoding snomedSimple(@Nonnull final String code) {
     return new SimpleCoding(SNOMED_URI, code);
@@ -159,10 +182,32 @@ public static Coding snomedCoding(@Nonnull final String code,
   }
 
   @Nonnull
-  private static Coding snomedVersionedCoding(@Nonnull final String code,
+  public static Coding snomedVersionedCoding(@Nonnull final String code,
       @Nonnull final String displayName) {
     return newVersionedCoding(SNOMED_URI, code, SNOMED_VERSION, displayName);
   }
 
 
+  @Nonnull
+  public static Coding mockCoding(@Nonnull final String system, @Nonnull final String code,
+      final int index) {
+    return new Coding(system, code + "-" + index, "Display-" + index);
+  }
+
+  public static boolean codingEquals(@Nullable final Coding left,
+      @Nullable final Coding right) {
+    if (left == null) {
+      return right == null;
+    } else {
+      return right != null &&
+          (left.hasSystem()
+           ? left.getSystem().equals(right.getSystem())
+           : !right.hasSystem()) &&
+          (left.hasCode()
+           ? left.getCode().equals(right.getCode())
+           : !right.hasCode()) &&
+          (!left.hasVersion() || left.getVersion().equals(right.getVersion()));
+    }
+  }
+
 }
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
new file mode 100644
index 0000000000..d2d7d40661
--- /dev/null
+++ b/terminology/src/test/java/au/csiro/pathling/test/helpers/TerminologyServiceHelpers.java
@@ -0,0 +1,182 @@
+package au.csiro.pathling.test.helpers;
+
+import static au.csiro.pathling.test.helpers.FhirMatchers.codingEq;
+import static au.csiro.pathling.test.helpers.TerminologyHelpers.codingEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.when;
+
+import au.csiro.pathling.terminology.TerminologyService2;
+import au.csiro.pathling.terminology.TerminologyService2.Translation;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.IntStream;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import org.hl7.fhir.r4.model.Coding;
+import org.hl7.fhir.r4.model.Parameters;
+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.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);
+  public final static Parameters RESULT_FALSE = new Parameters().setParameter("result", false);
+
+  public final static Parameters OUTCOME_EQUIVALENT = new Parameters().setParameter("outcome",
+      EQUIVALENT.toCode());
+  public final static Parameters OUTCOME_SUBSUMES = new Parameters().setParameter("outcome",
+      SUBSUMES.toCode());
+  public final static Parameters OUTCOME_SUBSUMEDBY = new Parameters().setParameter("outcome",
+      SUBSUMEDBY.toCode());
+
+  public static class ValidateExpectations {
+
+    @Nonnull
+    private final TerminologyService2 mockService;
+
+    ValidateExpectations(@Nonnull final TerminologyService2 mockService) {
+      this.mockService = mockService;
+      clearInvocations(mockService);
+      when(mockService.validate(any(), any())).thenReturn(false);
+    }
+
+    @Nonnull
+    public ValidateExpectations withValueSet(@Nonnull final String valueSetUrl,
+        @Nonnull final Coding... codings) {
+      for (Coding coding : codings) {
+        when(mockService.validate(eq(valueSetUrl), codingEq(coding))).thenReturn(true);
+      }
+      return this;
+    }
+
+    @SuppressWarnings("UnusedReturnValue")
+    @Nonnull
+    public ValidateExpectations fromValueSet(@Nonnull final String valueSetUri,
+        @Nonnull final ValueSet valueSet) {
+      final Coding[] codings = valueSet.getExpansion().getContains().stream()
+          .map(contains -> new Coding(contains.getSystem(), contains.getCode(),
+              contains.getDisplay()))
+          .toArray(Coding[]::new);
+      return withValueSet(valueSetUri, codings);
+    }
+  }
+
+  public static class TranslateExpectations {
+
+    @Nonnull
+    private final TerminologyService2 mockService;
+
+    TranslateExpectations(@Nonnull final TerminologyService2 mockService) {
+      this.mockService = mockService;
+      clearInvocations(mockService);
+      when(mockService.translate(any(), any(), anyBoolean(), isNull())).thenReturn(
+          Collections.emptyList());
+    }
+
+    public TranslateExpectations withTranslations(@Nonnull final Coding coding,
+        @Nonnull final String conceptMapUrl,
+        @Nonnull final Translation... translations) {
+      return withTranslations(coding, conceptMapUrl, false, translations);
+    }
+
+
+    public TranslateExpectations withMockTranslations(@Nonnull final Coding sourceCoding,
+        @Nonnull final String conceptMapUrl, @Nonnull final String toSystem,
+        final int noOfMappings) {
+
+      final Translation[] translations = IntStream.range(0, noOfMappings)
+          .mapToObj(i -> TerminologyHelpers.mockCoding(toSystem, sourceCoding.getCode(), i))
+          .map(coding -> Translation.of(ConceptMapEquivalence.EQUIVALENT, coding))
+          .toArray(Translation[]::new);
+
+      return withTranslations(sourceCoding, conceptMapUrl, false, translations);
+    }
+
+    public TranslateExpectations withTranslations(
+        @Nonnull final Coding coding,
+        @Nonnull final String conceptMapUrl,
+        final boolean reverse, @Nonnull final Translation... translations) {
+
+      return withTranslations(coding, conceptMapUrl, reverse, null, translations);
+    }
+
+    public TranslateExpectations withTranslations(
+        @Nonnull final Coding coding,
+        @Nonnull final String conceptMapUrl,
+        final boolean reverse,
+        @Nullable final String target,
+        @Nonnull final Translation... translations) {
+
+      when(
+          mockService.translate(codingEq(coding), eq(conceptMapUrl), eq(reverse),
+              eq(target))).thenReturn(List.of(translations));
+      return this;
+    }
+  }
+
+  public static class SubsumesExpectations {
+
+    @Nonnull
+    private final TerminologyService2 mockService;
+
+    private static class DefaultAnswer implements Answer<ConceptSubsumptionOutcome> {
+
+      @Override
+      public ConceptSubsumptionOutcome answer(final InvocationOnMock invocationOnMock) {
+        final Coding codingA = invocationOnMock.getArgument(0);
+        final Coding codingB = invocationOnMock.getArgument(1);
+
+        if (codingA != null && codingEquals(codingA, codingB)) {
+          return EQUIVALENT;
+        } else {
+          return NOTSUBSUMED;
+        }
+      }
+    }
+
+    public SubsumesExpectations(@Nonnull final TerminologyService2 mockService) {
+      this.mockService = mockService;
+      clearInvocations(mockService);
+      doAnswer(new DefaultAnswer()).when(mockService).subsumes(any(), any());
+    }
+
+    public SubsumesExpectations withSubsumes(@Nonnull final Coding codingA,
+        @Nonnull final Coding codingB) {
+      when(mockService.subsumes(codingEq(codingA), codingEq(codingB))).thenReturn(SUBSUMES);
+      when(mockService.subsumes(codingEq(codingB), codingEq(codingA))).thenReturn(
+          SUBSUMEDBY);
+      return this;
+    }
+  }
+
+  public static ValidateExpectations setupValidate(@Nonnull final TerminologyService2 mockService) {
+    return new ValidateExpectations(mockService);
+  }
+
+
+  public static TranslateExpectations setupTranslate(
+      @Nonnull final TerminologyService2 mockService) {
+    return new TranslateExpectations(mockService);
+  }
+
+
+  public static SubsumesExpectations setupSubsumes(
+      @Nonnull final TerminologyService2 mockService) {
+    return new SubsumesExpectations(mockService);
+  }
+
+
+}