Skip to main content

5. Servicios de dominio

5.1. Propósito y estructura de un servicio

Los servicios de dominio son el corazón de la lógica de negocio en nuestro framework. Mientras que los controladores se ocupan de la comunicación HTTP y los repositorios del acceso a datos, los servicios contienen las reglas y operaciones específicas del dominio.

La estructura básica de un servicio es la siguiente:

namespace ApiLayer\Domains\Products;

use App\Base\DomainService;
use App\Core\RequestFactory as Request;
use Infrastructures\Legacy\PMaes\MaesartiProductos\MaesartiProductosRepository;
use ApiLayer\Domains\Products\DTOs\ResMaesartiProductosDTO;
use ApiLayer\Domains\Products\DTOs\ReqMaesartiProductosDTO;

class ProductsService extends DomainService
{
protected $maesartiProductosRepository;
protected $resMaesartiProductosDTO;
protected $reqMaesartiProductosDTO;

public function __construct(
MaesartiProductosRepository $maesartiProductosRepository,
ResMaesartiProductosDTO $resMaesartiProductosDTO,
ReqMaesartiProductosDTO $reqMaesartiProductosDTO
) {
$this->maesartiProductosRepository = $maesartiProductosRepository;
$this->resMaesartiProductosDTO = $resMaesartiProductosDTO;
$this->reqMaesartiProductosDTO = $reqMaesartiProductosDTO;
$this->maesartiProductosRepository->selectable(ResMaesartiProductosDTO::$selectables);
}

public function index(Request $request)
{
$resources = $this->maesartiProductosRepository->collection($request->all());
$resources['collection'] = $this->mapToDTO($resources['collection'], $this->resMaesartiProductosDTO);
return $resources;
}

public function selectById($id)
{
$resource = $this->maesartiProductosRepository->selectById($id);
return $this->resMaesartiProductosDTO->handle($resource)->toArray();
}

public function store(array $request)
{
$dto = $this->reqMaesartiProductosDTO->handle($request);
$resource = $this->maesartiProductosRepository->store($dto->toArray());
return $this->resMaesartiProductosDTO->handle($resource)->toArray();
}

public function update($id, array $request)
{
$dto = $this->reqMaesartiProductosDTO->handle($request);
$resource = $this->maesartiProductosRepository->update($id, $dto->toArray());
return $this->resMaesartiProductosDTO->handle($resource)->toArray();
}

public function delete($id)
{
$this->maesartiProductosRepository->delete($id);
return ['deleted' => $id];
}
}

Aspectos importantes:

  • Los servicios extienden DomainService
  • Reciben repositorios y DTOs por inyección de dependencias
  • Implementan los 5 métodos básicos CRUD: index, selectById, store, update, delete
  • Utilizan los DTOs para transformar datos de entrada y salida
  • Configuran qué campos seleccionar con selectable()

5.2. Implementando la lógica de negocio

Los servicios son el lugar ideal para implementar lógica de negocio compleja. Estos son los tipos de operaciones que deberías incluir en tus servicios:

Validaciones complejas de negocio

public function store(array $request)
{
$dto = $this->reqMaesartiProductosDTO->handle($request);
$data = $dto->toArray();

// Validación de lógica de negocio (no de formato)
if ($data['precio'] < 0) {
throw new BadRequestException("El precio no puede ser negativo");
}

// Verificar si ya existe un producto con el mismo código
$existing = $this->maesartiProductosRepository->selectOne(['codigo_articulo' => $data['codigo_articulo']]);
if ($existing) {
throw new BadRequestException("Ya existe un producto con este código");
}

$resource = $this->maesartiProductosRepository->store($data);
return $this->resMaesartiProductosDTO->handle($resource)->toArray();
}

Procesamiento de datos antes/después de operaciones de base de datos

public function update($id, array $request)
{
$dto = $this->reqMaesartiProductosDTO->handle($request);
$data = $dto->toArray();

// Obtener el producto actual para comparar
$currentProduct = $this->maesartiProductosRepository->selectById($id);

// Lógica pre-actualización
if (isset($data['precio']) && $data['precio'] != $currentProduct['precio']) {
// Si el precio cambia, registrar el cambio
$this->logPriceChange($id, $currentProduct['precio'], $data['precio']);
}

// Realizar actualización
$resource = $this->maesartiProductosRepository->update($id, $data);

// Lógica post-actualización
if (isset($data['stock']) && $data['stock'] < 5) {
// Si el stock es bajo, enviar notificación
$this->notifyLowStock($resource);
}

return $this->resMaesartiProductosDTO->handle($resource)->toArray();
}

