Mejores Prácticas de Desarrollo

Recomendaciones y estándares para el desarrollo backend con AdonisJS 6, Lucid ORM y TypeScript

Introducción

Esta guía establece las mejores prácticas y estándares de código para el desarrollo backend en RedCollege utilizando AdonisJS 6. El objetivo es mantener una base de código coherente, legible y mantenible que todos los desarrolladores puedan entender y modificar con facilidad.

Estructura de Directorios

Mantener una estructura de directorios coherente es crucial para la navegabilidad del proyecto:

app/
  ├── controllers/           # Agrupados por dominio funcional
  │   ├── auth/
  │   ├── establecimiento/
  │   └── planificacion/
  ├── models/                # Agrupados por dominio funcional
  │   ├── auth/
  │   ├── establecimiento/
  │   └── planificacion/
  ├── validators/            # Validadores según mismo esquema
  │   ├── auth/
  │   └── planificacion/
  ├── services/              # Lógica de negocio reutilizable
  ├── policies/              # Políticas de autorización
  └── middleware/            # Middleware personalizado

Modelos

Principios Generales

  • Importaciones: Siempre usar alias #models para importar modelos
  • Organización: Agrupar por funcionalidad y mantener el mismo esquema en todos los directorios
  • Mixins: Utilizar composición para características como SoftDeletes y Auditable

Estructura Recomendada

import { DateTime } from 'luxon'
import { BaseModel, column, belongsTo, hasMany } from '@adonisjs/lucid/orm'
import { compose } from '@adonisjs/core/helpers'
import { SoftDeletes } from '#models/mixins/soft_deletes'
import { Auditable } from '@stouder-io/adonis-auditing'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'

// Importaciones de modelos relacionados (¡usar siempre #models!)
import Usuario from '#models/auth/usuario'
import Establecimiento from '#models/establecimiento/establecimiento' 

export default class Recurso extends compose(BaseModel, SoftDeletes, Auditable) {
  // Configuración de tabla
  static table = 'recurso.recursos'

  // Columnas principales
  @column({ isPrimary: true })
  declare id: number

  @column()
  declare titulo: string

  @column()
  declare descripcion: string | null

  // Claves foráneas
  @column()
  declare usuarioId: number

  @column()
  declare establecimientoId: number

  // Columnas de auditoría
  @column.dateTime({ autoCreate: true })
  declare createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  declare updatedAt: DateTime

  @column.dateTime()
  declare deletedAt: DateTime | null

  @column()
  declare deletedBy: number | null

  // Relaciones
  @belongsTo(() => Usuario)
  declare usuario: BelongsTo<typeof Usuario>

  @belongsTo(() => Establecimiento)
  declare establecimiento: BelongsTo<typeof Establecimiento>
}

Recomendaciones Clave

  1. SoftDeletes: Implementar en todos los modelos para mantener historial
  2. Auditable: Usar para registrar quién creó/modificó/eliminó registros
  3. Convenciones de Nombres:
    • Modelos: PascalCase singular (ej: Usuario)
    • Tablas: snake_case, incluir namespace (ej: auth.usuarios)
  4. Tipos: Declarar explícitamente todos los tipos de columnas y relaciones
  5. Organización: Agrupar campos y relaciones lógicamente

Controladores

Estructura Recomendada

import type { HttpContext } from '@adonisjs/core/http'
import db from '@adonisjs/lucid/services/db'

// Importar modelos y validadores (¡usar siempre #models y #validators!)
import Recurso from '#models/recurso/recurso'
import { RecursoValidator } from '#validators/recurso/recurso_validator'
import RecursoPolicy from '#policies/recurso/recurso_policy'

export default class RecursoController {
  private PAGINATE_RECORDS = 15

