Setting the file. One moment.
Service Template · .NET Backend Patterns · wshobson/agents · Skills Docs
ContentsBack to the top of the page Turborepo Caching
Python
Raw file
assets/ service-template.cs
C# · 336 lines · 12 KB
Application
.
Services
;
10
11 /// < summary >
12 /// Configuration options for the service
13 /// </ summary >
14 public class ProductServiceOptions
15 {
16 public const string SectionName = "ProductService" ;
17
18 public int DefaultPageSize { get ; set ; } = 50 ;
19 public int MaxPageSize { get ; set ; } = 200 ;
20 public TimeSpan CacheDuration { get ; set ; } = TimeSpan. FromMinutes ( 15 );
21 public bool EnableEnrichment { get ; set ; } = true ;
22 }
23
24 /// < summary >
25 /// Generic result type for operations that can fail
26 /// </ summary >
27 public class Result < T >
28 {
29 public bool IsSuccess { get ; }
30 public T ? Value { get ; }
31 public string ? Error { get ; }
32 public string ? ErrorCode { get ; }
33
34 private Result ( bool isSuccess , T ? value , string ? error , string ? errorCode )
35 {
36 IsSuccess = isSuccess;
37 Value = value;
38 Error = error;
39 ErrorCode = errorCode;
40 }
41
42 public static Result < T > Success ( T value ) => new ( true , value, null , null );
43 public static Result < T > Failure ( string error , string ? code = null ) => new ( false , default , error, code);
44
45 public Result < TNew > Map < TNew >( Func < T , TNew > mapper ) =>
46 IsSuccess ? Result< TNew >. Success ( mapper (Value ! )) : Result< TNew >. Failure (Error ! , ErrorCode);
47 }
48
49 /// < summary >
50 /// Service interface - define the contract
51 /// </ summary >
52 public interface IProductService
53 {
54 Task < Result < Product >> GetByIdAsync ( string id , CancellationToken ct = default );
55 Task < Result < PagedResult < Product >>> SearchAsync ( ProductSearchRequest request , CancellationToken ct = default );
56 Task < Result < Product >> CreateAsync ( CreateProductRequest request , CancellationToken ct = default );
57 Task < Result < Product >> UpdateAsync ( string id , UpdateProductRequest request , CancellationToken ct = default );
58 Task < Result < bool >> DeleteAsync ( string id , CancellationToken ct = default );
59 }
60
61 /// < summary >
62 /// Service implementation with full patterns
63 /// </ summary >
64 public class ProductService : IProductService
65 {
66 private readonly IProductRepository _repository ;
67 private readonly ICacheService _cache ;
68 private readonly IValidator < CreateProductRequest > _createValidator ;
69 private readonly IValidator < UpdateProductRequest > _updateValidator ;
70 private readonly ILogger < ProductService > _logger ;
71 private readonly ProductServiceOptions _options ;
72
73 public ProductService (
74 IProductRepository repository ,
75 ICacheService cache ,
76 IValidator < CreateProductRequest > createValidator ,
77 IValidator < UpdateProductRequest > updateValidator ,
78 ILogger < ProductService > logger ,
79 IOptions < ProductServiceOptions > options )
80 {
81 _repository = repository ?? throw new ArgumentNullException ( nameof (repository));
82 _cache = cache ?? throw new ArgumentNullException ( nameof (cache));
83 _createValidator = createValidator ?? throw new ArgumentNullException ( nameof (createValidator));
84 _updateValidator = updateValidator ?? throw new ArgumentNullException ( nameof (updateValidator));
85 _logger = logger ?? throw new ArgumentNullException ( nameof (logger));
86 _options = options ? .Value ?? throw new ArgumentNullException ( nameof (options));
87 }
88
89 public async Task < Result < Product >> GetByIdAsync ( string id , CancellationToken ct = default )
90 {
91 if ( string . IsNullOrWhiteSpace (id))
92 return Result< Product >. Failure ( "Product ID is required" , "INVALID_ID" );
93
94 try
95 {
96 // Try cache first
97 var cacheKey = GetCacheKey (id);
98 var cached = await _cache. GetAsync < Product >(cacheKey, ct);
99
100 if (cached != null )
101 {
102 _logger. LogDebug ( "Cache hit for product {ProductId}" , id);
103 return Result< Product >. Success (cached);
104 }
105
106 // Fetch from repository
107 var product = await _repository. GetByIdAsync (id, ct);
108
109 if (product == null )
110 {
111 _logger. LogWarning ( "Product not found: {ProductId}" , id);
112 return Result< Product >. Failure ( $"Product ' { id } ' not found" , "NOT_FOUND" );
113 }
114
115 // Populate cache
116 await _cache. SetAsync (cacheKey, product, _options.CacheDuration, ct);
117
118 return Result< Product >. Success (product);
119 }
120 catch ( Exception ex )
121 {
122 _logger. LogError (ex, "Error retrieving product {ProductId}" , id);
123 return Result< Product >. Failure ( "An error occurred while retrieving the product" , "INTERNAL_ERROR" );
124 }
125 }
126
127 public async Task < Result < PagedResult < Product >>> SearchAsync (
128 ProductSearchRequest request ,
129 CancellationToken ct = default )
130 {
131 try
132 {
133 // Sanitize pagination
134 var pageSize = Math. Clamp (request.PageSize ?? _options.DefaultPageSize, 1 , _options.MaxPageSize);
135 var page = Math. Max (request.Page ?? 1 , 1 );
136
137 var sanitizedRequest = request with
138 {
139 PageSize = pageSize,
140 Page = page
141 };
142
143 // Execute search
144 var ( items , totalCount ) = await _repository. SearchAsync (sanitizedRequest, ct);
145
146 var result = new PagedResult < Product >
147 {
148 Items = items,
149 TotalCount = totalCount,
150 Page = page,
151 PageSize = pageSize,
152 TotalPages = ( int )Math. Ceiling (( double )totalCount / pageSize)
153 };
154
155 return Result< PagedResult < Product >>. Success (result);
156 }
157 catch ( Exception ex )
158 {
159 _logger. LogError (ex, "Error searching products with request {@Request}" , request);
160 return Result< PagedResult < Product >>. Failure ( "An error occurred while searching products" , "INTERNAL_ERROR" );
161 }
162 }
163
164 public async Task < Result < Product >> CreateAsync ( CreateProductRequest request , CancellationToken ct = default )
165 {
166 // Validate
167 var validation = await _createValidator. ValidateAsync (request, ct);
168 if ( ! validation.IsValid)
169 {
170 var errors = string . Join ( "; " , validation.Errors. Select ( e => e.ErrorMessage));
171 return Result< Product >. Failure (errors, "VALIDATION_ERROR" );
172 }
173
174 try
175 {
176 // Check for duplicates
177 var existing = await _repository. GetBySkuAsync (request.Sku, ct);
178 if (existing != null )
179 return Result< Product >. Failure ( $"Product with SKU ' { request . Sku } ' already exists" , "DUPLICATE_SKU" );
180
181 // Create entity
182 var product = new Product
183 {
184 Id = Guid. NewGuid (). ToString ( "N" ),
185 Name = request.Name,
186 Sku = request.Sku,
187 Price = request.Price,
188 CategoryId = request.CategoryId,
189 CreatedAt = DateTime.UtcNow
190 };
191
192 // Persist
193 var created = await _repository. CreateAsync (product, ct);
194
195 _logger. LogInformation ( "Created product {ProductId} with SKU {Sku}" , created.Id, created.Sku);
196
197 return Result< Product >. Success (created);
198 }
199 catch ( Exception ex )
200 {
201 _logger. LogError (ex, "Error creating product with SKU {Sku}" , request.Sku);
202 return Result< Product >. Failure ( "An error occurred while creating the product" , "INTERNAL_ERROR" );
203 }
204 }
205
206 public async Task < Result < Product >> UpdateAsync (
207 string id ,
208 UpdateProductRequest request ,
209 CancellationToken ct = default )
210 {
211 if ( string . IsNullOrWhiteSpace (id))
212 return Result< Product >. Failure ( "Product ID is required" , "INVALID_ID" );
213
214 // Validate
215 var validation = await _updateValidator. ValidateAsync (request, ct);
216 if ( ! validation.IsValid)
217 {
218 var errors = string . Join ( "; " , validation.Errors. Select ( e => e.ErrorMessage));
219 return Result< Product >. Failure (errors, "VALIDATION_ERROR" );
220 }
221
222 try
223 {
224 // Fetch existing
225 var existing = await _repository. GetByIdAsync (id, ct);
226 if (existing == null )
227 return Result< Product >. Failure ( $"Product ' { id } ' not found" , "NOT_FOUND" );
228
229 // Apply updates (only non-null values)
230 if (request.Name != null ) existing.Name = request.Name;
231 if (request.Price.HasValue) existing.Price = request.Price.Value;
232 if (request.CategoryId.HasValue) existing.CategoryId = request.CategoryId.Value;
233 existing.UpdatedAt = DateTime.UtcNow;
234
235 // Persist
236 var updated = await _repository. UpdateAsync (existing, ct);
237
238 // Invalidate cache
239 await _cache. RemoveAsync ( GetCacheKey (id), ct);
240
241 _logger. LogInformation ( "Updated product {ProductId}" , id);
242
243 return Result< Product >. Success (updated);
244 }
245 catch ( Exception ex )
246 {
247 _logger. LogError (ex, "Error updating product {ProductId}" , id);
248 return Result< Product >. Failure ( "An error occurred while updating the product" , "INTERNAL_ERROR" );
249 }
250 }
251
252 public async Task < Result < bool >> DeleteAsync ( string id , CancellationToken ct = default )
253 {
254 if ( string . IsNullOrWhiteSpace (id))
255 return Result< bool >. Failure ( "Product ID is required" , "INVALID_ID" );
256
257 try
258 {
259 var existing = await _repository. GetByIdAsync (id, ct);
260 if (existing == null )
261 return Result< bool >. Failure ( $"Product ' { id } ' not found" , "NOT_FOUND" );
262
263 // Soft delete
264 await _repository. DeleteAsync (id, ct);
265
266 // Invalidate cache
267 await _cache. RemoveAsync ( GetCacheKey (id), ct);
268
269 _logger. LogInformation ( "Deleted product {ProductId}" , id);
270
271 return Result< bool >. Success ( true );
272 }
273 catch ( Exception ex )
274 {
275 _logger. LogError (ex, "Error deleting product {ProductId}" , id);
276 return Result< bool >. Failure ( "An error occurred while deleting the product" , "INTERNAL_ERROR" );
277 }
278 }
279
280 private static string GetCacheKey ( string id ) => $"product: { id } " ;
281 }
282
283 // Supporting types
284 public record CreateProductRequest ( string Name , string Sku , decimal Price , int CategoryId );
285 public record UpdateProductRequest ( string ? Name = null , decimal ? Price = null , int ? CategoryId = null );
286 public record ProductSearchRequest (
287 string ? SearchTerm = null ,
288 int ? CategoryId = null ,
289 decimal ? MinPrice = null ,
290 decimal ? MaxPrice = null ,
291 int ? Page = null ,
292 int ? PageSize = null );
293
294 public class PagedResult < T >
295 {
296 public IReadOnlyList < T > Items { get ; init ; } = Array. Empty < T >();
297 public int TotalCount { get ; init ; }
298 public int Page { get ; init ; }
299 public int PageSize { get ; init ; }
300 public int TotalPages { get ; init ; }
301 public bool HasNextPage => Page < TotalPages;
302 public bool HasPreviousPage => Page > 1 ;
303 }
304
305 public class Product
306 {
307 public string Id { get ; set ; } = string .Empty;
308 public string Name { get ; set ; } = string .Empty;
309 public string Sku { get ; set ; } = string .Empty;
310 public decimal Price { get ; set ; }
311 public int CategoryId { get ; set ; }
312 public DateTime CreatedAt { get ; set ; }
313 public DateTime ? UpdatedAt { get ; set ; }
314 }
315
316 // Validators using FluentValidation
317 public class CreateProductRequestValidator : AbstractValidator < CreateProductRequest >
318 {
319 public CreateProductRequestValidator ()
320 {
321 RuleFor ( x => x.Name)
322 . NotEmpty (). WithMessage ( "Name is required" )
323 . MaximumLength ( 200 ). WithMessage ( "Name must not exceed 200 characters" );
324
325 RuleFor ( x => x.Sku)
326 . NotEmpty (). WithMessage ( "SKU is required" )
327 . MaximumLength ( 50 ). WithMessage ( "SKU must not exceed 50 characters" )
328 . Matches ( @"^[A-Z0-9\-]+$" ). WithMessage ( "SKU must contain only uppercase letters, numbers, and hyphens" );
329
330 RuleFor ( x => x.Price)
331 . GreaterThan ( 0 ). WithMessage ( "Price must be greater than 0" );
332
333 RuleFor ( x => x.CategoryId)
334 . GreaterThan ( 0 ). WithMessage ( "Category is required" );
335 }
336 }