private function logPriceChange($productId, $oldPrice, $newPrice)
{
// Lógica para registrar cambio de precio
}

private function notifyLowStock($product)
{
// Lógica para enviar notificación
}

Métodos personalizados para operaciones específicas

// Método para buscar productos por término
public function searchProducts($term)
{
// Aplicar filtro de búsqueda
$filters = [
'$or' => [
['nombre' => ['like' => $term]],
['codigo_articulo' => ['like' => $term]]
]
];

$request = [
'page' => 1,
'itemsPerPage' => 50,
'filters' => json_encode($filters)
];

$results = $this->maesartiProductosRepository->collection($request);
$results['collection'] = $this->mapToDTO($results['collection'], $this->resMaesartiProductosDTO);

return $results;
}

// Método para obtener productos por categoría
public function getProductsByCategory($categoryId)
{
$filters = [
'categoria_id' => ['=' => $categoryId]
];

$request = [
'page' => 1,
'itemsPerPage' => 100,
'filters' => json_encode($filters),
'sortBy' => json_encode(['nombre']),
'sortDesc' => json_encode([false])
];

$results = $this->maesartiProductosRepository->collection($request);
return $this->mapToDTO($results['collection'], $this->resMaesartiProductosDTO);
}

5.3. Utilizando múltiples repositorios en un servicio

Una gran ventaja de los servicios es que pueden orquestar operaciones que involucren múltiples repositorios. Esto es ideal para operaciones complejas que tocan varias entidades.

Ejemplo de un servicio de pedidos que usa múltiples repositorios:

namespace ApiLayer\Domains\Orders;

use App\Base\DomainService;
use App\Core\RequestFactory as Request;
use Infrastructures\Legacy\PVtas\VenfaccabPedidos\VenfaccabPedidosRepository;
use Infrastructures\Legacy\PVtas\VenfaclinPedidosLinea\VenfaclinPedidosLineaRepository;
use Infrastructures\Legacy\PMaes\MaesartiProductos\MaesartiProductosRepository;
use Infrastructures\Legacy\PPos\PoscliClientes\PoscliClientesRepository;
use ApiLayer\Domains\Orders\DTOs\ReqOrderDTO;
use ApiLayer\Domains\Orders\DTOs\ResOrderDTO;

class OrdersService extends DomainService
{
protected $orderRepository;
protected $orderLineRepository;
protected $productRepository;
protected $customerRepository;
protected $resOrderDTO;
protected $reqOrderDTO;

public function __construct(
VenfaccabPedidosRepository $orderRepository,
VenfaclinPedidosLineaRepository $orderLineRepository,
MaesartiProductosRepository $productRepository,
PoscliClientesRepository $customerRepository,
ResOrderDTO $resOrderDTO,
ReqOrderDTO $reqOrderDTO
) {
$this->orderRepository = $orderRepository;
$this->orderLineRepository = $orderLineRepository;
$this->productRepository = $productRepository;
$this->customerRepository = $customerRepository;
$this->resOrderDTO = $resOrderDTO;
$this->reqOrderDTO = $reqOrderDTO;
}

public function store(array $request)
{
// Validar que el cliente exista
$clientId = $request['clientId'];
$client = $this->customerRepository->selectById($clientId);
if (!$client) {
throw new \Exception("Cliente no encontrado", 404);
}

// Validar productos y stock
foreach ($request['items'] as $item) {
$productId = $item['productId'];
$quantity = $item['quantity'];

$product = $this->productRepository->selectById($productId);
if (!$product) {
throw new \Exception("Producto {$productId} no encontrado", 404);
}

if ($product['stock'] < $quantity) {
throw new \Exception("Stock insuficiente para el producto {$product['nombre']}", 400);
}
}

// Crear el pedido base
$orderData = $this->reqOrderDTO->handle($request)->toArray();
$order = $this->orderRepository->store($orderData);

// Crear las líneas del pedido
foreach ($request['items'] as $item) {
$lineData = [
'pedido_id' => $order['id'],
'producto_id' => $item['productId'],
'cantidad' => $item['quantity'],
'precio' => $item['price'],
'total_linea' => $item['quantity'] * $item['price']
];

$this->orderLineRepository->store($lineData);

// Actualizar stock del producto
$product = $this->productRepository->selectById($item['productId']);
$this->productRepository->update($item['productId'], [
'stock' => $product['stock'] - $item['quantity']
]);
}

// Devolver el pedido completo
return $this->getCompleteOrder($order['id']);
}

public function getCompleteOrder($orderId)
{
// Obtener pedido base
$order = $this->orderRepository->selectById($orderId);

// Obtener líneas del pedido
$lines = $this->orderLineRepository->collection([
'filters' => json_encode(['pedido_id' => ['=' => $orderId]]),
'page' => 1,
'itemsPerPage' => 100
]);

// Obtener cliente
$client = $this->customerRepository->selectById($order['cliente_id']);

// Construir respuesta completa
$order['lines'] = $lines['collection'];
$order['client'] = $client;

return $this->resOrderDTO->handle($order)->toArray();
}
}