  // Métodos CRUD y específicos
  async index({ request, response, auth }: HttpContext) {
    try {
      const user = auth.user
      const { page = 1, query = '', establecimientoId } = request.qs()
      
      // Aplicar filtros y paginación
      const recursos = await Recurso.query()
        .where('establecimiento_id', establecimientoId)
        .where('titulo', 'ILIKE', `%${query}%`)
        .preload('usuario')
        .orderBy('created_at', 'desc')
        .paginate(page, this.PAGINATE_RECORDS)
      
      return response.ok(recursos)
    } catch (error) {
      return response.internalServerError({
        message: 'Error al obtener recursos',
        error: error.message
      })
    }
  }

  async store({ request, response, auth, bouncer }: HttpContext) {
    try {
      const user = auth.user
      const data = await RecursoValidator.validate(request.all())
      
      // Verificar permisos
      const establecimiento = data.establecimientoId
      if (await bouncer.with(RecursoPolicy).denies('create', { establecimientoId: establecimiento })) {
        return response.forbidden({ message: 'No autorizado para crear recursos en este establecimiento' })
      }
      
      // Crear con transacción
      const recurso = await db.transaction(async (trx) => {
        const nuevoRecurso = await Recurso.create({
          ...data,
          usuarioId: user?.id
        }, { client: trx })
        
        // Procesar relaciones si existen
        if (data.etiquetas?.length) {
          await nuevoRecurso.related('etiquetas').sync(data.etiquetas, { client: trx })
        }
        
        return nuevoRecurso
      })
      
      await recurso.load('usuario')
      return response.created(recurso)
    } catch (error) {
      return response.badRequest({
        message: 'Error al crear recurso',
        error: error.message
      })
    }
  }

  // Otros métodos...
}

Recomendaciones Clave

  1. Manejo de Errores:
    • Usar bloques try/catch en cada método público
    • Devolver respuestas de error específicas y claras
  2. Autorización:
    • Utilizar políticas para centralizar reglas
    • Verificar permisos a nivel de establecimiento
  3. Transacciones:
    • Usar en operaciones que modifican múltiples tablas
    • Aplicar cuando se manejan relaciones
  4. Consultas Eficientes:
    • Cargar relaciones con preload para evitar problemas N+1
    • Aplicar filtros en la base de datos, no en memoria
  5. Respuestas Consistentes:
    • Usar métodos helpers: response.ok(), response.created(), etc.
    • Estructura de error uniforme: { message, error }

Rutas

Estructura Recomendada

import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

// Grupo de rutas con prefijo común
const recursoRoutes = router.group(() => {
  // Rutas básicas CRUD
  router.get('/', '#controllers/recurso/recurso_controller.index')
  router.get('/:id', '#controllers/recurso/recurso_controller.show')
  router.post('/', '#controllers/recurso/recurso_controller.store')
  router.put('/:id', '#controllers/recurso/recurso_controller.update')
  router.delete('/:id', '#controllers/recurso/recurso_controller.destroy')
  
  // Rutas especializadas
  router.get('/byEstablecimiento/:establecimientoId', '#controllers/recurso/recurso_controller.getByEstablecimiento')
  router.patch('/:id/restore', '#controllers/recurso/recurso_controller.restore')
  
  // Subrecursos
  router.group(() => {
    router.get('/', '#controllers/recurso/comentario_controller.index')
    router.post('/', '#controllers/recurso/comentario_controller.store')
  }).prefix('/:recursoId/comentarios')
})
.prefix('/api/v3/recursos')
.middleware(middleware.auth()) // Aplicado a todo el grupo

export default recursoRoutes

Recomendaciones Clave

  1. Organización:
    • Agrupar rutas por dominio funcional
    • Usar prefijos para versiones de API (/api/v3/...)
    • Estructurar recursos y subrecursos jerárquicamente
  2. Convenciones RESTful:
    • Seguir verbos HTTP estándar (GET, POST, PUT, DELETE)
    • Usar sustantivos plurales para recursos (/recursos)
  3. Middleware:
    • Aplicar middleware común a nivel de grupo
    • Middleware específico a nivel de ruta cuando sea necesario
  4. Referencias a Controladores:
    • Usar siempre ruta absoluta con # ('#controllers/...')
    • Mantener convención controlador.método

