一键导入
service-layer
Business logic behind an interface — Service interface in domain types, Deps struct for collaborators, sentinel errors at the storage edge.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Business logic behind an interface — Service interface in domain types, Deps struct for collaborators, sentinel errors at the storage edge.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Visual-design discipline for forge frontends — brief the work before building (never invent an aesthetic without user input), declare the design system before coding, lean on the component library, use restrained color/type systems, never hand-draw complex SVGs, and verify visually before declaring done.
Write Next.js frontends — generated hooks, component library, Tailwind v4, visual verification, and Connect RPC clients.
Outbound boundary translators. One adapter per third-party system / queue / storage backend; narrow interface, vendor-neutral callers.
Write Connect RPC handlers — proto-driven codegen, the thin-translation handler pattern (validate, extract auth, convert proto↔internal, call service, wrap errors via `svcerr.Wrap`), middleware, and testing. Business logic lives in `internal/handlers/<svc>/contract.go`, never in handlers.
Forge project conventions and architecture — project structure, generated vs hand-written code, the generate pipeline, proto annotations, contracts, wiring, and naming.
Use-case orchestrators that compose two or more adapters/services. Deps are interfaces only — designed for unit tests with all-mock collaborators.
| name | service-layer |
| description | Business logic behind an interface — Service interface in domain types, Deps struct for collaborators, sentinel errors at the storage edge. |
| emit | both |
The service layer is where your application's business logic lives, behind an interface defined in domain terms. The handler/transport layer is wire glue on top; the storage layer is data plumbing underneath. The service is the actual app.
contract.go (or your equivalent boundary file) declares what the package does, in the language of the domain — not in wire types, not in DB row types.
type Service interface {
DoThing(ctx context.Context, in DoThingInput) (DoThingResult, error)
GetThing(ctx context.Context, id string) (Thing, error)
ListThings(ctx context.Context, in ListThingsInput) (ListThingsResult, error)
}
type Thing struct {
ID string
Name string
OwnerID string
CreatedAt time.Time
UpdatedAt time.Time
}
type DoThingInput struct {
UserID string
Name string
}
Rules that pay off immediately:
time.Time, plain string IDs. Wrap in wire-specific types (*timestamppb.Timestamp) at the handler boundary; convert to storage-specific types (pgtype.UUID) at the storage boundary.Service.) Use suffixed names (Storer, Authenticator) only for multi-implementation strategies where the suffix carries meaning.New(Deps) Service returning the Service interface is the deliberate exception — that interface is the real mock/test seam, not speculation. But collaborators in Deps follow the rule: each is a narrow interface naming only the methods this service calls, declared here at the consumer. A wide interface that each caller only uses a slice of is a smell — split it per-consumer.// Smell: a 20-method Repository, of which this service calls three.
// Better: depend on the slice you use, declared next to the service.
type Deps struct {
Things thingReader // narrow, consumer-declared — not the whole ORM surface
}
type thingReader interface {
GetThing(ctx context.Context, id string) (Thing, error)
InsertThing(ctx context.Context, t Thing) error
}
Behind the interface, the implementation type is unexported. Construction goes through a constructor that returns the interface.
type Deps struct {
DB *db.Queries
Now func() time.Time
NewID func() string
}
type svc struct {
deps Deps
}
func New(deps Deps) (Service, error) {
if deps.Now == nil { deps.Now = time.Now }
if deps.NewID == nil { deps.NewID = newULID }
return &svc{deps: deps}, nil
}
func (s *svc) DoThing(ctx context.Context, in DoThingInput) (DoThingResult, error) {
if in.Name == "" {
return DoThingResult{}, ValidationError{Field: "name", Reason: "required"}
}
row, err := s.deps.DB.InsertThing(ctx, &db.Thing{
ID: s.deps.NewID(),
Name: in.Name,
OwnerID: in.UserID,
CreatedAt: s.deps.Now(),
})
if err != nil {
if db.IsUniqueViolation(err) {
return DoThingResult{}, ErrAlreadyExists
}
return DoThingResult{}, fmt.Errorf("insert thing: %w", err)
}
return DoThingResult{ID: row.ID, CreatedAt: row.CreatedAt}, nil
}
Notes on the shape:
Deps struct, not positional args. Adding a dep doesn't churn every call site, and Deps{DB: ..., Now: time.Now} is self-documenting.time.Now and ID generators are the big two. Tests get deterministic timestamps and IDs without monkey-patching.Use a shared sentinel set across services — don't redeclare ErrNotFound / ErrAlreadyExists / ErrInvalidArgument / ErrPermissionDenied per package. The shared set is what lets the handler layer map a service error to a transport-layer status code uniformly.
// At the storage boundary, convert driver errors to sentinels:
if errors.Is(err, db.ErrNoRows) {
return Thing{}, ErrNotFound
}
return Thing{}, fmt.Errorf("get thing: %w", err)
For domain-specific structured detail (e.g., a validation failure naming the field), use a typed error AND wrap a sentinel so the handler-side mapping still works:
type ValidationError struct {
Field string
Reason string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("validation: %s: %s", e.Field, e.Reason)
}
// Unwrap to the sentinel so generic error handling still maps correctly.
func (e ValidationError) Unwrap() error { return ErrInvalidArgument }
Discipline:
fmt.Errorf("...: %w", err). Add context; never replace.ErrAlreadyExists; the no-rows sentinel becomes ErrNotFound.Unwrap() error returning the matching sentinel so generic handling still maps them correctly.The service is the natural unit-test boundary. Two patterns matter:
func TestThingsService_DoThing(t *testing.T) {
fixedTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
s, _ := things.New(things.Deps{
DB: fakedb.New(t),
Now: func() time.Time { return fixedTime },
NewID: func() string { return "thing_01" },
})
res, err := s.DoThing(ctx, things.DoThingInput{UserID: "u1", Name: "n"})
if err != nil {
t.Fatalf("unexpected: %v", err)
}
if res.ID != "thing_01" || !res.CreatedAt.Equal(fixedTime) {
t.Fatalf("got %+v", res)
}
}
For a domain with many methods, keep them in one Service interface as long as they all belong to the same domain. Splitting Service into Reader + Writer interfaces is a sometimes-useful refinement (callers that only read can depend on Reader), but start with one interface and split when a real call site needs the narrower one.
time.Now and ID generators so tests are deterministic.Unwrap() to a sentinel.%w; add context, never replace.Every service domain in a forge project lives in ONE internal/handlers/<svc>/ directory — owned and generated files co-located in a single component dir under the internal/handlers/ role subtree (no two-tier handlers/<svc>/ + internal/<svc>/ split):
internal/handlers/things/
contract.go # Service interface — the domain surface
service.go # implementation behind the interface
errors.go # domain-typed error sentinels and types
handlers_gen.go # generated Connect handlers (svcerr.Wrap)
internal/handlers/mocks/
things_mock.go # generated by `forge generate` — your test seam, free (package mocks)
forge generate reads contract.go, emits handlers_gen.go alongside your owned files, and writes the service mock to the shared internal/handlers/mocks/ directory (package mocks, one <svc>_mock.go per service).
Per-package observability wrappers (middleware_gen.go, tracing_gen.go, metrics_gen.go) were removed in 1.7. Logging, tracing, metrics, recovery, and request-id are applied uniformly at the Connect handler boundary via forge/pkg/observe interceptors (observe.Chain(observe.Deps{…}) in the generated cmd/<bin>/cmd/serve.go). Per-method opt-in helpers — observe.LogCall / observe.TraceCall / observe.NewCallMetrics — are available for the rare case of an explicit child span at a service-to-service call site.
Use forge/pkg/svcerr sentinels and constructors directly — svcerr.NotFound("user"), svcerr.PermissionDenied("admin only"), svcerr.InvalidArgument("field required"), svcerr.AlreadyExists("resource"). Do not redeclare ErrNotFound / ErrAlreadyExists per package — the shared sentinel set is what makes svcerr.Wrap(err) work uniformly in every handler.
if errors.Is(err, db.ErrNoRows) {
return Thing{}, svcerr.NotFound("thing")
}
return Thing{}, fmt.Errorf("get thing: %w", err)
Custom typed errors Unwrap() error to the matching svcerr.Err* sentinel:
func (e ValidationError) Unwrap() error { return svcerr.ErrInvalidArgument }
internal/app/compose.go)The service is constructed in the explicit composition root, NewComponents in the regenerated internal/app/compose.go. There is no god-hook and no name-matched *App fields — NewComponents calls things.New and hands each collaborator in by type, reading the collaborator off the owned *Infra (built in internal/app/providers.go, OpenInfra):
// internal/app/compose.go (regenerated — Deps filled inline off *Infra)
func NewComponents(infra *Infra) (*Components, error) {
c := &Components{}
c.Things = things.New(things.Deps{
Repo: infra.Repo, // an interface, resolved by type — not by field name
Now: time.Now,
NewID: ulid.Make,
})
// ... every other component constructed inline off *Infra ...
return c, nil
}
A collaborator is passed as its interface (Users user.Service), so things can't tell whether it got the in-process service, a Connect client, or a mock. Which concrete value fills infra.Users is chosen once in OpenInfra (internal/app/providers.go) — splitting things out to its own Deployment later is a one-line swap there (infra.Users = userclient.New(conn)), with the consumer untouched. To hand-customize the composition (a carved authz, a two-phase setter), forge disown internal/app/compose.go and own the bytes.
The service body carries no observability code — observe.Chain(observe.Deps{…}) in the generated cmd serve.go wraps it at the handler boundary.
Required deps live in validateDeps() so they fail fast at construction. Optional deps — fields a service can run without (a NATS publisher used only on the rollback path, an audit fallback, an optional gateway feature) — are tagged with // forge:optional-dep on the line directly above the field:
type Deps struct {
Logger *slog.Logger
Cfg config.ThingsConfig
Repo Repository
// NATSPublisher publishes domain events; nil disables rollback.
// forge:optional-dep
NATSPublisher EventPublisher
}
The marker tells:
validateDeps() — do NOT add a check. The marker says "nil is OK". (Required deps are still gated here at construction.)if s.deps.X != nil { ... } guards alone.Optionality is expressed in the composition: infra.<Field> is left nil (or the optional field is omitted from the Deps literal) for the optional collaborator. There is no name-matched wiring layer to "emit a typed-zero" — the construction site (NewComponents off OpenInfra) is explicit, so the absence is visible at the one place.
Misplaced markers (on the struct itself, on a method docstring, etc.) are caught by forge lint --conventions (forgeconv-optional-dep-marker-position).
internal/handlers/mocks/<svc>_mock.go). Edit contract.go and re-run forge generate.Service is the canonical name for single-impl. forge generate assumes it. Use <Name>er only for multi-impl strategies.internal/app/compose.go, NewComponents, off the owned internal/app/providers.go Infra/OpenInfra). Collaborators are passed by interface, resolved by type — never assigned to name-matched *App fields. Observability is applied at the handler boundary by forge/pkg/observe interceptors.contracts.svcerr.Wrap, proto↔internal conversion) — see api.contracts, "Multi-impl with strategy pattern".Service interfaces; see interactor.db.Service / Deps / New triple and snake_case directory paths, plus the owned composition root (Build) — see architecture → Naming conventions.