@polymorphism-tech/morph-spec 8.46.0 → 8.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CLAUDE.md +5 -5
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +1 -4
  4. package/bin/validate.js +4 -25
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +2 -2
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/CLAUDE.md +1 -1
  9. package/framework/MORPH.md +8 -8
  10. package/framework/commands/create-umbraco-lp.md +2 -0
  11. package/framework/commands/migrate-lp-to-umbraco.md +2 -0
  12. package/framework/commands/morph-archive.md +1 -1
  13. package/framework/commands/morph-hotfix.md +1 -1
  14. package/framework/commands/morph-infra.md +17 -203
  15. package/framework/commands/morph-media.md +3 -0
  16. package/framework/commands/morph-preflight.md +5 -28
  17. package/framework/commands/morph-proposal.md +2 -2
  18. package/framework/commands/morph-troubleshoot.md +2 -14
  19. package/framework/docs/CLI.md +3 -3
  20. package/framework/hooks/claude-code/core/state-sync.js +13 -1
  21. package/framework/hooks/shared/e2e-check.js +3 -4
  22. package/framework/hooks/shared/state-writer.js +1 -0
  23. package/framework/rules/ai-agents-standards.md +18 -16
  24. package/framework/rules/asaas-standards.md +3 -1
  25. package/framework/rules/csharp-standards.md +10 -8
  26. package/framework/rules/data-standards.md +5 -5
  27. package/framework/rules/dotnet-templates.md +1 -1
  28. package/framework/rules/frontend-standards.md +4 -2
  29. package/framework/rules/gsap-standards.md +10 -5
  30. package/framework/rules/hangfire-standards.md +3 -1
  31. package/framework/rules/infrastructure-standards.md +4 -2
  32. package/framework/rules/morph-workflow.md +2 -2
  33. package/framework/rules/neon-auth-standards.md +3 -1
  34. package/framework/rules/neon-standards.md +6 -4
  35. package/framework/rules/nextjs-standards.md +13 -11
  36. package/framework/rules/nextjs-templates.md +1 -1
  37. package/framework/rules/resend-standards.md +3 -1
  38. package/framework/schemas/feature.schema.json +1 -3
  39. package/framework/skills/README.md +1 -1
  40. package/framework/skills/core/create-umbraco-lp/SKILL.md +3 -3
  41. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +3 -3
  42. package/framework/skills/core/morph-apply/SKILL.md +3 -3
  43. package/framework/skills/core/morph-brainstorming/SKILL.md +3 -3
  44. package/framework/skills/core/morph-hotfix/SKILL.md +4 -4
  45. package/framework/skills/core/morph-implement/SKILL.md +3 -3
  46. package/framework/skills/core/morph-machine/SKILL.md +3 -3
  47. package/framework/skills/core/morph-media/SKILL.md +3 -3
  48. package/framework/skills/core/morph-plan/SKILL.md +3 -3
  49. package/framework/skills/core/morph-proposal/SKILL.md +3 -3
  50. package/framework/skills/core/morph-review/SKILL.md +3 -3
  51. package/framework/skills/core/morph-stack-scan/SKILL.md +3 -3
  52. package/framework/skills/core/morph-standards/SKILL.md +6 -6
  53. package/framework/skills/core/morph-uiux/SKILL.md +3 -3
  54. package/framework/standards/STANDARDS.json +59 -56
  55. package/framework/standards/ai-agents/evals-with-cache.md +1 -1
  56. package/framework/standards/ai-agents/llm-runtime-defaults.md +1 -1
  57. package/framework/standards/ai-agents/modalities-speech-to-text.md +36 -26
  58. package/framework/standards/ai-agents/prompt-sources.md +6 -4
  59. package/framework/standards/ai-agents/providers/model-registry.md +1 -1
  60. package/framework/standards/ai-agents/rag-custom-pgvector.md +67 -39
  61. package/framework/standards/ai-agents/setup.md +1 -1
  62. package/framework/standards/ai-agents/structured-output.md +1 -1
  63. package/framework/standards/ai-agents/sweet-spot.md +1 -1
  64. package/framework/standards/architecture/code-comments.md +2 -2
  65. package/framework/standards/architecture/vertical-slice/vertical-slice.md +18 -9
  66. package/framework/standards/backend/api/validation.md +135 -47
  67. package/framework/standards/backend/database/postgresql/database.md +11 -7
  68. package/framework/standards/backend/database/vector-search-rag.md +207 -324
  69. package/framework/standards/backend/dotnet/fluent-validation-vsa.md +14 -8
  70. package/framework/standards/backend/dotnet/result-pattern.md +40 -6
  71. package/framework/standards/backend/dotnet/scalar-ui.md +13 -7
  72. package/framework/standards/backend/dotnet/scrutor.md +1 -1
  73. package/framework/standards/backend/dotnet/testing.md +1 -1
  74. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +7 -7
  75. package/framework/standards/backend/integrations/asaas/asaas-api.md +309 -165
  76. package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +134 -154
  77. package/framework/standards/data/nosql/cache/redis.md +120 -44
  78. package/framework/standards/data/nosql/cosmos-db.md +114 -71
  79. package/framework/standards/frontend/design-system/animations.md +1 -1
  80. package/framework/standards/frontend/design-system/naming.md +1 -1
  81. package/framework/standards/frontend/nextjs/motion-patterns.md +45 -29
  82. package/framework/standards/frontend/nextjs/naming-conventions.md +1 -1
  83. package/framework/standards/infrastructure/docker/coolify-deploy.md +8 -6
  84. package/framework/standards/infrastructure/neon/neon-pgvector.md +120 -75
  85. package/framework/standards/infrastructure/neon/neon-setup.md +53 -31
  86. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +2 -2
  87. package/framework/templates/code/dotnet/extensions/database-extensions.cs +1 -1
  88. package/framework/templates/code/dotnet/extensions/result-extensions.cs +27 -0
  89. package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +2 -2
  90. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +1 -1
  91. package/framework/templates/infrastructure/docker/coolify.template.json +3 -3
  92. package/framework/templates/infrastructure/github/README.md +2 -1
  93. package/framework/templates/infrastructure/github/workflows/deploy-coolify.yml.hbs +2 -1
  94. package/package.json +1 -1
  95. package/src/commands/observability/telemetry.js +3 -5
  96. package/src/commands/project/init.js +2 -1
  97. package/src/commands/project/update.js +16 -18
  98. package/src/commands/state/approve.js +17 -0
  99. package/src/commands/state/gate-check.js +4 -14
  100. package/src/commands/validation/verify.js +3 -12
  101. package/src/core/paths/output-schema.js +1 -1
  102. package/src/dashboard/app/App.jsx +0 -2
  103. package/src/dashboard/app/components/CommandPalette.jsx +2 -3
  104. package/src/dashboard/app/components/EntityLink.jsx +0 -1
  105. package/src/dashboard/app/components/Markdown.jsx +1 -1
  106. package/src/dashboard/app/components/Sidebar.jsx +1 -2
  107. package/src/dashboard/app/pages/RulesMap.jsx +3 -3
  108. package/src/dashboard/app/pages/SkillsLibrary.jsx +3 -3
  109. package/src/dashboard/scanner/activity-scanner.js +1 -1
  110. package/src/dashboard/scanner/docs-scanner.js +5 -23
  111. package/src/dashboard/scanner/features-scanner.js +1 -1
  112. package/src/dashboard/scanner/index.js +0 -2
  113. package/src/dashboard/scanner/rules-scanner.js +15 -10
  114. package/src/dashboard/scanner/skills-scanner.js +2 -3
  115. package/src/lib/evaluator-report.js +13 -0
  116. package/src/lib/risk-detector.js +8 -0
  117. package/src/lib/stack/stack-profile.js +1 -1
  118. package/src/lib/validators/reuse/reuse-validator.js +2 -3
  119. package/src/lib/validators/reuse/symbol-scanner.js +2 -4
  120. package/src/lib/validators/validation-runner.js +8 -88
  121. package/src/lib/validators/verify-runner.js +22 -72
  122. package/src/scripts/setup-infra.js +33 -22
  123. package/src/utils/agents-installer.js +20 -8
  124. package/src/utils/file-copier.js +7 -5
  125. package/framework/commands/commit.md +0 -166
  126. package/framework/rubrics/backend.json +0 -41
  127. package/framework/rubrics/default.json +0 -36
  128. package/framework/rubrics/frontend.json +0 -36
  129. package/framework/templates/project/validate.js +0 -97
  130. package/src/dashboard/app/pages/Rubrics.jsx +0 -97
  131. package/src/dashboard/scanner/rubrics-scanner.js +0 -12
  132. package/src/lib/validators/architecture/architecture-validator.js +0 -270
  133. package/src/lib/validators/architecture/index.js +0 -7
  134. package/src/lib/validators/css/css-validator.js +0 -340
  135. package/src/lib/validators/css/index.js +0 -7
  136. package/src/lib/validators/design-system/design-system-validator.js +0 -237
  137. package/src/lib/validators/design-system/index.js +0 -7
  138. package/src/lib/validators/score-aggregator.js +0 -244
  139. package/src/lib/validators/ui/index.js +0 -7
  140. package/src/lib/validators/ui/ui-contrast-validator.js +0 -422
