一键导入
interactor
Use-case orchestrators that compose two or more adapters/services. Deps are interfaces only — designed for unit tests with all-mock collaborators.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Use-case orchestrators that compose two or more adapters/services. Deps are interfaces only — designed for unit tests with all-mock collaborators.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
| name | interactor |
| description | Use-case orchestrators that compose two or more adapters/services. Deps are interfaces only — designed for unit tests with all-mock collaborators. |
| emit | both |
An interactor is the package that owns one workflow: a sequence of calls to two or more collaborators, with validation, error wrapping, and (when needed) transaction coordination. It sits above adapters and services and below the transport / handler layer.
Add an interactor whenever you find yourself wanting to call two or more collaborators in sequence to fulfill one user-facing operation:
If a workflow only calls one collaborator, you don't need an interactor — that one method belongs on the collaborator's own interface. Interactors earn their keep by composing.
if in.Foo == "" { return ... }).fmt.Errorf("step: %w", err) in Go; equivalents elsewhere).Foo Source field to Deps and call s.deps.Foo.Fetch(ctx, ...).Deps field).The reason interactors are testable is that every collaborator is behind an interface. Concrete struct pointers in the dep set defeat the all-mock test surface and force tests to drag in real downstreams.
type Service interface {
ChargeAndAudit(ctx context.Context, in ChargeAndAuditInput) error
}
// Dep interfaces — what this interactor needs FROM each collaborator.
// The interfaces live next to the interactor (here), not next to the
// adapter, so the interactor's full dep surface is documented in one place.
type Charger interface {
Charge(ctx context.Context, userID string, amount int64) (chargeID string, err error)
}
type Auditor interface {
Append(ctx context.Context, userID, action, refID string) error
}
type Deps struct {
Logger *slog.Logger
Charger Charger // implemented by a concrete adapter in production
Auditor Auditor
}
type service struct{ deps Deps }
func New(deps Deps) (Service, error) { return &service{deps: deps}, nil }
func (s *service) ChargeAndAudit(ctx context.Context, in ChargeAndAuditInput) error {
if in.UserID == "" || in.AmountMinor <= 0 {
return fmt.Errorf("billing-flow: invalid input")
}
chargeID, err := s.deps.Charger.Charge(ctx, in.UserID, in.AmountMinor)
if err != nil {
return fmt.Errorf("billing-flow: charge: %w", err)
}
if err := s.deps.Auditor.Append(ctx, in.UserID, "charge", chargeID); err != nil {
return fmt.Errorf("billing-flow: audit: %w", err)
}
return nil
}
The interactor is unaware that Charger is Stripe (or Adyen, or a test fake) — that's the point.
This is the general idiom, not an interactor quirk: declare interfaces at the CONSUMER, not the implementation. Accept interfaces (flexibility in), return concrete structs (no speculative abstraction out). Each dep interface is narrow — it names only the methods this interactor calls. If a collaborator exposes a wide interface and every caller uses a different slice of it, that's a smell: split it into per-consumer interfaces like the Charger/Auditor pair above, rather than importing one god-interface everywhere.
// Smell: one wide interface each caller uses a slice of.
type Payments interface { Charge(...); Refund(...); ListDisputes(...); /* …12 more… */ }
// Better: this interactor declares only what it calls.
type Charger interface { Charge(ctx context.Context, userID string, amount int64) (string, error) }
NewComponentsSometimes collaborator B needs a value that only exists after collaborator A is constructed (worker A produces a snapshot saver that worker B consumes; service X exposes a registry that interactor Y registers handlers into). Putting that value in B's Deps creates a construction-order cycle — New(Deps) resolves its dep closure once and has no slot for "set this later".
Construct-then-inject is a first-class plain method call in the composition, not a framework hook. forge disown internal/app/compose.go to hand-own the construction site, then in NewComponents construct A and B, and call B's setter with A's product:
func NewComponents(infra *Infra) (*Components, error) {
c := &Components{}
c.Snapshotter = snapshot.New(snapshot.Deps{...})
c.Trader = trader.New(trader.Deps{...})
// two-phase wiring: B's setter, after both ends exist
c.Trader.SetSnapshotSaver(c.Snapshotter.SnapshotSaver())
return c, nil
}
This is just ordinary Go in the disowned compose.go you own — no PostBootstrap, no *App field read by name, no parallel hook system. Near-diamonds and post-construction setters (bill.WithReliantAPIKeyIssuer(llm)) are the same pattern: construct, then inject. Any model based on pure constructor topo-ordering deadlocks on the real graph; NewComponents supports construct-then-inject explicitly.
For the related case where a typed Deps field can't reference its target yet because the owning lane hasn't merged, the interface seam handles it — the consumer depends only on the collaborator's interface, so the fill in NewComponents is a one-line swap once the concrete type lands (real in-process instance, a Connect client, or a mock). There is no placeholder marker; see the "Deferred / cross-lane typing is handled by the seam" section of the api skill. That is distinct from the runtime construct-then-inject case above.
All-mock deps. Hand-rolled fakes for small surfaces, generated mocks for large ones. The shape:
type fakeCharger struct {
chargeID string
err error
}
func (f *fakeCharger) Charge(_ context.Context, _ string, _ int64) (string, error) {
return f.chargeID, f.err
}
type fakeAuditor struct {
appended []auditCall
}
func (f *fakeAuditor) Append(_ context.Context, u, a, r string) error {
f.appended = append(f.appended, auditCall{u, a, r})
return nil
}
func TestChargeAndAudit_HappyPath(t *testing.T) {
auditor := &fakeAuditor{}
svc, _ := New(Deps{Charger: &fakeCharger{chargeID: "ch_1"}, Auditor: auditor})
_ = svc.ChargeAndAudit(ctx, ChargeAndAuditInput{UserID: "u1", AmountMinor: 100})
if len(auditor.appended) != 1 || auditor.appended[0].refID != "ch_1" {
t.Fatalf("auditor not called with charge id: %+v", auditor.appended)
}
}
The two assertions interactor tests typically need:
Scaffold an interactor with:
forge add package billing-flow --type interactor
This emits the canonical package layout:
internal/billing-flow/
contract.go # // forge:interactor — Service + dep interfaces
interactor.go # service struct + composition; New(Deps) Service
interactor_test.go # all-mock deps; assert composition order
Plus two placeholder dep interfaces (Source, Sink) that demonstrate the composition pattern — replace them with the real interfaces your workflow needs.
Every interactor package's contract.go carries a // forge:interactor marker comment. One lint rule depends on it:
forgeconv-interactor-deps-are-interfaces — every field on Deps in a // forge:interactor-marked package must be an interface type, not a concrete struct pointer. Concrete pointers defeat the all-mock test surface.NewComponents)The interactor is wired in internal/app/compose.go NewComponents (generated; constructs every component INLINE off the owned internal/app/providers.go Infra), as typed interface fills in one place: adapters first, then the interactor on top, then the handler depending on the interactor. Each fill is resolved by type off infra.<Field> — no *App fields, no name-matching, no string-keyed registry.
func NewComponents(infra *Infra) (*Components, error) {
charger := stripeadapter.New(stripeadapter.Deps{...})
auditor := audit.New(audit.Deps{...})
flow, err := billingflow.New(billingflow.Deps{
Logger: infra.Log,
Charger: charger, // adapter satisfies the dep interface
Auditor: auditor,
})
if err != nil {
return nil, err
}
c := &Components{}
c.Billing = billinghandler.New(billinghandler.Deps{Flow: flow})
return c, nil
}
Each collaborator is filled by its interface, so swapping the real in-process adapter for a Connect client or a mock is a one-line change here with the interactor untouched. That is also what makes "spin up this interactor with all collaborators mocked" a few-line call against NewComponents.
adapter.service-layer.api.service-layer's errors section and forge/pkg/svcerr.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.
Proto file reference — annotations, CRUD conventions, field rules, and common mistakes.