用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
直接命令不会经过审查 Prompt;运行前请先检查来源。
npx skills add https://github.com/hpsgd/turtlestack --skill write-aggregate命令会保持在同一行。复制前请横向滚动并检查完整内容。
想先保存到本地?可下载 SkillsMP 当前能够提供的文件。
Review staged or recent changes — native Claude Code review for mechanics, layered with team conventions and the team verdict contract
Perform a security-focused audit of code changes or a specific area of the codebase.
Propose a change to a marketplace repo based on learned patterns — new rules, updated skills, evolved regex patterns. Infers which upstream marketplace the learning belongs to, confirms with the user, then creates a branch, applies changes, shows diff for review, and raises a PR on approval. Use when patterns have enough evidence to share upstream.
基于 SOC 职业分类
正在显示 SKILL.md
| name | write-aggregate |
| description | Write an event-sourced aggregate using EventSauce — aggregate class, domain events, and state replay. |
| argument-hint | [aggregate description, e.g. 'Crawl with start and complete'] |
| user-invocable | true |
| allowed-tools | Read, Write, Edit, Bash, Glob, Grep |
| paths | ["**/*.php"] |
Write an event-sourced aggregate for $ARGUMENTS.
Before writing the aggregate:
Read existing aggregates — match the project's patterns:
grep -rn "AggregateRoot\|AggregateRootBehaviour" --include="*.php" src/ | head -20
Confirm the event-sourcing library — EventSauce is the recommended default. If patchlevel/event-sourcing is in use, follow its conventions instead (the structure is similar but the trait and method names differ)
Identify the bounded context — which directory under src/Domain/ does this aggregate belong to? If unclear, STOP and consult the architect
Identify the events — what state changes does the aggregate emit? List them before writing the class. Each event corresponds to a fact that happened, in past tense
Identify the invariants — what must always be true? Examples: a completed crawl cannot be completed again; a page cannot be extracted before the crawl starts
The aggregate ID is a typed value object, not a raw string. Use UUID v7 — time-ordered, sorts chronologically, which matters for event-store queries.
<?php
declare(strict_types=1);
namespace App\Domain\Crawl;
use EventSauce\EventSourcing\AggregateRootId;
use Ramsey\Uuid\Uuid;
use Webmozart\Assert\Assert;
final readonly class CrawlId implements AggregateRootId
{
public function __construct(public string $value)
{
Assert::uuid($value);
}
public static function generate(): self
{
return new self(Uuid::uuid7()->toString());
}
public static function fromString(string ):
{
();
}
{
->value;
}
}
Events are final readonly class. They represent facts that happened, named in past tense (CrawlStarted, not StartCrawl). Constructor-promoted, immutable.
<?php
declare(strict_types=1);
namespace App\Domain\Crawl\Event;
use App\Domain\Crawl\CrawlId;
use App\Domain\Crawl\PageId;
use EventSauce\EventSourcing\Serialization\SerializablePayload;
final readonly class CrawlStarted implements SerializablePayload
{
/**
* @param list<PageId> $pageIds
*/
public function __construct(
public CrawlId $crawlId,
public array $pageIds,
public \DateTimeImmutable $startedAt,
) {}
/**
* @return array<string, mixed>
*/
public function toPayload(): array
{
return [
'crawl_id' => $this->crawlId->(),
=> ( fn (PageId ): => ->(), ->pageIds),
=> ->startedAt->(::),
];
}
{
(([]));
(([]));
(([]));
(
crawlId: ::([]),
pageIds: (
fn ( ): PageId => ::(() ),
[],
),
startedAt: ([]),
);
}
}
Event rules:
CrawlStarted, CrawlCompleted, PageExtractedsrc/Domain/<Context>/Event/final readonly class with constructor promotionSerializablePayload (EventSauce) — defines toPayload() / fromPayload()snake_case (matches JSON conventions)Invariant violations throw domain exceptions, never raw \RuntimeException.
<?php
declare(strict_types=1);
namespace App\Domain\Crawl\Exception;
use App\Domain\Crawl\CrawlId;
use App\Domain\DomainException;
final class CrawlAlreadyCompleted extends DomainException
{
public function __construct(public readonly CrawlId $crawlId)
{
parent::__construct("Crawl {$crawlId->toString()} is already completed");
}
}
<?php
declare(strict_types=1);
namespace App\Domain\Crawl;
use App\Domain\Crawl\Event\CrawlCompleted;
use App\Domain\Crawl\Event\CrawlStarted;
use App\Domain\Crawl\Exception\CrawlAlreadyCompleted;
use EventSauce\EventSourcing\AggregateRoot;
use EventSauce\EventSourcing\AggregateRootBehaviour;
final class CrawlAggregate implements AggregateRoot
{
use AggregateRootBehaviour;
/** @var list<PageId> */
private array $pageIds = [];
private bool $completed = false;
/**
* @param list<PageId> $pageIds
*/
public static function ():
{
= ();
->( (, , ()));
;
}
{
(->completed) {
= ->();
();
}
->( ( ()));
}
{
->pageIds = ->pageIds;
}
{
->completed = ;
}
}
Aggregate rules:
final class with AggregateRootBehaviour traitstart, register, open) — never new directlycomplete, cancel, assignTo) — express what the aggregate does, not settersrecordThat() + apply*() — NEVER direct field mutation. If you do $this->completed = true; outside applyCrawlCompleted(), the change is lost on event replayrecordThat() — throw a domain exception if violatedapply*() methods are protected, take a single event argument, mutate state, return voidapply*()<?php
declare(strict_types=1);
namespace App\Tests\Unit\Domain\Crawl;
use App\Domain\Crawl\CrawlAggregate;
use App\Domain\Crawl\CrawlId;
use App\Domain\Crawl\Event\CrawlCompleted;
use App\Domain\Crawl\Event\CrawlStarted;
use App\Domain\Crawl\Exception\CrawlAlreadyCompleted;
use App\Domain\Crawl\PageId;
it('records CrawlStarted when starting', function (): void {
$id = CrawlId::generate();
$pages = [PageId::generate(), PageId::generate()];
$crawl = CrawlAggregate::(, );
(->())
->()
->{}->(::);
});
(, function (): {
= ::(::(), [::()]);
->();
->();
(->())
->()
->{}->(::);
});
(, function (): {
= ::(::(), [::()]);
->();
->();
->();
})->(::);
<?php
declare(strict_types=1);
namespace App\Tests\Integration\Domain\Crawl;
use App\Domain\Crawl\CrawlAggregate;
use App\Domain\Crawl\CrawlId;
use App\Domain\Crawl\PageId;
use EventSauce\EventSourcing\AggregateRootRepository;
it('rehydrates the aggregate from the event stream', function (AggregateRootRepository $repo): void {
$id = CrawlId::generate();
$crawl = CrawlAggregate::start($id, [PageId::generate()]);
$crawl->complete();
$repo->persist($crawl);
/** @var CrawlAggregate $rehydrated */
$rehydrated = $repo->retrieve($id);
(fn () => ->())
->(::);
})->();
Test rules:
Aggregates that accumulate many events (subscriptions billed monthly for years, accounts with thousands of transactions, long-running workflows) become expensive to rehydrate as the stream grows. EventSauce's SnapshotRepository is the standard fix — periodically persist the aggregate's state, and on rehydration load the snapshot plus only events recorded since.
// Wire snapshotting in the aggregate repository factory
$snapshotRepository = new ConstructingSnapshotRepository(
aggregateRootClass: CrawlAggregate::class,
snapshotStateSerializer: new ConstructingSnapshotStateSerializer(),
snapshotMessageStorage: $dbalSnapshotStorage,
);
$aggregateRepository = new EventSourcedAggregateRootRepositoryWithSnapshotting(
aggregateRootClass: CrawlAggregate::class,
messageRepository: $messageRepository,
snapshotRepository: $snapshotRepository,
dispatcher: $dispatcher,
);
Snapshot rules:
Decide upfront whether this aggregate needs snapshots. If unsure, document the expected event count per aggregate lifetime in the aggregate's docblock and revisit when the count exceeds 100.
apply*() — the change is invisible on replay. Always go through recordThat()complete, assignTo), not property settersextends from frameworks in the domain — the aggregate uses the EventSauce trait but does not depend on framework code beyond the AggregateRoot interface. No Doctrine, no Symfony in the domain layerCrawlId, not string $id)CrawlCompleting or CrawlWillComplete are wrong. Past tense onlycomplete() must throw before recording if the crawl is already complete. Recording an event that should not have happened poisons the streamDeliver:
<Name>Id implements AggregateRootId)final readonly class ... implements SerializablePayload)final class ... implements AggregateRoot using AggregateRootBehaviour)vendor/bin/pest, exit code)/php-developer:write-feature-spec — write the Behat scenario before this aggregate. The scenario specifies the behaviour; the aggregate satisfies it/php-developer:write-handler — once the aggregate exists, the handler dispatches commands to it via the message bus