@@ -5,34 +5,29 @@
5
5
  > **Keywords:** hangfire, background job, recurring, fire-and-forget, scheduled
6
6
  > **Load When:** hangfire or background job keywords detected
7
7
 
8
- **Verified against:** .NET 10 + Hangfire 1.8 (Core/SqlServer/AspNetCore). Last-verified: 2026-05-20.
8
+ **Verified against:** .NET 10 (SDK 10.0.400-preview) + Hangfire.Core/Hangfire.AspNetCore 1.8.25 + Hangfire.PostgreSql 1.21.1 + Newtonsoft.Json 13.0.4. The startup, job/handler, dashboard-filter and idempotency blocks compile with `TreatWarningsAsErrors`; the Job Types calls compile inside a wrapper method; the per-job DI scope (`AspNetCoreJobActivator` → `IServiceScopeFactory`) and the `PostgreSqlStorageOptions` defaults were read by reflection on those assemblies; the pooled-endpoint limits (LISTEN/NOTIFY, migrations on the direct string) come from the Neon connection-pooling docs. Cron strings are documentary. Last-verified: 2026-10-03.
9
9
 
10
10
  ---
11
11
 
12
- Background job processing for .NET applications with Hangfire.
12
+ Background job processing for .NET 10 VSA applications with Hangfire on Neon Postgres.
13
13
 
