Skip to content

Patrón: Contracts (Dependency Inversion Principle)

Tipo: Patrón Técnico de Backend Alcance: Interfaces públicas entre módulos de Sistema Bautista Estado: Implementado Fecha: 2026-01-29 Versión: 1.0.0

Implementación de Referencia: Módulo Membresia Contracts

Este documento describe el patrón arquitectónico de Contracts basado en el principio SOLID Dependency Inversion Principle (DIP), que permite el desacoplamiento entre módulos mediante interfaces públicas.


Índice

  1. Problema Técnico
  2. Descripción del Patrón
  3. Componentes
  4. Uso
  5. Testing
  6. Versionado Semántico
  7. Extensión
  8. Referencia de API

Problema Técnico

Contexto

En Sistema Bautista, los módulos de negocio (CtaCte, Ventas, Compras, Stock, etc.) frecuentemente necesitan consultar información de otros módulos. Por ejemplo:

  • CtaCte necesita validar si un miembro de membresía está activo para aceptar cupones
  • Ventas necesita obtener extensiones de productos relacionadas con membresías
  • Tesorería necesita consultar facturación pendiente de membresías

Problemas antes del patrón

  • Acoplamiento Directo: Módulos dependían de implementaciones concretas de otros módulos

    php
    // CtaCte dependía directamente de la clase concreta
    use Membresia\Application\Services\MiembrosService;
    
    class CuponValidacionService {
        public function __construct(
            private MiembrosService $miembrosService // ❌ Dependencia concreta
        ) {}
    }
  • Fragilidad: Cambios internos en un módulo rompían consumidores externos

  • Dificultad para Testing: Imposible mockear dependencias sin frameworks complejos

  • Evolución Bloqueada: No se podía refactorizar internamente sin coordinar con todos los consumidores

  • Sin Contratos Explícitos: No había garantías sobre qué métodos eran públicos vs internos

Código repetitivo sin el patrón

php
// ANTES: Cada módulo consumidor tenía que conocer detalles internos

// En CtaCte
$miembro = $miembrosService->find($id); // ❌ ¿Qué retorna? ¿Array? ¿Object?
if ($miembro && $miembro['estado'] === 'activo') { // ❌ ¿Qué campos tiene?
    // validar cupón
}

// En Ventas
$miembro = $miembrosService->getMiembro($id); // ❌ Método diferente
if ($miembro->isActive()) { // ❌ API inconsistente
    // procesar venta
}

Descripción del Patrón

Solución: Interfaces Públicas como Contratos

El patrón Contracts establece interfaces públicas que definen el contrato entre módulos:

  1. Módulo Proveedor (ej: Membresia) expone interfaces en Contracts/
  2. Módulos Consumidores (ej: CtaCte, Ventas) dependen de las interfaces, NO de implementaciones
  3. Dependency Injection Container resuelve interfaces a implementaciones concretas
  4. Versionado Semántico garantiza estabilidad de contratos

Principios de Diseño

  • Dependency Inversion Principle (SOLID): Módulos de alto nivel no dependen de módulos de bajo nivel; ambos dependen de abstracciones
  • Interface Segregation Principle: Interfaces específicas por caso de uso (MiembrosServiceInterface, FacturacionRepositoryInterface)
  • Separation of Concerns: Interfaces públicas separadas de implementaciones internas
  • Semantic Versioning: Contratos versionados con garantías de compatibilidad

Diagrama de Arquitectura

┌─────────────────────────────────────────────────────────────┐
│  MÓDULO CONSUMIDOR (CtaCte, Ventas, Tesorería)             │
│                                                             │
│  class CuponValidacionService {                             │
│      __construct(                                           │
│          MiembrosServiceInterface $service  ← Interface     │
│      )                                                      │
│  }                                                          │
└────────────────────────┬────────────────────────────────────┘
                         │ Depende de abstracción

