DEV Community

Cover image for ¿Cómo diseño APIs REST limpias y fáciles de mantener (Python/Laravel)?
Enrique Lazo Bello
Enrique Lazo Bello

Posted on

¿Cómo diseño APIs REST limpias y fáciles de mantener (Python/Laravel)?

Durante los últimos años trabajando como arquitecto backend, he visto equipos completos bloqueados por APIs mal diseñadas. El problema nunca es la complejidad técnica del negocio, sino decisiones estructurales que tomamos al inicio y que terminan convirtiéndose en deuda técnica imposible de pagar. Una API bien diseñada no es aquella que funciona hoy, es aquella que otro desarrollador puede extender mañana sin necesidad de llamarte a las 3 AM.

En proyectos con multiples API o microservicios, he aplicado un conjunto de principios que transforman APIs caóticas en sistemas predecibles. No se trata de arquitecturas complejas ni patrones rebuscados. Se trata de decisiones pequeñas y consistentes que facilitan el mantenimiento, reducen bugs en producción y permiten que el equipo escale sin inconvenientes.

Este artículo desglosa mi enfoque práctico para estructurar APIs REST profesionales, con ejemplos reales de decisiones que marcan la diferencia entre un sistema mantenible y uno que nadie quiere tocar.

Estructurando nuestro proyecto

La primera regla de una API mantenible es que cualquier desarrollador pueda predecir dónde está cada cosa sin necesidad de buscar. He trabajado en sistemas donde cada endpoint tenía su propia lógica de organización: algunos mezclaban validación con lógica de negocio, otros tenían consultas SQL directamente en los controladores. El resultado era predecible: cada cambio requería horas de análisis y el riesgo de romper algo no relacionado era altísimo.

La arquitectura que aplico divide el flujo en tres capas con responsabilidades claras:

Controlador (Capa de Transporte): Recibe el HTTP Request, orquesta la llamada al servicio y devuelve el HTTP Response. No contiene reglas de negocio. Su única responsabilidad es traducir entre el protocolo HTTP y tu dominio de aplicación.

Servicio (Lógica de Negocio): Aquí reside la inteligencia del sistema. Es agnóstico al framework HTTP, no sabe si la petición vino de una API REST, una tarea programada o un comando de consola. Esta independencia permite reutilizar la lógica en múltiples contextos sin duplicación.

Repositorio (Capa de Persistencia): Abstrae las consultas a la base de datos o servicios externos. Si mañana cambias de MySQL a PostgreSQL o consumes una API externa, el servicio no debería enterarse.

Mi estructura estándar en FastAPI:

app/
├── api/
│   └── v1/
│       ├── endpoints/
│       │   ├── users.py
│       │   └── orders.py
│       └── __init__.py
├── schemas/
│   ├── user.py
│   └── order.py
├── services/
│   ├── user_service.py
│   └── order_service.py
└── repositories/
    ├── user_repository.py
    └── order_repository.py
Enter fullscreen mode Exit fullscreen mode

En Laravel, mantengo el mismo principio con la estructura nativa del framework:

app/
├── Http/
│   ├── Controllers/
│   │   └── Api/
│   │       └── V1/
│   │           ├── UserController.php
│   │           └── OrderController.php
│   └── Requests/
│       └── V1/
│           ├── CreateUserRequest.php
│           └── CreateOrderRequest.php
├── Services/
│   ├── UserService.php
│   └── OrderService.php
└── Repositories/
    ├── UserRepository.php
    └── OrderRepository.php
Enter fullscreen mode Exit fullscreen mode

Esta separación tiene un propósito claro: cada capa tiene una única razón para cambiar. Cuando necesitas modificar cómo se valida un campo, sabes exactamente que vas a schemas o Requests. Cuando cambias la lógica de cálculo de descuentos, vas directo al servicio correspondiente. Cuando optimizas una consulta SQL, modificas solo el repositorio sin tocar la lógica de negocio.

