Skip to content

Commit 11886f6

Browse files
authored
Merge pull request #195 from informedica/copilot/investigate-safe-clean-architecture
Investigate Safe Clean Architecture / Tagless Final for GenPRES
2 parents d4adea6 + eea4971 commit 11886f6

File tree

2 files changed

+758
-0
lines changed

2 files changed

+758
-0
lines changed
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
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

Comments
 (0)