Why toolup-forge
The opinionated decisions behind the SDK — F# end-to-end, MVU discipline, companion packages, the six portability rules, schema-driven modules.
There are good reasons to pick a different stack. This page exists to make toolup-forge's tradeoffs legible — so you can tell whether they line up with what you're building before you commit a quarter to it.
F# end-to-end, with shared types crossing the wire
The server is Giraffe over ASP.NET Core. The client is Fable + Elmish + Feliz. The build is FAKE. All F#. Shared records and discriminated unions cross the client/server boundary via Fable.Remoting — no DTO duplication, no hand-rolled JSON, no contract-first generation step. The function signature on the server is the function signature on the client.
// SharedTypes.fs — visible to both sides
type SalesApi = {
GetTotals: DateRange -> Async<SalesTotals>
SubmitForecast: Forecast -> Async<Result<ForecastId, string>>
}
// Client.fs — typed proxy auto-generated by Fable.Remoting.Client
let api : SalesApi = Remoting.createApi() |> Remoting.buildProxy
// api.GetTotals dateRange |> Async.RunSynchronously
Refactoring a record field changes both ends at once. Renaming a function on the server breaks the client compile. The wire becomes invisible.
Why this is load-bearing: the typical full-stack pain is the contract layer between languages — OpenAPI, gRPC, GraphQL, hand-written DTOs. Each is a place where the server's understanding can drift from the client's. F# end-to-end deletes the seam.
Background: Architecture.
Elmish MVU on the client — pure, predictable
Every module is a small Model / Msg / init / update / view Elmish unit. update is a pure function. Side effects go through Cmd. There is no in-memory mutable state at module level. Across an entire app this discipline means:
- A render can always be reproduced from
Modelalone. - A failing test reproduces by replaying a sequence of
Msgs. - Concurrent updates don't tangle — the runtime is single-threaded by construction.
- The Fable client compiles to JS that React renders, but the React lifecycle is below the MVU layer — modules never write
useEffectchains.
The trade is real: contributors fluent in React-hooks-with-state-libraries find Elmish takes a week to feel natural. The payoff is a predictable client codebase even at the size where a hook-heavy React app has become spooky-action-at-a-distance. Forge's client conventions describe the discipline a contributor adopts.
Background: Modules.
Companion packages isolate vendors
The SDK core (ToolUp.Platform.*) carries no third-party vendor SDK. Every cloud / vendor integration lives in a companion package behind an SDK interface:
IBlobStorage→ToolUp.Storage.AwsS3|ToolUp.Storage.Azure|ToolUp.Storage.GoogleCloudIAIProvider→ToolUp.AIProviders.Claude|ToolUp.AIProviders.OpenAIIAuthProvider→ToolUp.AuthProviders.Oidc|ToolUp.AuthProviders.OidcClient|ToolUp.AuthProviders.ClerkUIIAuditSink→ToolUp.AuditSinks.S3Archive|ToolUp.AuditSinks.SplunkHec|ToolUp.AuditSinks.DatadogLogsINotificationChannel→ToolUp.NotificationChannels.Redis|Email.Smtp|Email.SendGrid|Sms.Twilio|Push.WebPush
Two consequences:
- No paid-by-default dependencies. The default composition runs on freely-available components. Paid / enterprise components (AG Grid Enterprise, hosted auth UIs) are opt-in companions a deployment explicitly chooses.
- Vendors are interchangeable. Swapping S3 for Azure Blob is a
<PackageReference>change plus aServerApp.withStorageline. No code outside the composition root moves.
Browse the full companion catalogue for the current set.
Background: Auth providers · Storage providers.
The six portability rules
The SDK's distributed-ready interfaces (IJobScheduler, IJobStore, IModuleQueryBus, INotificationChannel, IShareTokenStore, etc.) obey six rules that keep them implementable by any distributed task framework — Akka.NET, Orleans, Hangfire, Redis-backed, in-process — without breaking consumers. Briefly:
- Identity by value. Returns and parameters use
string,Guid, or domain records — never live framework handles (IActorRef,IGrainReference). - Async at every boundary. Every method returns
Async<T>orTask<T>. Sync methods can't propagate distributed failures. - Retry + supervision as data. Behaviour expressed as records (
RetryPolicy). Callback parameters likeOnFailure: exn -> unitleak framework semantics. - Stateless handlers between invocations. Handlers receive all state via parameters — Orleans deactivates grains, Akka.Persistence restarts actors.
- No cross-shard ordering promises. Ordering guaranteed only within a
ShardKey. Cross-shard ordering correctness is a violation. - Precision at the lower bound. Scheduling primitives declare their precision contract (
JobPrecision: Second | Minute). Implicit sub-second promises some implementations can't honour are violations.
Conformance test packs (IJobSchedulerContract, IModuleQueryBusContract, IShareTokenStoreContract) enforce these executably — any new implementation passes the same tests.
This is unusually strict for an SDK of this shape. The reason: a small-deployment in-process implementation and a large-deployment distributed implementation must look the same to consumer code. Six rules satisfied at the interface level keeps that promise.
Background: Portability rules.
Schema-driven, not framework-driven
CRUD-heavy intake-and-approval flows — surveys, applications, forms with validation, multi-step workflows — are described as data (a FormSchema + a WorkflowDefinition record), not coded per-form. The Forms package renders them, validates them, persists them, drives state machines, and (when needed) publishes them as anonymous-submit surveys with HMAC-signed share tokens.
The trade vs a hand-rolled form library: less per-form customisation, more per-form productivity. For an app that needs 30 forms, this is unambiguously the right shape; for an app that needs three highly custom forms, less so.
Background: Forms concepts.
Multi-tenant by construction
Scope isolation, RBAC, per-tenant data scoping, and audit trail are first-class properties of the SDK, not retrofitted middleware. The PlatformMode DU pins five possible auth + isolation postures:
Anonymous— session-scoped, no auth, dev / demo / public tools.AuthenticatedEphemeral— user-scoped, no persistence — trial accounts, compliance-sensitive analysis.Individual— user-scoped, persistent — single-user paid accounts.Team— team-scoped, persistent — multi-user organisations, one team per user.MultiTeam— active-team-scoped, persistent — users belong to many teams and switch in-session.
Both server (ServerConfig.Mode) and client (ClientConfig.Mode) must agree at compile time. When they don't, the SDK fails loudly rather than rendering a half-authenticated shell. The 2026-05 Surfaces compile-time precedence finding made this explicit.
Background: Surfaces.
Backward-compatible defaults
A new SDK feature defaults to off / to its prior behaviour. A deployment that upgrades stays byte-for-byte identical until it explicitly opts in. fromEnv helpers and config records preserve prior dispatch behaviour exactly. This means SDK minor-bump upgrades aren't an outage class.
Conversely: when a breaking change is necessary (the Forms substrate rewrite, the Elmish 0.4.x adoption, the AI provider substrate refresh), a migration doc lands under docs/migrations/ with before/after diffs, verification steps, and rollback. The migration is the explicit ask; the default-preserving path is the silent one.
Type erasure stays inside two boundaries
F# is type-safe at every layer the SDK exposes. The two sanctioned box / unbox boundaries are:
ClientModule.register— erases per-module'Model/'Msgso the shell can hold a heterogeneous list of modules.DataTypeDisplay.RenderSummary— every data-producing module boxes its summary record in its server-sideDataType.Processand unboxes in the client-sideRenderSummarycallback. A symmetric same-module-known-type cast on both ends.
Outside these two, module code never sees obj. Type erasure is contained, documented, and reviewed at the boundary; it doesn't leak into domain code.
What it isn't trying to be
- A SaaS PaaS. Forge is an SDK you compose. There's no "deploy your app to forge.cloud" button. Your hosting is your call.
- A starter template.
samples/HelloWorld/is a runnable end-to-end sample, but the SDK is a library, not a generator. The trade vs Yeoman /dotnet newstyle scaffolding: the project shape you ship doesn't drift from the framework's expectations because the framework is just a NuGet package set. - Cross-language. F# end-to-end is the load-bearing assumption. If your team can't or won't write F# on the client, this is the wrong SDK. (A separately-tracked polyglot SDK initiative exists for the consumer-side C# / VB story, but it's not the default and not the recommended path.)
- Opinionated about the database.
IEntityStoreis the SDK's persistence interface; default implementations file-back or use Azure Table Storage. Bring whatever you want behind the interface.
When it's not the right shape
If your app is:
- Static-first (marketing, blog, docs) and never grows interactive surfaces → use a static-site generator. (This site is the borderline case: SSR with no Fable client. It's open-source if you want a reference.)
- Single-user with no persistence and no AI →
Anonymousmode works, but you're paying the SDK's structural cost for very little of its surface. A SAFE template directly will be lighter. - A workflow your team has already solved in another full-stack F# stack (Suave + SAFE, Bolero, Avalonia.FuncUI) → familiarity usually wins. Forge competes on shape, not on novelty.
What you give up
In exchange for the multi-tenant infrastructure, the AI / RAG / Forms / Scheduling companions, and the typed-wire ergonomics:
- A learning curve. Composition roots, module convention, scope resolution, the surface DU, premium claims — there's vocabulary. The Getting started walkthrough flattens the worst of it.
- F# adoption cost. Hiring an F# team is harder than hiring a TypeScript team. Mitigated if your existing team writes F# (or wants to); painful if not.
- A pre-1.0 surface. Breaking changes ship in 0.x minor releases per SemVer-on-0.x. Migration docs accompany each.
If the trade is right for you, the next step is Getting started. If not, that's useful information too.