Next.js
Data Access Layer y DTOs
Explica cómo una Data Access Layer centraliza tenant, autorización, consultas, transacciones, caché y DTOs mínimos para pages, Actions y APIs.
- Última actualización
- Actualizada
- Nivel
- Aplicación
Next.js
Explica cómo una Data Access Layer centraliza tenant, autorización, consultas, transacciones, caché y DTOs mínimos para pages, Actions y APIs.
Una Data Access Layer concentra lecturas y escrituras seguras cerca de la fuente: aplica tenant scope, autorización, selección de campos y transformación a DTOs. Su objetivo no es añadir repositorios ceremoniales, sino impedir que cada page, Action y Handler invente reglas distintas o exponga entidades internas completas.
Page / Action / Route Handler
↓
Data Access Layer
├─ session/principal
├─ authorization
├─ query or transaction
├─ cache policy
├─ mapping
└─ typed result / DTO
↓
Database or external serviceLa DAL devuelve información preparada para su consumidor, no la fila cruda por defecto.
Sin DAL:
// page A
const order = await db.order.findUnique({ where: { id } });
// action B
const order = await db.order.findFirst({ where: { id, userId } });
// route C
const order = await db.order.findUnique({ include: { customer: true } });Cada entrada:
La DAL crea un camino recomendado y testeable.
Aporta cuando:
En una página pequeña, una función server-only bien nombrada ya puede ser suficiente. No necesitas interfaces, factories y diez capas si no resuelven un cambio real.
// features/orders/data/get-order.ts
import "server-only";
import { cache } from "react";
import { db } from "@/server/db";
import { verifySession } from "@/server/auth";server-only provoca un error de build si una boundary cliente intenta importarlo.
No protege datos que tú mismo serializas; solo el grafo de código.
Entidad interna:
type OrderRecord = {
id: string;
organizationId: string;
customerId: string;
internalCost: number;
fraudScore: number;
status: string;
total: number;
createdAt: Date;
};DTO público:
export type OrderView = {
id: string;
status: "pending" | "confirmed" | "cancelled";
total: number;
createdAt: string;
};Mapper:
function toOrderView(order: OrderRecord): OrderView {
return {
id: order.id,
status: parseOrderStatus(order.status),
total: order.total,
createdAt: order.createdAt.toISOString(),
};
}El DTO limita exposición y desacopla el contrato de nombres/relaciones de la DB.
export const getOrderForCurrentUser = cache(
async (orderId: string): Promise<OrderView | null> => {
const session = await verifySession();
if (!session) return null;
const order = await db.order.findFirst({
where: {
id: orderId,
organizationId: session.organizationId,
organization: {
members: { some: { userId: session.userId } },
},
},
select: {
id: true,
status: true,
total: true,
createdAt: true,
},
});
return order ? toOrderView(order as OrderRecord) : null;
},
);Idealmente el tipo del ORM coincide con select sin cast. El ejemplo destaca que la query incluye actor/tenant y selecciona solo campos necesarios.
React.cache deduplica llamadas en el mismo request.
No toda ausencia es una excepción:
type GetOrderResult =
| { status: "found"; order: OrderView }
| { status: "notFound" }
| { status: "unauthenticated" };La page decide:
const result = await getOrder(input);
if (result.status === "unauthenticated") redirect("/login");
if (result.status === "notFound") notFound();
return <OrderDetails order={result.order} />;La DAL no necesita importar siempre redirect() o notFound(). Mantener resultados de dominio facilita reutilizarla desde APIs y jobs. En una app pequeña, lanzar una excepción de control tipada también puede ser razonable si el contrato es claro.
Devuelve DTO para pantalla, metadata o endpoint.
Ejecuta transición y transacción:
export async function cancelOrder(input: {
orderId: string;
expectedVersion: number;
}): Promise<CancelOrderResult> {
const session = await requireSession();
return db.$transaction(async (tx) => {
const order = await tx.order.findFirst({
where: {
id: input.orderId,
organizationId: session.organizationId,
},
});
if (!order) return { status: "notFound" };
if (!canCancelOrder(session, order)) return { status: "forbidden" };
if (order.version !== input.expectedVersion) return { status: "conflict" };
await tx.order.update({
where: { id: order.id },
data: { status: "cancelled", version: { increment: 1 } },
});
return { status: "success", orderId: order.id };
});
}La Action adapta FormData y después invalida cache.
Server Action
→ parse FormData
→ call cancelOrder
→ updateTag
→ return UI state
Route Handler
→ parse JSON
→ call cancelOrder
→ return HTTP statusEl mismo caso de uso sirve a dos adaptadores sin hacer HTTP interno.
export const getCurrentOrganization = cache(async () => { ... });Evita duplicación en layout/page/metadata.
export async function getPublicProduct(slug: string) {
"use cache";
cacheLife("hours");
cacheTag(`product:${slug}`);
return toPublicProductView(await repository.findBySlug(slug));
}Datos por usuario suelen permanecer dinámicos. Si cacheas, la key, lifetime e invalidación deben mantener aislamiento.
No escondas use cache dentro de una función que parece siempre fresh sin documentar su semántica.
Ventaja:
query cannot return inaccessible rowsFrente a:
load everything
→ filter in pageLa primera reduce fugas accidentales, payload y errores de reutilización.
Para agregados, cada join/subquery debe conservar el scope. Un count global no debe mezclarse con lista filtrada.
No existe un UserDTO universal:
UserNavigationView
→ id, displayName, avatar
UserAdminView
→ status, roles, lastLogin
UserPublicProfileView
→ displayName, bioCada consumidor recibe lo mínimo. Compartir una entidad enorme por comodidad derrota el propósito.
Cuando datos vienen de API externa:
const raw = await provider.getCustomer(id);
const parsed = providerCustomerSchema.parse(raw);
return toCustomerView(parsed);El tipo del SDK no prueba el payload runtime. Validar en la frontera detecta cambios del proveedor.
Para DB gestionada por tu schema, las restricciones y tipos del ORM aportan más confianza; valida reglas que no están en la DB.
Next.js permite habilitar APIs experimentales de tainting:
const nextConfig = {
experimental: {
taint: true,
},
};Pueden marcar objetos/valores para impedir que crucen al cliente.
Limitaciones:
app.Úsalo como guardrail adicional, no como diseño principal.
Pregúntate:
Ejemplo: muestra últimos cuatro dígitos, no número completo.
La DAL puede mapear:
No expongas mensajes SQL. Conserva cause/correlation internamente y devuelve un resultado seguro.
Registra operación y resultado, no la entidad completa:
getOrder
organization=org_x
actor=user_y
order=ord_z
outcome=found
latency=24msRedacta emails, tokens y documentos. Un DTO seguro para UI no necesariamente es seguro para logs públicos.
La DAL/service conoce qué operaciones deben ser atómicas:
create order
+ decrement stock
+ insert audit/outbox
→ one transactionNo mantengas transacción abierta mientras llamas un proveedor lento; escribe outbox y procesa después.
Actor/tenant/action matrices.
Asegura que otro tenant no retorna filas.
Verifica campos permitidos; evita snapshots enormes.
Transacción, unique, foreign keys y version conflicts.
Keys por tenant, invalidación y usuarios distintos.
Import de módulo server-only desde cliente debe fallar en build.
Una DAL no debe convertirse en “god module”. Divide por feature/caso de uso:
features/orders/data/get-order.ts
features/orders/data/list-orders.ts
features/orders/services/cancel-order.tsExporta una API pública de la feature. Evita barrels que introducen ciclos server/client.
Para una landing con una única consulta pública:
export async function getPublishedPosts() { ... }Puede bastar. Añade abstracción cuando reduzca riesgo, repetición o acoplamiento, no para imitar una arquitectura empresarial.
Fuga y serialización.
La fila ya fue cargada sin scope.
Termina con todos los campos opcionales.
No expresa políticas del dominio.
Fuga entre usuarios.
Experimental y evadible mediante copias/derivaciones.
Devuelve JSX o mensajes específicos y no puede reutilizarse.
server-only?Variables de entorno y configuración segura distingue build-time, runtime y valores públicos antes de conectar servicios desde la DAL.