Skip to main content

The anatomy of a Variant application

Variant is a low-code development platform that transforms YAML-based logic (Y# files), resource files, and .NET Core assemblies into a deployable Azure Function App or containerized application. This section provides a high-level overview of the application’s components, message flow, and structural behaviour.

Instead of relying on handwritten C# code, Variant uses YAML as a declarative abstraction layer over a modified .NET Core runtime. The result is a consistent development experience that supports testable, pluggable, and composable services.

Each application is built by combining several distinct types of files:

  • The Variant runtime: The underlying execution engine that supports hosting, pipelines, logging, and messaging.

  • Extension packages: Optional assemblies that introduce additional pipes, connectors, and strategies.

  • Configuration files: Define settings and external dependencies (e.g., appsettings.json, Startup.yaml).

  • YAML code / Y# files: The primary business logic and service definitions.

  • Reference files: Static assets or schemas used at runtime (e.g., OpenApi specs, static JSONs).

When a service is deployed or a release is created, the Variant platform compiles all components into a single .NET Core ZIP artifact. This artifact can be hosted on Azure Functions or within a custom container, allowing flexibility in deployment models and environments.

The Runtime

The runtime is a versioned, core execution engine packaged with every deployed service. It includes a suite of built-in capabilities essential for message processing and orchestration. Variant currently supports two hosting targets:

  • Azure Function App (isolated worker process)

  • Kestrel-based containerized app

Both runtime targets provide:

  • HTTP endpoint support

  • Background service execution

  • Integrated logging and telemetry (via Open Telemetry)

  • Environment configuration and substitution resolution

  • Inline code execution

  • Base classes for extending the platform via custom assemblies


Extension Packages

Extension packages are modular assemblies that enhance the base platform with reusable components. They can include:

  • Predefined YAML-mapped connectors, pipes, and strategies: For example, libraries for Azure services, storage, APIs, and queues.

  • Compiled .NET Core code: Developers can write custom implementations based on the Variant SDK, which are then automatically exposed via YAML metadata.

  • Custom substitution functions: Extend the substitution engine to provide reusable runtime expressions.

  • Reusable service fragments: Promote shared logic by packaging complex integrations or behaviours for reuse across teams or applications.

These packages streamline development by reducing boilerplate and centralizing logic where appropriate. They can be consumed from public or private repositories.


Configuration Files

There are three primary configuration files used in a Variant application:

  • appsettings.json
    This is a standard .NET Core configuration file that holds application-level settings such as API keys, environment-specific flags, connection strings, and custom values used across your Y# code.

  • site-config.json
    A project-specific metadata file automatically managed by the platform. It tracks extension packages, project structure, and deployment-related metadata. This file typically does not need to be manually edited.

  • Startup.yaml
    Defines the service's startup behavior and any external dependencies. This includes:

    • Binding configuration sources (e.g., Key Vault, Azure App Configuration)
    • Defining listeners and routing connectors
    • Registering service-specific authentication, diagnostic, and infrastructure behavior

YAML code or Y# files

Variant applications work on the idea of a message that is processed through a series of pipes i.e. a pipeline. That message is created by a certain event or timer and that message then flows down the pipes being processed and updated by each pipe as and when required . Variant determines where these messages come from and configured through 4 different types of objects:

  • Connections
  • Endpoints
  • Pipes
  • Strategies

Connections, connectors and connector.

An application can contain multiple inbound connectors. and these fall into 2 categories:

  • Listeners: Waits for to external systems to contact it. Implementation examples include: an HTTP endpoints,, Service bus listeners an endpoint connected to Twitter or a web sockets.
  • Timers: Interval or schedule timers can be connected to intents that pull a single message or multiple messages and then process those items or just fire off an instruction to start off another type of process. Example of these include reading a database for a list of users to perform some action on each or polling a queue for messages

Below is an example of the main API connector found in the default service StartUp page.

# ###################
# CONNECTIONS
# ###################
connections:
  #
  # WEB API ENTRY POINT
  #
  - connector: Variant.AzureFunctions.AzureFunctionIsolatedConnector
    ADD_C_O_R_S: $[Variant.Environment.IsDevelopmentEnvironment]
    pipeline:

       - pipe: Variant.Core.Simple.DefaultScopedPipe
         SCOPED_PIPES: 
           # Health and integration tests have there own security keys
           - pipe: Variant.Core.BreakPipe
             CAN_EXECUTE_EXPRESSION: >-
               ${Request.Property.LocalPath} == "/openapi.json" ||
               ${Request.Property.LocalPath} == "/health" || 
               ${Request.Property.LocalPath} == "/api/integrationtests"
           # Temp access rights. For production either replace   
           # this or update it with a key stored securely
           - pipe: Variant.Core.AccessKeyBreakPipe
             ACCESS_KEY: $[AccessKeys.Default]
             HEADER: Authorization

      - pipe: Variant.Core.WebApi.WebApiPushMessageStrategyPipe
      - pipe: Variant.Json.ConvertObjectToJsonStrategyPipe
        DATA_HEADER: Response
        NAMESPACE: Response

The AzureFunctionIsolatedConnector is the primary API endpoint connector for managing API calls using functionsApps. All API calls go through this connector and are routed on the their specific API endpoint.

Endpoints

Endpoints are routed HTTP connections triggered by the runtime. They use:

  • AzureFunctionIsolatedConnector for Azure Function Apps
  • KestrelAspConnector for containerized apps

These listeners are configured differently from standard connectors but share the same pipeline property, which defines the workflow logic.

In addition to the pipeline, an endpoint can define the request body, query parameters, headers, OpenAPI specification, and response definitions. These features enable OpenAPI generation and input validation. Here’s an example:

- routeTemplate: api/greeting
routeMethod: POST
routeOpenApi:
tags: [Examples]
description: Greeting API using JSON
security: [DefaultAccessKeyAuth: []]
requestBody: ${OpenApi.Requests.Greeting}
responses:
200: ${OpenApi.Responses.ExampleResponse}
401: ${OpenApi.Responses.AuthorizationError}
pipeline:
- pipe: Variant.Json.ConvertAndValidateRequest
NAMESPACE: Request

# Set response
- pipe: Variant.Core.SetResponse
RESPONSE:
greeting: $[Salutation] ${Request.name.ToUpper()} :)

