Less code to decode.
Forward pipes. Named functions. Explicit cases. Spend your attention on what the program does, without a detour through operator trivia.
Less head-scratching. More shipping.
F# with fewer sharp edges. Less squinting at “clever” code and more shipping!
Open source · MIT licensed · Opinionated on purpose
let positiveIncrements values =
List.filter isPositive <|
List.map increment values
let positiveIncrements values =
values
|> List.map increment
|> List.filter isPositive
Same result. Fewer forehead wrinkles.
“F# for dullards.” Affectionately. Because the person reading your code might be a teammate, a new hire, or you on a Tuesday with very little sleep. Let’s make their day easier.
Forward pipes. Named functions. Explicit cases. Spend your attention on what the program does, without a detour through operator trivia.
Values stay put. Failure gets a type. Side effects have a boundary. A smaller set of habits makes risky assumptions easier to spot.
Standardize the readable path across the codebase. Reviews can focus on the decision being made, with fewer debates about how cleverly to spell it.
Write code that makes the next person say “oh, I get it.”
69 rule IDs. 13 families. One idea: make the obvious thing the standard thing. Here’s what each rule asks for, and what it saves you from having to keep in your head.
A shared vocabulary beats a personal collection of clever tricks. Ordinary function calls and forward pipes make the order of work visible.
Reject backward pipes: <|, <|| and <|||, including aliases and operator values. Use ordinary calls or |> instead.
Why it helps: Read the data flow in one direction, without mentally reversing a chain.
let result =
List.filter isPositive <|
List.map increment values
let result =
values
|> List.map increment
|> List.filter isPositive
Map first, filter second. The after version reads in the same order it runs.
Reject tuple pipes (||> and |||>) and application-defined symbolic operators outside the approved vocabulary.
Why it helps: Named functions tell the next reader what an operation means without a private operator glossary.
let total =
(price, quantity) ||> (*)
let lineTotal price quantity =
price * quantity
let total = lineTotal price quantity
A name explains the operation; the reader does not have to decode a tuple pipe.
Keep raw fold, scan, reduce and similar accumulation APIs inside exact, reviewed pure helpers.
Why it helps: A named helper explains intent; a generic accumulator makes every reader reconstruct the algorithm. A helper profile alone grants no permission.
let total =
items
|> List.fold
(fun acc item -> acc + item.Price) 0m
let total =
items
|> List.sumBy (fun item -> item.Price)
Use a specific operation when it fits. More complex accumulation belongs in an exact, reviewed helper.
Prefer transformations of immutable data. An immutable wrapper is only useful if its contents are immutable too.
Reject mutable local and module bindings such as let mutable.
Why it helps: A name keeps its value, so understanding it does not require tracing every earlier statement.
let mutable price = 100m
price <- price * 0.9m
price <- price + 5m
let price = 100m
let discountedPrice = price * 0.9m
let finalPrice = discountedPrice + 5m
Each name tells you which value you are looking at. Earlier values stay available.
Reject assignment, setters and field, property or index writes, including aliases.
Why it helps: Explicit replacement values make changes visible instead of quietly changing somebody else’s data.
customer.Email <- newEmail
// Who else sees this change?
let updatedCustomer =
{ customer with Email = newEmail }
Return or pass on updatedCustomer explicitly; existing references still describe the original.
Reject mutable fields, writable properties and stateful application object declarations.
Why it helps: A type should not look like a snapshot while carrying a hidden editing interface.
type Account =
{ mutable Balance: decimal }
type Account =
{ Balance: decimal }
let deposit amount account =
{ account with
Balance = account.Balance + amount }
The function returns an updated account instead of editing a shared object.
Reject ref cells and their creation, reads and updates.
Why it helps: Moving mutation into a reference cell does not remove the need to reason about who can change it.
let count = ref 0
count.Value <- count.Value + 1
let answer = count.Value
let increment count = count + 1
let answer = increment 0
A reference cell introduces a changeable location. A value and a function are easier to follow.
Reject mutable collections and builders such as ResizeArray, Dictionary and StringBuilder in ordinary code.
Why it helps: Lists, maps and sets let callers share values without sharing a surprise write.
let names = ResizeArray<string>()
names.Add("Ada")
names.Add("Grace")
let names = [ "Ada"; "Grace" ]
The list describes the finished value without an editing phase or an exposed builder.
Reject references and calls to mutating methods, even when there is no assignment operator.
Why it helps: The rule follows the operation, so an Add call or alias cannot hide a state change.
cache.Add(customerId, customer)
// Add mutates the existing cache.
let updatedCache =
cache |> Map.add customerId customer
Map.add returns a new map. Calling a method named Add is not automatically the same operation.
Reject shared mutable globals, singletons, caches and unapproved synchronization capabilities.
Why it helps: Behavior should not depend on which other request happened to run first.
let mutable requestCount = 0
let recordRequest () =
requestCount <- requestCount + 1
requestCount
let recordRequest requestCount =
requestCount + 1
State becomes an explicit input and output. Shared persistence and concurrency still need an approved boundary.
Reject arrays and aliasable mutable views as pure data.
Why it helps: Another owner can change the backing storage even when your reference only exposes reads.
type Report =
{ Scores: int array }
// Another owner can edit the array.
type Report =
{ Scores: int list }
The contract asks for immutable values. Copying external arrays into this shape belongs at a reviewed boundary.
Reject while and statement-oriented for loops outside approved effect adapters. Pure list comprehensions are distinct.
Why it helps: Map, filter and named transformations make the result clearer than a sequence of updates.
let doubled = ResizeArray<int>()
for value in values do
doubled.Add(value * 2)
let doubled =
values
|> List.map (fun value -> value * 2)
The transformation says what each output is, without a loop and an accumulating buffer.
Reject mutable or unapproved payloads nested in otherwise immutable types.
Why it helps: An immutable record containing a mutable collection still lets its meaning change underneath you.
type Batch =
{ Pending: ResizeArray<int> option }
type Batch =
{ Pending: int list option }
Wrapping a mutable collection in a record and Option does not make its contents immutable.
Preserve useful static information. Statically safe upcasts to approved typed abstractions are allowed.
Reject :?> and downcast in ordinary code.
Why it helps: Model the alternatives explicitly so the compiler can check them before runtime.
let name (value: obj) =
(value :?> Customer).Name
let name (customer: Customer) =
customer.Name
Ask for the type you need. The caller must supply a Customer before the function can run.
Reject unbox and unchecked unboxing, including qualified and aliased references.
Why it helps: Keeping values typed avoids a runtime assumption about what an object contains.
let increment (value: obj) =
(unbox<int> value) + 1
let increment (value: int) =
value + 1
The typed parameter removes the runtime bet that the box contains an integer.
Reject indirect cast APIs such as Seq.cast and LINQ Cast<T>.
Why it helps: A collection helper should not conceal the same runtime gamble as a direct cast.
let names (values: obj list) =
values
|> Seq.cast<string>
|> Seq.toList
let names (values: string list) =
values
Keep elements typed from the start instead of casting the collection one item at a time.
Reject boxing and erasure to obj, System.Object or objnull, including nested payloads, except exact boundary contracts.
Why it helps: Specific types preserve the information needed to read and check the program.
type Event =
{ Payload: obj }
let event = { Payload = box 42 }
type Event =
| CountChanged of int
| NameChanged of string
let event = CountChanged 42
The union preserves both the data and its meaning; obj throws that information away.
Reject runtime type dispatch such as :?, tryUnbox and OfType in pure modeling.
Why it helps: A discriminated union makes the possible cases explicit and reviewable.
let describe (value: obj) =
match value with
| :? string as text -> text
| :? int -> "A number"
| _ -> "Something else"
type Value = Text of string | Number of int
let describe value =
match value with
| Text text -> text
| Number _ -> "A number"
The domain now lists its alternatives instead of making runtime type checks stand in for a model.
Reject unapproved user-defined implicit and explicit conversion operators.
Why it helps: Readers should see where one meaning becomes another instead of discovering a hidden conversion.
type Quantity =
| Quantity of int
static member op_Implicit(value: int) =
Quantity value
type Quantity = private Quantity of int
let createQuantity value =
if value > 0 then Ok (Quantity value)
else Error "Must be positive"
An explicit, reviewed construction function can validate the meaning of the conversion.
Reject unapproved narrowing, truncating and numeric-to-enum or domain conversions.
Why it helps: Validation keeps overflow, lost precision and invalid values from becoming silent assumptions.
let count (value: int64) =
int value
let count (value: int64) =
CheckedNumber.toInt32 value
// Result<int, RangeError>
CheckedNumber is an illustrative reviewed helper that checks the range before conversion; it is not an FDull API.
Use explicit absence and validation. Normalize external nullable values at an approved boundary.
Reject authored null values in pure code; exact boundary normalization can be reviewed separately.
Why it helps: Option makes absence something a caller can see and handle.
type Customer =
{ Nickname: string | null }
let customer = { Nickname = null }
type Customer =
{ Nickname: string option }
let customer = { Nickname = None }
Option makes the missing value part of the model instead of a null hiding behind a reference.
Reject Unchecked.defaultof and unapproved Unchecked APIs.
Why it helps: A default value can bypass the construction rules that make a type meaningful.
let customer =
Unchecked.defaultof<Customer>
let customer =
Customer.create name
// Result<Customer, ValidationError>
Use the domain’s reviewed constructor. Zeroed memory is not evidence of a valid Customer.
Reject unchecked nullability removal and unchecked null-related patterns.
Why it helps: A promise to the compiler is not a runtime validation of external data.
let name (raw: string | null) =
Unchecked.nonNull raw
// In an exact approved boundary normalizer:
let name (raw: string | null) =
match raw with
| null -> Error "Name is required"
| text -> Ok text
The boundary checks the value instead of just removing its nullable type. Null handling still needs an exact grant.
Reject unapproved zero/default initialization of invariant-bearing types and element shapes.
Why it helps: Memory full of defaults does not establish the conditions your domain requires.
// A zeroed value may violate its invariant.
let quantity =
Unchecked.defaultof<Quantity>
let quantity =
Quantity.create requestedQuantity
Initialize an invariant-bearing value through validation, not through its default memory representation.
Reject nullable or oblivious external shapes admitted directly as pure data.
Why it helps: The domain can work with explicit values and absence instead of repeating defensive null checks.
// Framework data enters the domain.
type Customer =
{ Name: string | null }
// Domain input after boundary normalization.
type CustomerInput =
{ Name: string option }
An approved normalizer must actually handle the external nulls. Changing an annotation alone is not normalization.
Metadata can change construction, representation and trust. It belongs in review, just like a function body.
Reject attributes outside the profile’s approved catalog, including their targets and arguments.
Why it helps: A small annotation can have a large effect on generated behavior.
[<System.Serializable>]
type Customer =
{ Name: string }
type Customer =
{ Name: string }
Leave an attribute out when its behavior is not needed. Necessary attributes require review of the exact use.
Restrict AllowNullLiteral, DefaultValue and CLIMutable to exact reviewed DTO or adapter needs.
Why it helps: Framework convenience should not quietly make domain data nullable or writable.
[<CLIMutable>]
type Customer =
{ Name: string }
type Customer =
{ Name: string }
CLIMutable generates a default constructor and setters. The after record exposes neither shortcut.
Reject application-defined UseNullAsTrueValue representations.
Why it helps: A union case should not introduce an unexpected null representation into application types.
[<CompilationRepresentation(
CompilationRepresentationFlags
.UseNullAsTrueValue)>]
type MaybeName =
| Missing
| Present of string
type MaybeName =
| Missing
| Present of string
Keep the explicit cases without asking the runtime to represent one of your cases as null.
Reject unapproved attribute-based suppression, friend access, trust escalation and generated-code claims.
Why it helps: The code being checked should not be able to grant itself an exemption.
[<assembly:
System.Runtime.CompilerServices
.InternalsVisibleTo("App.Web")>]
do ()
// App.Web uses the reviewed public API.
let customer =
Customer.create name
Granting friend access widens what the caller can reach. Removing that grant preserves the intended public boundary.
Keep reflective capabilities in exact, reviewed adapters where their effects and construction behavior can be understood.
Restrict authored Type, typeof, GetType and reflective metadata capabilities.
Why it helps: Static types keep the core understandable without a second program that inspects the first.
let kind (value: obj) =
value.GetType().Name
type Event =
| OrderPlaced of int
| OrderCancelled of int
let kind event =
match event with
| OrderPlaced _ -> "Placed"
| OrderCancelled _ -> "Cancelled"
A domain decision can match named cases instead of inspecting a runtime type name.
Reject unapproved reflective or dynamic invocation, private access and object construction.
Why it helps: Reflection can skip the functions and access restrictions that protect valid data.
let customer =
System.Activator
.CreateInstance<Customer>()
let customer =
Customer.create name
The reviewed constructor owns validation. A reflective factory can choose a different construction path.
Reject uninitialized-object creation APIs.
Why it helps: An allocated object is not necessarily an initialized, valid object.
let customer =
System.Runtime.CompilerServices
.RuntimeHelpers.GetUninitializedObject(
typeof<Customer>)
let customer =
Customer.create name
Allocating a Customer without running construction does not establish its invariants.
Reject unapproved runtime loading, code emission, evaluation, embedded scripting and type providers.
Why it helps: Reviewable source and build inputs are more predictable than code introduced behind the checker.
let plugin =
System.Reflection.Assembly.LoadFrom path
type Pricing = Standard | Member
let discount pricing =
match pricing with
| Standard -> 0m
| Member -> 0.1m
When the alternatives are known, keep them in reviewed source instead of loading executable behavior from a path.
Native integration is outside the ordinary application vocabulary. This preview assigns no native interop profile.
Reject P/Invoke, extern and native import declarations.
Why it helps: Native calls introduce behavior beyond the managed source and dependency contracts.
open System.Runtime.InteropServices
[<DllImport("pricing-native")>]
extern int calculateDiscount(int tier)
type Tier = Standard | Member
let calculateDiscount tier =
match tier with
| Standard -> 0
| Member -> 10
For this small decision, ordinary managed code is enough. Genuine native requirements need a separately approved design.
Reject unapproved NativePtr, Unsafe, Marshal and memory reinterpretation capabilities.
Why it helps: Ordinary domain code should not depend on manual layout or unchecked memory assumptions.
let bytes =
System.Runtime.InteropServices.Marshal
.AllocHGlobal 128
// Model the business data directly.
type Reading =
{ SensorId: int
Value: decimal }
Raw memory is not a business-data contract. Protocol decoding, when needed, belongs in a separately reviewed boundary.
Restrict pointers, byrefs, native integers, spans, memory views and address-taking to exact supported boundaries.
Why it helps: Avoiding borrowed or aliasable memory keeps lifetime and mutation puzzles out of the pure core.
let increment (value: byref<int>) =
value <- value + 1
let increment value =
value + 1
Return the next value instead of borrowing a writable location owned by the caller.
Decode transport data, normalize it, then validate it through domain construction. A serializer is not a validator.
Reject automatic materialization into designated domain types, even when nested in another target.
Why it helps: A serializer must not bypass private construction and manufacture invalid domain values.
let customer =
JsonSerializer
.Deserialize<Customer>(json)
let customer =
CustomerJson.decodeDto json
|> Result.bind CustomerInput.validate
The illustrative approved decoder returns a normalized DTO. Validation constructs the private domain value and returns a modeled error.
Restrict raw serializers, ORM materializers and framework binders to approved adapters.
Why it helps: A single reviewed entry point makes construction and normalization behavior easier to audit.
// Raw materializer in a request handler.
let dto =
JsonSerializer
.Deserialize<CustomerDto>(json)
// Concrete, reviewed adapter API.
let decoded =
CustomerJson.decodeDto json
// Result<CustomerDto, DecodeError>
The adapter owns decoding, normalization and supported failures; callers do not get raw materializer access.
Restrict arbitrary generic or runtime-type materializer wrappers.
Why it helps: Callers should not be able to choose a protected domain target through an innocent-looking helper.
val deserialize<'T> :
string -> 'T
val decodeCustomerDto :
string -> Result<CustomerDto, DecodeError>
The caller can request a DTO, but cannot swap in a private domain type as the generic target.
Reject domain types, executable capabilities and other unapproved payloads in DTO targets.
Why it helps: Plain transport data separates untrusted input from validated meaning and behavior.
type CreateOrderDto =
{ Quantity: Domain.Quantity }
type CreateOrderDto =
{ Quantity: int }
// A separate validation step calls:
// Quantity.create dto.Quantity
A transport contract carries the raw quantity. Only reviewed domain construction can turn it into a valid domain value.
Restrict unnormalized boundary data and domain graphs used as transport contracts.
Why it helps: Explicit mapping keeps framework quirks outside domain decisions.
Known serializer and nested-domain checks exist; general interprocedural taint tracking does not.
// dto still contains raw framework values.
Domain.register dto.Name dto.Email
let registration =
RegistrationInput.normalize dto
|> Result.bind RegistrationInput.validate
|> Result.map Domain.register
These illustrative reviewed functions normalize external shapes, validate domain meaning and only then make the decision.
Use approved data shapes, protected construction and explicit cases to make domain decisions easier to inspect.
Reject unapproved pure data shapes, hidden executable payloads and unvetted opaque types.
Why it helps: A value’s visible shape should explain what it carries and what callers can do with it.
type Invoice =
{ Amount: decimal
OnPaid: unit -> unit }
type Invoice =
{ Amount: decimal
Status: PaymentStatus }
Store the payment state as data. Decide on follow-up work explicitly instead of hiding an executable callback in the model.
Require private representations for types explicitly designated as invariant-bearing in policy.
Why it helps: Callers must go through a construction boundary instead of inventing unchecked values.
Designation and private representation do not prove that a business invariant is correct.
type Quantity = Quantity of int
let impossible = Quantity -5
type Quantity = private Quantity of int
let create value =
if value > 0 then Ok (Quantity value)
else Error "Must be positive"
Designate the type in policy and review its constructor. Private representation makes callers use that entry point.
Reject unchecked helpers, widened construction signatures and unapproved friend-access bypasses.
Why it helps: An invariant is only as strong as every path that can create the type.
// A new shortcut skips the validator.
let fromStorage value =
Quantity value
let fromStorage value =
Quantity.create value
A storage origin is not proof of validity. Keep the call on the existing reviewed construction path.
Reject catch-all patterns that erase explicit coverage of closed domain union decisions.
Why it helps: Adding a case should send you back to the decisions that need to handle it.
let canShip status =
match status with
| Paid -> true
| _ -> false
let canShip status =
match status with
| Paid -> true
| Pending -> false
| Cancelled -> false
When a new union case is added, explicit coverage makes the missing decision visible to the compiler.
Keep decisions pure and put I/O in narrow, approved boundaries. The checker inspects resolved API identities, not just their spelling.
Reject external API references outside exact approved contracts.
Why it helps: A well-typed function can still mutate, perform I/O or bypass construction.
let name =
System.String.Intern suppliedName
let name = suppliedName
Interning touches runtime-managed shared state. If interning is unnecessary, keep the ordinary string value.
Reject ambient time, randomness, environment, culture, configuration and I/O in pure code.
Why it helps: Explicit inputs make decisions easier to reproduce and test.
let isExpired expiresAt =
expiresAt < System.DateTimeOffset.UtcNow
let isExpired now expiresAt =
expiresAt < now
A reviewed boundary supplies now. Tests can supply the same time every run.
Reject resources, service objects and unapproved executable capabilities crossing into pure data.
Why it helps: A record carrying a task or callback can hide work behind the appearance of a simple value.
type QuoteInput =
{ Price: decimal
GetDiscount: unit -> decimal }
type QuoteInput =
{ Price: decimal
Discount: decimal }
Compute the external value before entering the pure decision, instead of handing it a callback that may do anything.
Reject unapproved computation-expression builders, providers and executable abstractions.
Why it helps: Friendly syntax should not smuggle effects into a supposedly pure workflow.
// A custom builder hides how this runs.
let result =
retrying {
return charge order
}
let command =
PaymentDecision.forOrder order
// Boundary uses an exact reviewed API.
let result =
PaymentAdapter.execute command
A reviewed command and boundary make execution explicit. Renaming or wrapping the builder alone would not grant permission.
Represent expected failures explicitly and observe their results. Wrapping a throwing operation in Ok does not make it safe.
Reject try … with in ordinary application and business code.
Why it helps: Result gives expected failure a visible place in the function’s contract.
let quantity text =
try
Ok (System.Int32.Parse text)
with _ ->
Error "Bad quantity"
let quantity text =
QuantityText.parse text
// Result<Quantity, QuantityError>
The illustrative reviewed parser models expected validation failures. A catch-all can also swallow an unrelated defect.
Reject deliberate throwing, exception declarations and assert-as-validation for application flow.
Why it helps: Callers can handle modeled alternatives without discovering invisible exits.
let divide numerator denominator =
if denominator = 0m then
failwith "Cannot divide by zero"
else
numerator / denominator
let divide numerator denominator =
if denominator = 0m then
Error "Cannot divide by zero"
else
Ok (numerator / denominator)
The signature now exposes expected failure. Result does not by itself eliminate arithmetic overflow or other unexpected exceptions.
Reject indirect catch/raise APIs and general-purpose catch-everything wrappers.
Why it helps: Turning every exception into a Result can mislabel programming defects as expected business errors.
let catchAnything work =
try Ok (work ())
with error -> Error error
// Call an operation with a reviewed
// Result-returning contract.
let result = OrderValidation.validate input
A general catch wrapper makes all exceptions look expected; the reviewed validator models the failures it actually understands.
Restrict adapter exception translation to exact approved exception types.
Why it helps: Unexpected failures should stay visible instead of becoming a misleading business response.
try
File.ReadAllText path |> Ok
with _ ->
Error NotFound
// Exact reviewed adapter exception contract.
try
File.ReadAllText path |> Ok
with
| :? FileNotFoundException -> Error NotFound
Both snippets assume System.IO is in scope. The adapter handles only the approved missing-file exception; other failures remain unexpected.
Reject exception objects and exception-containing payloads as pure business data.
Why it helps: A domain error describes what went wrong without importing runtime exception machinery.
type CheckoutError =
| Failed of exn
type CheckoutError =
| OutOfStock of productId: int
| PaymentDeclined
The caller sees business alternatives instead of unpacking an arbitrary runtime exception.
Reject cataloged partial operations such as List.head and throwing parsers.
Why it helps: List.tryHead and approved validated access make absence or invalid input explicit.
let firstCustomer customers =
List.head customers
let firstCustomer customers =
List.tryHead customers
An empty list becomes None instead of an exception. The caller now handles presence or absence explicitly.
Detect obvious discarded or unobserved Result and cataloged must-handle operations.
Why it helps: A failure value cannot help if the caller throws it away.
Local-flow checks are partial; FDull does not prove linear or complete Result use.
saveOrder order |> ignore
Ok "Saved"
match saveOrder order with
| Ok receipt -> Ok receipt
| Error error -> Error error
The after version observes and propagates both outcomes instead of claiming success after discarding the result.
Reject implicit error-to-success or default conversions outside approved recovery helpers.
Why it helps: A fallback can hide a real failure; name and review the policy that makes it acceptable.
let balance =
loadBalance accountId
|> Result.defaultValue 0m
let balance =
match loadBalance accountId with
| Ok value -> Ok value
| Error error -> Error error
A failed balance lookup is not a zero balance. Preserve the error unless a reviewed recovery policy says otherwise.
Keep acquisition and cleanup constructs outside the pure core and inside approved adapters.
Why it helps: Resource lifetime stays close to the effect. Cleanup and exception recovery remain separate concerns.
// Business calculation owns a file handle.
let total path =
use reader = File.OpenText path
parsePrices (reader.ReadToEnd())
// Reviewed boundary owns I/O and cleanup.
let totalText text =
parsePrices text
Pass already-read data into the pure calculation. The adapter still has to own and dispose the file correctly.
Restrict blocking waits, fire-and-forget work, ad hoc background execution and cancellation swallowing.
Why it helps: Work needs an observable outcome and a clear lifetime, including when the caller goes away.
let receipt =
(PaymentAdapter.charge order).Result
// Approved task and adapter contracts.
task {
let! receipt =
PaymentAdapter.charge order cancellation
return receipt
}
Await the result and pass cancellation through the reviewed boundary API. Merely using task syntax is not an automatic permission.
The approved project graph and dependency inventory help preserve the separation between the core, its adapters and its tooling.
Reject forbidden project and dependency directions in the approved graph.
Why it helps: A pure core should not quietly become coupled to a serializer or an effectful host.
The preview supports an exact acyclic Release/net10.0 graph with literal project references.
<!-- App.Domain.fsproj -->
<ItemGroup>
<ProjectReference
Include="../App.Storage/App.Storage.fsproj" />
</ItemGroup>
<!-- App.Boundary.fsproj -->
<ItemGroup>
<ProjectReference
Include="../App.Domain/App.Domain.fsproj" />
<ProjectReference
Include="../App.Storage/App.Storage.fsproj" />
</ItemGroup>
Move orchestration to the boundary and remove the domain’s storage reference. The domain should not depend on its persistence adapter.
Reject unapproved dependencies and inputs covered by the protected inventory.
Why it helps: Review includes the code and assets that can enter through a dependency.
Transitive package, runtime, source and native asset inventory remains incomplete.
<PackageReference
Include="FDull"
Version="*" />
<PackageReference
Include="FDull"
Version="0.2.0-preview.1" />
Pin the reviewed version, commit the lock file and use locked restore. Version pinning alone does not complete the transitive asset inventory.
Reject unapproved languages, scripts, projects and executable build extensions in checked workspaces.
Why it helps: A second implementation path should not sidestep the source and build policy.
# Business logic in an unclassified script.
def can_ship(status):
return status == "paid"
// Authored in a classified, compiled .fs file.
type Status = Paid | Pending | Cancelled
let canShip status =
match status with
| Paid -> true
| Pending -> false
| Cancelled -> false
One checked implementation path. The new F# source and its build inputs still need explicit inventory and review.
Reject shipping dependencies on tests and tooling, and self-authorized profile exemptions.
Why it helps: Test conveniences should not become back doors into production behavior.
<!-- App.Domain.fsproj -->
<ProjectReference
Include="../App.Tests/App.Tests.fsproj" />
<!-- App.Tests.fsproj -->
<ProjectReference
Include="../App.Domain/App.Domain.fsproj" />
Tests depend on shipping code. Shipping code does not acquire test permissions or helpers by depending on tests.
Keep the policy strict and bind the result to reviewed inputs. Some guarantees require organizational infrastructure outside the local package.
Reject source suppressions and diagnostic path spoofing.
Why it helps: A passing result should come from conformance, not from hiding the evidence.
#nowarn "25"
let canShip status =
match status with
| Paid -> true
let canShip status =
match status with
| Paid -> true
| Pending -> false
| Cancelled -> false
Handle the missing cases instead of hiding the incomplete-match warning.
Reject compiler and toolchain policy drift, warning downgrades and skipped checks.
Why it helps: The same source should be evaluated against the same approved language and build settings.
<PropertyGroup>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
Keep the full pinned compiler preset as well. Restoring one setting does not replace toolchain and build-policy verification.
Fail analysis that is incomplete, malformed, crashed or out of resources.
Why it helps: A checker that did not finish cannot certify the input it was given.
let passes (report: FDull.SafetyReport) =
report.Diagnostics.IsEmpty
let passes (report: FDull.SafetyReport) =
report.Complete
&& report.ExitCode = 0
&& report.Diagnostics.IsEmpty
An empty diagnostic list cannot turn a crash, timeout or incomplete analysis into success. Check the complete report and the process exit.
Bind analysis to source, reference, policy and compiler-input fingerprints.
Why it helps: A clean report for yesterday’s inputs should not approve today’s artifact.
Deployment-side admission, trusted artifact signing and additional race fixtures remain outside or unfinished.
# A previous report covered different inputs.
dotnet fdull lint .
# Source or references change here.
dotnet build -c Release
# Verify the current reviewed inputs.
dotnet fdull verify .
verify rebuilds and compares inputs before accepting the result. Trusted artifact signing and deployment admission remain external responsibilities.
Validate capability entries and reject invalid, stale, widened, duplicate or unused grants.
Why it helps: A narrow exception should not quietly turn into permission for an entire file or profile.
Temporary exceptions are unsupported. Review identities and expiry require an external policy authority.
{
"File": "src/App/Decisions.fs",
"Profile": "ADAPTER"
}
{
"File": "src/App/Decisions.fs",
"Profile": "PURE"
}
Illustrative source-entry fragments, not complete policy files. If the function only makes pure decisions, remove unused grants and use its actual profile; ADAPTER never grants blanket permissions.
Require trusted CI check producers, repository rules and controlled merge and deployment admission.
Why it helps: A local check is not an organizational guarantee if anyone can replace or bypass it.
External control: not implemented by the NuGet package.
required_check:
name: fdull
producer: any
bypass: allowed
required_check:
name: fdull
producer: organization-owned
bypass: restricted
Conceptual repository settings, not FDull config or a runnable CI file. The organization must enforce trusted check producers and controlled bypasses outside the NuGet package.
No rules found. Try a different word or select “All rules.”
The examples are focused excerpts, with surrounding types or imports omitted where helpful. Application-specific helpers illustrate reviewed contracts; they are not FDull APIs. Each comparison explains one rule, rather than certifying a complete program.
These are the standard’s rules, not a claim that every part is fully enforced. Scope and exact reviewed capabilities matter; an adapter or test profile never grants blanket permission. See the executable-evidence inventory for implementation paths, test evidence and remaining work for each ID, or run dotnet fdull coverage. BUILD006 requires external organizational controls.
This strict, experimental preview targets .NET SDK 10.0.200, F# 10.0 and net10.0. The worker needs Microsoft.NETCore.App 10.0.4, with runtime roll-forward disabled. Install the matching SDK and runtime first.
Run in your repository. Create a tool manifest only if you don’t already have one.
dotnet new tool-manifest
dotnet tool install FDull.Tool --version 0.2.0-preview.1Create or update global.json with the pinned SDK:
{
"sdk": {
"version": "10.0.200",
"rollForward": "disable"
}
}For a new repository with no Directory.Build.props:
dotnet fdull defaults > Directory.Build.propsIf the file exists, inspect dotnet fdull defaults and merge its settings. The preset enables checked arithmetic, nullability, strict warnings, deterministic compilation, dependency locks and NuGet auditing.
Build once so the evaluated compiler inputs exist, then initialize a policy and check it. Pass a solution or project to restore/build if your directory is ambiguous.
dotnet restore --use-lock-file
dotnet build -c Release --no-restore -m:1 --disable-build-servers
dotnet fdull init .
dotnet fdull lint .
dotnet fdull verify .init starts every project in PURE with zero exceptions and records source/build fingerprints. It refuses to replace an existing fdull.json. A first lint failure means there’s code or explicit policy to review.
The thinking behind a smaller F# vocabulary, fewer clever surprises, and code that feels like a team sport.
FDull checks a deliberately restricted subset of ordinary F#. It uses FSharp.Compiler.Service to inspect resolved symbols, types and source syntax. Aliases and nested payloads are part of those checks. It is not a new language, and passing it is not proof of a bug-free program.
The broader standard is not fully implemented. Result-flow analysis, interprocedural validation and transitive dependency-asset checks have limits. Protected policy ownership, trusted CI, artifact signing and deployment admission need external infrastructure. Read the coverage inventory alongside the rulebook.
The current preview supports ordinary F# SDK workspaces with literal project references, authored .fs/.fsi files and the standard Release/net10.0 metadata layout. Multi-targeting, custom source generators and non-F# authored automation are outside its supported workspace model.