toolup-forgetoolup-forge

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 Model alone.
  • 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 useEffect chains.

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:

  • IBlobStorageToolUp.Storage.AwsS3 | ToolUp.Storage.Azure | ToolUp.Storage.GoogleCloud
  • IAIProviderToolUp.AIProviders.Claude | ToolUp.AIProviders.OpenAI
  • IAuthProviderToolUp.AuthProviders.Oidc | ToolUp.AuthProviders.OidcClient | ToolUp.AuthProviders.ClerkUI
  • IAuditSinkToolUp.AuditSinks.S3Archive | ToolUp.AuditSinks.SplunkHec | ToolUp.AuditSinks.DatadogLogs
  • INotificationChannelToolUp.NotificationChannels.Redis | Email.Smtp | Email.SendGrid | Sms.Twilio | Push.WebPush

Two consequences:

  1. 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.
  2. Vendors are interchangeable. Swapping S3 for Azure Blob is a <PackageReference> change plus a ServerApp.withStorage line. 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:

  1. Identity by value. Returns and parameters use string, Guid, or domain records — never live framework handles (IActorRef, IGrainReference).
  2. Async at every boundary. Every method returns Async<T> or Task<T>. Sync methods can't propagate distributed failures.
  3. Retry + supervision as data. Behaviour expressed as records (RetryPolicy). Callback parameters like OnFailure: exn -> unit leak framework semantics.
  4. Stateless handlers between invocations. Handlers receive all state via parameters — Orleans deactivates grains, Akka.Persistence restarts actors.
  5. No cross-shard ordering promises. Ordering guaranteed only within a ShardKey. Cross-shard ordering correctness is a violation.
  6. 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:

  1. ClientModule.register — erases per-module 'Model / 'Msg so the shell can hold a heterogeneous list of modules.
  2. DataTypeDisplay.RenderSummary — every data-producing module boxes its summary record in its server-side DataType.Process and unboxes in the client-side RenderSummary callback. 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 new style 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. IEntityStore is 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 AIAnonymous mode 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.