Aller au contenu principal

Partie 2 — Le domaine

Pourquoi cette étape existe : dans AUSUS, vous n'écrivez pas de modèles, de contrôleurs ni de migrations. Vous écrivez un plugin — une classe unique qui décrit votre domaine. Le framework compile cette description et l'exécute. Cette partie écrit l'intégralité du domaine du système de tickets dans un seul fichier.

Cette partie couvre cinq choses à la fois, parce qu'elles vivent toutes dans le même fichier : amorcer l'Application, créer l'entité, ajouter des champs, ajouter des actions, et déclarer le workflow.

2.1 L'Application et le plugin

Ausus\Application est l'objet qui exécute votre domaine. Son cycle de vie tient en quatre appels :

$app = Application::create([...]) // configure tenant, actor, database
->register(new TicketSystem()) // hand it your plugin(s)
->boot(); // compile + wire the runtime

register() prend un plugin. Un plugin est une classe quelconque qui étend Ausus\DslPlugin et qui implémente trois méthodes : name(), phpNamespace() et dsl(). La méthode dsl() est l'endroit où le domaine est décrit.

Créez le fichier src/TicketSystem.php avec le squelette vide :

<?php
declare(strict_types=1);

namespace Helpdesk;

use Ausus\{DslPlugin, Dsl, Field, Action};

final class TicketSystem extends DslPlugin
{
/** The plugin name — becomes the first segment of every FQN. */
public function name(): string { return 'helpdesk'; }

/** The PHP namespace this plugin's code lives in. */
public function phpNamespace(): string { return 'Helpdesk'; }

/** Describe the domain. AUSUS calls this when the Application boots. */
public function dsl(Dsl $dsl): void
{
// entity, fields, actions and workflow go here
}
}

Pourquoi name() est important : c'est le premier segment de chaque nom pleinement qualifié (FQN). Avec name() qui renvoie helpdesk, l'entité que vous créez ensuite est helpdesk.ticket et son action de création est helpdesk.ticket.create. Les FQN sont la manière dont chaque couche — runtime, HTTP, renderer — fait référence à votre domaine.

Le reste de cette partie remplit le corps de dsl().

2.2 Créer l'entité

Une entité est un type d'enregistrement que votre application stocke. Le système de tickets en a exactement une : un ticket.

$dsl->entity('ticket')
// ->fields([...]) — next
;

entity('ticket') déclare une entité avec le FQN helpdesk.ticket. Elle renvoie un EntityBuilder sur lequel vous continuez à chaîner des appels. Pourquoi un builder : les champs, les actions et le workflow sont tous déclarés en chaînant à partir de cet unique appel, de sorte que toute l'entité se lit comme une seule expression.

2.3 Ajouter des champs

Les champs sont les colonnes de l'entité. Vous déclarez chacun avec le builder Field. Le système de tickets en a besoin de cinq :

->fields([
'title' => Field::string()->max(200),
'requester' => Field::string()->max(120),
'priority' => Field::enum('LOW', 'NORMAL', 'HIGH')->default('NORMAL'),
'status' => Field::enum('OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED')->default('OPEN'),
'resolved_at' => Field::datetime()->nullable(),
])

Chaque ligne, et pourquoi :

ChampBuilderPourquoi
titleField::string()->max(200)Le titre du ticket. max() plafonne la longueur.
requesterField::string()->max(120)Qui l'a signalé.
priorityField::enum(...)->default('NORMAL')Un choix fixe. default() est utilisé lorsqu'aucune valeur n'est fournie.
statusField::enum(...)->default('OPEN')L'état du cycle de vie — le workflow sera attaché à ce champ.
resolved_atField::datetime()->nullable()Renseigné uniquement quand le ticket est résolu, il doit donc être nullable().

Pourquoi vous ne déclarez pas d'id, d'horodatages ni de colonne tenant : AUSUS ajoute automatiquement cinq champs système à chaque entité — id, tenant_id, _version, created_at, updated_at. Vous ne les gérez jamais à la main.

