Public API style
Rask's promise is that you can read a Rask program aloud. That is a property of the names, not of the engine, and names drift unless something holds them. This page is what holds them, and the public-API gate is what makes it stick.
It governs every public member of a shipped package — Rask (the Rask.Core assembly) above all, whose
surface (the engine and the whole HTML/SVG element family) is the one every component author writes
against. It does not govern the source generators, their code fixes,
or the MSBuild tasks: the compiler and MSBuild construct those by name, and nobody writes code against
them.
The rules
1. A short noun, with the mechanism left out
The name is the shortest thing that says what it is. Mechanism suffixes go — Queue, Store,
ConnectionFactory — and so does the Rask prefix, which the namespace already carries.
IMail IJobs ICache ILogs ISqlite IWebPush
not IMailQueue, IJobQueue, ILogStore, IRaskSqliteConnectionFactory.
That the mail is queued is true, and it is a fact the caller never acts on — a queue that became a direct SMTP write would leave every call site correct and every name a lie. The guarantee belongs in the doc comment, where it can be stated properly, not smuggled into a noun.
A noun that names a role rather than a mechanism stays, however long. IDispatcher is not
IDispatchQueue; it is the thing that dispatches, which is exactly what you want to know.
IQueryClient is the client side of the query pipeline — a role, not a plumbing detail. The rule is
against mechanism, not against length, and Queue/Store/ConnectionFactory fail it because they
describe how the thing is built rather than what it is for.
2. A line reads as a sentence
Read the call site aloud. If it is not an English sentence, the name is wrong — however standard it is.
var products = await Cache.Remember("products", LoadProducts).For(10.Minutes);
await Cache.Forget("products");
"Remember the products for ten minutes. Forget the products." Not GetOrAddAsync/RemoveAsync: those
are what ConcurrentDictionary calls the operation, and a reader who knows them still has to translate
"get or add" into what the line is for. The borrowed word stays where it already reads — Get, Set,
Send, Publish, Delete — and gives way where it does not.
Unless it stutters against its own parameter. ILogs.Query(LogQuery query) would say "query" twice
and its type a third time; Search(LogQuery) is what the operator at /_rask is actually doing.
3. One concept, one verb, everywhere
A verb means the same thing in every package. Ask for data with Query, tell the system to do
something with Send, announce that something happened with Publish — in Rask.Cqrs, and
in anything that comes later.
This is the rule the codebase broke worst. One DispatchAsync did three jobs, distinguished only by
parameter type, and the same operations were called MutateAsync, Fetch and Query one package
over — four words for two ideas, so moving between two first-party packages meant relearning both.
Different semantics earn a different verb. Rask.Query's Query(...) deliberately does not
share the mediator's Query: it returns an observable Query<T> that re-renders its component,
not a Task<T> you await once. Two names because they are two things — which is the rule, not an
exception to it. The test is whether a caller could swap one for the other and be right. Sending is
the opposite case: IQueryClient.Send is the dispatcher's Send plus the invalidation the
command declares, so it keeps the verb — and Command<T>, the renderable form, sends with it too.
4. Awaitables are awaited, not suffixed — and the token is ambient
await Cache.Forget("products");
await Product.Create(model);
await already says the call is asynchronous; ForgetAsync says it twice. A Rask method drops the suffix.
The exceptions are the ones the compiler or the BCL impose — an override of BackgroundService.ExecuteAsync,
an implementation of IDistributedCache.GetAsync — and a synchronous twin, which gets a different word,
never a suffix. Two twins keep the suffix because they mirror ADO.NET's own Open/OpenAsync on the
connection they hand back: ISqlite.CreateOpen/CreateOpenAsync and SqlitePragmas.Apply/ApplyAsync.
A cancellation token is still the last parameter and still defaulted, so a hosted service or a shutdown
drain has somewhere to put its lifetime. But the call site that leaves it out is not uncancellable: it runs
inside a handler, a render, a request or a job, and is cancelled with that work. Ambient.Or(token) is the
one line an implementation writes to honour both.
5. At most one Action<TOptions>?, and it goes last
Two option delegates in a row cannot be told apart at a call site:
// don't
AddRaskSqlite(services, connectionString,
Action<SqlitePragmaOptions>? configure = null,
Action<SqliteBusyRetryOptions>? configureRetry = null)
Nothing about AddRaskSqlite(cs, null, o => …) says which one you configured, and swapping them
compiles. One options type, one delegate — nest the second set of knobs as a property.
6. No bare bool parameter
UseRaskSqlite(cs, null, null, true) // true what?
A bool in a parameter list is a value with no name at the call site. It becomes a property on the
options type, where it keeps the name it was given.
7. Configuration is optional, or the method doesn't exist
Every Add* takes Action<TOptions>? configure = null. A required configure delegate means the
method cannot express its own default, which means there isn't one — and a package with no working
default is a package that fails on first use.
AddRaskWebPush is the standing exception, and the reasoning is worth stating because it is the
line the rule stops at. Sending needs a VAPID key pair, which cannot have a default: a generated one
would silently break every existing subscription on restart, and an absent one turns every send into a
runtime failure. Making the delegate required means you cannot forget what the signature will not let
you omit — a compile error rather than a startup one. The rule holds everywhere a default is
possible; where none is, prefer the compiler over a good error message.
8. Machinery is hidden
Anything the user does not write but can see — generated intermediates, chain stages, collection
builders — carries [EditorBrowsable(EditorBrowsableState.Never)]. If a doc page has to tell readers
to ignore a name in their completion list, hide the name instead of writing the paragraph.
Hiding is not removing: an [EditorBrowsable(Never)] member is still callable, so it stays in the
baseline. The attribute fixes what a developer sees; the baseline records what they can reach.
9. An exception names a call that compiles
The message a developer reads at 2am is API surface. It must name the real method, in the real shape:
// don't — Context<T>(value)[…] is not a thing you can type
throw new InvalidOperationException($"Wrap an ancestor in Context<{typeof(T).Name}>(value)[ … ].");
// do
throw new InvalidOperationException($"No {typeof(T).Name} in scope. Provide one with Context.Provide<{typeof(T).Name}>(value)[ … ] on an ancestor.");
And one problem gets one spelling. Two messages for one cause means searching for the text you saw finds half the story.
10. Registration verbs follow ASP.NET, not us
Add* on IServiceCollection, Use* on IApplicationBuilder or an options builder, Map* on
IEndpointRouteBuilder. These are borrowed words with settled meanings; a framework that redefines
them makes every reader translate. Most apps never type them at all — RaskApp
wires the batteries — but the escape hatch obeys the same law as everything else.
11. Values are literals
await Task.Delay(3.Seconds);
o.MaxFileSize = 50.Megabytes;
var since = 3.Days.Ago;
Not TimeSpan.FromSeconds(3) or 50 * 1024 * 1024. The literals are plain TimeSpans and longs, so they
go anywhere the .NET type does; a count of one takes the singular (1.Hour), and
RASK092 points out the slip.
12. Timing is a step at the end
await Cache.Remember("products", LoadProducts).For(10.Minutes);
for: and in: are C# keywords, so a duration cannot be a named argument that reads. It is a step on an
awaitable that does nothing until it is awaited: .For(…), .Sliding(…), .Until(…), .In(…), .At(…).
13. Nothing to inject for the everyday call
The batteries are reached through a static — Cache.Remember(…) — that finds the services of the work in
progress, the way Current.UserId and QueryClient.Query(…) already do. Constructor injection still works,
and reads the same (cache.Remember(…)); it is what code outside any work — a hosted service, a timer —
uses, and what the static's error message tells you to write there.
14. The web platform is named by MDN
An element type, and every attribute on it, takes the name MDN gives it — PascalCased, never re-imagined.
The types are generated from MDN's data (src/Rask.Core/Dom/mdn.snapshot.json), so this rule is kept by the
build rather than by review: HTMLAnchorElement, HTMLTableCellElement.ColSpan, HTMLFormElement.NoValidate.
What a reader writes is the tag (A, Td, Form), because MDN's element pages are titled by tag.
The only departures are the DOM's own JavaScript workarounds — For for htmlFor, Class for
className — and Rask's security and in-process policies (a form offers no action), each named in
src/Rask.Dom.Tasks/DomEmitter.cs beside its reason.
The vocabulary
What the rules above settled, so a new package has one place to look rather than a precedent to guess at.
| Idea | Noun | Verbs |
|---|---|---|
| Transactional email | IMail |
Send(email), .In(24.Hours), .At(moment) |
| Background work | IJobs |
Enqueue(job), .In(24.Hours), .At(moment) |
| Cache | Cache (static) / ICache |
Remember, Set, Get, Forget; .For, .Sliding, .Until |
| Mediator | Dispatcher (static) / IDispatcher |
Query, Send, Publish — the static works outside any work too |
| Cached reads | QueryClient (static) / IQueryClient |
Query, Load, Warm, Send, Command, Invalidate |
| Durable log | ILogs |
Search, Categories, Count, Trim().OlderThan(30.Days) |
| SQLite connections | ISqlite |
InImmediateTransaction |
| Time | Clock (static) |
Now; Clock.Fake(at:) + Advance in tests |
| Durations and sizes | Units (ambient) |
3.Seconds, 1.Hour, 50.Megabytes, 3.Days.Ago, 2.Hours.FromNow |
| Web Push | Push (static) / IPush |
Subscribe, Send, Unsubscribe |
| Is the battery running | every static | IsOn |
| Battery settings | JobsOptions, LogsOptions, PushOptions, OpsOptions… |
Retention, SweepInterval, PollInterval, ShutdownGracePeriod, DefaultLifetime |
| Test fakes | Jobs.Fake(), Mail.Fake(), Outbox.Fake()… |
Run(), Enqueued<T>(), Sent(), Stored<T>(); .Once(), .Never() |
The browser subscribes through MDN's own PushManager from Rask.Web; Push.Subscribe keeps what its
ToJSON() answers.
The gate
Microsoft.CodeAnalysis.PublicApiAnalyzers tracks every public member in a checked-in
PublicAPI/<tfm>/PublicAPI.Shipped.txt and PublicAPI.Unshipped.txt pair, wired up in
Directory.Build.targets for every project the rules govern.
- RS0016 — a public member missing from the files.
- RS0017 — a file entry with no member behind it.
Both are compiler warnings, and the repo builds warnings-as-errors, so a public-surface change that isn't recorded fails the build. That is the whole point: adding, renaming or removing anything public shows up as a diff in a text file, in the same PR, where a reviewer reads it as English rather than reconstructing it from a thousand lines of implementation.
Generated public API is recorded too. Rask hand-writes almost none of the surface a user types —
Div.Class("panel"), HomePage.Url() and every chain step are emitted by the generators — so a gate
that skipped generated code would skip the part people actually call. It also cannot be skipped
cleanly: path-scoped .editorconfig severity does not reach generator-produced trees, so an exclusion
would have to be a project-wide NoWarn, which is a hole rather than a rule.
The files are per target framework because Rask.Core, Rask.Wasm, Rask.SQLite.Browser
and Rask build for the server and browser targets with genuinely different surfaces. Every project
uses the same layout, including the ones with a single framework today — so a project that gains a
second one gets an empty baseline to fill rather than a gate that quietly starts contradicting itself.
RS0026 and RS0027 are off (.editorconfig). They protect callers of a frozen API from a
source-breaking recompile, and Rask is pre-1.0 and breaks deliberately — and since rule 4 puts a
defaulted CancellationToken on every awaitable, any interface with overloads trips RS0026 by
construction. They get another look at 1.0.
A missing baseline is an error, not a pass. Directory.Build.targets includes the two files only if
they exist, which is what lets a new project be added before its surface is written down — and is also
exactly how this gate would come to pass by not running. RaskVerifyPublicApiBaseline fails the build
of any tracked project that has no baseline for the framework being compiled, and
scripts/tests/public-api-gate.test.sh proves all of it by breaking the gate on purpose: an
unrecorded member, an entry with nothing behind it, and a deleted baseline each have to fail, against
a control that requires the clean tree to be green.
Working with it
Rules 2, 4, 11, 12 and 13 are newer than most of the surface, and it is being brought to them package by package; the vocabulary above lists what already obeys.
Pre-1.0, everything lives in PublicAPI.Unshipped.txt and PublicAPI.Shipped.txt is empty. Nothing
here is frozen yet, and saying otherwise in a filename would be a claim the project has not earned;
the release process promotes unshipped to shipped
at 1.0, once.
Adding a member fails the build until you record it. The signature the analyzer wants is in the RS0016
message verbatim — copy it into PublicAPI.Unshipped.txt, or take the IDE's "Add to public API"
quick-fix. Removing or renaming one fails as RS0017 until you delete the old line.
Neither is busywork you route around. A member that is hard to write down is usually hard to use, and the line you are about to add is the sentence a stranger will read.
When a rule and a caller disagree
The caller wins. These rules exist to make call sites read well, so a rule that makes one read worse has hit a case its author didn't have. Say so in the PR and change the rule here in the same commit — a law with undocumented exceptions is worse than no law, because the next person can't tell an exception from a mistake.
Related
- Code analysis & analyzers — the rest of the build's analyzer configuration.
- Development workflow — where this sits in the definition of done.
- Diagnostics — the RASK0xx errors that enforce the framework's own rules.