<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="es">
    <title>BlackMesa Labs</title>
    <subtitle>Notas de ingeniería: lo que construimos, lo que rompimos, lo que aprendimos.</subtitle>
    <link href="https://mesa.black/es/feed.xml" rel="self"/>
    <link href="https://mesa.black/es/"/>
    <updated>2026-10-04T00:00:00+00:00</updated>
    <id>https://mesa.black/es/</id>
        <entry>
        <title>Nuestra capa de dominio solo contiene excepciones. A propósito.</title>
        <link href="https://mesa.black/es/nuestro-dominio-solo-contiene-excepciones/"/>
        <id>https://mesa.black/es/nuestro-dominio-solo-contiene-excepciones/</id>
        <updated>2026-10-04T00:00:00+00:00</updated>
        <summary>Nueve contextos, veintinueve comandos, un único manejador de consultas, cero puertos. Qué conservamos de DDD y de la arquitectura hexagonal, qué rechazamos, y los cinco puntos por donde la infraestructura cruza igualmente la frontera. Esto no es DDD purista, es time to market.</summary>        <content type="html">&lt;p&gt;Los artículos sobre arquitectura hexagonal muestran casi siempre el mismo esquema: un círculo en el centro, puertos alrededor, adaptadores fuera y una flecha que apunta hacia dentro. Ninguno enseña nunca qué contiene la carpeta &lt;code&gt;Domain/&lt;/code&gt; seis meses después.&lt;/p&gt;
&lt;p&gt;Aquí está la nuestra. Nueve contextos y, en total, doce archivos de dominio: nueve excepciones, dos enumeraciones, un rol. Cero interfaces. Cero agregados. Cero objetos de valor. Llamar a eso capa de negocio sería mentir, y de eso trata exactamente este artículo.&lt;/p&gt;
&lt;h2&gt;Lo que conservamos: la C de CQRS, no la Q&lt;/h2&gt;
&lt;p&gt;La configuración del bus declara tres canales con una semántica explícita:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;default_bus: command.bus
buses:
    command.bus:            # un comando muta el estado, exactamente un manejador
        default_middleware: { enabled: true, allow_no_handlers: false }
    query.bus:              # una consulta devuelve un valor, un único manejador
        default_middleware: { enabled: true, allow_no_handlers: false }
    event.bus:              # un evento de dominio: 0..n suscriptores
        default_middleware: { enabled: true, allow_no_handlers: true }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;El recuento real a día de hoy: &lt;strong&gt;veintinueve manejadores de comando, uno solo de consulta.&lt;/strong&gt; El bus de consultas existe, está configurado y está casi vacío.&lt;/p&gt;