Validadores

Estructura Recomendada

import vine from '@vinejs/vine'
import { Infer } from '@vinejs/vine/types'

// Esquema de validación básico
export const RecursoValidator = vine.compile(
  vine.object({
    titulo: vine.string().trim().minLength(3).maxLength(255),
    descripcion: vine.string().trim().nullable(),
    tipo: vine.string().trim().in(['documento', 'imagen', 'video', 'otro']),
    establecimientoId: vine.number().min(1),
    etiquetas: vine.array(vine.number()).optional()
  })
)

// Esquema de actualización (permitir campos parciales)
export const RecursoUpdateValidator = vine.compile(
  vine.object({
    titulo: vine.string().trim().minLength(3).maxLength(255).optional(),
    descripcion: vine.string().trim().nullable().optional(),
    tipo: vine.string().trim().in(['documento', 'imagen', 'video', 'otro']).optional(),
    etiquetas: vine.array(vine.number()).optional()
  })
)

// Exportar tipos para uso en controladores
export type IRecurso = Infer<typeof RecursoValidator>
export type IRecursoUpdate = Infer<typeof RecursoUpdateValidator>

Recomendaciones Clave

  1. Validaciones Específicas:
    • Crear esquemas específicos para cada operación (crear/actualizar)
    • Definir reglas claras con mensajes de error personalizados
  2. Tipos:
    • Exportar tipos inferidos para usar en controladores
    • Usar prefijo 'I' para interfaces derivadas de validadores
  3. Organización:
    • Mantener validadores en misma estructura de directorios que controladores
    • Agrupar validadores relacionados en un mismo archivo
  4. Reglas de Validación:
    • Ser específico en tipos y restricciones
    • Validar referencias a otros modelos (ids existentes)
    • Validar formatos cuando corresponda (regex para emails, fechas, etc.)

Políticas de Autorización

Estructura Recomendada

import { BasePolicy } from '@adonisjs/bouncer'
import Usuario from '#models/auth/usuario'
import Recurso from '#models/recurso/recurso'

export default class RecursoPolicy extends BasePolicy {
  // Verificar acceso a establecimiento
  async verifyEstablecimientoAccess(user: Usuario, establecimientoId: number) {
    return !!(await Usuario.query()
      .where('id', user.id)
      .whereHas('establecimientos', (query) => {
        query.where('establecimientos.id', establecimientoId)
      })
      .first())
  }
  
  // Verificar si es propietario o administrador
  async isOwnerOrAdmin(user: Usuario, recurso: Recurso) {
    if (user.id === recurso.usuarioId) return true
    
    const roles = await user.related('roles').query()
    return roles.some(role => ['admin', 'super_admin'].includes(role.slug))
  }
  
  // Políticas específicas
  async create(user: Usuario, { establecimientoId }: { establecimientoId: number }) {
    return this.verifyEstablecimientoAccess(user, establecimientoId)
  }
  
  async view(user: Usuario, recurso: Recurso) {
    return this.verifyEstablecimientoAccess(user, recurso.establecimientoId)
  }
  
  async update(user: Usuario, recurso: Recurso) {
    return this.isOwnerOrAdmin(user, recurso)
  }
  
  async delete(user: Usuario, recurso: Recurso) {
    return this.isOwnerOrAdmin(user, recurso)
  }
}

Recomendaciones Clave

  1. Métodos Auxiliares:
    • Extraer lógica común a métodos reutilizables
    • Centralizar verificaciones como acceso a establecimiento
  2. Granularidad:
    • Definir políticas específicas por acción (create, view, update, delete)
    • Considerar niveles de permisos (propietario vs administrador)
  3. Integración con Controladores:
    • Usar bouncer en controladores: await bouncer.with(Policy).authorize('action', resource)
    • Manejar respuestas de autorización denegada adecuadamente

