diff --git a/src/api/SIGCM2.Domain/Entities/Product.cs b/src/api/SIGCM2.Domain/Entities/Product.cs
new file mode 100644
index 0000000..1962efb
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Entities/Product.cs
@@ -0,0 +1,172 @@
+namespace SIGCM2.Domain.Entities;
+
+///
+/// Immutable product entity for the commercial catalog.
+/// Factory method ForCreation creates new products (Id=0).
+/// Mutation methods (With*) return new instances — original is never modified.
+/// Flag coherence (RequiresCategory/HasDuration) is enforced by Application handlers
+/// at creation/update time against the ProductType, NOT here in the entity.
+/// MedioId and ProductTypeId are immutable post-creation by design.
+///
+public sealed class Product
+{
+ private const int NombreMaxLength = 300;
+
+ public int Id { get; }
+ public string Nombre { get; }
+ public int MedioId { get; }
+ public int ProductTypeId { get; }
+ public int? RubroId { get; }
+ public decimal BasePrice { get; }
+ public int? PriceDurationDays { get; }
+ public bool IsActive { get; }
+ public DateTime FechaCreacion { get; }
+ public DateTime? FechaModificacion { get; }
+
+ /// Full hydration constructor — used by the repository to reconstruct from DB rows.
+ public Product(
+ int id,
+ string nombre,
+ int medioId,
+ int productTypeId,
+ int? rubroId,
+ decimal basePrice,
+ int? priceDurationDays,
+ bool isActive,
+ DateTime fechaCreacion,
+ DateTime? fechaModificacion)
+ {
+ Id = id;
+ Nombre = nombre;
+ MedioId = medioId;
+ ProductTypeId = productTypeId;
+ RubroId = rubroId;
+ BasePrice = basePrice;
+ PriceDurationDays = priceDurationDays;
+ IsActive = isActive;
+ FechaCreacion = fechaCreacion;
+ FechaModificacion = fechaModificacion;
+ }
+
+ ///
+ /// Factory for a new Product. Id=0 — DB assigns via IDENTITY.
+ /// IsActive=true, FechaModificacion=null.
+ ///
+ public static Product ForCreation(
+ string nombre,
+ int medioId,
+ int productTypeId,
+ int? rubroId,
+ decimal basePrice,
+ int? priceDurationDays,
+ TimeProvider timeProvider)
+ {
+ ValidateNombre(nombre);
+ ValidateMedioId(medioId);
+ ValidateProductTypeId(productTypeId);
+ ValidateRubroId(rubroId);
+ ValidateBasePrice(basePrice);
+ ValidatePriceDurationDays(priceDurationDays);
+
+ return new Product(
+ id: 0,
+ nombre: nombre.Trim(),
+ medioId: medioId,
+ productTypeId: productTypeId,
+ rubroId: rubroId,
+ basePrice: basePrice,
+ priceDurationDays: priceDurationDays,
+ isActive: true,
+ fechaCreacion: timeProvider.GetUtcNow().UtcDateTime,
+ fechaModificacion: null);
+ }
+
+ public Product WithRenamed(string nuevoNombre, TimeProvider timeProvider)
+ {
+ ValidateNombre(nuevoNombre);
+ return new Product(Id, nuevoNombre.Trim(), MedioId, ProductTypeId, RubroId,
+ BasePrice, PriceDurationDays, IsActive, FechaCreacion,
+ timeProvider.GetUtcNow().UtcDateTime);
+ }
+
+ public Product WithUpdatedPrice(decimal basePrice, int? priceDurationDays, TimeProvider timeProvider)
+ {
+ ValidateBasePrice(basePrice);
+ ValidatePriceDurationDays(priceDurationDays);
+ return new Product(Id, Nombre, MedioId, ProductTypeId, RubroId,
+ basePrice, priceDurationDays, IsActive, FechaCreacion,
+ timeProvider.GetUtcNow().UtcDateTime);
+ }
+
+ public Product WithUpdatedCategory(int? rubroId, TimeProvider timeProvider)
+ {
+ ValidateRubroId(rubroId);
+ return new Product(Id, Nombre, MedioId, ProductTypeId, rubroId,
+ BasePrice, PriceDurationDays, IsActive, FechaCreacion,
+ timeProvider.GetUtcNow().UtcDateTime);
+ }
+
+ ///
+ /// Combo mutator: renames, updates price and category in one call.
+ /// Used by UpdateProductCommandHandler.
+ ///
+ public Product WithUpdated(string nombre, int? rubroId, decimal basePrice, int? priceDurationDays, TimeProvider timeProvider)
+ {
+ ValidateNombre(nombre);
+ ValidateRubroId(rubroId);
+ ValidateBasePrice(basePrice);
+ ValidatePriceDurationDays(priceDurationDays);
+ return new Product(Id, nombre.Trim(), MedioId, ProductTypeId, rubroId,
+ basePrice, priceDurationDays, IsActive, FechaCreacion,
+ timeProvider.GetUtcNow().UtcDateTime);
+ }
+
+ public Product WithDeactivated(TimeProvider timeProvider)
+ => new(Id, Nombre, MedioId, ProductTypeId, RubroId,
+ BasePrice, PriceDurationDays, isActive: false, FechaCreacion,
+ timeProvider.GetUtcNow().UtcDateTime);
+
+ // ── Private validators ────────────────────────────────────────────────────
+
+ private static void ValidateNombre(string nombre)
+ {
+ if (string.IsNullOrWhiteSpace(nombre))
+ throw new ArgumentException(
+ "El Nombre del producto no puede estar vacío o ser solo espacios.", nameof(nombre));
+ if (nombre.Length > NombreMaxLength)
+ throw new ArgumentException(
+ $"El Nombre del producto no puede superar los {NombreMaxLength} caracteres.", nameof(nombre));
+ }
+
+ private static void ValidateMedioId(int medioId)
+ {
+ if (medioId <= 0)
+ throw new ArgumentException("medioId debe ser un entero positivo.", nameof(medioId));
+ }
+
+ private static void ValidateProductTypeId(int productTypeId)
+ {
+ if (productTypeId <= 0)
+ throw new ArgumentException("productTypeId debe ser un entero positivo.", nameof(productTypeId));
+ }
+
+ private static void ValidateRubroId(int? rubroId)
+ {
+ if (rubroId.HasValue && rubroId.Value <= 0)
+ throw new ArgumentException(
+ "rubroId debe ser un entero positivo cuando no es nulo.", nameof(rubroId));
+ }
+
+ private static void ValidateBasePrice(decimal basePrice)
+ {
+ if (basePrice < 0m)
+ throw new ArgumentException("basePrice no puede ser negativo.", nameof(basePrice));
+ }
+
+ private static void ValidatePriceDurationDays(int? priceDurationDays)
+ {
+ if (priceDurationDays.HasValue && priceDurationDays.Value <= 0)
+ throw new ArgumentException(
+ "priceDurationDays debe ser >= 1 cuando se provee.", nameof(priceDurationDays));
+ }
+}
diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductNombreDuplicadoEnMedioTipoException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductNombreDuplicadoEnMedioTipoException.cs
new file mode 100644
index 0000000..7eed7b7
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Exceptions/ProductNombreDuplicadoEnMedioTipoException.cs
@@ -0,0 +1,19 @@
+namespace SIGCM2.Domain.Exceptions;
+
+///
+/// Thrown when a Product with the same Nombre already exists for a given MedioId+ProductTypeId. → HTTP 409
+///
+public sealed class ProductNombreDuplicadoEnMedioTipoException : DomainException
+{
+ public int MedioId { get; }
+ public int ProductTypeId { get; }
+ public string Nombre { get; }
+
+ public ProductNombreDuplicadoEnMedioTipoException(int medioId, int productTypeId, string nombre)
+ : base($"Ya existe un producto activo con nombre '{nombre}' para medioId={medioId} y productTypeId={productTypeId}.")
+ {
+ MedioId = medioId;
+ ProductTypeId = productTypeId;
+ Nombre = nombre;
+ }
+}
diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductNotFoundException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductNotFoundException.cs
new file mode 100644
index 0000000..912b362
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Exceptions/ProductNotFoundException.cs
@@ -0,0 +1,15 @@
+namespace SIGCM2.Domain.Exceptions;
+
+///
+/// Thrown when a requested Product does not exist. → HTTP 404
+///
+public sealed class ProductNotFoundException : DomainException
+{
+ public int ProductId { get; }
+
+ public ProductNotFoundException(int id)
+ : base($"El producto con id={id} no existe.")
+ {
+ ProductId = id;
+ }
+}
diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductTipoFlagsIncoherentesException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductTipoFlagsIncoherentesException.cs
new file mode 100644
index 0000000..9cc9805
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Exceptions/ProductTipoFlagsIncoherentesException.cs
@@ -0,0 +1,16 @@
+namespace SIGCM2.Domain.Exceptions;
+
+///
+/// Thrown when a Product's field violates the flags coherence rules of its ProductType
+/// (e.g. RequiresCategory=true but RubroId is null, or HasDuration=true but PriceDurationDays is null). → HTTP 422
+///
+public sealed class ProductTipoFlagsIncoherentesException : DomainException
+{
+ public string Field { get; }
+
+ public ProductTipoFlagsIncoherentesException(string reason, string field)
+ : base($"Incoherencia de flags del tipo de producto: {reason}.")
+ {
+ Field = field;
+ }
+}
diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductTypeInactivoException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductTypeInactivoException.cs
new file mode 100644
index 0000000..f853221
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Exceptions/ProductTypeInactivoException.cs
@@ -0,0 +1,15 @@
+namespace SIGCM2.Domain.Exceptions;
+
+///
+/// Thrown when attempting to create/update a Product referencing an inactive ProductType. → HTTP 422
+///
+public sealed class ProductTypeInactivoException : DomainException
+{
+ public int ProductTypeId { get; }
+
+ public ProductTypeInactivoException(int productTypeId)
+ : base($"El tipo de producto con id={productTypeId} está inactivo y no puede asignarse a un producto.")
+ {
+ ProductTypeId = productTypeId;
+ }
+}
diff --git a/src/api/SIGCM2.Domain/Exceptions/RubroInactivoException.cs b/src/api/SIGCM2.Domain/Exceptions/RubroInactivoException.cs
new file mode 100644
index 0000000..7bbea89
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Exceptions/RubroInactivoException.cs
@@ -0,0 +1,15 @@
+namespace SIGCM2.Domain.Exceptions;
+
+///
+/// Thrown when attempting to create/update a Product referencing an inactive Rubro. → HTTP 422
+///
+public sealed class RubroInactivoException : DomainException
+{
+ public int RubroId { get; }
+
+ public RubroInactivoException(int rubroId)
+ : base($"El rubro con id={rubroId} está inactivo y no puede asignarse a un producto.")
+ {
+ RubroId = rubroId;
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/Domain/ProductTests.cs b/tests/SIGCM2.Application.Tests/Domain/ProductTests.cs
new file mode 100644
index 0000000..d7e6e41
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Domain/ProductTests.cs
@@ -0,0 +1,218 @@
+using FluentAssertions;
+using Microsoft.Extensions.Time.Testing;
+using SIGCM2.Domain.Entities;
+
+namespace SIGCM2.Application.Tests.Domain;
+
+///
+/// PRD-002 — Domain entity tests for Product.
+/// Validates factory method, mutation methods, and validation rules.
+///
+public class ProductTests
+{
+ private static readonly FakeTimeProvider _time =
+ new(new DateTimeOffset(2026, 4, 19, 10, 0, 0, TimeSpan.Zero));
+
+ private static Product ValidProduct(int? rubroId = null, int? priceDurationDays = null)
+ => Product.ForCreation(
+ nombre: "Clasificado Estándar",
+ medioId: 1,
+ productTypeId: 2,
+ rubroId: rubroId,
+ basePrice: 100.50m,
+ priceDurationDays: priceDurationDays,
+ timeProvider: _time);
+
+ // ── R1-S1: ForCreation happy path ─────────────────────────────────────────
+
+ [Fact]
+ public void ForCreation_ValidData_ReturnsEntityWithDefaults()
+ {
+ var p = ValidProduct();
+
+ p.IsActive.Should().BeTrue();
+ p.Id.Should().Be(0);
+ p.FechaCreacion.Should().Be(_time.GetUtcNow().UtcDateTime);
+ p.FechaModificacion.Should().BeNull();
+ p.Nombre.Should().Be("Clasificado Estándar");
+ p.MedioId.Should().Be(1);
+ p.ProductTypeId.Should().Be(2);
+ p.RubroId.Should().BeNull();
+ p.BasePrice.Should().Be(100.50m);
+ p.PriceDurationDays.Should().BeNull();
+ }
+
+ // ── R1-S2: Nombre vacío ────────────────────────────────────────────────────
+
+ [Fact]
+ public void ForCreation_EmptyNombre_ThrowsArgumentException()
+ {
+ var act = () => Product.ForCreation("", 1, 2, null, 10m, null, _time);
+
+ act.Should().Throw()
+ .WithMessage("*Nombre*");
+ }
+
+ // ── R1-S3: Nombre solo espacios ────────────────────────────────────────────
+
+ [Fact]
+ public void ForCreation_WhitespaceNombre_ThrowsArgumentException()
+ {
+ var act = () => Product.ForCreation(" ", 1, 2, null, 10m, null, _time);
+
+ act.Should().Throw();
+ }
+
+ // ── R1-S4: BasePrice negativo ──────────────────────────────────────────────
+
+ [Fact]
+ public void ForCreation_NegativeBasePrice_ThrowsArgumentException()
+ {
+ var act = () => Product.ForCreation("Test", 1, 2, null, -1m, null, _time);
+
+ act.Should().Throw()
+ .WithMessage("*basePrice*");
+ }
+
+ // ── R1-S5: BasePrice = 0 es válido ─────────────────────────────────────────
+
+ [Fact]
+ public void ForCreation_ZeroBasePrice_DoesNotThrow()
+ {
+ var act = () => Product.ForCreation("Test", 1, 2, null, 0m, null, _time);
+
+ act.Should().NotThrow();
+ }
+
+ // ── R1-S6: PriceDurationDays = 0 ──────────────────────────────────────────
+
+ [Fact]
+ public void ForCreation_ZeroPriceDurationDays_ThrowsArgumentException()
+ {
+ var act = () => Product.ForCreation("Test", 1, 2, null, 10m, 0, _time);
+
+ act.Should().Throw()
+ .WithMessage("*priceDurationDays*");
+ }
+
+ // ── R1-S7: PriceDurationDays negativo ─────────────────────────────────────
+
+ [Fact]
+ public void ForCreation_NegativePriceDurationDays_ThrowsArgumentException()
+ {
+ var act = () => Product.ForCreation("Test", 1, 2, null, 10m, -5, _time);
+
+ act.Should().Throw();
+ }
+
+ // ── R1-S8: PriceDurationDays válido ───────────────────────────────────────
+
+ [Fact]
+ public void ForCreation_ValidPriceDurationDays_SetsValue()
+ {
+ var p = Product.ForCreation("Test", 1, 2, null, 10m, 30, _time);
+
+ p.PriceDurationDays.Should().Be(30);
+ }
+
+ // ── R1-S9: WithDeactivated ─────────────────────────────────────────────────
+
+ [Fact]
+ public void WithDeactivated_ActiveProduct_ReturnsInactiveWithModDate()
+ {
+ var product = ValidProduct();
+ var tp2 = new FakeTimeProvider(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
+
+ var deactivated = product.WithDeactivated(tp2);
+
+ deactivated.IsActive.Should().BeFalse();
+ deactivated.FechaModificacion.Should().Be(tp2.GetUtcNow().UtcDateTime);
+ // Immutability: original unchanged
+ product.IsActive.Should().BeTrue();
+ }
+
+ // ── R1-S10: WithDeactivated idempotente ───────────────────────────────────
+
+ [Fact]
+ public void WithDeactivated_AlreadyInactive_ReturnsInactiveNoProblem()
+ {
+ var product = ValidProduct();
+ var deactivated = product.WithDeactivated(_time);
+
+ var act = () => deactivated.WithDeactivated(_time);
+
+ act.Should().NotThrow();
+ act().IsActive.Should().BeFalse();
+ }
+
+ // ── R1-S11: WithUpdated ───────────────────────────────────────────────────
+
+ [Fact]
+ public void WithUpdated_ValidFields_ReturnsNewInstanceWithUpdatedValues()
+ {
+ var product = ValidProduct();
+ var tp2 = new FakeTimeProvider(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
+
+ var updated = product.WithUpdated("Nuevo Nombre", rubroId: null, basePrice: 200m, priceDurationDays: 15, tp2);
+
+ updated.Nombre.Should().Be("Nuevo Nombre");
+ updated.BasePrice.Should().Be(200m);
+ updated.PriceDurationDays.Should().Be(15);
+ updated.FechaModificacion.Should().Be(tp2.GetUtcNow().UtcDateTime);
+ // Immutability: original unchanged
+ product.Nombre.Should().Be("Clasificado Estándar");
+ product.BasePrice.Should().Be(100.50m);
+ }
+
+ // ── Immutability: With* return new instances ──────────────────────────────
+
+ [Fact]
+ public void WithRenamed_ReturnsNewInstance()
+ {
+ var p = ValidProduct();
+ var renamed = p.WithRenamed("Nuevo", _time);
+
+ renamed.Should().NotBeSameAs(p);
+ renamed.Nombre.Should().Be("Nuevo");
+ }
+
+ [Fact]
+ public void WithUpdatedPrice_ReturnsNewInstance()
+ {
+ var p = ValidProduct();
+ var updated = p.WithUpdatedPrice(999m, null, _time);
+
+ updated.Should().NotBeSameAs(p);
+ updated.BasePrice.Should().Be(999m);
+ }
+
+ [Fact]
+ public void WithUpdatedCategory_ReturnsNewInstance()
+ {
+ var p = ValidProduct();
+ var updated = p.WithUpdatedCategory(5, _time);
+
+ updated.Should().NotBeSameAs(p);
+ updated.RubroId.Should().Be(5);
+ }
+
+ // ── MedioId and ProductTypeId are immutable ───────────────────────────────
+
+ [Fact]
+ public void Product_HasNoMethodToChangeMedioId()
+ {
+ var type = typeof(Product);
+ var methods = type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
+
+ methods.Should().NotContain(m => m.Name.Contains("Medio") && m.Name.StartsWith("With"));
+ }
+
+ [Fact]
+ public void Product_HasNoMethodToChangeProductTypeId()
+ {
+ var type = typeof(Product);
+ var methods = type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
+
+ methods.Should().NotContain(m => m.Name.Contains("ProductType") && m.Name.StartsWith("With"));
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/Products/Exceptions/ProductExceptionsTests.cs b/tests/SIGCM2.Application.Tests/Products/Exceptions/ProductExceptionsTests.cs
new file mode 100644
index 0000000..43d3de4
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Products/Exceptions/ProductExceptionsTests.cs
@@ -0,0 +1,60 @@
+using FluentAssertions;
+using SIGCM2.Domain.Exceptions;
+
+namespace SIGCM2.Application.Tests.Products.Exceptions;
+
+///
+/// PRD-002 — Tests for new Product domain exceptions.
+/// Verifies message content and property values.
+///
+public class ProductExceptionsTests
+{
+ [Fact]
+ public void ProductNotFoundException_ContainsId_InMessage()
+ {
+ var ex = new ProductNotFoundException(5);
+
+ ex.Message.Should().Contain("5");
+ ex.ProductId.Should().Be(5);
+ }
+
+ [Fact]
+ public void ProductNombreDuplicadoEnMedioTipoException_ContainsDetails()
+ {
+ var ex = new ProductNombreDuplicadoEnMedioTipoException(2, 3, "Clasificado");
+
+ ex.Message.Should().Contain("Clasificado");
+ ex.Message.Should().Contain("2");
+ ex.Message.Should().Contain("3");
+ ex.MedioId.Should().Be(2);
+ ex.ProductTypeId.Should().Be(3);
+ ex.Nombre.Should().Be("Clasificado");
+ }
+
+ [Fact]
+ public void ProductTipoFlagsIncoherentesException_FieldAndMessage()
+ {
+ var ex = new ProductTipoFlagsIncoherentesException("requiere RubroId", "rubroId");
+
+ ex.Field.Should().Be("rubroId");
+ ex.Message.Should().Contain("requiere RubroId");
+ }
+
+ [Fact]
+ public void ProductTypeInactivoException_ContainsProductTypeId()
+ {
+ var ex = new ProductTypeInactivoException(7);
+
+ ex.Message.Should().Contain("7");
+ ex.ProductTypeId.Should().Be(7);
+ }
+
+ [Fact]
+ public void RubroInactivoException_ContainsRubroId()
+ {
+ var ex = new RubroInactivoException(12);
+
+ ex.Message.Should().Contain("12");
+ ex.RubroId.Should().Be(12);
+ }
+}