Skip to main content

Y# Specialisations

Overview

The ability to specialise not only pipelines but also connectors is one of Variant's key strengths, particularly when combined with extension packages that can be shared across multiple projects. Specialisations enable developers to encapsulate standardised functionality—like JWT generation, validation, or complex logic chains—into reusable, self-documenting components.

Specialisations come in three forms:

  • Pipe specialisations

  • Process specialisations

  • Process specialisations with implementation injection

We’ll explore each in detail, along with a deeper dive into the replacement/default mechanics and common pitfalls.

[!Note] Versioned local extension packages are ideal for storing reusable specialisations such as shared queue handlers, blob storage utilities, or API clients.

Pipes and strategies

Before we look at specialisations we should have a look at exactly what pipes and strategies are in Y#. We'll do this by looking at definitions of the following pipes through to how they are combined to create pipe specialisation

  • ModifyMessagePipe
  • ModifyMessageStrategy

ModifyMessagePipe Definition

- key: Variant.Core.ModifyMessagePipe
value:
type: Variant.Strategies.Core.ModifyMessagePipe, Variant.Strategies.Core, Version=1.1.0.0, Culture=neutral, PublicKeyToken=null
replacements:
MODIFY_MESSAGE_STRATEGY:
CAN_EXECUTE_STRATEGY:
EXECUTION_FLOW_STRATEGY:
CAN_EXECUTE_EXPRESSION:
INSTANCE_NAME:
ACTIVITY_NAME:
IS_ENABLED:
CONTINUATION_POLICY: Default # [Default | ReturnIfExecuted | ContinueOnNotExecuted | ContinueOnError | ReturnOnSuccess | ReturnIfNotExecuted]
CONTINUE_ON_ERROR_EXCEPTION_NS: Exception
# ... Additional settings removed for brevity

This pipe includes three core functional areas, which are common to most pipes:

  1. Strategy – Defines how to handle the PushMessage, allowing different implementation types.
  2. Execution Flow – Determines whether the pipe should run, and what to do afterward.
  3. Instrumentation – Optionally adds OpenTelemetry metadata to the execution context.

ModifyMessageStrategy Definition

This strategy modifies the LokiMessage, which is created in the connection and passed through each pipe.

- key: Variant.Core.ModifyMessageStrategy
value:
type: Variant.Strategies.Core.ModifyMessageStrategy, Variant.Strategies.Core, Version=1.1.0.0, Culture=neutral, PublicKeyToken=null
replacements:
VALUE:
TEMP_VALUES:
INSTANCE_NAME:
NAMESPACE: Response
APPEND_VALUE: False
IGNORE_SUBSTITUTIONS: False
FORCE_DEEP_COPY_OF_J_TOKEN: False
# ... Additional settings removed for brevity

Specialisations

Pipe Specialisation

If you were to use these components in a pipeline without specialisation, it might look like this:

- pipe: Variant.Core.ModifyMessagePipe
PUSH_MESSAGE_STRATEGY:
strategy: Variant.Core.ModifyMessageStrategy
VALUE: Hello world!!
TEMP_VALUES:
INSTANCE_NAME:
NAMESPACE: Response
APPEND_VALUE: False
IGNORE_SUBSTITUTIONS: False
FORCE_DEEP_COPY_OF_J_TOKEN: False
LOG_ERRORS: True
CAN_EXECUTE_STRATEGY:
EXECUTION_FLOW_STRATEGY:
CAN_EXECUTE_EXPRESSION:
INSTANCE_NAME:
ACTIVITY_NAME:
IS_ENABLED:
CONTINUATION_POLICY: Default
CONTINUE_ON_ERROR_EXCEPTION_NS: Exception

Most of these values are defaults and automatically inserted by IntelliSense. You can simplify this to:

- pipe: Variant.Core.ModifyMessagePipe
PUSH_MESSAGE_STRATEGY:
strategy: Variant.Core.ModifyMessageStrategy
NAMESPACE: Response
VALUE: Hello world!!!

This can be simplified further by using a custom specialised pipe:

- key: Variant.Core.MyModifyMessageStrategyPipe
value:
pipe: Variant.Core.ModifyMessagePipe
replacements:
NAMESPACE: Response
VALUE:
defaults:
PUSH_MESSAGE_STRATEGY:
strategy: Variant.Core.ModifyMessageStrategy

Now the usage becomes:

- pipe: Variant.Core.MyModifyMessageStrategyPipe
NAMESPACE: Response
VALUE: HelloWorld

And we can specialise this further:

- key: Variant.Core.SetResponse
value:
pipe: Variant.Core.MyModifyMessageStrategyPipe
replacements:
RESPONSE:
defaults:
VALUE!: RESPONSE
NAMESPACE!: Response

Resulting in this final, clean invocation:

- pipe: Variant.Core.SetResponse
RESPONSE: HelloWorld

These are examples of pipe specialisations, but Y# also supports process specialisations, where multiple pipes can be grouped and reused as a unit. We’ll cover those in a later section. For now, remember that specialisations are one of the most powerful constructs in Y#, enabling rapid creation of reusable, testable components.