En un proyecto de e-commerce que heredé, refactoricé una API donde todo estaba en los controladores. El resultado: reducimos el tiempo promedio de implementación de nuevas features de 3 días a 1 día, y los bugs en producción cayeron un 60% en el primer trimestre. La razón fue simple: al separar responsabilidades, cada cambio afectaba solo a una capa, eliminando efectos secundarios inesperados.

Validar todo desde el lado del cliente

Uno de los errores más costosos que veo en APIs es validar datos dentro de la lógica de negocio. Cuando la validación está dispersa, terminas con código defensivo por todas partes, verificando el mismo campo en múltiples lugares y generando inconsistencias. La validación debe ocurrir en la puerta de entrada. Si el dato no es válido, recházalo inmediatamente con HTTP 422 antes de consumir recursos del servidor.

En FastAPI, Pydantic es tu mejor aliado:

from pydantic import BaseModel, EmailStr, validator, Field
from typing import Optional
from datetime import datetime

class CreateUserRequest(BaseModel):
    email: EmailStr
    full_name: str = Field(..., min_length=3, max_length=100)
    phone: Optional[str] = None
    birth_date: datetime

    @validator('full_name')
    def validate_full_name(cls, v):
        if len(v.strip()) < 3:
            raise ValueError('El nombre debe tener al menos 3 caracteres')
        return v.strip()

    @validator('phone')
    def validate_phone(cls, v):
        if v and not v.isdigit():
            raise ValueError('El teléfono solo puede contener dígitos')
        return v

    @validator('birth_date')
    def validate_age(cls, v):
        age = (datetime.now() - v).days / 365
        if age < 18:
            raise ValueError('El usuario debe ser mayor de edad')
        return v

@router.post("/users", status_code=201)
async def create_user(
    user_data: CreateUserRequest,
    service: UserService = Depends(get_user_service)
):
    user = await service.create_user(user_data)
    return {"user_id": user.id, "email": user.email}
Enter fullscreen mode Exit fullscreen mode

El servicio recibe datos ya validados y transformados. No necesita verificar si el email es válido o si el nombre cumple las reglas de negocio. Esto permite que las pruebas unitarias del servicio se concentren en la lógica real, no en casos extremos de validación.

En Laravel, los FormRequests cumplen el mismo rol y extraen la validación a una clase dedicada que limpia el controlador:

class CreateUserRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'email' => 'required|email|unique:users,email|max:255',
            'full_name' => 'required|min:3|max:100',
            'phone' => 'nullable|digits:9',
            'birth_date' => 'required|date|before:18 years ago',
        ];
    }

    public function messages(): array
    {
        return [
            'birth_date.before' => 'El usuario debe ser mayor de edad',
            'phone.digits' => 'El teléfono debe tener 9 dígitos',
            'email.unique' => 'Este email ya está registrado',
        ];
    }

    protected function prepareForValidation()
    {
        $this->merge([
            'full_name' => trim($this->full_name),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Esta centralización tiene un beneficio enorme cuando tu API crece: si cambias las reglas de validación de usuarios, modificas un único archivo. No tienes que rastrear validaciones dispersas por 15 controladores diferentes. Además, la documentación automática en FastAPI (Swagger) y las respuestas de validación en Laravel son consistentes y predecibles para los consumidores de tu API.

Versionado simple

El versionado de APIs genera debates interminables sobre la estrategia perfecta. Después de implementar sistemas con versionado en headers, query params y URLs, mi recomendación es usar versionado en la URL con prefijo /v1, /v2. Es explícito, fácil de documentar, y no requiere que los clientes de tu API configuren headers especiales. Cuando un cliente consume api.tuapp.com/v1/users, sabe exactamente qué versión está usando.

Mi regla práctica para crear una nueva versión:

Cambios backward-compatible (agregar campos opcionales, nuevos endpoints): mantener misma versión.

Cambios breaking (eliminar campos, cambiar tipos de datos, modificar comportamiento esperado): nueva versión.

El error común es versionar desde el día 1 sin justificación. Prefiero versionar cuando toca, no antes. Si tu API aún no está en producción o solo la consumes internamente, no necesitas versiones múltiples. La complejidad que agrega el versionado prematuro no vale la pena.

En FastAPI:

from fastapi import APIRouter

router_v1 = APIRouter(prefix="/api/v1", tags=["v1"])
router_v2 = APIRouter(prefix="/api/v2", tags=["v2"])

# v1: retorna user_id como string y estructura simple
@router_v1.get("/users/{user_id}")
async def get_user_v1(user_id: str):
    return {
        "user_id": user_id,
        "name": "John Doe",
        "email": "john@example.com"
    }

# v2: retorna user_id como int, agrega metadata y estructura más rica
@router_v2.get("/users/{user_id}")
async def get_user_v2(user_id: int):
    return {
        "user_id": user_id,
        "name": "John Doe",
        "email": "john@example.com",
        "metadata": {
            "created_at": "2024-01-01T00:00:00Z",
            "last_login": "2024-12-01T10:30:00Z"
        },
        "preferences": {
            "language": "es",
            "timezone": "America/Lima"
        }
    }

# Registro en la app
app.include_router(router_v1)
app.include_router(router_v2)
Enter fullscreen mode Exit fullscreen mode

En Laravel:

// routes/api.php
Route::prefix('v1')->group(function () {
    Route::get('/users/{id}', [UserController::class, 'show']);
    Route::post('/users', [UserController::class, 'store']);
});

Route::prefix('v2')->group(function () {
    Route::get('/users/{id}', [UserControllerV2::class, 'show']);
    Route::post('/users', [UserControllerV2::class, 'store']);
});
Enter fullscreen mode Exit fullscreen mode

Una estrategia que aplico en producción: cuando lanzamos v2, v1 permanece activa con un header X-API-Deprecation-Date que informa la fecha de cierre del API. Esto permite a los clientes migrar sin prisa, y a nosotros deprecar versiones antiguas de manera controlada. Típicamente mantengo una versión antigua activa entre 6 y 12 meses después de lanzar la nueva, dependiendo de la criticidad de la API y el número de integraciones activas.

Manejo explícito de errores: hablar el idioma correcto

Un error HTTP 500 con el mensaje "Error" no ayuda a nadie. He depurado sistemas donde cada problema se devolvía como 200 OK con un campo status: "error" en el JSON. Esto rompe el contrato HTTP y obliga a los clientes a implementar lógica adicional para interpretar respuestas. El protocolo HTTP ya tiene códigos de estado bien definidos, úsalos correctamente.

Mi estructura estándar de errores en FastAPI:

from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse
from datetime import datetime

class APIException(Exception):
    def __init__(self, status_code: int, message: str, code: str = None, details: dict = None):
        self.status_code = status_code
        self.message = message
        self.code = code or "INTERNAL_ERROR"
        self.details = details or {}

@app.exception_handler(APIException)
async def api_exception_handler(request: Request, exc: APIException):
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "error": {
                "code": exc.code,
                "message": exc.message,
                "details": exc.details,
                "path": str(request.url),
                "timestamp": datetime.now().isoformat(),
                "request_id": request.state.request_id
            }
        }
    )

# Uso en el servicio
class UserService:
    async def get_user(self, user_id: int):
        user = await self.repository.find(user_id)
        if not user:
            raise APIException(
                status_code=404,
                code="USER_NOT_FOUND",
                message="Usuario no encontrado",
                details={"user_id": user_id}
            )
        return user

    async def create_user(self, user_data: CreateUserRequest):
        try:
            return await self.repository.create(user_data)
        except IntegrityError:
            raise APIException(
                status_code=409,
                code="EMAIL_ALREADY_EXISTS",
                message="El email ya está registrado",
                details={"email": user_data.email}
            )
Enter fullscreen mode Exit fullscreen mode

En Laravel, los Custom Exceptions permiten un patrón similar:

class UserNotFoundException extends Exception
{
    public function render($request)
    {
        return response()->json([
            'error' => [
                'code' => 'USER_NOT_FOUND',
                'message' => 'Usuario no encontrado',
                'details' => ['user_id' => $request->route('id')],
                'path' => $request->path(),
                'timestamp' => now()->toIso8601String(),
                'request_id' => request()->header('X-Request-ID')
            ]
        ], 404);
    }
}

class EmailAlreadyExistsException extends Exception
{
    protected $email;

    public function __construct($email)
    {
        parent::__construct();
        $this->email = $email;
    }

    public function render($request)
    {
        return response()->json([
            'error' => [
                'code' => 'EMAIL_ALREADY_EXISTS',
                'message' => 'El email ya está registrado',
                'details' => ['email' => $this->email],
                'path' => $request->path(),
                'timestamp' => now()->toIso8601String(),
                'request_id' => request()->header('X-Request-ID')
            ]
        ], 409);
    }
}
Enter fullscreen mode Exit fullscreen mode

Esta consistencia permite que los equipos frontend construyan manejadores de errores genéricos. Si todas las respuestas de error siguen la misma estructura, el cliente puede implementar un interceptor único que procese cualquier error de la API. Además, el campo code permite al frontend reaccionar programáticamente a errores específicos sin tener que parsear mensajes de texto que pueden cambiar.

Logging y trazabilidad: debugging sin adivinar

Cuando un endpoint falla en producción a las 2 AM, necesitas responder una pregunta: qué pasó exactamente. Sin logging estructurado, estás adivinando. No hablo de logs masivos, hablo de logs útiles que permitan reconstruir el flujo completo de una request en minutos.

Mi configuración base en FastAPI con middleware de logging:

import structlog
from uuid import uuid4
from fastapi import Request
import time

logger = structlog.get_logger()

@app.middleware("http")
async def logging_middleware(request: Request, call_next):
    request_id = str(uuid4())
    request.state.request_id = request_id

    structlog.contextvars.clear_contextvars()
    structlog.contextvars.bind_contextvars(
        request_id=request_id,
        path=request.url.path,
        method=request.method,
        client_ip=request.client.host
    )

    start_time = time.time()

    logger.info("request_started")

    try:
        response = await call_next(request)
        duration = time.time() - start_time

        logger.info(
            "request_completed",
            status_code=response.status_code,
            duration_ms=round(duration * 1000, 2)
        )

        response.headers["X-Request-ID"] = request_id
        return response
    except Exception as e:
        duration = time.time() - start_time
        logger.error(
            "request_failed",
            error=str(e),
            duration_ms=round(duration * 1000, 2),
            exc_info=True
        )
        raise

# En tu servicio
class UserService:
    async def create_user(self, user_data: CreateUserRequest):
        logger.info("creating_user", email=user_data.email)

        try:
            user = await self.repository.create(user_data)
            logger.info("user_created", user_id=user.id)
            return user
        except IntegrityError as e:
            logger.error("user_creation_failed", error=str(e), email=user_data.email)
            raise APIException(409, "EMAIL_ALREADY_EXISTS", "El email ya existe")
Enter fullscreen mode Exit fullscreen mode

En Laravel:

// app/Http/Middleware/LogRequests.php
class LogRequests
{
    public function handle(Request $request, Closure $next)
    {
        $requestId = Str::uuid()->toString();
        $request->headers->set('X-Request-ID', $requestId);

        $startTime = microtime(true);

        Log::channel('api')->info('request_started', [
            'request_id' => $requestId,
            'method' => $request->method(),
            'url' => $request->fullUrl(),
            'ip' => $request->ip(),
            'user_id' => optional($request->user())->id,
        ]);

        try {
            $response = $next($request);

            $duration = microtime(true) - $startTime;

            Log::channel('api')->info('request_completed', [
                'request_id' => $requestId,
                'duration_ms' => round($duration * 1000, 2),
                'status_code' => $response->getStatusCode(),
            ]);

            return $response->header('X-Request-ID', $requestId);
        } catch (Exception $e) {
            $duration = microtime(true) - $startTime;

            Log::channel('api')->error('request_failed', [
                'request_id' => $requestId,
                'duration_ms' => round($duration * 1000, 2),
                'error' => $e->getMessage(),
                'trace' => $e->getTraceAsString(),
            ]);

            throw $e;
        }
    }
}

// app/Services/UserService.php
class UserService
{
    public function createUser(CreateUserRequest $request)
    {
        $requestId = request()->header('X-Request-ID');

        Log::withContext(['request_id' => $requestId]);
        Log::info('creating_user', ['email' => $request->email]);

        try {
            $user = $this->repository->create($request->validated());
            Log::info('user_created', ['user_id' => $user->id]);
            return $user;
        } catch (QueryException $e) {
            Log::error('user_creation_failed', [
                'error' => $e->getMessage(),
                'email' => $request->email
            ]);
            throw new EmailAlreadyExistsException($request->email);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Este request_id es oro puro en debugging. Cuando un cliente reporta un error, me da el X-Request-ID, y yo puedo rastrear cada log relacionado a esa petición específica. En un sistema con 10,000 requests por minuto, esto reduce el tiempo de diagnóstico de horas a minutos. Además, incluir duración en milisegundos permite detectar endpoints lentos y optimizarlos proactivamente.

Evitar sobrecargar los endpoints: una responsabilidad por endpoint

Uno de los antipatrones más comunes es el endpoint Frankenstein: hace validación, lógica de negocio, envío de emails, actualización de caché y notificaciones push. Todo en un solo lugar. Si un endpoint hace demasiado, mezcla casos de uso o devuelve sets de datos gigantes, lo divido sin excepción.

Ejemplo de lo que no debes hacer:

@router.post("/orders")
async def create_order_bad(order_data: dict):
    # Validación manual mezclada
    if not order_data.get('items'):
        raise HTTPException(400, "Items requeridos")

    # Lógica de negocio directa
    total = sum(item['price'] * item['qty'] for item in order_data['items'])
    if total > 10000:
        order_data['requires_approval'] = True

    # Guardado directo sin abstracción
    order = db.orders.insert(order_data)

    # Efectos secundarios sincrónicos
    send_email(order_data['email'], "Orden creada")
    cache.set(f"order:{order.id}", order)
    push_service.notify(order_data['user_id'], "Tu orden está en proceso")

    return order
Enter fullscreen mode Exit fullscreen mode

Esto es una bomba de tiempo. Cada responsabilidad agregada aumenta la complejidad de testing y la probabilidad de efectos secundarios. Si el envío de email falla, toda la creación de la orden falla. Si necesitas agregar una nueva acción cuando se crea una orden, tienes que modificar este endpoint y arriesgarte a romper algo.

La versión correcta:

@router.post("/orders", status_code=201)
async def create_order(
    order_data: CreateOrderRequest,
    service: OrderService = Depends(get_order_service)
):
    """
    Crea una nueva orden de compra.

    La orden se crea de forma síncrona.
    Las notificaciones y procesos secundarios se ejecutan de forma asíncrona.
    """
    order = await service.create_order(order_data)
    return {"order_id": order.id, "status": order.status}

# En el servicio
class OrderService:
    async def create_order(self, order_data: CreateOrderRequest):
        # La validación ya ocurrió en CreateOrderRequest
        logger.info("creating_order", user_id=order_data.user_id)

        order = await self.repository.create(order_data)

        # Las operaciones asíncronas se delegan a workers
        await self.event_bus.publish(
            OrderCreatedEvent(order_id=order.id, user_id=order_data.user_id)
        )

        logger.info("order_created", order_id=order.id)
        return order
Enter fullscreen mode Exit fullscreen mode

El endpoint solo coordina. La lógica de envío de emails, actualización de caché y notificaciones vive en event handlers separados que procesan el evento OrderCreatedEvent. Si el envío de email falla, no afecta la creación de la orden. Si necesitas agregar una nueva acción cuando se crea una orden, creas un nuevo handler sin tocar el endpoint.

Ejemplo práctico: estructura completa de un endpoint real

Veamos cómo todos estos principios se integran en un endpoint de producción. Este ejemplo muestra la creación de una orden de compra con validación completa, cálculo de totales y manejo de stock.

Estructura completa en FastAPI:

# schemas/order.py
from pydantic import BaseModel, validator, Field
from typing import List
from decimal import Decimal

class OrderItem(BaseModel):
    product_id: int = Field(..., gt=0)
    quantity: int = Field(..., gt=0)

    @validator('quantity')
    def validate_quantity(cls, v):
        if v < 1:
            raise ValueError('La cantidad debe ser mayor a 0')
        if v > 100:
            raise ValueError('La cantidad máxima por producto es 100')
        return v

class CreateOrderRequest(BaseModel):
    user_id: int = Field(..., gt=0)
    items: List[OrderItem] = Field(..., min_items=1)
    shipping_address: str = Field(..., min_length=10, max_length=500)

    @validator('items')
    def validate_items(cls, v):
        if not v:
            raise ValueError('La orden debe tener al menos un producto')
        if len(v) > 50:
            raise ValueError('Máximo 50 productos por orden')
        return v

# repositories/order_repository.py
class OrderRepository:
    def __init__(self, db):
        self.db = db

    async def create(self, order_data: dict):
        query = """
            INSERT INTO orders (user_id, total, status, shipping_address, created_at)
            VALUES (:user_id, :total, :status, :shipping_address, NOW())
            RETURNING id, user_id, total, status, shipping_address, created_at
        """
        result = await self.db.execute(query, order_data)
        return result

    async def create_items(self, order_id: int, items: List[dict]):
        query = """
            INSERT INTO order_items (order_id, product_id, quantity, price)
            VALUES (:order_id, :product_id, :quantity, :price)
        """
        await self.db.execute_many(query, items)

# services/order_service.py
class OrderService:
    def __init__(
        self,
        repository: OrderRepository,
        product_service: ProductService,
        event_bus: EventBus
    ):
        self.repository = repository
        self.product_service = product_service
        self.event_bus = event_bus

    async def create_order(self, order_data: CreateOrderRequest):
        logger.info("creating_order", user_id=order_data.user_id, items_count=len(order_data.items))

        # Validar disponibilidad y calcular total
        total = Decimal(0)
        validated_items = []

        for item in order_data.items:
            product = await self.product_service.get_product(item.product_id)

            if product.stock < item.quantity:
                raise APIException(
                    400,
                    "INSUFFICIENT_STOCK",
                    "Stock insuficiente",
                    {
                        "product_id": item.product_id,
                        "requested": item.quantity,
                        "available": product.stock
                    }
                )

            item_total = product.price * item.quantity
            total += item_total

            validated_items.append({
                "product_id": item.product_id,
                "quantity": item.quantity,
                "price": product.price
            })

        # Crear orden
        order = await self.repository.create({
            "user_id": order_data.user_id,
            "total": total,
            "status": "pending",
            "shipping_address": order_data.shipping_address
        })

        # Crear items de la orden
        items_with_order_id = [
            {**item, "order_id": order.id}
            for item in validated_items
        ]
        await self.repository.create_items(order.id, items_with_order_id)

        # Publicar evento para procesos asíncronos
        await self.event_bus.publish(
            OrderCreatedEvent(
                order_id=order.id,
                user_id=order_data.user_id,
                total=float(total)
            )
        )

        logger.info("order_created", order_id=order.id, total=float(total))
        return order

# endpoints/orders.py
@router.post("/orders", status_code=201, response_model=OrderResponse)
async def create_order(
    order_data: CreateOrderRequest,
    service: OrderService = Depends(get_order_service),
    current_user: User = Depends(get_current_user)
):
    """
    Crea una nueva orden de compra.

    Valida stock disponible, calcula el total y genera la orden.
    Las notificaciones y emails se procesan de forma asíncrona.

    Requiere autenticación.
    """
    if order_data.user_id != current_user.id:
        raise APIException(
            403,
            "FORBIDDEN",
            "No tienes permiso para crear órdenes de otros usuarios"
        )

    order = await service.create_order(order_data)

    return {
        "order_id": order.id,
        "total": float(order.total),
        "status": order.status,
        "created_at": order.created_at.isoformat()
    }
Enter fullscreen mode Exit fullscreen mode

Implementación equivalente en Laravel:

// app/Http/Requests/CreateOrderRequest.php
class CreateOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->id === $this->input('user_id');
    }

    public function rules(): array
    {
        return [
            'user_id' => 'required|integer|exists:users,id',
            'items' => 'required|array|min:1|max:50',
            'items.*.product_id' => 'required|integer|exists:products,id',
            'items.*.quantity' => 'required|integer|min:1|max:100',
            'shipping_address' => 'required|string|min:10|max:500',
        ];
    }
}

// app/Repositories/OrderRepository.php
class OrderRepository
{
    public function create(array $data): Order
    {
        return DB::transaction(function () use ($data) {
            $order = Order::create([
                'user_id' => $data['user_id'],
                'total' => $data['total'],
                'status' => $data['status'],
                'shipping_address' => $data['shipping_address'],
            ]);

            foreach ($data['items'] as $item) {
                $order->items()->create($item);
            }

            return $order->fresh('items');
        });
    }
}

// app/Services/OrderService.php
class OrderService
{
    public function __construct(
        private OrderRepository $repository,
        private ProductService $productService
    ) {}

    public function createOrder(array $data): Order
    {
        $requestId = request()->header('X-Request-ID');
        Log::withContext(['request_id' => $requestId]);

        Log::info('creating_order', [
            'user_id' => $data['user_id'],
            'items_count' => count($data['items'])
        ]);

        $total = 0;
        $validatedItems = [];

        foreach ($data['items'] as $item) {
            $product = $this->productService->getProduct($item['product_id']);

            if ($product->stock < $item['quantity']) {
                throw new InsufficientStockException(
                    $item['product_id'],
                    $item['quantity'],
                    $product->stock
                );
            }

            $itemTotal = $product->price * $item['quantity'];
            $total += $itemTotal;

            $validatedItems[] = [
                'product_id' => $item['product_id'],
                'quantity' => $item['quantity'],
                'price' => $product->price,
            ];
        }

        $order = $this->repository->create([
            'user_id' => $data['user_id'],
            'total' => $total,
            'status' => 'pending',
            'shipping_address' => $data['shipping_address'],
            'items' => $validatedItems,
        ]);

        event(new OrderCreated($order));

        Log::info('order_created', [
            'order_id' => $order->id,
            'total' => $total
        ]);

        return $order;
    }
}

// app/Http/Controllers/Api/V1/OrderController.php
class OrderController extends Controller
{
    public function __construct(private OrderService $service) {}

    public function store(CreateOrderRequest $request): JsonResponse
    {
        $order = $this->service->createOrder($request->validated());

        return response()->json([
            'order_id' => $order->id,
            'total' => $order->total,
            'status' => $order->status,
            'created_at' => $order->created_at->toIso8601String(),
        ], 201);
    }
}
Enter fullscreen mode Exit fullscreen mode

Este endpoint cumple con todos los principios discutidos: estructura predecible con cada componente en su lugar, validación temprana antes de llegar al servicio, manejo explícito de errores con códigos y mensajes claros, logging estructurado con request_id para trazabilidad completa, y responsabilidad única donde el endpoint solo coordina mientras el servicio contiene toda la lógica.

Conclusión

Diseñar APIs mantenibles no requiere arquitecturas complejas ni frameworks exóticos. Requiere disciplina y consistencia en decisiones pequeñas que tomamos cada día. La estructura predecible reduce la carga cognitiva del equipo. La validación temprana elimina bugs antes de que lleguen a producción. El versionado explícito protege a tus clientes de cambios inesperados. El manejo de errores claro acelera el debugging. El logging estructurado convierte el diagnóstico en ciencia, no en arte adivinatorio.

Cada una de estas prácticas tiene un costo inicial: requiere planificación, coordinación con el equipo y establecer estándares claros. Pero ese costo se paga una sola vez. El beneficio se multiplica con cada nueva feature, cada bug que se evita y cada desarrollador que se incorpora al equipo sin necesidad de semanas de onboarding. La mantenibilidad de una API no depende del framework, sino de la disciplina arquitectónica.

¿Cuál ha sido tu mayor desafío al mantener APIs en producción?
¿Qué prácticas te han funcionado mejor en tu equipo?
Déjamelo en los comentarios.

Top comments (0)