&lt;p&gt;No es deuda de migración, es una conclusión. Un comando merece su ceremonia porque aporta tres cosas que antes no teníamos: un nombre de intención (&lt;code&gt;ChangeCompanySubscriptionPlan&lt;/code&gt; no es &lt;code&gt;setSubscriptionPlan&lt;/code&gt;), la garantía de que existe exactamente un sitio que lo ejecuta — &lt;code&gt;allow_no_handlers: false&lt;/code&gt; falla al arrancar, no en producción — y una frontera de transacción evidente, la del manejador.&lt;/p&gt;
&lt;p&gt;Una lectura no aporta nada de eso. Su forma la dicta la pantalla que la muestra: esta página necesita estas siete columnas, unidas así, ordenadas de esta manera. Pasar eso por un bus no añade ninguna regla, añade una capa y traslada el SQL de un archivo a otro. Así que leemos a través de los repositorios de Doctrine, directamente, y sin pedir perdón por ello.&lt;/p&gt;
&lt;p&gt;Un detalle que importa más de lo que parece: &lt;code&gt;default_bus: command.bus&lt;/code&gt;. Un &lt;code&gt;dispatch()&lt;/code&gt; sin más es un comando. El valor por defecto es el canal que muta el estado, es decir, el único que nunca quieres ver saliendo por el canal equivocado.&lt;/p&gt;
&lt;h2&gt;Lo que rechazamos: la inversión de dependencias&lt;/h2&gt;
&lt;p&gt;Es el corazón del hexágono en la literatura: el dominio declara interfaces, la infraestructura las implementa, la flecha de dependencia apunta hacia dentro. No lo hicimos. Aquí va un manejador entero, sin cortes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;#[AsMessageHandler(bus: &#039;command.bus&#039;)]
final readonly class ChangeCompanySubscriptionPlanHandler
{
    public function __construct(
        private EntityManagerInterface $entityManager,
        private LoggerInterface $logger,
    ) {}

    public function __invoke(ChangeCompanySubscriptionPlanCommand $command): void
    {
        $company = $this-&amp;gt;entityManager-&amp;gt;find(Company::class, Ulid::fromString($command-&amp;gt;companyId));
        if (!$company instanceof Company) {
            throw CompanyNotFoundException::withId($command-&amp;gt;companyId);
        }
        // …
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;EntityManagerInterface&lt;/code&gt; como dependencia directa. De treinta y ocho manejadores, &lt;strong&gt;veinticuatro&lt;/strong&gt; dependen de él. La entidad &lt;code&gt;Company&lt;/code&gt; viene de &lt;code&gt;App\Entity&lt;/code&gt;, compartida por todos los contextos. No hay repositorio abstracto, ni puerto, ni modelo de dominio distinto de la tabla.&lt;/p&gt;
&lt;p&gt;Dos observaciones sobre ese ejemplo, porque dicen más que el esquema.&lt;/p&gt;
&lt;p&gt;Primera: el comando transporta &lt;code&gt;string $companyId&lt;/code&gt;, no un objeto de valor &lt;code&gt;CompanyId&lt;/code&gt;. No es pereza: el mensaje tiene que poder serializarse para el transporte asíncrono. La frontera del bus impone la forma del mensaje — infraestructura dictando la firma de lo que llamamos dominio, desde la primera línea.&lt;/p&gt;
&lt;p&gt;Segunda: &lt;code&gt;Ulid::fromString(...)&lt;/code&gt;. El identificador del dominio existe en dos formas según se transporte o se consulte con él, y esa conversión es una restricción del almacenamiento, no del negocio.&lt;/p&gt;
&lt;h2&gt;Lo que la frontera compra de todos modos&lt;/h2&gt;
&lt;p&gt;Una cosa, exactamente una, y vale su precio: &lt;strong&gt;el acoplamiento entre contextos se volvió visible en la lista de importaciones.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;El manejador anterior vive en &lt;code&gt;App\Billing&lt;/code&gt;. Importa &lt;code&gt;App\Company\Domain\Exception\CompanyNotFoundException&lt;/code&gt;. Esa sola importación dice que la facturación depende del contexto Empresa, y lo dice en la cabecera del archivo, en una línea, sin ningún diagrama que mantener al día. El grafo de dependencias entre contextos se obtiene con un &lt;code&gt;grep&lt;/code&gt; sobre los &lt;code&gt;use&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Es modesto. Comparado con una base donde todo vive en &lt;code&gt;App\Service&lt;/code&gt;, es la diferencia entre «suponemos que está acoplado» y «aquí está exactamente dónde». Ese beneficio es lo que compramos, y nada más.&lt;/p&gt;
&lt;h2&gt;Los cinco puntos por donde cruza la infraestructura&lt;/h2&gt;
&lt;p&gt;Esto es lo que ningún esquema enseña: los sitios donde Doctrine decide la forma de una operación de negocio. Son fugas reales, todas presentes hoy en el código.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. La regla de negocio escrita en dos dialectos.&lt;/strong&gt; Nuestra política para mantener a las empresas internas fuera de las cifras comerciales cabe en un predicado. Existe en dos versiones, porque algunos caminos de lectura son SQL nativo por rendimiento y otros son DQL:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;public static function sql(string $alias = &#039;c&#039;): string  // consultas nativas
{ return ($alias !== &#039;&#039; ? $alias.&#039;.&#039; : &#039;&#039;).&#039;excluded_from_stats = false&#039;; }

public static function dql(string $alias = &#039;c&#039;): string  // consultas del ORM
{ return $alias.&#039;.excludedFromStats = false&#039;; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Una regla, dos escrituras, por culpa del lenguaje de consulta. La divergencia es de un carácter — &lt;code&gt;excluded_from_stats&lt;/code&gt; frente a &lt;code&gt;excludedFromStats&lt;/code&gt; — así que resulta invisible en una lectura rápida, y no hay ninguna prueba que fallara si una de las dos se desviara.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. El filtro ambiental.&lt;/strong&gt; El filtro de borrado lógico de Doctrine es estado global mutable. Una consulta que busca un identificador único no encuentra nada mientras el índice único sí lo sigue reteniendo. La salida es desactivar el filtro y volver a ponerlo:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$filters = $this-&amp;gt;em-&amp;gt;getFilters();
$wasEnabled = $filters-&amp;gt;isEnabled(&#039;softdeleteable&#039;);
if ($wasEnabled) { $filters-&amp;gt;disable(&#039;softdeleteable&#039;); }
$existing = $repo-&amp;gt;findOneBy([&#039;slug&#039; =&amp;gt; $slug]);
if ($wasEnabled) { $filters-&amp;gt;enable(&#039;softdeleteable&#039;); }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Una «consulta de negocio» cuyo resultado depende de un estado ambiental no es una función. No es un defecto de Doctrine — el filtro hace exactamente lo que le pedimos — pero impide razonar sobre el código de aplicación sin saber en qué modo se ejecuta.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. El orden de escritura dentro de la unidad de trabajo.&lt;/strong&gt; Reemplazar las traducciones de un contenido parece una sola operación: quitar las antiguas, añadir las nuevas, guardar. Dentro de un único &lt;code&gt;flush()&lt;/code&gt;, Doctrine ejecuta los &lt;code&gt;INSERT&lt;/code&gt; antes que los &lt;code&gt;DELETE&lt;/code&gt; de huérfanos, y la restricción de unicidad sobre &lt;code&gt;(feedback_id, locale)&lt;/code&gt; revienta. Hay que partir la operación en dos guardados sucesivos.&lt;/p&gt;
&lt;p&gt;Dicho de otro modo: la forma de la operación aplicativa no la fija el negocio, sino la planificación interna del ORM. Ningún puerto habría protegido de eso, porque la restricción no vive ni en el dominio ni en el adaptador: vive en el orden de ejecución que los une.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. El alias de unión que trunca en silencio.&lt;/strong&gt; Filtrar sobre una asociación ya unida con &lt;code&gt;fetch&lt;/code&gt; restringe además la colección hidratada: crees que filtras filas y estás amputando el objeto devuelto. El filtrado necesita su propia segunda unión:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$qb-&amp;gt;innerJoin(&#039;f.industries&#039;, &#039;i_filter&#039;)      // alias distinto del de fetch
   -&amp;gt;andWhere(&#039;i_filter.id IN (:industries)&#039;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;El fallo es invisible: la consulta devuelve las entidades correctas, con colecciones incompletas. Es semántica de infraestructura, en mitad de lo que parece una regla de búsqueda.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. El tipo del identificador.&lt;/strong&gt; Los identificadores viajan como ULID, se comparan en RFC 4122 dentro de las consultas, y olvidar la conversión produce un resultado vacío en lugar de un error. Un puerto habría desplazado la conversión; no la habría eliminado.&lt;/p&gt;
&lt;h2&gt;La regla de migración&lt;/h2&gt;
&lt;p&gt;Queda la pregunta que mata las reescrituras: ¿qué hacemos con el código existente, escrito como servicios clásicos de Symfony?&lt;/p&gt;
&lt;p&gt;Nada, mientras nadie lo toque. La política está escrita: &lt;strong&gt;CQRS para las escrituras y lecturas nuevas; el código existente migra solo bajo demanda.&lt;/strong&gt; Nunca una reescritura masiva en nombre de la homogeneidad.&lt;/p&gt;
&lt;p&gt;Porque la homogeneidad no es un resultado de negocio. Reescribir un servicio que funciona para que se parezca a sus vecinos produce riesgo sin producir valor, y es exactamente el tipo de trabajo que se justifica solo, indefinidamente, porque su criterio de parada es estético.&lt;/p&gt;
&lt;h2&gt;Esto no es DDD purista, es time to market&lt;/h2&gt;
&lt;p&gt;Hay que nombrar la razón de verdad, porque todo lo anterior se lee de otra manera una vez puesta sobre la mesa.&lt;/p&gt;
&lt;p&gt;Ya hemos escrito aquí que &lt;a href=&quot;/es/el-codigo-que-no-escribimos/&quot;&gt;la funcionalidad más barata es la que no se construye&lt;/a&gt;. Un puerto es una funcionalidad. Un agregado también, un objeto de valor también, una capa anticorrupción también. Cada uno cuesta escribirlo, cuesta leerlo a quien llegue después, y cuesta mantenerlo mientras el código siga vivo. Una abstracción no es gratis por ser inmaterial.&lt;/p&gt;
&lt;p&gt;La pregunta útil no es, por tanto, «¿esto es DDD correcto?». Es: ¿qué compra esta abstracción hoy y qué se limita a aplazar? Un repositorio detrás de una interfaz compra la posibilidad de cambiar de almacenamiento — no vamos a dejar PostgreSQL. Compra también pruebas sin base de datos, y eso sí es un beneficio real: es el único argumento que quizá nos haga pagar la factura algún día.&lt;/p&gt;
&lt;p&gt;Lo que hicimos en su lugar cabe en una frase: entregar, y conservar solo las fronteras que se pagan solas. El bus de comandos cuesta tres archivos y sirve el mismo día. Los puertos cuestan una capa entera y quizá sirvan, más adelante, a un equipo que todavía no existe.&lt;/p&gt;
&lt;p&gt;El riesgo de esta postura es conocido, y escribirlo forma parte del precio: se parece muchísimo a la pereza. La diferencia está en un solo detalle — sabemos nombrar lo que no construimos, y por qué.&lt;/p&gt;
&lt;p&gt;Y lo asumimos. Estas decisiones son nuestras, no accidentes que descubriríamos al releer. Las corregimos cuando podemos y cuando tenemos tiempo: el día en que una molestia se vuelve real y medible, no el día en que un artículo de arquitectura nos explica que son incorrectas. Una deuda escrita, fechada y argumentada no es una deuda negada: es la única que uno es capaz de devolver en el momento adecuado.&lt;/p&gt;
&lt;h2&gt;Lo que dio de sí&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Nueve contextos con nombre&lt;/strong&gt;, cuyo acoplamiento mutuo se lee en las importaciones y no en un diagrama caducado.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Veintinueve comandos&lt;/strong&gt;, cada uno con un nombre de intención, un manejador único garantizado en el arranque y una frontera de transacción evidente.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Un único manejador de consultas&lt;/strong&gt;, asumido: las lecturas pasan por los repositorios.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Doce archivos de dominio&lt;/strong&gt; — nueve excepciones, dos enumeraciones, un rol — es decir, ninguna regla de negocio realmente aislada de Doctrine.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cero reescrituras&lt;/strong&gt; del código clásico existente, que sigue funcionando al lado.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Buenas prácticas&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Tratar cada abstracción como una funcionalidad: tiene que decir qué compra hoy, no qué permitiría algún día.&lt;/li&gt;
&lt;li&gt;Adoptar las piezas por separado. El bus de comandos aporta algo sin el resto; los puertos, los agregados y los objetos de valor son compras distintas, cada una con su factura.&lt;/li&gt;
&lt;li&gt;Elegir el valor por defecto más estricto: el bus por defecto es el que muta el estado, y la ausencia de manejador hace fallar el arranque en lugar de la producción.&lt;/li&gt;
&lt;li&gt;Usar los espacios de nombres como detector de acoplamiento y no como barrera: no protegen de nada, hacen visible.&lt;/li&gt;
&lt;li&gt;Escribir la regla de migración con su criterio de parada. Sin él, «homogeneizar la arquitectura» es un trabajo infinito.&lt;/li&gt;
&lt;li&gt;Mirar qué contiene realmente &lt;code&gt;Domain/&lt;/code&gt; antes de decir que se hace DDD. El recuento es instructivo.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Puntos de vigilancia&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;La carpeta no es la frontera.&lt;/strong&gt; Crear &lt;code&gt;Domain/&lt;/code&gt;, &lt;code&gt;Application/&lt;/code&gt;, &lt;code&gt;Infrastructure/&lt;/code&gt; da una sensación reconfortante de avance medible, y medir una arquitectura por su número de carpetas es la forma más segura de no obtener ninguna de las garantías que sugieren.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Veinticuatro de treinta y ocho manejadores dependen del &lt;code&gt;EntityManager&lt;/code&gt;&lt;/strong&gt;: ninguno puede probarse con un repositorio en memoria. La batería de pruebas necesita una base de datos real. Es un coste, está asumido, no es gratis.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Las entidades compartidas de Doctrine son el acoplamiento real&lt;/strong&gt;, y es invisible en el árbol de carpetas. El día en que dos contextos quieran que la misma tabla diverja, ese será el muro, no la ausencia de puertos.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Una regla de negocio duplicada en dos dialectos de consulta acabará divergiendo&lt;/strong&gt;, y hoy nada lo detectaría. Es la deuda más concreta de todo lo anterior.&lt;/li&gt;
&lt;/ul&gt;
</content>
    </entry>
        <entry>
        <title>Éramos nuestro propio mejor cliente. Era un fallo.</title>
        <link href="https://mesa.black/es/nuestro-propio-mejor-cliente/"/>
        <id>https://mesa.black/es/nuestro-propio-mejor-cliente/</id>
        <updated>2026-09-30T00:00:00+00:00</updated>
        <summary>Nuestra empresa publica en nuestra plataforma, y se contaba a sí misma en la facturación, en el embudo y en las listas de seguimiento. Cómo la sacamos de las estadísticas sin sacarla del sitio.</summary>        <content type="html">&lt;p&gt;Todo el mundo recomienda usar el propio producto. Nadie advierte del efecto secundario: en cuanto tu empresa tiene una cuenta, entra en tus cifras. No en una nota al pie, sino en la facturación, en la tasa de conversión, en la lista de clientes a los que hay que llamar.&lt;/p&gt;
&lt;p&gt;Nuestro caso: BlackMesa publica casos reales en &lt;a href=&quot;https://showmetherex.com/es/&quot;&gt;Show me the REX&lt;/a&gt;. La cuenta es real, el plan es real, los artículos se leen. Lo que no es real son los ingresos: no nos facturamos a nosotros mismos. Resultado: un cliente de cero euros figuraba en el MRR, inflaba el reparto por planes y ocupaba una casilla del embudo de conversión sin haber convertido jamás nada.&lt;/p&gt;
&lt;p&gt;La corrección evidente — «excluimos nuestra empresa» — es falsa. Da por supuesto que el problema es la empresa. El problema está en otro sitio.&lt;/p&gt;
&lt;h2&gt;No es «a quién excluir», es «qué responde esta cifra»&lt;/h2&gt;
&lt;p&gt;El reflejo es buscar una lista de entidades que apartar. La pregunta correcta se plantea métrica a métrica: ¿de qué pregunta es respuesta esta cifra?&lt;/p&gt;
&lt;p&gt;Un contador de visualizaciones responde a «cuánta gente ha leído este texto». La respuesta es la misma venga el lector de nuestras oficinas o de cualquier otro sitio: la página se sirvió, se leyó, el contenido existe. Excluir nuestras visualizaciones volvería falsa esa cifra.&lt;/p&gt;
&lt;p&gt;La facturación mensual responde a «cuánto nos pagan nuestros clientes». Nuestra propia empresa no es un cliente. Su presencia vuelve falsa esa cifra.&lt;/p&gt;
&lt;p&gt;Ambas métricas miran la misma base de datos, a veces la misma fila, y necesitan reglas opuestas. No es una excepción que gestionar, es la regla: &lt;strong&gt;la unidad de exclusión no es el dato, es la pregunta formulada.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;El caso que zanja: una visualización que cuenta y no cuenta&lt;/h2&gt;
&lt;p&gt;El mejor ejemplo es también el más incómodo, porque descarta cualquier atajo de implementación.&lt;/p&gt;
&lt;p&gt;En la plataforma, la visualización de un caso alimenta dos cosas distintas. Por un lado la audiencia: el contador que aparece en el artículo, el acumulado del panel, la curva de tráfico. Por otro, una señal comercial: «qué empresas han consultado tus casos», que sirve para identificar contactos con los que retomar el hilo.&lt;/p&gt;
&lt;p&gt;Mismo evento, mismo registro. En el primer grupo, una lectura hecha desde nuestras oficinas cuenta: alguien ha leído de verdad. En el segundo no debe contar de ninguna manera: no somos un cliente potencial al que llamar, y un lector interno dentro de una lista de leads es una acción comercial disparada para nada.&lt;/p&gt;
&lt;p&gt;No existe, por tanto, un filtro global que se ponga una sola vez en la entrada. Cada consulta tiene que saber a qué familia pertenece.&lt;/p&gt;
&lt;h2&gt;Una marca en la base de datos, no una constante en el código&lt;/h2&gt;
&lt;p&gt;Primera versión, la más rápida: una lista de nombres de empresa escrita en el código. Duró una hora. Una constante obliga a desplegar para reclasificar una empresa y, sobre todo, miente sobre la naturaleza de la información: «esta empresa no es un cliente» es un dato de negocio, cambia, se decide, y tiene que estar a la vista de quien administra las cuentas.&lt;/p&gt;
&lt;p&gt;Así que se convirtió en una casilla en la ficha de empresa, con su texto de ayuda explícito, porque una opción cuyo alcance nadie entiende acaba marcada al azar:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Nuestras propias empresas publican y siguen visibles en el sitio público, pero no cuentan en ningún dato comercial ni de marketing: MRR, planes, embudo, upsell/abandono, leads, resúmenes. Las visualizaciones y las visitas sí se siguen contando.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;La lista de nombres no desapareció: sirve de arranque, para que un entorno recién instalado no empiece nunca con cifras contaminadas. Pero ya no decide nada.&lt;/p&gt;
&lt;p&gt;El predicado en sí vive en una única clase pequeña, en dos versiones: una para las consultas del ORM y otra para el SQL en crudo. Parece anecdótico; es lo que hace la regla auditable. Encontrar los sitios que la aplican se convirtió en una búsqueda de texto, y contrastarla con la lista de cifras comerciales se hace a ojo en un minuto.&lt;/p&gt;
&lt;h2&gt;Verificar en lugar de releer&lt;/h2&gt;
&lt;p&gt;Releer el propio código de exclusión no demuestra nada: uno relee lo que cree haber escrito. La única verificación que vale consiste en cambiar la casilla y comparar los paneles antes y después, cifra por cifra.&lt;/p&gt;
&lt;p&gt;Una treintena de valores se movieron. Siete se quedaron rigurosamente idénticos, y ese era el resultado esperado: son los contadores de audiencia y los volúmenes de contenido publicado. Una cifra que se mueve cuando no debía es un fallo; una cifra que no se mueve cuando debía es un olvido. Sin ese paso no habríamos distinguido ninguno de los dos.&lt;/p&gt;
&lt;p&gt;El resultado final: una marca, una clase de política, treinta y cuatro predicados repartidos en ocho archivos — MRR, planes, embudo, upsell, riesgo de abandono, seguimientos, inteligencia comercial, resúmenes mensuales, caducidad de cupones, nurturing.&lt;/p&gt;
&lt;p&gt;Este último merece una mención. Sin la exclusión, nuestra propia empresa entraba en la secuencia automática de reactivación. Nos habríamos enviado nuestros propios correos de recuperación.&lt;/p&gt;
&lt;h2&gt;Lo que dio de sí&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Un MRR sin ningún cliente de cero euros dentro&lt;/strong&gt;, y una tasa de conversión que ya no cuenta nuestro propio registro en el denominador.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Una treintena de valores corregidos&lt;/strong&gt; en los paneles comerciales, siete intencionadamente sin cambios.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cero cambios en la parte pública&lt;/strong&gt;: los casos siguen publicados, visibles, filtrables, y sus visualizaciones se siguen contando.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Una regla escrita en un solo sitio&lt;/strong&gt;, con su motivo, en lugar de un &lt;code&gt;AND&lt;/code&gt; copiado de consulta en consulta.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Una casilla&lt;/strong&gt; que permite reclasificar una empresa sin desplegar.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Buenas prácticas&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Clasificar cada métrica antes de programar el filtro: medida de audiencia o señal comercial. La respuesta determina la regla, y no es la misma para dos cifras sacadas de la misma tabla.&lt;/li&gt;
&lt;li&gt;Poner la pertenencia en la base de datos, no en una constante: es un dato de negocio, cambia y se decide.&lt;/li&gt;
&lt;li&gt;Concentrar el predicado en una sola clase aunque quepa en una línea: lo importante no es reutilizarlo, es poder encontrar todas las llamadas.&lt;/li&gt;
&lt;li&gt;Verificar cambiando la casilla: comparar las cifras antes y después es la única forma de distinguir un olvido de una decisión.&lt;/li&gt;
&lt;li&gt;Escribir el alcance en la interfaz de administración, justo donde se marca la casilla.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Puntos de vigilancia&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Nada impide que la próxima consulta olvide el predicado.&lt;/strong&gt; La regla se sostiene con la revisión de código, no con una prueba automática, y hoy esa es la debilidad principal del montaje.&lt;/li&gt;
&lt;li&gt;Una exclusión demasiado amplia es tan falsa como la ausencia de exclusión: quitar nuestras visualizaciones del contador de audiencia habría producido una mentira en el otro sentido.&lt;/li&gt;
&lt;li&gt;El día en que una empresa interna se convierta en cliente de pago de verdad, habrá que desmarcar la casilla, y nadie lo recordará. Esa decisión es humana y necesita un momento fijado para revisarla.&lt;/li&gt;
&lt;li&gt;Decir públicamente que tus estadísticas excluyen tus propios datos no cuesta nada, y es mejor que descubrirlo en boca de otro.&lt;/li&gt;
&lt;/ul&gt;
</content>
    </entry>
        <entry>
        <title>La funcionalidad más barata es la que no se construye</title>
        <link href="https://mesa.black/es/el-codigo-que-no-escribimos/"/>
        <id>https://mesa.black/es/el-codigo-que-no-escribimos/</id>
        <updated>2026-09-27T00:00:00+00:00</updated>
        <summary>Tres señales que dicen «no escribas este código», y lo que nos ahorraron en un solo día.</summary>        <content type="html">&lt;p&gt;La sobriedad digital casi siempre se plantea como un problema de optimización: imágenes más
ligeras, mejor caché, una región más limpia. Todo es cierto, y todo es marginal. El
desperdicio real está en otra parte, y nadie lo mide: &lt;strong&gt;el código escrito para nada&lt;/strong&gt;. Una
migración que rehacer, una herramienta comprada y abandonada, una funcionalidad entregada que
nadie abre. Queda la pregunta que nunca habíamos formulado: ¿cómo se &lt;em&gt;decide&lt;/em&gt; no construir
algo?&lt;/p&gt;
&lt;p&gt;El caso expone las &lt;strong&gt;tres señales&lt;/strong&gt; que nos dimos, y lo que cada una ahorró en una jornada de
trabajo corriente: una funcionalidad rota de la que nadie se quejó — información sobre la
funcionalidad, no sobre el fallo; 1.400 traducciones que no escribimos porque ningún lector
podía alcanzarlas; y una carencia de supervisión que resultó ser una carencia de ensayo, no
de herramienta.&lt;/p&gt;
&lt;p&gt;También dice lo que esas señales &lt;strong&gt;no&lt;/strong&gt; significan. «Nadie lo necesita» es la excusa perfecta
para no hacer nada, y una regla que solo sabe decir que no ha dejado de ser una regla. Dos
salvaguardas la mantienen honesta — entre ellas esta: una funcionalidad sin uso a veces está
mal expuesta, no es inútil.&lt;/p&gt;
</content>
    </entry>
        <entry>
        <title>Subir el SO bajo Docker: lo que sigue acoplado y lo que nada valida</title>
        <link href="https://mesa.black/es/subir-el-so-bajo-docker/"/>
        <id>https://mesa.black/es/subir-el-so-bajo-docker/</id>
        <updated>2026-09-27T00:00:00+00:00</updated>
        <summary>La aplicación vive en una imagen, así que la distribución no puede tocarla. Quedan cuatro puntos de acoplamiento, y uno de ellos está en un punto ciego que ningún pipeline cubre.</summary>        <content type="html">&lt;p&gt;&lt;a href=&quot;https://showmetherex.com/es/&quot;&gt;Show me the REX&lt;/a&gt; funciona en un único anfitrión: un Postgres, un Redis, un proxy frontal y la aplicación desplegada en blue-green — dos instancias idénticas, «azul» y «verde», de las que solo una sirve tráfico a la vez. La nueva versión se arranca en la que está en reposo y, cuando responde bien, se conmuta el proxy. Cuando escribimos esto, ese anfitrión sirve 52 casos publicados y algo más de 16.000 visualizaciones acumuladas.&lt;/p&gt;
&lt;p&gt;Este montaje protege una entrega: si la nueva versión se porta mal, volver atrás lleva un segundo y nadie se entera. No protege nada frente a la máquina misma, porque las dos instancias viven en ese mismo anfitrión — si se para, se paran las dos.&lt;/p&gt;
&lt;p&gt;El anfitrión único es una decisión asumida, no un descuido: sin redundancia mientras el tráfico no la justifique, y con el umbral de revisión por escrito — mil visitas al día. Por debajo, una segunda máquina cuesta mucho más en complejidad (replicación, conmutación, coherencia) de lo que aporta en disponibilidad, y la complejidad que nadie necesita todavía es lo más caro que se puede construir.&lt;/p&gt;
&lt;p&gt;Pero asumir esa decisión crea una obligación. Si aceptas que un reinicio corta el servicio, te debes una cifra exacta de lo que cuesta. Eso es lo que hicimos mal esa noche, y volvemos sobre ello al final.&lt;/p&gt;
&lt;p&gt;Subimos ese anfitrión de una versión de Ubuntu a la siguiente. La operación salió bien, y eso no es lo interesante. Lo interesante es que la contenerización ha reducido lo que una subida así puede romper a una lista muy corta, y que encontramos justo el elemento de esa lista que nada en nuestra cadena vigilaba.&lt;/p&gt;
&lt;h2&gt;Lo que la contenerización desacopló de verdad&lt;/h2&gt;
&lt;p&gt;La aplicación es inmune a una subida de distribución, y conviene precisar por qué: no usa nada del anfitrión. Su PHP, sus extensiones, sus bibliotecas de sistema y su almacén de certificados viajan dentro de la imagen. Se puede sustituir por completo el &lt;code&gt;ca-certificates&lt;/code&gt; del anfitrión: nuestras llamadas salientes a VIES, a la pasarela de pago y al almacenamiento de objetos no se enteran, porque nunca leen ese almacén.&lt;/p&gt;
&lt;p&gt;Por eso los equipos han acabado tratando una subida de SO en un anfitrión de contenedores como rutina. Es casi cierto, y es ese «casi» el que cuesta.&lt;/p&gt;
&lt;h2&gt;Los cuatro puntos de acoplamiento que quedan&lt;/h2&gt;
&lt;p&gt;Cuando ya has metido en imágenes todo lo que se podía, lo que sigue perteneciendo al anfitrión es una lista corta, y es la misma en cualquier servidor contenerizado:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;El núcleo.&lt;/strong&gt; Los contenedores lo comparten. Un salto mayor cambia el suelo bajo una base de datos mucho más que bajo un proceso web.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;El demonio Docker.&lt;/strong&gt; No está en tu imagen: es un paquete del anfitrión, instalado desde un repositorio de terceros.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Las fuentes apt de ese demonio.&lt;/strong&gt; Lo que decide si el punto anterior volverá a recibir algún parche de seguridad.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;El contrato de reinicio.&lt;/strong&gt; Qué contenedores vuelven solos tras un arranque y cuáles, deliberadamente, no — la instancia en reposo, por ejemplo, debe quedarse abajo, o dos versiones de la aplicación se pelearían por la misma base de datos.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Nada más importa realmente. La lista es lo bastante corta como para revisarla a mano, antes y después — lo que hace imperdonable no revisarla.&lt;/p&gt;
&lt;h2&gt;El único daño duradero cayó en el punto tres&lt;/h2&gt;
&lt;p&gt;Una subida de versión desactiva o elimina las fuentes apt de terceros. No es un fallo: esas fuentes están compiladas para la versión que abandonas, y mantenerlas activas durante el salto es la forma de romper el sistema. La herramienta hace bien, y lo advierte.&lt;/p&gt;
&lt;p&gt;Lo que no hace es decírtelo después. Nuestra fuente de Docker había desaparecido. Nada se rompió: el demonio siguió funcionando, los contenedores con él, el sitio sirviendo. Pero el paquete instalado estaba compilado para la distribución anterior, y ya no quedaba ningún repositorio capaz de reemplazarlo. &lt;strong&gt;El modo de fallo no es un servicio que se detiene; es un gestor de paquetes que se vuelve autoritativo y vacío a la vez.&lt;/strong&gt; Informa de que todo está al día, y dice la verdad sobre un universo que ya no puede ver.&lt;/p&gt;
&lt;p&gt;Restaurar la fuente son cuatro líneas y ningún reinicio. Reveló de inmediato siete versiones menores de deriva acumuladas en silencio. Nada habría levantado la mano jamás: ni el demonio, que funciona; ni la monitorización que no tenemos; ni &lt;code&gt;apt&lt;/code&gt;, que ya no tenía con qué comparar.&lt;/p&gt;
&lt;h2&gt;El punto ciego: los ficheros compose&lt;/h2&gt;
&lt;p&gt;Y llegamos al hallazgo estructural, que es el que merece llevarse.&lt;/p&gt;
&lt;p&gt;Nuestro servicio de base de datos no tenía política de reinicio. Tras un arranque, todos los demás contenedores habrían vuelto y Postgres no. El fallo es trivial: falta una línea. Su vida útil no lo es: llevaba meses ahí, y solo podía manifestarse en un reinicio del anfitrión, que no había ocurrido en todo ese tiempo.&lt;/p&gt;
&lt;p&gt;La pregunta buena no es cómo se escribió. Es por qué nada lo detectó. Y la respuesta se generaliza mucho más allá de nuestro montaje: &lt;strong&gt;los ficheros compose son la única configuración de producción que nadie posee.&lt;/strong&gt; No se cuecen en la imagen, así que la construcción nunca los ve. Ningún test los ejercita, porque los tests corren contra la aplicación, no contra la topología del anfitrión. Y nuestro job de despliegue ni siquiera los copia: abre una sesión SSH y lanza un script. Se editan en el repositorio, se aplican a mano y no los valida nada.&lt;/p&gt;
&lt;p&gt;En una cadena por lo demás totalmente automatizada —tests, análisis estático, construcción de imagen, despliegue sin corte— ahí es donde un defecto puede dormir indefinidamente. No en el código que el pipeline lee veinte veces al día, sino en el puñado de líneas YAML que no abre nunca.&lt;/p&gt;
&lt;h2&gt;Lo que el núcleo pudo romper, y lo que no verificamos&lt;/h2&gt;
&lt;p&gt;Un salto mayor de núcleo bajo un Postgres contenerizado merece algo más que «volvió a arrancar». Hay tres preguntas que hacerse, y solo podemos responder a dos:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;El directorio de datos.&lt;/em&gt; Vive en un volumen con nombre, en el mismo sistema de ficheros, con el mismo driver de almacenamiento. Nada se movió, y esa es la razón de que la subida fuera superable, no la suerte.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Los perfiles de confinamiento del demonio.&lt;/em&gt; Los valores por defecto de seccomp y AppArmor vienen con el paquete Docker, no con la distribución, y por eso los contenedores encontraron el mismo entorno al otro lado.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;La semántica de durabilidad.&lt;/em&gt; Si un núcleo nuevo cambia algo para Postgres con nuestras opciones de montaje es una pregunta que no respondimos. Aguantó, lo que no prueba nada. Lo escribimos en lugar de reclamar una verificación que nunca hicimos.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;La medición ya existía&lt;/h2&gt;
&lt;p&gt;Queda la obligación planteada en la introducción: conocer el coste real del corte que decidimos aceptar. Dimos una cifra leyendo los registros de los contenedores: el intervalo entre el último error de un proceso y el arranque del siguiente. Ese número describe el anfitrión, no a los visitantes. Deja fuera el apagado anterior y el calentamiento posterior, y puede errar por un factor de tres.&lt;/p&gt;
&lt;p&gt;Mientras tanto, todo nuestro tráfico pasa por una CDN que ya había registrado, petición a petición, exactamente lo que recibió la gente en esa ventana. &lt;strong&gt;La medición que creíamos que nos faltaba ya la habían tomado por nosotros, en un aparato que pagábamos y nunca habíamos consultado.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;El reflejo a corregir no es «añadir instrumentación». Es inventariar lo que ya mide antes de añadir nada: la CDN, los registros del propio proxy, la consola del proveedor. Nuestros tropiezos de procedimiento de esa noche —un nombre de contenedor adivinado, variables de shell que no sobreviven a una reconexión SSH, un grep tan amplio que informó de 41 líneas para 2 errores— son todos de la misma familia y valen exactamente una frase: un runbook resuelve lo que necesita, nunca lo congela. Qué instancia está sirviendo es el caso de manual: cambia en cada despliegue.&lt;/p&gt;
&lt;h2&gt;Por qué lo publicamos todo — y lo único que nos callamos&lt;/h2&gt;
&lt;p&gt;Un caso solo sirve si es concreto, así que este lleva las órdenes, los modos de fallo y las lagunas. Lo que plantea una pregunta legítima: ¿publicar todo eso no es entregarle un mapa a un atacante?&lt;/p&gt;
&lt;p&gt;Nuestra regla cabe en una frase. &lt;strong&gt;El problema nunca es nombrar una versión. Es nombrar una versión en la que sigues siendo vulnerable.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;«Estábamos en la versión X, nos costó esto, ya está corregido» es práctica habitual de post-mortem. El mismo texto publicado antes de la corrección es una debilidad todavía vigente con la diana puesta: un caso va firmado, nombra una empresa y un dominio. Así que primero corregir, después contar, y generalizar lo que no enseña nada. La &lt;em&gt;distancia&lt;/em&gt; —siete versiones de deriva silenciosa— es la lección; la cadena de versión exacta de nuestro servidor no lo es. Lo que nunca se publica es la otra categoría, la que no se aprende pero sí se copia: nombres de máquina, rutas, la cadena de claves que descifra una copia. La frontera no es «sensible o inofensivo», es &lt;strong&gt;¿esto enseña, o esto abre?&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;Lo que dio de sí&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cuatro puntos de acoplamiento&lt;/strong&gt; aislados entre una aplicación contenerizada y su anfitrión: núcleo, demonio, fuentes apt del demonio, contrato de reinicio. Lo bastante pocos para revisarlos a mano, antes y después.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Una vía de actualización congelada, restaurada&lt;/strong&gt;: el demonio había derivado siete versiones menores sin que nada pudiera señalarlo.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Un punto ciego estructural, nombrado&lt;/strong&gt;: los ficheros compose, la única configuración de producción que ninguna construcción, ningún test y ningún despliegue llegan a leer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Una medición recuperada en lugar de construida&lt;/strong&gt;: la CDN ya había registrado lo que vieron los visitantes, gratis.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Buenas prácticas&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Escribe tus puntos de acoplamiento una vez. En un anfitrión de contenedores son cuatro, siempre los mismos, y revisarlos lleva diez minutos.&lt;/li&gt;
&lt;li&gt;Tras una subida de versión, revisa primero las fuentes de terceros: un gestor de paquetes sin nada con qué comparar informa de que todo está al día, y no miente.&lt;/li&gt;
&lt;li&gt;Busca lo que tu automatización no posee. En una cadena totalmente automatizada, el defecto que sobrevive está en el fichero que el pipeline no abre nunca.&lt;/li&gt;
&lt;li&gt;Inventaría lo que ya mide —CDN, proxy, consola del proveedor— antes de añadir instrumentación. La cifra que te falta suele estar ya registrada.&lt;/li&gt;
&lt;li&gt;Un runbook resuelve, nunca congela: qué instancia está sirviendo, su nombre de contenedor y el id del servicio se piden en el momento.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Puntos de vigilancia&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;El fallo peligroso no es el servicio que se detiene, es el que sigue funcionando mientras pierde su capacidad de ser actualizado. No emite nada.&lt;/li&gt;
&lt;li&gt;«Volvió a arrancar» no prueba nada sobre durabilidad. Escribe las preguntas que no respondiste en vez de reclamar una verificación que no hiciste.&lt;/li&gt;
&lt;li&gt;Un defecto que solo puede manifestarse en un reinicio del anfitrión vive lo que dura el intervalo entre dos reinicios. En un servidor que se porta bien, son meses.&lt;/li&gt;
&lt;li&gt;Un registro interno dice cuándo murió un proceso, nunca cuándo dejaron de ser servidos los visitantes. Eso solo lo dice una medición tomada por delante de la pila.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Corrige antes de contar: un caso que describe una debilidad todavía abierta, firmado con tu nombre, no es transparencia: es un manual de instrucciones.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
</content>
    </entry>
        <entry>
        <title>Construir un SaaS completo y listo para producción con un agente de código</title>
        <link href="https://mesa.black/es/un-saas-completo-con-un-agente-de-codigo/"/>
        <id>https://mesa.black/es/un-saas-completo-con-un-agente-de-codigo/</id>
        <updated>2026-09-26T00:00:00+00:00</updated>
        <summary>Despliegue sin corte, pagos reales, acceso sin contraseña — en pareja con una IA.</summary>        <content type="html">&lt;p&gt;Cómo diseñamos, aseguramos y desplegamos una plataforma entera — conmutación blue-green sin
corte, domiciliaciones reales, acceso con passkey, sitio trilingüe, copias de seguridad
cifradas — trabajando en pareja con un agente de código.&lt;/p&gt;
&lt;p&gt;El caso detalla la arquitectura elegida, lo que el agente aceleró de verdad y, sobre todo,
los puntos en los que hubo que retomar el control.&lt;/p&gt;
</content>
    </entry>
        <entry>
        <title>Garantizar casos legítimos sin equipo de moderación</title>
        <link href="https://mesa.black/es/legitimidad-sin-moderacion/"/>
        <id>https://mesa.black/es/legitimidad-sin-moderacion/</id>
        <updated>2026-09-25T00:00:00+00:00</updated>
        <summary>Apostar por la identidad verificada en lugar de la censura, y los dos fallos que nos hicieron dudar.</summary>        <content type="html">&lt;p&gt;Una plataforma de casos vale exactamente lo que vale la confianza depositada en lo que allí
se publica. Un solo caso falso y la credibilidad de todo el corpus se tambalea. La pregunta
que nos ocupaba: ¿cómo garantizar la legitimidad de cada publicación, a escala, sin montar
un equipo de moderación — y sin delegar el filtrado en una IA, que juzga la forma de un
texto y no la legitimidad de quien lo publica?&lt;/p&gt;
&lt;p&gt;Nuestra respuesta sustituye la moderación del contenido por la &lt;strong&gt;verificación del emisor&lt;/strong&gt;:
sin empresa, no hay publicación; se exige una sustancia mínima; el número de IVA
intracomunitario se valida en el registro europeo; y todo queda trazado.&lt;/p&gt;
&lt;p&gt;El caso cuenta además las &lt;strong&gt;dos trampas&lt;/strong&gt; que encontramos por el camino, ambas de la misma
familia: un servicio de terceros que responde &lt;code&gt;200 OK&lt;/code&gt; sin haber podido verificar nada, y
después un estado «desconocido» que en realidad cubría dos situaciones opuestas — «no he
podido verificar ahora» y «nunca podré verificar este valor». La segunda dejó pasar
cualquier cadena de caracteres durante meses.&lt;/p&gt;
</content>
    </entry>
    </feed>
