.claude/skills/do-integrate/SKILL.md
Wire a domain into the server — API handler, outbox, and registration
npx skillsauth add viqueen/claude-go-playground .claude/skills/do-integrateInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Wire a domain into the server — API handler, outbox workers, and cmd/server registration. This PR is auditable as: "Is this wired correctly?"
Depends on: do-domain agent PR (internal/domain/<domain>/ must exist).
The user will specify:
content)connect-rpc-backend or grpc-backendAll file paths below are relative to the chosen project folder.
All make commands must be run from the project root.
The framework is determined by the project:
connect-rpc-backend → Connect-RPC (uses connectutil, connectapp, connect.Request wrappers)grpc-backend → gRPC (uses grpcutil, grpcapp, direct proto messages, Unimplemented*Server embedding)internal/api/<domain>/v1/handler.goPrivate struct implementing the generated service interface.
The Go package name must be api<domain><version> (e.g., apicontentv1 for internal/api/content/v1/).
Import aliases must follow a consistent naming convention.
package apicontentv1
import (
"connectrpc.com/connect"
contentv1 "<module>/gen/sdk/content/v1"
contentv1connect "<module>/gen/sdk/content/v1/contentv1connect"
dbcontent "<module>/gen/db/content"
contentdomain "<module>/internal/domain/content"
"<module>/pkg/connectutil"
)
// Dependencies defines the dependencies for the content API handler.
type Dependencies struct {
Service contentdomain.Service
}
// New returns the Connect-generated handler interface. Struct is private.
func New(deps Dependencies) contentv1connect.ContentServiceHandler {
return &handler{service: deps.Service}
}
type handler struct {
service contentdomain.Service
}
var errorMappings = map[error]connect.Code{
contentdomain.ErrNotFound: connect.CodeNotFound,
contentdomain.ErrAlreadyExists: connect.CodeAlreadyExists,
}
Import alias conventions (Connect-RPC):
<domain>v1 for proto types: contentv1 "<module>/gen/sdk/content/v1"<domain>v1connect for connect service: contentv1connect "<module>/gen/sdk/content/v1/contentv1connect"db<domain> for sqlc types: dbcontent "<module>/gen/db/content"<domain>domain for domain service: contentdomain "<module>/internal/domain/content"package apicontentv1
import (
contentv1 "<module>/gen/sdk/content/v1"
contentv1grpc "<module>/gen/sdk/content/v1/contentv1grpc"
dbcontent "<module>/gen/db/content"
contentdomain "<module>/internal/domain/content"
"<module>/pkg/grpcutil"
"google.golang.org/grpc/codes"
)
// Dependencies defines the dependencies for the content API handler.
type Dependencies struct {
Service contentdomain.Service
}
// New returns the gRPC-generated service server. Struct is private.
func New(deps Dependencies) contentv1grpc.ContentServiceServer {
return &handler{service: deps.Service}
}
type handler struct {
contentv1grpc.UnimplementedContentServiceServer
service contentdomain.Service
}
var errorMappings = map[error]codes.Code{
contentdomain.ErrNotFound: codes.NotFound,
contentdomain.ErrAlreadyExists: codes.AlreadyExists,
}
Import alias conventions (gRPC):
<domain>v1 for proto types: contentv1 "<module>/gen/sdk/content/v1"<domain>v1grpc for gRPC service: contentv1grpc "<module>/gen/sdk/content/v1/contentv1grpc"db<domain> for sqlc types: dbcontent "<module>/gen/db/content"<domain>domain for domain service: contentdomain "<module>/internal/domain/content"Note: gRPC handlers embed Unimplemented<Service>Server for forward compatibility.
internal/api/<domain>/v1/mapper.goMapping functions between proto types and sqlc models:
toProto(model) *proto.Resource — sqlc model → proto responsefromProtoCreate(msg) sqlcparams — proto create request → sqlc create paramsvalidateUpdateMask(paths) error — validates update_mask paths are non-empty and supportedfromProtoUpdate(msg) sqlcparams — proto update request → sqlc update params
pgtype.Text{String: val, Valid: true} for nullable string fields from sqlc.narg()pgtype.Int4{Int32: val, Valid: true} for nullable int fieldsThe validateUpdateMask function must:
paths with an errorsupportedUpdatePaths map as package-level var listing all valid pathsinternal/api/<domain>/v1/route_<rpc>.go — One file per RPCEach file contains a single method on the handler:
func (h *handler) Create<Resource>(
ctx context.Context,
req *connect.Request[<domain>v1.Create<Resource>Request],
) (*connect.Response[<domain>v1.Create<Resource>Response], error) {
result, err := h.service.Create(ctx, fromProtoCreate(req.Msg))
if err != nil {
return nil, connectutil.NewErrorFrom(err, errorMappings)
}
return connect.NewResponse(&<domain>v1.Create<Resource>Response{
<Resource>: toProto(result),
}), nil
}
func (h *handler) Create<Resource>(
ctx context.Context,
req *<domain>v1.Create<Resource>Request,
) (*<domain>v1.Create<Resource>Response, error) {
result, err := h.service.Create(ctx, fromProtoCreate(req))
if err != nil {
return nil, grpcutil.NewErrorFrom(err, errorMappings)
}
return &<domain>v1.Create<Resource>Response{
<Resource>: toProto(result),
}, nil
}
Key differences: gRPC handlers take proto messages directly (no connect.Request wrapper)
and return proto messages directly (no connect.Response wrapper).
codes.InvalidArgument (or connect.CodeInvalidArgument) directly via status.Errorf — do NOT pass through errorMappings (which would fall through to codes.Internal)id and a nested resource id (e.g., req.id and req.space.id), reject with InvalidArgument when they differvalidateUpdateMask() before fromProtoUpdate() — reject empty or unsupported paths with InvalidArgumentgrpcutil.NewErrorFrom(err, errorMappings) or connectutil.NewErrorFrom(err, errorMappings) for errors from the service layerinternal/outbox/river.go — Update event mappingIf this is the first domain, create internal/outbox/river.go with the NewRiverOutbox constructor
and mapEvent switch. If it already exists, add cases for the new domain's event types.
package outbox
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/riverqueue/river"
contentdomain "<module>/internal/domain/content"
contentevents "<module>/internal/outbox/content"
"<module>/pkg/outbox"
)
func NewRiverOutbox(client *river.Client[pgx.Tx]) outbox.Outbox[pgx.Tx] {
return &riverOutbox{client: client}
}
type riverOutbox struct {
client *river.Client[pgx.Tx]
}
func (o *riverOutbox) Emit(ctx context.Context, tx pgx.Tx, events ...outbox.Event) error {
for _, event := range events {
jobs, err := o.mapEvent(event)
if err != nil {
return err
}
for _, args := range jobs {
if _, err := o.client.InsertTx(ctx, tx, args, nil); err != nil {
return err
}
}
}
return nil
}
// mapEvent fans out a domain event into one or more river jobs.
func (o *riverOutbox) mapEvent(event outbox.Event) ([]river.JobArgs, error) {
switch event.Type {
case contentdomain.EventCreated:
return []river.JobArgs{
contentevents.NewIndexArgs(event),
contentevents.NewAuditArgs(event),
}, nil
case contentdomain.EventUpdated:
return []river.JobArgs{
contentevents.NewIndexArgs(event),
contentevents.NewAuditArgs(event),
}, nil
case contentdomain.EventDeleted:
return []river.JobArgs{
contentevents.NewIndexArgs(event),
contentevents.NewAuditArgs(event),
}, nil
default:
return nil, fmt.Errorf("unknown event type: %s", event.Type)
}
}
Key rules:
<domain>domain.Event* constants from internal/domain/<domain>/events.go — never hardcode event type stringsImport alias conventions for outbox:
<domain>domain for event constants: contentdomain "<module>/internal/domain/content"<domain>events for domain event workers: contentevents "<module>/internal/outbox/content"internal/outbox/<domain>/event_<concern>.go — One file per concernEach file contains river JobArgs + Worker for a specific concern:
event_index.go — indexing concernevent_audit.go — auditing concernWorkers must accept ctx context.Context (not _) and use log.Ctx(ctx) for context-aware logging, even if the current implementation is a stub.
cmd/server/ — Wiring updatesUpdate the setup files to register the new domain:
setup_connections.go — register outbox workers with river (river.AddWorker)setup_domains.go — add domain to Domains struct, wire service with dependenciessetup_gateway.go:
connect.WithInterceptors, add path to muxapplication.Server() via generated Register<Service>Server()api<domain><version> (e.g., apicontentv1 for internal/api/content/v1/)internal/api/<domain>/v1/ mirrors the proto package <domain>.v1. When a v2 proto is introduced, handlers go under internal/api/<domain>/v2/.<domain>v1 (proto), <domain>v1connect or <domain>v1grpc (service), db<domain> (sqlc), <domain>domain (domain service), <domain>events (outbox events)route_<rpc>.go in api, event_<concern>.go in outboxhandler.go, used by all routes via connectutil.NewErrorFrom (Connect-RPC) or grpcutil.NewErrorFrom (gRPC)mapper.go, nowhere elseinternal/api/ can depend on: internal/domain/, gen/sdk/, gen/db/, pkg/connectutil or pkg/grpcutilinternal/outbox/river.go can depend on: internal/domain/ (for event constants only), internal/outbox/<domain>/, pkg/outbox, riverinternal/outbox/<domain>/ can depend on: pkg/outbox, river — must NOT import internal/domain/ or internal/api/cmd/ wires everything togethermake vet — fix all compilation errorsmake build — confirm Docker build worksmake start — starts infra + server via air, confirm /health returns 200make teardown — stops infraapi<domain><version> (e.g., apicontentv1)<domain>v1, <domain>v1connect, db<domain>, <domain>domain, <domain>eventshandler.go with Dependencies, constructor, error mappingsmapper.go with toProto + fromProtoCreate + validateUpdateMask + fromProtoUpdateroute_*.go per RPC methodInvalidArgument directly (not via errorMappings)internal/outbox/river.go updated with new event types using domain constantsevent_*.go per outbox concern, workers accept ctxsetup_connections.go registers new workerssetup_domains.go wires new domain servicesetup_gateway.go registers new handler + reflectionmake vet passesmake build succeedsmake start boots with /health returning 200testing
Review a test PR
tools
Review a search indexing PR
tools
Review a scaffold PR
tools
Review a proto PR