Pipes

Each endpoint, listener, or connector includes a pipeline where the actual work of the service is done.

There are two types of pipes in Y#:

1. Strategy-Based Pipes

These pipes delegate their core logic to an associated strategy implementation.

Examples:

  • PushMessagePipe – Pushes data to an external system. Strategies may include:

    • HTTP calls
    • File writers
    • Database upserts
  • ModifyMessagePipe – Modifies the LokiMessage. Example strategies include:

    • Encryption/Decryption
    • Compression
    • JSON serialization/deserialization
    • Value manipulation
  • AggregatePipe – Aggregates values across messages:

    • StringBuilderAggregate
    • JArrayAggregate

2. Functional-Based Pipes

These pipes contain higher-level control flow logic and may include nested pipelines.

Examples:

  • ForEachPipe – Iterates over arrays
  • TryCatchScopedPipe – Provides error handling
  • WhilePipe – Looping constructs
  • LoggerPipe – Adds log entries

Common Pipe Features

Many pipes share common properties that support observability and control flow:

  • INSTANCE_NAME and ACTIVITY_NAME – Integrate with Variant’s built-in OpenTelemetry framework.
  • Pipes that derive from VariantConditionPipe can control their execution using:
    • CAN_EXECUTE_EXPRESSION
    • CONTINUATION_POLICY:
      Default | ReturnIfExecuted | ContinueOnNotExecuted | ContinueOnError | ReturnOnSuccess | ReturnIfNotExecuted

Strategies

Strategies are atomic, code-based units of functionality. They cannot be executed independently but are composed into pipes or other strategies.

A good example is the ICacheStrategy interface, which provides different caching implementations. Strategies include:

  • IRedisCacheStrategy
  • InMemoryCacheStrategy

These can be used in:

  • CacheScopedPipe
  • Directly in pipes like HttpPushMessageStrategy

This modular approach allows you to easily swap out implementations without changing pipe logic.


Pipes, Strategies, Specialisations, and Reuse

We’ll explore specialisations more [[specialisations-and-derivatives]] in depth but for now, let’s take a quick look at how Y# handles them — using an API response-setting scenario as an example.

To configure this, we use two key prebuilt components:

  • 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

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!!!

Pipe Specialisation

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.


Overview and Startup Sequence

When you deploy or publish a Variant application, the development environment generates a deployable zip artifact. This artifact is shaped by your selected deployment target—either an Azure Function App or a containerized app.

The zip file contains:

  • Variant runtime assemblies: Core interfaces, services, and messaging components that enable execution.

  • Strategy assemblies: .NET Core extension libraries for specific functionality like Azure Storage, XML parsing, compression, etc.

  • Service.yaml: Contains startup and dependency pipeline definitions.

  • Site-config.json: Metadata tracking extension packages and service-wide deployment info (primarily used during development).

  • YAML files: All user-authored Y# code for endpoints, pipes, and logic.

This structure is illustrated below:

Application Startup Flow

Upon execution, the following startup sequence takes place:

  1. Logging is initialized – Configures diagnostics and tracing pipelines.

  2. Core services are initialized – Internal runtime infrastructure is activated.

  3. All strategy assemblies are loaded – Includes user-defined and built-in extension packages.

  4. Dependency services (from service.yaml) are initialized – Application-specific prerequisites are wired up.

  5. Listener instances are initialized – Connectors and endpoints are registered and prepared.

  6. Connectors are started – The app begins accepting input from APIs, timers, queues, etc.

⚠️ Each connector, API route, and pipeline is initialized only once and reused across requests. These instances are not thread-safe, so avoid storing state across invocations.

This start up design allows for quick initialization and consistent behaviour across environments and executions.


ILokiMessage Interface

At the heart of every Variant application lies the ILokiMessage interface—a fundamental contract that defines how data is passed and transformed throughout the system. Every event, API call, or integration triggers a LokiMessage instance, which carries all relevant information through the pipeline execution lifecycle.

Each LokiMessage encapsulates three primary components:

  • Headers: A dictionary of key-value pairs used for routing, metadata, and control flags. Headers can influence flow decisions, manage correlation IDs, and support parallel or split processing.

  • Payload: The core content or data being processed. LokiMessage supports multi-part payloads, but the Payload property specifically refers to the first message part—commonly used for most processing logic.

  • Message Cloning and Correlation: Using CreateSpawnedMessage() and IsSpawnedMessage, developers can clone messages as part of fan-out or branching flows. Spawned messages retain a reference to their origin, allowing for correlation, grouping, and synchronization when aggregating results.

This interface underpins Variant's event-driven architecture and provides a uniform, extensible way to move data through pipelines while maintaining high visibility and traceability.