Skip to content

Jobs

A Job is a named unit of queued work with a payload and a registered handler.

Jobs make background work explicit, observable, and operable. Retry policy is opt-in: a generated job does not gain application retries until dispatch sets a retry budget.

When to Use Jobs

Use a job when background work needs a stable name, typed payload, handler, retry behavior, or worker lifecycle. Start with a small payload containing IDs that let the handler load current source-of-truth data.

A direct function call is simpler for synchronous behavior. Publish an event instead when the message is a fact that subscribers may observe. Add dedicated queues, retry policy, idempotency keys, and worker allocation when the operational requirements call for them.

Generate a Job

bash
forj make:job reports:generate --queue reports

For an additional app, prefix the generator with the app name:

bash
forj admin make:job reports:generate --queue reports

Use category:action for job names, such as emails:send or reports:generate. See Naming Conventions for the full naming map.

Implement the Job

The scaffold supplies dispatch and handler seams. Replace its placeholder payload with the smallest source-of-truth references the worker needs.

GenerateJobTypeName remains an explicit constant because queue registration and transport use a stable string identifier. Go cannot derive a package-level constant from the payload struct type, and reflection would make that operational contract less visible rather than cleaner.

Payload and Dependencies

go
// SendWelcomeEmailTypeName identifies the job during dispatch and handler registration.
const SendWelcomeEmailTypeName = "emails:welcome"

// SendWelcomeEmailPayload identifies the user whose current state the worker should load.
type SendWelcomeEmailPayload struct {
	UserID string `json:"user_id"`
}

// SendWelcomeEmail dispatches and handles the welcome-email workflow.
type SendWelcomeEmail struct {
	queues *queues.Manager
	users  *users.Service
}

// NewSendWelcomeEmail constructs the job with its queue and application dependencies.
func NewSendWelcomeEmail(queues *queues.Manager, users *users.Service) *SendWelcomeEmail {
	return &SendWelcomeEmail{queues: queues, users: users}
}

Job names should be stable operational identifiers.

Dispatch

Jobs own their dispatch shape. Add time to the file's imports when applying this policy:

go
// Queue dispatches the welcome-email job with its retry and timeout policy.
func (j *SendWelcomeEmail) Queue(ctx context.Context, userID string) error {
	payload, err := json.Marshal(SendWelcomeEmailPayload{UserID: userID})
	if err != nil {
		return err
	}

	_, err = j.queues.WithContext(ctx).Dispatch(
		queue.NewJob(SendWelcomeEmailTypeName).
				Payload(payload).
				OnQueue("emails").
				Retry(3).
				Backoff(2*time.Second).
				Timeout(30*time.Second),
	)
	return err
}

Services can call job.Queue(ctx, id) without constructing raw queue messages.

Retry(3) permits up to three application retry attempts after the first attempt. Backoff delays those retries, and Timeout bounds each attempt. Choose values from the side effect and service-level objective rather than copying these example values unchanged.

Handling

Handlers bind payloads and delegate business behavior:

go
// HandleTask loads the queued user reference and delegates delivery to the user service.
func (j *SendWelcomeEmail) HandleTask(ctx context.Context, msg queue.Message) error {
	var payload SendWelcomeEmailPayload
	if err := msg.Bind(&payload); err != nil {
		return fmt.Errorf("bind send welcome email payload: %w", err)
	}

	return j.users.SendWelcomeEmail(ctx, payload.UserID)
}

Return errors for retryable failures so the queue can apply the policy attached at dispatch. With errors imported, return queue.Permanent(err) for a terminal failure that should not spend the remaining application retry budget:

go
if errors.Is(err, users.ErrInvalidEmail) {
	return queue.Permanent(err)
}
return err

Broker redelivery after an infrastructure failure is distinct from the application retry budget. Review acknowledgement and durability behavior for the selected driver, and keep handlers idempotent even when Retry(0) is intentional.

Test Dispatch and Handling

Keep dispatch and handling as separate assertions.

For dispatch, inject a recording queue manager, call Queue, and assert the job type, logical queue, payload IDs, retry budget, backoff, and timeout. For handling, construct a message with the encoded payload and call the handler directly:

go
payload, err := json.Marshal(SendWelcomeEmailPayload{UserID: "user-123"})
if err != nil {
	t.Fatal(err)
}
err = job.HandleTask(
	context.Background(),
	queue.NewMessage(SendWelcomeEmailTypeName, payload),
)

Expected result: the fake user service receives user-123. Add cases for malformed payloads, retryable dependency errors, terminal errors, and repeated delivery. A direct handler test does not prove App registration, so keep one smoke test that dispatches through the generated manager and runs a worker.

In deployment, supervise ./bin/app worker or a queue-specific form such as ./bin/app worker --queue emails. After release, dispatch one safe job and confirm its App, queue, stable job name, and outcome in logs, metrics, and an Inspect when enabled.

Existing Job Registration

The generated-code tab shows the registration path for new jobs. The App-owned registerJobHandlers function is also the extension point for a manually written job.

Projects created before this registration seam may already contain custom job constructors that were never registered. Rerender migrates known framework jobs but does not guess whether an arbitrary provider is a job. Add each older custom job as a typed registerJobHandlers parameter and register its type name with queueManager.Register; future make:job calls maintain both entries automatically.

Do not register handlers after workers are already running.

Next Steps