Buenas Prácticas Generales

TypeScript

  1. Definir Tipos Explícitamente:
    • Usar interfaces/types para peticiones y respuestas
    • Evitar any, preferir tipos específicos o unknown
  2. Imports:
    • Usar aliases consistentes: #models, #controllers, #validators
    • Agrupar imports por categoría (core, modelos, validators, etc.)

Base de Datos

  1. Soft Delete:
    • Implementar en todos los modelos que almacenen información valiosa
    • Incluir deletedBy para registrar quién realizó la eliminación
    • Usar métodos onlyTrashed() para consultas específicas
  2. Transacciones:
    • Usar db.transaction para operaciones que afecten múltiples tablas
    • Asegurar la integridad de los datos relacionados
  3. Índices y Optimización:
    • Crear índices para campos de búsqueda frecuente
    • Analizar consultas complejas para optimizar rendimiento

Gestión de Errores

  1. Estructura de Error:
    try {
      // Operación que puede fallar
    } catch (error) {
      return response.status(error.status || 500).send({
        message: 'Mensaje descriptivo del error',
        error: process.env.NODE_ENV === 'production' ? undefined : error.message
      })
    }
    
  2. Errores Específicos:
    • HTTP 400: Validación fallida, datos incorrectos
    • HTTP 401: No autenticado
    • HTTP 403: No autorizado (autenticado pero sin permisos)
    • HTTP 404: Recurso no encontrado
    • HTTP 409: Conflicto (ej. duplicación)
    • HTTP 500: Error interno del servidor
  3. Logs:
    • Registrar errores críticos con detalles suficientes para diagnóstico
    • No incluir información sensible en logs

Seguridad

  1. Validación:
    • Validar TODOS los datos de entrada, sin excepciones
    • Usar reglas estrictas y específicas
  2. Autorización:
    • Verificar permisos en cada método de controlador
    • Implementar políticas para centralizar lógica de autorización
  3. Datos Sensibles:
    • No devolver datos sensibles en respuestas API
    • Usar environment variables para configuraciones sensibles

Ejemplos Prácticos

Modelo Completo

import { DateTime } from 'luxon'
import { BaseModel, column, belongsTo, hasMany, manyToMany } from '@adonisjs/lucid/orm'
import { compose } from '@adonisjs/core/helpers'
import { SoftDeletes } from '#models/mixins/soft_deletes'
import { Auditable } from '@stouder-io/adonis-auditing'
import { Filterable } from 'adonis-lucid-filter'
import type { BelongsTo, HasMany, ManyToMany } from '@adonisjs/lucid/types/relations'

// Imports de modelos relacionados
import Usuario from '#models/auth/usuario'
import Categoria from '#models/catalogo/categoria'
import Comentario from '#models/catalogo/comentario'
import Etiqueta from '#models/catalogo/etiqueta'

// Imports de filtros
import RecursoFilter from './filters/recurso_filter'

export default class Recurso extends compose(BaseModel, SoftDeletes, Filterable, Auditable) {
  static $filter = () => RecursoFilter
  static table = 'catalogo.recursos'

  @column({ isPrimary: true })
  declare id: number

  @column()
  declare titulo: string

  @column()
  declare descripcion: string | null

  @column()
  declare url: string

  @column()
  declare tipo: 'documento' | 'imagen' | 'video' | 'otro'

  @column()
  declare usuarioId: number

  @column()
  declare categoriaId: number

  @column()
  declare establecimientoId: number

  @column.dateTime({ autoCreate: true })
  declare createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  declare updatedAt: DateTime

  @column.dateTime()
  declare deletedAt: DateTime | null

  @column()
  declare deletedBy: number | null

  // Relaciones
  @belongsTo(() => Usuario)
  declare usuario: BelongsTo<typeof Usuario>

  @belongsTo(() => Categoria)
  declare categoria: BelongsTo<typeof Categoria>

  @hasMany(() => Comentario)
  declare comentarios: HasMany<typeof Comentario>

