Skip to main content

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) plus issue and cancel (transition actions).
  • A workflow on the status enum field — the runtime infers states and initial value from the field.
  • Two projections — read-shaped views named summary and detail.

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. The create action did not pass status.

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.nextCursor is always null.
  • There is no delete action kind and no rich input validation beyond field presence and declared types.
  • cancel uses andTransition() to declare two explicit sources; wildcard (from: '*') transitions are supported by the runtime but not used here.

Next