The problem you're describing is one that makes your solution messy; if you can't change the problem, then you're going to be in a bad position forever, because you're describing constraints that stop you doing a decent job at this layer.
Your description says that some callers absolutely know that there's a database involved, and cannot work if this layer hides the database from them. Other callers must not know there's a database involved, and cannot work if this layer requires them to know about the database. That's the underlying source of mess here, and until you fix that, you're going to have trouble doing a good job at this layer.
Option one is to say that all callers should know about the database, and you will handle callers that "must not" know about the database by giving them an easy route to handle transactions using a pool you manage. Callers that have a bare connection can get a transaction trivially by calling begin()
, and can call commit()
when they've done their work, and your functions never call commit()
without a matching begin()
(so they can do sub-transactions, but the caller is required to handle the outer transaction.
Option 2 is to hide the database from the callers completely, and have an internal pool; you get a connection from the pool, start a transaction, do your work, commit or rollback, and return the connection to the pool. The caller now can't put your work inside a larger transaction, but the caller doesn't have to care about the database.
You're trying to implement option 3 - allow for callers who know about the database, callers who treat the database as an opaque "thing", and callers who don't know about the database, all inside the individual service functions. This is always going to result in less-clear code, since you're trying to handle three very different classes of caller.
I therefore think you are better off having the service functions handle one class of caller (I'd suggest the one that has a transaction for you to do the work in), and providing utility functions for running a given call inside a transaction (like sqlx::Connection::transaction
provides for bare connections), and for getting a connection from a pool, getting a transaction from that pool, running the service function, then committing the transaction and returning the connection.
That way, your service functions have one job - do my thing inside an outer transaction. Your callers get to decide, via the utility functions, whether they ask the service function to work inside an existing outer transaction, inside an existing connection, or inside a fresh connection obtained for this one call.