Railway for .NET

In the previous post (Railway-Oriented Programming), I explained how to use the Railway programming pattern with examples in JavaScript. I have received many questions about how to apply this approach in more structured languages such as Java and C#.

This article will show how you can use the Railway programming pattern in .NET. I will use the same simple application requirements and the data source as in the previous post.

Code example

We will start by creating a simple .NET core console application:

$ dotnet new console -n rails-demo
$ cd rails-demo
$ dotnet add package Newtonsoft.Json

You will need to add two more folders. Make sure your directories structure looks like this:

rails-demo/
├── domain
└── model

The model folder is for classes that represent data structure. In our case, we need one class that matches the data structure of the JSON file. Create a file model/Person.cs like that:

public class Person
{
  public string name;
  public int? age;
}

The domain folder contains classes that describe the functional domain. In our example, we will create the process context class that will handle railway pattern for our tasks. Create class domain/RailsContext.cs like that:

public class RailsContext
{
  public string TaskName;
  public string LastError;
  public bool HasError => !string.IsNullOrEmpty(LastError);

  public T setError<T>(string message, T dv = default) 
  {
    LastError = message;
    StackTrace stackTrace = new StackTrace();
    TaskName = stackTrace.GetFrame(1).GetMethod().Name;
    return dv;
  }

  public T setException<T>(Exception ex, T dv = default)
  {
    LastError = ex.Message;
    StackTrace stackTrace = new StackTrace();
    TaskName = stackTrace.GetFrame(1).GetMethod().Name;
    return dv;
  }
}

As you can see, we are using several C# features to make code less verbose and more readable. Generics allow us to use a single return statement for every derailment point. We also use System.Diagnostics library to collect the name of the calling function. It helps to diagnose large processes.

The main program method will look almost exactly the same as in the JavaScript example:

static void Main(string[] args)
{
  var context = new RailsContext();
  var fname = validateParams(context, args);
  var data = readFromFile(context, fname);
  data = validateData(context, data);
  displayResult(context, data);
}

The main difference is — instead of storing data inside the context object, we are returning them from functions back to the calling method. Our first task is to validate the parameters. The function code looks like this:

static string validateParams(RailsContext context, string[] args)
{
  if (context.HasError) return null;
  if (args.Length < 1) 
    return context.setError("Provide file name", "");
  string fname = args[0];
  if (File.Exists(fname)) 
    return fname;
  return context.setError($"File '{fname}' was not found", "");
}

In this method, we verify the file name corresponds to an existing file. You can move this validation to the next task, or even do it twice. It is up to the developer. The next step is to read the JSON file. Note that we are passing the file name as a parameter:

static Person readFromFile(RailsContext context, string fname) 
{
  if (context.HasError) return null;
  try
  {
    var text = File.ReadAllText(fname);
    var data = JsonConvert.DeserializeObject<Person>(text);
    return data;
  }
  catch (Exception ex) {
    return context.setException<Person>(ex);
  }
}

After we loaded the JSON structure to memory, we can run additional data validation. The function receives the Person object and returns the same data it collects. That allows you to modify the data here, if necessary:

static Person validateData(RailsContext context, Person data)
{
  if (context.HasError) return null;
  if (data == null) 
    return context.setError("Invalid data", data);
  try
  {
    if (string.IsNullOrEmpty(data.name)) 
      return context.setError("Invalid name", data);
    if (!data.age.HasValue) 
      return context.setError("Age is missing", data);
    if (data.age <= 0) 
      return context.setError($"Age {data.age} is invalid", data);
    return data;
  }
  catch (Exception ex) {
    return context.setException(ex, data);
  }
}

The final step is to display the result. There is no need to check data for null because we already did in the previous step:

static void displayResult(RailsContext context, Person data)
{
  Console.WriteLine(context.HasError
      ? $"Failed! Task {context.TaskName} says: {context.LastError}"
      : $"User is {data.name} ({data.age})");
}

To run the program from the console, use the following command:

$ dotnet run data.json
User is John Doe (35)

Sergey Kucherov