用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
直接命令不会经过审查 Prompt;运行前请先检查来源。
npx skills add https://github.com/hpsgd/turtlestack --skill write-handler命令会保持在同一行。复制前请横向滚动并检查完整内容。
想先保存到本地?可下载 SkillsMP 当前能够提供的文件。
基于 SOC 职业分类
正在显示 SKILL.md
| name | write-handler |
| description | Write a Wolverine command handler with aggregate loading and cascading messages. |
| argument-hint | [handler description, e.g. 'TriggerCrawlExtraction'] |
| user-invocable | true |
| allowed-tools | Read, Write, Edit, Bash, Glob, Grep |
| paths | ["**/*.cs"] |
Write a Wolverine handler for $ARGUMENTS.
Before writing the handler:
Read existing handlers — match the project's patterns:
grep -rn "AggregateHandler\|public static.*Handle(" --include="*.cs" | head -20
Identify the aggregate — which Marten aggregate does this handler operate on?
Identify the message — what command triggers this handler?
Identify the side effects — what downstream messages should cascade?
Check for existing messages — reuse existing command/event types where appropriate
Choose the correct handler pattern based on what the handler does:
| Pattern | When to use | Aggregate loading |
|---|---|---|
[AggregateHandler] with aggregate parameter | Handler operates on a Marten event-sourced aggregate | Automatic — Wolverine loads by convention |
Static handler with IDocumentSession | Handler operates on document store data | Manual — load in handler |
| Static handler with external service | Handler calls external APIs or infrastructure | No aggregate — orchestration only |
| Handler returning cascading messages | Handler triggers downstream work | Any of the above + return type |
Event-sourced aggregates evolve their state by emitting events, not mutating fields. Return the event(s) from Handle — Marten appends them to the aggregate stream and Apply methods on the aggregate fold them into state.
[AggregateHandler]
public static class CompleteCrawlHandler
{
public static (CrawlCompleted, IEnumerable<ExtractPage>) Handle(
CompleteCrawl command,
Crawl crawl)
{
// Emit the domain event — Marten appends it to the stream.
// Do NOT mutate crawl.Status / crawl.CompletedAt directly.
var completed = new CrawlCompleted(crawl.Id, DateTimeOffset.UtcNow);
// Fan out — one ExtractPage per page, each its own unit of work
var extractions = crawl.Pages.Select(p => new ExtractPage(crawl.Id, p.Id, p.Url));
return (completed, extractions);
}
}
Event-sourced state changes:
Handle — Wolverine + Marten append it to the stream automatically. The aggregate's Apply(CrawlCompleted) method updates state on rehydrationcrawl.Status = ...) — the change won't be persisted as an event and is invisible on replaysession.Store(crawl) after explicit field changes — but this is the document path, not the event-sourced pathsession.Events.Append(streamId, @event) and let IDocumentSession flush[AggregateHandler] rules:
Id property (or {AggregateName}Id)Id property (or a property named {AggregateName}Id) that maps to the aggregate identityrecord types with immutable properties — never classes with settersCascading returns are how handlers trigger downstream work. The return value of Handle is automatically published as a message.
// Single cascade — return one event (Marten appends it to the stream)
public static CrawlCompleted Handle(CompleteCrawl command, Crawl crawl)
{
return new CrawlCompleted(crawl.Id, DateTimeOffset.UtcNow);
}
// Multiple cascades — return a tuple of event + message
public static (CrawlCompleted, NotifySourceOwner) Handle(CompleteCrawl command, Crawl crawl)
{
return (
new CrawlCompleted(crawl.Id, DateTimeOffset.UtcNow),
new NotifySourceOwner(crawl.SourceId, $"Crawl {crawl.Id} completed")
);
}
// Polymorphic cascade — return object? for branching
public static object? Handle(ProcessCrawlResult command, Crawl crawl)
{
return command.Success
? new CrawlCompleted(crawl.Id)
: new CrawlFailed(crawl.Id, command.Error);
}
// No cascade — return void or null
public static void Handle(LogCrawlMetrics command, ILogger logger)
{
logger.LogInformation("Crawl {CrawlId} processed {Pages} pages", command.CrawlId, command.PageCount);
// No return — fire and forget
}
// Fan-out — return IEnumerable for N cascading messages
public static IEnumerable<ExtractPage> Handle(
ExtractCrawlPages command,
Crawl crawl)
{
// ONE message per page — not one handler processing N pages inline
command.PageIds.Select(pageId => ExtractPage(crawl.Id, pageId));
}
Cascading rules:
object?, IEnumerable<T>, or voidIEnumerable<T> and let each item process independentlynull (with object? return type) to skip cascade — no downstream work neededThis is the most important rule in Wolverine handler design.
// WRONG — processing N items inline
public static async Task Handle(ProcessAllPages command, IDocumentSession session)
{
var pages = await session.Query<Page>()
.Where(p => p.CrawlId == command.CrawlId)
.ToListAsync();
foreach (var page in pages) // BAD: if page 47 fails, pages 1-46 are lost
{
await ExtractContent(page);
session.Store(page);
}
}
// CORRECT — fan out to individual handlers
public static IEnumerable<ExtractPage> Handle(
ExtractCrawlPages command,
Crawl crawl)
{
return crawl.Pages.Select(p => new ExtractPage(crawl.Id, p.Id));
}
// Each page is an independent unit of work
[AggregateHandler]
public static class ExtractPageHandler
{
public static PageExtracted Handle(ExtractPage command, Page page)
{
page.Content = ExtractContent(page.Html);
return new PageExtracted(page.Id);
}
}
Why:
// CORRECT — managed session via dependency injection
public static async Task Handle(
MyCommand command,
IDocumentSession session, // Wolverine manages the session lifecycle
CancellationToken ct)
{
var entity = await session.LoadAsync<MyEntity>(command.Id, ct);
entity.Update(command);
session.Store(entity);
// Wolverine calls SaveChangesAsync automatically
}
// WRONG — creating your own session
public static async Task Handle(
MyCommand command,
IDocumentStore store) // BAD: manual session management
{
await using var session = store.LightweightSession(); // NOT managed by Wolverine
// ...
await session.SaveChangesAsync(); // Manual save — bypasses Wolverine's unit of work
}
Session rules:
IDocumentSession — never create sessions from IDocumentStoreSaveChangesAsync after Handle succeedsIQuerySession (read-only) if the handler only reads dataSaveChangesAsync manually — Wolverine does it. Calling it yourself causes double-save// Non-fatal errors: catch, log, continue pipeline
public static object? Handle(
ProcessExternalData command,
ILogger logger)
{
try
{
var result = ParseExternalPayload(command.Payload);
return new DataProcessed(result);
}
catch (FormatException ex)
{
// Non-fatal: log and skip. Don't crash the pipeline
logger.LogWarning(ex, "Failed to parse payload for {CommandId}", command.Id);
return null; // No cascade — this item is skipped
}
}
// Fatal errors: let them propagate — Wolverine handles retry/dead-letter
public static CrawlCompleted Handle(CompleteCrawl command, Crawl crawl)
{
// No try/catch — if this throws, Wolverine retries per policy
crawl.Complete();
return new CrawlCompleted(crawl.Id);
}
Error handling rules:
// External dependencies: constructor injection on the handler class
public class NotifyExternalServiceHandler
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<NotifyExternalServiceHandler> _logger;
public NotifyExternalServiceHandler(
IHttpClientFactory httpClientFactory,
ILogger<NotifyExternalServiceHandler> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task<object?> Handle(
NotifyExternalService command,
CancellationToken ct)
{
var client = _httpClientFactory.CreateClient("external");
var response = await client.PostAsJsonAsync("/webhook", command, ct);
return response.IsSuccessStatusCode
? new ExternalServiceNotified(command.Id)
: null; // Will be retried by Wolverine
}
}
Rules:
[AggregateHandler] handlers are typically static — inject IDocumentSession as a method parameterIHttpClientFactory for HTTP clients — never new HttpClient()For event-sourced handlers, assert on the events returned by Handle (the cascade) — not on persisted state. Persistence is Marten's job and belongs in integration tests.
public class WhenCompletingACrawl
{
[Fact]
public void it_emits_crawl_completed_and_one_extract_per_page()
{
// Arrange
var crawl = CrawlFactory.Create(pageCount: 3);
var command = new CompleteCrawl(crawl.Id);
// Act
var (completed, extractions) = CompleteCrawlHandler.Handle(command, crawl);
// Assert — on the events, not on mutated state
completed.ShouldBeOfType<CrawlCompleted>();
completed.CrawlId.ShouldBe(crawl.Id);
completed.CompletedAt.ShouldBeGreaterThan(DateTimeOffset.MinValue);
var pages = extractions.ToList();
pages.Count.ShouldBe(3);
pages.ShouldAllBe(p => p.CrawlId == crawl.Id);
}
}
Verify both the resulting aggregate state and the cascading messages published to the bus. Use Wolverine's TrackedSession (via Host.TrackActivity().InvokeMessageAndWaitAsync(...)) to capture published messages.
public class CompleteCrawlIntegrationTest : IntegrationContext
{
[Fact]
public async Task it_completes_the_crawl_and_publishes_one_extract_per_page()
{
// Arrange — seed the aggregate via its event stream
var crawlId = Guid.NewGuid();
await using var session = Store.LightweightSession();
session.Events.StartStream<Crawl>(crawlId, new CrawlStarted(crawlId, pages: 3));
await session.SaveChangesAsync();
// Act — track the session so we can assert on cascading messages
var tracked = await Host
.TrackActivity()
.IncludeExternalTransports()
.InvokeMessageAndWaitAsync(new CompleteCrawl(crawlId));
// Assert — aggregate state
var crawl = await session.Events.AggregateStreamAsync<Crawl>(crawlId);
crawl!.Status.ShouldBe(CrawlStatus.Completed);
crawl.CompletedAt.ShouldNotBeNull();
// Assert — one ExtractPage per page was published
var extracts = tracked.Sent.MessagesOf<ExtractPage>().ToList();
extracts.Count.ShouldBe(3);
extracts.ShouldAllBe(e => e.CrawlId == crawlId);
}
}
IEnumerable<T> insteadstore.LightweightSession() bypasses Wolverine's unit of workDbException and logging it defeats the retry pipeline[AggregateHandler] can't load without an Id property matching the aggregatecrawl.Status = ... on an event-sourced aggregate is silently lost. Return events from Handle and let Apply methods fold staterecord types, not classes with settersDeliver:
/dotnet-developer:write-endpoint — handlers are invoked by endpoints. If the handler needs a new HTTP entry point, create the endpoint first.