Safe and clean architecture: add modular agent bounded contexts#204
Safe and clean architecture: add modular agent bounded contexts#204halcwb merged 3 commits intoinformedica:masterfrom
Conversation
Greptile SummaryThis PR completes the bounded-context refactor by splitting the single monolithic Key changes:
Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Client
participant FormularyAgent
participant OrderCtxAgent
participant OrderPlanAgent
participant NutritionAgent
Note over FormularyAgent,NutritionAgent: Per-component MailboxProcessors
Client->>+OrderPlanAgent: postAsync UpdateOrderPlan
Note over OrderPlanAgent: Async.RunSynchronously
OrderPlanAgent->>+OrderCtxAgent: postAsync Evaluate(cmd, ctx)
OrderCtxAgent->>OrderCtxAgent: OrderContextService.evaluate
OrderCtxAgent-->>-OrderPlanAgent: OrderContext result
OrderPlanAgent->>OrderPlanAgent: calculateTotals
OrderPlanAgent-->>-Client: OrderPlan result
Client->>+NutritionAgent: postAsync AddNutritionContext
Note over NutritionAgent: Async.RunSynchronously
NutritionAgent->>+OrderCtxAgent: postAsync Evaluate(UpdateOrderContext, ctx)
OrderCtxAgent-->>-NutritionAgent: OrderContext result
NutritionAgent-->>-Client: NutritionPlan result
Reviews (1): Last reviewed commit: "refactor(server): switch to bounded doma..." | Re-trigger Greptile |
…ariant that accepts 'Request -> Async<'Reply> instead of 'Request -> 'Reply. The agent's internal loop uses let! reply = processor request so it awaits async operations without blocking a thread pool thread.
2. Converted processOrderPlanCommand from returning OrderPlanResponse to Async<OrderPlanResponse>, using let! to await OrderPlanService.updateOrderPlan instead of Async.RunSynchronously.
3. Converted processNutritionCommand from returning NutritionResponse to Async<NutritionResponse>, using let! for all 4 async service calls instead of Async.RunSynchronously.
4. Switched both agents from Agent.createReply to Agent.createReplyAsync, with the callback wrapped in async { ... }
halcwb
left a comment
There was a problem hiding this comment.
@greptile if everything is now correct approve.
There was a problem hiding this comment.
Pull request overview
This PR refactors the GenPRES server architecture toward clearer bounded contexts by moving domain logic into *Service modules and composing ports around a shared OrderContextPort, with agent-backed adapters providing per-component serialization.
Changes:
- Renamed/refactored server “service” modules (e.g.,
OrderPlan→OrderPlanService,NutritionPlan→NutritionPlanService) and updated call sites. - Reworked port composition so
OrderPlan/NutritionPlandepend on an injectedOrderContextPort(improves decoupling/testability). - Split the single server agent into per-component agents and added
Agent.createReplyAsyncto support async request/reply processors.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Informedica.GenPRES.Server/ServerApi.fs | Removed legacy monolithic ServerApi implementation (moved into more modular structure). |
| src/Informedica.GenPRES.Server/ServerApi.Services.fs | Renamed modules to *Service and refactored OrderPlan/NutritionPlan logic to use OrderContextPort async evaluation. |
| src/Informedica.GenPRES.Server/ServerApi.AgentAdapters.fs | Introduced per-bounded-context MailboxProcessor agents and updated AppEnv composition to use shared OrderContextPort. |
| src/Informedica.GenPRES.Server/ServerApi.Adapters.fs | Updated non-agent adapters to the new service module names and OrderContextPort-based composition. |
| src/Informedica.Agents.Lib/Agent.fs | Added createReplyAsync for async request/reply agent processors. |
| match logAgent with | ||
| | Some a -> a |> Logging.setComponentName (Some name) |> Async.RunSynchronously | ||
| | None -> () | ||
|
|
||
| match cmd with | ||
| | OrderPlanCommand.Update (tp, cmdOpt) -> | ||
| setComponent "TreatmentPlan" |
There was a problem hiding this comment.
This setComponent helper also uses Async.RunSynchronously even though it’s already inside an async { ... } block. This blocks a thread-pool thread unnecessarily; prefer match logAgent with | Some a -> do! ... | None -> () and keep the whole function non-blocking.
| match logAgent with | |
| | Some a -> a |> Logging.setComponentName (Some name) |> Async.RunSynchronously | |
| | None -> () | |
| match cmd with | |
| | OrderPlanCommand.Update (tp, cmdOpt) -> | |
| setComponent "TreatmentPlan" | |
| async { | |
| match logAgent with | |
| | Some a -> do! a |> Logging.setComponentName (Some name) | |
| | None -> return () | |
| } | |
| match cmd with | |
| | OrderPlanCommand.Update (tp, cmdOpt) -> | |
| do! setComponent "TreatmentPlan" |
| writeDebugMessage $"[FormularyAgent] -> {cmd |> formularyCommandToString} completed" | ||
| response | ||
| with ex -> | ||
| writeErrorMessage $"[FormularyAgent] error in {cmd |> formularyCommandToString}: {ex}" |
There was a problem hiding this comment.
This error log interpolates the exception value as {ex} without an explicit format specifier, which the repository’s F# string interpolation guideline discourages. Consider formatting explicitly (e.g., message vs full exception) so logs are consistent and predictable.
| writeErrorMessage $"[FormularyAgent] error in {cmd |> formularyCommandToString}: {ex}" | |
| writeErrorMessage $"[FormularyAgent] error in %s{cmd |> formularyCommandToString}: %s{ex.Message}" |
| writeDebugMessage $"[OrderCtxAgent] -> {cmd |> orderCtxCommandToString} completed" | ||
| response | ||
| with ex -> | ||
| writeErrorMessage $"[OrderCtxAgent] error in {cmd |> orderCtxCommandToString}: {ex}" |
There was a problem hiding this comment.
Same string interpolation issue here: {ex} is formatted without an explicit specifier. Please use a specific format (%s for ex.Message or %O/%A for the full exception) to match the project guideline and keep logs consistent.
| writeErrorMessage $"[OrderCtxAgent] error in {cmd |> orderCtxCommandToString}: {ex}" | |
| writeErrorMessage $"[OrderCtxAgent] error in {cmd |> orderCtxCommandToString}: %O{ex}" |
| writeDebugMessage $"[NutritionAgent] -> {cmd |> nutritionCommandToString} completed" | ||
| return response | ||
| with ex -> | ||
| writeErrorMessage $"[NutritionAgent] error in {cmd |> nutritionCommandToString}: {ex}" |
There was a problem hiding this comment.
Same interpolation issue: {ex} is used without an explicit format specifier in this log message. Please use an explicit format so exception logging is consistent and aligned with the repository’s interpolation guideline.
| writeErrorMessage $"[NutritionAgent] error in {cmd |> nutritionCommandToString}: {ex}" | |
| writeErrorMessage $"[NutritionAgent] error in %s{cmd |> nutritionCommandToString}: %O{ex}" |
| /// Build an AppEnv backed by per-component MailboxProcessor agents. | ||
| /// Each domain context gets its own agent, providing: | ||
| /// - Independent serialization per component (no cross-domain blocking) | ||
| /// - Thread-safe access to the provider within each component |
There was a problem hiding this comment.
This module-level comment claims “thread-safe access to the provider within each component”, but provider is shared across multiple agents and can now be accessed concurrently across components. Unless Resources.IResourceProvider is guaranteed thread-safe, this comment is misleading—either clarify the thread-safety requirement (e.g., must be a locked/cached provider) or reintroduce a shared serialization/lock around provider calls.
| /// - Thread-safe access to the provider within each component | |
| /// - An async boundary for all calls into the component | |
| /// | |
| /// Note: The same IResourceProvider instance is shared across all component agents, | |
| /// so calls into `provider` may occur concurrently across components. The supplied | |
| /// `provider` implementation must therefore be safe for concurrent use (for example, | |
| /// internally synchronized or otherwise thread-safe). | |
| /// |
| /// <summary> | ||
| /// Creates and starts an agent that supports request-reply messaging | ||
| /// with an async processor function. Unlike createReply, the processor | ||
| /// returns Async<'Reply>, so the agent can await async operations | ||
| /// (e.g., inter-agent calls) without blocking a thread pool thread. | ||
| /// </summary> | ||
| /// <param name="processor">An async function to process each request and produce a reply.</param> | ||
| /// <returns>An Agent instance.</returns> | ||
| let createReplyAsync<'Request, 'Reply>(processor: 'Request -> Async<'Reply>) = | ||
| Agent<'Request * AsyncReplyChannel<'Reply>>.Start(fun inbox -> | ||
| let rec loop () = async { | ||
| let! request, replyChannel = inbox.Receive() | ||
| let! reply = processor request | ||
| replyChannel.Reply(reply) | ||
| return! loop () | ||
| } | ||
| loop () | ||
| ) |
There was a problem hiding this comment.
createReplyAsync introduces new agent behavior (request/reply where the processor is async), but there are existing comprehensive Agent tests and none appear to cover this new constructor. Please add tests validating that async processors are awaited, replies are returned, and exceptions behave as expected.
| | other -> Error [| $"Unexpected response: {other}" |] | ||
|
|
||
|
|
||
| let private extractParenteralia = function | ||
| | FormularyResponse.Parenteralia r -> r | ||
| | other -> Error [| $"Unexpected response: {other}" |] |
There was a problem hiding this comment.
Same issue here: the error message interpolates {other} without a format specifier. Please use an explicit interpolation format so the union value is rendered predictably (and to match the project’s interpolation guideline).
| | other -> Error [| $"Unexpected response: {other}" |] | |
| let private extractParenteralia = function | |
| | FormularyResponse.Parenteralia r -> r | |
| | other -> Error [| $"Unexpected response: {other}" |] | |
| | other -> Error [| $"Unexpected response: %A{other}" |] | |
| let private extractParenteralia = function | |
| | FormularyResponse.Parenteralia r -> r | |
| | other -> Error [| $"Unexpected response: %A{other}" |] |
| writeDebugMessage $"[OrderPlanAgent] -> {cmd |> orderPlanCommandToString} completed" | ||
| return response | ||
| with ex -> | ||
| writeErrorMessage $"[OrderPlanAgent] error in {cmd |> orderPlanCommandToString}: {ex}" |
There was a problem hiding this comment.
Same interpolation issue: the exception is interpolated as {ex} without an explicit format specifier. Please format explicitly (and consider whether you want ex.Message or the full exception).
| writeErrorMessage $"[OrderPlanAgent] error in {cmd |> orderPlanCommandToString}: {ex}" | |
| writeErrorMessage $"[OrderPlanAgent] error in {cmd |> orderPlanCommandToString}: %A{ex}" |
| | other -> Error [| $"Unexpected response: {other}" |] | ||
|
|
||
|
|
||
| let private extractParenteralia = function | ||
| | FormularyResponse.Parenteralia r -> r | ||
| | other -> Error [| $"Unexpected response: {other}" |] |
There was a problem hiding this comment.
In these extraction helpers, the interpolated string uses {other} without an explicit format specifier. This can accidentally call ToString() and lose useful structure; the codebase’s F# guidelines require specifying a format (e.g., %A/%O/%s) in interpolated strings.
| | other -> Error [| $"Unexpected response: {other}" |] | |
| let private extractParenteralia = function | |
| | FormularyResponse.Parenteralia r -> r | |
| | other -> Error [| $"Unexpected response: {other}" |] | |
| | other -> Error [| $"Unexpected response: %A{other}" |] | |
| let private extractParenteralia = function | |
| | FormularyResponse.Parenteralia r -> r | |
| | other -> Error [| $"Unexpected response: %A{other}" |] |
| let setComponent name = | ||
| match logAgent with | ||
| | Some a -> a |> Logging.setComponentName (Some name) |> Async.RunSynchronously | ||
| | None -> () |
There was a problem hiding this comment.
Async.RunSynchronously inside agent command processing can block the agent thread and can deadlock if the logging agent ever depends on the calling agent. Since this is already inside an async workflow elsewhere in this file, prefer making this path async (e.g., using createReplyAsync and do! Logging.setComponentName ...) instead of blocking.
…ssues - Add entries for safe-and-clean server architecture (PR informedica#204) - Add Fable update (PR informedica#205) - Add GenFORM/GenORDER/NKF tests migrated to CI (PR informedica#206) - Add graceful shutdown (PR informedica#207) - Add Fantomas formatter pre-commit hook (PR informedica#208/informedica#209) - Fix '+' prefixes on 3 lines (leftover diff markers from PR #16 merge) - Remove 2 duplicate entries (GenOrderTestScaffolding, Server/GenForm) - Add Removed section for cleaned-up W3 analysis scripts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix duplicate entries in [Unreleased] section (GenOrderTestScaffolding, Server/GenForm error handling appeared twice due to merge artifact) - Remove spurious '+' prefix on 3 lines (git merge artifact in previous PR) - Promote [Unreleased] content to [0.1.2-alpha] dated 2026-03-23 - Add entries for recent PRs #201-#209: * Modular agent bounded contexts / safe-clean architecture (PR #204) * Graceful shutdown (PR #207) * Fantomas enforcement on commit (PR #208, #209) * New tests for GenForm, GenOrder, NKF (PR #206) * Fable dependency update (PR #205) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This pull request refactors several service modules and updates how ports are composed and used in the application environment. The main focus is to improve separation of concerns by moving business logic into dedicated service modules (with
Servicesuffix) and updating the dependency injection pattern for ports, especially for order and nutrition plans.Key changes include:
Service module refactoring:
Formulary,Parenteralia,Order,OrderPlan, andNutritionPlantoFormularyService,ParenteraliaService,OrderService,OrderPlanService, andNutritionPlanServicerespectively, and updated all usages accordingly. This centralizes business logic and clarifies responsibilities. [1] [2] [3] [4] [5]Dependency injection and port composition:
OrderPlanPortandNutritionPlanPortto accept anOrderContextPortinstead of passing logger and provider directly, enabling better testability and decoupling from infrastructure. Updated the application environment (makeAppEnv) to create and pass the sharedOrderContextPortinstance.Order and Nutrition plan logic updates:
OrderPlanServiceandNutritionPlanServiceto use the injectedOrderContextPortfor evaluating contexts asynchronously, improving consistency and enabling better error handling. [1] [2] [3]OrderServicemodule for order total calculations, ensuring all business logic is routed through the appropriate service abstraction. [1] [2] [3]Adapters update:
Adaptersmodule to use the new service modules and updated function signatures to match the new dependency injection pattern. [1] [2]These changes collectively improve maintainability, testability, and clarity of the codebase by enforcing a clear separation between business logic and infrastructure concerns.