Saltar al contenido
Módulo 7 de 7Avanzado

Arquitectura viva: errores en los límites, fitness functions y ADRs

La misma flecha con tres nombres — hexagonal, Onion, Clean — y lo que hace falta para sostenerla en producción: un crate por capa, errores traducidos en cada frontera, tests que vigilan la arquitectura y decisiones documentadas. Con fragmentos reales de un sistema de firma electrónica.

Llegaste al último módulo con el hexágono completo: sabes qué es un puerto, quién conduce a quién, dónde se cablea todo, cómo se prueba y con qué se modela el interior. Falta lo más difícil: mantener viva la arquitectura cuando el proyecto crece, el equipo rota y las prisas aprietan. En este módulo la teoría se encuentra con la práctica: casi todos los fragmentos de código que verás provienen de un sistema real de firma electrónica y evidencia digital escrito en Rust, tal como corre en producción. Cafetería fuera; criptografía dentro. Verás que las formas son idénticas.

Tres nombres para la misma flecha

Antes de entrar al código, ordenemos a la familia. Después de Cockburn (2005) llegaron dos reformulaciones célebres. Jeffrey Palermo publicó en 2008 la Onion Architecture, círculos concéntricos con el modelo de dominio al centro y una sentencia inolvidable:

“The database is not the center. It is external.”

Robert C. Martin sintetizó todo en 2012 como Clean Architecture, gobernada por una única regla de dependencia:

“Source code dependencies can only point inwards. Nothing in an inner circle can know anything at all about something in an outer circle.”

El propio Martin reconoce que su diagrama unifica enfoques previos — hexagonal, Onion, su Screaming Architecture (“si tu arquitectura se basa en frameworks, no puede basarse en tus casos de uso”), DCI y BCE — porque “all have the same objective, which is the separation of concerns”.

Aspecto Hexagonal (Cockburn, 2005) Onion (Palermo, 2008) Clean (Martin, 2012)
Diagrama Hexágono (forma arbitraria) Círculos concéntricos Círculos concéntricos, 4 capas nombradas
Foco Simetría entrada/salida vía puertos El modelo de dominio como centro Dependency Rule + separación Entities/Use Cases
Vocabulario Puerto, adaptador, actor primario/secundario Domain Model, capas de servicios Entities, Use Cases, Interface Adapters, Frameworks
Relación El patrón origen Relee hexagonal con énfasis en DDD Sintetiza hexagonal + Onion + Screaming + DCI + BCE

La conclusión pedagógica importa más que la tabla: no son tres arquitecturas para elegir, sino tres formulaciones históricas del mismo principio — invertir la dependencia hacia el dominio. Si dominas una, dominas las tres.

Un crate por capa: el compilador como arquitecto

¿Cómo se ve esa flecha en un proyecto Rust de verdad? El sistema de firma electrónica que nos acompaña este módulo es un workspace de Cargo con un crate por capa — domain, application, infrastructure y un binario que actúa de composition root, el único lugar donde las tres capas coinciden. Explora cómo se reparten los conceptos del hexágono entre esas cajas:

  • DominioDominioSha256DigestSealedPayloadSignedDigest

    Entidades y value objects del sistema de firma: puro negocio, sin E/S.

  • PuertosPuertosClockAuthenticatedCipherDocumentSignerSignatureVerifier
  • AdaptadoresAdaptadoresRingAesGcmCipher
  • InfraestructuraInfraestructuramain — composition roottests/architecture.rs — fitness function

Fíjate en el matiz que el propio diagrama expone: el crate domain contiene dos colores conceptuales a la vez —entidades y puertos— porque Cargo organiza por crate, no por capa hexagonal; el color sigue siempre al concepto, nunca al nombre del archivo. La gracia del arreglo es que la regla de dependencia deja de ser una convención: si domain no declara a infrastructure en su manifiesto, el código que intente esa importación ni siquiera compila. La fitness function la impone el build.