┌─────────────────────────────────────────────────────────────┐
│  CONTRACTS (Interfaces Públicas)                            │
│  Módulo: Membresia/Contracts/                               │
│                                                             │
│  - MiembrosServiceInterface.php                             │
│  - MembresiaFacturacionRepositoryInterface.php              │
│  - ProductExtensionProviderInterface.php                    │
└────────────────────────┬────────────────────────────────────┘
                         │ Implementa

┌─────────────────────────────────────────────────────────────┐
│  IMPLEMENTACIÓN (Módulo Proveedor)                          │
│  Módulo: Membresia/Application/Services/                    │
│                                                             │
│  class MiembrosService implements MiembrosServiceInterface  │
│  {                                                          │
│      // Implementación concreta                            │
│  }                                                          │
└─────────────────────────────────────────────────────────────┘

Componentes

1. Contracts Directory

Ubicación: server/Modules/{Module}/Contracts/

Namespace: {Module}\Contracts

Tipo: Directorio de interfaces públicas

Responsabilidad: Contiene todas las interfaces que otros módulos pueden consumir

Estructura:

Modules/Membresia/
├── Contracts/                    # ← Interfaces públicas
│   ├── MiembrosServiceInterface.php
│   ├── MembresiaFacturacionRepositoryInterface.php
│   ├── ProductExtensionProviderInterface.php
│   └── README.md                 # Documentación de contratos
├── Application/Services/         # Implementaciones
├── Infrastructure/Repository/    # Acceso a datos
└── Presentation/Provider/        # Extensiones

2. Interface Pública (Ejemplo: MiembrosServiceInterface)

Ubicación: server/Modules/Membresia/Contracts/MiembrosServiceInterface.php

Namespace: Membresia\Contracts

Tipo: Interface

Responsabilidad: Define contrato para operaciones sobre miembros

php
<?php

declare(strict_types=1);

namespace Membresia\Contracts;

/**
 * Interface MiembrosServiceInterface
 *
 * Contrato público para operaciones sobre miembros.
 *
 * GARANTÍAS:
 * - Métodos estables (breaking changes solo en major versions)
 * - Respuestas consistentes y documentadas
 * - Excepciones bien definidas
 *
 * @version 1.0.0
 * @since 2026-01-29
 */
interface MiembrosServiceInterface
{
    /**
     * Obtener miembro por ID
     *
     * @param int $miembroId ID del miembro
     * @return array|null Datos del miembro o null si no existe
     * @throws \InvalidArgumentException Si el ID es inválido
     */
    public function getMiembroById(int $miembroId): ?array;

    /**
     * Verificar si un miembro está activo
     *
     * @param int $miembroId ID del miembro
     * @return bool True si está activo, false en caso contrario
     * @throws \InvalidArgumentException Si el ID es inválido
     */
    public function isMiembroActivo(int $miembroId): bool;

    /**
     * Obtener datos básicos de miembro para validaciones
     *
     * @param int $miembroId ID del miembro
     * @return array Datos básicos: id, nombre, estado, categoria_id
     * @throws \InvalidArgumentException Si el ID es inválido
     * @throws \RuntimeException Si el miembro no existe
     */
    public function getMiembroBasicData(int $miembroId): array;
}

3. Implementación Concreta

Ubicación: server/Modules/Membresia/Application/Services/MiembrosService.php

Namespace: Membresia\Application\Services

Tipo: Service Class

Responsabilidad: Implementa el contrato definido en la interface

php
<?php

declare(strict_types=1);

namespace Membresia\Application\Services;

use Membresia\Contracts\MiembrosServiceInterface;
use DI\Attribute\Injectable;

/**
 * Servicio para gestión de miembros (ordenantes/contribuyentes).
 *
 * @inheritDoc
 */
#[Injectable(lazy: true)]
class MiembrosService implements MiembrosServiceInterface
{
    public function getMiembroById(int $miembroId): ?array
    {
        // Implementación con acceso a base de datos
        // Retorna array consistente con el contrato
    }

    public function isMiembroActivo(int $miembroId): bool
    {
        // Implementación con validación de estado
    }

    public function getMiembroBasicData(int $miembroId): array
    {
        // Implementación con datos mínimos requeridos
    }
}

