diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 6aaab6b39..6f05ee215 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -111,6 +111,7 @@ const config: UserConfig = { {text: 'Modular Monoliths', link: '/tutorials/modular-monolith'}, {text: 'Event Sourcing and CQRS with Marten', link: '/tutorials/cqrs-with-marten'}, {text: 'Event Sourcing and CQRS with Polecat', link: '/tutorials/cqrs-with-polecat'}, + {text: 'Using Wolverine with F#', link: '/tutorials/fsharp'}, {text: 'Railway Programming with Wolverine', link: '/tutorials/railway-programming'}, {text: 'Interoperability with Non-Wolverine Systems', link: '/tutorials/interop'}, {text: 'Leader Election and Agents', link: '/tutorials/leader-election'}, diff --git a/docs/tutorials/fsharp.md b/docs/tutorials/fsharp.md new file mode 100644 index 000000000..6a49d26a3 --- /dev/null +++ b/docs/tutorials/fsharp.md @@ -0,0 +1,276 @@ +# Using Wolverine with F# + +Wolverine works with F#, not just C#. You can author message handlers as F# functions/methods, use the +EF Core, Marten, CosmosDB, and FluentValidation integrations, and — the focus of this tutorial — ship an +F# application that runs entirely on **pre-generated F# code** with no runtime code generation. + +::: tip +All of the F# in this tutorial comes from runnable samples under +[`src/Samples`](https://github.com/JasperFx/wolverine/tree/main/src/Samples) (the `Wolverine*FSharpSample` +projects) and the behavioural test under +[`src/Testing/Wolverine.Behavioural.FSharp*`](https://github.com/JasperFx/wolverine/tree/main/src/Testing). +::: + +## How Wolverine code generation works with F# + +For every message handler, Wolverine generates a small adapter class (a `MessageHandler`) that pulls the +message off the envelope, resolves dependencies, runs your handler, applies middleware (transactions, +validation, the outbox, OpenTelemetry tagging, cascading messages…), and saves changes. By default that +adapter is generated and compiled **at startup** with Roslyn (`TypeLoadMode.Dynamic`). Wolverine can also +emit that adapter as **F#** and load it from your already-compiled assembly at runtime +(`TypeLoadMode.Static`) — so an F# app can ship with zero runtime compilation. + +There are therefore two ways to run an F# Wolverine app: + +1. **Dynamic code generation** — quickest to get going; needs the Roslyn runtime compiler. +2. **Pre-generated (static) code** — commit the generated F# and load it under `TypeLoadMode.Static`. + +## Writing a handler in F# + +A handler is just a type with a `Handle`/`Consume` method. Here is an EF Core transactional handler — it +takes the command and an injected `DbContext`, writes an entity, and returns an event that Wolverine +cascades as an outgoing message: + + + +```fs +type CreateItemHandler = + [] + static member Handle(command: CreateItemCommand, db: ItemsDbContext) : ItemCreated = + let item = { Id = Guid.NewGuid(); Name = command.Name } + db.Items.Add(item) |> ignore + { Id = item.Id } +``` +snippet source | anchor + + +Records work well for messages and events, and `[]` gives EF Core / Marten / CosmosDB the +parameterless constructor and settable properties they need for entities and documents. + +Bootstrapping looks just like C#, through the `UseWolverine` lambda: + + + +```fs +let host = + Host + .CreateDefaultBuilder(args) + .UseWolverine(fun opts -> + // Register the DbContext with Wolverine's EF Core outbox integration. + opts.Services.AddDbContextWithWolverineIntegration(fun o -> + o.UseNpgsql(connectionString) |> ignore) + |> ignore + + // Durable message store backing the transactional outbox. + opts.PersistMessagesWithPostgresql(connectionString) |> ignore + + opts.UseEntityFrameworkCoreTransactions() |> ignore + opts.Policies.AutoApplyTransactions() |> ignore + opts.Discovery.IncludeType() |> ignore + + // Core Wolverine dropped the in-box Roslyn compiler (GH-2876); enable it so this demo + // runs via dynamic codegen. (The static F# story is proven by the compile-gate test.) + opts.UseRuntimeCompilation() |> ignore) + // Provision the Wolverine message-store tables on startup. + .UseResourceSetupOnStartup() + .Build() +``` +snippet source | anchor + + +::: warning Dynamic code generation needs the runtime compiler +Core Wolverine no longer ships the Roslyn compiler ([GH-2876](https://github.com/JasperFx/wolverine/issues/2876)). +To run via **dynamic** code generation, reference the `WolverineFx.RuntimeCompilation` package and call +`opts.UseRuntimeCompilation()` as shown above. Apps that run on pre-generated code (below) do not need it. +::: + +## What Wolverine generates — as F# + +Take this minimal F# handler: + + + +```fs +type BehaviouralPingHandler = + static member Handle(ping: BehaviouralPing) = BehaviouralSink.record ping.Value +``` +snippet source | anchor + + +Wolverine emits the following F# adapter for it. Notice the idiomatic F#: the message downcast with `:?>`, +the `task { }` computation expression, the OpenTelemetry guard (F# has no `?.`), and the trailing +`Task.CompletedTask`: + +```fsharp +namespace Internal.Generated.WolverineHandlers + +open System +open System.Threading +open System.Threading.Tasks +open Wolverine.Runtime +open Wolverine.Runtime.Handlers + +type BehaviouralPingHandler1244766258() = + inherit Wolverine.Runtime.Handlers.MessageHandler() + override this.HandleAsync(context: Wolverine.Runtime.MessageContext, cancellation: System.Threading.CancellationToken) : System.Threading.Tasks.Task = + // The actual message body + let behaviouralPing = context.Envelope.Message :?> WolverineBehaviouralFSharpApp.BehaviouralPing + + if not (isNull System.Diagnostics.Activity.Current) then + System.Diagnostics.Activity.Current.SetTag("message.handler", "WolverineBehaviouralFSharpApp.BehaviouralPingHandler") |> ignore + System.Diagnostics.Activity.Current.SetTag("handler.type", "WolverineBehaviouralFSharpApp.BehaviouralPingHandler") |> ignore + + // The actual message execution + WolverineBehaviouralFSharpApp.BehaviouralPingHandler.Handle(behaviouralPing) + + System.Threading.Tasks.Task.CompletedTask +``` + +## Running on pre-generated F# code + +To ship an F# app that runs on pre-generated code rather than compiling at startup: + +1. Generate the F# adapters and commit them into your application (for example as a `Generated.fs` + compiled into the app assembly). The generated type names are deterministic for a given handler graph. +2. Boot the host in `TypeLoadMode.Static` and point Wolverine's `ApplicationAssembly` at the assembly that + contains the pre-generated F# — Wolverine then loads each handler adapter **by name** out of that + assembly's exported types, with no Roslyn at runtime: + + + +```cs +var appAssembly = typeof(BehaviouralPingHandler).Assembly; + +using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + // Shared with the generation step so the generated type-name hash matches: + // DisableConventionalDiscovery() + IncludeType(). + BehaviouralCodegen.Configure(opts); + + // Load the pre-generated F# handler adapter out of the app assembly instead of + // compiling at runtime. Setting ApplicationAssembly (which cascades to + // CodeGeneration.ApplicationAssembly) pins the assembly Wolverine scans for pre-built + // types to the F# app, and TypeLoadMode.Static means no Roslyn at runtime. + opts.ApplicationAssembly = appAssembly; + opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Static; + }) + .StartAsync(); + +// The pre-generated F# MessageHandler is loaded by name and executed — no runtime compilation. +var bus = host.MessageBus(); +await bus.InvokeAsync(new BehaviouralPing(42)); +``` +snippet source | anchor + + +::: tip +Setting `opts.ApplicationAssembly` cascades to `opts.CodeGeneration.ApplicationAssembly` and pins the +assembly Wolverine scans for pre-built types. If the committed F# drifts from the configured handler graph +the generated type name won't match and the host throws `ExpectedTypeMissingException` at startup — a loud, +useful signal to regenerate. +::: + +## Persistence and middleware in F# + +The same handler authoring style works across Wolverine's integrations. Each of these is exercised by a +runnable sample and a code-generation compile gate. + +### Marten documents + +A `[]` handler that stores a document through an injected `IDocumentSession`: + + + +```fs +type CreateProductHandler = + [] + static member Handle(command: CreateProductCommand, session: IDocumentSession) : ProductCreated = + let product = { Id = Guid.NewGuid(); Name = command.Name } + session.Store(product) + { Id = product.Id } +``` +snippet source | anchor + + +### Marten event sourcing + +An `[]` receives the loaded aggregate (via `FetchForWriting`) plus the command and returns +the event(s) to append: + + + +```fs +type IncrementHandler = + [] + static member Handle(command: IncrementCounter, counter: Counter) : Incremented = + { By = command.By } +``` +snippet source | anchor + + +::: warning F# aggregates: override `Evolve` +Marten's convention-based aggregation (`Create`/`Apply` methods) is dispatched by the **C#-only** +`JasperFx.Events` source generator, which does not run for F# assemblies. For an F# aggregate, override +`Evolve` directly — an explicit per-event fold — instead of using convention methods: +::: + + + +```fs +type CounterProjection() = + inherit SingleStreamProjection() + + override _.Evolve(snapshot: Counter, _id: Guid, e: IEvent) : Counter = + match e.Data with + | :? CounterStarted as started -> { Id = started.Id; Count = 0 } + | :? Incremented as inc -> { snapshot with Count = snapshot.Count + inc.By } + | _ -> snapshot +``` +snippet source | anchor + + +### FluentValidation + +Register `opts.UseFluentValidation()` and a validator; the validation middleware runs before the handler. +F# automatically converts the property-selector lambdas (`fun x -> x.Name`) into the LINQ expression trees +`RuleFor` expects, so an F# `AbstractValidator` reads naturally: + + + +```fs +type CreateThingValidator() as self = + inherit AbstractValidator() + + do + self.RuleFor(fun x -> x.Id).NotEmpty() |> ignore + self.RuleFor(fun x -> x.Name).NotEmpty() |> ignore +``` +snippet source | anchor + + +### CosmosDB + +A `[]` handler that returns an `ICosmosDbOp` side effect; Wolverine applies it inside the +CosmosDB outbox transaction: + + + +```fs +type CreateThingHandler = + [] + static member Handle(command: CreateThing) : ICosmosDbOp = + CosmosDbOps.Store({ id = command.Id; Name = command.Name }) +``` +snippet source | anchor + + +## F# gotchas, summarized + +- **Dynamic codegen** needs `WolverineFx.RuntimeCompilation` + `opts.UseRuntimeCompilation()` (GH-2876). + Pre-generated/static apps don't. +- **Entities & documents**: use `[]` records so the persistence tooling can construct/populate them. +- **Marten aggregates**: override `Evolve` rather than using convention `Create`/`Apply` (the source + generator is C#-only). +- **FluentValidation**: F# auto-quotes `RuleFor` lambdas to expression trees — no special handling needed. +- **Static loading**: set `opts.ApplicationAssembly` to the assembly carrying the committed F# adapters. diff --git a/src/Samples/WolverineCosmosFSharpSample/Domain.fs b/src/Samples/WolverineCosmosFSharpSample/Domain.fs index 70597e675..743e0da4a 100644 --- a/src/Samples/WolverineCosmosFSharpSample/Domain.fs +++ b/src/Samples/WolverineCosmosFSharpSample/Domain.fs @@ -15,9 +15,11 @@ type ThingCreated = { Id: string } /// FluentValidation validator for CreateThing. Wolverine's FluentValidation middleware runs this /// before the handler (and short-circuits with a failure result if invalid). F# auto-converts the /// property-selector lambdas to the LINQ expression trees RuleFor expects. +// begin-snippet: sample_fsharp_fluentvalidation_validator type CreateThingValidator() as self = inherit AbstractValidator() do self.RuleFor(fun x -> x.Id).NotEmpty() |> ignore self.RuleFor(fun x -> x.Name).NotEmpty() |> ignore +// end-snippet diff --git a/src/Samples/WolverineCosmosFSharpSample/Handlers.fs b/src/Samples/WolverineCosmosFSharpSample/Handlers.fs index 8d8318d76..2ca90c143 100644 --- a/src/Samples/WolverineCosmosFSharpSample/Handlers.fs +++ b/src/Samples/WolverineCosmosFSharpSample/Handlers.fs @@ -7,7 +7,9 @@ open Wolverine.CosmosDb /// * FluentValidation — the CreateThingValidator runs before this method (validation middleware). /// * CosmosDB persistence — [] enlists the CosmosDB outbox, and the returned /// ICosmosDbOp side effect (CosmosDbOps.Store) is applied within that transaction. +// begin-snippet: sample_fsharp_cosmos_handler type CreateThingHandler = [] static member Handle(command: CreateThing) : ICosmosDbOp = CosmosDbOps.Store({ id = command.Id; Name = command.Name }) +// end-snippet diff --git a/src/Samples/WolverineFSharpSample/Handlers.fs b/src/Samples/WolverineFSharpSample/Handlers.fs index c58c9adbb..eccfc82c6 100644 --- a/src/Samples/WolverineFSharpSample/Handlers.fs +++ b/src/Samples/WolverineFSharpSample/Handlers.fs @@ -7,9 +7,11 @@ open Wolverine.Attributes /// [], the command + injected DbContext, add an entity, and return an event that /// Wolverine sends as a cascading message. Wolverine's EF Core middleware wraps this in the outbox /// transaction (enroll -> SaveChanges -> commit), all of which the F# codegen now emits. +// begin-snippet: sample_fsharp_efcore_handler type CreateItemHandler = [] static member Handle(command: CreateItemCommand, db: ItemsDbContext) : ItemCreated = let item = { Id = Guid.NewGuid(); Name = command.Name } db.Items.Add(item) |> ignore { Id = item.Id } +// end-snippet diff --git a/src/Samples/WolverineFSharpSample/Program.fs b/src/Samples/WolverineFSharpSample/Program.fs index 176b42988..5baf654c4 100644 --- a/src/Samples/WolverineFSharpSample/Program.fs +++ b/src/Samples/WolverineFSharpSample/Program.fs @@ -20,6 +20,7 @@ let connectionString = [] let main args = + // begin-snippet: sample_fsharp_efcore_bootstrap let host = Host .CreateDefaultBuilder(args) @@ -42,6 +43,7 @@ let main args = // Provision the Wolverine message-store tables on startup. .UseResourceSetupOnStartup() .Build() + // end-snippet // Create the database + the DbContext's Item table BEFORE starting the host, so Wolverine's // message-store resource setup (on Start) finds an existing database to provision into. diff --git a/src/Samples/WolverineMartenAggregateFSharpSample/Domain.fs b/src/Samples/WolverineMartenAggregateFSharpSample/Domain.fs index 160119824..30ade3cae 100644 --- a/src/Samples/WolverineMartenAggregateFSharpSample/Domain.fs +++ b/src/Samples/WolverineMartenAggregateFSharpSample/Domain.fs @@ -16,6 +16,7 @@ type Counter = { Id: Guid; Count: int } /// A single-stream projection that folds the Counter events into the aggregate. It overrides Evolve /// directly (an explicit per-event fold) rather than using convention Create/Apply methods: those are /// dispatched by the C#-only JasperFx.Events source generator, which does not run for F# assemblies. +// begin-snippet: sample_fsharp_aggregate_projection type CounterProjection() = inherit SingleStreamProjection() @@ -24,6 +25,7 @@ type CounterProjection() = | :? CounterStarted as started -> { Id = started.Id; Count = 0 } | :? Incremented as inc -> { snapshot with Count = snapshot.Count + inc.By } | _ -> snapshot +// end-snippet /// The command handled by IncrementHandler. CounterId names the aggregate stream to load. type IncrementCounter = { CounterId: Guid; By: int } diff --git a/src/Samples/WolverineMartenAggregateFSharpSample/Handlers.fs b/src/Samples/WolverineMartenAggregateFSharpSample/Handlers.fs index 787cb9256..d7fc5bb5f 100644 --- a/src/Samples/WolverineMartenAggregateFSharpSample/Handlers.fs +++ b/src/Samples/WolverineMartenAggregateFSharpSample/Handlers.fs @@ -6,7 +6,9 @@ open Wolverine.Marten /// load the Counter aggregate for the stream named by the command's CounterId (FetchForWriting), pass /// it to this method, and append the returned event(s) back to that stream. The returned Incremented /// is registered against the loaded stream by the generated adapter. +// begin-snippet: sample_fsharp_aggregate_handler type IncrementHandler = [] static member Handle(command: IncrementCounter, counter: Counter) : Incremented = { By = command.By } +// end-snippet diff --git a/src/Samples/WolverineMartenFSharpSample/Handlers.fs b/src/Samples/WolverineMartenFSharpSample/Handlers.fs index 46b769ead..ef983df85 100644 --- a/src/Samples/WolverineMartenFSharpSample/Handlers.fs +++ b/src/Samples/WolverineMartenFSharpSample/Handlers.fs @@ -8,9 +8,11 @@ open Wolverine.Attributes /// an injected IDocumentSession: store a document and return an event that Wolverine sends as a /// cascading message. Wolverine's Marten middleware opens the outbox-enrolled session and saves /// changes around this call — both of which the F# codegen now emits. +// begin-snippet: sample_fsharp_marten_document_handler type CreateProductHandler = [] static member Handle(command: CreateProductCommand, session: IDocumentSession) : ProductCreated = let product = { Id = Guid.NewGuid(); Name = command.Name } session.Store(product) { Id = product.Id } +// end-snippet diff --git a/src/Testing/Wolverine.Behavioural.FSharpApp/Domain.fs b/src/Testing/Wolverine.Behavioural.FSharpApp/Domain.fs index 9b73b7bac..7f2c78ef6 100644 --- a/src/Testing/Wolverine.Behavioural.FSharpApp/Domain.fs +++ b/src/Testing/Wolverine.Behavioural.FSharpApp/Domain.fs @@ -22,5 +22,7 @@ type BehaviouralPing = { Value: int } /// A minimal F# handler. The generated F# adapter (Generated.fs) calls this; the behavioural test /// boots a host in TypeLoadMode.Static, sends a BehaviouralPing, and asserts the sink recorded it. +// begin-snippet: sample_fsharp_behavioural_handler type BehaviouralPingHandler = static member Handle(ping: BehaviouralPing) = BehaviouralSink.record ping.Value +// end-snippet diff --git a/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs b/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs index eac077903..71c7a47cd 100644 --- a/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs +++ b/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs @@ -30,26 +30,29 @@ public async Task generated_fsharp_handler_runs_under_static_load() { BehaviouralSink.reset(); + // begin-snippet: sample_fsharp_static_host var appAssembly = typeof(BehaviouralPingHandler).Assembly; using var host = await Host.CreateDefaultBuilder() .UseWolverine(opts => { + // Shared with the generation step so the generated type-name hash matches: + // DisableConventionalDiscovery() + IncludeType(). BehaviouralCodegen.Configure(opts); // Load the pre-generated F# handler adapter out of the app assembly instead of // compiling at runtime. Setting ApplicationAssembly (which cascades to - // CodeGeneration.ApplicationAssembly) BEFORE bootstrap pins the assembly Wolverine - // scans for pre-built types to the F# app — not this test assembly. If the committed - // Generated.fs has drifted from this config, the type name won't match and the host - // throws ExpectedTypeMissingException at startup — a loud, useful signal. + // CodeGeneration.ApplicationAssembly) pins the assembly Wolverine scans for pre-built + // types to the F# app, and TypeLoadMode.Static means no Roslyn at runtime. opts.ApplicationAssembly = appAssembly; opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Static; }) .StartAsync(); + // The pre-generated F# MessageHandler is loaded by name and executed — no runtime compilation. var bus = host.MessageBus(); await bus.InvokeAsync(new BehaviouralPing(42)); + // end-snippet using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var received = await BehaviouralSink.received().WaitAsync(timeout.Token);