Dentro de domain, cada puerto es un trait pequeño y de una sola responsabilidad. El más pequeño de todos:

domain/clock.rs — el puerto más pequeño posiblePuertos
/// Source of the current instant.
pub trait Clock {
    /// Returns the current instant.
    fn now(&self) -> OffsetDateTime;
}

Una interfaz de una sola función que el dominio necesita (“¿qué hora es?”) pero no sabe ni le importa cómo se resuelve. Cualquier adaptador —el reloj del sistema en producción, un reloj congelado en pruebas— puede implementarlo. Si el módulo 2 te enseñó qué es un puerto, este es el puerto reducido a su esencia.

El adaptador que cumple el contrato

Entre los puertos del dominio hay uno de cifrado autenticado, AuthenticatedCipher, con dos métodos: seal (cifrar y autenticar) y open (descifrar verificando). El material de claves cruza la interfaz como bytes planos y el resultado viaja en un value object del dominio (SealedPayload), de modo que el dominio nunca importa una biblioteca criptográfica. Del otro lado de la frontera, el adaptador lo implementa con la crate ring:

infrastructure/encryption.rs — el adaptador que cumple el contratoAdaptadores
impl AuthenticatedCipher for RingAesGcmCipher {
    fn seal(&self, key: &[u8], aad: &[u8], plaintext: &[u8]) -> Result<SealedPayload, DomainError> {
        let mut nonce = [0u8; AES_GCM_NONCE_LEN];
        self.rng
            .fill(&mut nonce)
            .map_err(|_| CryptoError::Backend("csprng failed to produce a nonce".to_string()))?;
        Ok(self.seal_with_nonce(key, &nonce, aad, plaintext)?)
    }

    fn open(
        &self,
        key: &[u8],
        aad: &[u8],
        payload: &SealedPayload,
    ) -> Result<Vec<u8>, DomainError> {
        let key = build_key(key)?;
        let nonce = Nonce::try_assume_unique_for_key(payload.nonce())
            .map_err(|_| CryptoError::DecryptionFailed)?;
        let mut in_out = payload.ciphertext_and_tag().to_vec();
        let plaintext_len = key
            .open_in_place(nonce, Aad::from(aad), &mut in_out)
            .map_err(|_| CryptoError::DecryptionFailed)?
            .len();
        in_out.truncate(plaintext_len);
        Ok(in_out)
    }
}

Observa el cableado 1:1 entre la firma del puerto y la firma del impl, y un detalle que será el tema central en un momento: el error de la biblioteca externa (ring devuelve un error opaco) se traduce a un tipo de error propio de la capa (CryptoError) antes de cruzar de vuelta hacia el dominio.

Inyección por genéricos: el caso de uso que no conoce a nadie

En la capa de aplicación, los casos de uso reciben sus puertos por inyección de constructor — con genéricos:

application/signing.rs — un caso de uso genérico sobre dos puertosDominio
/// Streams a document through the hashing port and signs its digest.
pub struct SignDocument<H, S> {
    hasher: H,
    signer: S,
}

impl<H: DocumentHasher, S: DocumentSigner> SignDocument<H, S> {
    /// Builds the use case over a hashing port and a signing port.
    pub fn new(hasher: H, signer: S) -> Self {
        Self { hasher, signer }
    }

    /// Digests `reader` to the end and signs the digest.
    pub fn execute(&self, reader: &mut dyn Read) -> Result<SignedDigest, ApplicationError> {
        let digest = self.hasher.hash_stream(reader)?;
        let signature = self.signer.sign(&digest)?;
        Ok(SignedDigest { digest, signature })
    }
}