14
14
  ---
15
15
 
16
- ## Overview
16
+ ## When to use Hangfire
17
17
 
18
- Hangfire provides:
19
- - Fire-and-forget jobs
20
- - Delayed jobs
21
- - Recurring jobs (cron schedules)
22
- - Job continuations
23
- - Built-in dashboard
24
- - Retry logic with exponential backoff
18
+ Hangfire is in the stack **only with a real reason**: work that must survive a process restart, run on a schedule, or be retried later (daily reindex, delayed reminders, retrying a flaky third party). Work that fits in the request (send one email after a signup, index one uploaded document) runs in the slice handler. Record the reason in `decisions.md`.
25
19
 
26
- **Stack:** .NET 10
20
+ Hangfire provides fire-and-forget, delayed and recurring (cron) jobs, continuations, a dashboard and automatic retries.
27
21
 
28
22
  ---
29
23
 
30
24
  ## Core Principles
31
25
 
32
- 1. **Stateless Jobs**: Job methods should be stateless and idempotent
33
- 2. **Minimal Dependencies**: Jobs should not depend on HTTP context or scoped services
34
- 3. **Explicit Retries**: Configure retry behavior per job type
35
- 4. **Monitor Dashboard**: Use Hangfire Dashboard for job monitoring
26
+ 1. **Jobs are thin entry points**: a job class lives in the slice folder and calls the slice handler; the logic and the `Result` live in the handler
27
+ 2. **Idempotent**: a job can run twice (retry, server restart) -- check state and return if already done
28
+ 3. **No HTTP context**: jobs run outside a request; pass ids, never entities or `HttpContext` data
29
+ 4. **Explicit retries**: `[AutomaticRetry]` per job; no hand-written retry loops
30
+ 5. **Protected dashboard**: authenticated + Admin in production
36
31
 