Les types de champs disponibles en v0.1.x sont string, integer, datetime, money et enum. Les méthodes du builder incluent ->max(), ->nullable(), ->default(), ->unique() et ->currency() (pour money).

2.4 Ajouter des actions

Les enregistrements ne changent pas d'eux-mêmes. Chaque changement passe par une action. Il en existe deux sortes, et le système de tickets utilise les deux.

Une action de création fait naître un nouvel enregistrement :

'create' => Action::create('title', 'requester', 'priority')
->requireRole('ticket.agent'),

Les arguments d'Action::create() sont les noms des champs que l'appelant doit fournir. status n'est pas listé — le workflow l'initialise. resolved_at n'est pas listé — il est vide jusqu'à ce que le ticket soit résolu.

Une action de transition déplace un enregistrement d'un état à un autre :

'start' => Action::transition('status', from: 'OPEN', to: 'IN_PROGRESS')
->requireRole('ticket.agent'),
'resolve' => Action::transition('status', from: 'IN_PROGRESS', to: 'RESOLVED')
->stamp('resolved_at')
->requireRole('ticket.agent'),
'close' => Action::transition('status', from: 'RESOLVED', to: 'CLOSED')
->requireRole('ticket.agent'),

transition('status', from:, to:) dit « cette action change status d'une valeur à une autre. » Remarques, avec le pourquoi :

  • ->stamp('resolved_at') sur resolve écrit l'horodatage courant dans resolved_at dans le cadre de la transition. C'est pour cela que le champ existe.
  • ->requireRole('ticket.agent') attache une policy : seul un acteur détenant le rôle ticket.agent peut invoquer l'action. Sans un rôle correspondant, le runtime rejette l'appel avant qu'aucun travail ne soit effectué.

Mis bout à bout, le bloc ->actions([...]) est :

->actions([
'create' => Action::create('title', 'requester', 'priority')
->requireRole('ticket.agent'),
'start' => Action::transition('status', from: 'OPEN', to: 'IN_PROGRESS')
->requireRole('ticket.agent'),
'resolve' => Action::transition('status', from: 'IN_PROGRESS', to: 'RESOLVED')
->stamp('resolved_at')
->requireRole('ticket.agent'),
'close' => Action::transition('status', from: 'RESOLVED', to: 'CLOSED')
->requireRole('ticket.agent'),
])

2.5 Déclarer le workflow

Les actions de transition décrivent des mouvements individuels. Le workflow les relie en une seule machine à états et indique à AUSUS où démarre un nouveau ticket :

Machine à états du workflow ticket : un nouveau ticket démarre en OPEN ; start le passe à IN_PROGRESS ; resolve le passe à RESOLVED et estampille resolved_at ; close le passe à CLOSED.

Le runtime applique ce diagramme : appeler start sur un ticket RESOLVED, ou resolve sur un ticket OPEN, lève WorkflowStateMismatch avant la moindre écriture.

->workflow(field: 'status', initial: 'OPEN')
  • field: 'status' — le champ enum qui contient l'état. Ses options (OPEN, IN_PROGRESS, RESOLVED, CLOSED) deviennent les états du workflow.
  • initial: 'OPEN' — l'état dans lequel démarre un ticket fraîchement créé.

Pourquoi le déclarer explicitement : le workflow est ce qui fait que le runtime protège les transitions. Avec le workflow en place, appeler resolve sur un ticket OPEN échoue avec un WorkflowStateMismatch — il n'y a pas de transition OPEN → RESOLVED. Passez toujours initial explicitement ; l'omettre déclenche un avis de dépréciation.

Enfin, ajoutez deux projections — des vues nommées, en forme de lecture, de l'entité que l'API HTTP et l'interface React consomment :

->projection('summary',
fields: ['id', 'title', 'requester', 'priority', 'status'],
actions: ['create', 'start', 'resolve', 'close'],
role: 'ticket.viewer')
->projection('detail',
fields: ['id', 'title', 'requester', 'priority', 'status', 'resolved_at', 'created_at', 'updated_at'],
actions: ['start', 'resolve', 'close'],
role: 'ticket.viewer');

