소스 정보
- 저장소
- hpsgd/turtlestack
- 최근 소스 활동
- 2026년 4월 29일 09:33
- 감지된 SKILL.md 언어
- 영어
- 스타
- 0
- 포크
- 0
설치 방법
기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.
소스 파일 검토
설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.
메뉴
기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.
설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.
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.