37
32
  ---
38
33
 
@@ -40,195 +35,190 @@ Hangfire provides:
40
35
 
41
36
  ### Install Packages
42
37
 
43
- ```bash
44
- dotnet add package Hangfire.Core
45
- dotnet add package Hangfire.SqlServer
46
- dotnet add package Hangfire.AspNetCore
38
+ ```xml
39
+ <PackageReference Include="Hangfire.Core" Version="1.8.25" />
40
+ <PackageReference Include="Hangfire.AspNetCore" Version="1.8.25" />
41
+ <PackageReference Include="Hangfire.PostgreSql" Version="1.21.1" />
42
+ <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
47
43
  ```
48
44
 
45
+ `Hangfire.Core` 1.8.25 depends on `Newtonsoft.Json` 11.0.1, which carries a high-severity advisory (GHSA-5crp-9r3c-p9vr): restore fails with `NU1903` under `TreatWarningsAsErrors`. The explicit `Newtonsoft.Json` reference lifts it to a patched version.
46
+
49
47
  ### Program.cs Configuration
50
48
 
51
49
  ```csharp
52
50
  // Program.cs
53
51
  using Hangfire;
54
- using Hangfire.SqlServer;
52
+ using Hangfire.PostgreSql;
55
53
 
56
54
  var builder = WebApplication.CreateBuilder(args);
57
55
 
58
- // Add Hangfire services
59
56
  builder.Services.AddHangfire(configuration => configuration
60
57
  .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
61
58
  .UseSimpleAssemblyNameTypeSerializer()
62
59
  .UseRecommendedSerializerSettings()
63
- .UseSqlServerStorage(
64
- builder.Configuration.GetConnectionString("HangfireConnection"),
65
- new SqlServerStorageOptions
66
- {
67
- CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),
68
- SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5),
69
- QueuePollInterval = TimeSpan.Zero,
70
- UseRecommendedIsolationLevel = true,
71
- DisableGlobalLocks = true
72
- }));
60
+ .UsePostgreSqlStorage(options =>
61
+ options.UseNpgsqlConnection(builder.Configuration.GetConnectionString("HangfireConnection"))));
73
62
 
74
63
  builder.Services.AddHangfireServer();
75
64
 
76
65
  var app = builder.Build();
77
66
 
78
- // Hangfire Dashboard (protect in production!)
79
67
  app.UseHangfireDashboard("/hangfire", new DashboardOptions
80
68
  {
81
- Authorization = new[] { new HangfireAuthorizationFilter() }
69
+ Authorization = [new HangfireAuthorizationFilter()]
82
70
  });
83
71
 
84
72
  app.Run();
85
73
  ```
86
74
 
75
+ - **Storage on Neon Postgres** (`Hangfire.PostgreSql`), not SQL Server. The `UsePostgreSqlStorage(string, ...)` overloads are `[Obsolete]` in 1.21; use the `options => options.UseNpgsqlConnection(...)` form above.
76
+ - **`HangfireConnection` points at the direct (unpooled) Neon endpoint**, like migrations (`infrastructure/neon/neon-setup.md`): the storage installs its schema at startup (`PrepareSchemaIfNecessary = true` by default), and Neon recommends the direct string for migrations; and if you turn on `EnableLongPolling` it uses `LISTEN/NOTIFY`, which Neon's PgBouncer (transaction mode) does not support.
77
+ - Defaults read from `PostgreSqlStorageOptions` 1.21.1: `SchemaName = "hangfire"`, `QueuePollInterval = 15 s`, `InvisibilityTimeout = 30 min`, `DistributedLockTimeout = 10 min`, `EnableLongPolling = false`.
78
+ - The connection string comes from configuration (user-secrets in development, Coolify environment variables in production), never from code.
79
+
87
80
  ---
88
81
 
89
82
  ## Job Types
90
83
 
84
+ The examples enqueue the job classes of the next section. Hangfire replaces a `CancellationToken` argument (pass `CancellationToken.None`) with the job's own cancellation token at execution time.
85
+
91
86
  ### Fire-and-Forget Jobs
92
87
 
93
88
  ```csharp
