Skip to content

hnordt/hyperfact

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 

Repository files navigation

Hyperfact

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.

Why

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 -> Query

This 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.

Core vocabulary

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

Mental model

Command
  -> Fact
    -> Procedure
      -> Fact
        -> Projection
          -> Query

A 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.

Architecture diagram

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
Loading

CRUD comparison

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
Loading

Traditional CRUD

In a typical CRUD backend, the write path usually looks like this:

Request
  -> Validate
  -> Authorize
  -> Mutate tables
  -> Return response

The 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.

Hyperfact

In Hyperfact, the write path looks like this:

Request
  -> Command
  -> Validate
  -> Authorize
  -> Append Fact
  -> Project Fact
  -> Run Procedures
  -> Append more Facts

The database stores what happened.

Current state is derived from facts through projections.

Automatic business behavior is represented through procedures.

Example API

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

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

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

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(...);

Procedure rule

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 fact

Projections

Projections 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

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],
    };
  },
});

Full minimal example

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;
  },
});

Runtime invariants

Hyperfact depends on strict runtime rules.

1. Commands express intent

A command is not a fact.

A command is something someone wants to happen.

RegisterUser
ChangeUserEmail
PayInvoice

A command may be rejected.

2. Facts are durable truth

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
UserSuspended

3. Procedures are caused by facts

Procedures do not run randomly.

They are triggered by facts.

UserRegistered -> UserOnboarding
InvoiceOverdue -> InvoiceCollection
LeadCreated -> LeadQualification

4. Procedures return one fact per step

Each procedure step must produce one fact.

UserRegistered
  -> UserOnboarding
    -> WelcomeEmailSent

5. Projections are derived

Projection tables are read models.

They can be rebuilt from facts.

They are not the source of truth.

6. Queries read projections

Queries should read projection tables.

They should not contain write behavior.

7. Side effects must become facts

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
WhatsAppMessageSent

Failures should also be facts when they matter to the business.

WelcomeEmailFailed
PaymentFailed
WebhookDeliveryFailed
WhatsAppMessageFailed

Suggested database shape

This 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 execution

A 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
);

Advantages

Durable business history

The system stores what happened, not only what is currently true.

Instead of only seeing this:

user.status = active

You can inspect this:

UserRegistered
WelcomeEmailSent
UserActivated

Better auditability

Facts make it easier to answer:

What happened?
When did it happen?
What caused it?
Which procedure produced this result?

Rebuildable read models

Projection tables can be rebuilt from facts.

This makes it easier to add new views later without changing old write logic.

Explicit automation

Procedures make automatic business behavior visible.

Instead of hiding automation inside services, hooks, or callbacks, procedures become named domain concepts.

UserOnboarding
InvoiceCollection
LeadQualification
CustomerRetention

Clear write and read boundaries

Commands handle writes.

Queries handle reads.

Facts connect the write side to projections and procedures.

Better fit for analytical features

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?

Better fit for AI-assisted development

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 needed

This can make feature implementation more predictable.

Drawbacks

More concepts than CRUD

CRUD is simpler at the beginning.

Hyperfact introduces more moving parts:

Command
Fact
Procedure
Projection
Query

For small features, this can feel heavier.

More infrastructure

A reliable implementation needs runtime support:

facts table
projection runner
procedure dispatcher
procedure execution tracking
retries
idempotency

Without these pieces, the architecture becomes fragile.

Eventual consistency

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.

More naming pressure

The architecture depends on clear names.

Bad names make the system harder to understand.

Weak:

HandleUser
DoEmail
UserEvent
ProcessStuff

Better:

RegisterUser
UserRegistered
UserOnboarding
WelcomeEmailSent

More upfront design

You 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.

Requires discipline

The model becomes weaker if procedures mutate projections directly, facts are not durable, or queries start changing state.

The rules need to be strict.

When to use this

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 time

When not to use this

Hyperfact may be too heavy for:

simple CRUD admin panels
throwaway prototypes
static websites
apps with very little business logic
systems where current state is enough

Naming conventions

Commands

Imperative verb phrase.

const RegisterUser = command(...);
const ChangeUserEmail = command(...);
const PayInvoice = command(...);
const QualifyLead = command(...);

Facts

Past tense.

const UserRegistered = fact();
const UserEmailChanged = fact();
const InvoicePaid = fact();
const LeadQualified = fact();

Procedures

Business procedure noun.

const UserOnboarding = procedure(...);
const InvoiceCollection = procedure(...);
const LeadQualification = procedure(...);
const CustomerRetention = procedure(...);

Projections

Read model noun.

const Users = projection(...);
const Invoices = projection(...);
const Leads = projection(...);

Queries

Read intent.

const GetUsers = query(...);
const ListInvoices = query(...);
const SearchLeads = query(...);

Design principles

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.

Roadmap ideas

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 queries

License

MIT

Author

Created by Heliton Nordt.

Releases

No releases published

Contributors