diff --git a/docs/applications/controllers.md b/docs/applications/controllers.md index 07b0013..7cd4f5e 100644 --- a/docs/applications/controllers.md +++ b/docs/applications/controllers.md @@ -204,6 +204,31 @@ report, err := c.service.Generate(ctx.Context(), input) Use `web.Context` for HTTP-specific behavior such as params, binding, response helpers, request metadata, and response writing. +## Cacheable File Responses + +When a controller returns an image or another stable file, choose an explicit browser cache policy and support revalidation when the content has a stable version. Keep authorization and storage lookup in the service; the controller translates the returned representation into HTTP headers and status codes. + + +```go +func (c *Controller) Image(r web.Context) error { + image, err := c.service.Image(r.Context(), r.Param("id")) + if err != nil { + return err + } + + etag := `"` + image.Digest + `"` + r.SetHeader("Cache-Control", "private, max-age=0, must-revalidate") + r.SetHeader("ETag", etag) + if r.Request().Header.Get("If-None-Match") == etag { + return r.NoContent(http.StatusNotModified) + } + + return r.Blob(http.StatusOK, image.ContentType, image.Body) +} +``` + +The cache policy depends on the resource. Public immutable assets can use a long lifetime and content-addressed URL. Private or replaceable media usually needs a validator and an authorization decision on each revalidation. Test both the initial `200` response and the matching conditional request that returns `304 Not Modified` without a response body. + ## Next Steps - [JSON API Route](/scenarios/json-api-route) follows a complete controller, service, test, build, route-list, and request workflow. diff --git a/docs/applications/services.md b/docs/applications/services.md index b410242..46aafe6 100644 --- a/docs/applications/services.md +++ b/docs/applications/services.md @@ -66,6 +66,23 @@ func (s *Service) Create(ctx context.Context, input CreateReportInput) (Report, This keeps service APIs independent from HTTP request structs, CLI flag structs, and queue payload structs. +When several returned values describe one application outcome, give that outcome a name rather than growing a tuple: + + +```go +type CreateReportResult struct { + Report Report + Queued bool + DeliveryTime time.Time +} + +func (s *Service) Create(ctx context.Context, input CreateReportInput) (CreateReportResult, error) { + // ... +} +``` + +Keep conventional and naturally small returns such as `(Report, error)`. The point is to make a cohesive result easier to understand and extend, not to replace every pair of values with a struct. + ## Runtime Boundaries Multiple entry points may call the same service: diff --git a/docs/data/repositories.md b/docs/data/repositories.md index cf3e889..7cd483c 100644 --- a/docs/data/repositories.md +++ b/docs/data/repositories.md @@ -57,6 +57,30 @@ func (s *Service) Find(ctx context.Context, id string) (User, error) { Keep service inputs independent from database model structs unless that type is intentionally the application model. +## Relationships + +Use `forj make:model --package ` when an existing table needs the conventional schema-derived model and repository scaffold. Inspect the real table, foreign keys, and nearby package ownership first. The generator derives columns from the selected table and reads supported relationship declarations from `.db-relationships.yaml`. + +For example, this declaration gives generated users their related posts while keeping the key mapping explicit and schema-validated: + +```yaml +users: + - "1-many id->posts:user_id" +``` + +Generate the referenced model in the same package before the model that exposes it: + +```bash +forj make:model posts --package content +forj make:model users --package content +``` + +The generated user model receives the relationship field and reports its eager-loading path through `Relationships()`. + +The config owns the generated relationship fields. Repositories still own persistence-specific joins, preloads, and mapping across related rows. Return a domain or application result that expresses what the caller needs rather than making controllers, commands, jobs, or frontend code navigate database relationships directly. + +For example, a billing repository can return `InvoiceDetails` containing an invoice and its line items. Whether that query uses a join, preload, or separate bounded reads stays behind the repository method and can change without rewriting its callers. + ## Named Connections Use named connections when a feature has a real persistence boundary: diff --git a/docs/data/storage-patterns.md b/docs/data/storage-patterns.md index 8a6ff5e..c598471 100644 --- a/docs/data/storage-patterns.md +++ b/docs/data/storage-patterns.md @@ -17,6 +17,8 @@ This page covers your App's generated disks and their configuration. The [storag Use storage when a workflow produces or consumes files, blobs, exports, uploads, or remote objects. Start with local or memory storage for development and tests. Choose object storage or a remote filesystem when more than one host or process needs the same files. +When a feature introduces a durable category such as avatars, invoice attachments, or generated reports, consider giving it a named storage disk. A distinct name is valuable when the category has its own access, retention, visibility, or deployment policy. Reuse an existing disk when those policies are genuinely shared; do not create names merely to mirror directories. + Keep relational metadata, ownership, authorization state, and transactional updates in the database. ## Access Storage from Application Code diff --git a/docs/frontend/react-starter-kit.md b/docs/frontend/react-starter-kit.md index c64d5e8..d395332 100644 --- a/docs/frontend/react-starter-kit.md +++ b/docs/frontend/react-starter-kit.md @@ -67,6 +67,16 @@ npm install npm run dev ``` +## Load Data Without Flicker + +Keep feature-local server data in the route or component that owns it, or in a colocated hook. Global context is appropriate for genuinely application-wide client state; it should not become the default home for every request merely to share loading machinery. + +Delay transient skeletons or spinners briefly. If a fast request resolves before that threshold, commit the content directly without flashing a pending state. During refresh or revalidation, retain the current usable content until replacement data is ready instead of clearing the view. + +Still model explicit loading, error, empty, and ready states. A slower request should reveal a stable pending layout with the same general dimensions as the result, while a completed request should never be artificially delayed to satisfy an animation. + +Route-critical data can load before navigation commits. Predictable modal and detail data can be prefetched so opening the interface does not immediately tear down and replace its contents. + ## Build for Deployment The default deployment remains one App binary. React compiles into `frontend/dist`, then `forj build` embeds those assets alongside the Go application. diff --git a/docs/frontend/templ-htmx-starter-kit.md b/docs/frontend/templ-htmx-starter-kit.md index 4c85e1e..9bc2dc0 100644 --- a/docs/frontend/templ-htmx-starter-kit.md +++ b/docs/frontend/templ-htmx-starter-kit.md @@ -117,6 +117,14 @@ The starter uses `hx-boost` on its navigation. A click still performs a normal ` This means the route, controller, and page remain useful when JavaScript is unavailable. htmx enhances the request rather than defining a separate browser-side routing model. +### Keep Pending UI Stable + +Resolve data required by the initial page contract in the controller or service before rendering the page. Do not send a page that visually presents itself as complete and then immediately replaces its primary contents when required data arrives. + +For htmx refreshes, keep the existing target visible while the request is pending. Delay transient progress indicators so a fast response does not flash a loader, and use a stable target whose dimensions do not collapse before the swap. If the request fails, replace or annotate the target with an intentional error state rather than leaving it blank. + +Progressive regions are still useful when they are genuinely independent. The distinction is ownership: required page data should be ready for the page render, while an independently useful region may load separately without making the rest of the page appear unfinished. + ### Add a Partial Update The starter pages return complete documents. When one interaction benefits from replacing a smaller region, render a smaller templ component from a dedicated handler. diff --git a/docs/frontend/vue-starter-kit.md b/docs/frontend/vue-starter-kit.md index f9f35b5..c1af740 100644 --- a/docs/frontend/vue-starter-kit.md +++ b/docs/frontend/vue-starter-kit.md @@ -64,6 +64,16 @@ forj dev for the default local App lifecycle. +## Load Data Without Flicker + +Keep feature-local server data in the view that owns it or in a colocated composable. A global store is useful for genuinely application-wide client state; it should not become the default home for every request merely to centralize loading behavior. + +Fast requests need a different pending treatment from slow ones. Delay a transient skeleton or spinner briefly. If the request resolves before that threshold, render the result directly and never show the pending state. Once useful content is visible, keep it visible during refresh or revalidation instead of replacing it with a skeleton. + +The view should still define intentional loading, error, empty, and ready states. Match pending layout dimensions to the completed view when a slower request does make loading visible, and do not delay successfully loaded content simply to keep an animation on screen. + +For data that must exist before a route makes sense, prefer loading it before committing that route. Prefetch predictable modal or detail data when practical so opening the interface does not immediately replace its contents. + ## Build for Deployment The default deployment remains one App binary. Vue compiles into `frontend/dist`, then `forj build` embeds those assets alongside the Go application. diff --git a/docs/reference/make-commands.md b/docs/reference/make-commands.md index 3952a8a..c4728b4 100644 --- a/docs/reference/make-commands.md +++ b/docs/reference/make-commands.md @@ -865,6 +865,16 @@ forj make:model invoices --package billing The positional argument is the exact name of an existing table. The generator inspects `invoices` through the default database connection, so that connection must be available. It does not create or migrate the table, and `make:model` does not select a named connection. The generated Go type and filename are singularized from the inspected table name; when an exact table is missing, the command may suggest an existing singular or plural variant. Models use `--package` rather than `-d` because their placement follows database table ownership. +By default, `make:model` also reads `.db-relationships.yaml`. Use `--config ` when the Project keeps that relationship contract elsewhere. Relationship declarations are explicit so the generator can validate every referenced local, remote, and join key against the active schema before changing source. + +```yaml +users: + - "1-many id->posts:user_id" + - "many-many id->roles:id via user_roles:user_id:role_id" +``` + +Supported declarations cover one-to-many, many-to-many, and polymorphic relationships. Generate every referenced model in the same package when the resulting fields refer to one another. Keep application-specific query selection, preload policy, and result mapping in repository methods rather than making transports navigate persistence relationships. + ```bash forj make:model invoices --package billing --remove ``` @@ -1164,7 +1174,7 @@ Generated files are starting points. Your App still owns: - schedule intervals and handler behavior - event payloads and subscribers - migration SQL -- model relationships and repository options +- repository query, preload, and persistence options beyond the generated relationship contract Keep dependencies explicit. If a generated controller, command, or job needs an application service, add that service constructor to the right provider set and let Wire pass it in. diff --git a/docs/scenarios/cached-user-profile.md b/docs/scenarios/cached-user-profile.md index ca165b9..6a6e5dc 100644 --- a/docs/scenarios/cached-user-profile.md +++ b/docs/scenarios/cached-user-profile.md @@ -85,11 +85,15 @@ CACHE_PROFILES_DEFAULT_TTL_SECONDS=300 CACHE_PROFILES_PREFIX=profiles ``` +## Step 2: Refresh Cache Resources + +Run the build pipeline after changing named resource configuration. + ```bash forj build ``` -## Step 2: Add the Repository +## Step 3: Add the Repository Create `internal/users/repository.go`. @@ -190,7 +194,7 @@ func profileCacheKey(id string) string { } ``` -## Step 3: Use the Repository in the Service +## Step 4: Use the Repository in the Service Replace `internal/users/service.go`. @@ -237,7 +241,7 @@ func (s *Service) Find(ctx context.Context, id string) (User, error) { } ``` -## Step 4: Wire the Repository and Cache +## Step 5: Wire the Repository and Cache Open `app/wire/inject_services_app.go`. @@ -252,7 +256,7 @@ import ( "your/module/internal/caches" ``` -## Step 5: Add Repository Providers +## Step 6: Add Repository Providers Add the source repository, cached repository provider, and named cache provider. @@ -265,7 +269,7 @@ provideUserRepository, users.NewService, ``` -## Step 6: Add Provider Functions +## Step 7: Add Provider Functions `provideUserProfileCache` selects the named resource. `provideUserRepository` keeps the service wired to the repository interface. @@ -283,7 +287,7 @@ func provideUserProfileCache(manager *caches.Manager) *cache.Cache { } ``` -## Step 7: Add Repository Tests +## Step 8: Add Repository Tests Create `internal/users/repository_test.go`. @@ -339,7 +343,7 @@ func TestCachedUserRepositoryFindsAndCachesUser(t *testing.T) { } ``` -## Step 8: Update the Service Test +## Step 9: Update the Service Test Keep the service test focused on service behavior. diff --git a/docs/scenarios/file-upload-storage.md b/docs/scenarios/file-upload-storage.md index 9264e44..22deec3 100644 --- a/docs/scenarios/file-upload-storage.md +++ b/docs/scenarios/file-upload-storage.md @@ -80,6 +80,16 @@ Do not edit generated storage files by hand. Add a named `uploads` disk to `.env`, then run the build pipeline so your App exposes `app.Storage().Uploads()`. +Update `.env` so it includes: + +```dotenv +STORAGE_SUPPORTED_DRIVERS=local,memory +``` + +## Step 2: Configure the Uploads Disk + +Keep the named disk's driver and path in application configuration. + Append to `.env`: ```dotenv @@ -88,17 +98,15 @@ STORAGE_UPLOADS_ROOT=storage/app/uploads STORAGE_UPLOADS_PREFIX= ``` -Update `.env` so it includes: +## Step 3: Refresh Storage Resources -```dotenv -STORAGE_SUPPORTED_DRIVERS=local,memory -``` +Run the build pipeline after changing named resource configuration. ```bash forj build ``` -## Step 2: Scaffold the Controller +## Step 4: Scaffold the Controller Start with the real make command. It creates the uploads controller, wires the constructor, and registers its routes. @@ -106,7 +114,7 @@ Start with the real make command. It creates the uploads controller, wires the c forj make:controller uploads ``` -## Step 3: Add the Service +## Step 5: Add the Service Create `internal/uploads/service.go`. @@ -221,7 +229,7 @@ func safeFilename(name string) string { } ``` -## Step 4: Replace the Starter Controller +## Step 6: Replace the Starter Controller Replace `internal/uploads/controller.go`. @@ -294,7 +302,7 @@ func (c *Controller) Store(ctx web.Context) error { } ``` -## Step 5: Add Upload Imports +## Step 7: Add Upload Imports Add imports for the generated storage manager and uploads package. @@ -306,7 +314,7 @@ Update `app/wire/inject_services_app.go` so it includes: "your/module/internal/uploads" ``` -## Step 6: Add Upload Providers +## Step 8: Add Upload Providers Add the upload service provider, which selects its named disk at the composition root. @@ -317,7 +325,7 @@ provideUploadsService, provideUserProfileCache, ``` -## Step 7: Add the Upload Service Provider +## Step 9: Add the Upload Service Provider `provideUploadsService` keeps named disk selection out of application behavior without exporting an ambiguous `storage.Storage` to Wire. @@ -333,7 +341,7 @@ func provideUploadsService(manager *storages.Manager) *uploads.Service { func provideUserRepository(source *users.MemoryUserRepository, profileCache *cache.Cache) users.UserRepository { ``` -## Step 8: Add a Service Test +## Step 10: Add a Service Test Create `internal/uploads/service_test.go`. diff --git a/docs/scenarios/reports-daily-schedule.md b/docs/scenarios/reports-daily-schedule.md index 7dc4833..93f4bd8 100644 --- a/docs/scenarios/reports-daily-schedule.md +++ b/docs/scenarios/reports-daily-schedule.md @@ -97,16 +97,10 @@ import ( "fmt" ) -// DailyTarget carries the stable identity required to queue a report without loading the full user model. -type DailyTarget struct { - UserID string - Email string -} - // DailyTargetRepository keeps schedule eligibility rules behind the application's persistence boundary. type DailyTargetRepository interface { - // ListDailyReportTargets returns only the stable fields needed to enqueue daily work. - ListDailyReportTargets(ctx context.Context) ([]DailyTarget, error) + // ListDailyReportTargets returns only stable identities needed to enqueue daily work. + ListDailyReportTargets(ctx context.Context) ([]string, error) } // DailyRunner turns one scheduler invocation into queue-backed report jobs without generating reports inline. @@ -130,9 +124,9 @@ func (r *DailyRunner) Run(ctx context.Context) error { return fmt.Errorf("load daily report targets: %w", err) } - for _, target := range targets { - if err := r.queue.Queue(ctx, target.UserID, target.Email); err != nil { - return fmt.Errorf("queue daily report for %s: %w", target.UserID, err) + for _, userID := range targets { + if err := r.queue.Queue(ctx, userID); err != nil { + return fmt.Errorf("queue daily report for %s: %w", userID, err) } } @@ -142,19 +136,7 @@ func (r *DailyRunner) Run(ctx context.Context) error { ## 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: - -```go -"github.com/goforj/cache" - -"your/module/internal/reports" -``` - -## Step 3: Implement Daily Target Lookup - -Keep target selection behind the repository boundary. +Extend `MemoryUserRepository` so the schedule can ask for due user IDs while target selection stays behind the persistence boundary. Update `internal/users/repository.go` so it includes: @@ -173,22 +155,19 @@ func (r *MemoryUserRepository) Save(_ context.Context, user User) (User, error) } // ListDailyReportTargets keeps selection behind the repository; this in-memory example treats every user as due. -func (r *MemoryUserRepository) ListDailyReportTargets(_ context.Context) ([]reports.DailyTarget, error) { +func (r *MemoryUserRepository) ListDailyReportTargets(_ context.Context) ([]string, error) { r.mu.RLock() defer r.mu.RUnlock() - 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 + targets := make([]string, 0, len(r.users)) + for _, user := range r.users { + targets = append(targets, user.ID) + } + return targets, nil } ``` -## Step 4: Scaffold the Daily Schedule +## Step 3: Scaffold the Daily Schedule Use the App-owned generator so the schedule enters the existing `AppSchedules` collection without replacing the App registry or any schedules already registered there. @@ -196,7 +175,7 @@ Use the App-owned generator so the schedule enters the existing `AppSchedules` c forj make:schedule reports:daily --every 24h --no-open ``` -## Step 5: Connect the Schedule to the Runner +## Step 4: Connect the Schedule to the Runner Replace the new App-owned schedule scaffold with its real dependency and handler. Its constructor is already present in `app/wire/inject_schedules_app.go`, so Wire supplies `DailyRunner` when it builds the schedule collection. @@ -237,7 +216,7 @@ func (s *DailySchedule) Handle(ctx context.Context) error { } ``` -## Step 6: Wire the Runner +## Step 5: Wire the Runner The previous scenario already binds the report job to `ReportQueue`. Add the daily runner and bind the user repository to daily target lookup. @@ -249,7 +228,7 @@ reports.NewDailyRunner, wire.Bind(new(reports.DailyTargetRepository), new(*users.MemoryUserRepository)), ``` -## Step 7: Test the Runner +## Step 6: Test the Runner Create `internal/reports/daily_test.go`. @@ -269,31 +248,28 @@ import ( // fakeDailyTargetRepository gives the runner a fixed eligibility result without persistence setup. type fakeDailyTargetRepository struct { - targets []DailyTarget + targets []string } // ListDailyReportTargets returns the fixture's declared targets so the test controls schedule input. -func (repo fakeDailyTargetRepository) ListDailyReportTargets(context.Context) ([]DailyTarget, error) { +func (repo fakeDailyTargetRepository) ListDailyReportTargets(context.Context) ([]string, error) { return repo.targets, nil } // recordingReportQueue exposes queued targets without starting a worker runtime. type recordingReportQueue struct { - queued []DailyTarget + queued []string } // Queue records the job-dispatch boundary while satisfying the same interface as the generated report job. -func (queue *recordingReportQueue) Queue(_ context.Context, userID string, email string) error { - queue.queued = append(queue.queued, DailyTarget{UserID: userID, Email: email}) +func (queue *recordingReportQueue) Queue(_ context.Context, userID string) error { + queue.queued = append(queue.queued, userID) return nil } // TestDailyRunnerQueuesReports proves one schedule invocation dispatches every eligible target exactly once. func TestDailyRunnerQueuesReports(t *testing.T) { - targets := []DailyTarget{ - {UserID: "42", Email: "ada@example.test"}, - {UserID: "43", Email: "grace@example.test"}, - } + targets := []string{"42", "43"} queue := &recordingReportQueue{} runner := NewDailyRunner( fakeDailyTargetRepository{targets: targets}, diff --git a/docs/scenarios/reports-generate-job.md b/docs/scenarios/reports-generate-job.md index 52ccd7c..4a5e562 100644 --- a/docs/scenarios/reports-generate-job.md +++ b/docs/scenarios/reports-generate-job.md @@ -20,7 +20,7 @@ The event still announces that a user was created. The subscriber now queues `re - `QUEUE_*` config selects the queue backend used by API and worker processes. - `STORAGE_REPORTS_*` defines a named disk for generated report artifacts. - `reports.Service` writes a report file to storage. -- `reports.GenerateJob` owns the queue payload, dispatch shape, and handler. +- `reports.GenerateJob` owns an ID-only queue payload, dispatch shape, and handler. - `notifications.Service` dispatches the job from the `users.created` subscriber. - Wire binds the job to a small queueing interface used by notifications. @@ -117,7 +117,7 @@ forj build Create `internal/reports/service.go`. -The service writes through `storage.Storage`, not a local filesystem or cloud SDK. The selected driver remains configuration. +The service reloads the user through its repository before writing through `storage.Storage`. A delayed job therefore uses current user state without coupling the handler to persistence or a concrete storage backend. Create or replace `internal/reports/service.go`: @@ -135,6 +135,8 @@ import ( "time" "github.com/goforj/storage" + + "your/module/internal/users" ) var ( @@ -144,15 +146,16 @@ var ( ErrEmailRequired = errors.New("email is required") ) -// Service writes report artifacts through a configured storage disk rather than a concrete backend. +// Service reloads report subjects through the repository and writes artifacts through a configured storage disk. type Service struct { - disk storage.Storage + users users.UserRepository + disk storage.Storage } // ReportQueue keeps report requesters independent of queue payloads and dispatch policy. type ReportQueue interface { // Queue moves report generation behind the configured worker lifecycle. - Queue(ctx context.Context, userID string, email string) error + Queue(ctx context.Context, userID string) error } // UserReport is the stable artifact stored by the runnable report workflow. @@ -162,19 +165,24 @@ type UserReport struct { GeneratedAt time.Time `json:"generated_at"` } -// NewService requires the named report disk because successful generation must persist an artifact. -func NewService(disk storage.Storage) *Service { - return &Service{disk: disk} +// NewService requires current user state and the named report disk because queued identity data can become stale before execution. +func NewService(userRepository users.UserRepository, disk storage.Storage) *Service { + return &Service{users: userRepository, disk: disk} } -// GenerateForUser validates path and identity data before writing one deterministic report location. -func (s *Service) GenerateForUser(ctx context.Context, userID string, email string) (string, error) { +// GenerateForUser reloads current user state before writing one deterministic report location. +func (s *Service) GenerateForUser(ctx context.Context, userID string) (string, error) { userID = reportPathSegment(userID) if userID == "" { return "", ErrUserIDRequired } - email = strings.TrimSpace(email) + user, err := s.users.Find(ctx, userID) + if err != nil { + return "", fmt.Errorf("find user: %w", err) + } + + email := strings.TrimSpace(user.Email) if email == "" { return "", ErrEmailRequired } @@ -241,10 +249,9 @@ import ( // GenerateJobTypeName is the stable queue identity shared by dispatchers and workers. const GenerateJobTypeName = "reports:generate" -// GeneratePayload keeps queued data small so report artifacts remain in storage rather than the queue. +// GeneratePayload carries durable identity so delayed work reloads current state instead of consuming a stale snapshot. type GeneratePayload struct { UserID string `json:"user_id"` - Email string `json:"email"` } // GenerateJob owns report dispatch policy and translates queue messages into service calls. @@ -262,10 +269,9 @@ func NewGenerateJob(queues *queues.Manager, service *Service) *GenerateJob { } // Queue serializes the stable payload and applies retry and timeout policy at the job boundary. -func (j *GenerateJob) Queue(ctx context.Context, userID string, email string) error { +func (j *GenerateJob) Queue(ctx context.Context, userID string) error { payload, err := json.Marshal(GeneratePayload{ UserID: userID, - Email: email, }) if err != nil { return fmt.Errorf("encode generate report payload: %w", err) @@ -291,7 +297,7 @@ func (j *GenerateJob) HandleTask(ctx context.Context, msg queue.Message) error { return fmt.Errorf("bind generate report payload: %w", err) } - if _, err := j.service.GenerateForUser(ctx, payload.UserID, payload.Email); err != nil { + if _, err := j.service.GenerateForUser(ctx, payload.UserID); err != nil { return fmt.Errorf("generate user report: %w", err) } return nil @@ -337,8 +343,8 @@ func NewService(generateReport reports.ReportQueue) *Service { } // HandleUserCreated dispatches report work without making the event subscriber understand queue details. -func (s *Service) HandleUserCreated(ctx context.Context, userID string, email string) error { - return s.generateReport.Queue(ctx, userID, email) +func (s *Service) HandleUserCreated(ctx context.Context, userID string, _ string) error { + return s.generateReport.Queue(ctx, userID) } ``` @@ -434,8 +440,8 @@ Update `app/wire/inject_services_app.go` so it includes: ```go // provideReportService selects the named disk where dependencies are composed instead of inside report behavior. -func provideReportService(manager *storages.Manager) *reports.Service { - return reports.NewService(manager.Reports()) +func provideReportService(userRepository users.UserRepository, manager *storages.Manager) *reports.Service { + return reports.NewService(userRepository, manager.Reports()) } // provideEventBus exposes the default generated bus without coupling the publisher to its manager. @@ -462,6 +468,8 @@ import ( "github.com/goforj/storage" "github.com/goforj/storage/driver/memorystorage" + + "your/module/internal/users" ) // newTestDisk keeps test setup focused on report behavior while failing immediately on invalid storage wiring. @@ -495,8 +503,8 @@ func readTestReport(t *testing.T, disk storage.Storage, reportPath string) UserR func TestServiceGeneratesUserReport(t *testing.T) { ctx := context.Background() disk := newTestDisk(t) - service := NewService(disk) - reportPath, err := service.GenerateForUser(ctx, "42", "ada@example.test") + service := NewService(users.NewMemoryUserRepository(), disk) + reportPath, err := service.GenerateForUser(ctx, "42") if err != nil { t.Fatalf("generate report: %v", err) } @@ -519,28 +527,31 @@ func TestServiceGeneratesUserReport(t *testing.T) { // TestServiceRejectsInvalidReports keeps malformed identity data from reaching storage. func TestServiceRejectsInvalidReports(t *testing.T) { ctx := context.Background() - service := NewService(newTestDisk(t)) + repository := users.NewMemoryUserRepository() + userWithoutEmail, err := repository.Save(ctx, users.User{Name: "Missing Email"}) + if err != nil { + t.Fatalf("save user without email: %v", err) + } + service := NewService(repository, newTestDisk(t)) tests := []struct { name string userID string - email string wantErr error }{ { name: "missing user id", - email: "ada@example.test", wantErr: ErrUserIDRequired, }, { name: "missing email", - userID: "42", + userID: userWithoutEmail.ID, wantErr: ErrEmailRequired, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - _, err := service.GenerateForUser(ctx, test.userID, test.email) + _, err := service.GenerateForUser(ctx, test.userID) if !errors.Is(err, test.wantErr) { t.Fatalf("GenerateForUser() error = %v, want %v", err, test.wantErr) } @@ -553,7 +564,7 @@ func TestServiceRejectsInvalidReports(t *testing.T) { Create `internal/reports/generate_job_test.go`. -The synchronous queue supplies the real `queue.Message` contract, so the test proves payload binding and delegation without testing private fields or starting external infrastructure. +The synchronous queue supplies the real `queue.Message` contract, so the test proves ID-only payload binding, current-state lookup, and delegation without testing private fields or starting external infrastructure. Create or replace `internal/reports/generate_job_test.go`: @@ -568,6 +579,7 @@ import ( "github.com/goforj/queue" "your/module/internal/queues" + "your/module/internal/users" ) // TestGenerateJobHandlesPayload proves a queue message is bound and delegated to report generation. @@ -587,7 +599,11 @@ func TestGenerateJobHandlesPayload(t *testing.T) { }) disk := newTestDisk(t) - job := NewGenerateJob(queueManager, NewService(disk)) + userRepository := users.NewMemoryUserRepository() + if _, err := userRepository.Save(ctx, users.User{ID: "42", Name: "Ada Lovelace", Email: "current@example.test"}); err != nil { + t.Fatalf("update user before job execution: %v", err) + } + job := NewGenerateJob(queueManager, NewService(userRepository, disk)) queueManager.Register(GenerateJobTypeName, job.HandleTask) if err := runtimeQueue.StartWorkers(ctx); err != nil { t.Fatalf("start queue workers: %v", err) @@ -595,7 +611,7 @@ func TestGenerateJobHandlesPayload(t *testing.T) { _, err = queueManager.WithContext(ctx).Dispatch( queue.NewJob(GenerateJobTypeName). - Payload([]byte(`{"user_id":"42","email":"ada@example.test"}`)). + Payload([]byte(`{"user_id":"42"}`)). OnQueue("default"), ) if err != nil { @@ -606,8 +622,8 @@ func TestGenerateJobHandlesPayload(t *testing.T) { if report.UserID != "42" { t.Fatalf("report user id = %q, want %q", report.UserID, "42") } - if report.Email != "ada@example.test" { - t.Fatalf("report email = %q, want %q", report.Email, "ada@example.test") + if report.Email != "current@example.test" { + t.Fatalf("report email = %q, want current repository state", report.Email) } } ``` @@ -668,7 +684,7 @@ Operational notes: - Durability and cross-process delivery come from the selected queue driver; `workerpool` remains process-local. - Use this boundary for work that sends email, generates reports, calls external APIs, or may need operational recovery. - The job can appear in queue metrics, inspect records, Lighthouse queue views, worker logs, and driver backend state. -- Keep job payloads stable and small. Store large artifacts in storage, not inside queue payloads. +- Keep job payloads stable and small. Carry identity, reload current state in the service, and store large artifacts outside the queue payload. ## Swap the Driver