Saltar a contenido

Repositorios e Interfaces — Guía de Buenas Prácticas

Esta guía describe el sistema de repositorios base implementado en el proyecto, diseñado para proveer funcionalidad completa de Index (búsqueda, filtros, ordenamiento, paginación), operaciones masivas y utilidades de concurrencia.

Principios Arquitectónicos

Contrato Primero

Los repositorios implementan interfaces específicas que definen el contrato de operaciones disponibles. Esto facilita testing, inyección de dependencias y intercambio de implementaciones.

interface UserRepositoryInterface extends RepositoryInterface
{
    public function findByEmail(string $email): ?User;
    public function findActiveUsers(): Collection;
}

Repositorios Sin Reglas de Dominio

Los repositorios se enfocan únicamente en persistencia y recuperación de datos. Las reglas de negocio deben implementarse en servicios de dominio separados.

✅ Correcto

// En el repositorio
public function findActiveUsers(): Collection
{
    return $this->builder()->where('active', true)->get();
}

// En el servicio de dominio
public function promoteUserToAdmin(User $user): User
{
    if (!$user->canBePromoted()) {
        throw new InvalidOperationException('User cannot be promoted');
    }

    return $this->userRepository->update($user, ['role' => 'admin']);
}

❌ Incorrecto

// Lógica de negocio en el repositorio
public function promoteToAdmin(User $user): User
{
    if (!$user->canBePromoted()) {
        throw new InvalidOperationException('User cannot be promoted');
    }

    return $this->update($user, ['role' => 'admin']);
}

Implementación de Repositorios Concretos

Estructura Básica

<?php

namespace App\Repositories;

use App\Contracts\Repositories\UserRepositoryInterface;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;

class UserRepository extends BaseRepository implements UserRepositoryInterface
{
    protected string $modelClass = User::class;

    protected function searchable(): array
    {
        return ['name', 'email', 'username'];
    }

    protected function allowedSorts(): array
    {
        return ['id', 'name', 'email', 'created_at', 'updated_at', 'last_login_at'];
    }

    protected function defaultSort(): array
    {
        return ['created_at', 'desc'];
    }

    protected function filterMap(): array
    {
        return [
            'role' => fn(Builder $q, $value) => $q->where('role', $value),
            'verified' => fn(Builder $q, $value) => $q->whereNotNull('email_verified_at'),
            'recent_login' => fn(Builder $q, $value) => $q->where('last_login_at', '>=', now()->subDays($value)),
        ];
    }

    public function findByEmail(string $email): ?User
    {
        return $this->builder()->where('email', $email)->first();
    }

    public function findActiveUsers(): Collection
    {
        return $this->builder()->where('active', true)->get();
    }
}

Hooks Disponibles

searchable(): array

Define las columnas que pueden ser buscadas con el parámetro q del ListQuery.

protected function searchable(): array
{
    return ['name', 'email', 'username', 'bio'];
}

allowedSorts(): array

Whitelist de columnas permitidas para ordenamiento, previene inyección SQL.

protected function allowedSorts(): array
{
    return ['id', 'name', 'email', 'created_at', 'updated_at', 'score'];
}

defaultSort(): array

Ordenamiento por defecto cuando no se especifica o es inválido.

protected function defaultSort(): array
{
    return ['updated_at', 'desc'];
}

filterMap(): array

Define filtros personalizados más allá de los estándar.

protected function filterMap(): array
{
    return [
        'role' => fn(Builder $q, $value) => $q->where('role', $value),
        'has_posts' => fn(Builder $q, $value) => $q->has('posts', $value ? '>=' : '=', $value ? 1 : 0),
        'joined_after' => fn(Builder $q, $value) => $q->where('created_at', '>=', $value),
    ];
}

withRelations(Builder $builder): Builder

Aplica eager loading por defecto.

protected function withRelations(Builder $builder): Builder
{
    return $builder->with(['profile', 'roles'])->withCount(['posts', 'comments']);
}

Filtros para Index

Filtros Estándar

