Reports Daily Schedule
Verified Scenario
We test this scenario against the current GoForj templates, including the generated files, wiring changes, commands, and verification steps.
This scenario adds a reports:daily schedule that dispatches the existing reports:generate job.
The schedule decides when daily report work should begin. The queue still owns execution, retries, worker lifecycle, and failure visibility.
What You Will Build
internal/reports/daily.goselects users that need daily reports.internal/schedules/scheduler_registry.goregisters a namedreports:dailyschedule.- The schedule calls a domain-owned method instead of putting report logic in scheduler bootstrap.
- The method dispatches
reports:generatejobs, so workers continue to process report generation.
flowchart LR scheduler["schedule:run reports:daily"] --> daily["reports.DailyRunner"] daily --> queue["queue dispatch: reports:generate"] queue --> worker["queue:work"] worker --> report["reports.Service writes artifact"] report --> storage["reports disk"]
Prerequisites
Complete Reports Generate Job first.
The generated App should have scheduler and jobs enabled. Verify these generated packages exist:
internal/schedules
internal/jobs
internal/queuesGolden Path State
Before this scenario, reports are generated when the users.created subscriber dispatches reports:generate.
After this scenario, reports:daily can start the same report workflow on a recurring schedule. The schedule decides when work begins; the queue and workers still own execution.
Files
This scenario edits or creates:
Reports feature
internal/reports/daily.go
internal/reports/daily_test.goUsers repository
internal/users/repository.goScheduler
internal/schedules/scheduler.go
internal/schedules/scheduler_registry.goApp wiring
wire/inject_app_services.goStep 1: Add A Daily Runner
Create internal/reports/daily.go.
The runner does not generate reports itself. It turns a recurring schedule into queued work.
Create or replace internal/reports/daily.go:
package reports
import (
"context"
"fmt"
)
type DailyTarget struct {
UserID string
Email string
}
type DailyTargetRepository interface {
DueForDailyReport(ctx context.Context) ([]DailyTarget, error)
}
type DailyRunner struct {
targets DailyTargetRepository
reports ReportQueue
}
func NewDailyRunner(targets DailyTargetRepository, reports ReportQueue) *DailyRunner {
return &DailyRunner{
targets: targets,
reports: reports,
}
}
func (r *DailyRunner) Run(ctx context.Context) error {
targets, err := r.targets.DueForDailyReport(ctx)
if err != nil {
return fmt.Errorf("load daily report targets: %w", err)
}
for _, target := range targets {
if err := r.reports.Queue(ctx, target.UserID, target.Email); err != nil {
return fmt.Errorf("queue daily report for %s: %w", target.UserID, err)
}
}
return nil
}Step 2: Add Daily Targets To The Repository
Extend MemoryUserRepository so the schedule can ask the repository for due report targets.
Update internal/users/repository.go so it includes:
"github.com/goforj/cache"
"your/module/internal/reports"Step 3: Implement Daily Target Lookup
Keep target selection behind the repository boundary.
Update internal/users/repository.go so it includes:
func (r *MemoryUserRepository) Save(ctx context.Context, user User) (User, error) {
r.mu.Lock()
defer r.mu.Unlock()
if user.ID == "" {
user.ID = strconv.Itoa(r.nextID)
r.nextID++
}
r.users[user.ID] = user
return user, nil
}
func (r *MemoryUserRepository) DueForDailyReport(ctx context.Context) ([]reports.DailyTarget, error) {
r.mu.Lock()
defer r.mu.Unlock()
targets := make([]reports.DailyTarget, 0, len(r.users))
for _, user := range r.users {
targets = append(targets, reports.DailyTarget{
UserID: user.ID,
Email: user.Email,
})
}
return targets, nil
}Step 4: Inject The Daily Runner Into Scheduler
Add the daily runner to the generated scheduler type.
Update internal/schedules/scheduler.go so it includes:
"your/module/internal/inspects"
"your/module/internal/reports"Step 5: Add Scheduler Field
Store the injected runner on the scheduler.
Update internal/schedules/scheduler.go so it includes:
inspectManager *inspects.Manager
dailyReports *reports.DailyRunnerStep 6: Add Scheduler Constructor Parameter
Wire can now provide the runner to the scheduler.
Update internal/schedules/scheduler.go so it includes:
inspectManager *inspects.Manager,
dailyReports *reports.DailyRunner,Step 7: Assign Scheduler Runner
Preserve the generated scheduler wiring and add the new field assignment.
Update internal/schedules/scheduler.go so it includes:
inspectManager: inspectManager,
dailyReports: dailyReports,Step 8: Register The Schedule
Keep the registry declarative. The registry names the schedule and points to the domain-owned method.
Update internal/schedules/scheduler_registry.go so it includes:
s.registerJobObservers()
s.DailyAt("04:00").
Name("reports:daily").
Do(s.inspectTask("reports:daily", s.dailyReports.Run))Step 9: Wire The Runner
Bind the existing report job to the small queueing interface and bind the user repository to daily target lookup.
Update wire/inject_app_services.go so it includes:
reports.NewService,
reports.NewDailyRunner,
wire.Bind(new(reports.DailyTargetRepository), new(*users.MemoryUserRepository)),Step 10: Test The Runner
Create internal/reports/daily_test.go.
The unit test proves schedule target behavior without waiting for the scheduler runtime.
Create or replace internal/reports/daily_test.go:
package reports
import (
"context"
"testing"
)
type fakeDailyTargets struct {
targets []DailyTarget
}
func (f fakeDailyTargets) DueForDailyReport(context.Context) ([]DailyTarget, error) {
return f.targets, nil
}
type fakeReportQueue struct {
queued []DailyTarget
}
func (f *fakeReportQueue) Queue(_ context.Context, userID string, email string) error {
f.queued = append(f.queued, DailyTarget{UserID: userID, Email: email})
return nil
}
func TestDailyRunnerQueuesReports(t *testing.T) {
queue := &fakeReportQueue{}
runner := NewDailyRunner(
fakeDailyTargets{targets: []DailyTarget{{UserID: "42", Email: "ada@example.test"}}},
queue,
)
if err := runner.Run(context.Background()); err != nil {
t.Fatalf("run daily reports: %v", err)
}
if len(queue.queued) != 1 {
t.Fatalf("queued reports = %d", len(queue.queued))
}
}Build And Verify
forj buildgo test ./...Verify The Schedule
Start a worker in one terminal:
forj workerStart the scheduler in another terminal:
forj schedulerFor a fast local check, temporarily use a short interval while developing:
s.Every(30).Seconds().
Name("reports:daily").
Do(s.inspectTask("reports:daily", s.dailyReports.Run))Return to DailyAt("04:00") before treating the schedule as production-shaped.
Operations
Operational notes:
- Run scheduler processes explicitly with
./bin/app scheduler. - Keep the scheduler singleton unless your locking strategy supports more than one scheduler process.
- Scale workers separately with
./bin/app worker.
Common Mistakes
Common mistakes
- Do not duplicate report generation logic in the scheduler registry.
- Do not use an anonymous callback for
reports:daily. - Do not treat a schedule as a durable queue.
- Do not run multiple scheduler processes unless overlap and locking behavior are intentional.
- Do not skip the worker process; the schedule dispatches jobs, and workers perform the work.
Next Steps
- Next, follow the full API, event, job, schedule, metrics, inspects, Lighthouse, and log path in Runtime Observability.