Recorre la ejecución paso a paso — y fíjate en el color de cada caja: sigue al concepto, no al crate donde vive el archivo:

  1. reader: &mut dyn ReadEl documento entra como un stream cualquiera: el caso de uso no sabe si viene de un archivo o de la red.
  2. Puertoshasher.hash_stream(reader)Primer puerto: DocumentHasher consume el stream completo y produce el digest.
  3. Dominiodigest: Sha256DigestUn value object del dominio: puro valor, sin rastro de la biblioteca criptográfica.
  4. Puertossigner.sign(&digest)Segundo puerto: DocumentSigner firma el digest, sin decir con qué.
  5. DominioSignedDigest { digest, signature }El resultado del caso de uso: digest y firma juntos, como valor.

SignDocument es genérico sobre cualquier H que implemente DocumentHasher y cualquier S que implemente DocumentSigner: no sabe si el hash lo calcula ring o un doble de prueba. Aquí conviene detenerse en un trade-off clásico de Rust. Con genéricos, el compilador monomorfiza cada combinación: despacho estático, cero costo en tiempo de ejecución, chequeo exhaustivo al compilar. Con trait objects (&dyn Clock, Box<dyn DocumentSigner>) obtienes flexibilidad en tiempo de ejecución — puedes decidir el adaptador por configuración — a cambio de una indirección. Este sistema usa ambos con criterio: genéricos en los casos de uso, y &dyn Trait donde la flexibilidad importa más que el rendimiento. No hay una “forma correcta” única; hay un trade-off que ahora sabes nombrar.

Probar la orquestación en milisegundos

¿Recuerdas la promesa del módulo 5 — probar el núcleo sin tecnología real? El truco tampoco cambió: otro arranque que elige otro adaptador para el mismo puerto. Intercámbialo:

El caso de uso SignDocument<H, S> habla con el puertoPuertosDocumentSigner y no cambia con ninguna de estas opciones:

Firmador RSA de producción
Firma con la biblioteca criptográfica real — lento, y determinista solo si la clave es fija.
MockSigner
Devuelve la firma fija declarada con expect_sign() — milisegundos, sin criptografía real.

Así se cobra esa promesa en producción, con mockall generando dobles de los puertos (en el test, digest() y signature() son funciones auxiliares que construyen valores fijos, y stream_hasher arma un MockHasher que espera el contenido del stream):

application/signing.rs (tests) — la orquestación verificada con mocksDominio
mock! {
    Hasher {}

    impl DocumentHasher for Hasher {
        fn hash_bytes(&self, data: &[u8]) -> Sha256Digest;
        fn hash_stream(&self, reader: &mut dyn Read) -> Result<Sha256Digest, DomainError>;
    }
}

mock! {
    Signer {}

    impl DocumentSigner for Signer {
        fn sign(&self, digest: &Sha256Digest) -> Result<Signature, DomainError>;
    }
}

#[test]
fn sign_document_signs_the_digest_of_the_streamed_content() {
    let mut signer = MockSigner::new();
    signer
        .expect_sign()
        .with(eq(digest()))
        .times(1)
        .returning(|_| Ok(signature()));

    let use_case = SignDocument::new(stream_hasher(b"contract body"), signer);
    let signed = use_case.execute(&mut &b"contract body"[..]).unwrap();
    assert_eq!(signed.digest, digest());
    assert_eq!(signed.signature, signature());
}

El test comprueba que SignDocument llama a sign exactamente una vez y con el digest esperado. Es una prueba de orquestación, no de criptografía: jamás se ejecuta RSA de verdad, y por eso corre en milisegundos. Esta es la evidencia directa de que separar puertos de adaptadores compra velocidad de prueba, no solo pureza conceptual.

Errores que no cruzan la frontera sin pasaporte

Ahora, el caso de estudio de este módulo. Un equipo despliega su API y un cliente recibe esto como respuesta HTTP:

500 Internal Server Error
ERROR: duplicate key value violates unique constraint "pedidos_pkey"

