|
| 1 | +# Clean Safe Architecture Investigation |
| 2 | + |
| 3 | +**Issue**: [Safe Clean Architecture](https://github.com/informedica/GenPRES/issues) — Investigate requirements for adopting a Clean Safe Architecture with Tagless Final style. |
| 4 | + |
| 5 | +**Reference**: <https://rdeneau.gitbook.io/safe-clean-architecture/domain-workflows/1-introduction/5-tagless-final> |
| 6 | + |
| 7 | +**Date**: 2026-03-22 |
| 8 | + |
| 9 | +**Issue**: [Safe Clean Architecture #194](https://github.com/informedica/GenPRES/issues/194) — Investigate requirements for adopting a Clean Safe Architecture with Tagless Final style. |
| 10 | + |
| 11 | +## Table of Contents |
| 12 | + |
| 13 | +- [Summary](#summary) |
| 14 | +- [Current Architecture](#current-architecture) |
| 15 | + - [Strengths](#strengths) |
| 16 | + - [Gaps](#gaps) |
| 17 | +- [Clean Safe Architecture Principles](#clean-safe-architecture-principles) |
| 18 | + - [Tagless Final in F#](#tagless-final-in-f) |
| 19 | +- [Gap Analysis](#gap-analysis) |
| 20 | +- [Recommended Changes](#recommended-changes) |
| 21 | + - [Phase 1 — Split ServerApi.fs into cohesive layers](#phase-1--split-serverapifs-into-cohesive-layers) |
| 22 | + - [Phase 2 — Introduce Application-Layer Ports](#phase-2--introduce-application-layer-ports) |
| 23 | + - [Phase 3 — Wire via a single Composition Root](#phase-3--wire-via-a-single-composition-root) |
| 24 | + - [Phase 4 — Improve test coverage with stub adapters](#phase-4--improve-test-coverage-with-stub-adapters) |
| 25 | +- [What to Leave Alone](#what-to-leave-alone) |
| 26 | +- [Architecture Gap Table](#architecture-gap-table) |
| 27 | +- [Prototype](#prototype) |
| 28 | + |
| 29 | +--- |
| 30 | + |
| 31 | +## Summary |
| 32 | + |
| 33 | +GenPRES already has several Clean Architecture elements in place — notably `IResourceProvider` as a data-access port and pure-functional domain libraries. The main improvement opportunity is in the **Application layer** (`ServerApi.fs`), which currently mixes DTO mapping, orchestration logic, command routing, and infrastructure setup in a single 1 400-line file. |
| 34 | + |
| 35 | +Adopting the **Tagless Final** style means introducing narrow *application-layer port* types (records of functions) for each workflow area, coding the application services against those abstractions, and resolving the concrete dependencies once in a dedicated **Composition Root**. |
| 36 | + |
| 37 | +The changes are **incremental and low-risk** — they do not touch the domain libraries or the client side. |
| 38 | + |
| 39 | +--- |
| 40 | + |
| 41 | +## Current Architecture |
| 42 | + |
| 43 | +``` |
| 44 | +┌──────────────────────────────────────────────────────────────┐ |
| 45 | +│ PRESENTATION IServerApi (Fable.Remoting) │ |
| 46 | +├──────────────────────────────────────────────────────────────┤ |
| 47 | +│ APPLICATION ServerApi.fs (1 400 lines) │ |
| 48 | +│ • Mappers — DTO ↔ domain conversions │ |
| 49 | +│ • OrderContext, Formulary, OrderPlan, ... │ |
| 50 | +│ • Command — command router │ |
| 51 | +│ • ApiImpl — creates IServerApi instance │ |
| 52 | +│ Dependencies: provider + logger threaded │ |
| 53 | +│ through every function call │ |
| 54 | +├──────────────────────────────────────────────────────────────┤ |
| 55 | +│ PORT IResourceProvider (GenForm.Lib.Resources) │ |
| 56 | +├──────────────────────────────────────────────────────────────┤ |
| 57 | +│ DOMAIN GenOrder.Lib GenForm.Lib GenSolver.Lib │ |
| 58 | +│ (mostly pure functional, no I/O) │ |
| 59 | +├──────────────────────────────────────────────────────────────┤ |
| 60 | +│ INFRASTRUCTURE ResourceProvider / CachedResourceProvider │ |
| 61 | +│ (Google Sheets → CSV → domain types) │ |
| 62 | +└──────────────────────────────────────────────────────────────┘ |
| 63 | +``` |
| 64 | + |
| 65 | +### Strengths |
| 66 | + |
| 67 | +| Element | Why it is good | |
| 68 | +|---|---| |
| 69 | +| `IResourceProvider` | Already a well-defined Port; abstracts all resource loading | |
| 70 | +| `ResourceConfig` | Uses record-of-functions — this *is* Tagless Final style | |
| 71 | +| Domain libraries | Pure F# with no I/O side effects; highly testable | |
| 72 | +| `IServerApi` | Clean presentation-layer boundary via Fable.Remoting | |
| 73 | +| `Shared.Api.Command` discriminated union | Explicit command model; aligns with CQRS | |
| 74 | + |
| 75 | +### Gaps |
| 76 | + |
| 77 | +| Gap | Impact | |
| 78 | +|---|---| |
| 79 | +| `ServerApi.fs` is a 1 400-line monolith mixing 4+ concerns | Hard to navigate, test, and extend | |
| 80 | +| `logger` and `provider` are threaded through every function | Fragile, verbose; no single wiring point | |
| 81 | +| No narrow application-layer ports (only `IResourceProvider`) | Cannot substitute parts for testing without a full provider | |
| 82 | +| Effects (`async`/`Result`) composed inconsistently | Some functions return `Result`, others `Async<Result>`, mixing convention | |
| 83 | +| No explicit Composition Root | Dependency resolution is scattered across `Server.fs` and `ServerApi.fs` | |
| 84 | + |
| 85 | +--- |
| 86 | + |
| 87 | +## Clean Safe Architecture Principles |
| 88 | + |
| 89 | +Clean Architecture in the SAFE Stack context has five concerns: |
| 90 | + |
| 91 | +1. **Domain Core** — pure types and business rules; no I/O. |
| 92 | +2. **Application Services** — orchestrate workflows; depend on ports, not concrete infrastructure. |
| 93 | +3. **Ports** — abstract interfaces (records of functions in F#) representing external capabilities. |
| 94 | +4. **Adapters / Infrastructure** — concrete implementations of ports; depend on external systems. |
| 95 | +5. **Composition Root** — the single place that wires everything together. |
| 96 | + |
| 97 | +The dependency rule: inner layers must not depend on outer layers. |
| 98 | + |
| 99 | +``` |
| 100 | +Domain Core ← Application Services ← Ports ← Adapters ← Composition Root |
| 101 | +``` |
| 102 | + |
| 103 | +### Tagless Final in F# |
| 104 | + |
| 105 | +"Tagless Final" (also called "finally tagless" or the "free monad lite") is a functional programming pattern where abstract behaviours are represented as parameterised *algebras*. In Haskell this uses type classes; in F# the idiomatic encoding is a **record of functions**: |
| 106 | +Domain Core → Application Services → Ports ← Adapters ← Composition Root |
| 107 | +```fsharp |
| 108 | +// The "algebra" — a port — expressed as a record of functions |
| 109 | +type IOrderContextPort = |
| 110 | + { |
| 111 | + evaluate : |
| 112 | + Api.OrderContextCommand |
| 113 | + -> OrderContext |
| 114 | + -> Async<Result<OrderContext, string[]>> |
| 115 | + } |
| 116 | +``` |
| 117 | + |
| 118 | +The application layer codes against these abstract records and never touches a concrete `IResourceProvider`. Concrete adapters implement the records at the composition root. |
| 119 | + |
| 120 | +The key properties: |
| 121 | + |
| 122 | +- **Testability** — any port can be replaced with a stub record in tests. |
| 123 | +- **Composability** — ports are plain F# values; they compose naturally. |
| 124 | +- **Single dependency axis** — the application layer only sees the port records, not the infrastructure. |
| 125 | + |
| 126 | +--- |
| 127 | + |
| 128 | +## Gap Analysis |
| 129 | + |
| 130 | +| Concern | Current state | Target state | |
| 131 | +|---|---|---| |
| 132 | +| Application layer size | 1 file, 1 400 lines | 3–4 focused files | |
| 133 | +| Application-layer ports | None (only `IResourceProvider`) | `IOrderContextPort`, `IFormularyPort`, `IOrderPlanPort`, `INutritionPlanPort` | |
| 134 | +| Effect type consistency | Mixed (`Result` / `Async<Result>`) | Uniform `Async<Result<'T, string[]>>` for all ports | |
| 135 | +| Composition Root | Implicit, scattered | Explicit `CompositionRoot.fs` | |
| 136 | +| Testability at application layer | Requires real provider | Stub `AppEnv` with in-memory port implementations | |
| 137 | +| Domain / Infrastructure coupling | `GenOrder.Api` takes `provider` directly | Already thin — no change needed | |
| 138 | + |
| 139 | +--- |
| 140 | + |
| 141 | +## Recommended Changes |
| 142 | + |
| 143 | +### Phase 1 — Split `ServerApi.fs` into cohesive layers |
| 144 | + |
| 145 | +Split the existing file without changing any behaviour: |
| 146 | + |
| 147 | +``` |
| 148 | +src/Informedica.GenPRES.Server/ |
| 149 | +├── ServerApi.Mappers.fs ← extracted Mappers module (pure DTO conversions) |
| 150 | +├── ServerApi.Services.fs ← extracted application-service modules (OrderContext, Formulary, etc.) |
| 151 | +├── ServerApi.Command.fs ← extracted Command router |
| 152 | +└── ServerApi.ApiImpl.fs ← extracted IServerApi implementation + ApiImpl |
| 153 | +``` |
| 154 | + |
| 155 | +**Risk**: Low — pure refactoring, no logic changes. |
| 156 | +**Benefit**: Navigability, clearer ownership, smaller review surface per file. |
| 157 | + |
| 158 | +### Phase 2 — Introduce Application-Layer Ports |
| 159 | + |
| 160 | +Define a set of narrow application-level port types as F# record types. A prototype is provided in `src/Informedica.GenPRES.Server/Scripts/CleanArchitecture.fsx`. |
| 161 | + |
| 162 | +```fsharp |
| 163 | +type IOrderContextPort = |
| 164 | + { evaluate : Api.OrderContextCommand -> OrderContext -> Async<Result<OrderContext, string[]>> } |
| 165 | +
|
| 166 | +type IFormularyPort = |
| 167 | + { getDoseRules : Formulary -> Async<Result<Formulary, string>> |
| 168 | + getParenteralia : Parenteralia -> Async<Result<Parenteralia, string>> } |
| 169 | +
|
| 170 | +type IOrderPlanPort = |
| 171 | + { updateOrderPlan : OrderPlan -> (Api.OrderContextCommand * OrderContext) option -> Async<Result<OrderPlan, string[]>> |
| 172 | + filterOrderPlan : OrderPlan -> Async<Result<OrderPlan, string[]>> } |
| 173 | +
|
| 174 | +type INutritionPlanPort = { ... } |
| 175 | +
|
| 176 | +/// Root environment — the "Reader environment" for all application services |
| 177 | +type AppEnv = |
| 178 | + { formulary : IFormularyPort |
| 179 | + orderContext : IOrderContextPort |
| 180 | + orderPlan : IOrderPlanPort |
| 181 | + nutritionPlan: INutritionPlanPort } |
| 182 | +``` |
| 183 | + |
| 184 | +The `ApplicationService.processCommand` function then takes `AppEnv` instead of `provider + logger`: |
| 185 | + |
| 186 | +```fsharp |
| 187 | +let processCommand (env: AppEnv) (cmd: Command) : Async<Result<Response, string[]>> |
| 188 | +``` |
| 189 | + |
| 190 | +**Risk**: Medium — changes the signature of the command processor. |
| 191 | +**Benefit**: Clear seam for testing; no concrete infrastructure in application code. |
| 192 | + |
| 193 | +### Phase 3 — Wire via a single Composition Root |
| 194 | + |
| 195 | +Create `Server/CompositionRoot.fs` (or extend `Server.fs`) that: |
| 196 | + |
| 197 | +1. Creates `provider` (already done in `Server.fs`). |
| 198 | +2. Creates a `Logger` (currently recreated per-command in `Command.processCmd`). |
| 199 | +3. Creates the concrete adapters (`makeOrderContextPort`, etc.). |
| 200 | +4. Assembles `AppEnv`. |
| 201 | +5. Creates `IServerApi` using `AppEnv`. |
| 202 | + |
| 203 | +```fsharp |
| 204 | +// CompositionRoot.fs |
| 205 | +let compose (dataUrlId: string) : IServerApi = |
| 206 | + let logger = resolveLogger () |
| 207 | + let provider = GenForm.Lib.Api.getCachedProviderWithDataUrlId logger dataUrlId |
| 208 | + let env = Adapters.makeAppEnv provider logger |
| 209 | + { processCommand = ApplicationService.processCommand env |
| 210 | + testApi = fun () -> async { return "Hello world!" } } |
| 211 | +``` |
| 212 | + |
| 213 | +**Risk**: Low — isolates wiring; reduces hidden coupling. |
| 214 | +**Benefit**: Single place to read the dependency graph; easier onboarding. |
| 215 | + |
| 216 | +### Phase 4 — Improve test coverage with stub adapters |
| 217 | + |
| 218 | +With `AppEnv` in place, any application-layer test can build a minimal stub environment: |
| 219 | + |
| 220 | +```fsharp |
| 221 | +let stubEnv returnCtx = |
| 222 | + { orderContext = { evaluate = fun _cmd _ctx -> async { return Ok returnCtx } } |
| 223 | + formulary = { getDoseRules = fun _ -> failwith "not stubbed" |
| 224 | + getParenteralia = fun _ -> failwith "not stubbed" } |
| 225 | + orderPlan = { ... } |
| 226 | + nutritionPlan = { ... } } |
| 227 | +
|
| 228 | +testAsync "order context command returns Ok" { |
| 229 | + let env = stubEnv someContext |
| 230 | + let cmd = OrderContextCmd (UpdateOrderContext, someContext) |
| 231 | + let! resp = ApplicationService.processCommand env cmd |
| 232 | + resp |> Expect.isOk "should succeed" |
| 233 | +} |
| 234 | +``` |
| 235 | + |
| 236 | +**Risk**: None — additive only. |
| 237 | +**Benefit**: Fast, isolated application-layer tests with no I/O or resource loading. |
| 238 | + |
| 239 | +--- |
| 240 | + |
| 241 | +## What to Leave Alone |
| 242 | + |
| 243 | +| Area | Reason | |
| 244 | +|---|---| |
| 245 | +| `IResourceProvider` | Already a good port; well-tested; do not replace | |
| 246 | +| Domain libraries (`GenOrder`, `GenForm`, `GenSolver`) | Already pure; no changes needed | |
| 247 | +| `IServerApi` / `Shared.Api` | Clean presentation boundary; no changes needed | |
| 248 | +| Client-side Elmish MVU | Out of scope; this investigation focuses on the server | |
| 249 | +| `ResourceConfig` record-of-functions | Already Tagless Final style; a model to follow | |
| 250 | + |
| 251 | +--- |
| 252 | + |
| 253 | +## Architecture Gap Table |
| 254 | + |
| 255 | +| Layer | Current | Clean Safe Architecture target | |
| 256 | +|---|---|---| |
| 257 | +| Presentation | `IServerApi` (Remoting) | unchanged | |
| 258 | +| Application | `ServerApi.fs` — 1 file, 1 400 lines | split into 3–4 focused files | |
| 259 | +| Ports | `IResourceProvider` only | `IResourceProvider` + 4 application-level ports | |
| 260 | +| Composition Root | implicit in `Server.fs` | explicit `CompositionRoot.fs` | |
| 261 | +| Domain | `GenOrder`/`GenForm` (mostly pure) | unchanged | |
| 262 | +| Infrastructure | `ResourceProvider`/`CachedResourceProvider` | + narrow `Adapters` module | |
| 263 | +| Testability | requires full `IResourceProvider` | stub `AppEnv` for unit tests | |
| 264 | + |
| 265 | +--- |
| 266 | + |
| 267 | +## Prototype |
| 268 | + |
| 269 | +A working prototype of the patterns described in this document is provided in: |
| 270 | + |
| 271 | +``` |
| 272 | +src/Informedica.GenPRES.Server/Scripts/CleanArchitecture.fsx |
| 273 | +``` |
| 274 | + |
| 275 | +The script demonstrates: |
| 276 | + |
| 277 | +1. Current architecture observations (Section 1) |
| 278 | +2. Tagless Final record-of-functions encoding (Section 2) |
| 279 | +3. Proposed `AppEnv` port types (Section 3) |
| 280 | +4. `ApplicationService.processCommand` coded against `AppEnv` (Section 4) |
| 281 | +5. Concrete adapters wiring `AppEnv` from `IResourceProvider` (Section 5) |
| 282 | +6. Stub adapters for unit testing (Section 6) |
| 283 | +7. Phased migration plan (Section 7) |
| 284 | +8. Summary gap table (Section 8) |
| 285 | + |
| 286 | +The prototype uses `#load "load.fsx"` to bring in the compiled domain DLLs and existing `ServerApi.fs` modules, so all type signatures compile against the actual codebase types. |
0 commit comments