El sistema reconoce automáticamente varios tipos de filtros basados en convenciones de naming:

Filtro Equals

// URL: ?filters[active]=true&filters[role]=admin
$query = new ListQuery(filters: ['active' => true, 'role' => 'admin']);

Filtro LIKE (case-insensitive)

// URL: ?filters[name_like]=john
$query = new ListQuery(filters: ['name_like' => 'john']);
// Genera: WHERE LOWER(name) LIKE '%john%'

Filtro BETWEEN (rangos)

// URL: ?filters[created_between][from]=2023-01-01&filters[created_between][to]=2023-12-31
$query = new ListQuery(filters: [
    'created_between' => ['from' => '2023-01-01', 'to' => '2023-12-31']
]);

Filtro IN (arrays)

// URL: ?filters[status_in][]=active&filters[status_in][]=pending
$query = new ListQuery(filters: ['status_in' => ['active', 'pending']]);

Filtro IS NULL/NOT NULL

// URL: ?filters[deleted_at_is]=null
$query = new ListQuery(filters: ['deleted_at_is' => 'null']);

// URL: ?filters[email_verified_at_is]=notnull
$query = new ListQuery(filters: ['email_verified_at_is' => 'notnull']);

Filtro de Conteo de Relaciones

// URL: ?filters[posts_count]=5
$query = new ListQuery(filters: ['posts_count' => 5]);
// Genera: ->has('posts', '>=', 5)

Filtros Personalizados

Usa filterMap() para lógica compleja:

protected function filterMap(): array
{
    return [
        'subscription_status' => function (Builder $q, $value) {
            switch ($value) {
                case 'active':
                    $q->whereHas('subscription', fn($sq) => $sq->where('status', 'active'));
                    break;
                case 'expired':
                    $q->whereHas('subscription', fn($sq) => $sq->where('expires_at', '<', now()));
                    break;
                case 'none':
                    $q->whereDoesntHave('subscription');
                    break;
            }
        },

        'activity_level' => function (Builder $q, $value) {
            $days = match($value) {
                'high' => 7,
                'medium' => 30,
                'low' => 90,
                default => 30
            };

            $q->where('last_activity_at', '>=', now()->subDays($days));
        },
    ];
}

Validación de Query

FormRequest para Validación

La validación de parámetros de consulta debe realizarse en FormRequests, no en el DTO:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UserIndexRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'q' => 'nullable|string|max:255',
            'page' => 'nullable|integer|min:1',
            'per_page' => 'nullable|integer|min:1|max:100',
            'sort' => 'nullable|string|in:id,name,email,created_at,updated_at',
            'dir' => 'nullable|string|in:asc,desc',
            'filters.active' => 'nullable|boolean',
            'filters.role' => 'nullable|string|in:user,admin,moderator',
            'filters.created_between.from' => 'nullable|date',
            'filters.created_between.to' => 'nullable|date|after_or_equal:filters.created_between.from',
        ];
    }
}

Uso en Controladores

<?php

namespace App\Http\Controllers;

use App\DTO\ListQuery;
use App\Http\Requests\UserIndexRequest;
use App\Contracts\Repositories\UserRepositoryInterface;

class UserController extends Controller
{
    public function index(UserIndexRequest $request, UserRepositoryInterface $repository)
    {
        $query = ListQuery::fromRequest($request);

        $users = $repository->paginate(
            query: $query,
            with: ['profile', 'roles'],
            withCount: ['posts', 'comments']
        );

        return response()->json($users);
    }
}

Índices Recomendados (PostgreSQL)

Índices Funcionales para Búsquedas Case-Insensitive

Para mejorar rendimiento en búsquedas de texto:

-- Índices funcionales LOWER() para columnas searchable
CREATE INDEX idx_users_name_lower ON users (LOWER(name));
CREATE INDEX idx_users_email_lower ON users (LOWER(email));

-- Índices compuestos para filtros comunes
CREATE INDEX idx_users_active_created_at ON users (active, created_at DESC);
CREATE INDEX idx_users_role_active ON users (role, active);