Un error de SQL, crudo, en la cara del usuario — con nombre de constraint incluido. ¿Qué capa falló? La tentación es culpar al handler HTTP, pero la fuga empezó antes: el adaptador de persistencia dejó pasar el error del driver sin traducirlo al tipo de error que su puerto promete. La guía de referencia de hexagonal en Rust lo formula como principio:

“Always separate your public errors from their domain representations.”

La receta tiene dos ingredientes: cada capa tiene su propio tipo de error, y las conversiones entre ellos son explícitas, capa por capa. Compara la fuga del caso de estudio con la cadena traducida del sistema real:

La fuga

  1. Error de SQL crudo
  2. 500 Internal Server Error al cliente

Ninguna frontera pidió pasaporte: el nombre de la constraint viajó hasta la pantalla.

Fuga — el adaptador no tradujo

La cadena traducida

  1. CryptoError (infraestructura)
  2. DomainError (dominio)
  3. ApplicationError (aplicación)
  4. 422 con mensaje de negocio al cliente

Así lo hace el sistema real con thiserror:

infrastructure/error.rs — un tipo de error por capa, conversiones explícitasAdaptadores
#[derive(Debug, Error)]
pub enum CryptoError {
    #[error("authenticated decryption failed")]
    DecryptionFailed,
    #[error("invalid key material: {0}")]
    InvalidKeyMaterial(String),
    #[error("cryptographic backend failure: {0}")]
    Backend(String),
}

impl From<CryptoError> for ApplicationError {
    fn from(err: CryptoError) -> Self {
        ApplicationError::Port(err.to_string())
    }
}

/// Adapters implement domain ports, so their backend failures must surface
/// as domain errors. The decryption case maps onto the deliberately opaque
/// [`DomainError::AuthenticationFailed`].
impl From<CryptoError> for DomainError {
    fn from(err: CryptoError) -> Self {
        match err {
            CryptoError::DecryptionFailed => DomainError::AuthenticationFailed,
            CryptoError::InvalidKeyMaterial(msg) => DomainError::InvalidKeyMaterial(msg),
            CryptoError::Backend(msg) => DomainError::CryptoBackendFailure(msg),
        }
    }
}
Traducido — cada impl From documenta la frontera

CryptoError pertenece a la infraestructura, DomainError al dominio, ApplicationError a la aplicación — y jamás un “error genérico” compartido por todos. Cada impl From documenta por qué existe esa conversión: un adaptador que implementa un puerto del dominio está obligado a reportar sus fallas como el tipo de error que ese puerto promete. Nota el detalle de seguridad: la falla de descifrado se mapea a un AuthenticationFailed deliberadamente opaco, sin regalar pistas. En el extremo opuesto del hexágono, el adaptador HTTP hace su propia traducción — el panel de la derecha desemboca justamente en ese 422 con mensaje de negocio. En el caso de estudio faltaron las dos traducciones, pero la primera fuga —la original— fue del adaptador de persistencia.

Las fronteras también disciplinan a las transacciones. Si un caso de uso debe guardar una entidad y publicar un evento de forma atómica, pero persistencia y mensajería son dos puertos con dos adaptadores, la atomicidad no aparece por arte de magia: o defines un puerto explícito de unidad de trabajo (unit of work) que abstraiga “esto ocurre junto o no ocurre”, o aplicas el patrón outbox — el evento se guarda en la misma transacción que la entidad y otro proceso lo publica después. La arquitectura hexagonal no resuelve la transaccionalidad distribuida; su virtud es hacerla visible en el diseño de puertos en lugar de esconderla en un ORM.

Un rechazo no es una excepción

Hay una decisión de diseño en este sistema que merece módulo propio en miniatura. Cuando se verifica una firma electrónica y no corresponde, ¿eso es un error? El sistema responde que no:

domain/signature.rs — el rechazo es un valor, no un errorPuertos
/// Result of checking a signature against a digest and key material.
///
/// A rejected signature is a normal, expected outcome and therefore not an
/// error; the error path is reserved for backend failures.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignatureVerification {
    /// The signature matches the digest under the presented key material.
    Valid,
    /// The signature was rejected for the contained reason.
    Invalid(SignatureRejection),
}

/// Outbound port: checks signatures against digests and key material.
pub trait SignatureVerifier {
    /// Checks `signature` over `digest` under `key_material`.
    ///
    /// Rejections are reported through [`SignatureVerification::Invalid`];
    /// the error path is reserved for backend failures.
    fn verify(
        &self,
        digest: &Sha256Digest,
        signature: &Signature,
        key_material: &[u8],
    ) -> Result<SignatureVerification, DomainError>;
}

El tipo de retorno lo dice todo: Result<SignatureVerification, DomainError>. Un documento alterado o una clave equivocada son un resultado esperado del negocio — Ok(Invalid(..)) — mientras que el Err queda reservado para fallas del backend (el módulo criptográfico no responde). La máxima citable: “una firma inválida no es un error, es un resultado”. El mismo patrón se repite en la verificación de contraseñas y de códigos TOTP. Modelar los resultados esperados como valores obliga al que llama a manejarlos con un match exhaustivo; las excepciones —o los Err usados como excepciones— permiten ignorarlos.

Fitness functions: la regla que se ejecuta sola

Todo lo anterior son promesas, y las promesas se rompen a las 6 de la tarde de un viernes. La respuesta madura viene de Building Evolutionary Architectures (Neal Ford, Rebecca Parsons, Patrick Kua):

“An architectural fitness function provides an objective integrity assessment of some architectural characteristic(s).”

Una fitness function arquitectónica es un test automatizado que no verifica una funcionalidad sino una regla estructural: “ningún módulo del dominio importa infraestructura”. Estas son las flechas que la suite vigila — toca cada una para leer qué la garantiza:

  • DominioDominioSha256DigestSealedPayloadSignedDigest

    Entidades y value objects del sistema de firma: puro negocio, sin E/S.

  • PuertosPuertosClockAuthenticatedCipherDocumentSignerSignatureVerifier
  • AdaptadoresAdaptadoresRingAesGcmCipher
  • InfraestructuraInfraestructuramain — composition roottests/architecture.rs — fitness function
  • Adaptadores → Dominio — prohibida: el manifiesto de domain no declara crates de adaptadores: ese import ni compila — bloqueado en compilación
  • Adaptadores → Puertos — permitida: RingAesGcmCipher implementa AuthenticatedCipher — verificado por tests/architecture.rs
  • Dominio → Puertos — permitida: el dominio define sus traits junto a sus entidades — verificado por tests/architecture.rs
  • Infraestructura → Adaptadores — permitida: ring y las crates criptográficas son la tecnología detrás del adaptador — verificado por tests/architecture.rs

En el sistema real, esa regla corre en cada cargo test:

tests/architecture.rs — la regla de dependencia como aserción ejecutableInfraestructura
fn domain_manifest() -> String {
    let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    path.pop(); // crates/bin -> crates
    path.push("domain/Cargo.toml");
    fs::read_to_string(&path).expect("domain manifest is readable")
}

fn dependency_names(manifest: &str) -> Vec<String> {
    let mut names = Vec::new();
    let mut in_deps = false;
    for line in manifest.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with('[') {
            in_deps = trimmed == "[dependencies]";
            continue;
        }
        if !in_deps || trimmed.is_empty() || trimmed.starts_with('#') {
            continue;
        }
        if let Some((name, _)) = trimmed.split_once(['=', '.']) {
            names.push(name.trim().to_string());
        }
    }
    names
}

#[test]
fn domain_has_no_workspace_dependencies() {
    let deps = dependency_names(&domain_manifest());
    for forbidden in ["application", "infrastructure", "web"] {
        assert!(
            !deps.iter().any(|dep| dep == forbidden),
            "domain must not depend on `{forbidden}`; found it in the manifest"
        );
    }
}

