⌘J
En esta página 7

Engineering / Arquitectura: visión general

Arquitectura: visión general

Cómo se organiza el código de platform (un solo repo, vertical slicing por dominio, props siempre vía Data object) y qué se difiere con disparador.

La arquitectura de código quedó cerrada en una sesión de diseño (decisión 0.7) y el foundation ya la está fijando en código: F0–F2 mergeadas (bootstrap, auth, tenancy + RLS). Los slices de dominio (app/Domain/*) llegan con las fases de producto; hasta entonces, lo de abajo describe la convención que el código nuevo sigue.

El principio que gobierna toda la arquitectura, en palabras del CTO:

Diseñar para el futuro (no cerrarse puertas) es barato; construir para el futuro es sobre-ingeniería.

La estructura queda flexible por diseño y liviana por recorte. Lo especulativo (una API REST, interfaces entre slices) se difiere con un disparador concreto, no se construye “por si acaso”.

El mapa

Así fluye un request por platform, de arriba hacia abajo. Las secciones que siguen explican cada frontera del diagrama.

Navegador — React + Inertia

TypeScript estricto, shadcn

platform (un solo repo Laravel)

Rutas tenant

el producto, con middleware de tenancy

Rutas centrales · /admin/*

backoffice legal, sin tenancy (cross-tenant)

Controllers delgados → Actions

hidratan Data, invocan la lógica, renderean

Domain/Labor

motor del Código de Trabajo — PHP puro, cero deps

Domain/Contracts

aggregate Contract, eventos, documento

Domain/Signing

frontera SigningService (UANATACA / fake)

Models (Eloquent)

solo persistencia

Postgres — tablas de dominio

tenant_id + RLS fail-closed

Postgres — tablas globales

users, tenants, clauses (sin tenant_id)

El flujo de un request en platform. Las dos fronteras duras: los props a React pasan siempre por un Data object, yDomain/Labor no conoce Laravel.

Un solo repo

platform = el producto (app) más el backoffice legal interno, en un mismo repo Laravel. Comparten el dominio: el backoffice edita las cláusulas y el motor que el producto consume, así que separarlos duplicaría o acoplaría por red el activo crítico.

platform (un repo)

Producto + backoffice legal juntos. Es el patrón que stancl/tenancy endosa: rutas centrales (sin middleware de tenancy) para el backoffice cross-tenant, rutas tenant (con middleware) para el producto.

AxelOps (repo aparte)

La tool interna del equipo (clon de Basecamp) vive en su propio repo: cero dominio compartido con el producto, ya existe.

El backoffice legal vive en /admin/* del mismo platform, sobre las tablas globales/centrales (sin tenant_id), no como un deploy aparte. Se construye en Inertia + React + shadcn (el mismo stack del producto), no en Filament: mantener un solo paradigma de frontend baja la carga de review y reusa el design system.

El detalle de cómo se aísla cada cliente —el modelo de tenancy, el tenant_id y las garantías de aislamiento— vive en Multi-tenancy y aislamiento.

Vertical slicing por dominio

app se organiza por dominio, no por capa técnica. La estructura “grita” que es una legal-tech de contratos.

app
Domain
Labor — el motor del Código de Trabajo (PHP puro, cero deps)
Contracts — aggregate Contract, eventos, generación de documento
Signing — frontera SigningService + implementaciones
Actions — lógica de negocio (sin sufijo Action)
Http
Controllers — delgados: hidratan Data, invocan Actions, renderean
Models — Eloquent, solo persistencia

Cada slice lleva sus Actions, Models, Data y Policies. Reusa las convenciones de AxelOps (Actions sin sufijo, spatie/laravel-data, Pest, phpstan nivel 7) pero no su estructura: AxelOps organiza por capa porque es una tool interna pequeña; platform es un producto complejo donde el vertical slicing paga. Es disciplina de carpetas, no ceremonia: nada de nwidart/laravel-modules.

El slice más puro es app/Domain/Labor/: PHP puro, cero dependencias de Laravel, Eloquent o HTTP. Es la única pieza que merece esa frontera, justo porque el resto puede ser CRUD normal. Ver Motor del Código de Trabajo.

Convenciones de código

Lógica en Actions, no en controllers

La frase que lo gobierna: “Inertia no te encierra; los controllers gordos sí.” Con la lógica en Actions, la puerta a mobile y a MCP queda abierta sin construir nada hoy.

phpstan nivel 7 + Pest

Larastan en nivel 7, Pint para formato, Pest para tests, TypeScript estricto en el front desde el día 1 (el PoC no tenía TS: no se hereda esa carencia).

Props siempre vía Data object

La regla que cierra la fuga de PII y cross-tenant: los datos que bajan a React nunca son un modelo Eloquent crudo. Siempre pasan por un objeto spatie/laravel-data que declara explícitamente qué campos salen (whitelist).

ContractController.php
// ✗ NUNCA — expone todo el modelo, incluida PII return Inertia::render('Contracts/Show', ['contract' => $contract]); // ✓ SIEMPRE — el Data object declara qué sale (salario, DUI solo si se incluye a propósito) return Inertia::render('Contracts/Show', [ 'contract' => ContractData::from($contract), ]);

Un campo sensible (salario, DUI) solo llega al cliente si el DTO lo incluye deliberadamente. Bonus: el mismo Data object sirve props de Inertia y JSON de una API futura, una sola capa de serialización. Los errores de validación usan el mecanismo estándar de Inertia (errors en props), sin envolver.

Qué se difiere (con disparador)

Los dos escenarios futuros que se marcaron (front alternativo/mobile, extraer un módulo a servicio) no requieren construir nada hoy. Requieren solo no meter la lógica en el lugar equivocado.

DiferidoDisparador para construirlo
api.php + Sanctum (capa API para mobile)Existe un 2º cliente real (mobile/integración)
Interfaces + eventos entre slices, sin FK cross-móduloDecisión concreta de extraer un slice (ej. Signing) a un servicio
nwidart/laravel-modules8-10 dominios que colisionen (carpetas + disciplina bastan antes)
Motor a paquete Composer (packages/labor-engine)Entra la 2ª jurisdicción y se quiere versionar el motor aparte del deploy

Inertia (web) y una API-first (conectable) coexisten: Laravel sirve la web vía Inertia y expone endpoints API para lo conectable sobre el mismo backend. No es elegir uno. Meter un front separado tipo Next duplicaría la capa de presentación sin ganar nada que Inertia + API no dé ya.

Por qué importaba cerrarlo antes de escribir código

El primer código fija la convención de facto

Sin la arquitectura decidida, el primer commit habría fijado la estructura sin una decisión consciente. Ahora está resuelta: flexible por diseño, liviana por recorte. Toda la base de código de #1 en adelante se escribe sobre esto.