Fling significantly reduces boilerplate needed to efficiently save/load complex domain entities to/from multiple tables.
Fling works with your existing (simple, per-table) get/insert/update/delete data access code, and enhances it with minimal boilerplate:
- When loading, Fling fetches child entities and supports batch loading child entities for multiple parent entities.
- When saving, Fling only inserts/updates/deletes changed rows.
Fling is completely database agnostic.
If you use SQL Server, Fling synergizes very well with Facil, which can fully generate the data access code that Fling can use. However, Fling is just as useful without it.
Given data access code, DTO types and functions to convert between domain and DTO types, Fling allows you to write these three helpers for efficiently saving/loading complex domain entities as described above:
open Fling.Fling
// 'Order option' is the old order (None for initial insert)
let save: 'arg -> Order option -> Order -> Async<'saveResult option> =
saveRoot orderToDbDto insertOrder updateOrder
|> saveChildren
orderToLineDtos
(fun dto -> dto.OrderLineId)
Db.insertOrderLine
Db.updateOrderLine
Db.deleteOrderLine
|> saveOptChild orderToCouponDto (fun dto -> dto.OrderId) Db.insertCoupon Db.updateCoupon Db.deleteCoupon
|> saveChild Db.orderToPriceDataDto Db.insertPriceData Db.updatePriceData
let load: 'arg -> OrderDto -> Async<Order> =
createLoader Dto.orderToDomain (fun dto -> dto.OrderId)
|> loadChild Db.getOrderLinesForOrder
|> loadChild Db.getCouponForOrder
|> loadChild Db.getPriceDataForOrder
|> loadSerialWithTransaction
let loadBatch: 'arg -> OrderDto list -> Async<Order list> =
createBatchLoader Dto.orderToDomain (fun dto -> dto.OrderId)
|> batchLoadChildren Db.getOrderLinesForOrders (fun dto -> dto.OrderId)
|> batchLoadOptChild Db.getCouponForOrders (fun dto -> dto.OrderId)
|> batchLoadChild Db.getPriceDataForOrders (fun dto -> dto.OrderId)
|> loadBatchSerialWithTransaction
Install Fling from NuGet.
Below, Order
is a complex domain object (“aggregate root” in DDD terms) that contains child entities.
type UserId = UserId of int
type OrderLineId = OrderLineId of int
type OrderId = OrderId of int
type OrderLine = { Id: OrderLineId; ProductName: string }
type Coupon = {
Code: string
Expiration: DateTimeOffset
}
type PriceData = { NetPrice: decimal }
type Order = {
Id: OrderId
OrderNumber: string
Lines: OrderLine list
AssociatedUsers: Set<UserId>
Coupon: Coupon option
PriceData: PriceData
}
Facil can generate these for you if you use SQL Server.
For demonstration purposes, we store the Order aggregate in five tables: One for the top-level order data, one for the order line data (each order can have 0..N lines), one for the associated users (0..N), one for the coupon used on the order (0..1), and one for the price data (1-to-1).
type OrderDto = { OrderId: int; OrderNumber: string }
type OrderLineDto = {
OrderId: int
OrderLineId: int
ProductName: string
}
type OrderAssociatedUserDto = { OrderId: int; UserId: int }
type OrderCouponDto = {
OrderId: int
Code: string
Expiration: DateTimeOffset
}
type OrderPriceDataDto = { OrderId: int; NetPrice: decimal }
For saving, you need one function for each of the DTO types that accepts the aggregate root (Order
) and returns the
DTO(s).
let orderToDto (order: Order) : OrderDto =
failwith "Your code here"
let orderToLineDtos (order: Order) : OrderLineDto list =
failwith "Your code here"
let orderToAssociatedUserDtos (order: Order) : OrderAssociatedUserDto list =
failwith "Your code here"
let orderToCouponDto (order: Order) : OrderCouponDto option =
failwith "Your code here"
let orderToPriceDataDto (order: Order) : OrderPriceDataDto =
failwith "Your code here"
For loading, then you need one function that accepts all relevant DTOs and produce your domain object.
let orderFromDtos
(dto: OrderDto)
(lines: OrderLineDto list)
(users: OrderAssociatedUserDto list)
(coupon: OrderCouponDto option)
(price: OrderPriceDataDto)
: Order =
failwith "Your code here"
Facil can generate these for you if you use SQL Server. If you use Facil, it is highly recommended you also install Fling.Interop.Facil and see the instructions later in the readme.
Note that all of these functions accept 'arg
as their first argument. This can be anything, but will typically be a
connection string, a connection object, or tuple containing a connection and a transaction. (Just use ()
if you don’t
need it.)
For non-batch loading, you need functions that accept the root ID (the order ID in our case) and return the DTO(s) that belong to the root:
let getOrderLinesForOrder (connStr: string) (orderId: int) : Async<Dtos.OrderLineDto list> =
failwith "Your code here"
let getAssociatedUsersForOrder (connStr: string) (orderId: int) : Async<Dtos.OrderAssociatedUserDto list> =
failwith "Your code here"
let getCouponForOrder (connStr: string) (orderId: int) : Async<Dtos.OrderCouponDto option> =
failwith "Your code here"
let getPriceDataForOrder (connStr: string) (orderId: int) : Async<Dtos.OrderPriceDataDto> =
failwith "Your code here"
For batch loading, you need functions that accept a list of root IDs and returns all DTOs that belong to those roots:
let getOrderLinesForOrders (connStr: string) (orderIds: int list) : Async<Dtos.OrderLineDto list> =
failwith ""
let getAssociatedUsersForOrders (connStr: string) (orderIds: int list) : Async<Dtos.OrderAssociatedUserDto list> =
failwith ""
let getCouponForOrders (connStr: string) (orderIds: int list) : Async<Dtos.OrderCouponDto list> =
failwith ""
let getPriceDataForOrders (connStr: string) (orderIds: int list) : Async<Dtos.OrderPriceDataDto list> =
failwith ""
For saving, you need functions to insert/update the root DTO and all (non-optional) to-one child DTOs, and you need
functions to insert/update/delete all to-many or optional to-one child DTOs. You typically want to run all of these in a
transaction, so for the 'arg
will typically contain a connection/transaction.
You can, if you want, use an “upsert” function instead of insert/update. If you do, just pass this function as both the insert and update function in the next step.
The “insert root” and “update root” functions may return Async<'a>
(e.g. for returning a generated ID), and must both
return the same type. All child entity insert/update/delete functions must return Async<unit>
.
let insertOrder conn (dto: OrderDto) : Async<unit> =
failwith "Your code here"
let updateOrder conn (dto: OrderDto) : Async<unit> =
failwith "Your code here"
let insertOrderLine conn (dto: OrderLineDto) : Async<unit> =
failwith "Your code here"
let updateOrderLine conn (dto: OrderLineDto) : Async<unit> =
failwith "Your code here"
let deleteOrderLine conn (orderLineId: int) : Async<unit> =
failwith "Your code here"
let insertOrderAssociatedUser conn (dto: OrderAssociatedUserDto) : Async<unit> =
failwith "Your code here"
let updateOrderAssociatedUser conn (dto: OrderAssociatedUserDto) : Async<unit> =
failwith "Your code here"
let deleteOrderAssociatedUser conn (orderId: int, userId: int) : Async<unit> =
failwith "Your code here"
let insertCoupon conn (dto: OrderCouponDto) : Async<unit> =
failwith "Your code here"
let updateCoupon conn (dto: OrderCouponDto) : Async<unit> =
failwith "Your code here"
let deleteCoupon conn (orderId: int) : Async<unit> =
failwith "Your code here"
let insertPriceData conn (dto: OrderPriceDataDto) : Async<unit> =
failwith "Your code here"
let updatePriceData conn (dto: OrderPriceDataDto) : Async<unit> =
failwith "Your code here"
Fling now allows you to wire everything together using a declarative syntax.
Given a single root DTO, the function below loads all child entities in a transaction and calls your DTO-to-domain function to return the root entity.
open Fling.Fling
let load: 'arg -> OrderDto -> Async<Order> =
createLoader orderFromDtos (fun dto -> dto.OrderId)
|> loadChild getOrderLinesForOrder
|> loadChild getAssociatedUsersForOrder
|> loadChild getCouponForOrder
|> loadChild getPriceDataForOrder
|> loadSerialWithTransaction
Given multiple root DTOs, the function below loads all child entities for all the root entities and calls your DTO-to-domain function to return the root entities.
In all of the calls below, you specify a function to get the root ID given the child ID. Fling uses this to know which child entities belong to which roots.
open Fling.Fling
let loadBatch: 'arg -> OrderDto list -> Async<Order list> =
createBatchLoader orderFromDtos (fun dto -> dto.OrderId)
|> batchLoadChildren getOrderLinesForOrders (fun dto -> dto.OrderId)
|> batchLoadChildren getAssociatedUsersForOrders (fun dto -> dto.OrderId)
|> batchLoadOptChild getCouponForOrders (fun dto -> dto.OrderId)
|> batchLoadChild getPriceDataForOrders (fun dto -> dto.OrderId)
|> loadBatchSerialWithTransaction
Given an old root entity (None
for initial creation, must be Some
for updates) and an updated root entity, this
helper performs the necessary inserts/updates/deletes. It skips updating identical records (compared using =
).
Everything is done in the order you specify here. For to-many child entities, all deletes are performed first, then all updates, then all inserts.
For to-many and optional to-one children, you specify a function to get the ID (typically the table’s primary key) of
the DTO. This will be passed to the delete
function if the entity needs to be deleted, and is used for to-many
children to know which child entities to compare, delete, and insert. Though these are trivial, bugs can sneak in
here – Facil can generate these for you if you use SQL Server.
open Fling.Fling
let save: 'arg -> Order option -> Order -> Async<unit> =
saveRoot orderToDbDto insertOrder updateOrder
|> batchSaveChildren orderToLineDtos (fun dto -> dto.OrderLineId) insertOrderLines updateOrderLines deleteOrderLines
|> saveChildren
orderToAssociatedUserDtos
(fun dto -> dto.OrderId, dto.UserId)
insertOrderAssociatedUser
updateOrderAssociatedUser
deleteOrderAssociatedUser
|> saveOptChild orderToCouponDto (fun dto -> dto.OrderId) insertCoupon updateCoupon deleteCoupon
|> saveChild orderToPriceDataDto insertPriceData updatePriceData
Note: You MUST pass Some oldEntity
if you are updating. Pass None
only for initial insert of the domain
aggregate. If you supply None
while updating, all entities will be inserted, which at best will fail with a primary
key violation if the entity exists, or at worst will leave old child entities that should have been deleted still
present in your database, causing the next load to return invalid data.
If you need to return a result, use saveRootWithOutput
instead of saveRoot
. You then get Async<'a option>
instead
of Async<unit>
, where 'a
is the return type of your insert and update functions. If the root entity was
inserted/updated, the function returns Some
with the result of the insert/update; otherwise it returns None
.
For example:
let saveChangesToOrder connStr (oldOrder: Order option) (newOrder: Order) = async {
use conn = new SqlConnection(connStr)
do! conn.OpenAsync() |> Async.AwaitTask
use tran = conn.BeginTransaction()
do! save (conn, tran) oldOrder newOrder
do! tran.CommitAsync() |> Async.AwaitTask
}
let getOrderById connStr (OrderId orderId) = async {
match! dbGetOrderById connStr orderId with
| None -> return None
| Some orderDto ->
let! order = load connStr orderDto
return Some order
}
let getAllOrders connStr = async {
let! orderDtos = dbGetAllOrders connStr
return! loadBatch connStr orderDtos
}
Fling is production ready.
Fling is fairly well tested and is used in several mission-critical production services at our company. I’m not claiming it’s perfect, or even bug-free, but I have a vested interest in keeping it working properly.
It’s still at 0.x because it's still new and I may still be discovering improvements that require breaking changes every now and then. However, do not take 0.x to mean that it’s a buggy mess, or that the API will radically change every other week. Breaking changes will cause a lot of churn for me, too.
Fling.Interop.Facil uses ugly SRTP code to entirely remove the boilerplate needed to use Fling with the data access code generated by Facil.
Fling.Interop.Facil works with code generated by Facil >= 1.1.0.
To use it, install Fling.Interop.Facil from NuGet
and open Fling.Interop.Facil.Fling
after open Fling.Fling
, then use the Facil script/procedure types instead of DB
functions in all Fling functions.
open Fling.Fling
open Fling.Interop.Facil.Fling
For (non-batch) loading:
- Unlike Fling, you have to use
loadChild
,loadOptChild
, orloadChildren
depending on the cardinality of the relationship (in Fling,loadChild
serves all three). 'arg
is locked tostring
, i.e. a connection string
open Fling.Fling
open Fling.Interop.Facil.Fling
let load: string -> OrderDto -> Async<Order> =
createLoader orderFromDtos (fun dto -> dto.OrderId)
|> loadChildren OrderLine_ByOrderId
|> loadChildren OrderAssociatedUser_ByOrderId
|> loadOptChild OrderCoupon_ByOrderId
|> loadChild OrderPriceData_ByOrderId
|> loadSerialWithTransaction
For batch loading:
'arg
is locked tostring
, i.e. a connection string
let loadBatch: string -> OrderDto list -> Async<Order list> =
createBatchLoader orderFromDtos (fun dto -> dto.OrderId)
|> batchLoadChildren OrderLine_ByOrderIds (fun dto -> dto.OrderId)
|> batchLoadChildren OrderAssociatedUser_ByOrderIds (fun dto -> dto.OrderId)
|> batchLoadOptChild OrderCoupon_ByOrderIds (fun dto -> dto.OrderId)
|> batchLoadChild OrderPriceData_ByOrderIds (fun dto -> dto.OrderId)
|> loadBatchSerialWithTransaction
For saving:
'arg
is locked toSqlConnection * SqlTransaction
let save: (SqlConnection * SqlTransaction) -> Order option -> Order -> Async<unit> =
saveRoot orderToDbDto Order_Insert Order_Update
|> batchSaveChildren
orderToLineDtos
DbGen.TableDtos.OrderLine.getPrimaryKey
OrderLine_InsertBatch
OrderLine_UpdateBatch
OrderLine_DeleteBatch
|> saveChildren
orderToAssociatedUserDtos
DbGen.TableDtos.OrderAssociatedUser.getPrimaryKey
OrderAssociatedUser_Insert
OrderAssociatedUser_Update
OrderAssociatedUser_Delete
|> saveOptChild
orderToCouponDto
DbGen.TableDtos.OrderCoupon.getPrimaryKey
OrderCoupon_Insert
OrderCoupon_Update
OrderCoupon_Delete
|> saveChild orderToPriceDataDto OrderPriceData_Insert OrderPriceData_Update
Use withTransactionFromConnStr
to “convert” the SqlConnection * SqlTransaction
to a string
(connection string) and
run the whole save in a transaction. This is useful if you don’t need to run the save in a transaction with anything
else:
let save : string -> Order option -> Order -> Async<unit> =
(* same code as above *)
|> withTransactionFromConnStr
As with Fling, use saveRootWithOutput
instead of saveRoot
if you need to return anything from the root’s
insert/update script.
It’s not possible to interleave inserts/updates/deletes for different tables. For example, you can’t specify that Fling should insert first into table A and then into table B while at the same time delete from table A and then from table B. The ordering of operations can only be specified at the table (or “child”) level; all inserts/updates/deletes for a table is performed before the next table. This may have implications for foreign key constraints in complex aggregates.
For maintainers.
- Make necessary changes to the code
- Update the changelog
- Update the version in
Fling.fsproj
and/orFling.Interop.Facil.fsproj
- Commit and push to
main
. If the GitHub build succeeds, the packages are automatically published to NuGet.