@dennisrongo/skills 0.1.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +230 -0
  3. package/bin/claude-skills.js +189 -0
  4. package/lib/install.js +111 -0
  5. package/lib/list.js +63 -0
  6. package/lib/paths.js +39 -0
  7. package/lib/remove.js +52 -0
  8. package/lib/skills.js +121 -0
  9. package/package.json +48 -0
  10. package/skills/_template/SKILL.md +34 -0
  11. package/skills/conventional-commits/SKILL.md +136 -0
  12. package/skills/diagnose/SKILL.md +140 -0
  13. package/skills/dotnet-onion-api/SKILL.md +267 -0
  14. package/skills/dotnet-onion-api/references/anti-patterns.md +155 -0
  15. package/skills/dotnet-onion-api/references/solution-layout.md +113 -0
  16. package/skills/dotnet-onion-api/references/templates/appsettings.md +75 -0
  17. package/skills/dotnet-onion-api/references/templates/base-controller.cs.md +90 -0
  18. package/skills/dotnet-onion-api/references/templates/csproj-files.md +178 -0
  19. package/skills/dotnet-onion-api/references/templates/dbcontext.cs.md +149 -0
  20. package/skills/dotnet-onion-api/references/templates/exception-middleware.cs.md +101 -0
  21. package/skills/dotnet-onion-api/references/templates/feature-slice.md +349 -0
  22. package/skills/dotnet-onion-api/references/templates/program-cs.md +171 -0
  23. package/skills/dotnet-onion-api/references/templates/worker-program.cs.md +166 -0
  24. package/skills/grill-with-docs/SKILL.md +203 -0
  25. package/skills/handoff/SKILL.md +155 -0
  26. package/skills/improve-codebase-architecture/DEEPENING.md +37 -0
  27. package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +44 -0
  28. package/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
  29. package/skills/improve-codebase-architecture/SKILL.md +121 -0
  30. package/skills/nextjs-app-router/SKILL.md +328 -0
  31. package/skills/nextjs-app-router/references/anti-patterns.md +203 -0
  32. package/skills/nextjs-app-router/references/folder-layout.md +151 -0
  33. package/skills/nextjs-app-router/references/good-patterns.md +212 -0
  34. package/skills/nextjs-app-router/references/templates/api-base.md +62 -0
  35. package/skills/nextjs-app-router/references/templates/api-slice.md +75 -0
  36. package/skills/nextjs-app-router/references/templates/auth-slice.md +95 -0
  37. package/skills/nextjs-app-router/references/templates/ci-and-hooks.md +94 -0
  38. package/skills/nextjs-app-router/references/templates/components-json.md +41 -0
  39. package/skills/nextjs-app-router/references/templates/env-and-utils.md +110 -0
  40. package/skills/nextjs-app-router/references/templates/eslint-prettier.md +82 -0
  41. package/skills/nextjs-app-router/references/templates/feature-slice.md +184 -0
  42. package/skills/nextjs-app-router/references/templates/form-with-zod.md +192 -0
  43. package/skills/nextjs-app-router/references/templates/middleware.md +88 -0
  44. package/skills/nextjs-app-router/references/templates/next-config.md +42 -0
  45. package/skills/nextjs-app-router/references/templates/package.md +99 -0
  46. package/skills/nextjs-app-router/references/templates/providers-and-store.md +79 -0
  47. package/skills/nextjs-app-router/references/templates/root-layout.md +146 -0
  48. package/skills/nextjs-app-router/references/templates/route-group-layouts.md +90 -0
  49. package/skills/nextjs-app-router/references/templates/tailwind-config.md +89 -0
  50. package/skills/nextjs-app-router/references/templates/testing.md +137 -0
  51. package/skills/nextjs-app-router/references/templates/tsconfig.md +40 -0
  52. package/skills/plan-and-build/SKILL.md +214 -0
  53. package/skills/pr-review/SKILL.md +132 -0
  54. package/skills/write-a-skill/SKILL.md +191 -0
