-
Notifications
You must be signed in to change notification settings - Fork 0
Extract Product bounded context into standalone Product microservice #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
25f3761
f2b9b9b
b42ecdb
a8724fb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,29 +1,195 @@ | ||
| using Microsoft.AspNetCore.Authorization; | ||
| using Microsoft.AspNetCore.Mvc; | ||
| using Microsoft.EntityFrameworkCore; | ||
| using Product.API.ViewModels; | ||
| using Product.Infrastructure.Data; | ||
| using ProductEntity = Product.Domain.Product; | ||
|
|
||
| namespace Product.API.Controllers; | ||
|
|
||
| [ApiController] | ||
| [Route("api/[controller]")] | ||
| [Authorize] | ||
| public class ProductController : ControllerBase | ||
| { | ||
| private readonly ProductDbContext _db; | ||
| private readonly ILogger<ProductController> _logger; | ||
|
|
||
| public ProductController(ILogger<ProductController> logger) | ||
| public ProductController(ProductDbContext db, ILogger<ProductController> logger) | ||
| { | ||
| _db = db; | ||
| _logger = logger; | ||
| } | ||
|
|
||
| [HttpGet] | ||
| public IActionResult GetAll() | ||
| // External: GET /api/products -> gateway strips prefix -> GET / | ||
| [HttpGet("/")] | ||
| [ProducesResponseType(typeof(IEnumerable<ProductVM>), StatusCodes.Status200OK)] | ||
| public async Task<IActionResult> GetAll() | ||
| { | ||
| // TODO: Implement — migrate logic from monolith's ProductController | ||
| return Ok(new { service = "Product", status = "scaffold" }); | ||
| var products = await _db.Products | ||
| .Include(p => p.ProductCategory) | ||
| .AsNoTracking() | ||
| .ToListAsync(); | ||
|
|
||
| return Ok(products.Select(MapToVM)); | ||
| } | ||
|
|
||
| // External: GET /api/products/{id} -> GET /{id} | ||
| [HttpGet("/{id:int}")] | ||
| [ProducesResponseType(typeof(ProductVM), StatusCodes.Status200OK)] | ||
| [ProducesResponseType(StatusCodes.Status404NotFound)] | ||
| public async Task<IActionResult> GetById(int id) | ||
| { | ||
| var product = await _db.Products | ||
| .Include(p => p.ProductCategory) | ||
| .AsNoTracking() | ||
| .FirstOrDefaultAsync(p => p.Id == id); | ||
|
|
||
| if (product == null) | ||
| return NotFound(id); | ||
|
|
||
| return Ok(MapToVM(product)); | ||
| } | ||
|
|
||
| [HttpGet("{id}")] | ||
| public IActionResult GetById(int id) | ||
| // External: POST /api/products -> POST / | ||
| [HttpPost("/")] | ||
| [ProducesResponseType(typeof(ProductVM), StatusCodes.Status201Created)] | ||
| [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||
| public async Task<IActionResult> Create([FromBody] ProductVM model) | ||
| { | ||
| // TODO: Implement — migrate logic from monolith | ||
| return Ok(new { service = "Product", id }); | ||
| if (!ModelState.IsValid) | ||
| return BadRequest(ModelState); | ||
|
|
||
| if (string.IsNullOrWhiteSpace(model.Name)) | ||
| return BadRequest("Name is required."); | ||
|
|
||
| var category = await ResolveCategoryAsync(model.ProductCategoryName); | ||
|
|
||
| var product = new ProductEntity | ||
| { | ||
| Name = model.Name, | ||
| Description = model.Description, | ||
| Icon = model.Icon, | ||
| BuyingPrice = model.BuyingPrice, | ||
| SellingPrice = model.SellingPrice, | ||
| UnitsInStock = model.UnitsInStock, | ||
| IsActive = model.IsActive, | ||
| IsDiscontinued = model.IsDiscontinued, | ||
| ProductCategory = category, | ||
| CreatedDate = DateTime.UtcNow, | ||
| UpdatedDate = DateTime.UtcNow | ||
| }; | ||
|
|
||
| _db.Products.Add(product); | ||
| await _db.SaveChangesAsync(); | ||
|
|
||
| // Location reflects the public gateway path; the service itself receives /api/products stripped. | ||
| return Created($"/api/products/{product.Id}", MapToVM(product)); | ||
| } | ||
|
|
||
| // External: PUT /api/products/{id} -> PUT /{id} | ||
| [HttpPut("/{id:int}")] | ||
| [ProducesResponseType(typeof(ProductVM), StatusCodes.Status200OK)] | ||
| [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||
| [ProducesResponseType(StatusCodes.Status404NotFound)] | ||
| public async Task<IActionResult> Update(int id, [FromBody] ProductVM model) | ||
| { | ||
| if (!ModelState.IsValid) | ||
| return BadRequest(ModelState); | ||
|
|
||
| var product = await _db.Products | ||
| .Include(p => p.ProductCategory) | ||
| .FirstOrDefaultAsync(p => p.Id == id); | ||
|
|
||
| if (product == null) | ||
| return NotFound(id); | ||
|
|
||
| if (string.IsNullOrWhiteSpace(model.Name)) | ||
| return BadRequest("Name is required."); | ||
|
|
||
| product.Name = model.Name; | ||
| product.Description = model.Description; | ||
| product.Icon = model.Icon; | ||
| product.BuyingPrice = model.BuyingPrice; | ||
| product.SellingPrice = model.SellingPrice; | ||
| product.UnitsInStock = model.UnitsInStock; | ||
| product.IsActive = model.IsActive; | ||
| product.IsDiscontinued = model.IsDiscontinued; | ||
| product.UpdatedDate = DateTime.UtcNow; | ||
|
|
||
| var resolvedCategory = await ResolveCategoryAsync(model.ProductCategoryName); | ||
| if (resolvedCategory.Name != product.ProductCategory?.Name) | ||
| { | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
| product.ProductCategory = resolvedCategory; | ||
| } | ||
|
Comment on lines
+119
to
+123
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Update with null category name silently reclassifies product to 'Uncategorized' In the Was this helpful? React with 👍 or 👎 to provide feedback.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Intentional and, as noted, correct PUT full-replacement semantics — now consistent with |
||
|
|
||
| await _db.SaveChangesAsync(); | ||
|
|
||
| return Ok(MapToVM(product)); | ||
| } | ||
|
|
||
| // External: DELETE /api/products/{id} -> DELETE /{id} | ||
| [HttpDelete("/{id:int}")] | ||
| [ProducesResponseType(StatusCodes.Status204NoContent)] | ||
| [ProducesResponseType(StatusCodes.Status404NotFound)] | ||
| [ProducesResponseType(StatusCodes.Status409Conflict)] | ||
| public async Task<IActionResult> Delete(int id) | ||
| { | ||
| var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == id); | ||
| if (product == null) | ||
| return NotFound(id); | ||
|
|
||
| // Parent->Children uses DeleteBehavior.Restrict; deleting a parent with children | ||
| // would raise an FK violation, so reject it explicitly. | ||
| if (await _db.Products.AnyAsync(p => p.ParentId == id)) | ||
| return Conflict($"Product {id} has child products and cannot be deleted."); | ||
|
|
||
| _db.Products.Remove(product); | ||
| await _db.SaveChangesAsync(); | ||
|
|
||
| return NoContent(); | ||
| } | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
|
|
||
| // External: GET /api/products/categories -> GET /categories | ||
| [HttpGet("/categories")] | ||
| [ProducesResponseType(typeof(IEnumerable<string>), StatusCodes.Status200OK)] | ||
| public async Task<IActionResult> GetCategories() | ||
| { | ||
| var categories = await _db.ProductCategories | ||
| .AsNoTracking() | ||
| .Select(c => c.Name) | ||
| .ToListAsync(); | ||
|
|
||
| categories.Sort(StringComparer.OrdinalIgnoreCase); | ||
|
|
||
| return Ok(categories); | ||
| } | ||
|
|
||
| private async Task<Domain.ProductCategory> ResolveCategoryAsync(string? name) | ||
| { | ||
| var categoryName = string.IsNullOrWhiteSpace(name) ? "Uncategorized" : name; | ||
|
|
||
| var existing = await _db.ProductCategories | ||
| .FirstOrDefaultAsync(c => c.Name == categoryName); | ||
|
|
||
| return existing ?? new Domain.ProductCategory | ||
| { | ||
| Name = categoryName, | ||
| CreatedDate = DateTime.UtcNow, | ||
| UpdatedDate = DateTime.UtcNow | ||
| }; | ||
| } | ||
|
|
||
| private static ProductVM MapToVM(ProductEntity product) => new() | ||
| { | ||
| Id = product.Id, | ||
| Name = product.Name, | ||
| Description = product.Description, | ||
| Icon = product.Icon, | ||
| BuyingPrice = product.BuyingPrice, | ||
| SellingPrice = product.SellingPrice, | ||
| UnitsInStock = product.UnitsInStock, | ||
| IsActive = product.IsActive, | ||
| IsDiscontinued = product.IsDiscontinued, | ||
| ProductCategoryName = product.ProductCategory?.Name | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,58 @@ | ||
| using Product.Infrastructure.Data; | ||
| using System.Text; | ||
| using Microsoft.AspNetCore.Authentication.JwtBearer; | ||
| using Microsoft.EntityFrameworkCore; | ||
| using Microsoft.IdentityModel.Tokens; | ||
| using Microsoft.OpenApi.Models; | ||
| using Product.Infrastructure.Data; | ||
|
|
||
| var builder = WebApplication.CreateBuilder(args); | ||
|
|
||
| builder.Services.AddControllers(); | ||
| builder.Services.AddEndpointsApiExplorer(); | ||
| builder.Services.AddSwaggerGen(); | ||
| builder.Services.AddSwaggerGen(options => | ||
| { | ||
| options.SwaggerDoc("v1", new OpenApiInfo { Title = "Product.API", Version = "v1" }); | ||
|
|
||
| var scheme = new OpenApiSecurityScheme | ||
| { | ||
| Name = "Authorization", | ||
| Type = SecuritySchemeType.Http, | ||
| Scheme = "bearer", | ||
| BearerFormat = "JWT", | ||
| In = ParameterLocation.Header, | ||
| Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } | ||
| }; | ||
| options.AddSecurityDefinition("Bearer", scheme); | ||
| options.AddSecurityRequirement(new OpenApiSecurityRequirement { [scheme] = Array.Empty<string>() }); | ||
| }); | ||
| builder.Services.AddHealthChecks(); | ||
|
|
||
| builder.Services.AddDbContext<ProductDbContext>(options => | ||
| options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); | ||
|
|
||
| // Canonical JWT contract (HS256) — must match the Identity service token issuance. | ||
| var jwtSection = builder.Configuration.GetSection("Jwt"); | ||
| var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSection["Key"]!)); | ||
|
|
||
| builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) | ||
| .AddJwtBearer(options => | ||
| { | ||
| options.MapInboundClaims = false; | ||
| options.TokenValidationParameters = new TokenValidationParameters | ||
| { | ||
| ValidateIssuer = true, | ||
| ValidateAudience = true, | ||
| ValidateLifetime = true, | ||
| ValidateIssuerSigningKey = true, | ||
| ValidIssuer = jwtSection["Issuer"], | ||
| ValidAudience = jwtSection["Audience"], | ||
| IssuerSigningKey = signingKey, | ||
| NameClaimType = "name", | ||
| RoleClaimType = "role" | ||
| }; | ||
| }); | ||
| builder.Services.AddAuthorization(); | ||
|
|
||
| var app = builder.Build(); | ||
|
|
||
| if (app.Environment.IsDevelopment()) | ||
|
|
@@ -19,7 +61,31 @@ | |
| app.UseSwaggerUI(); | ||
| } | ||
|
|
||
| app.UseAuthentication(); | ||
| app.UseAuthorization(); | ||
|
|
||
| app.MapControllers(); | ||
| app.MapHealthChecks("/healthz"); | ||
|
|
||
| // Create the schema on boot (Postgres may not be ready immediately under compose). | ||
| 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)); | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+71
to
+89
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚩 Database initialization blocks startup — health checks unavailable during retry window The Was this helpful? React with 👍 or 👎 to provide feedback.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the conventional EF Core startup pattern (run |
||
|
|
||
| app.Run(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| namespace Product.API.ViewModels; | ||
|
|
||
| public class ProductVM | ||
| { | ||
| public int Id { get; set; } | ||
| public string? Name { get; set; } | ||
| public string? Description { get; set; } | ||
| public string? Icon { get; set; } | ||
| public decimal BuyingPrice { get; set; } | ||
| public decimal SellingPrice { get; set; } | ||
| public int UnitsInStock { get; set; } | ||
| public bool IsActive { get; set; } | ||
| public bool IsDiscontinued { get; set; } | ||
| public string? ProductCategoryName { get; set; } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| using System.ComponentModel.DataAnnotations; | ||
|
|
||
| namespace Product.Domain; | ||
|
|
||
| public abstract class BaseEntity | ||
| { | ||
| public int Id { get; set; } | ||
|
|
||
| [MaxLength(40)] | ||
| public string? CreatedBy { get; set; } | ||
|
|
||
| [MaxLength(40)] | ||
| public string? UpdatedBy { get; set; } | ||
|
|
||
| public DateTime CreatedDate { get; set; } | ||
|
|
||
| public DateTime UpdatedDate { get; set; } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| namespace Product.Domain; | ||
|
|
||
| public class Product : BaseEntity | ||
| { | ||
| public required string Name { get; set; } | ||
| public string? Description { get; set; } | ||
| public string? Icon { get; set; } | ||
| public decimal BuyingPrice { get; set; } | ||
| public decimal SellingPrice { get; set; } | ||
| public int UnitsInStock { get; set; } | ||
| public bool IsActive { get; set; } | ||
| public bool IsDiscontinued { get; set; } | ||
|
|
||
| public int? ParentId { get; set; } | ||
| public Product? Parent { get; set; } | ||
|
|
||
| public int ProductCategoryId { get; set; } | ||
| public required ProductCategory ProductCategory { get; set; } | ||
|
|
||
| public ICollection<Product> Children { get; } = []; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| namespace Product.Domain; | ||
|
|
||
| public class ProductCategory : BaseEntity | ||
| { | ||
| public required string Name { get; set; } | ||
| public string? Description { get; set; } | ||
| public string? Icon { get; set; } | ||
|
|
||
| public ICollection<Product> Products { get; } = []; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚩 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/productsviaPathRemovePrefix(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},/categoriesrather 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Intentional and pinned by the carve-out spec. The gateway route
Match /api/products/{**catch-all}+PathRemovePrefix /api/productsmeans the service receives the prefix stripped, so externalGET /api/productsarrives asGET /. 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.