4. Dependency Injection Container Binding

Ubicación: server/index.php

Responsabilidad: Vincular interfaces con implementaciones concretas

php
use DI\ContainerBuilder;
use function DI\autowire;
use Membresia\Contracts\MiembrosServiceInterface;
use Membresia\Application\Services\MiembrosService;

$containerBuilder = new ContainerBuilder();
$containerBuilder->addDefinitions([
    // Bind interface to concrete implementation
    MiembrosServiceInterface::class => autowire(MiembrosService::class),
]);

$container = $containerBuilder->build();

Uso

Implementación en Módulo Consumidor

Paso 1: Declarar dependencia de interface (NO implementación)

php
<?php

namespace App\service\CtaCte;

use Membresia\Contracts\MiembrosServiceInterface; // ✅ Depende de interface

class CuponValidacionService
{
    public function __construct(
        private MiembrosServiceInterface $miembrosService // ✅ Interface, no clase concreta
    ) {}
}

Paso 2: Usar métodos del contrato

php
public function validarCupon(CuponValidacionRequest $request): CuponValidacionResponse
{
    // 1. Validar que miembro exista y esté activo
    if (!$this->miembrosService->isMiembroActivo($request->miembroId)) {
        throw new BadRequest('Miembro inactivo o inexistente');
    }

    // 2. Obtener datos básicos para validaciones
    $miembroData = $this->miembrosService->getMiembroBasicData($request->miembroId);

    // 3. Continuar con lógica de negocio...
    return new CuponValidacionResponse(/* ... */);
}

Ejemplo Completo: CuponValidacionService

php
<?php

namespace App\service\CtaCte;

use App\connection\ConnectionManager;
use App\Domain\CtaCte\Cupon\BarcodeValidator;
use App\Domain\CtaCte\Cupon\CuponBusinessValidator;
use App\Resources\CtaCte\Cupon\CuponValidacionRequest;
use App\Resources\CtaCte\Cupon\CuponValidacionResponse;
use Membresia\Contracts\MiembrosServiceInterface;
use Membresia\Contracts\MembresiaFacturacionRepositoryInterface;

/**
 * Servicio de validación de cupones desacoplado de implementaciones de Membresia.
 *
 * ARQUITECTURA: Usa Dependency Inversion Principle
 * - Depende de abstracciones (interfaces), no implementaciones concretas
 * - Facilita testing con mocks
 * - Permite evolución independiente de módulos
 */
class CuponValidacionService
{
    public function __construct(
        private ConnectionManager $manager,
        private BarcodeValidator $barcodeValidator,
        private CuponBusinessValidator $businessValidator,
        private MiembrosServiceInterface $miembrosService,  // ✅ Interface
        private MembresiaFacturacionRepositoryInterface $facturacionRepository // ✅ Interface
    ) {}

    public function validarCupon(CuponValidacionRequest $request): CuponValidacionResponse
    {
        // 1. Validar formato de código de barras (Domain Layer)
        $barcode = $this->barcodeValidator->parse($request->codigoBarras);

        // 2. Usar interface para obtener datos del miembro
        $miembro = $this->miembrosService->getMiembroById($barcode->miembroId);

        if (!$miembro) {
            throw new BadRequest('Miembro no encontrado');
        }

        // 3. Usar interface para verificar facturación
        $facturacion = $this->facturacionRepository->getByOrdconAndPeriodo(
            $barcode->ordconId,
            $barcode->anio,
            $barcode->mes
        );

        // 4. Validar reglas de negocio (Domain Layer)
        $this->businessValidator->validate($miembro, $facturacion);

        // 5. Retornar respuesta
        return CuponValidacionResponse::fromData($miembro, $facturacion);
    }
}

Testing

Test de Interface con Mocks

Ubicación: Tests/Unit/CtaCte/CuponValidacionServiceTest.php

Ventaja: Mocks triviales sin dependencias reales

php
<?php

use PHPUnit\Framework\TestCase;
use App\service\CtaCte\CuponValidacionService;
use Membresia\Contracts\MiembrosServiceInterface;
use Membresia\Contracts\MembresiaFacturacionRepositoryInterface;

