├── .gitignore ├── Acceptance ├── Acceptance.fsproj ├── Program.fs ├── WithFlexibleCompositionRoot │ ├── TestFlexibleCompositionRoot.fs │ └── Tests2.fs └── WithInflexibleCompositionRoot │ ├── TestInflexibleCompositionRoot.fs │ └── Tests1.fs ├── Api ├── Api.fsproj ├── Dtos.fs ├── FlexibleCompositionRoot │ ├── FlexibleCompositionRoot.fs │ ├── Leaves │ │ └── StockItemWorkflowsDependencies.fs │ └── Trunk.fs ├── HttpHandler.fs ├── IdGenerator.fs ├── InflexibleCompositionRoot.fs ├── Program.fs ├── Settings.fs ├── Views.fs ├── WebRoot │ └── main.css └── appsettings.json ├── FsharpComposition.sln ├── Intragration ├── Intragration.fsproj ├── Program.fs └── StockItemDao.fs ├── README.md ├── Stock.Application ├── Queries │ └── StockItemById.fs ├── Stock.Application.fsproj └── StockItemWorkflows.fs ├── Stock.PostgresDao ├── DapperFSharp.fs ├── Stock.PostgresDao.fsproj ├── StockItemDao.fs └── StockItemQueryDao.fs ├── Stock ├── Stock.fsproj └── StockItem.fs ├── Toolbox ├── HttpContext.fs ├── Toolbox.fs └── Toolbox.fsproj ├── Unit ├── Program.fs ├── StockItem.fs └── Unit.fsproj ├── database.sql └── docker-compose.yml /.gitignore: -------------------------------------------------------------------------------- 1 | #KaDiff3 2 | *.orig 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | *.DotSettings.user 10 | 11 | # User-specific files (MonoDevelop/Xamarin Studio) 12 | *.userprefs 13 | 14 | # Build results 15 | [Dd]ebug/ 16 | [Dd]ebugPublic/ 17 | [Rr]elease/ 18 | [Rr]eleases/ 19 | x64/ 20 | x86/ 21 | bld/ 22 | [Bb]in/ 23 | [Oo]bj/ 24 | [Ll]og/ 25 | 26 | # Visual Studio 2015 cache/options directory 27 | .vs/ 28 | 29 | # MSTest test Results 30 | [Tt]est[Rr]esult*/ 31 | [Bb]uild[Ll]og.* 32 | 33 | # NUNIT 34 | *.VisualState.xml 35 | TestResult.xml 36 | 37 | # Build Results of an ATL Project 38 | [Dd]ebugPS/ 39 | [Rr]eleasePS/ 40 | dlldata.c 41 | 42 | # DNX 43 | project.lock.json 44 | project.fragment.lock.json 45 | artifacts/ 46 | 47 | *_i.c 48 | *_p.c 49 | *_i.h 50 | *.ilk 51 | *.meta 52 | *.obj 53 | *.pch 54 | *.pdb 55 | *.pgc 56 | *.pgd 57 | *.rsp 58 | *.sbr 59 | *.tlb 60 | *.tli 61 | *.tlh 62 | *.tmp 63 | *.tmp_proj 64 | *.log 65 | *.vspscc 66 | *.vssscc 67 | .builds 68 | *.pidb 69 | *.svclog 70 | *.scc 71 | 72 | # Chutzpah Test files 73 | _Chutzpah* 74 | 75 | # Visual Studio profiler 76 | *.psess 77 | *.vsp 78 | *.vspx 79 | *.sap 80 | 81 | # ReSharper is a .NET coding add-in 82 | _ReSharper*/ 83 | *.[Rr]e[Ss]harper 84 | *.DotSettings.user 85 | 86 | # DotCover is a Code Coverage Tool 87 | *.dotCover 88 | 89 | # NCrunch 90 | _NCrunch_* 91 | .*crunch*.local.xml 92 | nCrunchTemp_* 93 | *.ncrunchsolution 94 | 95 | # Click-Once directory 96 | publish/ 97 | 98 | # NuGet Packages 99 | *.nupkg 100 | # The packages folder can be ignored because of Package Restore 101 | **/packages/* 102 | # except build/, which is used as an MSBuild target. 103 | !**/packages/build/ 104 | # Uncomment if necessary however generally it will be regenerated when needed 105 | #!**/packages/repositories.config 106 | # NuGet v3's project.json files produces more ignoreable files 107 | *.nuget.props 108 | *.nuget.targets 109 | 110 | # Paket dependency manager 111 | .paket/paket.exe 112 | paket-files/ 113 | 114 | # FAKE - F# Make 115 | .fake/ 116 | 117 | # JetBrains Rider 118 | .idea/ 119 | *.sln.iml 120 | -------------------------------------------------------------------------------- /Acceptance/Acceptance.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net5.0 4 | false 5 | false 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | all 21 | 22 | 23 | runtime; build; native; contentfiles; analyzers; buildtransitive 24 | all 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /Acceptance/Program.fs: -------------------------------------------------------------------------------- 1 | module Program = let [] main _ = 0 2 | -------------------------------------------------------------------------------- /Acceptance/WithFlexibleCompositionRoot/TestFlexibleCompositionRoot.fs: -------------------------------------------------------------------------------- 1 | module TestFlexibleCompositionRoot 2 | 3 | open System 4 | open Api.FlexibleCompositionRoot 5 | open FlexibleCompositionRoot 6 | open Settings 7 | let testSettings: Settings = 8 | // We can test with database but we don't have to. 9 | { SqlConnectionString = "Host=localhost;User Id=postgres;Password=Secret!Passw0rd;Database=stock;Port=5432" 10 | IdGeneratorSettings = 11 | { GeneratorId = 555 12 | Epoch = DateTimeOffset.Parse "2020-10-01 12:30:00" 13 | TimestampBits = byte 41 14 | GeneratorIdBits = byte 10 15 | SequenceBits = byte 12 } } 16 | 17 | let composeRoot tree = compose tree 18 | let testTrunk = Trunk.compose testSettings 19 | 20 | let ``with StockItem -> ReadBy`` substitute (trunk: Trunk.Trunk) = 21 | { trunk with StockItemWorkflowDependencies = { trunk.StockItemWorkflowDependencies with ReadBy = substitute } } 22 | 23 | let ``with StockItem -> Update`` substitute (trunk: Trunk.Trunk) = 24 | { trunk with StockItemWorkflowDependencies = { trunk.StockItemWorkflowDependencies with Update = substitute } } 25 | 26 | let ``with StockItem -> Insert`` substitute (trunk: Trunk.Trunk) = 27 | { trunk with StockItemWorkflowDependencies = { trunk.StockItemWorkflowDependencies with Insert = substitute } } 28 | 29 | let ``with Query -> StockItemById`` substitute (trunk: Trunk.Trunk) = 30 | { trunk with QueryStockItemBy = substitute } -------------------------------------------------------------------------------- /Acceptance/WithFlexibleCompositionRoot/Tests2.fs: -------------------------------------------------------------------------------- 1 | module Tests2 2 | 3 | open System 4 | open Api 5 | open Dtos 6 | open Microsoft.AspNetCore.Http 7 | open Stock.StockItem 8 | open Xunit 9 | open HttpContext 10 | open FSharp.Control.Tasks.V2 11 | open FsUnit.Xunit 12 | open TestFlexibleCompositionRoot 13 | 14 | [] 15 | let ``GIVEN id of not existing stock item WHEN QueryStockItemBy THEN none is returned and mapped into 404 not found`` () = 16 | let httpContext = buildMockHttpContext () 17 | // This is trivial example - We could as well build the QueryStockItemBy by ourselves and pass it to the handler. 18 | // To make it easy to understand more complex cases, let me do it by the "Flexible CompositionRoot" way. 19 | let root = testTrunk 20 | |> ``with Query -> StockItemById`` (fun _ -> async { return None }) 21 | |> composeRoot 22 | let http = 23 | task { 24 | let! ctxAfterHandler = HttpHandler.queryStockItemHandler root.QueryStockItemBy (int64 5) next httpContext 25 | return ctxAfterHandler 26 | } |> Async.AwaitTask |> Async.RunSynchronously |> Option.get 27 | http.Response.StatusCode |> should equal StatusCodes.Status404NotFound 28 | 29 | [] 30 | let ``GIVEN stock item was passed into request WHEN CreateStockItem THEN new stock item is created and location is returned which can be used to fetch created stock item`` () = 31 | // Arrange 32 | let (name, amount) = (Guid.NewGuid().ToString(), Random().Next(1, 15)) 33 | let httpContext = buildMockHttpContext () 34 | |> writeObjectToBody {Name = name; Amount = amount} 35 | // Again we can compose the function by ourself, but using our root we can test both - the function and the 36 | // dependencies composition (as in this way the dependencies in our tests are composed in the same way. 37 | let mutable createdStockItem: StockItem option = None 38 | let root = testTrunk 39 | |> ``with StockItem -> Insert`` (fun stockItem -> async { createdStockItem <- Some stockItem; return () }) 40 | |> ``with Query -> StockItemById`` (fun _ -> 41 | async { 42 | let stockItem = createdStockItem.Value 43 | let (StockItemId id) = stockItem.Id 44 | return ( Some { 45 | Id = id 46 | AvailableAmount = stockItem.AvailableAmount 47 | Name = stockItem.Name 48 | } 49 | : Queries.StockItemById.Result option) 50 | }) 51 | |> composeRoot 52 | // Act 53 | let http = 54 | task { 55 | let! ctxAfterHandler = HttpHandler.createStockItemHandler root.CreateStockItem root.GenerateId next httpContext 56 | return ctxAfterHandler 57 | } |> Async.AwaitTask |> Async.RunSynchronously |> Option.get 58 | // Assert 59 | http.Response.StatusCode |> should equal StatusCodes.Status201Created 60 | let createdId = http.Response.Headers.["Location"].ToString().[11..] |> Int64.Parse 61 | let httpContext4Query = buildMockHttpContext () 62 | let httpAfterQuery = 63 | task { 64 | let! ctxAfterQuery = HttpHandler.queryStockItemHandler root.QueryStockItemBy createdId next httpContext4Query 65 | return ctxAfterQuery 66 | } |> Async.AwaitTask |> Async.RunSynchronously |> Option.get 67 | let createdStockItem = httpAfterQuery.Response |> deserializeResponse 68 | createdStockItem.Id |> should equal createdId 69 | createdStockItem.Name |> should equal name 70 | createdStockItem.AvailableAmount |> should equal amount 71 | 72 | // Now You! Write Test for Update in both approaches. -------------------------------------------------------------------------------- /Acceptance/WithInflexibleCompositionRoot/TestInflexibleCompositionRoot.fs: -------------------------------------------------------------------------------- 1 | module TestInflexibleCompositionRoot 2 | 3 | open System 4 | open InflexibleCompositionRoot 5 | open Settings 6 | let testSettings: Settings = 7 | // We are forced to test against database 8 | { SqlConnectionString = "Host=localhost;User Id=postgres;Password=Secret!Passw0rd;Database=stock;Port=5432" 9 | IdGeneratorSettings = 10 | { GeneratorId = 555 11 | Epoch = DateTimeOffset.Parse "2020-10-01 12:30:00" 12 | TimestampBits = byte 41 13 | GeneratorIdBits = byte 10 14 | SequenceBits = byte 12 } } 15 | 16 | let testRoot = compose testSettings -------------------------------------------------------------------------------- /Acceptance/WithInflexibleCompositionRoot/Tests1.fs: -------------------------------------------------------------------------------- 1 | module Tests1 2 | 3 | open System 4 | open Api 5 | open Dtos 6 | open Microsoft.AspNetCore.Http 7 | open Xunit 8 | open HttpContext 9 | open FSharp.Control.Tasks.V2 10 | open FsUnit.Xunit 11 | open TestInflexibleCompositionRoot 12 | 13 | [] 14 | let ``GIVEN id of not existing stock item WHEN QueryStockItemBy THEN none is returned and mapped into 404 not found`` () = 15 | let httpContext = buildMockHttpContext () 16 | let http = 17 | task { 18 | let! ctxAfterHandler = HttpHandler.queryStockItemHandler testRoot.QueryStockItemBy (int64 5) next httpContext 19 | return ctxAfterHandler 20 | } |> Async.AwaitTask |> Async.RunSynchronously |> Option.get 21 | http.Response.StatusCode |> should equal StatusCodes.Status404NotFound 22 | 23 | [] 24 | let ``GIVEN stock item was passed into request WHEN CreateStockItem THEN new stockitem is created and location is returned which can be used to fetch created stockitem`` () = 25 | // Arrange 26 | let (name, amount) = (Guid.NewGuid().ToString(), Random().Next(1, 15)) 27 | let httpContext = buildMockHttpContext () 28 | |> writeObjectToBody {Name = name; Amount = amount} 29 | // Act 30 | let http = 31 | task { 32 | let! ctxAfterHandler = HttpHandler.createStockItemHandler testRoot.CreateStockItem testRoot.GenerateId next httpContext 33 | return ctxAfterHandler 34 | } |> Async.AwaitTask |> Async.RunSynchronously |> Option.get 35 | // Assert 36 | http.Response.StatusCode |> should equal StatusCodes.Status201Created 37 | let createdId = http.Response.Headers.["Location"].ToString().[11..] |> Int64.Parse 38 | let httpContext4Query = buildMockHttpContext () 39 | let httpAfterQuery = 40 | task { 41 | let! ctxAfterQuery = HttpHandler.queryStockItemHandler testRoot.QueryStockItemBy createdId next httpContext4Query 42 | return ctxAfterQuery 43 | } |> Async.AwaitTask |> Async.RunSynchronously |> Option.get 44 | let createdStockItem = httpAfterQuery.Response |> deserializeResponse 45 | createdStockItem.Id |> should equal createdId 46 | createdStockItem.Name |> should equal name 47 | createdStockItem.AvailableAmount |> should equal amount 48 | -------------------------------------------------------------------------------- /Api/Api.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net5.0 4 | Api.App 5 | false 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | PreserveNewest 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | PreserveNewest 36 | 37 | 38 | -------------------------------------------------------------------------------- /Api/Dtos.fs: -------------------------------------------------------------------------------- 1 | namespace Api 2 | 3 | module Dtos = 4 | type RemoveStockItemDto = { 5 | Id: int64 6 | Amount: int 7 | } 8 | 9 | type CreateStockItemDto = { 10 | Name: string 11 | Amount: int 12 | } 13 | 14 | -------------------------------------------------------------------------------- /Api/FlexibleCompositionRoot/FlexibleCompositionRoot.fs: -------------------------------------------------------------------------------- 1 | module FlexibleCompositionRoot 2 | open Api.FlexibleCompositionRoot 3 | open Stock.Application 4 | open Stock.StockItem 5 | 6 | type FlexibleCompositionRoot = 7 | { QueryStockItemBy: Queries.StockItemById.Query -> Async 8 | RemoveFromStock: int64 -> int -> Async> 9 | CreateStockItem: int64 -> string -> int -> Async 10 | GenerateId: unit -> int64 11 | } 12 | 13 | let compose (trunk: Trunk.Trunk) = 14 | { 15 | QueryStockItemBy = trunk.QueryStockItemBy 16 | RemoveFromStock = StockItemWorkflows.remove trunk.StockItemWorkflowDependencies 17 | CreateStockItem = StockItemWorkflows.create trunk.StockItemWorkflowDependencies 18 | GenerateId = trunk.GenerateId 19 | } -------------------------------------------------------------------------------- /Api/FlexibleCompositionRoot/Leaves/StockItemWorkflowsDependencies.fs: -------------------------------------------------------------------------------- 1 | namespace Api.FlexibleCompositionRoot.Leaves 2 | 3 | open System.Data 4 | open Stock.Application 5 | open Stock.PostgresDao 6 | 7 | module StockItemWorkflowDependencies = 8 | let compose (createDbConnection: unit -> Async) : StockItemWorkflows.IO = 9 | { 10 | ReadBy = StockItemDao.readBy createDbConnection 11 | Update = StockItemDao.update createDbConnection 12 | Insert = StockItemDao.insert createDbConnection 13 | } 14 | 15 | -------------------------------------------------------------------------------- /Api/FlexibleCompositionRoot/Trunk.fs: -------------------------------------------------------------------------------- 1 | namespace Api.FlexibleCompositionRoot 2 | 3 | open Settings 4 | open Stock.Application 5 | open Stock.PostgresDao 6 | 7 | module Trunk = 8 | type Trunk = 9 | { 10 | GenerateId: unit -> int64 11 | StockItemWorkflowDependencies: StockItemWorkflows.IO 12 | QueryStockItemBy: Queries.StockItemById.Query -> Async 13 | } 14 | 15 | let compose (settings: Settings) = 16 | let createDbConnection = DapperFSharp.createSqlConnection settings.SqlConnectionString 17 | { 18 | GenerateId = IdGenerator.create settings.IdGeneratorSettings 19 | StockItemWorkflowDependencies = Leaves.StockItemWorkflowDependencies.compose createDbConnection 20 | QueryStockItemBy = StockItemQueryDao.readBy createDbConnection 21 | // Your next application layer workflow dependencies ... 22 | } 23 | 24 | 25 | -------------------------------------------------------------------------------- /Api/HttpHandler.fs: -------------------------------------------------------------------------------- 1 | namespace Api 2 | 3 | open Api.Dtos 4 | open FlexibleCompositionRoot 5 | open Stock.StockItem 6 | open Giraffe 7 | open Microsoft.AspNetCore.Http 8 | open FSharp.Control.Tasks.V2.ContextInsensitive 9 | 10 | module HttpHandler = 11 | let queryStockItemHandler queryStockItemBy (id: int64): HttpHandler = 12 | fun (next: HttpFunc) (ctx: HttpContext) -> 13 | task { 14 | let! stockItem = queryStockItemBy (id |> Queries.StockItemById.Query) 15 | return! match stockItem with 16 | | Some stockItem -> json stockItem next ctx 17 | | None -> RequestErrors.notFound (text "Not Found") next ctx 18 | } 19 | 20 | let removeFromStockItem (removeStockItem: int64 -> int -> Async>): HttpHandler = 21 | fun (next: HttpFunc) (ctx: HttpContext) -> 22 | task { 23 | let! stockItemDto = ctx.BindJsonAsync() 24 | let! bookingResult = removeStockItem stockItemDto.Id stockItemDto.Amount 25 | let response = match bookingResult with 26 | | Ok _ -> Successful.OK (text "OK") next ctx 27 | | Error message -> RequestErrors.badRequest (json message) next ctx 28 | return! response 29 | } 30 | 31 | let createStockItemHandler createStockItem (createId: unit -> int64): HttpHandler = 32 | fun (next: HttpFunc) (ctx: HttpContext) -> 33 | let id = createId() 34 | task { 35 | let! stockItemDto = ctx.BindJsonAsync() 36 | do! createStockItem id stockItemDto.Name stockItemDto.Amount 37 | ctx.SetHttpHeader "Location" (sprintf "/stockitem/%d" id) 38 | return! Successful.created (text "Created") next ctx 39 | } 40 | 41 | let router (compositionRoot: FlexibleCompositionRoot): HttpFunc -> HttpContext -> HttpFuncResult = 42 | choose [ GET >=> route "/" >=> htmlView Views.index 43 | GET >=> routef "/stockitem/%d" (queryStockItemHandler compositionRoot.QueryStockItemBy) 44 | PATCH >=> route "/stockitem/" >=> (removeFromStockItem compositionRoot.RemoveFromStock) 45 | POST >=> route "/stockitem/" >=> (createStockItemHandler compositionRoot.CreateStockItem compositionRoot.GenerateId) 46 | setStatusCode 404 >=> text "Not Found" ] 47 | -------------------------------------------------------------------------------- /Api/IdGenerator.fs: -------------------------------------------------------------------------------- 1 | module IdGenerator 2 | open System 3 | open IdGen 4 | [] 5 | type Settings = 6 | { GeneratorId: int 7 | Epoch: DateTimeOffset 8 | TimestampBits: byte 9 | GeneratorIdBits: byte 10 | SequenceBits: byte } 11 | 12 | let create settings = 13 | let structure = IdStructure(settings.TimestampBits, settings.GeneratorIdBits, settings.SequenceBits) 14 | let options = IdGeneratorOptions(structure, DefaultTimeSource(settings.Epoch)) 15 | let generator = IdGenerator(settings.GeneratorId, options) 16 | fun() -> generator.CreateId() -------------------------------------------------------------------------------- /Api/InflexibleCompositionRoot.fs: -------------------------------------------------------------------------------- 1 | module InflexibleCompositionRoot 2 | 3 | open Stock.StockItem 4 | open Stock.Application 5 | open Stock.PostgresDao 6 | open Settings 7 | 8 | type InflexibleCompositionRoot = 9 | { QueryStockItemBy: Queries.StockItemById.Query -> Async 10 | RemoveFromStock: int64 -> int -> Async> 11 | CreateStockItem: int64 -> string -> int -> Async 12 | GenerateId: unit -> int64 13 | } 14 | 15 | let compose settings = 16 | let createSqlConnection = DapperFSharp.createSqlConnection settings.SqlConnectionString 17 | let idGenerator = IdGenerator.create settings.IdGeneratorSettings 18 | let stockItemWorkflowsIo: StockItemWorkflows.IO = { 19 | ReadBy = StockItemDao.readBy createSqlConnection 20 | Update = StockItemDao.update createSqlConnection 21 | Insert = StockItemDao.insert createSqlConnection 22 | } 23 | { 24 | QueryStockItemBy = StockItemQueryDao.readBy createSqlConnection 25 | RemoveFromStock = StockItemWorkflows.remove stockItemWorkflowsIo 26 | CreateStockItem = StockItemWorkflows.create stockItemWorkflowsIo 27 | GenerateId = idGenerator 28 | } 29 | -------------------------------------------------------------------------------- /Api/Program.fs: -------------------------------------------------------------------------------- 1 | module Api.App 2 | 3 | open System 4 | open System.IO 5 | open Microsoft.AspNetCore.Builder 6 | open Microsoft.AspNetCore.Hosting 7 | open Microsoft.Extensions.Configuration 8 | open Microsoft.Extensions.Hosting 9 | open Microsoft.Extensions.Logging 10 | open Microsoft.Extensions.DependencyInjection 11 | open Giraffe 12 | open Settings 13 | open FlexibleCompositionRoot 14 | open InflexibleCompositionRoot 15 | 16 | let errorHandler (ex : Exception) (logger : ILogger) = 17 | logger.LogError(ex, "An unhandled exception has occurred while executing the request.") 18 | clearResponse >=> setStatusCode 500 >=> text ex.Message 19 | 20 | let configureApp (compositionRoot: FlexibleCompositionRoot) 21 | (app : IApplicationBuilder) = 22 | let env = app.ApplicationServices.GetService() 23 | (match env.EnvironmentName with 24 | | "Development" -> app.UseDeveloperExceptionPage() 25 | | _ -> app.UseGiraffeErrorHandler(errorHandler)) 26 | .UseHttpsRedirection() 27 | .UseStaticFiles() 28 | .UseGiraffe(HttpHandler.router compositionRoot) 29 | 30 | let configureServices (services : IServiceCollection) = 31 | services.AddGiraffe() |> ignore 32 | 33 | let configureLogging (builder : ILoggingBuilder) = 34 | builder.AddFilter(fun l -> l.Equals LogLevel.Error) 35 | .AddConsole() 36 | .AddDebug() |> ignore 37 | 38 | let configureSettings (configurationBuilder: IConfigurationBuilder) = 39 | configurationBuilder.SetBasePath(AppContext.BaseDirectory) 40 | .AddJsonFile("appsettings.json", false) 41 | 42 | [] 43 | let main args = 44 | let contentRoot = Directory.GetCurrentDirectory() 45 | let webRoot = Path.Combine(contentRoot, "WebRoot") 46 | let confBuilder = ConfigurationBuilder() |> configureSettings 47 | //let root = InflexibleCompositionRoot.compose (confBuilder.Build().Get()) 48 | let trunk = Trunk.compose (confBuilder.Build().Get()) 49 | let root = FlexibleCompositionRoot.compose trunk 50 | Host.CreateDefaultBuilder(args) 51 | .ConfigureWebHostDefaults( 52 | fun webHostBuilder -> 53 | webHostBuilder 54 | .UseContentRoot(contentRoot) 55 | .UseWebRoot(webRoot) 56 | .Configure(Action (configureApp root)) 57 | .ConfigureServices(configureServices) 58 | .ConfigureLogging(configureLogging) 59 | |> ignore) 60 | .Build() 61 | .Run() 62 | 0 -------------------------------------------------------------------------------- /Api/Settings.fs: -------------------------------------------------------------------------------- 1 | module Settings 2 | 3 | [] 4 | type Settings = { 5 | IdGeneratorSettings: IdGenerator.Settings 6 | SqlConnectionString: string 7 | } -------------------------------------------------------------------------------- /Api/Views.fs: -------------------------------------------------------------------------------- 1 | namespace Api 2 | module Views = 3 | open Giraffe.ViewEngine 4 | 5 | let index = 6 | html [] [ 7 | head [] [ title [] [ encodedText "Whatever Api" ] ] 8 | body [] [ h1 [] [ encodedText "Whatever Api" ] ] 9 | ] 10 | -------------------------------------------------------------------------------- /Api/WebRoot/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial, Helvetica, sans-serif; 3 | color: #333; 4 | font-size: .9em; 5 | } 6 | 7 | h1 { 8 | font-size: 1.5em; 9 | color: #334499; 10 | } -------------------------------------------------------------------------------- /Api/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IdGeneratorSettings": { 3 | "GeneratorId": 0, 4 | "Epoch": "2020-10-01 12:30:00", 5 | "TimestampBits": 41, 6 | "GeneratorIdBits": 10, 7 | "SequenceBits": 12 8 | }, 9 | "SqlConnectionString": "Host=localhost;User Id=postgres;Password=Secret!Passw0rd;Database=stock;Port=5432" 10 | } 11 | -------------------------------------------------------------------------------- /FsharpComposition.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Api", "Api\Api.fsproj", "{84D2EC9A-C947-4DEC-BD2B-58A9A85D5046}" 4 | EndProject 5 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Stock", "Stock\Stock.fsproj", "{A775E71E-4546-4204-9E82-5B8262F7416A}" 6 | EndProject 7 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_infra", "_infra", "{C17148A6-A01F-49FB-B2A8-797059C8E339}" 8 | ProjectSection(SolutionItems) = preProject 9 | README.md = README.md 10 | docker-compose.yml = docker-compose.yml 11 | database.sql = database.sql 12 | EndProjectSection 13 | EndProject 14 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Stock.PostgresDao", "Stock.PostgresDao\Stock.PostgresDao.fsproj", "{9DE5858F-3D0D-4923-962C-67AB2B18DC13}" 15 | EndProject 16 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{A11824D7-478B-4EC2-9CC3-AFBB64A77643}" 17 | EndProject 18 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Intragration", "Intragration\Intragration.fsproj", "{E0FCB837-4E04-4537-A188-F8AE4FE5AF44}" 19 | EndProject 20 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Acceptance", "Acceptance\Acceptance.fsproj", "{C8A4FC6C-FC35-4801-AC51-7D824141BC1B}" 21 | EndProject 22 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Unit", "Unit\Unit.fsproj", "{18034065-98B4-4B16-A669-6D4549E50308}" 23 | EndProject 24 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Toolbox", "Toolbox\Toolbox.fsproj", "{EF2FB0ED-290E-4B9A-9BA0-A18A123C67F3}" 25 | EndProject 26 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Stock.Application", "Stock.Application\Stock.Application.fsproj", "{CDB03743-7A7F-4BDF-AFEC-2EC35BB428C1}" 27 | EndProject 28 | Global 29 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 30 | Debug|Any CPU = Debug|Any CPU 31 | Release|Any CPU = Release|Any CPU 32 | EndGlobalSection 33 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 34 | {84D2EC9A-C947-4DEC-BD2B-58A9A85D5046}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 35 | {84D2EC9A-C947-4DEC-BD2B-58A9A85D5046}.Debug|Any CPU.Build.0 = Debug|Any CPU 36 | {84D2EC9A-C947-4DEC-BD2B-58A9A85D5046}.Release|Any CPU.ActiveCfg = Release|Any CPU 37 | {84D2EC9A-C947-4DEC-BD2B-58A9A85D5046}.Release|Any CPU.Build.0 = Release|Any CPU 38 | {A775E71E-4546-4204-9E82-5B8262F7416A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 39 | {A775E71E-4546-4204-9E82-5B8262F7416A}.Debug|Any CPU.Build.0 = Debug|Any CPU 40 | {A775E71E-4546-4204-9E82-5B8262F7416A}.Release|Any CPU.ActiveCfg = Release|Any CPU 41 | {A775E71E-4546-4204-9E82-5B8262F7416A}.Release|Any CPU.Build.0 = Release|Any CPU 42 | {9DE5858F-3D0D-4923-962C-67AB2B18DC13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 43 | {9DE5858F-3D0D-4923-962C-67AB2B18DC13}.Debug|Any CPU.Build.0 = Debug|Any CPU 44 | {9DE5858F-3D0D-4923-962C-67AB2B18DC13}.Release|Any CPU.ActiveCfg = Release|Any CPU 45 | {9DE5858F-3D0D-4923-962C-67AB2B18DC13}.Release|Any CPU.Build.0 = Release|Any CPU 46 | {E0FCB837-4E04-4537-A188-F8AE4FE5AF44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 47 | {E0FCB837-4E04-4537-A188-F8AE4FE5AF44}.Debug|Any CPU.Build.0 = Debug|Any CPU 48 | {E0FCB837-4E04-4537-A188-F8AE4FE5AF44}.Release|Any CPU.ActiveCfg = Release|Any CPU 49 | {E0FCB837-4E04-4537-A188-F8AE4FE5AF44}.Release|Any CPU.Build.0 = Release|Any CPU 50 | {C8A4FC6C-FC35-4801-AC51-7D824141BC1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 51 | {C8A4FC6C-FC35-4801-AC51-7D824141BC1B}.Debug|Any CPU.Build.0 = Debug|Any CPU 52 | {C8A4FC6C-FC35-4801-AC51-7D824141BC1B}.Release|Any CPU.ActiveCfg = Release|Any CPU 53 | {C8A4FC6C-FC35-4801-AC51-7D824141BC1B}.Release|Any CPU.Build.0 = Release|Any CPU 54 | {18034065-98B4-4B16-A669-6D4549E50308}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 55 | {18034065-98B4-4B16-A669-6D4549E50308}.Debug|Any CPU.Build.0 = Debug|Any CPU 56 | {18034065-98B4-4B16-A669-6D4549E50308}.Release|Any CPU.ActiveCfg = Release|Any CPU 57 | {18034065-98B4-4B16-A669-6D4549E50308}.Release|Any CPU.Build.0 = Release|Any CPU 58 | {EF2FB0ED-290E-4B9A-9BA0-A18A123C67F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 59 | {EF2FB0ED-290E-4B9A-9BA0-A18A123C67F3}.Debug|Any CPU.Build.0 = Debug|Any CPU 60 | {EF2FB0ED-290E-4B9A-9BA0-A18A123C67F3}.Release|Any CPU.ActiveCfg = Release|Any CPU 61 | {EF2FB0ED-290E-4B9A-9BA0-A18A123C67F3}.Release|Any CPU.Build.0 = Release|Any CPU 62 | {CDB03743-7A7F-4BDF-AFEC-2EC35BB428C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 63 | {CDB03743-7A7F-4BDF-AFEC-2EC35BB428C1}.Debug|Any CPU.Build.0 = Debug|Any CPU 64 | {CDB03743-7A7F-4BDF-AFEC-2EC35BB428C1}.Release|Any CPU.ActiveCfg = Release|Any CPU 65 | {CDB03743-7A7F-4BDF-AFEC-2EC35BB428C1}.Release|Any CPU.Build.0 = Release|Any CPU 66 | EndGlobalSection 67 | GlobalSection(NestedProjects) = preSolution 68 | {E0FCB837-4E04-4537-A188-F8AE4FE5AF44} = {A11824D7-478B-4EC2-9CC3-AFBB64A77643} 69 | {C8A4FC6C-FC35-4801-AC51-7D824141BC1B} = {A11824D7-478B-4EC2-9CC3-AFBB64A77643} 70 | {18034065-98B4-4B16-A669-6D4549E50308} = {A11824D7-478B-4EC2-9CC3-AFBB64A77643} 71 | {EF2FB0ED-290E-4B9A-9BA0-A18A123C67F3} = {A11824D7-478B-4EC2-9CC3-AFBB64A77643} 72 | EndGlobalSection 73 | EndGlobal 74 | -------------------------------------------------------------------------------- /Intragration/Intragration.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net5.0 4 | false 5 | false 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | all 18 | 19 | 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | all 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /Intragration/Program.fs: -------------------------------------------------------------------------------- 1 | module Program = let [] main _ = 0 2 | -------------------------------------------------------------------------------- /Intragration/StockItemDao.fs: -------------------------------------------------------------------------------- 1 | module Tests 2 | 3 | open System 4 | open Stock 5 | open Xunit 6 | open Stock.PostgresDao 7 | open StockItem 8 | open FsToolkit.ErrorHandling 9 | open FsUnit.Xunit 10 | 11 | [] 12 | let ``GIVEN stock item WHEN inserted THEN after read it fully restored`` () = 13 | // Arrange 14 | let expectedStockItem: StockItem = 15 | { Id = Toolbox.generateId () |> StockItemId 16 | AvailableAmount = 4 17 | Name = Guid.NewGuid().ToString() } 18 | asyncResult { 19 | // Act 20 | do! StockItemDao.insert Toolbox.createSqlConnection expectedStockItem 21 | // Assert 22 | let! actualStockItem = StockItemDao.readBy Toolbox.createSqlConnection expectedStockItem.Id 23 | actualStockItem |> should equal expectedStockItem 24 | } |> Async.RunSynchronously 25 | 26 | [] 27 | let ``GIVEN stock item WHEN updated THEN after read it has updated values`` () = 28 | // Arrange 29 | let initialStockItem: StockItem = 30 | { Id = Toolbox.generateId () |> StockItemId 31 | AvailableAmount = 4 32 | Name = Guid.NewGuid().ToString() } 33 | let modifiedStockItem = { initialStockItem with AvailableAmount = 2; Name = Guid.NewGuid.ToString() } 34 | asyncResult { 35 | do! StockItemDao.insert Toolbox.createSqlConnection initialStockItem 36 | // Act 37 | do! StockItemDao.update Toolbox.createSqlConnection modifiedStockItem 38 | // Assert 39 | let! actualStockItem = StockItemDao.readBy Toolbox.createSqlConnection modifiedStockItem.Id 40 | actualStockItem |> should not' (equal initialStockItem) 41 | actualStockItem |> should equal modifiedStockItem 42 | } |> Async.RunSynchronously -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![F#](https://img.shields.io/badge/Made%20with-F%23-blue) 2 | 3 | This project is part of [blog post on F# dependencies composition](https://mcode.it/blog/2020-12-11-fsharp_composition_root/). 4 | 5 | # Building 6 | You will need dotnet 5.0 SDK to build and run the project. You can download it from here: https://dotnet.microsoft.com/download/dotnet/5.0. Then just use standard dotnet commands to build, test and run. 7 | -------------------------------------------------------------------------------- /Stock.Application/Queries/StockItemById.fs: -------------------------------------------------------------------------------- 1 | module Queries.StockItemById 2 | 3 | type Query = bigint 4 | 5 | type Result = { 6 | Id: int64 7 | Name: string 8 | AvailableAmount: int 9 | } 10 | -------------------------------------------------------------------------------- /Stock.Application/Stock.Application.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net5.0 4 | Stock.Application 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Stock.Application/StockItemWorkflows.fs: -------------------------------------------------------------------------------- 1 | namespace Stock.Application 2 | 3 | open Stock.StockItem 4 | open FsToolkit.ErrorHandling 5 | 6 | module StockItemWorkflows = 7 | type IO = { 8 | ReadBy: StockItemId -> Async> 9 | Update: StockItem -> Async 10 | Insert: StockItem -> Async 11 | } 12 | 13 | let remove io id amount: Async> = 14 | asyncResult { 15 | let! stockItem = io.ReadBy (id |> StockItemId) |> AsyncResult.mapError(fun _ -> CannotReadStockItem) 16 | let! stockItem = remove stockItem amount 17 | do! io.Update stockItem 18 | } 19 | 20 | let create io id name capacity: Async = 21 | create (id |> StockItemId) name capacity 22 | |> io.Insert -------------------------------------------------------------------------------- /Stock.PostgresDao/DapperFSharp.fs: -------------------------------------------------------------------------------- 1 | namespace Stock.PostgresDao 2 | 3 | open System.Data 4 | open Dapper 5 | open Npgsql 6 | 7 | module DapperFSharp = 8 | 9 | let sqlSingleOrNone<'Result> (query: string) (param: obj) (connection: IDbConnection): Async<'Result option> = 10 | async { 11 | let! result = connection.QuerySingleOrDefaultAsync<'Result>(query, param) 12 | |> Async.AwaitTask 13 | return match box result with 14 | | null -> None 15 | | _ -> Some result 16 | } 17 | 18 | let sqlSingle<'Result> (query: string) (param: obj) (connection: IDbConnection): Async<'Result> = 19 | async { 20 | let! result = connection.QuerySingleAsync<'Result>(query, param) 21 | |> Async.AwaitTask 22 | return result 23 | } 24 | 25 | let sqlExecute (sql: string) (param: obj) (connection: IDbConnection) = 26 | connection.ExecuteAsync(sql, param) 27 | |> Async.AwaitTask 28 | |> Async.Ignore 29 | 30 | let createSqlConnection (connectionString: string): unit -> Async = 31 | fun () -> async { 32 | let connection = new NpgsqlConnection(connectionString) 33 | if connection.State <> ConnectionState.Open 34 | then do! connection.OpenAsync() |> Async.AwaitTask 35 | return connection :> IDbConnection 36 | } 37 | -------------------------------------------------------------------------------- /Stock.PostgresDao/Stock.PostgresDao.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net5.0 4 | Hotel.PostgresDao 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /Stock.PostgresDao/StockItemDao.fs: -------------------------------------------------------------------------------- 1 | namespace Stock.PostgresDao 2 | 3 | open Stock.StockItem 4 | open DapperFSharp 5 | open Thoth.Json.Net 6 | 7 | module StockItemDao = 8 | let readBy createConnection (id: StockItemId) = 9 | async { 10 | use! connection = createConnection () 11 | let (StockItemId id) = id 12 | let! stockItemJson = connection |> sqlSingle "SELECT data FROM stockitems WHERE id = @id" {|id = id|} 13 | let stockItem = Decode.Auto.fromString(stockItemJson, CaseStrategy.PascalCase, Extra.empty |> Extra.withInt64) 14 | return stockItem 15 | } 16 | 17 | let insert createConnection (stockItem: StockItem) = 18 | async { 19 | use! connection = createConnection () 20 | let (StockItemId id) = stockItem.Id 21 | let json = Encode.Auto.toString(4, stockItem, CaseStrategy.PascalCase, Extra.empty |> Extra.withInt64) 22 | do! connection |> sqlExecute " 23 | INSERT INTO stockitems 24 | (id, data) 25 | VALUES(@Id, @Data::jsonb);" {| Id = id; Data = json |} 26 | } 27 | 28 | let update createConnection stockItem = 29 | async { 30 | use! connection = createConnection () 31 | let (StockItemId id) = stockItem.Id 32 | let json = Encode.Auto.toString(4, stockItem, CaseStrategy.PascalCase, Extra.empty |> Extra.withInt64) 33 | do! connection |> sqlExecute " 34 | UPDATE stockitems 35 | SET data = @Data::jsonb 36 | WHERE id = @Id" {| Id = id; Data = json |} 37 | } 38 | -------------------------------------------------------------------------------- /Stock.PostgresDao/StockItemQueryDao.fs: -------------------------------------------------------------------------------- 1 | namespace Stock.PostgresDao 2 | 3 | open DapperFSharp 4 | 5 | module StockItemQueryDao = 6 | 7 | let readBy createConnection (id: Queries.StockItemById.Query) = 8 | async { 9 | use! connection = createConnection () 10 | return! connection |> sqlSingleOrNone " 11 | SELECT Id, 12 | data ->> 'Name' AS Name, 13 | (data ->> 'AvailableAmount')::int AS AvailableAmount 14 | FROM stockitems 15 | WHERE Id = @Id 16 | " {|Id = id |> int64|} 17 | } -------------------------------------------------------------------------------- /Stock/Stock.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net5.0 4 | preview 5 | Hotels 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Stock/StockItem.fs: -------------------------------------------------------------------------------- 1 | namespace Stock 2 | 3 | module StockItem = 4 | 5 | type StockItemErrors = | OutOfStock | CannotReadStockItem 6 | type StockItemId = StockItemId of int64 7 | 8 | type StockItem = { 9 | Id: StockItemId 10 | Name: string 11 | AvailableAmount: int 12 | } 13 | 14 | let create id name amount = 15 | { Id = id; Name = name; AvailableAmount = amount } 16 | 17 | let (|GreaterThan|_|) k value = if value <= k then Some() else None 18 | 19 | let remove (stockItem: StockItem) amount: Result = 20 | match amount with 21 | | GreaterThan stockItem.AvailableAmount -> 22 | { stockItem with AvailableAmount = stockItem.AvailableAmount - amount } |> Ok 23 | | _ -> OutOfStock |> Error -------------------------------------------------------------------------------- /Toolbox/HttpContext.fs: -------------------------------------------------------------------------------- 1 | module HttpContext 2 | 3 | open System.Collections.Generic 4 | open System.Threading.Tasks 5 | open FakeItEasy 6 | open Giraffe 7 | open Giraffe.Serialization 8 | open Microsoft.AspNetCore.Http 9 | open System.IO 10 | open System.Text 11 | open Microsoft.Extensions.Primitives 12 | open Microsoft.FSharpLu.Json 13 | open Newtonsoft.Json 14 | 15 | let next: HttpFunc = Some >> Task.FromResult 16 | 17 | let buildMockHttpContext () = 18 | let emptyHeaders: IHeaderDictionary = 19 | HeaderDictionary(Dictionary()) :> IHeaderDictionary 20 | let customSettings = JsonSerializerSettings() 21 | customSettings.Converters.Add(CompactUnionJsonConverter(true)) 22 | let context = A.Fake() 23 | A.CallTo(fun () -> context.RequestServices.GetService(typeof)) 24 | .Returns(DefaultNegotiationConfig()) 25 | |> ignore 26 | A.CallTo(fun () -> context.RequestServices.GetService(typeof)) 27 | .Returns(NewtonsoftJsonSerializer(customSettings)) 28 | |> ignore 29 | A.CallTo(fun () -> context.Response.Headers).Returns(emptyHeaders) 30 | |> ignore 31 | context.Response.Body <- new MemoryStream() 32 | context 33 | 34 | let writeObjectToBody obj (httpContext: HttpContext) = 35 | let json = JsonConvert.SerializeObject(obj) 36 | let stream = 37 | new MemoryStream(Encoding.UTF8.GetBytes(json)) 38 | httpContext.Request.Body <- stream 39 | httpContext 40 | 41 | let deserializeResponse<'T> (response: HttpResponse) = 42 | response.Body.Position <- 0L 43 | use reader = 44 | new StreamReader(response.Body, Encoding.UTF8) 45 | let bodyJson = reader.ReadToEnd() 46 | let customSettings = JsonSerializerSettings() 47 | customSettings.Converters.Add(CompactUnionJsonConverter(true)) 48 | JsonConvert.DeserializeObject<'T>(bodyJson, customSettings) 49 | -------------------------------------------------------------------------------- /Toolbox/Toolbox.fs: -------------------------------------------------------------------------------- 1 | module Toolbox 2 | 3 | open System 4 | open System.Data 5 | open IdGen 6 | open Npgsql 7 | 8 | let createSqlConnection: unit -> Async = 9 | fun () -> async { 10 | let connection = new NpgsqlConnection("Host=localhost;User Id=postgres;Password=Secret!Passw0rd;Database=stock;Port=5432") 11 | if connection.State <> ConnectionState.Open 12 | then do! connection.OpenAsync() |> Async.AwaitTask 13 | return connection :> IDbConnection 14 | } 15 | 16 | let generateId = 17 | let structure = IdStructure(byte 41, byte 10, byte 12) 18 | let options = IdGeneratorOptions(structure, DefaultTimeSource(DateTimeOffset.Parse "2020-10-01 12:30:00")) 19 | let generator = IdGenerator(666, options) 20 | fun() -> generator.CreateId() -------------------------------------------------------------------------------- /Toolbox/Toolbox.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net5.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Unit/Program.fs: -------------------------------------------------------------------------------- 1 | module Program = let [] main _ = 0 2 | -------------------------------------------------------------------------------- /Unit/StockItem.fs: -------------------------------------------------------------------------------- 1 | module Tests 2 | 3 | open Xunit 4 | open Stock.StockItem 5 | open FsUnit.Xunit 6 | 7 | [] 8 | let ``GIVEN amount to remove is greater than available stock item amount WHEN remove THEN OutOfStock error is returned`` () = 9 | // Arrange 10 | let expectedError: Result = OutOfStock |> Result.Error 11 | 12 | let stockItem = 13 | { Id = 200 |> int64 |> StockItemId 14 | Name = "Whatever" 15 | AvailableAmount = 1 } 16 | // Act 17 | let result = remove stockItem 10 18 | // Assert 19 | result |> should equal expectedError 20 | 21 | [] 22 | [] 23 | [] 24 | [] 25 | let ``GIVEN amount to remove is equal or less than available stock item amount WHEN remove THEN StockItem available amount`` (amountToRemove) = 26 | // Arrange 27 | let stockItemUnderTest = 28 | { Id = 200 |> int64 |> StockItemId 29 | Name = "Whatever" 30 | AvailableAmount = 4 } 31 | let expectedStockItem: Result = 32 | { Id = stockItemUnderTest.Id 33 | Name = stockItemUnderTest.Name 34 | AvailableAmount = stockItemUnderTest.AvailableAmount - amountToRemove } 35 | |> Result.Ok 36 | // Act 37 | let result = remove stockItemUnderTest amountToRemove 38 | // Assert 39 | result |> should equal expectedStockItem 40 | -------------------------------------------------------------------------------- /Unit/Unit.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net5.0 4 | false 5 | false 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | all 18 | 19 | 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | all 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /database.sql: -------------------------------------------------------------------------------- 1 | create database stock; 2 | create table stockItems ( 3 | id bigint constraint pk_stockItems primary key, 4 | data json not null 5 | ); 6 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | db: 4 | image: postgres 5 | restart: always 6 | environment: 7 | POSTGRES_PASSWORD: Secret!Passw0rd 8 | POSTGRES_USER: postgres 9 | ports: 10 | - 5432:5432 --------------------------------------------------------------------------------