diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IProductQueryRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductQueryRepository.cs
new file mode 100644
index 0000000..e4418ba
--- /dev/null
+++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductQueryRepository.cs
@@ -0,0 +1,15 @@
+namespace SIGCM2.Application.Abstractions.Persistence;
+
+///
+/// PRD-002 handoff contract — query-only access to Product data needed by ProductType handlers.
+/// PRD-001 binds to NullProductQueryRepository (always returns false).
+/// PRD-002 binds to Dapper impl against dbo.Product (when that table exists).
+///
+public interface IProductQueryRepository
+{
+ ///
+ /// Returns true if at least one active Product with the given ProductTypeId exists.
+ /// Used by DeactivateProductTypeCommandHandler to guard against orphaning active products.
+ ///
+ Task ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default);
+}
diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IProductTypeRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductTypeRepository.cs
new file mode 100644
index 0000000..77b4e33
--- /dev/null
+++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductTypeRepository.cs
@@ -0,0 +1,31 @@
+using SIGCM2.Application.Common;
+using SIGCM2.Domain.Entities;
+
+namespace SIGCM2.Application.Abstractions.Persistence;
+
+///
+/// Write-side repository for ProductType.
+/// All reads needed by write handlers are included here.
+/// Query-side (for listing, filtering) uses GetPagedAsync with ProductTypesQuery.
+///
+public interface IProductTypeRepository
+{
+ /// Inserts a new ProductType and returns the DB-assigned Id.
+ Task AddAsync(ProductType productType, CancellationToken ct = default);
+
+ /// Returns the ProductType with the given Id, or null if not found.
+ Task GetByIdAsync(int id, CancellationToken ct = default);
+
+ /// Returns a paged result of ProductTypes matching the query.
+ Task> GetPagedAsync(ProductTypesQuery query, CancellationToken ct = default);
+
+ /// Persists all changes to an existing ProductType row.
+ Task UpdateAsync(ProductType productType, CancellationToken ct = default);
+
+ ///
+ /// Returns true if an active ProductType with the given nombre exists.
+ /// Pass excludeId to skip the self-comparison during rename (update scenario).
+ /// Case-insensitive — delegates to DB collation (SQL_Latin1_General_CP1_CI_AI).
+ ///
+ Task ExistsByNombreAsync(string nombre, int? excludeId = null, CancellationToken ct = default);
+}
diff --git a/src/api/SIGCM2.Application/Common/ProductTypesQuery.cs b/src/api/SIGCM2.Application/Common/ProductTypesQuery.cs
new file mode 100644
index 0000000..1659f13
--- /dev/null
+++ b/src/api/SIGCM2.Application/Common/ProductTypesQuery.cs
@@ -0,0 +1,10 @@
+namespace SIGCM2.Application.Common;
+
+///
+/// Query parameters for listing ProductTypes (used by IProductTypeRepository.GetPagedAsync).
+///
+public sealed record ProductTypesQuery(
+ int Page = 1,
+ int PageSize = 20,
+ bool? Activo = true,
+ string? Search = null);
diff --git a/src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQuery.cs b/src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQuery.cs
new file mode 100644
index 0000000..f24f204
--- /dev/null
+++ b/src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQuery.cs
@@ -0,0 +1,3 @@
+namespace SIGCM2.Application.ProductTypes.GetById;
+
+public sealed record GetProductTypeByIdQuery(int Id);
diff --git a/src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQueryHandler.cs b/src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQueryHandler.cs
new file mode 100644
index 0000000..27b52e9
--- /dev/null
+++ b/src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQueryHandler.cs
@@ -0,0 +1,30 @@
+using SIGCM2.Application.Abstractions;
+using SIGCM2.Application.Abstractions.Persistence;
+using SIGCM2.Domain.Exceptions;
+
+namespace SIGCM2.Application.ProductTypes.GetById;
+
+public sealed class GetProductTypeByIdQueryHandler
+ : ICommandHandler
+{
+ private readonly IProductTypeRepository _repo;
+
+ public GetProductTypeByIdQueryHandler(IProductTypeRepository repo)
+ {
+ _repo = repo;
+ }
+
+ public async Task Handle(GetProductTypeByIdQuery query)
+ {
+ var pt = await _repo.GetByIdAsync(query.Id)
+ ?? throw new ProductTypeNotFoundException(query.Id);
+
+ return new ProductTypeDetailDto(
+ pt.Id, pt.Nombre,
+ pt.HasDuration, pt.RequiresText, pt.RequiresCategory, pt.IsBundle,
+ pt.AllowImages,
+ pt.MaxImages, pt.MaxImageSizeMB, pt.MaxImageWidth, pt.MaxImageHeight,
+ pt.IsActive,
+ pt.FechaCreacion, pt.FechaModificacion);
+ }
+}
diff --git a/src/api/SIGCM2.Application/ProductTypes/GetById/ProductTypeDetailDto.cs b/src/api/SIGCM2.Application/ProductTypes/GetById/ProductTypeDetailDto.cs
new file mode 100644
index 0000000..52cafc2
--- /dev/null
+++ b/src/api/SIGCM2.Application/ProductTypes/GetById/ProductTypeDetailDto.cs
@@ -0,0 +1,17 @@
+namespace SIGCM2.Application.ProductTypes.GetById;
+
+public sealed record ProductTypeDetailDto(
+ int Id,
+ string Nombre,
+ bool HasDuration,
+ bool RequiresText,
+ bool RequiresCategory,
+ bool IsBundle,
+ bool AllowImages,
+ int? MaxImages,
+ decimal? MaxImageSizeMB,
+ int? MaxImageWidth,
+ int? MaxImageHeight,
+ bool IsActive,
+ DateTime FechaCreacion,
+ DateTime? FechaModificacion);
diff --git a/src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQuery.cs b/src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQuery.cs
new file mode 100644
index 0000000..bc4db25
--- /dev/null
+++ b/src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQuery.cs
@@ -0,0 +1,7 @@
+namespace SIGCM2.Application.ProductTypes.List;
+
+public sealed record ListProductTypesQuery(
+ int Page = 1,
+ int PageSize = 20,
+ bool? Activo = true,
+ string? Search = null);
diff --git a/src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQueryHandler.cs b/src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQueryHandler.cs
new file mode 100644
index 0000000..585b8eb
--- /dev/null
+++ b/src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQueryHandler.cs
@@ -0,0 +1,32 @@
+using SIGCM2.Application.Abstractions;
+using SIGCM2.Application.Abstractions.Persistence;
+using SIGCM2.Application.Common;
+
+namespace SIGCM2.Application.ProductTypes.List;
+
+public sealed class ListProductTypesQueryHandler
+ : ICommandHandler>
+{
+ private readonly IProductTypeRepository _repo;
+
+ public ListProductTypesQueryHandler(IProductTypeRepository repo)
+ {
+ _repo = repo;
+ }
+
+ public async Task> Handle(ListProductTypesQuery query)
+ {
+ var page = Math.Max(1, query.Page);
+ var pageSize = Math.Clamp(query.PageSize, 1, 100);
+
+ var repoQuery = new ProductTypesQuery(page, pageSize, query.Activo, query.Search);
+ var paged = await _repo.GetPagedAsync(repoQuery);
+
+ var items = paged.Items.Select(p => new ProductTypeListItemDto(
+ p.Id, p.Nombre,
+ p.HasDuration, p.RequiresText, p.RequiresCategory, p.IsBundle,
+ p.AllowImages, p.IsActive)).ToList();
+
+ return new PagedResult(items, paged.Page, paged.PageSize, paged.Total);
+ }
+}
diff --git a/src/api/SIGCM2.Application/ProductTypes/List/ProductTypeListItemDto.cs b/src/api/SIGCM2.Application/ProductTypes/List/ProductTypeListItemDto.cs
new file mode 100644
index 0000000..083df94
--- /dev/null
+++ b/src/api/SIGCM2.Application/ProductTypes/List/ProductTypeListItemDto.cs
@@ -0,0 +1,11 @@
+namespace SIGCM2.Application.ProductTypes.List;
+
+public sealed record ProductTypeListItemDto(
+ int Id,
+ string Nombre,
+ bool HasDuration,
+ bool RequiresText,
+ bool RequiresCategory,
+ bool IsBundle,
+ bool AllowImages,
+ bool IsActive);
diff --git a/src/api/SIGCM2.Application/Products/NullProductQueryRepository.cs b/src/api/SIGCM2.Application/Products/NullProductQueryRepository.cs
new file mode 100644
index 0000000..0f9b509
--- /dev/null
+++ b/src/api/SIGCM2.Application/Products/NullProductQueryRepository.cs
@@ -0,0 +1,14 @@
+using SIGCM2.Application.Abstractions.Persistence;
+
+namespace SIGCM2.Application.Products;
+
+///
+/// STUB — PRD-002 replaces the DI binding with a real Dapper impl against dbo.Product.
+/// Returns false for all queries so DeactivateProductTypeCommandHandler guard always passes.
+/// This is intentional for PRD-001: the mechanism is installed; the data feed arrives in PRD-002.
+///
+public sealed class NullProductQueryRepository : IProductQueryRepository
+{
+ public Task ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default)
+ => Task.FromResult(false);
+}
diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/GetById/GetProductTypeByIdQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/GetById/GetProductTypeByIdQueryHandlerTests.cs
new file mode 100644
index 0000000..640d278
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/ProductTypes/GetById/GetProductTypeByIdQueryHandlerTests.cs
@@ -0,0 +1,70 @@
+using FluentAssertions;
+using NSubstitute;
+using SIGCM2.Application.Abstractions.Persistence;
+using SIGCM2.Application.ProductTypes.GetById;
+using SIGCM2.Domain.Entities;
+using SIGCM2.Domain.Exceptions;
+
+namespace SIGCM2.Application.Tests.ProductTypes.GetById;
+
+public class GetProductTypeByIdQueryHandlerTests
+{
+ private readonly IProductTypeRepository _repo = Substitute.For();
+ private readonly GetProductTypeByIdQueryHandler _handler;
+
+ public GetProductTypeByIdQueryHandlerTests()
+ {
+ _handler = new GetProductTypeByIdQueryHandler(_repo);
+ }
+
+ private static ProductType FullProductType(int id = 5) =>
+ new(id, "Clasificados",
+ hasDuration: true, requiresText: true, requiresCategory: false, isBundle: false,
+ allowImages: true, maxImages: 10, maxImageSizeMB: 2.5m, maxImageWidth: 1920, maxImageHeight: 1080,
+ isActive: true,
+ fechaCreacion: new DateTime(2026, 1, 10, 0, 0, 0, DateTimeKind.Utc),
+ fechaModificacion: new DateTime(2026, 3, 5, 0, 0, 0, DateTimeKind.Utc));
+
+ [Fact]
+ public async Task Handle_Found_ReturnsDetailDto()
+ {
+ _repo.GetByIdAsync(5, Arg.Any()).Returns(FullProductType(5));
+
+ var result = await _handler.Handle(new GetProductTypeByIdQuery(5));
+
+ result.Id.Should().Be(5);
+ result.Nombre.Should().Be("Clasificados");
+ }
+
+ [Fact]
+ public async Task Handle_NotFound_ThrowsProductTypeNotFoundException()
+ {
+ _repo.GetByIdAsync(999, Arg.Any()).Returns((ProductType?)null);
+
+ var act = async () => await _handler.Handle(new GetProductTypeByIdQuery(999));
+
+ await act.Should().ThrowAsync()
+ .Where(e => e.ProductTypeId == 999);
+ }
+
+ [Fact]
+ public async Task Handle_DetailContainsAllFields()
+ {
+ _repo.GetByIdAsync(5, Arg.Any()).Returns(FullProductType(5));
+
+ var result = await _handler.Handle(new GetProductTypeByIdQuery(5));
+
+ result.HasDuration.Should().BeTrue();
+ result.RequiresText.Should().BeTrue();
+ result.RequiresCategory.Should().BeFalse();
+ result.IsBundle.Should().BeFalse();
+ result.AllowImages.Should().BeTrue();
+ result.MaxImages.Should().Be(10);
+ result.MaxImageSizeMB.Should().Be(2.5m);
+ result.MaxImageWidth.Should().Be(1920);
+ result.MaxImageHeight.Should().Be(1080);
+ result.IsActive.Should().BeTrue();
+ result.FechaCreacion.Should().Be(new DateTime(2026, 1, 10, 0, 0, 0, DateTimeKind.Utc));
+ result.FechaModificacion.Should().Be(new DateTime(2026, 3, 5, 0, 0, 0, DateTimeKind.Utc));
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/List/ListProductTypesQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/List/ListProductTypesQueryHandlerTests.cs
new file mode 100644
index 0000000..1b525ab
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/ProductTypes/List/ListProductTypesQueryHandlerTests.cs
@@ -0,0 +1,109 @@
+using FluentAssertions;
+using NSubstitute;
+using SIGCM2.Application.Abstractions.Persistence;
+using SIGCM2.Application.Common;
+using SIGCM2.Application.ProductTypes.List;
+using SIGCM2.Domain.Entities;
+
+namespace SIGCM2.Application.Tests.ProductTypes.List;
+
+public class ListProductTypesQueryHandlerTests
+{
+ private readonly IProductTypeRepository _repo = Substitute.For();
+ private readonly ListProductTypesQueryHandler _handler;
+
+ public ListProductTypesQueryHandlerTests()
+ {
+ _handler = new ListProductTypesQueryHandler(_repo);
+ }
+
+ private static ProductType MakeProductType(int id, string nombre, bool isActive = true) =>
+ new(id, nombre, false, false, false, false, false, null, null, null, null,
+ isActive, DateTime.UtcNow, null);
+
+ private static PagedResult PagedOf(List items, int page = 1, int pageSize = 20, int? total = null) =>
+ new(items, page, pageSize, total ?? items.Count);
+
+ // ── Happy path ───────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Handle_DefaultQuery_Returns20Active()
+ {
+ var items = Enumerable.Range(1, 5).Select(i => MakeProductType(i, $"Tipo{i}")).ToList();
+ _repo.GetPagedAsync(Arg.Any(), Arg.Any())
+ .Returns(PagedOf(items));
+
+ var result = await _handler.Handle(new ListProductTypesQuery());
+
+ result.Items.Should().HaveCount(5);
+ }
+
+ [Fact]
+ public async Task Handle_PageLessThan1_ClampsToOne()
+ {
+ _repo.GetPagedAsync(Arg.Any(), Arg.Any())
+ .Returns(PagedOf([]));
+
+ await _handler.Handle(new ListProductTypesQuery(Page: 0));
+
+ await _repo.Received(1).GetPagedAsync(
+ Arg.Is(q => q.Page == 1),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task Handle_PageSizeOver100_ClampsTo100()
+ {
+ _repo.GetPagedAsync(Arg.Any(), Arg.Any())
+ .Returns(PagedOf([]));
+
+ await _handler.Handle(new ListProductTypesQuery(PageSize: 200));
+
+ await _repo.Received(1).GetPagedAsync(
+ Arg.Is(q => q.PageSize == 100),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task Handle_ActivoFalse_ReturnsInactives()
+ {
+ var inactive = new List { MakeProductType(99, "Inactivo", false) };
+ _repo.GetPagedAsync(Arg.Any(), Arg.Any())
+ .Returns(PagedOf(inactive));
+
+ var result = await _handler.Handle(new ListProductTypesQuery(Activo: false));
+
+ await _repo.Received(1).GetPagedAsync(
+ Arg.Is(q => q.Activo == false),
+ Arg.Any());
+ result.Items.Should().HaveCount(1);
+ }
+
+ [Fact]
+ public async Task Handle_SearchFilter_PassesThroughToRepo()
+ {
+ _repo.GetPagedAsync(Arg.Any(), Arg.Any())
+ .Returns(PagedOf([]));
+
+ await _handler.Handle(new ListProductTypesQuery(Search: "clasif"));
+
+ await _repo.Received(1).GetPagedAsync(
+ Arg.Is(q => q.Search == "clasif"),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task Handle_PagedResult_ReturnsItemsAndTotal()
+ {
+ var items = Enumerable.Range(1, 5).Select(i => MakeProductType(i, $"Tipo{i}")).ToList();
+ _repo.GetPagedAsync(Arg.Any(), Arg.Any())
+ .Returns(new PagedResult(items, 2, 5, 15));
+
+ var result = await _handler.Handle(new ListProductTypesQuery(Page: 2, PageSize: 5));
+
+ result.Total.Should().Be(15);
+ result.Page.Should().Be(2);
+ result.PageSize.Should().Be(5);
+ result.Items.Should().HaveCount(5);
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/NullProductQueryRepositoryTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/NullProductQueryRepositoryTests.cs
new file mode 100644
index 0000000..175b4cc
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/ProductTypes/NullProductQueryRepositoryTests.cs
@@ -0,0 +1,26 @@
+using FluentAssertions;
+using SIGCM2.Application.Products;
+
+namespace SIGCM2.Application.Tests.ProductTypes;
+
+public class NullProductQueryRepositoryTests
+{
+ private readonly NullProductQueryRepository _sut = new();
+
+ [Fact]
+ public async Task ExistsActiveByProductTypeAsync_AlwaysReturnsFalse()
+ {
+ var result = await _sut.ExistsActiveByProductTypeAsync(productTypeId: 1);
+
+ result.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task ExistsActiveByProductTypeAsync_WithCancellationToken_DoesNotThrow()
+ {
+ using var cts = new CancellationTokenSource();
+ var act = async () => await _sut.ExistsActiveByProductTypeAsync(productTypeId: 999, ct: cts.Token);
+
+ await act.Should().NotThrowAsync();
+ }
+}