5.4. Operaciones con transacciones

Las transacciones son esenciales para mantener la integridad de los datos cuando una operación involucra múltiples cambios que deben ocurrir juntos o no ocurrir en absoluto. El repositorio proporciona métodos para manejar transacciones.

5.4.1. Cómo envolver operaciones en transacciones

Los repositorios tienen un método transaction que acepta una función de callback, dentro de la cual todas las operaciones de base de datos se ejecutan como una única transacción:

public function store(array $request)
{
$dto = $this->reqOrderDTO->handle($request);
$orderData = $dto->toArray();

// Usar el método transaction para envolver múltiples operaciones
return $this->orderRepository->transaction(function() use ($orderData, $request) {
// 1. Crear el pedido
$order = $this->orderRepository->store($orderData);
$orderId = $order['id'];

// 2. Crear las líneas del pedido
foreach ($request['items'] as $item) {
$lineData = [
'pedido_id' => $orderId,
'producto_id' => $item['productId'],
'cantidad' => $item['quantity'],
'precio' => $item['price']
];

$this->orderLineRepository->store($lineData);

// 3. Actualizar stock
$product = $this->productRepository->selectById($item['productId']);
$newStock = $product['stock'] - $item['quantity'];

$this->productRepository->update($item['productId'], [
'stock' => $newStock
]);
}

// 4. Actualizar totales del pedido
$total = array_sum(array_map(function($item) {
return $item['quantity'] * $item['price'];
}, $request['items']));

$this->orderRepository->update($orderId, [
'total' => $total
]);

// Devolver el pedido completo
return $this->getCompleteOrder($orderId);
});
}

5.4.2. Manejo de errores en transacciones

Si ocurre algún error dentro del callback de la transacción, automáticamente se producirá un rollback y se lanzará la excepción:

public function transferirFondos($cuentaOrigenId, $cuentaDestinoId, $monto)
{
return $this->cuentaRepository->transaction(function() use ($cuentaOrigenId, $cuentaDestinoId, $monto) {
// 1. Obtener cuentas
$origen = $this->cuentaRepository->selectById($cuentaOrigenId);
$destino = $this->cuentaRepository->selectById($cuentaDestinoId);

if (!$origen || !$destino) {
throw new NotFoundException("Una o ambas cuentas no existen");
}

// 2. Verificar saldo suficiente
if ($origen['saldo'] < $monto) {
throw new BadRequestException("Saldo insuficiente");
}

// 3. Restar de origen
$this->cuentaRepository->update($cuentaOrigenId, [
'saldo' => $origen['saldo'] - $monto
]);

// 4. Sumar a destino
$this->cuentaRepository->update($cuentaDestinoId, [
'saldo' => $destino['saldo'] + $monto
]);

// 5. Registrar movimiento
$this->movimientoRepository->store([
'cuenta_origen_id' => $cuentaOrigenId,
'cuenta_destino_id' => $cuentaDestinoId,
'monto' => $monto,
'fecha' => date('Y-m-d'),
'concepto' => 'Transferencia'
]);

return [
'success' => true,
'message' => 'Transferencia realizada correctamente',
'amount' => $monto,
'date' => date('Y-m-d H:i:s')
];
});
}

Si algo falla (como un error de base de datos o si se lanza una excepción), todos los cambios se revertirán automáticamente.

5.4.3. Ejemplos prácticos de transacciones

Ejemplo 1: Proceso de compra completo

