diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IProductRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductRepository.cs
new file mode 100644
index 0000000..3d7ff94
--- /dev/null
+++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductRepository.cs
@@ -0,0 +1,29 @@
+using SIGCM2.Application.Common;
+using SIGCM2.Domain.Entities;
+
+namespace SIGCM2.Application.Abstractions.Persistence;
+
+///
+/// Write-side repository for Product.
+/// All reads needed by write handlers are included here.
+///
+public interface IProductRepository
+{
+ /// Inserts a new Product and returns the DB-assigned Id.
+ Task AddAsync(Product product, CancellationToken ct = default);
+
+ /// Returns the Product with the given Id, or null if not found.
+ Task GetByIdAsync(int id, CancellationToken ct = default);
+
+ /// Returns a paged result of Products matching the query.
+ Task> GetPagedAsync(ProductsQuery query, CancellationToken ct = default);
+
+ /// Persists all changes to an existing Product row.
+ Task UpdateAsync(Product product, CancellationToken ct = default);
+
+ ///
+ /// Returns true if an active Product with the same Nombre exists for the given MedioId+ProductTypeId combination.
+ /// Pass excludeId to skip the self-comparison during rename (update scenario).
+ ///
+ Task ExistsByNombreAsync(string nombre, int medioId, int productTypeId, int? excludeId = null, CancellationToken ct = default);
+}
diff --git a/src/api/SIGCM2.Application/Common/ProductsQuery.cs b/src/api/SIGCM2.Application/Common/ProductsQuery.cs
new file mode 100644
index 0000000..c2ab7cb
--- /dev/null
+++ b/src/api/SIGCM2.Application/Common/ProductsQuery.cs
@@ -0,0 +1,13 @@
+namespace SIGCM2.Application.Common;
+
+///
+/// Query parameters for listing Products (used by IProductRepository.GetPagedAsync).
+///
+public sealed record ProductsQuery(
+ int Page = 1,
+ int PageSize = 20,
+ bool? Activo = true,
+ string? Search = null,
+ int? MedioId = null,
+ int? ProductTypeId = null,
+ int? RubroId = null);
diff --git a/src/api/SIGCM2.Application/Products/Create/CreateProductCommand.cs b/src/api/SIGCM2.Application/Products/Create/CreateProductCommand.cs
new file mode 100644
index 0000000..1645b22
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/Create/CreateProductCommand.cs
@@ -0,0 +1,9 @@
+namespace SIGCM2.Application.Products.Create;
+
+public sealed record CreateProductCommand(
+ string Nombre,
+ int MedioId,
+ int ProductTypeId,
+ int? RubroId,
+ decimal BasePrice,
+ int? PriceDurationDays);
diff --git a/src/api/SIGCM2.Application/Products/Create/CreateProductCommandValidator.cs b/src/api/SIGCM2.Application/Products/Create/CreateProductCommandValidator.cs
new file mode 100644
index 0000000..8851383
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/Create/CreateProductCommandValidator.cs
@@ -0,0 +1,26 @@
+using FluentValidation;
+
+namespace SIGCM2.Application.Products.Create;
+
+public sealed class CreateProductCommandValidator : AbstractValidator
+{
+ public CreateProductCommandValidator()
+ {
+ RuleFor(x => x.Nombre)
+ .NotEmpty().WithMessage("El nombre del producto es requerido.")
+ .MaximumLength(300).WithMessage("El nombre no puede superar los 300 caracteres.");
+
+ RuleFor(x => x.MedioId)
+ .GreaterThan(0).WithMessage("MedioId debe ser un entero positivo.");
+
+ RuleFor(x => x.ProductTypeId)
+ .GreaterThan(0).WithMessage("ProductTypeId debe ser un entero positivo.");
+
+ RuleFor(x => x.BasePrice)
+ .GreaterThanOrEqualTo(0m).WithMessage("El precio base no puede ser negativo.");
+
+ RuleFor(x => x.PriceDurationDays)
+ .GreaterThan(0).When(x => x.PriceDurationDays.HasValue)
+ .WithMessage("PriceDurationDays debe ser >= 1 cuando se provee.");
+ }
+}
diff --git a/src/api/SIGCM2.Application/Products/Create/ProductCreatedDto.cs b/src/api/SIGCM2.Application/Products/Create/ProductCreatedDto.cs
new file mode 100644
index 0000000..0ddeec7
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/Create/ProductCreatedDto.cs
@@ -0,0 +1,12 @@
+namespace SIGCM2.Application.Products.Create;
+
+public sealed record ProductCreatedDto(
+ int Id,
+ string Nombre,
+ int MedioId,
+ int ProductTypeId,
+ int? RubroId,
+ decimal BasePrice,
+ int? PriceDurationDays,
+ bool IsActive,
+ DateTime FechaCreacion);
diff --git a/src/api/SIGCM2.Application/Products/Deactivate/DeactivateProductCommand.cs b/src/api/SIGCM2.Application/Products/Deactivate/DeactivateProductCommand.cs
new file mode 100644
index 0000000..e963cb6
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/Deactivate/DeactivateProductCommand.cs
@@ -0,0 +1,3 @@
+namespace SIGCM2.Application.Products.Deactivate;
+
+public sealed record DeactivateProductCommand(int Id);
diff --git a/src/api/SIGCM2.Application/Products/Deactivate/ProductStatusDto.cs b/src/api/SIGCM2.Application/Products/Deactivate/ProductStatusDto.cs
new file mode 100644
index 0000000..26885b9
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/Deactivate/ProductStatusDto.cs
@@ -0,0 +1,6 @@
+namespace SIGCM2.Application.Products.Deactivate;
+
+public sealed record ProductStatusDto(
+ int Id,
+ bool IsActive,
+ DateTime? FechaModificacion);
diff --git a/src/api/SIGCM2.Application/Products/GetById/GetProductByIdQuery.cs b/src/api/SIGCM2.Application/Products/GetById/GetProductByIdQuery.cs
new file mode 100644
index 0000000..e4e103a
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/GetById/GetProductByIdQuery.cs
@@ -0,0 +1,3 @@
+namespace SIGCM2.Application.Products.GetById;
+
+public sealed record GetProductByIdQuery(int Id);
diff --git a/src/api/SIGCM2.Application/Products/GetById/ProductDetailDto.cs b/src/api/SIGCM2.Application/Products/GetById/ProductDetailDto.cs
new file mode 100644
index 0000000..315a9e3
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/GetById/ProductDetailDto.cs
@@ -0,0 +1,13 @@
+namespace SIGCM2.Application.Products.GetById;
+
+public sealed record ProductDetailDto(
+ int Id,
+ string Nombre,
+ int MedioId,
+ int ProductTypeId,
+ int? RubroId,
+ decimal BasePrice,
+ int? PriceDurationDays,
+ bool IsActive,
+ DateTime FechaCreacion,
+ DateTime? FechaModificacion);
diff --git a/src/api/SIGCM2.Application/Products/List/ListProductsQuery.cs b/src/api/SIGCM2.Application/Products/List/ListProductsQuery.cs
new file mode 100644
index 0000000..9db9d65
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/List/ListProductsQuery.cs
@@ -0,0 +1,10 @@
+namespace SIGCM2.Application.Products.List;
+
+public sealed record ListProductsQuery(
+ int Page = 1,
+ int PageSize = 20,
+ bool? Activo = true,
+ string? Search = null,
+ int? MedioId = null,
+ int? ProductTypeId = null,
+ int? RubroId = null);
diff --git a/src/api/SIGCM2.Application/Products/List/ProductListItemDto.cs b/src/api/SIGCM2.Application/Products/List/ProductListItemDto.cs
new file mode 100644
index 0000000..3c4bfc5
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/List/ProductListItemDto.cs
@@ -0,0 +1,11 @@
+namespace SIGCM2.Application.Products.List;
+
+public sealed record ProductListItemDto(
+ int Id,
+ string Nombre,
+ int MedioId,
+ int ProductTypeId,
+ int? RubroId,
+ decimal BasePrice,
+ int? PriceDurationDays,
+ bool IsActive);
diff --git a/src/api/SIGCM2.Application/Products/Update/ProductUpdatedDto.cs b/src/api/SIGCM2.Application/Products/Update/ProductUpdatedDto.cs
new file mode 100644
index 0000000..8c1e5a5
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/Update/ProductUpdatedDto.cs
@@ -0,0 +1,13 @@
+namespace SIGCM2.Application.Products.Update;
+
+public sealed record ProductUpdatedDto(
+ int Id,
+ string Nombre,
+ int MedioId,
+ int ProductTypeId,
+ int? RubroId,
+ decimal BasePrice,
+ int? PriceDurationDays,
+ bool IsActive,
+ DateTime FechaCreacion,
+ DateTime? FechaModificacion);
diff --git a/src/api/SIGCM2.Application/Products/Update/UpdateProductCommand.cs b/src/api/SIGCM2.Application/Products/Update/UpdateProductCommand.cs
new file mode 100644
index 0000000..b80c0d9
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/Update/UpdateProductCommand.cs
@@ -0,0 +1,8 @@
+namespace SIGCM2.Application.Products.Update;
+
+public sealed record UpdateProductCommand(
+ int Id,
+ string Nombre,
+ int? RubroId,
+ decimal BasePrice,
+ int? PriceDurationDays);
diff --git a/src/api/SIGCM2.Application/Products/Update/UpdateProductCommandValidator.cs b/src/api/SIGCM2.Application/Products/Update/UpdateProductCommandValidator.cs
new file mode 100644
index 0000000..daa5934
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/Update/UpdateProductCommandValidator.cs
@@ -0,0 +1,23 @@
+using FluentValidation;
+
+namespace SIGCM2.Application.Products.Update;
+
+public sealed class UpdateProductCommandValidator : AbstractValidator
+{
+ public UpdateProductCommandValidator()
+ {
+ RuleFor(x => x.Id)
+ .GreaterThan(0).WithMessage("Id debe ser un entero positivo.");
+
+ RuleFor(x => x.Nombre)
+ .NotEmpty().WithMessage("El nombre del producto es requerido.")
+ .MaximumLength(300).WithMessage("El nombre no puede superar los 300 caracteres.");
+
+ RuleFor(x => x.BasePrice)
+ .GreaterThanOrEqualTo(0m).WithMessage("El precio base no puede ser negativo.");
+
+ RuleFor(x => x.PriceDurationDays)
+ .GreaterThan(0).When(x => x.PriceDurationDays.HasValue)
+ .WithMessage("PriceDurationDays debe ser >= 1 cuando se provee.");
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/Products/Validators/ProductValidatorsTests.cs b/tests/SIGCM2.Application.Tests/Products/Validators/ProductValidatorsTests.cs
new file mode 100644
index 0000000..c39d444
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Products/Validators/ProductValidatorsTests.cs
@@ -0,0 +1,132 @@
+using FluentValidation.TestHelper;
+using SIGCM2.Application.Products.Create;
+using SIGCM2.Application.Products.Update;
+
+namespace SIGCM2.Application.Tests.Products.Validators;
+
+///
+/// PRD-002 — Validator tests for CreateProductCommand and UpdateProductCommand.
+///
+public class ProductValidatorsTests
+{
+ private readonly CreateProductCommandValidator _createValidator = new();
+ private readonly UpdateProductCommandValidator _updateValidator = new();
+
+ // ── Create: Nombre ────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Create_NombreEmpty_FailsValidation()
+ {
+ var cmd = ValidCreate() with { Nombre = "" };
+ _createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Nombre);
+ }
+
+ [Fact]
+ public void Create_NombreWhitespace_FailsValidation()
+ {
+ var cmd = ValidCreate() with { Nombre = " " };
+ _createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Nombre);
+ }
+
+ [Fact]
+ public void Create_NombreOver300Chars_FailsValidation()
+ {
+ var cmd = ValidCreate() with { Nombre = new string('X', 301) };
+ _createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Nombre);
+ }
+
+ // ── Create: MedioId / ProductTypeId ───────────────────────────────────────
+
+ [Fact]
+ public void Create_MedioIdZero_FailsValidation()
+ {
+ var cmd = ValidCreate() with { MedioId = 0 };
+ _createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.MedioId);
+ }
+
+ [Fact]
+ public void Create_ProductTypeIdZero_FailsValidation()
+ {
+ var cmd = ValidCreate() with { ProductTypeId = 0 };
+ _createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.ProductTypeId);
+ }
+
+ // ── Create: BasePrice ─────────────────────────────────────────────────────
+
+ [Fact]
+ public void Create_NegativeBasePrice_FailsValidation()
+ {
+ var cmd = ValidCreate() with { BasePrice = -0.01m };
+ _createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.BasePrice);
+ }
+
+ [Fact]
+ public void Create_ZeroBasePrice_Passes()
+ {
+ var cmd = ValidCreate() with { BasePrice = 0m };
+ _createValidator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.BasePrice);
+ }
+
+ // ── Create: PriceDurationDays ──────────────────────────────────────────────
+
+ [Fact]
+ public void Create_PriceDurationDaysZero_FailsValidation()
+ {
+ var cmd = ValidCreate() with { PriceDurationDays = 0 };
+ _createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PriceDurationDays);
+ }
+
+ [Fact]
+ public void Create_PriceDurationDaysNegative_FailsValidation()
+ {
+ var cmd = ValidCreate() with { PriceDurationDays = -1 };
+ _createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PriceDurationDays);
+ }
+
+ [Fact]
+ public void Create_PriceDurationDaysNull_Passes()
+ {
+ var cmd = ValidCreate() with { PriceDurationDays = null };
+ _createValidator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PriceDurationDays);
+ }
+
+ // ── Create: valid ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Create_ValidCommand_Passes()
+ {
+ _createValidator.TestValidate(ValidCreate()).ShouldNotHaveAnyValidationErrors();
+ }
+
+ // ── Update: Id must be > 0 ────────────────────────────────────────────────
+
+ [Fact]
+ public void Update_IdZero_FailsValidation()
+ {
+ var cmd = ValidUpdate() with { Id = 0 };
+ _updateValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Id);
+ }
+
+ [Fact]
+ public void Update_ValidCommand_Passes()
+ {
+ _updateValidator.TestValidate(ValidUpdate()).ShouldNotHaveAnyValidationErrors();
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private static CreateProductCommand ValidCreate() => new(
+ Nombre: "Clasificado Estándar",
+ MedioId: 1,
+ ProductTypeId: 2,
+ RubroId: null,
+ BasePrice: 100.50m,
+ PriceDurationDays: null);
+
+ private static UpdateProductCommand ValidUpdate() => new(
+ Id: 1,
+ Nombre: "Clasificado Estándar",
+ RubroId: null,
+ BasePrice: 100.50m,
+ PriceDurationDays: null);
+}