Appearance
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
- Problema Técnico
- Descripción del Patrón
- Componentes
- Uso
- Testing
- Versionado Semántico
- Extensión
- 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:
- Módulo Proveedor (ej: Membresia) expone interfaces en
Contracts/ - Módulos Consumidores (ej: CtaCte, Ventas) dependen de las interfaces, NO de implementaciones
- Dependency Injection Container resuelve interfaces a implementaciones concretas
- 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/ # Extensiones2. 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
| Cambio | Versión | Impacto | Acción Requerida |
|---|---|---|---|
| Nuevo método | MINOR | Bajo | Implementaciones deben agregar método |
| Nuevo parámetro opcional | MINOR | Bajo | Implementaciones pueden ignorar |
| Cambio de firma | MAJOR | Alto | Todas las implementaciones deben actualizarse |
| Remover método | MAJOR | Alto | Consumidores deben migrar |
| Cambio de tipo retorno | MAJOR | Alto | Consumidores deben adaptarse |
| Deprecación | MINOR → MAJOR | Medio | Advertir en MINOR, remover en MAJOR |
| Cambio en docblock | PATCH | Ninguno | Solo 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.0Paso 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
- Actualizar CHANGELOG.md con sección detallada
- Publicar breaking changes en reuniones de equipo
- Coordinar con módulos consumidores antes de release
- 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étodo | Parámetros | Retorno | Excepciones | Descripción |
|---|---|---|---|---|
getMiembroById | int $miembroId | ?array | InvalidArgumentException | Obtiene datos completos del miembro |
isMiembroActivo | int $miembroId | bool | InvalidArgumentException | Verifica si el miembro está activo |
getMiembroBasicData | int $miembroId | array | InvalidArgumentException, RuntimeException | Obtiene 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étodo | Parámetros | Retorno | Descripción |
|---|---|---|---|
getFacturacionByMiembro | int $miembroId, array $options = [] | array | Consulta facturación con filtros opcionales |
getFacturacionPendiente | int $miembroId | array | Obtiene facturación sin pagar |
hasFacturacionPendiente | int $miembroId | bool | Verifica si tiene pendientes |
getByOrdconAndPeriodo | int $ordconId, int $anio, int $mes | ?array | Facturació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étodo | Parámetros | Retorno | Excepciones | Descripción |
|---|---|---|---|---|
getProductExtensionById | int $extensionId | ?array | InvalidArgumentException | Obtiene extensión de producto |
validateProductExtension | int $extensionId | bool | - | Valida existencia y estado activo |
getExtensionsByProducto | int $productoId | array | - | 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ón | Cuándo se lanza | Código HTTP recomendado |
|---|---|---|
\InvalidArgumentException | Parámetros inválidos (ID <= 0, formato incorrecto) | 400 Bad Request |
\RuntimeException | Error en tiempo de ejecución (recurso no encontrado, DB error) | 404 Not Found o 500 Internal Error |
\LogicException | Violació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.phpserver/Tests/Unit/CtaCte/CuponValidacionServiceRefactoredTest.phpserver/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
- Dependency Inversion Principle (SOLID): https://en.wikipedia.org/wiki/Dependency_inversion_principle
- PSR-11: Container Interface: https://www.php-fig.org/psr/psr-11/
- Semantic Versioning: https://semver.org/
- PHP-DI Documentation: https://php-di.org/
Última actualización: 2026-01-29 Versión del patrón: 1.0.0 Mantenedor: Equipo de Arquitectura Backend