Spyke

Ok, I think that I may have come up with something that mixes some of the OO benefits of typed queries with some of the functional benefits of FSharp. One thing that I ran in to was how to run multiple queries in a transaction, but have them both with independent metrics. My first attempt on my own wound up having two different runners being used and looking like

dapperRunner.RunInTransaction(fun conn txn -> task {  
   let query1 = ... {with Transaction = txn }  
   let query2 = ... {with Transaction = txn }  

   let! result1 = metricsRunner.Run(query1, conn)  
   let! result2 = metricsRunner.Run(query2, conn)  

   return result1, result2  
}  

Needless to say. This is unweildy and who the heck (including future me) is going to remember to do it that way?

So, I asked both claude sonnet 4.6 (not 4.7) with the caveman skill to make it talk like a caveman and save tokens, as well as the smaller gpt-oss 20B model that can run on my GPU, just for fun. Surprisingly, the smaller model came up with a close solution and structure to sonnet, but wasn't quite able to get the composition that I wanted.

What I had proposed to Claude was a solution of running multiple typed queries with transactions and metrics (query name and text getting logged), but with monadic composition similar to what I already have in my validation module.

Monadic composition is where you can essentially combine two functions so that their output is your mapped return of all results. In the validation version, it looks like

let validationResult arg = 
   (fun firstName lastName -> User(firstName, lastName) // the mapper  
   <!> validateFirstName arg.fName  
   <*> validateLastName arg.lName  

To do this on the DbQuery would mean to turn the query and transaction portion into a delegate type.
Sonnet seemed to pick up on this and during its writing, I noticed its thinking catching certain dependencies that I was thinking about. It came up with a solution that I think combines things in a good way.

So, the final output first of what you'd do and how, let's run 2 db queries in a transaction with independent metrics:

// version 1, return the the tuple in a lambda  
// Multiple queries in a transaction — CE style.  
let resultCE = db.RunInTransaction(fun tx -> task {  
    let! res1 = tx.Run(GetUserById(42))  
    let! res2 = tx.Run(GetUserById(43))  
    return res1, res2  
})  

This version I think is pretty straight forward, the execution order and mapping to a tuple is done by you.
Now for multiple queries in function composition:

// Multiple queries in a transaction — applicative/TxOp composition style.  
let getUserOp (id: int) : TxOp<User option> = fun tx -> tx.Run(GetUserById(id))  

let composedOp : TxOp<User option * User option> =  
    getUserOp 42 |> TxOp.zip (getUserOp 43)  

let! resultComposed : User option * User option = db.RunInTransaction(composedOp)  

But what about more than 2?
I think that the cleanest approach would be to add another line in the resultCE version, which would be the cleanest.

Out of cursiosity, I was wondering if it'd be possible to get things to look like the validation applicative model. I wasn't quite successful working with Claude, but got close. Watching its thinking, it does have a better understanding of certain monadic principles than I do. I definitely wouldn't be able to write LanuageExt on my own, that's for sure.

I don't know that this is worth it, but I did get to an output that looks like the below. I'm returning a tuple, but you could combine them into whatever type you want.

let tripleOp : TxOp<User option * User option * User option> =  
    TxOp.retn (fun a b c -> a, b, c)  
    |> TxOp.apply (getUserOp 42)  
    |> TxOp.apply (getUserOp 43)  
    |> TxOp.apply (getUserOp 44)  

The validation module doesn't require the TxOp.retn to lift the final mapping function into a TxOp.
So, while it looks cool, I'll be leaving that portion commented out, most likely.

With some tweaking, I was able to copy portions of the Validation module to get it to look like

open TxOp // I forgot to open the dang module, so of course I was having issues  
let triple2 : TxOp<User option * User option * User option> = 
    (fun u o v -> u,o,v)  
    <!> getUserOp 42  
    <*> getUserOp 43  
    <*> getUserOp 44  

At this point, while it looks cool, I don't think it's really worth the effort and complexity over

let resultCE = db.RunInTransaction(fun tx -> task {  
    let! res1 = tx.Run(GetUserById(42))  
    let! res2 = tx.Run(GetUserById(43))  
    return res1, res2  
})  

Do you want them in Parallel? You can try creating new functions or leave it to your later self:

let resultCE = db.RunInTransaction(fun tx -> task {  
    let! res1 = tx.Run(GetUserById(42))  
    and! res2 = tx.Run(GetUserById(43)) // my understanding is that and! runs them in parallel  
    
    return res1, res2  
})  

All together, the new parts without the monadic behaviors looks like the below. Claude combined my two different runners into just one that ultimately goes into a RunOn method that uses the withDbActivity helper function. I'll do some tweaking later on. For example, explicitly opening the connection in the Run method isn't necessary since Dapper does that itself. Removing this would break parallel queries.

/// TxOp<'T> = a query operation that runs within an existing transaction scope.  
/// Compose multiple ops with `zip` / `<&>` before passing to RunInTransaction.  
type TxOp<'T> = TransactionScope -> Task<'T>  

and TransactionScope internal (conn: IDbConnection, txn: IDbTransaction, runner: QueryRunner) =  
    member _.Run<'Q, 'T when 'Q :> DbQuery<'T>>(query: 'Q) : Task<'T> =  
        query.Transaction <- Some txn  
        runner.RunOn(query, conn)  

and QueryRunner(logger: ILogger, connFactory: DbConnectionFactory) =  

    member internal _.RunOn<'Q, 'T when 'Q :> DbQuery<'T>>(query: 'Q, conn: IDbConnection) : Task<'T> =  
        withDbActivity logger typeof<'Q>.Name (Some query.QueryText) (fun () -> query.Run conn)  

    member this.Run<'Q, 'T when 'Q :> DbQuery<'T>>(query: 'Q) : Task<'T> =  
        use conn = connFactory ()  
        conn.Open()  
        this.RunOn(query, conn)  

    // TxOp<'T> is a type alias for TransactionScope -> Task<'T>,  
    // so inline lambdas and composed TxOps both satisfy this signature.  
    member this.RunInTransaction<'T>(op: TxOp<'T>) : Task<'T> = task {  
        use conn = connFactory ()  
        conn.Open()  
        use txn = conn.BeginTransaction()  
        let! res = op (TransactionScope(conn, txn, this))  
        txn.Commit()  
        return res  
    }  

// Examples  
type User = { FirstName: string }  
type GetUserById(id : int) =  
    inherit OptionQuery<User>()  
    override _.QueryText = "SELECT * FROM Users WHERE Id = @Id"  
    override _.Parameter = Some (box {| Id = id |})  

// Single runner — metrics + connection management in one place.  
let db = QueryRunner(Log.Logger, fun () -> new NpgsqlConnection("..."))  

// Single query — own connection, metrics applied.  
let userTask = db.Run(GetUserById(42))  

// Multiple queries in a transaction — CE style.  
let resultCE = db.RunInTransaction(fun tx -> task {  
    let! res1 = tx.Run(GetUserById(42))  
    let! res2 = tx.Run(GetUserById(43))  
    return res1, res2  
})  

let resultCE2 = db.RunInTransaction(fun tx -> task {  
    let! res1 = tx.Run(GetUserById(42))  
    and! res2 = tx.Run(GetUserById(43))  
    
    return res1, res2  
})  

The base types changed a bit to match FSharp, but I'm hitting the max comment length. It'll be in the blog.

I think that this is a decent place to be. I'm likely not going to go with the apply, map, and zip functions, or maybe I'll keep zip to make easy tuples, but I think that the most straightforward thing is to KISS it with db.RunInTransaction(...) and do everything there.

What I'm hoping this might allow me to do is to replace the longer Repository implementation with a module of individual queries and, instead of injecting the repository, inject the runner. Or maybe refactor some things for easier testing, though my tests are using Docker containers with Postgres for integration tests, so the end to end portions don't require me to do much mocking.

I'm very much open to everyone's thoughts. I'll be updating the blog post with this comment.

Overall, I think that in this thinking I:

  • tried to over-abstract, like dotnet devs love to do
  • tried combining too many things
  • made bad abstractions because they shouldn't have existed
  • made a decent way to combine OO behaviors with a functional style to have cleaner metrics showing up in the Aspire dashboard and request logging. Of course, if it was easier to get similar things like this done with Dapper as with EFCore, where it's automatic, I wouldn't be here
  • have no real idea what I'm going to do with the SqlHydra or DbFun queries
  • spent way too much time doing this instead of building features
1

You reached the end

Navigating and Learning Data Access in FSharp | Spyke