HelloInvoice Tutorial
HelloInvoice is the reference domain that ships inside ausus/starter and
the repository playground. It is a single invoice entity with a three-state
lifecycle. This tutorial walks through declaring it, then exercises every
runtime guarantee against it.
The runnable version lives at apps/playground/run.php in the monorepo and is
covered by 36 assertions in the validation gate.
1. Declare the domain
A domain in AUSUS is a plugin. Here is the complete HelloInvoice plugin
written with the DSL:
namespace Acme\Billing;
use Ausus\{DslPlugin, Dsl, Field, Action};
final class HelloInvoiceDsl extends DslPlugin
{
public function name(): string { return 'billing'; }
public function phpNamespace(): string { return 'Acme\\Billing'; }
public function dsl(Dsl $dsl): void
{
$dsl->entity('invoice')
->fields([
'number' => Field::string()->unique()->max(32),
'customer_name' => Field::string()->max(200),
'amount' => Field::money()->currency('USD'),
'status' => Field::enum('DRAFT', 'ISSUED', 'CANCELLED')->default('DRAFT'),
'issued_at' => Field::datetime()->nullable(),
])
->actions([
'create' => Action::create('number', 'customer_name', 'amount')
->requireRole('invoice.creator'),
'issue' => Action::transition('status', from: 'DRAFT', to: 'ISSUED')
->stamp('issued_at')
->requireRole('invoice.issuer'),
'cancel' => Action::transition('status', from: 'DRAFT', to: 'CANCELLED')
->andTransition('status', from: 'ISSUED', to: 'CANCELLED')
->requireRole('invoice.canceler'),
])
->workflow('status')
->projection('summary',
fields: ['id', 'number', 'customer_name', 'status', 'amount'],
actions: ['create', 'cancel'],
role: 'invoice.viewer')
->projection('detail',
fields: ['id', 'number', 'customer_name', 'status', 'amount', 'issued_at', 'created_at', 'updated_at'],
actions: ['issue', 'cancel'],
role: 'invoice.viewer');
}
}
What this declares:
- One entity,
billing.invoice, with five domain fields. The kernel adds five system fields automatically (id,tenant_id,_version,created_at,updated_at). - Three actions:
create(a create action) plusissueandcancel(transition actions). - A workflow on the
statusenum field — the runtime infers states and initial value from the field. - Two projections — read-shaped views named
summaryanddetail.
2. Compile and boot
use Ausus\Compiler;
use Ausus\Persistence\Sql\SchemaDeriver;
$graph = (new Compiler())->compile([new HelloInvoiceDsl()]);
echo "entities=", count($graph->entities),
" actions=", count($graph->actions),
" workflows=", count($graph->workflows), "\n";
// entities=1 actions=3 workflows=1
foreach (SchemaDeriver::deriveAll($graph) as $stmt) {
$pdo->exec($stmt);
}
See Your first app for the full runtime wiring; the steps
below assume an $invoker and $renderer are in scope.
3. Create an invoice
$created = $invoker->invoke('billing.invoice.create', null, [
'number' => 'INV-2026-001',
'customer_name' => 'ACME Corporation',
'amount' => ['amount' => '1500.00', 'currency' => 'USD'],
]);
$created['id']— a 26-character ULID.$created['status']—'DRAFT', applied automatically from the enum default. Thecreateaction did not passstatus.
4. Issue it (a workflow transition)
use Ausus\Reference;
$ref = new Reference('acme', 'billing.invoice', $created['id']);
$out = $invoker->invoke('billing.invoice.issue', $ref, []);
// $out['status'] === 'ISSUED'
// $out['issued_at'] is an RFC-3339 timestamp (the ->stamp('issued_at') effect)
The issue action is declared transition('status', from: 'DRAFT', to: 'ISSUED').
The workflow runtime checks the invoice is currently DRAFT before allowing it.
5. Watch the guards work
These calls are supposed to fail — they demonstrate the runtime guarantees.
// Issue again, from ISSUED -> rejected: no transition declared from ISSUED.
try {
$invoker->invoke('billing.invoice.issue', $ref, []);
} catch (\Ausus\WorkflowStateMismatch $e) {
// expected
}
// Cross-tenant reference -> rejected before any work happens.
$wrong = new Reference('other-tenant', 'billing.invoice', $created['id']);
try {
$invoker->invoke('billing.invoice.issue', $wrong, []);
} catch (\Ausus\TenantBoundaryViolation $e) {
// expected
}
Then a legal transition — cancel is declared from both DRAFT and
ISSUED:
$out = $invoker->invoke('billing.invoice.cancel', $ref, []);
// $out['status'] === 'CANCELLED'
6. Optimistic concurrency
Every row carries a _version ULID. An update with a stale version is
rejected:
$repo = $driver->context($tenant, $driver->beginTransaction($tenant))
->repository('billing.invoice');
$current = $repo->find($ref);
$stale = $current->version;
$repo->update($ref, ['customer_name' => 'New Name'], $stale); // ok — bumps _version
$repo->update($ref, ['customer_name' => 'Bad Name'], $stale); // throws ConcurrencyConflict
7. Render a projection
$summary = $renderer->render('billing.invoice.summary');
// $summary['schemaVersion'] === '1.0.0'
// $summary['fields'] -> 5 field descriptors
// $summary['actions'] -> 2 action descriptors (create, cancel)
// $summary['data']['items'] -> the invoices for this tenant
This ViewSchema is exactly what the HTTP API returns and what the React renderer draws.
What HelloInvoice proves
Running the full playground exercises, in order: persistence round-trip, enum-default application, workflow transitions, workflow rejection, tenant isolation, optimistic locking, audit-trail emission, and projection rendering — plus that the DSL plugin and an equivalent hand-written plugin compile to a byte-identical graph hash.
Current v0.1.0 limitations
- Projection list rendering reads rows for the current tenant with no
filtering or real pagination —
pagination.nextCursoris alwaysnull. - There is no
deleteaction kind and no rich input validation beyond field presence and declared types. cancelusesandTransition()to declare two explicit sources; wildcard (from: '*') transitions are supported by the runtime but not used here.
Next
- Core Concepts — the model behind all of this.
- The PHP DSL — every builder method.