An experimental backend architecture for building business applications from durable facts, explicit commands, automatic procedures, derived projections, and query boundaries.
Instead of mutating current state directly, Hyperfact appends facts first, derives read models from them, and advances business procedures from what happened.
Status: experimental.
The API is still being explored and may change.
Most backend applications start with CRUD because it is simple and direct.
A request arrives, the application validates it, mutates database tables, and returns the current state. This works well for many systems, but it becomes harder to reason about when the business depends on history, automation, auditability, and derived views.
Hyperfact exists to make those boundaries explicit.
Instead of treating the database as only the current state, Hyperfact treats the database as a record of durable business facts.
Command -> Fact -> Procedure -> Fact -> Projection -> QueryThis gives the application a stable sequence:
1. A command expresses intent.
2. If accepted, it appends a fact.
3. Facts become the durable source of truth.
4. Procedures react to facts and produce more facts.
5. Projections derive read models from facts.
6. Queries read those projections.The goal is not to replace CRUD everywhere.
The goal is to make business-heavy systems easier to inspect, extend, automate, and rebuild over time.
Hyperfact is designed for applications where these questions matter:
What happened?
Why did it happen?
What caused this state?
Which automatic procedure ran?
Can this read model be rebuilt?
Can I add a new view without changing old writes?
Can I analyze behavior from historical facts?In short:
CRUD optimizes for simple current-state mutation.
Hyperfact optimizes for durable business history, explicit automation, and rebuildable read models.Command = submitted intent
Fact = durable truth
Procedure = durable automatic business procedure caused by facts; returns one fact per step
Projection = read model
Query = read boundaryCommand
-> Fact
-> Procedure
-> Fact
-> Projection
-> QueryA command does not mutate application tables directly.
A command produces a fact.
Facts are stored durably.
Procedures are caused by facts.
Procedures may perform external work and must return exactly one fact per step.
Projections consume facts and build read models.
Queries read projections.
flowchart TD
Client["Client / API caller"]
subgraph WriteSide["Write side"]
Command["Command<br/>submitted intent"]
Validate["Validate input"]
Authorize["Authorize intent"]
DecideFact["Decide fact"]
AppendFact["Append fact in transaction"]
Facts[("facts<br/>durable ordered truth")]
end
subgraph ProcedureSide["Procedure side"]
ProcedureDispatcher["Procedure dispatcher<br/>reads newly appended facts"]
Procedure["Procedure<br/>automatic business procedure"]
ProcedureExecution[("procedure_executions<br/>procedure_name + fact_id")]
Effect["effect()<br/>executes one step"]
ResultFact["Result fact<br/>exactly one fact per step"]
end
subgraph ProjectionSide["Projection side"]
Projector["Projector<br/>replays facts"]
Projection["Projection<br/>read model"]
ProjectionTables[("projection tables<br/>query-optimized state")]
end
subgraph ReadSide["Read side"]
Query["Query<br/>read boundary"]
SQL["SQL"]
Response["Response"]
end
Client --> Command
Command --> Validate
Validate --> Authorize
Authorize --> DecideFact
DecideFact --> AppendFact
AppendFact --> Facts
Facts --> ProcedureDispatcher
ProcedureDispatcher --> Procedure
Procedure --> ProcedureExecution
ProcedureExecution --> Effect
Effect --> ResultFact
ResultFact --> AppendFact
Facts --> Projector
Projector --> Projection
Projection --> ProjectionTables
Client --> Query
Query --> SQL
SQL --> ProjectionTables
ProjectionTables --> Response
Facts -. "source of truth" .-> Projector
Facts -. "cause procedures" .-> ProcedureDispatcher
ProjectionTables -. "derived, rebuildable" .-> Facts
flowchart LR
subgraph CRUD["Traditional CRUD"]
CRUD_Request["HTTP request"]
CRUD_Handler["Controller / service"]
CRUD_Validate["Validate"]
CRUD_Authorize["Authorize"]
CRUD_Mutate["Mutate current state"]
CRUD_DB[("Application tables")]
CRUD_Query["Query current state"]
CRUD_Response["Response"]
CRUD_Request --> CRUD_Handler
CRUD_Handler --> CRUD_Validate
CRUD_Validate --> CRUD_Authorize
CRUD_Authorize --> CRUD_Mutate
CRUD_Mutate --> CRUD_DB
CRUD_DB --> CRUD_Query
CRUD_Query --> CRUD_Response
end
subgraph Hyperfact["Hyperfact"]
H_Request["HTTP request"]
H_Command["Command<br/>submitted intent"]
H_Validate["Validate"]
H_Authorize["Authorize"]
H_Fact["Append fact"]
H_Facts[("Facts<br/>durable truth")]
H_Procedure["Procedure<br/>automatic business procedure"]
H_ResultFact["Append result fact"]
H_Projector["Projector"]
H_Projection[("Projection tables")]
H_Query["Query<br/>read boundary"]
H_Response["Response"]
H_Request --> H_Command
H_Command --> H_Validate
H_Validate --> H_Authorize
H_Authorize --> H_Fact
H_Fact --> H_Facts
H_Facts --> H_Procedure
H_Procedure --> H_ResultFact
H_ResultFact --> H_Facts
H_Facts --> H_Projector
H_Projector --> H_Projection
H_Query --> H_Projection
H_Projection --> H_Response
end
In a typical CRUD backend, the write path usually looks like this:
Request
-> Validate
-> Authorize
-> Mutate tables
-> Return responseThe database stores the latest state.
That is simple and efficient, but business history is often lost unless extra audit tables, logs, or callbacks are added later.
In Hyperfact, the write path looks like this:
Request
-> Command
-> Validate
-> Authorize
-> Append Fact
-> Project Fact
-> Run Procedures
-> Append more FactsThe database stores what happened.
Current state is derived from facts through projections.
Automatic business behavior is represented through procedures.
This API is not final. It shows the current direction.
import {
command,
fact,
procedure,
projection,
query,
} from "@hnordt/hyperfact";
import * as z from "zod";Facts represent durable truth.
They should usually be named in the past tense.
const UserRegistered = fact();
const WelcomeEmailSent = fact();Possible future API:
const UserRegistered = fact({
type: "UserRegistered",
payload: z.object({
userId: z.string(),
email: z.email(),
}),
});A fact is not just a notification. It is a durable record that something is true in the system history.
Good fact names:
const UserRegistered = fact();
const UserEmailChanged = fact();
const WelcomeEmailSent = fact();
const InvoicePaid = fact();
const LeadQualified = fact();Weak fact names:
const RegisterUser = fact();
const SendEmail = fact();
const UserEvent = fact();
const HandleLead = fact();Commands represent submitted intent.
A command may fail validation.
A command may fail authorization.
A command may be rejected.
If accepted, a command appends a fact.
const RegisterUser = command({
authorize() {
return true;
},
fact() {
return UserRegistered;
},
});Possible future API:
const RegisterUser = command({
input: z.object({
email: z.email(),
}),
authorize(ctx, input) {
return ctx.actor.role === "admin";
},
fact(ctx, input) {
return UserRegistered({
userId: ctx.id(),
email: input.email,
});
},
});Command names should use imperative verbs:
const RegisterUser = command(...);
const ChangeUserEmail = command(...);
const PayInvoice = command(...);
const QualifyLead = command(...);Procedures are durable automatic business procedures caused by facts.
A procedure returns exactly one fact per step.
const UserOnboarding = procedure({
on: UserRegistered,
async effect() {
// await env.mailer.sendEmail(payload.email);
return WelcomeEmailSent;
},
});Possible future API:
const UserOnboarding = procedure({
on: UserRegistered,
async effect(ctx, fact) {
await ctx.mailer.sendWelcomeEmail({
to: fact.payload.email,
});
return WelcomeEmailSent({
userId: fact.payload.userId,
});
},
});The procedure name should describe the business procedure, not the low-level action.
Good procedure names:
const UserOnboarding = procedure(...);
const LeadQualification = procedure(...);
const InvoiceCollection = procedure(...);
const CustomerRetention = procedure(...);Weak procedure names:
const SendWelcomeEmail = procedure(...);
const HandleUserRegistered = procedure(...);
const UserRegisteredReaction = procedure(...);
const DoOnboardingStuff = procedure(...);A procedure step must return exactly one fact.
Good:
return WelcomeEmailSent(...);Also good:
return WelcomeEmailSkipped(...);Also good:
return WelcomeEmailFailed(...);Avoid:
return null;
return undefined;
return [WelcomeEmailSent(...), UserOnboarded(...)];This rule keeps the system easier to inspect, retry, and debug.
One triggering fact
-> one procedure step
-> one resulting factProjections are read models derived from facts.
They are optimized for queries.
They are not the source of truth.
const Users = projection();Possible future API:
const Users = projection({
table: "users",
on: {
UserRegistered(db, fact) {
db.insert("users", {
id: fact.payload.userId,
email: fact.payload.email,
});
},
UserEmailChanged(db, fact) {
db.update("users", {
id: fact.payload.userId,
email: fact.payload.email,
});
},
},
});A projection can be deleted and rebuilt from the facts table.
That is one of the main reasons to keep facts as the source of truth.
Queries are explicit read boundaries.
They read projections.
const GetUsers = query({
authorize() {
return true;
},
sql() {
return `SELECT * FROM users ORDER BY id ASC`;
},
});Possible future API:
const GetUsers = query({
input: z.object({
limit: z.number().min(1).max(100).default(50),
}),
authorize(ctx) {
return ctx.actor.role === "admin";
},
sql(ctx, input) {
return {
text: `
SELECT *
FROM users
ORDER BY id ASC
LIMIT ?
`,
values: [input.limit],
};
},
});import * as z from "zod";
// Command = submitted intent
// Fact = durable truth
// Procedure = durable automatic business procedure caused by facts; returns one fact per step
// Projection = read model
// Query = read boundary
function command(options: {
authorize: () => boolean;
fact: () => ReturnType<typeof fact>;
}) {
return () => ({
kind: "command",
...options,
});
}
function fact() {
return () => ({
kind: "fact",
});
}
function procedure(options: {
on: ReturnType<typeof fact>;
effect: () => Promise<ReturnType<typeof fact>>;
}) {
return () => ({
kind: "procedure",
...options,
});
}
function projection() {
return () => ({
kind: "projection",
});
}
function query(options: { authorize: () => boolean; sql: () => string }) {
return () => ({
kind: "query",
...options,
});
}
// Projections
const Users = projection();
// Queries
const GetUsers = query({
authorize() {
return true;
},
sql() {
return `SELECT * FROM users ORDER BY id ASC`;
},
});
// Facts
const UserRegistered = fact();
const WelcomeEmailSent = fact();
// Commands
const RegisterUser = command({
authorize() {
return true;
},
fact() {
return UserRegistered;
},
});
// Procedures
const UserOnboarding = procedure({
on: UserRegistered,
async effect() {
// await env.mailer.sendEmail(payload.email);
return WelcomeEmailSent;
},
});Hyperfact depends on strict runtime rules.
A command is not a fact.
A command is something someone wants to happen.
RegisterUser
ChangeUserEmail
PayInvoiceA command may be rejected.
A fact records something that happened.
Facts are append-only.
Do not update a fact to rewrite history.
If the business changes, append another fact.
UserRegistered
UserEmailChanged
UserSuspendedProcedures do not run randomly.
They are triggered by facts.
UserRegistered -> UserOnboarding
InvoiceOverdue -> InvoiceCollection
LeadCreated -> LeadQualificationEach procedure step must produce one fact.
UserRegistered
-> UserOnboarding
-> WelcomeEmailSentProjection tables are read models.
They can be rebuilt from facts.
They are not the source of truth.
Queries should read projection tables.
They should not contain write behavior.
If the system sends an email, calls an API, charges a card, or sends a message, the result should be recorded as a fact.
WelcomeEmailSent
PaymentCharged
WebhookDelivered
WhatsAppMessageSentFailures should also be facts when they matter to the business.
WelcomeEmailFailed
PaymentFailed
WebhookDeliveryFailed
WhatsAppMessageFailedThis is a possible SQLite-first shape.
CREATE TABLE facts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL
);For durable procedure execution:
CREATE TABLE procedure_executions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
procedure_name TEXT NOT NULL,
fact_id INTEGER NOT NULL,
status TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (procedure_name, fact_id)
);The unique constraint is important.
It prevents the same procedure from processing the same fact more than once.
procedure_name + fact_id = one executionA more complete facts table could include causation and correlation metadata:
CREATE TABLE facts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
payload TEXT NOT NULL,
caused_by_fact_id INTEGER,
caused_by_command_id TEXT,
correlation_id TEXT,
created_at TEXT NOT NULL
);The system stores what happened, not only what is currently true.
Instead of only seeing this:
user.status = activeYou can inspect this:
UserRegistered
WelcomeEmailSent
UserActivatedFacts make it easier to answer:
What happened?
When did it happen?
What caused it?
Which procedure produced this result?Projection tables can be rebuilt from facts.
This makes it easier to add new views later without changing old write logic.
Procedures make automatic business behavior visible.
Instead of hiding automation inside services, hooks, or callbacks, procedures become named domain concepts.
UserOnboarding
InvoiceCollection
LeadQualification
CustomerRetentionCommands handle writes.
Queries handle reads.
Facts connect the write side to projections and procedures.
Because facts preserve history, the system can answer historical and behavioral questions more naturally.
Examples:
How long does onboarding usually take?
How many users received the welcome email but never activated?
Which procedure fails most often?
Which facts usually happen before churn?The architecture is explicit and repetitive.
An AI coding agent can follow fixed patterns:
Add command
Add fact
Add projection
Add query
Add procedure when neededThis can make feature implementation more predictable.
CRUD is simpler at the beginning.
Hyperfact introduces more moving parts:
Command
Fact
Procedure
Projection
QueryFor small features, this can feel heavier.
A reliable implementation needs runtime support:
facts table
projection runner
procedure dispatcher
procedure execution tracking
retries
idempotencyWithout these pieces, the architecture becomes fragile.
Writes append facts first.
Projections may update after the fact is committed.
This means reads may be eventually consistent unless projections are updated in the same transaction.
The architecture depends on clear names.
Bad names make the system harder to understand.
Weak:
HandleUser
DoEmail
UserEvent
ProcessStuffBetter:
RegisterUser
UserRegistered
UserOnboarding
WelcomeEmailSentYou need to think about facts, procedures, and projections before implementing a feature.
That is useful for business-heavy systems, but can be unnecessary for simple CRUD screens.
The model becomes weaker if procedures mutate projections directly, facts are not durable, or queries start changing state.
The rules need to be strict.
Hyperfact may be a good fit for:
CRM systems
workflow-heavy business apps
audit-heavy systems
automation-heavy products
multi-tenant SaaS
systems where business history matters
systems where read models evolve over timeHyperfact may be too heavy for:
simple CRUD admin panels
throwaway prototypes
static websites
apps with very little business logic
systems where current state is enoughImperative verb phrase.
const RegisterUser = command(...);
const ChangeUserEmail = command(...);
const PayInvoice = command(...);
const QualifyLead = command(...);Past tense.
const UserRegistered = fact();
const UserEmailChanged = fact();
const InvoicePaid = fact();
const LeadQualified = fact();Business procedure noun.
const UserOnboarding = procedure(...);
const InvoiceCollection = procedure(...);
const LeadQualification = procedure(...);
const CustomerRetention = procedure(...);Read model noun.
const Users = projection(...);
const Invoices = projection(...);
const Leads = projection(...);Read intent.
const GetUsers = query(...);
const ListInvoices = query(...);
const SearchLeads = query(...);Commands express intent.
Facts store durable truth.
Procedures are caused by facts.
Procedures return one fact per step.
Projections are derived from facts.
Queries read projections.Possible next steps:
typed facts with Zod payload schemas
command input validation
procedure execution table
projection rebuild runner
SQLite adapter
transaction boundaries
fact metadata
causation and correlation IDs
test helpers
developer logs
CLI for creating commands, facts, procedures, projections, and queriesMIT
Created by Heliton Nordt.