  @manyToMany(() => Etiqueta, {
    pivotTable: 'catalogo.recursos_etiquetas',
    pivotForeignKey: 'recurso_id',
    pivotRelatedForeignKey: 'etiqueta_id',
  })
  declare etiquetas: ManyToMany<typeof Etiqueta>
}

Controlador Eficiente

import type { HttpContext } from '@adonisjs/core/http'
import db from '@adonisjs/lucid/services/db'
import Recurso from '#models/catalogo/recurso'
import { RecursoValidator, RecursoUpdateValidator } from '#validators/catalogo/recurso_validator'
import RecursoPolicy from '#policies/catalogo/recurso_policy'

export default class RecursoController {
  private PAGINATE_RECORDS = 15

  async index({ request, response, auth }: HttpContext) {
    try {
      const user = auth.user
      const { page = 1, establecimientoId, query = '', categoria } = request.qs()
      
      // Verificar acceso al establecimiento
      const tieneAcceso = await auth.user?.related('establecimientos')
        .query().where('id', establecimientoId).first()
      
      if (!tieneAcceso) {
        return response.forbidden({ message: 'No tiene acceso a este establecimiento' })
      }
      
      // Construir consulta base
      let recursosQuery = Recurso.query()
        .where('establecimiento_id', establecimientoId)
        .whereNull('deleted_at')
      
      // Aplicar filtros opcionales
      if (query) {
        recursosQuery.where('titulo', 'ILIKE', `%${query}%`)
      }
      
      if (categoria) {
        recursosQuery.where('categoria_id', categoria)
      }
      
      // Cargar relaciones y aplicar ordenamiento
      const recursos = await recursosQuery
        .preload('usuario', (q) => q.select('id', 'nombre', 'email'))
        .preload('categoria')
        .preload('etiquetas')
        .orderBy('created_at', 'desc')
        .paginate(page, this.PAGINATE_RECORDS)
      
      return response.ok(recursos)
    } catch (error) {
      return response.internalServerError({
        message: 'Error al obtener recursos',
        error: process.env.NODE_ENV === 'production' ? undefined : error.message
      })
    }
  }

  async store({ request, response, auth, bouncer }: HttpContext) {
    try {
      // Validar datos de entrada
      const data = await RecursoValidator.validate(request.all())
      
      // Verificar autorización
      if (await bouncer.with(RecursoPolicy).denies('create', { establecimientoId: data.establecimientoId })) {
        return response.forbidden({ message: 'No autorizado para crear recursos' })
      }
      
      // Crear recurso con transacción
      const recurso = await db.transaction(async (trx) => {
        const nuevoRecurso = await Recurso.create({
          ...data,
          usuarioId: auth.user!.id
        }, { client: trx })
        
        // Procesar etiquetas si existen
        if (data.etiquetas?.length) {
          await nuevoRecurso.related('etiquetas').sync(data.etiquetas, { client: trx })
        }
        
        return nuevoRecurso
      })
      
      // Cargar relaciones para la respuesta
      await recurso.load('usuario')
      await recurso.load('categoria')
      await recurso.load('etiquetas')
      
      return response.created(recurso)
    } catch (error) {
      return response.badRequest({
        message: 'Error al crear recurso',
        error: process.env.NODE_ENV === 'production' ? undefined : error.message
      })
    }
  }