[!Note] The differences between the defaults and replacements properties are explains [[#Code stack, replacements, defaults and pitfalls#Replacements & defaults|here.]]

Process Specialisations

Process specialisations occur when multiple pipes are combined into a single, callable pipe.

For example, suppose we need to send an email with an attachment, and all email requests must be placed on a queue. However, since attachments might be too large to fit within the queue message, we need a different approach. The solution is to write the attachment to blob storage and then place a reference link to it in the queue message.

Without using any process or pipe specialisation, the logic would look like this:

Instead, this can be encapsulated into a single specialised pipe:

This specialised pipe can then be included in an extension package. In the future, anyone needing to send an email can simply add the extension to their project and use it like this:

Process Specialisations With Implementation Injection

As we saw above, we can inject specific values such as email addresses and paths into a pipe. However, it’s also possible to inject both specification pipes and process specification pipes within a process specialisation.

For instance, if we want to read a blob from Azure Storage, update its contents, and then write it back, we can define a general process for fetching and storing data—regardless of the specific document type being updated.

The example below demonstrates how this can be implemented and invoked:

To use the above implementation, see below:

In practice, you could create another specification based on the Variant.AzureStorage.BlockBlob.GetAndUpdateFileAsString pipe shown above. This allows developers to apply specific updates to particular storage accounts:

This enables the user to call the pipe using the following:

[!Note] If the implementation pipe you want to inject contains multiple pipes, you can wrap it in a Variant.Core.DefaultScopedPipe and assign your implementations to its SCOPED_PIPES setting.

Connector Specialisations

Connections, just like pipes, can be specialised. If you're using the default Azure Storage REST extension to read data from a queue, you would define it from scratch like this:

connections:
  - connector: Variant.Azure.Storage.Rest.Queue.TimerConnector
    DEFAULTS: 
    ACTIVITY_NAME: 
    DUE_TIME: 0:0:01
    POLLING_INTERVAL: 00:00:15
    STORAGE_ACCOUNT_NAME: 
    QUEUE_AUTHORIZATION_PIPE: 
    QUEUE_NAME: 
    QUEUE_NUM_OF_MESSAGES: 32
    QUEUE_BATCH_SIZE: 8
    QUEUE_VISIBILITY_TIMEOUT_IN_SECONDS: 30
    QUEUE_MAX_DEQUEUE_COUNT: 2
    QUEUE_ITEM_NS: QueueItem
    QUEUE_ON_ERROR_HIDE_MESSAGE_FOR_N_SECONDS: 5
    QUEUE_DEAD_LETTER_PIPES: []
    QUEUE_PROCESS_PIPES: 
      # Set Request header 
      - pipe: Variant.Core.Simple.ModifyMessageStrategyPipe
        NAMESPACE: Request
        VALUE: ${QueueItem.MessageText.ConvertBase64ToString().ConvertStringToJToken()}

  # Do work here ...

If we look at the key: Variant.Azure.Storage.Environment.Queue.TimerConnector, which is part of a shared extension in a new project, we can see how it has been specialised to define the desired authentication and storage configuration:

  - key: Variant.Azure.Storage.Environment.Queue.TimerConnector
    value:
      connector: Variant.Azure.Storage.Rest.Queue.TimerConnector
      replacements:
        DEFAULTS: 
        ACTIVITY_NAME: 
        DUE_TIME: 0:0:01
        POLLING_INTERVAL: 00:00:15
        QUEUE_NAME: 
        QUEUE_NUM_OF_MESSAGES: 32
        QUEUE_BATCH_SIZE: 8
        QUEUE_VISIBILITY_TIMEOUT_IN_SECONDS: 30
        QUEUE_MAX_DEQUEUE_COUNT: 2
        QUEUE_ITEM_NS: QueueItem
        QUEUE_ON_ERROR_HIDE_MESSAGE_FOR_N_SECONDS: 5
        QUEUE_DEAD_LETTER_PIPES: []
        QUEUE_PROCESS_PIPES: |2
              # Set Request header 
              - pipe: Variant.Core.Simple.ModifyMessageStrategyPipe
                NAMESPACE: Request
                VALUE: ${QueueItem.MessageText.ConvertBase64ToString().ConvertStringToJToken()}
      defaults: 
        STORAGE_ACCOUNT_NAME!: $[Environment.Storage.Name]
        QUEUE_AUTHORIZATION_PIPE!: { pipe: Variant.Azure.Identity.CreateEnvironmentCachedManagedIdentityTokenStrategyPipe }

Deeper Dive and Pitfalls

Code Stack

Before diving into replacements and defaults, it helps to understand how pipes, strategies, and connectors are transformed into executable code. All pipes and strategies are resolved into .NET code, as shown below:

The assemblies that contain this code are uploaded to the platform, which then generates the following YAML configuration:

As seen before, multiple specialisations can then be created from this YAML:

These specialisations, defined using replacements and defaults, are then used to instantiate the C# code with all relevant values.

Replacements & Defaults

When creating specialised pipes, settings can be applied using either replacements or defaults. The difference between them is how the editor handles them:

  • Replacements: These appear in word-completion suggestions. If a replacement value is deleted, the editor will fall back to the default.

  • Defaults: These values are still settable, even if not shown in completions. This includes shared pipe settings like LOG_ERRORS, IS_ENABLED, EXECUTION_FLOW_STRATEGY, INSTANCE_NAME, etc.

Pitfalls

Since replacement values can override values from higher-level pipes, care must be taken—especially with commonly named properties like NAMESPACE, which could lead to unintended overrides.

To prevent this, you can append an exclamation mark (!) to the end of a setting name. This ensures the value cannot be overridden. For example:

By adding ! to ACCOUNT_NAME and ACCOUNT_KEY, these settings remain fixed and won't be overridden by any parent pipe configuration.