Extract Product bounded context into standalone Product microservice#57
Extract Product bounded context into standalone Product microservice#57devin-ai-integration[bot] wants to merge 4 commits into
Conversation
- Port Product/ProductCategory entities + BaseEntity into Product.Domain (drop OrderDetails nav; keep Parent/Children + ProductCategory) - Port verbatim fluent config into ProductDbContext (AppProducts/AppProductCategories, Name index, 18,2 precision, Parent->Children Restrict); EnsureCreated on boot - Add ProductVM DTO + full CRUD controller with real EF persistence, [Authorize], [ProducesResponseType], Swagger - Wire JWT bearer (HS256) per canonical Identity contract; UseAuthentication/UseAuthorization - Use absolute routes so gateway prefix-stripping maps /api/products -> / - Fix scaffold Shared project reference path (..\..\Shared -> ..\..\..\Shared) - Add e2e-smoke.sh (health 200 + unauth 401 through gateway)
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
| [HttpGet("/")] | ||
| [ProducesResponseType(typeof(IEnumerable<ProductVM>), StatusCodes.Status200OK)] |
There was a problem hiding this comment.
🚩 Absolute route templates bypass controller-level route prefix convention
All action methods use absolute route templates starting with / (e.g., [HttpGet("/")], [HttpGet("/{id:int}")]). This is a deliberate departure from the [Route("api/[controller]")] convention used by every other controller in the solution (src/Services/Identity/Identity.API/Controllers/IdentityController.cs:6, src/Services/Customer/Customer.API/Controllers/CustomerController.cs:6, src/Services/Order/Order.API/Controllers/OrderController.cs:6). The design works correctly because the YARP gateway strips /api/products via PathRemovePrefix (src/ApiGateway/appsettings.json:29), so the service receives root-relative paths. However, this means the Product service's Swagger UI will show routes at /, /{id}, /categories rather than under a prefix — making direct-to-service testing less intuitive and establishing a different pattern from the other microservices that will need to be reconciled when they are similarly migrated.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Intentional and pinned by the carve-out spec. The gateway route Match /api/products/{**catch-all} + PathRemovePrefix /api/products means the service receives the prefix stripped, so external GET /api/products arrives as GET /. Absolute routes ([HttpGet("/")], [HttpGet("/{id:int}")]) are required to map the resource controller to the service root. The other services still carry the placeholder [Route("api/[controller]")] from the scaffold and haven't been wired to their gateway contracts yet; reconciling that convention is part of their own carve-outs. Leaving as-is for this PR.
| modelBuilder.Entity<ProductCategory>(entity => | ||
| { | ||
| entity.Property(p => p.Name).IsRequired().HasMaxLength(100); | ||
| entity.Property(p => p.Description).HasMaxLength(500); | ||
| entity.ToTable("AppProductCategories"); | ||
| }); |
There was a problem hiding this comment.
🚩 No unique constraint on ProductCategory.Name allows duplicate categories under concurrency
The ResolveCategoryAsync method (src/Services/Product/Product.API/Controllers/ProductController.cs:161-173) queries for a category by name and creates a new one if not found. The DbContext configuration at src/Services/Product/Product.Infrastructure/Data/ProductDbContext.cs:20-25 does not define a unique index on ProductCategory.Name. Under concurrent requests creating products with the same new category name, both requests will find no existing category and each will insert a duplicate. Over time this can lead to multiple categories with identical names in the database, with products silently split across them. A unique index on Name plus retry-on-conflict logic would prevent this.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Valid edge case, but intentionally out of scope here. The carve-out spec requires the fluent config to be ported verbatim from the monolith's ApplicationDbContext, which does not define a unique index on ProductCategory.Name — so adding one would diverge from source-of-truth parity. The find-or-create in ResolveCategoryAsync is a convenience for write paths (the monolith DTO is read-oriented and flattens the category name); hardening it against concurrent duplicate-name inserts (unique index + retry-on-conflict) is a reasonable follow-up but would be a deliberate deviation from the ported schema. Flagging for the requester rather than changing it in this PR.
| using (var scope = app.Services.CreateScope()) | ||
| { | ||
| var db = scope.ServiceProvider.GetRequiredService<ProductDbContext>(); | ||
| var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>(); | ||
|
|
||
| for (var attempt = 1; attempt <= 10; attempt++) | ||
| { | ||
| try | ||
| { | ||
| db.Database.EnsureCreated(); | ||
| break; | ||
| } | ||
| catch (Exception ex) when (attempt < 10) | ||
| { | ||
| logger.LogWarning(ex, "Database not ready (attempt {Attempt}/10); retrying in 3s...", attempt); | ||
| Thread.Sleep(TimeSpan.FromSeconds(3)); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🚩 Database initialization blocks startup — health checks unavailable during retry window
The EnsureCreated() retry loop at Program.cs:71-89 runs synchronously (using Thread.Sleep) after the middleware pipeline is built but before app.Run(). This means the HTTP server is not listening during the up-to-30-second retry window. Container orchestrators or the gateway's health probes hitting /healthz will get connection refused during this period. The smoke test at e2e-smoke.sh:19 accommodates this with a 90-second wait, but in Kubernetes with tighter liveness probe deadlines this could cause unnecessary restarts. Moving DB initialization to an IHostedService or IStartupFilter (or using await db.Database.EnsureCreatedAsync() with Task.Delay) would allow the server to start accepting health checks immediately.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
This is the conventional EF Core startup pattern (run EnsureCreated/Migrate before app.Run()), and it's bounded: the loop only sleeps while Postgres is unreachable and breaks immediately once it's ready (typically the first attempt). The retry exists purely to absorb compose startup ordering. The validation gate confirms /healthz returns 200 once up, and the e2e script's wait accommodates the brief window. A K8s deployment would normally pair this with a startupProbe/initialDelaySeconds rather than relying on liveness during init. Moving to an IHostedService/IStartupFilter is a reasonable hardening but out of scope for this carve-out; leaving as-is.
… default Uncategorized)
| var resolvedCategory = await ResolveCategoryAsync(model.ProductCategoryName); | ||
| if (resolvedCategory.Name != product.ProductCategory?.Name) | ||
| { | ||
| product.ProductCategory = resolvedCategory; | ||
| } |
There was a problem hiding this comment.
📝 Info: Update with null category name silently reclassifies product to 'Uncategorized'
In the Update action (ProductController.cs:95-128), ResolveCategoryAsync is always called with model.ProductCategoryName. If the API consumer sends a PUT with ProductCategoryName omitted (null), the method defaults to "Uncategorized" (ProductController.cs:170). This follows PUT semantics (full replacement), but it means a consumer intending to update only the product name must also echo back the current category name, or the product will be silently re-categorized. This is consistent with the Create behavior but could surprise API consumers expecting partial-update semantics.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Intentional and, as noted, correct PUT full-replacement semantics — now consistent with Create (this is exactly the consistency that the previous review round asked for). A safer partial-update story (PATCH, or making category-name required on update) is a reasonable product decision but a behavioral/API-surface change beyond this carve-out's scope, so I'm leaving the PUT semantics as-is and flagging it for the requester.
…ve Location on Create
| modelBuilder.Entity<ProductCategory>(entity => | ||
| { | ||
| entity.Property(p => p.Name).IsRequired().HasMaxLength(100); | ||
| entity.Property(p => p.Description).HasMaxLength(500); | ||
| entity.ToTable("AppProductCategories"); | ||
| }); |
There was a problem hiding this comment.
🚩 Cascade delete on Product→ProductCategory relationship is unconfigured
The OnModelCreating explicitly configures DeleteBehavior.Restrict for the Parent→Children self-referencing relationship (ProductDbContext.cs:35-37), but the Product→ProductCategory relationship is left to EF Core convention. Since ProductCategoryId is a non-nullable int (Product.cs:17), EF Core treats it as a required relationship and defaults to DeleteBehavior.Cascade. This means deleting a ProductCategory row would cascade-delete all its products. There is no controller endpoint to delete categories, so this isn't immediately exploitable, but if one is added later or a manual DB operation is performed, products could be silently deleted. This contrasts with the explicit Restrict on Parent→Children, suggesting the author intended defensive FK behavior.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
This matches the source-of-truth verbatim. In the monolith's ApplicationDbContext, the only explicit OnDelete on the Product aggregate is Parent → Children (Restrict); the Product → ProductCategory relationship is left to EF convention (Cascade for the required FK) there too. The carve-out spec requires porting the fluent config verbatim, so I've preserved exactly that — adding an explicit Restrict/SetNull here would diverge from the monolith schema. As you note, there's no category-delete endpoint, so it's not reachable today. Reasonable hardening for a follow-up (alongside the category unique-index point), but I'm keeping parity in this PR and flagging it for the requester.
Summary
Implements the
Productservice scaffold atsrc/Services/Product/as a standalone .NET 10 microservice carved out of the QuickApp monolith. Entities, persistence config, and DTO are ported fromquickapp-monolith; the monolith itself is untouched. All endpoints require a JWT validated verbatim against the canonical Identity contract, and the service sits behind the existing/api/productsgateway route.Decoupling
The
Productbounded context is fully self-contained — zero references toOrder/OrderDetailanywhere in the service. The monolith'sProduct.OrderDetailsnavigation was dropped; the self-referentialParent/Childrenhierarchy and the ownedProductCategoryrelationship are kept.Domain (
Product.Domain)ProductandProductCategorymirror the monolith verbatim exceptOrderDetailsis removed. KeepsParentId/Parent/ChildrenandProductCategoryId/ProductCategory, usingrequiredper conventions.BaseEntity(int Id,CreatedBy/UpdatedBy[MaxLength(40)],CreatedDate/UpdatedDate) is recreated inside the service — it does not reference the monolith.Producttype name, so infra/API reference it viausing ProductEntity = Product.Domain.Product;.Persistence (
Product.Infrastructure.ProductDbContext, Npgsql)Fluent config ported verbatim:
Schema is created on boot via
EnsureCreated()(with a short connect-retry loop for compose startup ordering). Connection string read fromConnectionStrings:DefaultConnection.API (
Product.API)ProductVMmirrors the monolith DTO (flattenedProductCategoryName); manual entity↔DTO mapping.[ProducesResponseType], Swagger (with Bearer security scheme). OptionalGET /categoriesendpoint. Create/Update resolveProductCategoryby name (find-or-create) to satisfy therequirednav.[Authorize]→ unauthenticated requests get401. JWT bearer wired exactly per the canonical contract (HS256;MapInboundClaims=false;NameClaimType=name,RoleClaimType=role; validate issuer/audience/lifetime/signing-key againstJwt:Key|Issuer|Audienceadded toappsettings.json).app.UseAuthentication(); app.UseAuthorization();added. NuGetMicrosoft.AspNetCore.Authentication.JwtBearer(10.*) added./api/productsprefix, so the controller uses absolute routes ([HttpGet("/")],[HttpGet("/{id:int}")], …) → externalGET /api/productsmaps to serviceGET /. Health remains at/healthz(open, no token).Scaffold bug fix
Product.API.csprojreferenced Shared via..\..\Shared\...(resolves to non-existentsrc/Services/Shared, MSB9008 warning). Fixed to..\..\..\Shared\.... Dockerfile COPY lines are context-root-relative and already correct — left unchanged.Validation gate (all passing)
cd src && dotnet build Services/Product/Product.API/Product.API.csproj -c Release→ succeeds, 0 warnings.src/Services/ProductforOrder/OrderDetail→ zero refs.docker compose up --no-deps postgres product-service api-gateway(only these three;--no-depsavoids the unrelated brokennotification-servicebuild pulled in via the gateway'sdepends_on):GET /api/products/healthz→ 200GET /api/products(no token) → 401GET /api/productswith valid canonical HS256 token → 200; tampered signature → 401POST /api/products(valid token) → 201, round-trips viaGET /api/productsAppProductsandAppProductCategoriescreated inproductdb(verified viapsql \dt), withIX_AppProducts_Name,numeric(18,2)price columns, andFK_AppProducts_AppProducts_ParentId ... ON DELETE RESTRICT.src/Services/Product/e2e-smoke.shasserts health 200 + unauth 401 through the gateway and exits non-zero on failure.Scope
Changes are confined to
src/Services/Product/**. The monolith, gatewayappsettings.json, anddocker-compose.ymlwere not modified.Link to Devin session: https://partner-workshops.devinenterprise.com/sessions/d26161a657404ddd8a1ea97ec36348b5
Requested by: @mbatchelor81