summary est une vue de liste ; detail est une vue d'enregistrement unique. La liste fields choisit quelles colonnes apparaissent — y compris les champs système comme id et created_at.

Le plugin complet

Voici src/TicketSystem.php en entier. C'est l'intégralité du domaine — chaque partie ultérieure du tutoriel ne fait que l'exécuter :

<?php
declare(strict_types=1);

namespace Helpdesk;

use Ausus\{DslPlugin, Dsl, Field, Action};

/**
* TicketSystem — the AUSUS tutorial domain.
*
* One entity (helpdesk.ticket) with a four-state lifecycle:
* OPEN → IN_PROGRESS → RESOLVED → CLOSED
*/
final class TicketSystem extends DslPlugin
{
public function name(): string { return 'helpdesk'; }
public function phpNamespace(): string { return 'Helpdesk'; }

public function dsl(Dsl $dsl): void
{
$dsl->entity('ticket')
->fields([
'title' => Field::string()->max(200),
'requester' => Field::string()->max(120),
'priority' => Field::enum('LOW', 'NORMAL', 'HIGH')->default('NORMAL'),
'status' => Field::enum('OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED')->default('OPEN'),
'resolved_at' => Field::datetime()->nullable(),
])
->actions([
'create' => Action::create('title', 'requester', 'priority')
->requireRole('ticket.agent'),
'start' => Action::transition('status', from: 'OPEN', to: 'IN_PROGRESS')
->requireRole('ticket.agent'),
'resolve' => Action::transition('status', from: 'IN_PROGRESS', to: 'RESOLVED')
->stamp('resolved_at')
->requireRole('ticket.agent'),
'close' => Action::transition('status', from: 'RESOLVED', to: 'CLOSED')
->requireRole('ticket.agent'),
])
->workflow(field: 'status', initial: 'OPEN')
->projection('summary',
fields: ['id', 'title', 'requester', 'priority', 'status'],
actions: ['create', 'start', 'resolve', 'close'],
role: 'ticket.viewer')
->projection('detail',
fields: ['id', 'title', 'requester', 'priority', 'status', 'resolved_at', 'created_at', 'updated_at'],
actions: ['start', 'resolve', 'close'],
role: 'ticket.viewer');
}
}

Vérifier qu'il compile

Avant de persister quoi que ce soit, confirmez que le plugin compile proprement. Créez compile-check.php à la racine du projet :

<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Ausus\Application;
use Helpdesk\TicketSystem;

// boot() compiles the plugin into a MetadataGraph and wires the runtime.
$app = Application::create([
'tenant' => 'helpdesk',
'roles' => ['ticket.agent', 'ticket.viewer'],
])->register(new TicketSystem())->boot();

$graph = $app->graph();
echo 'graph hash : ', substr($graph->hash, 0, 16), "…\n";
echo 'entities : ', count($graph->entities), "\n";
echo 'actions : ', count($graph->actions), "\n";
echo 'workflows : ', count($graph->workflows), "\n";
echo "plugin compiles cleanly.\n";

Exécutez-le :

php compile-check.php

Sortie attendue :

graph hash : 7c1e9b3a0f4d2e88…
entities : 1
actions : 4
workflows : 1
plugin compiles cleanly.

Le hash exact différera ; les comptes doivent correspondre. Si vous voyez plutôt une RuntimeException, lisez son message — le DSL valide votre déclaration et nomme le problème (un champ mal orthographié, un mauvais état de workflow, …). Vous pouvez supprimer compile-check.php une fois qu'il passe.

Ce que vous avez maintenant

ticket-system/
├── composer.json
├── src/
│ └── TicketSystem.php ← tout le domaine
└── vendor/

Le domaine existe, mais aucune donnée n'existe. Dans la partie suivante, vous donnez une base de données à l'application.

Suivant : Partie 3 — Persistance.