class CuponValidacionServiceTest extends TestCase
{
    public function testValidarCuponConMiembroActivo(): void
    {
        // Arrange: Mock de la interface (NO implementación concreta)
        $miembrosService = $this->createMock(MiembrosServiceInterface::class);

        $miembrosService
            ->expects($this->once())
            ->method('isMiembroActivo')
            ->with(123)
            ->willReturn(true); // ✅ Control total del comportamiento

        $miembrosService
            ->method('getMiembroBasicData')
            ->willReturn([
                'id' => 123,
                'nombre' => 'Juan Pérez',
                'estado' => 'activo',
                'categoria_id' => 1
            ]);

        // Crear servicio con mock inyectado
        $service = new CuponValidacionService(
            $this->createMock(ConnectionManager::class),
            $this->createMock(BarcodeValidator::class),
            $this->createMock(CuponBusinessValidator::class),
            $miembrosService,  // ✅ Mock de interface
            $this->createMock(MembresiaFacturacionRepositoryInterface::class)
        );

        // Act
        $result = $service->validarCupon($request);

        // Assert
        $this->assertTrue($result->isValid());
    }
}

Test de Implementación Concreta

Ubicación: Tests/Unit/Membresia/Application/Services/MiembrosServiceTest.php

php
<?php

use PHPUnit\Framework\TestCase;
use Membresia\Application\Services\MiembrosService;
use Membresia\Contracts\MiembrosServiceInterface;

class MiembrosServiceTest extends TestCase
{
    public function testImplementsInterface(): void
    {
        $service = new MiembrosService(/* dependencies */);

        // Verificar que implementa el contrato
        $this->assertInstanceOf(MiembrosServiceInterface::class, $service);
    }

    public function testGetMiembroByIdRetornaNull(): void
    {
        $service = new MiembrosService(/* dependencies */);

        $result = $service->getMiembroById(999999);

        $this->assertNull($result);
    }

    public function testIsMiembroActivoRetornaFalseParaMiembroInexistente(): void
    {
        $service = new MiembrosService(/* dependencies */);

        $result = $service->isMiembroActivo(999999);

        $this->assertFalse($result);
    }
}

Test de Integración

Ubicación: Tests/Integration/CtaCte/CuponValidacionServiceTest.php

php
<?php

use Tests\Integration\BaseIntegrationTestCase;
use App\service\CtaCte\CuponValidacionService;

class CuponValidacionServiceIntegrationTest extends BaseIntegrationTestCase
{
    public function testValidarCuponConDatosReales(): void
    {
        // Arrange: Usar implementación real, no mocks
        $service = $this->container->get(CuponValidacionService::class);

        // DI Container resuelve automáticamente:
        // MiembrosServiceInterface → MiembrosService (implementación real)

        // Act & Assert
        $result = $service->validarCupon($request);
        $this->assertTrue($result->isValid());
    }
}

Versionado Semántico

Versión Actual: 1.0.0

Las interfaces siguen Semantic Versioning (semver.org):

  • MAJOR (1.0.0 → 2.0.0): Breaking changes (métodos removidos, firmas cambiadas)
  • MINOR (1.0.0 → 1.1.0): Nuevos métodos (backward compatible)
  • PATCH (1.0.0 → 1.0.1): Cambios en documentación (sin afectar código)

Política de Compatibilidad

CambioVersiónImpactoAcción Requerida
Nuevo métodoMINORBajoImplementaciones deben agregar método
Nuevo parámetro opcionalMINORBajoImplementaciones pueden ignorar
Cambio de firmaMAJORAltoTodas las implementaciones deben actualizarse
Remover métodoMAJORAltoConsumidores deben migrar
Cambio de tipo retornoMAJORAltoConsumidores deben adaptarse
DeprecaciónMINOR → MAJORMedioAdvertir en MINOR, remover en MAJOR
Cambio en docblockPATCHNingunoSolo documentación

Proceso de Deprecación

Paso 1: Marcar como deprecated (MINOR version)