La regla “el dominio no depende de nada externo” no vive en un documento ni en la cabeza del arquitecto: es una aserción que falla el build si alguien agrega infrastructure al manifiesto del dominio. El mismo sistema tiene una segunda fitness function aún más ambiciosa: un test que escanea todos los pub trait del dominio y exige que cada puerto tenga al menos un adaptador en infraestructura y un mock en el workspace — ningún puerto queda sin implementación ni sin capacidad de prueba. La arquitectura deja de ser un diagrama y se convierte en parte de la suite.

Practica: el linter de arquitectura

Toca hacer de fitness function humana. Para cada import descrito, decide si respeta la regla de dependencia o la viola. Cuidado: hay un caso con matices.

Dos lecciones de diseño, contadas por sus ADRs

Este sistema documenta sus decisiones en registros escritos, y dos de ellas enseñan más que cualquier regla mecánica.

La frontera del dominio no es “cero dependencias”. El dominio de este sistema importa una crate externa, zeroize, que garantiza que el material secreto (claves, semillas de segundo factor) se borre de la memoria al soltarse. ¿No viola eso la pureza del dominio? La decisión, justificada por escrito: la regla real nunca fue “cero dependencias”, sino cero dependencias que traigan E/S o acoplamiento a un framework. “Este valor se borra al dejar de usarse” es una propiedad de seguridad del negocio — del mismo rango que “no se modifica un pedido cerrado” —, así que expresarla en el dominio es correcto. Las reglas absolutas son fáciles de recitar; las fronteras reales exigen juicio, y el juicio se documenta.

La honestidad sobre los límites también es arquitectura. El mismo sistema mantiene una bitácora de auditoría con hashes encadenados: cada evento incorpora el hash del anterior, de modo que alterar, insertar o borrar un evento intermedio rompe la cadena. Su ADR admite sin rodeos lo que la cadena no garantiza: truncar los eventos más recientes es indetectable, porque todo prefijo de una cadena válida sigue siendo válido. Cerrar esa brecha exige un anclaje externo — algo que el dominio, sin almacenamiento ni red, deliberadamente no puede hacer. En lugar de fingir una garantía, el documento delimita cuál existe y qué pieza faltaría. Un buen dominio conoce sus teoremas y sus supuestos.

Cierra el curso: escribe tu ADR

Esas decisiones están escritas en el formato que Michael Nygard propuso en 2011: el Architecture Decision Record. Martin Fowler lo resume como:

“A short document that captures and explains a single decision relevant to a product or ecosystem […] the decision, the context for making it, and significant ramifications.”

La estructura clásica cabe en una página: título, estado (propuesta, aceptada, reemplazada), contexto (las fuerzas en juego), decisión (qué se hará y por qué) y consecuencias (lo bueno, lo malo y lo que habrá que vigilar). Un buen ADR registra también los riesgos que se aceptan y bajo qué condiciones se reconsideraría la decisión.

Y este es tu proyecto final. Toma un proyecto propio —real o por comenzar— y redacta el ADR “Adoptar arquitectura hexagonal”: qué problema de acoplamiento te duele (módulo 1), qué puertos tendría tu núcleo (módulos 2 y 3), dónde vivirá tu composition root (módulo 4), qué vas a poder probar sin infraestructura (módulo 5), qué agregados protegerán tus invariantes (módulo 6) y qué fitness function hará cumplir la regla cuando tú no estés mirando (este módulo). Si puedes escribir ese documento con contexto y consecuencias honestas —incluidas las contras: más archivos, más indirección, disciplina de traducción en cada frontera—, el curso hizo su trabajo. El hexágono nunca fue un dibujo: es una decisión, y ahora sabes defenderla por escrito.

Fuentes

Práctica

Practica lo aprendido

Tarjetas de repaso

Evaluación del módulo