public function procesarCompra(array $request)
{
return $this->ventaRepository->transaction(function() use ($request) {
// 1. Crear cabecera de venta
$ventaData = [
'cliente_id' => $request['clienteId'],
'fecha' => date('Y-m-d'),
'total' => 0
];

$venta = $this->ventaRepository->store($ventaData);
$ventaId = $venta['id'];

$totalVenta = 0;

// 2. Procesar cada línea
foreach ($request['productos'] as $producto) {
// Obtener datos de producto
$productoData = $this->productoRepository->selectById($producto['id']);

// Verificar stock
if ($productoData['stock'] < $producto['cantidad']) {
throw new BadRequestException("Stock insuficiente para {$productoData['nombre']}");
}

// Calcular importe de línea
$importeLinea = $producto['cantidad'] * $productoData['precio'];
$totalVenta += $importeLinea;

// Crear línea de venta
$lineaData = [
'venta_id' => $ventaId,
'producto_id' => $producto['id'],
'cantidad' => $producto['cantidad'],
'precio' => $productoData['precio'],
'importe' => $importeLinea
];

$this->ventaLineaRepository->store($lineaData);

// Actualizar stock
$this->productoRepository->update($producto['id'], [
'stock' => $productoData['stock'] - $producto['cantidad']
]);
}

// 3. Actualizar total en cabecera
$this->ventaRepository->update($ventaId, [
'total' => $totalVenta
]);

// 4. Generar asiento contable
$this->contabilidadRepository->store([
'documento_id' => $ventaId,
'tipo' => 'VENTA',
'importe' => $totalVenta,
'fecha' => date('Y-m-d')
]);

return [
'ventaId' => $ventaId,
'total' => $totalVenta,
'fecha' => date('Y-m-d'),
'cliente' => $this->clienteRepository->selectById($request['clienteId']),
'productos' => count($request['productos'])
];
});
}

Ejemplo 2: Proceso de importación de productos

public function importarProductos(array $productos)
{
return $this->productoRepository->transaction(function() use ($productos) {
$resultados = [
'importados' => 0,
'actualizados' => 0,
'errores' => []
];

foreach ($productos as $index => $producto) {
try {
// Comprobar si el producto ya existe
$existente = $this->productoRepository->selectOne([
'codigo' => $producto['codigo']
]);

if ($existente) {
// Actualizar producto existente
$this->productoRepository->update($existente['id'], [
'nombre' => $producto['nombre'],
'precio' => $producto['precio'],
'stock' => $producto['stock'],
'categoria_id' => $this->obtenerCategoriaId($producto['categoria'])
]);

$resultados['actualizados']++;
} else {
// Crear nuevo producto
$this->productoRepository->store([
'codigo' => $producto['codigo'],
'nombre' => $producto['nombre'],
'precio' => $producto['precio'],
'stock' => $producto['stock'],
'categoria_id' => $this->obtenerCategoriaId($producto['categoria'])
]);

$resultados['importados']++;
}
} catch (\Exception $e) {
// Registrar error pero continuar con el siguiente
$resultados['errores'][] = [
'linea' => $index + 1,
'producto' => $producto['codigo'],
'error' => $e->getMessage()
];
}
}

return $resultados;
});
}

private function obtenerCategoriaId($nombreCategoria)
{
// Buscar o crear categoría
$categoria = $this->categoriaRepository->selectOne([
'nombre' => $nombreCategoria
]);

if (!$categoria) {
// Crear nueva categoría
$categoria = $this->categoriaRepository->store([
'nombre' => $nombreCategoria
]);
}

return $categoria['id'];
}

Buenas prácticas en servicios

  1. Separación de responsabilidades: Los servicios deben contener solo lógica de negocio, no detalles de presentación o acceso a datos directo.

  2. Uso de transacciones: Envuelve operaciones múltiples relacionadas en transacciones para mantener la integridad de los datos.

  3. Validaciones de negocio: Realiza validaciones específicas del dominio en el servicio, no solo validaciones de formato.

  4. Métodos privados para lógica auxiliar: Extrae lógica compleja a métodos privados para mantener el código limpio y testeable.

  5. Inyección de dependencias: Recibe todos los repositorios y DTOs necesarios a través del constructor.

  6. Evita lógica duplicada: Centraliza operaciones comunes en métodos reutilizables.

  7. Usa nombres descriptivos: Los nombres de métodos deben reflejar claramente la acción que realizan.

  8. Manejo proactivo de errores: Anticipa posibles problemas y lanza excepciones específicas con mensajes claros.

  9. Documenta casos especiales: Añade comentarios para explicar reglas de negocio no obvias o casos excepcionales.

  10. Evita acoplamientos innecesarios: Un servicio solo debe depender de los repositorios y servicios que realmente necesita.

Los servicios de dominio son el núcleo de la aplicación, donde debe residir la lógica de negocio. Bien diseñados, permiten una aplicación flexible, mantenible y enfocada en resolver los problemas del dominio de forma clara y estructurada.