-- Índices para filtros de fecha con rangos
CREATE INDEX idx_users_created_at_btree ON users USING btree (created_at);
CREATE INDEX idx_users_updated_at_btree ON users USING btree (updated_at);

Índices para Soft Deletes

-- Índice parcial para registros activos (no eliminados)
CREATE INDEX idx_users_active_not_deleted ON users (id) WHERE deleted_at IS NULL;

-- Índice compuesto incluyendo soft deletes
CREATE INDEX idx_users_active_deleted_at ON users (active, deleted_at);

Scopes y Global Scopes

Uso con Repositorios

Los scopes de Eloquent son compatibles con el sistema de repositorios:

// En el modelo User
public function scopeActive(Builder $query): Builder
{
    return $query->where('active', true);
}

public function scopeVerified(Builder $query): Builder
{
    return $query->whereNotNull('email_verified_at');
}

// En el repositorio
protected function builder(): Builder
{
    return $this->modelClass::query()->active(); // Aplica scope por defecto
}

// O en métodos específicos
public function findVerifiedUsers(): Collection
{
    return $this->builder()->verified()->get();
}

Global Scopes

Para filtros automáticos en todas las consultas:

// Scope global
class ActiveScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where('active', true);
    }
}

// En el modelo
protected static function booted(): void
{
    static::addGlobalScope(new ActiveScope);
}

// En el repositorio, override para incluir inactivos cuando necesario
public function findAllIncludingInactive(): Collection
{
    return $this->modelClass::withoutGlobalScope(ActiveScope::class)->get();
}

Integración con Frontend

Inertia.js Partial Reloads

Para actualizaciones eficientes del Index:

// resources/js/pages/Users/Index.tsx
import { router } from '@inertiajs/react';

const updateFilters = (newFilters: Record<string, any>) => {
    router.get(
        route('users.index'),
        { ...filters, ...newFilters },
        {
            preserveState: true,
            preserveScroll: true,
            only: ['users'], // Partial reload
        },
    );
};

TanStack Query v8 Server-Side

Para integración con TanStack Query:

// resources/js/hooks/useUsers.ts
import { useQuery } from '@tanstack/react-query';

export const useUsers = (filters: UserFilters) => {
    return useQuery({
        queryKey: ['users', filters],
        queryFn: async () => {
            const response = await fetch(`/api/users?${new URLSearchParams(filters)}`);
            return response.json();
        },
        staleTime: 5 * 60 * 1000, // 5 minutos
    });
};

Registro de Repositorios

Service Provider

Registra los bindings en app/Providers/DomainServiceProvider.php:

private function registerRepositories(): void
{
    $this->app->bind(
        \App\Contracts\Repositories\UserRepositoryInterface::class,
        \App\Repositories\UserRepository::class
    );

    $this->app->bind(
        \App\Contracts\Repositories\PostRepositoryInterface::class,
        \App\Repositories\PostRepository::class
    );
}

Inyección en Servicios

<?php

namespace App\Services;

use App\Contracts\Repositories\UserRepositoryInterface;

class UserService
{
    public function __construct(
        private UserRepositoryInterface $userRepository
    ) {}

    public function promoteUser(int $userId): User
    {
        return $this->userRepository->withPessimisticLockById($userId, function (User $user) {
            // Lógica de negocio aquí
            return $this->userRepository->update($user, ['role' => 'admin']);
        });
    }
}

Alternativas

Spatie Query Builder

Para casos simples, considera Spatie Query Builder:

use Spatie\QueryBuilder\QueryBuilder;

public function index(Request $request)
{
    $users = QueryBuilder::for(User::class)
        ->allowedFilters(['name', 'email', Filter::exact('role')])
        ->allowedSorts(['name', 'email', 'created_at'])
        ->paginate($request->input('per_page', 15));

    return response()->json($users);
}

Pros: Menos código, funcionalidad inmediata Cons: Menos control, más difícil de testear, mixing de responsabilidades

El sistema de repositorios base ofrece mayor flexibilidad y control para aplicaciones complejas.