@@ -0,0 +1,349 @@
1
+ # Feature slice template — example entity `Customer`
2
+
3
+ Use this for **Mode 2 (`add-feature`)**. Substitute `Customer` → `{{Feature}}` and `Customers` → folder/plural form.
4
+
5
+ ## 1. Domain — `src/{{Solution}}.Domain/Customers/Customer.cs`
6
+
7
+ ```csharp
8
+ namespace {{Solution}}.Domain.Customers;
9
+
10
+ public sealed class Customer
11
+ {
12
+ public int Id { get; private set; }
13
+ public string Name { get; private set; } = default!;
14
+ public string Email { get; private set; } = default!;
15
+ public DateTime CreatedUtc { get; private set; }
16
+
17
+ // EF needs a parameterless ctor; keep it private.
18
+ private Customer() { }
19
+
20
+ public Customer(string name, string email)
21
+ {
22
+ if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Name required.", nameof(name));
23
+ if (string.IsNullOrWhiteSpace(email)) throw new ArgumentException("Email required.", nameof(email));
24
+
25
+ Name = name.Trim();
26
+ Email = email.Trim().ToLowerInvariant();
27
+ CreatedUtc = DateTime.UtcNow;
28
+ }
29
+
30
+ public void Rename(string newName)
31
+ {
32
+ if (string.IsNullOrWhiteSpace(newName)) throw new ArgumentException("Name required.", nameof(newName));
33
+ Name = newName.Trim();
34
+ }
35
+ }
36
+ ```
37
+
38
+ ## 2. Application — port
39
+
40
+ `src/{{Solution}}.Application/Customers/ICustomerRepository.cs`
41
+
42
+ ```csharp
43
+ using {{Solution}}.Domain.Customers;
44
+
45
+ namespace {{Solution}}.Application.Customers;
46
+
47
+ public interface ICustomerRepository
48
+ {
49
+ Task<Customer?> GetByIdAsync(int id, CancellationToken ct = default);
50
+ Task<IReadOnlyList<Customer>> ListAsync(CancellationToken ct = default);
51
+ Task AddAsync(Customer customer, CancellationToken ct = default);
52
+ void Remove(Customer customer);
53
+ Task<bool> EmailExistsAsync(string email, CancellationToken ct = default);
54
+ }
55
+ ```
56
+
57
+ ## 3. Application — DTOs
58
+
59
+ `src/{{Solution}}.Application/Customers/Dtos/CustomerDto.cs`
60
+
61
+ ```csharp
62
+ namespace {{Solution}}.Application.Customers.Dtos;
63
+
64
+ public sealed record CustomerDto(int Id, string Name, string Email, DateTime CreatedUtc);
65
+
66
+ public sealed record CreateCustomerRequest(string Name, string Email);
67
+
68
+ public sealed record UpdateCustomerRequest(string Name);
69
+ ```
70
+
71
+ ## 4. Application — service + interface
72
+
73
+ `src/{{Solution}}.Application/Customers/ICustomerService.cs`
74
+
75
+ ```csharp
76
+ using {{Solution}}.Application.Customers.Dtos;
77
+
78
+ namespace {{Solution}}.Application.Customers;
79
+
80
+ public interface ICustomerService
81
+ {
82
+ Task<CustomerDto?> GetAsync(int id, CancellationToken ct = default);
83
+ Task<IReadOnlyList<CustomerDto>> ListAsync(CancellationToken ct = default);
84
+ Task<CustomerDto> CreateAsync(CreateCustomerRequest req, CancellationToken ct = default);
85
+ Task UpdateAsync(int id, UpdateCustomerRequest req, CancellationToken ct = default);
86
+ Task DeleteAsync(int id, CancellationToken ct = default);
87
+ }
88
+ ```
89
+
90
+ `src/{{Solution}}.Application/Customers/CustomerService.cs`
91
+
92
+ ```csharp
93
+ using {{Solution}}.Application.Common;
94
+ using {{Solution}}.Application.Common.Exceptions;
95
+ using {{Solution}}.Application.Customers.Dtos;
96
+ using {{Solution}}.Domain.Customers;
97
+ using AutoMapper;
98
+
99
+ namespace {{Solution}}.Application.Customers;
100
+
101
+ public sealed class CustomerService : ICustomerService
102
+ {
103
+ private readonly ICustomerRepository _repo;
104
+ private readonly IUnitOfWork _uow;
105
+ private readonly IMapper _mapper;
106
+
107
+ public CustomerService(ICustomerRepository repo, IUnitOfWork uow, IMapper mapper)
108
+ {
109
+ _repo = repo;
110
+ _uow = uow;
111
+ _mapper = mapper;
112
+ }
113
+
114
+ public async Task<CustomerDto?> GetAsync(int id, CancellationToken ct = default)
115
+ {
116
+ var c = await _repo.GetByIdAsync(id, ct);
117
+ return c is null ? null : _mapper.Map<CustomerDto>(c);
118
+ }
119
+
120
+ public async Task<IReadOnlyList<CustomerDto>> ListAsync(CancellationToken ct = default)
121
+ {
122
+ var list = await _repo.ListAsync(ct);
123
+ return _mapper.Map<IReadOnlyList<CustomerDto>>(list);
124
+ }
125
+
126
+ public async Task<CustomerDto> CreateAsync(CreateCustomerRequest req, CancellationToken ct = default)
127
+ {
128
+ if (await _repo.EmailExistsAsync(req.Email, ct))
129
+ throw new ConflictException($"Customer with email '{req.Email}' already exists.");
130
+
131
+ var customer = new Customer(req.Name, req.Email);
132
+ await _repo.AddAsync(customer, ct);
133
+ await _uow.SaveChangesAsync(ct);
134
+
135
+ return _mapper.Map<CustomerDto>(customer);
136
+ }
137
+
138
+ public async Task UpdateAsync(int id, UpdateCustomerRequest req, CancellationToken ct = default)
139
+ {
140
+ var customer = await _repo.GetByIdAsync(id, ct)
141
+ ?? throw new NotFoundException(nameof(Customer), id);
142
+
143
+ customer.Rename(req.Name);
144
+ await _uow.SaveChangesAsync(ct);
145
+ }
146
+
147
+ public async Task DeleteAsync(int id, CancellationToken ct = default)
148
+ {
149
+ var customer = await _repo.GetByIdAsync(id, ct)
150
+ ?? throw new NotFoundException(nameof(Customer), id);
151
+
152
+ _repo.Remove(customer);
153
+ await _uow.SaveChangesAsync(ct);
154
+ }
155
+ }
156
+ ```
157
+
158
+ ## 5. Application — AutoMapper profile
159
+
160
+ `src/{{Solution}}.Application/Customers/Mapping/CustomerProfile.cs`
161
+
162
+ ```csharp
163
+ using {{Solution}}.Application.Customers.Dtos;
164
+ using {{Solution}}.Domain.Customers;
165
+ using AutoMapper;
166
+
167
+ namespace {{Solution}}.Application.Customers.Mapping;
168
+
169
+ public sealed class CustomerProfile : Profile
170
+ {
171
+ public CustomerProfile()
172
+ {
173
+ CreateMap<Customer, CustomerDto>();
174
+ }
175
+ }
176
+ ```
177
+
178
+ ## 6. Infrastructure — EF configuration
179
+
180
+ `src/{{Solution}}.Infrastructure/Persistence/EntityConfigurations/CustomerConfiguration.cs`
181
+
182
+ ```csharp
183
+ using {{Solution}}.Domain.Customers;
184
+ using Microsoft.EntityFrameworkCore;
185
+ using Microsoft.EntityFrameworkCore.Metadata.Builders;
186
+
187
+ namespace {{Solution}}.Infrastructure.Persistence.EntityConfigurations;
188
+
189
+ public sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer>
190
+ {
191
+ public void Configure(EntityTypeBuilder<Customer> b)
192
+ {
193
+ b.ToTable("Customers");
194
+ b.HasKey(x => x.Id);
195
+ b.Property(x => x.Name).HasMaxLength(200).IsRequired();
196
+ b.Property(x => x.Email).HasMaxLength(320).IsRequired();
197
+ b.HasIndex(x => x.Email).IsUnique();
198
+ b.Property(x => x.CreatedUtc).IsRequired();
199
+ }
200
+ }
201
+ ```
202
+
203
+ ## 7. Infrastructure — repository adapter
204
+
205
+ `src/{{Solution}}.Infrastructure/Persistence/Repositories/CustomerRepository.cs`
206
+
207
+ ```csharp
208
+ using {{Solution}}.Application.Customers;
209
+ using {{Solution}}.Domain.Customers;
210
+ using Microsoft.EntityFrameworkCore;
211
+
212
+ namespace {{Solution}}.Infrastructure.Persistence.Repositories;
213
+
214
+ public sealed class CustomerRepository : ICustomerRepository
215
+ {
216
+ private readonly AppDbContext _db;
217
+ public CustomerRepository(AppDbContext db) => _db = db;
218
+
219
+ public Task<Customer?> GetByIdAsync(int id, CancellationToken ct = default) =>
220
+ _db.Customers.FirstOrDefaultAsync(c => c.Id == id, ct);
221
+
222
+ public async Task<IReadOnlyList<Customer>> ListAsync(CancellationToken ct = default) =>
223
+ await _db.Customers.AsNoTracking().OrderBy(c => c.Name).ToListAsync(ct);
224
+
225
+ public async Task AddAsync(Customer customer, CancellationToken ct = default) =>
226
+ await _db.Customers.AddAsync(customer, ct);
227
+
228
+ public void Remove(Customer customer) => _db.Customers.Remove(customer);
229
+
230
+ public Task<bool> EmailExistsAsync(string email, CancellationToken ct = default) =>
231
+ _db.Customers.AnyAsync(c => c.Email == email.ToLower(), ct);
232
+ }
233
+ ```
234
+
235
+ Also register the DbSet on `AppDbContext`:
236
+
237
+ ```csharp
238
+ public DbSet<Customer> Customers => Set<Customer>();
239
+ ```
240
+
241
+ ## 8. Api — controller
242
+
243
+ `src/{{Solution}}.Api/Controllers/CustomersController.cs`
244
+
245
+ ```csharp
246
+ using {{Solution}}.Application.Common;
247
+ using {{Solution}}.Application.Customers;
248
+ using {{Solution}}.Application.Customers.Dtos;
249
+ using Microsoft.AspNetCore.Mvc;
250
+
251
+ namespace {{Solution}}.Api.Controllers;
252
+
253
+ public sealed class CustomersController : BaseController
254
+ {
255
+ private readonly ICustomerService _service;
256
+
257
+ public CustomersController(IUserContext userContext, ICustomerService service)
258
+ : base(userContext)
259
+ {
260
+ _service = service;
261
+ }
262
+
263
+ [HttpGet("{id:int}")]
264
+ public async Task<ActionResult<CustomerDto>> Get(int id, CancellationToken ct)
265
+ {
266
+ var dto = await _service.GetAsync(id, ct);
267
+ return dto is null ? NotFound() : Ok(dto);
268
+ }
269
+
270
+ [HttpGet]
271
+ public async Task<ActionResult<IReadOnlyList<CustomerDto>>> List(CancellationToken ct) =>
272
+ Ok(await _service.ListAsync(ct));
273
+
274
+ [HttpPost]
275
+ public async Task<ActionResult<CustomerDto>> Create([FromBody] CreateCustomerRequest req, CancellationToken ct)
276
+ {
277
+ var dto = await _service.CreateAsync(req, ct);
278
+ return CreatedAtAction(nameof(Get), new { id = dto.Id }, dto);
279
+ }
280
+
281
+ [HttpPut("{id:int}")]
282
+ public async Task<IActionResult> Update(int id, [FromBody] UpdateCustomerRequest req, CancellationToken ct)
283
+ {
284
+ await _service.UpdateAsync(id, req, ct);
285
+ return NoContent();
286
+ }
287
+
288
+ [HttpDelete("{id:int}")]
289
+ public async Task<IActionResult> Delete(int id, CancellationToken ct)
290
+ {
291
+ await _service.DeleteAsync(id, ct);
292
+ return NoContent();
293
+ }
294
+ }
295
+ ```
296
+
297
+ ## 9. Tests — `tests/{{Solution}}.UnitTests/Customers/CustomerServiceTests.cs`
298
+
299
+ ```csharp
300
+ using {{Solution}}.Application.Common;
301
+ using {{Solution}}.Application.Common.Exceptions;
302
+ using {{Solution}}.Application.Customers;
303
+ using {{Solution}}.Application.Customers.Dtos;
304
+ using {{Solution}}.Application.Customers.Mapping;
305
+ using {{Solution}}.Domain.Customers;
306
+ using AutoMapper;
307
+ using FluentAssertions;
308
+ using NSubstitute;
309
+ using Xunit;
310
+
311
+ namespace {{Solution}}.UnitTests.Customers;
312
+
313
+ public class CustomerServiceTests
314
+ {
315
+ private readonly ICustomerRepository _repo = Substitute.For<ICustomerRepository>();
316
+ private readonly IUnitOfWork _uow = Substitute.For<IUnitOfWork>();
317
+ private readonly IMapper _mapper;
318
+ private readonly CustomerService _sut;
319
+
320
+ public CustomerServiceTests()
321
+ {
322
+ var cfg = new MapperConfiguration(c => c.AddProfile<CustomerProfile>());
323
+ _mapper = cfg.CreateMapper();
324
+ _sut = new CustomerService(_repo, _uow, _mapper);
325
+ }
326
+
327
+ [Fact]
328
+ public async Task CreateAsync_persists_and_returns_dto()
329
+ {
330
+ _repo.EmailExistsAsync("a@b.com", Arg.Any<CancellationToken>()).Returns(false);
331
+
332
+ var dto = await _sut.CreateAsync(new CreateCustomerRequest("Alice", "a@b.com"));
333
+
334
+ dto.Email.Should().Be("a@b.com");
335
+ await _repo.Received(1).AddAsync(Arg.Any<Customer>(), Arg.Any<CancellationToken>());
336
+ await _uow.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
337
+ }
338
+
339
+ [Fact]
340
+ public async Task CreateAsync_throws_when_email_exists()
341
+ {
342
+ _repo.EmailExistsAsync("a@b.com", Arg.Any<CancellationToken>()).Returns(true);
343
+
344
+ var act = async () => await _sut.CreateAsync(new CreateCustomerRequest("Alice", "a@b.com"));
345
+
346
+ await act.Should().ThrowAsync<ConflictException>();
347
+ }
348
+ }
349
+ ```
@@ -0,0 +1,171 @@
1
+ # `src/{{Solution}}.Api/Program.cs`
2
+
3
+ Thin top-level program; all wiring is in extension methods.
4
+
5
+ ```csharp
6
+ using {{Solution}}.Api.Extensions;
7
+ using {{Solution}}.Api.Middlewares;
8
+ using {{Solution}}.Application;
9
+ using {{Solution}}.Infrastructure;
10
+
11
+ var builder = WebApplication.CreateBuilder(args);
12
+
13
+ builder.Services
14
+ .AddApplication()
15
+ .AddInfrastructure(builder.Configuration)
16
+ .AddApiServices(builder.Configuration)
17
+ .AddJwtAuth(builder.Configuration)
18
+ .AddSwaggerDocs()
19
+ .AddCorsPolicies(builder.Configuration);
20
+
21
+ var app = builder.Build();
22
+
23
+ app.UseCustomExceptionHandler();
24
+
25
+ if (app.Environment.IsDevelopment() || app.Environment.IsStaging())
26
+ {
27
+ app.UseSwagger();
28
+ app.UseSwaggerUI();
29
+ }
30
+
31
+ app.UseHttpsRedirection();
32
+ app.UseCors();
33
+ app.UseAuthentication();
34
+ app.UseAuthorization();
35
+ app.MapControllers();
36
+ app.Run();
37
+
38
+ // Expose Program for WebApplicationFactory<Program> in integration tests.
39
+ public partial class Program;
40
+ ```
41
+
42
+ ## `Extensions/ApiBehaviorExtensions.cs`
43
+
44
+ ```csharp
45
+ using {{Solution}}.Api.Models;
46
+ using Microsoft.AspNetCore.Mvc;
47
+
48
+ namespace {{Solution}}.Api.Extensions;
49
+
50
+ public static class ApiBehaviorExtensions
51
+ {
52
+ public static IServiceCollection AddApiServices(this IServiceCollection services, IConfiguration config)
53
+ {
54
+ services.AddControllers()
55
+ .ConfigureApiBehaviorOptions(options =>
56
+ {
57
+ options.InvalidModelStateResponseFactory = ctx =>
58
+ {
59
+ var errors = ctx.ModelState
60
+ .Where(e => e.Value?.Errors.Count > 0)
61
+ .SelectMany(x => x.Value!.Errors)
62
+ .Select(x => x.ErrorMessage)
63
+ .ToArray();
64
+
65
+ return new BadRequestObjectResult(new ApiValidationErrorResponse
66
+ {
67
+ Errors = errors,
68
+ Message = "One or more validation errors occurred."
69
+ });
70
+ };
71
+ });
72
+
73
+ services.AddEndpointsApiExplorer();
74
+ services.AddHttpContextAccessor();
75
+ return services;
76
+ }
77
+ }
78
+ ```
79
+
80
+ ## `Extensions/AuthExtensions.cs`
81
+
82
+ ```csharp
83
+ using Microsoft.AspNetCore.Authentication.JwtBearer;
84
+ using Microsoft.IdentityModel.Tokens;
85
+ using System.Text;
86
+
87
+ namespace {{Solution}}.Api.Extensions;
88
+
89
+ public static class AuthExtensions
90
+ {
91
+ public static IServiceCollection AddJwtAuth(this IServiceCollection services, IConfiguration config)
92
+ {
93
+ var secret = config["AppSettings:SecretToken"]
94
+ ?? throw new InvalidOperationException("AppSettings:SecretToken is required.");
95
+
96
+ services
97
+ .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
98
+ .AddJwtBearer(options =>
99
+ {
100
+ options.TokenValidationParameters = new TokenValidationParameters
101
+ {
102
+ ValidateIssuerSigningKey = true,
103
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)),
104
+ ValidateIssuer = false,
105
+ ValidateAudience = false,
106
+ };
107
+ });
108
+
109
+ services.AddAuthorization();
110
+ return services;
111
+ }
112
+ }
113
+ ```
114
+
115
+ ## `Extensions/SwaggerExtensions.cs`
116
+
117
+ ```csharp
118
+ using Microsoft.OpenApi.Models;
119
+
120
+ namespace {{Solution}}.Api.Extensions;
121
+
122
+ public static class SwaggerExtensions
123
+ {
124
+ public static IServiceCollection AddSwaggerDocs(this IServiceCollection services)
125
+ {
126
+ services.AddSwaggerGen(c =>
127
+ {
128
+ c.SwaggerDoc("v1", new OpenApiInfo { Title = "{{Solution}} API", Version = "v1" });
129
+
130
+ var jwt = new OpenApiSecurityScheme
131
+ {
132
+ Name = "Authorization",
133
+ Type = SecuritySchemeType.Http,
134
+ Scheme = "bearer",
135
+ BearerFormat = "JWT",
136
+ In = ParameterLocation.Header,
137
+ Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
138
+ };
139
+ c.AddSecurityDefinition("Bearer", jwt);
140
+ c.AddSecurityRequirement(new OpenApiSecurityRequirement { [jwt] = Array.Empty<string>() });
141
+ });
142
+
143
+ return services;
144
+ }
145
+ }
146
+ ```
147
+
148
+ ## `Extensions/CorsExtensions.cs`
149
+
150
+ ```csharp
151
+ namespace {{Solution}}.Api.Extensions;
152
+
153
+ public static class CorsExtensions
154
+ {
155
+ public static IServiceCollection AddCorsPolicies(this IServiceCollection services, IConfiguration config)
156
+ {
157
+ var origins = config.GetSection("AppSettings:AllowedOrigins").Get<string[]>() ?? [];
158
+
159
+ services.AddCors(options =>
160
+ {
161
+ options.AddDefaultPolicy(p => p
162
+ .WithOrigins(origins)
163
+ .AllowAnyHeader()
164
+ .AllowAnyMethod()
165
+ .AllowCredentials());
166
+ });
167
+
168
+ return services;
169
+ }
170
+ }
171
+ ```
@@ -0,0 +1,166 @@
1
+ # Worker microservice template (`BackgroundService`)
2
+
3
+ Use for **Mode 3 (`add-worker`)**. One worker = one `.csproj`.
4
+
5
+ ## `src/{{Solution}}.Workers.{{Worker}}/Program.cs`
6
+
7
+ ```csharp
8
+ using {{Solution}}.Application;
9
+ using {{Solution}}.Infrastructure;
10
+ using {{Solution}}.Workers.{{Worker}};
11
+
12
+ var builder = Host.CreateApplicationBuilder(args);
13
+
14
+ builder.Services
15
+ .AddApplication()
16
+ .AddInfrastructure(builder.Configuration);
17
+
18
+ builder.Services.AddHostedService<Worker>();
19
+
20
+ var host = builder.Build();
21
+ await host.RunAsync();
22
+ ```
23
+
24
+ ## `src/{{Solution}}.Workers.{{Worker}}/Worker.cs`
25
+
26
+ Choose **one** of the two `ExecuteAsync` bodies below based on the queue technology.
27
+
28
+ ### Azure Service Bus (preferred for transactional queue workloads)
29
+
30
+ ```csharp
31
+ using Azure.Messaging.ServiceBus;
32
+ using Microsoft.Extensions.Configuration;
33
+ using Microsoft.Extensions.Hosting;
34
+ using Microsoft.Extensions.Logging;
35
+
36
+ namespace {{Solution}}.Workers.{{Worker}};
37
+
38
+ public sealed class Worker : BackgroundService
39
+ {
40
+ private readonly ILogger<Worker> _logger;
41
+ private readonly ServiceBusClient _client;
42
+ private readonly string _queueName;
43
+
44
+ public Worker(ILogger<Worker> logger, IConfiguration config)
45
+ {
46
+ _logger = logger;
47
+ var connString = config.GetConnectionString("ServiceBus")
48
+ ?? throw new InvalidOperationException("ConnectionStrings:ServiceBus required.");
49
+ _queueName = config["Worker:QueueName"]
50
+ ?? throw new InvalidOperationException("Worker:QueueName required.");
51
+ _client = new ServiceBusClient(connString);
52
+ }
53
+
54
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
55
+ {
56
+ await using var processor = _client.CreateProcessor(_queueName, new ServiceBusProcessorOptions
57
+ {
58
+ AutoCompleteMessages = false,
59
+ MaxConcurrentCalls = 1
60
+ });
61
+
62
+ processor.ProcessMessageAsync += async args =>
63
+ {
64
+ try
65
+ {
66
+ _logger.LogInformation("Received message {Id}", args.Message.MessageId);
67
+
68
+ // TODO: deserialize, call into Application service via DI scope, persist results.
69
+ // Use args.CancellationToken (linked to stoppingToken).
70
+
71
+ await args.CompleteMessageAsync(args.Message, args.CancellationToken);
72
+ }
73
+ catch (Exception ex)
74
+ {
75
+ _logger.LogError(ex, "Failed to process message {Id}", args.Message.MessageId);
76
+ await args.AbandonMessageAsync(args.Message, cancellationToken: args.CancellationToken);
77
+ }
78
+ };
79
+
80
+ processor.ProcessErrorAsync += args =>
81
+ {
82
+ _logger.LogError(args.Exception, "Service Bus error from {Source}", args.ErrorSource);
83
+ return Task.CompletedTask;
84
+ };
85
+
86
+ await processor.StartProcessingAsync(stoppingToken);
87
+ await Task.Delay(Timeout.Infinite, stoppingToken);
88
+ await processor.StopProcessingAsync(CancellationToken.None);
89
+ }
90
+
91
+ public override async Task StopAsync(CancellationToken cancellationToken)
92
+ {
93
+ await _client.DisposeAsync();
94
+ await base.StopAsync(cancellationToken);
95
+ }
96
+ }
97
+ ```
98
+
99
+ ### Azure Storage Queue (when there's no Service Bus)
100
+
101
+ ```csharp
102
+ using Azure.Storage.Queues;
103
+ using Azure.Storage.Queues.Models;
104
+
105
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
106
+ {
107
+ var queue = new QueueClient(_connectionString, _queueName);
108
+ await queue.CreateIfNotExistsAsync(cancellationToken: stoppingToken);
109
+
110
+ while (!stoppingToken.IsCancellationRequested)
111
+ {
112
+ QueueMessage[] messages = await queue.ReceiveMessagesAsync(maxMessages: 10, cancellationToken: stoppingToken);
113
+
114
+ if (messages.Length == 0)
115
+ {
116
+ try { await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); }
117
+ catch (TaskCanceledException) { break; }
118
+ continue;
119
+ }
120
+
121
+ foreach (var msg in messages)
122
+ {
123
+ try
124
+ {
125
+ using var scope = _serviceProvider.CreateScope();
126
+ // Resolve scoped services here, dispatch.
127
+ await queue.DeleteMessageAsync(msg.MessageId, msg.PopReceipt, stoppingToken);
128
+ }
129
+ catch (Exception ex)
130
+ {
131
+ _logger.LogError(ex, "Worker failed processing message {Id}", msg.MessageId);
132
+ // Don't delete — let the queue re-deliver after the visibility timeout.
133
+ }
134
+ }
135
+ }
136
+ }
137
+ ```
138
+
139
+ Inject `IServiceProvider` to create a scope per message (so EF Core's scoped `AppDbContext` is fresh each iteration).
140
+
141
+ ## `appsettings.json`
142
+
143
+ ```json
144
+ {
145
+ "Logging": {
146
+ "LogLevel": {
147
+ "Default": "Information",
148
+ "Microsoft.Hosting.Lifetime": "Information"
149
+ }
150
+ },
151
+ "ConnectionStrings": {
152
+ "Default": "",
153
+ "ServiceBus": ""
154
+ },
155
+ "Worker": {
156
+ "QueueName": ""
157
+ }
158
+ }
159
+ ```
160
+
161
+ ## What the worker must **never** do
162
+
163
+ - `while (true) { ... Task.Delay(5s); }` without checking `stoppingToken` — breaks graceful shutdown.
164
+ - Resolve scoped services from the root provider — always create an `IServiceScope` per message.
165
+ - Reference `{{Solution}}.Api`. Workers depend on Application + Infrastructure only.
166
+ - Catch `Exception` and silently delete the message — that loses the failure forever.