94
- // Enqueue immediately
95
- BackgroundJob.Enqueue<IEmailService>(x => x.SendWelcomeEmail(userId));
96
-
97
- // Alternative: Static method
98
- BackgroundJob.Enqueue(() => Console.WriteLine("Hello, world!"));
89
+ BackgroundJob.Enqueue<SendWelcomeEmailJob>(job => job.RunAsync(userId, CancellationToken.None));
99
90
  ```
100
91
 
101
92
  ### Delayed Jobs
102
93
 
103
94
  ```csharp
104
- // Run after 1 hour
105
- BackgroundJob.Schedule<IReportService>(
106
- x => x.GenerateMonthlyReport(),
95
+ BackgroundJob.Schedule<SendWelcomeEmailJob>(
96
+ job => job.RunAsync(userId, CancellationToken.None),
107
97
  TimeSpan.FromHours(1));
108
98
 
109
- // Run at specific time
110
- BackgroundJob.Schedule<INotificationService>(
111
- x => x.SendReminder(userId),
112
- DateTimeOffset.UtcNow.AddDays(7));
99
+ BackgroundJob.Schedule<SendWelcomeEmailJob>(
100
+ job => job.RunAsync(userId, CancellationToken.None),
101
+ timeProvider.GetUtcNow().AddDays(7));
113
102
  ```
114
103
 
115
104
  ### Recurring Jobs
116
105
 
117
106
  ```csharp
118
- // Run daily at 2 AM UTC
119
- RecurringJob.AddOrUpdate<ICleanupService>(
120
- "cleanup-old-data",
121
- x => x.CleanupOldRecords(),
107
+ RecurringJob.AddOrUpdate<CleanupExpiredSessionsJob>(
108
+ "cleanup-expired-sessions",
109
+ job => job.RunAsync(CancellationToken.None),
122
110
  Cron.Daily(2));
123
111
 
124
- // Run every 15 minutes
125
- RecurringJob.AddOrUpdate<ISyncService>(
126
- "sync-external-data",
127
- x => x.SyncData(),
112
+ RecurringJob.AddOrUpdate<CleanupExpiredSessionsJob>(
113
+ "cleanup-expired-sessions-frequent",
114
+ job => job.RunAsync(CancellationToken.None),
128
115
  "*/15 * * * *");
129
116
  ```
130
117
 
118
+ Recurring job ids are stable strings (`AddOrUpdate` with the same id replaces the schedule). Cron times are UTC.
119
+
131
120
  ### Job Continuations
132
121
 
133
122
  ```csharp
134
- var parentJobId = BackgroundJob.Enqueue<IOrderService>(
135
- x => x.ProcessOrder(orderId));
123
+ var parentJobId = BackgroundJob.Enqueue<SendWelcomeEmailJob>(
124
+ job => job.RunAsync(userId, CancellationToken.None));
136
125
 
137
- BackgroundJob.ContinueJobWith<IEmailService>(
126
+ BackgroundJob.ContinueJobWith<CleanupExpiredSessionsJob>(
138
127
  parentJobId,
139
- x => x.SendOrderConfirmation(orderId));
128
+ job => job.RunAsync(CancellationToken.None));
140
129
  ```
141
130
 
142
131
  ---
143
132
 
144
- ## Job Service Pattern
133
+ ## Job + Handler Pattern (in the slice)
145
134
 
146
135
  ```csharp
147
- // Services/Jobs/IEmailJobService.cs
148
- public interface IEmailJobService
136
+ // Features/UserFeature/SendWelcomeEmail/SendWelcomeEmail.cs
137
+ using Hangfire;
138
+ using Microsoft.EntityFrameworkCore;
139
+ using Microsoft.Extensions.Logging;
140
+
141
+ public sealed record SendWelcomeEmailRequest(Guid UserId);
142
+
143
+ public static class UserErrors
149
144
  {
150
- Task SendWelcomeEmailAsync(string userId);
151
- Task SendPasswordResetAsync(string email);
145
+ public static Error NotFound(Guid userId) =>
146
+ Error.NotFound("Users.NotFound", $"The User with Id '{userId}' was not found");
152
147
  }
153
148
 
154
- // Services/Jobs/EmailJobService.cs
155
- public class EmailJobService : IEmailJobService
149
+ public interface IEmailSender
156
150
  {
157
- private readonly IDbContextFactory<AppDbContext> _dbFactory;
158
- private readonly IEmailSender _emailSender;
151
+ Task SendAsync(string to, string subject, string body, CancellationToken cancellationToken);
152
+ }
159
153
 
160
- public EmailJobService(
161
- IDbContextFactory<AppDbContext> dbFactory,
162
- IEmailSender emailSender)
154
+ public sealed class SendWelcomeEmailHandler(
155
+ ApplicationDbContext db,
156
+ IEmailSender emailSender,
157
+ TimeProvider timeProvider) : IHandler<SendWelcomeEmailRequest, Result>
158
+ {
159
+ public async Task<Result> HandleAsync(SendWelcomeEmailRequest request, CancellationToken cancellationToken)
163
160
  {
164
- _dbFactory = dbFactory;
165
- _emailSender = emailSender;
166
- }
161
+ var user = await db.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
162
+ if (user is null)
163
+ return UserErrors.NotFound(request.UserId);
167
164
 
168
- public async Task SendWelcomeEmailAsync(string userId)
169
- {
170
- // Use IDbContextFactory for background jobs
171
- await using var db = await _dbFactory.CreateDbContextAsync();
165
+ if (user.WelcomeEmailSentAt is not null)
166
+ return Result.Success();
172
167
 
173
- var user = await db.Users.FindAsync(userId);
174
- if (user == null) return;
168
+ await emailSender.SendAsync(user.Email, "Welcome!", $"Hello {user.Name}!", cancellationToken);
175
169
 
176
- await _emailSender.SendAsync(
177
- user.Email,
178
- "Welcome!",
179
- $"Hello {user.Name}!");
170
+ user.WelcomeEmailSentAt = timeProvider.GetUtcNow();
171
+ await db.SaveChangesAsync(cancellationToken);
172
+ return Result.Success();
180
173
  }
181
174
  }
182
175
 
183
- // Register in DI
184
- builder.Services.AddScoped<IEmailJobService, EmailJobService>();
176
+ public sealed class SendWelcomeEmailJob(
177
+ IHandler<SendWelcomeEmailRequest, Result> handler,
178
+ ILogger<SendWelcomeEmailJob> logger)
179
+ {
180
+ [AutomaticRetry(Attempts = 3, DelaysInSeconds = [60, 300, 900])]
181
+ public async Task RunAsync(Guid userId, CancellationToken cancellationToken)
182
+ {
183
+ var result = await handler.HandleAsync(new SendWelcomeEmailRequest(userId), cancellationToken);
184
+ if (result.IsFailure)
185
+ logger.LogWarning("Welcome email for {UserId} not sent: {Code}", userId, result.Error.Code);
186
+ }
187
+ }
185
188
  ```
186
189
 
190
+ - **The handler and the `ApplicationDbContext` are injected directly.** `Hangfire.AspNetCore` activates each job through `AspNetCoreJobActivator`, which opens an `IServiceScope` per job execution (`IServiceScopeFactory`) and disposes it at the end, so scoped services, including the `DbContext`, get a fresh instance per job, exactly like a request. `IDbContextFactory<ApplicationDbContext>` is only needed when **one** job runs several units of work in parallel.
191
+ - No `IEmailJobService`/`XService` layer: the job is the entry point, the handler is the logic, `IEmailSender` is the port to the external provider.
192
+ - Register the job class nowhere: the activator resolves it from the scope (its constructor dependencies must be registered).
193
+
187
194
  ---
188
195
 
189
196
  ## Retry & Error Handling
190
197
 
191
- ### Custom Retry Attribute
198
+ ### Expected failures vs. unexpected failures
192
199
 
193
- ```csharp
194
- [AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 60, 300, 900 })]
195
- public async Task ProcessPaymentAsync(string paymentId)
196
- {
197
- // Job logic with automatic retry on failure
198
- }
199
- ```
200
+ | Failure | Where it surfaces | What Hangfire does |
201
+ |---------|-------------------|--------------------|
202
+ | Expected (user gone, already processed, business rule) | `Result` failure from the handler | Job logs and **completes** -- retrying would not change the outcome |
203
+ | Unexpected (database down, provider timeout, HTTP 5xx) | Exception thrown by EF Core/HttpClient, not caught anywhere | Job **fails** and `[AutomaticRetry]` schedules the next attempt |
200
204
 
201
- ### Manual Retry Logic
205
+ There is no `try/catch` in the job or the handler: the exception the infrastructure already throws is the retry signal, and a transient provider failure is not mapped into a `Result` (that would turn a retryable outage into a completed job).
206
+
207
+ ### Custom Retry Attribute
202
208
 
203
209
  ```csharp
204
- public async Task SendEmailWithRetryAsync(string email)
205
- {
206
- int maxRetries = 3;
207
- int attempt = 0;
210
+ using Hangfire;
208
211
 
209
- while (attempt < maxRetries)
210
- {
211
- try
212
- {
213
- await _emailSender.SendAsync(email, "Subject", "Body");
214
- return; // Success
215
- }
216
- catch (Exception ex)
217
- {
218
- attempt++;
219
- if (attempt >= maxRetries)
220
- {
221
- // Log and give up
222
- _logger.LogError(ex, "Failed to send email after {Attempts} attempts", maxRetries);
223
- throw;
224
- }
225
-
226
- await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
227
- }
228
- }
212
+ public sealed class SyncInvoicesJob
213
+ {
214
+ [AutomaticRetry(Attempts = 5, DelaysInSeconds = [60, 300, 900, 3600, 21600])]
215
+ public Task RunAsync(CancellationToken cancellationToken) => Task.CompletedTask;
229
216
  }
230
217
  ```
231
218
 
219
+ - `Attempts` counts retries after the first failure; after the last one the job moves to the Failed list on the dashboard (`OnAttemptsExceeded = AttemptsExceededAction.Delete` drops it instead).
220
+ - Never write a manual `while (attempt < max) { try ... catch ... }` loop inside a job: it hides the attempts from the dashboard and doubles the retry policy.
221
+
232
222
  ---
233
223
 
234
224
  ## Dashboard Authorization
@@ -237,71 +227,59 @@ public async Task SendEmailWithRetryAsync(string email)
237
227
  // Filters/HangfireAuthorizationFilter.cs
238
228
  using Hangfire.Dashboard;
239
229
 
240
- public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
230
+ public sealed class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
241
231
  {
242
232
  public bool Authorize(DashboardContext context)
243
233
  {
244
234
  var httpContext = context.GetHttpContext();
245
235
 
246
- // Allow in development
247
- if (httpContext.Request.Host.Host.Contains("localhost"))
236
+ if (httpContext.RequestServices.GetRequiredService<IHostEnvironment>().IsDevelopment())
248
237
  return true;
249
238
 
250
- // Require authenticated user with Admin role
251
239
  return httpContext.User.Identity?.IsAuthenticated == true
252
240
  && httpContext.User.IsInRole("Admin");
253
241
  }
254
242
  }
255
243
  ```
256
244
 
245
+ Never trust `Request.Host` to detect "localhost": the `Host` header is set by the client, so `Host: localhost` from the internet would open the dashboard. Decide by the hosting environment (server-side), and require an authenticated Admin everywhere else.
246
+
257
247
  ---
258
248
 
259
249
  ## Best Practices
260
250
 
261
- ### Avoid Scoped Services
251
+ ### Scoped services: inject them, the activator scopes per job
262
252
 
263
253
  ```csharp
264
- // ❌ BAD: Don't inject DbContext directly
265
- public class BadJobService
266
- {
267
- private readonly AppDbContext _db; // Scoped service!
254
+ using Microsoft.EntityFrameworkCore;
268
255
 
269
- public BadJobService(AppDbContext db) => _db = db;
270
- }
271
-
272
- // ✅ GOOD: Use IDbContextFactory
273
- public class GoodJobService
256
+ // ✅ Hangfire.AspNetCore creates a DI scope per job: a scoped DbContext is safe here
257
+ public sealed class RecalculateTotalsJob(ApplicationDbContext db)
274
258
  {
275
- private readonly IDbContextFactory<AppDbContext> _dbFactory;
276
-
277
- public GoodJobService(IDbContextFactory<AppDbContext> dbFactory)
278
- => _dbFactory = dbFactory;
279
-
280
- public async Task ProcessAsync()
281
- {
282
- await using var db = await _dbFactory.CreateDbContextAsync();
283
- // Use db...
284
- }
259
+ public Task<int> RunAsync(CancellationToken cancellationToken) =>
260
+ db.Orders.Where(o => o.Status == OrderStatus.Pending).CountAsync(cancellationToken);
285
261
  }
286
262
  ```
287
263
 
264
+ What is **not** safe: capturing a `DbContext` (or any scoped service) from a request and passing it into `BackgroundJob.Enqueue(() => ...)` -- the request scope is disposed long before the job runs. Pass ids; let the job resolve its own dependencies.
265
+
288
266
  ### Idempotent Jobs
289
267
 
290
268
  ```csharp
291
- public async Task ProcessOrderAsync(string orderId)
292
- {
293
- await using var db = await _dbFactory.CreateDbContextAsync();
269
+ using Microsoft.EntityFrameworkCore;
294
270
 
295
- var order = await db.Orders.FindAsync(orderId);
296
- if (order == null || order.Status == OrderStatus.Processed)
271
+ public sealed class ProcessOrderHandler(ApplicationDbContext db)
272
+ {
273
+ public async Task<Result> HandleAsync(Guid orderId, CancellationToken cancellationToken)
297
274
  {
298
- // Already processed or doesn't exist - skip
299
- return;
300
- }
275
+ var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, cancellationToken);
276
+ if (order is null || order.Status == OrderStatus.Processed)
277
+ return Result.Success();
301
278
 
302
- // Process order...
303
- order.Status = OrderStatus.Processed;
304
- await db.SaveChangesAsync();
279
+ order.Status = OrderStatus.Processed;
280
+ await db.SaveChangesAsync(cancellationToken);
281
+ return Result.Success();
282
+ }
305
283
  }
306
284
  ```
307
285
 
@@ -337,9 +315,10 @@ Access the dashboard at `/hangfire` (configured in Program.cs).
337
315
  | Issue | Cause | Solution |
338
316
  |-------|-------|----------|
339
317
  | Jobs not processing | Hangfire server not started | Ensure `AddHangfireServer()` is called |
340
- | DbContext errors | Using scoped DbContext | Use `IDbContextFactory` |
318
+ | `ObjectDisposedException` on DbContext | A request-scoped instance captured into the job | Pass ids; inject the DbContext into the job class (scoped per job) |
341
319
  | Jobs timing out | Long-running job | Split into smaller jobs with continuations |
342
- | Memory leaks | Not disposing DbContext | Use `await using` for DbContext |
320
+ | Restore fails with `NU1903` (Newtonsoft.Json) | `Hangfire.Core` pulls 11.0.1 | Reference `Newtonsoft.Json` 13.0.4 explicitly |
321
+ | `EnableLongPolling` never wakes the server on the `-pooler` endpoint | `LISTEN/NOTIFY` is unsupported in PgBouncer transaction mode (Neon docs) | `HangfireConnection` on the direct endpoint |
343
322
 
344
323
  ---
345
324
 
@@ -347,6 +326,7 @@ Access the dashboard at `/hangfire` (configured in Program.cs).
347
326
 
348
327
  - [Hangfire Documentation](https://docs.hangfire.io/)
349
328
  - [Hangfire Best Practices](https://docs.hangfire.io/en/latest/best-practices.html)
329
+ - [Hangfire.PostgreSql](https://github.com/hangfire-postgres/Hangfire.PostgreSql)
350
330
  - [Cron Expression Generator](https://crontab.guru/)
351
331
 
352
332
  ---