githubEdit

hands-asl-interpretingDependency Interpretation

Dependency Interpretation is a functional programming pattern that addresses the limitations of dependency injection by treating dependencies as data rather than behavior. This is the first version of our program computation expression.

From Interfaces to Instructions

Instead of injecting interface implementations, we abstract dependencies as instructions represented by a discriminated union type.

Data Layer Interfaces

Let's start with typical dependencies from the Data layer:

[<Interface>]
type IChannelClient =
    abstract GetChannelDescription: channelId: int -> Async<string option>

[<Interface>]
type IMailSender =
    abstract Send: MailEntities.Mail -> Async<unit>

[<Interface>]
type IMappingClient =
    abstract NotifyLinkEvent: channelId: int * hotelId: int * LinkStatus -> Async<unit>
    abstract GetMappingActivation: channelId: int * hotelId: int -> Async<MappingEntities.MappingActivationDto option>

The Program Type

We transform these interfaces into a recursive union type that represents a program as a list of instructions:

Understanding Program Instructions

Each instruction follows a common pattern:

Command/Query Separation Convention

We adopt a clear convention for instruction return types:

  • Commands return a Result<unit, Error> (no output data, only success or error)

  • Queries return an option (where None ≃ HTTP 404 NotFound)

How the Program Type Works

Key concepts:

  • Recursive type: Program is a list of instructions, each containing the next program step.

  • Terminal case: Stop contains the program's final returned value.

  • Continuation: The second element of each instructionβ€”Output -> Program<'a>β€”is a function that:

    • Processes the instruction's output,

    • Returns the rest of the program,

    • Contains the program logic.

Building Programs

Example 1: Simple Value

Example 2: Single Query

circle-info

Key point

Stop matches the continuation signatureβ€”'a -> Program<'a>. The program's returned value is passed to Stop, hence the returned type: Program<int> and Program<ChannelDescription option>.

Example 3: Transforming Results

The map Function

To avoid nested continuations, we can use a functorial map function:

Implementing map

The map f program function is based on pattern matching:

How it works:

  • Each instruction has a continuation function next that returns the rest of the program

  • We compose next with map f to transform the final result: next >> map f

  • When we reach Stop x, we apply f to the returned value: f x

The Program Computation Expression

We can improve the syntax further with a computation expression (CE):

Building the CE

The program CE is a monadic computation expression. The minimum required methods are:

  • Return: Elevates a value to a Programβ€”it's just Stop.

  • Bind: Delegates to the Program.bind function for monadic composition.

Implementing bind

The monadic bind f program is very similar to map:

Difference from map:

  • We bind the program returned by the instruction continuation.

  • The Stop x case returns the program produced by f x (not f x wrapped in Stop).

πŸ”— Additional resources

Here are three series of articles, each offering a different perspective to explore these difficult-to-grasp concepts in greater depth:

Instruction Helpers

To make programs more readable, we define helpers for each instruction:

The pattern is simple: let instruction args = Instruction(args, Stop)

Now we can write:

The Interpreter

A program is a pure valueβ€”producing no side effectsβ€”that needs to be interpreted to execute and get its returned value.

The interpreter collaborates with dependencies from the Data layer to execute the instructions.

circle-info

The interpreter lives not in the Domain layer but in the Application layer of the Clean Architecture.

Interpreter Implementation

The interpreter is a recursive, asynchronous function:

How it works:

  1. Pattern match on the program

  2. For Stop, return the value

  3. For each instruction:

    • Execute the operation using the appropriate dependency

    • Pass the result to the continuation to get the next program

    • Recursively interpret the rest of the program

Complete Example

Let's see a complete workflow:

This example comes from Roman Nevolin's excellent article Fighting complexity in software developmentarrow-up-right.

Pattern Name and Origins

This pattern is called Dependency Interpretation by Scott Wlaschin in his Dependency Injection seriesarrow-up-right.

The key insight: instead of injecting dependencies as objects, we interpret a data structure (our program) that describes what operations to perform.

Benefits

Clear Separation

Complete separation between:

  • What to do (the program as data)

  • How to do it (the interpreter)

Pure Domain Logic

Domain workflows remain pure functions returning Program<'a> values. All side effects are isolated in the interpreter.

Easy Testing

Programs can be inspected, transformed, or interpreted differently for tests without any mocking framework.

Explicit Effects

The Program type makes all possible side effects visible and trackable.

Limitations

Despite its benefits, this approach has a significant limitation:

circle-exclamation

Every new instruction requires:

  • Adding a new case to the Program type

  • Updating both map and bind functions

  • Adding a case to the interpreter

How can we improve this pattern to scale better?

Last updated