  async update({ request, response, params, bouncer }: HttpContext) {
    try {
      const { id } = params
      const data = await RecursoUpdateValidator.validate(request.all())
      
      // Buscar recurso existente
      const recurso = await Recurso.find(id)
      if (!recurso) {
        return response.notFound({ message: 'Recurso no encontrado' })
      }
      
      // Verificar autorización
      if (await bouncer.with(RecursoPolicy).denies('update', recurso)) {
        return response.forbidden({ message: 'No autorizado para actualizar este recurso' })
      }
      
      // Actualizar con transacción
      await db.transaction(async (trx) => {
        // Actualizar campos básicos
        await recurso.merge(data).save()
        
        // Actualizar etiquetas si se proporcionaron
        if (data.etiquetas !== undefined) {
          await recurso.related('etiquetas').sync(data.etiquetas, { client: trx })
        }
      })
      
      // Cargar relaciones para la respuesta
      await recurso.load('usuario')
      await recurso.load('categoria')
      await recurso.load('etiquetas')
      
      return response.ok(recurso)
    } catch (error) {
      return response.badRequest({
        message: 'Error al actualizar recurso',
        error: process.env.NODE_ENV === 'production' ? undefined : error.message
      })
    }
  }

  async destroy({ params, response, auth, bouncer }: HttpContext) {
    try {
      const { id } = params
      
      // Buscar recurso
      const recurso = await Recurso.find(id)
      if (!recurso) {
        return response.notFound({ message: 'Recurso no encontrado' })
      }
      
      // Verificar autorización
      if (await bouncer.with(RecursoPolicy).denies('delete', recurso)) {
        return response.forbidden({ message: 'No autorizado para eliminar este recurso' })
      }
      
      // Aplicar soft delete
      recurso.deletedBy = auth.user!.id
      await recurso.save()
      await recurso.delete()
      
      return response.ok({ message: 'Recurso eliminado correctamente' })
    } catch (error) {
      return response.internalServerError({
        message: 'Error al eliminar recurso',
        error: process.env.NODE_ENV === 'production' ? undefined : error.message
      })
    }
  }
}

Rutas Organizadas

import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

// Rutas de recursos educativos
const recursoRoutes = router.group(() => {
  // CRUD básico
  router.get('/', '#controllers/catalogo/recurso_controller.index')
  router.get('/:id', '#controllers/catalogo/recurso_controller.show')
  router.post('/', '#controllers/catalogo/recurso_controller.store')
  router.put('/:id', '#controllers/catalogo/recurso_controller.update')
  router.delete('/:id', '#controllers/catalogo/recurso_controller.destroy')
  
  // Restauración (soft delete)
  router.patch('/:id/restore', '#controllers/catalogo/recurso_controller.restore')
  
  // Rutas específicas
  router.get('/byCategoria/:categoriaId', '#controllers/catalogo/recurso_controller.getByCategoria')
  
  // Comentarios (subrecurso)
  router.group(() => {
    router.get('/', '#controllers/catalogo/comentario_controller.index')
    router.post('/', '#controllers/catalogo/comentario_controller.store')
    router.delete('/:comentarioId', '#controllers/catalogo/comentario_controller.destroy')
  }).prefix('/:recursoId/comentarios')
})
.prefix('/api/v3/recursos')
.middleware(middleware.auth())

export default recursoRoutes

Resumen Final

Principios Fundamentales

  1. Consistencia: Mantener el mismo estilo y estructura en todo el código
  2. Modularidad: Separar lógica en componentes reutilizables
  3. Seguridad: Validar datos, verificar permisos, evitar vulnerabilidades
  4. Mantenibilidad: Escribir código autodocumentado y fácil de entender
  5. Rendimiento: Optimizar consultas y transacciones de base de datos

Checklist de Calidad

  • ¿El código sigue las convenciones de nomenclatura?
  • ¿Se implementa soft delete en los modelos relevantes?
  • ¿Se usan validadores para todos los datos de entrada?
  • ¿Las consultas cargan relaciones eficientemente?
  • ¿Se verifica autorización en cada endpoint?
  • ¿El manejo de errores es consistente?
  • ¿Las transacciones protegen operaciones complejas?
  • ¿La estructura de archivos sigue el patrón establecido?
  • ¿Los tipos de TypeScript están bien definidos?
  • ¿El código es legible y está bien documentado?

Seguir estas prácticas no solo mejorará la calidad del código, sino que facilitará la colaboración entre desarrolladores y el mantenimiento a largo plazo del proyecto RedCollege.