Aria API Style Contract
September 1, 2026 · View on GitHub
This document is the framework's authoritative reference for API style. Together with
lifecycle.mdit forms the standard "read these and you can write Aria-style code". Any naming, namespacing, include-path, error-message, template- diagnostic, or deprecation argument should ultimately cite this file. Every contract item is numberedS-Nfor citation in code and commit messages.A "best-in-class C++ MVVM framework" API must satisfy three overarching principles:
- Consistent: equivalent things appear in the same shape across every module.
- Direct: a user knows what to autocomplete BEFORE typing the first character.
- Diagnosable: misuse produces a one-line, human-readable error from the compiler — never a 30-frame SFINAE eruption.
0. Overview
Public namespace Scope Users should use Implementation namespace
-------------------------------------------------------------------------------------------------
aria:: Top-level public API (core) yes — preferred —
aria::async:: Async + coroutine public API yes — preferred —
aria::binding:: View binding + ViewModels yes — preferred —
aria::runtime:: App runtime services yes — preferred —
aria::abi:: ABI boundary type-erasure ⚠ advanced users —
aria::reactive:: Reactive implementation no — internal yes
aria::detail:: Type-erasure signal wrappers no — internal yes
aria::*::detail:: Per-module impl details no — internal yes
aria::*::testing:: Framework-bundled test kits yes — for tests —
1. Namespace contract
S-1: single public entry point
Core MVVM types are exposed under aria:: — users facing the
core reactive API write only aria:::
| Category | Public symbol | Notes |
|---|---|---|
| Reactive primitives | Property<T> / Computed<T> / Effect | State, derived values, side effects |
| Subscription | Subscription / SubscriptionBag | Unified RAII detach |
| Command | Command<Args...> | Synchronous command |
| Collections | ObservableList<T> / FilteredList<T> / SortedList<T> / MappedList<U,V> / ListChange<T> / ListChangeKind | Collections + derived collections |
| Validation | Validator<T> / ValidationState | Per-field validator + form state |
| Abstractions | IProperty<T> | ABI-friendly interface |
| Globals | batch / untracked / BatchScope / UntrackedScope / dep | Control primitives |
| Exceptions | CircularDependencyError | Reactive failure signal |
| Concepts | PropertyValue / EqualityComparable / Observable / InvocableR | Template constraints |
Per-domain public exports:
aria::(root namespace, unified scheduler base) —IScheduler/SchedulerCaps/has_caps/require_caps/unsupported_capability/IDelayedScheduler.aria::(hot-path zero-allocation callable utilities) —function_ref<R(Args...)>/inplace_function<R(Args...), N, Align>/bad_inplace_function_call.aria::(unified callback-failure reporting channel) —CallbackFailure/CallbackFailureSink/set_callback_failure_sink/current_callback_failure_sink/report_callback_failure.aria::async::—Task/AsyncCommand/AsyncResource/Channel/CancellationSource/CancellationToken/OperationCancelled/with_timeout/when_any/when_any_cancellable/when_all/IExecutor/InlineExecutor/VirtualTimeExecutor.aria::binding::—BindingEngine/IView/IViewAdapter/Converter<T,U>/ConversionError/ViewModel/ViewModelScope; adapter conformance facilities live underaria::binding::testing::conformance::.aria::runtime::—Logger/Container/IDispatcher/EventBus/install_default_diagnostics/uninstall_default_diagnostics.aria::abi::— the v-table bridge forIProperty,SignalErased/SlotErased, andSlotInvokeFailureHook/set_slot_invoke_failure_hook(only relevant when writing a cross-dylib bridge).
Unified scheduler base: every scheduler (
IExecutor/IDelayedScheduler/IDispatcherand their subclasses) virtually inherits fromaria::ISchedulerand reports its capabilities viacaps()returning aSchedulerCapsbitmask. A component checks requirements with a singlehas_caps(s, SchedulerCaps::Delay | SchedulerCaps::MainThread)line; if the capability is missing,require_capsthrowsunsupported_capability. The legacyIExecutor::post/IDelayedScheduler::post_after/IDispatcher::post_delayedare retained as equivalent aliases ofIScheduler::schedule/schedule_after.
Hot-path callable contract:
aria::function_ref<R(Args...)>— non-owning,sizeof == 2 * sizeof(void*), trivially copyable. Use it as a parameter type to accept any callable without forcing astd::functioncopy / heap allocation. Never store it as a field — it does not extend the target's lifetime.aria::inplace_function<R(Args...), N=32, Align=alignof(max_align_t)>— owning,N-byte inline buffer; capacity overflow is a compile-timestatic_assert, never a heap allocation. Copyable / movable iff the erased callable is copyable / movable. Use cases: (1) derived-list owning callbacks (FilteredList::Predicate/SortedList::Comparator/MappedList::Mapper/DistinctList::KeyOf/GroupedList::KeyOfare switched toinplace_function<…, 32>; the zero-heap-allocation contract is type-system enforced); (2) anywhere you want to keep a lambda long-term but absolutely forbid it from silently callingmalloc.- Selection rule: short-lived sync callback →
function_ref; long-lived storage with known capacity →inplace_function; long-lived storage with unknown capacity / crossing an ABI boundary →std::function. Together they form the "non-owning, fixed-capacity owning, unbounded owning" trio.
Unified callback-failure reporting channel: every framework- internal boundary that "must stay
noexceptyet calls into user code" (thread-pool worker / main-thread drain & run_one / SimpleDispatcher pump & run_one / VirtualTimeExecutor advance & run_until_idle / ABI slot trampoline / async detached path) MUST route througharia::report_callback_failure(category, std::current_exception()).
- Category naming: dotted,
module.subsystem.action, e.g."executor.thread_pool.worker"/"executor.main_thread.drain"/"executor.main_thread.run_one"/"runtime.simple_dispatcher.pump"/"runtime.simple_dispatcher.run_one"/"executor.virtual_time.advance"/"executor.virtual_time.run_until_idle"/"abi.slot.invoke"/"async"(legacy async surface).- Storage location:
sink_storage()'s real definition lives inlibaria_abi(single TU); every SHARED module shares the same physical slot, avoiding the "inline static across DSOs" duplicate-storage problem. The slot-failure hook (aria::abi::set_slot_invoke_failure_hook) follows the same model.- Default behaviour: with no sink installed, stderr emits one line
[aria.callback_failure] <category>: <message>. The host application callsaria::runtime::install_default_diagnostics()inmain()to bridge every sink toaria::Logger::error, with the category prefixed byaria.(e.g.aria.executor.thread_pool.worker).- Invariant:
report_callback_failureitself never throws — if a user-installed sink throws, the framework's stderr fallback handles it. This guarantees no framework-internalnoexceptboundary ever callsstd::terminate.- ABI bridge: the abi layer cannot back-depend on core; the
SlotInvokeFailureHookis abi's injection point, bridged toreport_callback_failure("abi.slot.invoke", …)at startup byruntime::install_default_diagnostics().
Converter failure-semantics contract: historically
aria::binding::Converter<T,U>had onlyto_view/to_modelfields, and the built-into_modelsilently returnedT{}on a parse failure (int→ 0,double→ 0.0). The business code could not distinguish "user typed 0" from "input is invalid".
- New field:
std::function<std::optional<T>(const U&)> try_to_model.std::nulloptmeans "cannot parse"; this is the channel the binding engine prefers.- Strict to_model: built-in converters (
int_to_string/double_to_string/bool_to_yes_no) throwaria::binding::ConversionError(derives fromstd::runtime_error) on parse failure rather than silently returning 0. Thetry_to_modelfield is filled with the equivalent non-throwing implementation.- Engine-side contract:
BindingEngine::bind_text_convertedon the View → Model path:
- Calls
try_to_modelfirst; onstd::nulloptdoes not write the Model and reports viaaria::report_callback_failure("binding.converter", nullptr, "converter.try_to_model rejected input").- When the user-supplied converter does not populate
try_to_model, falls back toto_modelwrapped in atry/catchthat routes to the same channel (category"binding.converter"). Both paths guarantee the Model is never written with a fabricated default value.- Trailing-garbage strictness: the built-in numeric converters use
std::stoi(s, &consumed)/std::stod(s, &consumed)and assertconsumed == s.size(), so"12abc"is detected as invalid input rather than12.- Backward compatibility: the legacy entry points
c.to_view(x)/c.to_model(s)are retained —to_modelsimply upgrades from "return 0 on bad input" to "throw, caught by the engine". Hosts will seebinding.converterevents in their logs; the model is no longer silently corrupted.
BindingEngine trace helper contract:
BindingEngine::dispatch_to_view_<Fn>is a template member, so it instantiates once per binding parameterFn. Three branches (Direct/SmartMarshalon the main thread /dispatcher.post) each need to emit aTraceCategory::Bindingevent at two points ("view destroyed" and "VM→View write") — sixpublish_trace_uncheckedcall sites in total.
- Helper abstraction: two non-template static members,
BindingEngine::trace_drop_(std::string_view platform) noexceptandtrace_emit_(std::string_view platform) noexcept, are the only entities behind those six call sites. They encapsulate thearia::trace::Binding{ platform_str, "", "view_destroyed_drop" / "vm_to_view" }payload construction and thepublish_trace_uncheckedpublish.- Guards retained: call sites still have
if (tracing) trace_drop_(platform)/if (::aria::has_trace_sink()) trace_emit_(platform_copy)— ensures we pay zero call cost when tracing is off (anoexcepthelper still has to copy the input string into its parameter, which is what the guard short-circuits). The template body is not slowed by the helper extraction.- Why non-template: the helpers are intentionally regular functions, not helper templates, so that
dispatch_to_view_<Fn>'s template bloat does not also copy the trace payload-construction code into every instantiation. BindingEngine has 5 instantiation branches today — they share the same helper code, zero duplication.- Why static: helpers don't depend on
*this, only on the platform string. Making themstaticlets dispatcher-posted lambdas callBindingEngine::trace_drop_(platform_copy)directly without capturingthis, structurally avoiding the "engine destructed but in-flight lambda still references this" dangling-access risk.- Future trace events: any new BindingEngine trace event added later MUST follow the same four invariants: non-template +
static+std::string_viewparameter +noexcept, with ahas_trace_sink()/tracingguard at the call site.
S-2: aria::reactive:: is the implementation namespace
Forbidden:
- Public documentation examples that use the long-form
aria::reactive::Property. - User tutorials and READMEs demonstrating
aria::reactive::*.
Allowed:
- Mutual references between internal implementation headers.
- The very rare cases where a user genuinely needs low-level APIs
like
Graph::set_graph_thread()/Graph::is_on_graph_thread(). aria::reactive::GraphInspectoris a public diagnostic tool, but should be accessed via the promoted aliasaria::GraphInspector(see S-3).
S-3: diagnostic tools are also promoted into aria::
reactive::GraphInspector MUST be promoted via
using reactive::GraphInspector to aria::GraphInspector.
Anything users should use must not require typing the
implementation namespace. This is a concrete instance of S-1.
S-4: aria::detail:: is the type-erasure bridge above ABI
aria::detail::TypedSignal<...> and aria::detail::ReactionNode etc.
wrap aria::abi::SignalErased and Node-like ABI primitives into
strongly-typed internal bridges.
Users MUST NOT use them directly.
Inside the framework they MUST be referenced fully qualified as
aria::detail::TypedSignal<...>; bare detail::TypedSignal is
forbidden because if a user wrote using namespace aria::reactive,
the bare detail:: would resolve to aria::reactive::detail and
become ambiguous.
S-5: per-module *::detail:: follows the single-file principle
aria::async::detail:: / aria::reactive::detail:: / each module's
detail:: namespace:
- Only accessible from within the same module's implementation files.
- Must not appear in that module's public-header signatures (showing up in private/protected/static helpers is fine).
- "Implementation details" that need cross-module sharing must be
promoted to
aria::detail::oraria::abi::, not borrowed across modules from someone else'sdetail::.
2. Include-path contract
S-10: user-facing includes always use <aria/...>
#include <aria/property.hpp>
#include <aria/observable_list.hpp>
#include <aria/async/async_command.hpp>
#include <aria/binding/binding_engine.hpp>
Never require users to write deep paths like
<aria/reactive/property.hpp>. Deep paths are an implementation
detail.
S-11: framework-internal includes also use the long aria/ path
The framework's own .hpp / .inl files include each other via
uniformly long "aria/..." paths; relative paths and bare file
names are forbidden:
Correct:
#include "aria/observable_list.hpp"
#include "aria/subscription.hpp"
#include "aria/detail/typed_signal.hpp"
Wrong:
#include "subscription.hpp" // relies on include path
#include "../subscription.hpp" // relative path
#include "concepts.hpp" // bare same-dir name
Reasons:
- IDE jump-to-definition is more stable.
- No reliance on the
PUBLIC/PRIVATEordering of CMaketarget_include_directories. - grep / file-move-safe.
- Top-tier frameworks all follow this convention (Boost, Folly, abseil, Eigen, QtCore's own module includes).
S-12: single facade umbrella header per module
Each module exposes one umbrella header as a "one-stop" entry; samples and quickstarts are encouraged to use it:
| Umbrella | Module |
|---|---|
<aria/aria.hpp> | All public core APIs |
<aria/async/async.hpp> | All public async APIs (when present) |
<aria/binding/binding.hpp> | All public binding APIs (when present) |
<aria/runtime/runtime.hpp> | All public runtime APIs (when present) |
Sub-headers can still be included individually; the umbrella is just a convenience entry point.
3. Naming-style contract
S-20: types are PascalCase
Property / ObservableList / BindingEngine / AsyncCommand.
Acronyms are leading-cap-only, not all-caps: IoExecutor, not
IOExecutor (per Google Style).
S-21: functions and methods are snake_case
prop.set(v) / prop.on_changed(fn) / list.push_back(x) /
engine.bind_text(...).
S-22: template parameters and concepts are PascalCase
template<PropertyValue T>
class Property;
template<typename Fn>
requires std::invocable<Fn>
void launch(Fn&&);
S-23: members use trailing_underscore_
Private data members are name_:
private:
std::vector<Slot> slots_;
std::shared_mutex mutex_;
S-24: macros only when truly necessary, ALL_CAPS prefixed ARIA_
ARIA_BINDING_API, ARIA_NO_DISCARD, ARIA_DEPRECATED.
Never use a macro to declare a user-visible API; only for
platform branching and export decoration.
S-25: async-entry naming
- Coroutine factory:
launch/start_detached - Awaitable factory:
schedule_on(executor)/schedule_after(scheduler, delay) - Combinators:
with_timeout/when_any/when_any_cancellable/when_all/retry - Cancellation:
CancellationSource/CancellationToken/throw_if_cancelled
S-26: observer-registration vs immediate-fire naming
| Shape | Naming | First-fire behaviour |
|---|---|---|
| Subscribe only, never fire | on_changed(fn) / observe(fn) / on_destroy(fn) / on_click(fn) | Not invoked |
| Fire once, then keep observing | bind(fn) | Synchronously invoked once |
| RAII side effect | Effect e{fn} | Construction fires once |
This naming contract is pinned in lifecycle.md
L-19; this document just restates it.
4. Errors and diagnostics (see also P0-α.2 error model)
S-30: compile-time diagnostic priority
Ordered by "how readable to the user", template entry points MUST satisfy at least the first two:
conceptconstraints: appearing in the template parameter list — IDEs immediately surface "constraint not satisfied".static_assertfallback: inside the template body, with a one-line explanation of the issue and a suggestion.- SFINAE /
requiresclauses: only as internal implementation detail, never as the first user-facing diagnostic line.
Anti-pattern (30-frame SFINAE):
template<typename Fn>
auto AsyncCommand::Builder::action(Fn fn) {
return /* ... wait until std::invoke fails and produces a wall of text ... */;
}
Correct:
template<typename Fn>
requires AsyncActionFn<Fn, T> // S-30 line 1
auto Builder::action(Fn fn) {
static_assert(!std::is_pointer_v<Fn>,
"AsyncCommand::action expects an invocable; "
"did you forget () after a function name?"); // S-30 line 2
/* ... */
}
S-31: runtime error messages must be locatable
A thrown exception's what() MUST contain:
- What: what happened ("reactive cycle detected").
- Where: location clue (node debug name / file / function).
- How: a hint about the next step ("check Effect that writes its own dependency").
CircularDependencyError already complies (carries the node-name
list). OperationCancelled is regular control flow and does not
need a "where".
S-32: exceptions inside observers MUST be swallowed
Per lifecycle.md L-13: exceptions thrown from a
user callback during a signal emit MUST be swallowed by the framework.
A misbehaving handler MUST NOT prevent later handlers from
running.
The reactive subsystem's recompute() is the exception: an
exception is propagated back to Graph::pull, which restores the
node to Clean and rethrows.
S-33: adapter platform_name and unsupported-widget contract
IViewAdapter::platform_name() returns a stable lowercase id
that matches IView::kind() exactly:
| Adapter | platform_name() | IView::kind() |
|---|---|---|
| Qt6 | "qt6" | "qt6" |
| AppKit | "appkit" | "appkit" |
| UIKit | "uikit" | "uikit" |
| Fake | "fake" | "fake" |
Reason: trace events / diagnostic sinks / log filters / showcase
routing all match on the platform_name() string. Mixing case
breaks "filter logs by platform" regexes. New adapters MUST follow
the lowercase id rule.
Unsupported-widget behaviour: every set_* / get_* /
on_*_changed / on_click that receives a widget class outside
the adapter's support matrix MUST go through the corresponding
warn_unsupported_(op, native) helper and emit one warning line:
<op>: no binding path for widget class '<cls>' # Qt6 side
<op>: no binding path for view class '<cls>' # AppKit / UIKit side
Routed via aria::runtime::Logger::warn(category, msg) with the
category set to "qt_adapter" / "appkit_adapter" /
"uikit_adapter". After the warn, the adapter MUST safely return
with a "zero subscription Subscription{}" / "default value 0 / false
/ empty string"; throwing or accessing nullptr is forbidden.
Reasons:
- Old design on AppKit / UIKit was
if (![o isKindOfClass:...]) return {};— silent early return — leaving ViewModels bound to the wrong widget with no clue. That is exactly the kind of "dark hole" a top-tier framework must not have. - The three adapters' unsupported paths must be symmetric: same op + same widget mismatch → same warn format → same safe return value. A host's tests cover all three platforms with one body of code.
- The warn is not rate-limited. Adapters are hot paths but the unsupported branch only fires when "the user bound the wrong widget" — a real diagnostic signal that should not be throttled away.
5. Deprecation and compatibility
S-40: the project ships zero deprecated APIs
Aria follows semantic versioning (currently 1.2.0); during evolution
we keep no deprecated aliases. Every P0/P1 closure breaks all call
sites outright in the same commit, and migrates the adapters, tests,
and documentation snippets in that commit too.
Reasons:
- A deprecated alias is "tomorrow's debt".
- The project goal is "best in class" — tolerating a deprecated block tolerates an inelegant block of code in the tree.
- During the no-external-user evolution window, this is the cleanest approach.
S-41: source MUST NOT contain version literals
- Source code / headers / inline docs / commit messages MUST NOT
contain version literals like
v2.x,since 1.x,v1.x. - Version evolution lives only in
CHANGELOG.mdandREADME.md. - Comments, documentation, and tests MUST be in English. Chinese is
permitted only in the Chinese localised README (
README.md).
6. Style checklist (PR review checklist)
Before merging any PR, self-review:
[ ] Public APIs do not require the user to write aria::reactive::* / aria::detail::*
[ ] Includes use the long "aria/..." path (incl. .inl)
[ ] Template entries have BOTH a concept constraint AND a one-line static_assert
[ ] Naming follows PascalCase types / snake_case functions / trailing_underscore_ members
[ ] No new deprecated aliases
[ ] No version literals in source; no Chinese comments / docs / tests outside README.* (Chinese defaults)
[ ] Exception what() satisfies the What/Where/How recipe
[ ] detail/testing namespaces stay in their lanes: detail does NOT appear in public signatures
[ ] Consistent with the relevant L-N in lifecycle.md; if there's a conflict, fix lifecycle.md first
7. Document governance
Style adjustments MUST flow as doc change → code change → test change; the reverse is not allowed (avoids "code drifts first, docs catch up later").