gRPC Write Path

How gRPC handlers execute domain commands, with error handling boundaries and helper conventions. Prerequisite: Event-driven system.

Handler to domain — execution flow

gRPC handler
  │
  ├─ build command (commandbuild.Core / commandbuild.System)
  ├─ choose Options (empty or preset)
  │
  └─ handler.ExecuteAndApplyDomainCommand(ctx, deps, applier, cmd, options)
       │
       ├─ domainwrite.NormalizeDomainWriteOptions(ctx, &options, config)  ← inject gRPC-aware error handlers
       │
       └─ WriteRuntime.ExecuteAndApply(ctx, domain, applier, cmd, options)
            │
            ├─ domain.Execute(cmd)            ← engine: validate → gate → load → decide → append → fold
            ├─ intent filter (ShouldApply)    ← skip audit-only/replay-only events
            └─ applier.Apply(event)           ← inline projection (if enabled)

Command-time mutation decisions replay from journal truth on every execution. The write path does not reuse process-local replay checkpoints or snapshots for command-state reconstruction.

Two execution helpers

Helper Inline projection Use when
handler.ExecuteAndApplyDomainCommand Yes Default. Handler needs read-after-write consistency
handler.ExecuteWithoutInlineApply No Outbox pattern or fire-and-forget writes

Both normalize options via NormalizeDomainWriteOptions and wrap final errors via grpcerror.EnsureStatus; the only difference is whether events apply inline.

Error handling boundaries

The design keeps domain logic transport-agnostic. Error mapping happens at two boundaries:

1. NormalizeDomainWriteOptions — injected error handlers

Sets three error handlers on Options if the caller didn’t provide custom ones:

Handler Wraps Default gRPC code
ExecuteErr Engine execution failures codes.Internal
ApplyErr Projection apply failures codes.Internal
RejectErr Domain rejections (business rule violations) codes.FailedPrecondition

These fire inside WriteRuntime.ExecuteAndApply.

2. grpcerror.EnsureStatus — final error wrapper

Catches any error that escapes without a gRPC status:

  1. Already a gRPC status → pass through.
  2. Domain error (apperrors.GetCode != CodeUnknown) → HandleDomainError maps to semantic gRPC code (NotFound, InvalidArgument, FailedPrecondition, etc.).
  3. Unknown error → codes.Internal.

3. grpcerror.HandleDomainError / HandleDomainErrorLocale — domain code mapping

Delegates to apperrors.HandleError(err, locale), which maps domain error codes to gRPC codes with i18n-ready structured error details. HandleDomainErrorLocale accepts an explicit locale; HandleDomainError uses DefaultLocale. The ErrorConversionUnaryInterceptor uses HandleDomainErrorLocale with the caller’s locale from request metadata.

Options type

type Options struct {
    RequireEvents      bool              // Reject if no events emitted
    MissingEventMsg    string            // Error message when RequireEvents fails
    ExecuteErr         func(error) error // Custom executor error wrapper
    ApplyErr           func(error) error // Custom applier error wrapper
    RejectErr          func(code, message string) error // Custom rejection wrapper (code enables i18n lookup)
    ExecuteErrMessage  string            // Fallback message for ExecuteErr
    ApplyErrMessage    string            // Fallback message for ApplyErr
}

Presets

  • domainwrite.RequireEvents(msg) — command must emit at least one event.
  • domainwrite.RequireEventsWithDiagnostics(msg, applyMsg) — same, with custom diagnostic messages.
  • domainwrite.Options{} (empty) — zero events allowed, default messages.

Intent filtering

WriteRuntime holds an intent filter built from the event registry. During inline apply, each event is checked:

  • IntentProjectionAndReplay → applied to projections.
  • IntentReplayOnly → skipped (affects aggregate state only).
  • IntentAuditOnly → skipped (journal-only).

This ensures projection appliers only process events they are responsible for.

Historical event import

Normal write handlers must execute domain commands through the helpers above. The one sanctioned non-command journal writer is the centralized historical import seam under internal/services/game/api/grpc/internal/journalimport/. It exists for already-authoritative history copy/import flows such as campaign fork replay; transports must not append imported events directly.

Startup store wiring contracts

Startup wires projection bundles with game.NewStoresFromProjection(...) and gameplaystores.NewFromProjection(...) instead of manually assigning every store field in bootstrap wiring. Shared transport error/default behavior is centralized under internal/services/game/api/grpc/internal/grpcerror.

Typical handler pattern

func (a *application) DoSomething(ctx context.Context, in *pb.Request) (*pb.Response, error) {
    if in == nil {
        return nil, status.Error(codes.InvalidArgument, "request is required")
    }
    campaignID := in.GetCampaignId()
    if campaignID == "" {
        return nil, status.Error(codes.InvalidArgument, "campaign_id is required")
    }

    cmd := commandbuild.Core(commandbuild.CoreInput{
        CampaignID:  campaignID,
        Type:        commandType,
        PayloadJSON: payloadJSON,
        // ...
    })

    _, err := handler.ExecuteAndApplyDomainCommand(ctx, a.deps, applier, cmd, domainwrite.Options{})
    if err != nil {
        return nil, err // already gRPC-wrapped
    }

    return &pb.Response{}, nil
}

Key conventions:

  • Validate request fields before building commands (return codes.InvalidArgument).
  • The returned error from ExecuteAndApplyDomainCommand is already gRPC-status-wrapped.
  • Domain errors from store lookups outside the command path are handled by the ErrorConversionUnaryInterceptor.

Adding a new write handler

  1. Define your command type and event types in the domain layer.
  2. Write a handler following the pattern above.
  3. Choose handler.ExecuteAndApplyDomainCommand (default) or handler.ExecuteWithoutInlineApply.
  4. Use domainwrite.Options{} or a preset — custom error handlers are rarely needed.
  5. Domain-operation errors are caught by the ErrorConversionUnaryInterceptor; command-path errors flow through the options handlers and grpcerror.EnsureStatus.