Tutorial: car rental pricing from F# to C#

Model pricing rules in F#, compile them to Morphir IR, and call them from a C# app

The pricing team at Acme Car Rental writes its rules in F#. The booking team writes C#. Morphir IR is the contract between them. In this tutorial you build that pipeline.

graph LR
    A[F# rules] -->|compile| B[Morphir IR]
    B -->|validate| B
    B -->|generate| C[C# library]
    C --> D[Booking app]

You need the .NET SDK 10.0 or later and dotnet-morphir (install it). The finished files are in examples/car-rental-pricing.

1. Set up the project

The pricing rules live in an ordinary F# project.

mkdir -p acme/src && cd acme
<!-- Acme.CarRental.fsproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <Compile Include="src/Rates.fs" />
    <Compile Include="src/Quote.fs" />
  </ItemGroup>
</Project>

morphir.toml tells compile what to read, so you do not repeat options:

# morphir.toml
[project]
name = "acme/car-rental"

[frontend]
language = "fsharp"

[frontend.fsharp]
project_file = "Acme.CarRental.fsproj"

The project file gives the file order. F# needs it: Quote.fs uses Rates.fs.

2. Write the rules

// src/Rates.fs
namespace Acme.CarRental

module Rates =
    type VehicleClass =
        | Economy
        | Compact
        | Suv

    let dailyRate (vehicle: VehicleClass) : int =
        match vehicle with
        | Economy -> 4000
        | Compact -> 5000
        | Suv -> 8000
// src/Quote.fs
namespace Acme.CarRental

module Quote =
    type Rental =
        { Vehicle: Rates.VehicleClass
          Days: int
          Insurance: bool }

    let insurancePerDay = 1500

    let total (rental: Rental) : int =
        let rentalCost = Rates.dailyRate rental.Vehicle * rental.Days

        if rental.Insurance then
            rentalCost + insurancePerDay * rental.Days
        else
            rentalCost

Amounts are in cents.

3. Compile

dotnet-morphir compile
compile succeeded: 2 module(s), IR version 4, /work/acme/.morphir/out/compile.dest/morphir-ir.json; no diagnostics
/work/acme/.morphir/out/compile.dest/morphir-ir.json

4. Add a discount, and meet a refused construct

Product asks for 10% off rentals of a week or more. The first attempt, in src/Discount.fs, uses a guard:

namespace Acme.CarRental

module Discount =
    let weeklyDiscount (days: int) (total: int) : int =
        match days with
        | d when d >= 7 -> total * 90 / 100
        | _ -> total

Add it to the project file, after Quote.fs, and compile:

    <Compile Include="src/Discount.fs" />
src/Discount.fs:6:18: error fsharp.unsupported_pattern_guard: Pattern guards ('when') are not supported; a Morphir pattern has no condition. Match the case, then use 'if' in its body.
compile failed (source, exit code 1); 1 error(s): error fsharp.unsupported_pattern_guard

Morphir IR has no guarded patterns, so compile refuses the code and tells you what to do. No IR is written. Follow the hint:

// src/Discount.fs
namespace Acme.CarRental

module Discount =
    let weeklyDiscount (days: int) (total: int) : int =
        if days >= 7 then total * 90 / 100 else total

    let discountedTotal (rental: Quote.Rental) : int =
        weeklyDiscount rental.Days (Quote.total rental)
compile succeeded: 3 module(s), IR version 4, /work/acme/.morphir/out/compile.dest/morphir-ir.json; no diagnostics

Every refused construct has a code and a fix. See the F# coverage reference.

5. Use the rules from C#

dotnet new console --output BookingApp
dotnet add BookingApp package Morphir.SDK.CSharp --prerelease
dotnet-morphir generate --target csharp --output BookingApp/Generated

Replace BookingApp/Program.cs:

using Acme.CarRental;

var weekTrip = new QuoteModule.Rental(new RatesModule.VehicleClass.Compact(), 7, false);

Console.WriteLine($"Before discount: {QuoteModule.Total(weekTrip)}");
Console.WriteLine($"After discount:  {DiscountModule.DiscountedTotal(weekTrip)}");
dotnet run --project BookingApp
Before discount: 35000
After discount:  31500

Each F# module becomes a static C# class (Quote becomes QuoteModule). Records and union cases become classes with value equality. Do not edit the files in Generated; run generate again when the rules change.

6. Check the IR in CI

Use --json for a machine-readable report. The exit code is 0, 1 (a source error) or 2 (a setup error).

dotnet-morphir compile --json | jq '{success, modules, diagnostics}'
{
  "success": true,
  "modules": [
    "Rates",
    "Quote",
    "Discount"
  ],
  "diagnostics": []
}

To run the type validator, compile to classic IR version 3 and validate the result. The validator does not read IR version 4 yet.

dotnet-morphir compile --ir-version 3 -o dist
dotnet-morphir validate --input dist/morphir-ir.json
0 errors, 0 warnings, 0 info, 0 hints. Analyzers: 1 ran, 0 not run, 0 failed.

-o dist puts a copy of the IR in dist for other teams. It replaces only files that an earlier compile put there.

7. One command for the booking team

dotnet-morphir compile --target csharp --generate-output BookingApp/Generated
compile succeeded: 3 module(s), IR version 4, /work/acme/.morphir/out/compile.dest/morphir-ir.json, generated 3 artifact(s) with morphir.csharp to /work/acme/BookingApp/Generated; no diagnostics

What you built

StepCommand
F# rules to IRdotnet-morphir compile
IR to C#dotnet-morphir generate --target csharp --output <dir>
Both in one rundotnet-morphir compile --target csharp --generate-output <dir>
CI checkdotnet-morphir compile --json, dotnet-morphir validate --input <ir>

Next: compile F# with the native executable, or read the compile reference.