php
interface MiembrosServiceInterface
{
    /**
     * @deprecated 1.1.0 Use getMiembroById() instead. Will be removed in 2.0.0
     */
    public function getMiembro(int $miembroId): ?array;

    /**
     * @since 1.1.0
     */
    public function getMiembroById(int $miembroId): ?array;
}

Paso 2: Publicar changelog

markdown
## [v1.1.0] - 2026-02-15

### Added
- `getMiembroById()` - New method replacing deprecated `getMiembro()`

### Deprecated
- `getMiembro()` - Use `getMiembroById()` instead. Will be removed in 2.0.0

Paso 3: Período de gracia (mínimo 1 release)

Paso 4: Remover en MAJOR version

php
// v2.0.0
interface MiembrosServiceInterface
{
    // ❌ getMiembro() removido

    public function getMiembroById(int $miembroId): ?array; // ✅ Único método
}

Comunicación de Cambios

  1. Actualizar CHANGELOG.md con sección detallada
  2. Publicar breaking changes en reuniones de equipo
  3. Coordinar con módulos consumidores antes de release
  4. Período de deprecación mínimo: 1 release (recomendado 2 releases)

Extensión

Agregar Nueva Interface al Módulo

Paso 1: Crear interface en Contracts/

php
<?php

declare(strict_types=1);

namespace Membresia\Contracts;

/**
 * Interface CategoriaServiceInterface
 *
 * Contrato para operaciones sobre categorías de membresía.
 *
 * @version 1.0.0
 * @since 2026-02-01
 */
interface CategoriaServiceInterface
{
    /**
     * Obtener categoría por ID
     *
     * @param int $categoriaId
     * @return array|null
     */
    public function getCategoriaById(int $categoriaId): ?array;

    /**
     * Listar categorías activas
     *
     * @return array
     */
    public function listCategoriasActivas(): array;
}

Paso 2: Implementar en Service

php
<?php

namespace Membresia\Application\Services;

use Membresia\Contracts\CategoriaServiceInterface;
use DI\Attribute\Injectable;

#[Injectable(lazy: true)]
class CategoriaService implements CategoriaServiceInterface
{
    public function getCategoriaById(int $categoriaId): ?array
    {
        // Implementación
    }

    public function listCategoriasActivas(): array
    {
        // Implementación
    }
}

Paso 3: Registrar en DI Container

php
// server/index.php
$containerBuilder->addDefinitions([
    CategoriaServiceInterface::class => autowire(CategoriaService::class),
]);

Paso 4: Documentar en README del módulo

markdown
### 4. CategoriaServiceInterface

Contrato para operaciones sobre categorías de membresía.

**Usuarios**: Ventas (validación de productos), CtaCte (cálculo de precios)

**Métodos**:
- `getCategoriaById(int $categoriaId): ?array`
- `listCategoriasActivas(): array`

Consumir Nueva Interface desde Otro Módulo

php
<?php

namespace App\service\Ventas;

use Membresia\Contracts\CategoriaServiceInterface;

class ProductoService
{
    public function __construct(
        private CategoriaServiceInterface $categoriaService // ✅ Nueva dependencia
    ) {}

    public function validarProducto(int $productoId, int $categoriaId): bool
    {
        $categoria = $this->categoriaService->getCategoriaById($categoriaId);
        return $categoria !== null;
    }
}

Referencia de API

MiembrosServiceInterface

MétodoParámetrosRetornoExcepcionesDescripción
getMiembroByIdint $miembroId?arrayInvalidArgumentExceptionObtiene datos completos del miembro
isMiembroActivoint $miembroIdboolInvalidArgumentExceptionVerifica si el miembro está activo
getMiembroBasicDataint $miembroIdarrayInvalidArgumentException, RuntimeExceptionObtiene datos básicos (id, nombre, estado, categoria_id)

Estructura de retorno getMiembroById y getMiembroBasicData:

php
[
    'id' => 123,
    'nombre' => 'Juan Pérez',
    'identificacion' => '20-12345678-9',
    'estado' => 'activo',
    'categoria_id' => 1,
    'email' => 'juan@example.com',
    'telefono1' => '1234567890',
    // ... más campos según necesidad
]

MembresiaFacturacionRepositoryInterface

MétodoParámetrosRetornoDescripción
getFacturacionByMiembroint $miembroId, array $options = []arrayConsulta facturación con filtros opcionales
getFacturacionPendienteint $miembroIdarrayObtiene facturación sin pagar
hasFacturacionPendienteint $miembroIdboolVerifica si tiene pendientes
getByOrdconAndPeriodoint $ordconId, int $anio, int $mes?arrayFacturación específica de un período

Opciones para getFacturacionByMiembro:

php
[
    'periodo' => '2024-01',    // Filtrar por período
    'estado' => 'pendiente',   // Filtrar por estado
    'limit' => 10,             // Límite de resultados
    'offset' => 0              // Offset para paginación
]

ProductExtensionProviderInterface

MétodoParámetrosRetornoExcepcionesDescripción
getProductExtensionByIdint $extensionId?arrayInvalidArgumentExceptionObtiene extensión de producto
validateProductExtensionint $extensionIdbool-Valida existencia y estado activo
getExtensionsByProductoint $productoIdarray-Lista extensiones de un producto

Garantías de Contratos

Garantías Técnicas

Cada interface especifica sus garantías técnicas:

MiembrosServiceInterface

  • Estabilidad: Breaking changes solo en major versions
  • Consistencia: Respuestas con estructura documentada
  • Excepciones: Tipos de excepción bien definidos (InvalidArgumentException, RuntimeException)
  • Idempotencia: Métodos de consulta sin efectos secundarios

MembresiaFacturacionRepositoryInterface

  • Performance: Queries optimizados con límites
  • Multi-tenancy: Manejo consistente de schemas PostgreSQL
  • Validación: Datos sanitizados y validados antes de retorno
  • Paginación: Soporte opcional de limit/offset

ProductExtensionProviderInterface

  • Validación: Existencia y permisos verificados
  • Multi-tenancy: Respeta contexto de tenant actual
  • Cacheabilidad: Respuestas diseñadas para ser cacheadas
  • Consistencia: Datos normalizados para consumo externo

Manejo de Excepciones

Los métodos pueden lanzar las siguientes excepciones estándar:

ExcepciónCuándo se lanzaCódigo HTTP recomendado
\InvalidArgumentExceptionParámetros inválidos (ID <= 0, formato incorrecto)400 Bad Request
\RuntimeExceptionError en tiempo de ejecución (recurso no encontrado, DB error)404 Not Found o 500 Internal Error
\LogicExceptionViolación de lógica de negocio (estado inconsistente)422 Unprocessable Entity

Ejemplo de manejo en consumidor:

php
try {
    $miembro = $this->miembrosService->getMiembroBasicData($id);
} catch (\InvalidArgumentException $e) {
    // ID inválido
    return $response->withJson(['error' => 'ID inválido'], 400);
} catch (\RuntimeException $e) {
    // Miembro no existe o error de DB
    return $response->withJson(['error' => 'Miembro no encontrado'], 404);
} catch (\LogicException $e) {
    // Estado inconsistente
    return $response->withJson(['error' => 'Estado de miembro inconsistente'], 422);
}

Referencias

  • Implementación de referencia: server/Modules/Membresia/Contracts/
  • Tests:
    • server/Tests/Unit/Membresia/Application/Services/MiembrosServiceInterfaceTest.php
    • server/Tests/Unit/CtaCte/CuponValidacionServiceRefactoredTest.php
    • server/Tests/Integration/CtaCte/CuponValidacionServiceTest.php
  • Documentación de Contracts: server/Modules/Membresia/Contracts/README.md
  • DI Container Setup: server/index.php (líneas 106-111)
  • Ejemplo de uso: server/service/CtaCte/CuponValidacionService.php

Recursos Externos


Última actualización: 2026-01-29 Versión del patrón: 1.0.0 Mantenedor: Equipo de Arquitectura Backend