← Portfolio · Artículo · Metodología

Cómo trabajo: ODD, arquitectura hexagonal, DDD, SRP y DRY

Escribo software con agentes de IA todos los días. Eso no significa que la IA decida: significa que tengo que ser más estricto con el proceso y con la arquitectura, porque un agente escribe rápido y también se equivoca rápido.

Mauro José Ciappina · 10 minutos de lectura · Código real: UNNA es público y cada bloque enlaza a sus líneas en GitHub; laburo y Mr.Mauroo Studio son privados, y la healthtech está bajo NDA

01ODD: mi día a día

Trabajo con ODD (Organic Driven Development), un flujo que corro sobre Gentle Shell. Cada pedido, chico o grande, pasa por los mismos pasos:

  1. Autorizo y entiendo.Separo si el pedido es un cambio o una pregunta. Si falta una decisión de producto, hago una sola pregunta y espero la respuesta.
  2. Exploro antes de proponer.Leo el código que voy a tocar y la memoria del proyecto en Engram: decisiones anteriores, errores ya resueltos, convenciones.
  3. Mido el tamaño.Si alguien puede terminar la tarea solo con el pedido y el diff, es chica y la hago directo. Si no, es grande y lleva documento.
  4. Escribo el documento de la feature.En odd/tasks/: specs que citan el pedido textual, una tarea por línea con su commit, y un registro con cada decisión y cada evidencia.
  5. TDD, tarea por tarea.Primero el test que falla, después el código. Si hay tareas independientes, las reparto entre agentes con archivos permitidos que no se pisan.
  6. Verifico según el riesgo y cierro.Si toca seguridad, datos o contratos, corre un verificador independiente. Cierro con commits chicos y un reporte de qué se probó y qué no.

En la práctica · laburo

El documento de laburo terminó con 27 specs, 27 tareas con su commit y 48 entradas de registro. Cuando cambié una regla a mitad de camino ("que solo encuentre postulaciones para mandar los mails"), el pedido se agregó textual al registro, se reescribió solo esa spec y se reabrió solo esa tarea.

En la práctica · Mr.Mauroo Studio

El estudio de video tiene el documento ODD más largo de mis proyectos: 23 specs, 171 tareas y 692 entradas de registro. Cada video pasa por los mismos gates que defino en las specs (brief, plan validado, storyboard, crítico con nota mínima 8, render) y el registro guarda por qué se tomó cada decisión de diseño.

02Arquitectura hexagonal

El dominio, que son las reglas del negocio, va en el centro y no sabe nada del mundo exterior. Todo lo que es infraestructura (HTTP, base de datos, Gmail, Mercado Pago, un LLM) entra o sale por un puerto: una interfaz que define el dominio. Las implementaciones concretas son adaptadores que se enchufan a ese puerto.

Diagrama de arquitectura hexagonal El dominio en el centro, rodeado por puertos. Afuera, adaptadores de entrada (HTTP, TUI) y de salida (base de datos, Gmail, LLM, Mercado Pago) conectados a los puertos. Dominio reglas · casos de uso HTTP / API TUI / CLI PostgreSQL Gmail API LLM Mercado Pago
Las líneas rojas son puertos: el dominio define la interfaz y cada adaptador la implementa.
Para qué

Para poder testear las reglas sin infraestructura y cambiar un proveedor sin tocar las reglas. Con agentes de IA esto es todavía más importante: le puedo dar a un agente un puerto y un test, y el cambio queda contenido en un adaptador.

En laburo el servicio no conoce Gmail ni Telegram; conoce estas interfaces:

app/internal/app/service.goRepo privado
type Source interface {
	Name() string
	Fetch(ctx context.Context, now time.Time) ([]core.Job, error)
}

type Mailer interface {
	Send(ctx context.Context, raw []byte) (string, error)
}

GetOnBoard, Telegram y la búsqueda de LinkedIn por Exa son adaptadores de Source. En los tests, cada uno se reemplaza por un fake de cinco líneas, y así pude probar las reglas de "nunca dos veces al mismo puesto" o "solo avisos con email" sin red y sin mandar un solo mail.

En la práctica · UNNA

La facturación electrónica con ARCA es un puerto del dominio. En desarrollo y en la demo se enchufa un simulador; en producción, el cliente real de ARCA. La numeración correlativa de comprobantes vive en el dominio y no cambia con el adaptador.

packages/domain/src/ports/fiscal.port.tsVer en GitHub
export interface FiscalAuthorizerPort {
  /** Last number ARCA authorized for (issuer, point of sale, type); 0 when none. */
  lastAuthorizedNumber(issuerCuit: string, pointOfSale: number, receiptType: FiscalReceiptType): Promise<number>;
  /**
   * Requests a CAE. Rejections come back as `approved: false`; transport failures (timeouts,
   * ARCA unavailable) throw, so the caller can keep the receipt as a retriable draft.
   */
  authorize(request: FiscalAuthorizationRequest): Promise<FiscalAuthorizationResult>;
}

Adaptador simulado Adaptador real de ARCA

En la práctica · Mr.Mauroo Studio

La validación del plan de cada video es un puerto del dominio. Zod es solo un adaptador: si mañana cambio de librería de esquemas, las reglas del plan y sus tests no se tocan.

src/planning/ports/plan-parser.tsRepo privado
// Turns plan.json text into a Plan, or the JSON/schema issues that stop it.
export interface PlanParser {
  parse(text: string): ParsedPlan;
}
src/planning/infrastructure/zod-plan-parser.tsRepo privado
// plan.json text -> domain Plan through the LinkedIn Zod schema.
export class ZodPlanParser implements PlanParser {
  parse(text: string): ParsedPlan {
    return parseWith(LinkedinPlan, text);
  }
}

03DDD: el lenguaje del negocio manda

Domain-Driven Design es modelar el software con el lenguaje y los límites del negocio. Uso tres herramientas concretas:

Contextos delimitados

En la plataforma healthtech separé autenticación, consentimiento y registros clínicos en contextos propios dentro de un monolito modular. Cada uno tiene sus reglas y sus datos; se comunican por contratos, no por tablas compartidas.

En Mr.Mauroo Studio cada parte del negocio es un contexto con su propio domain/, application/, ports/ e infrastructure/: planificación, crítico, render, publicación, música, investigación… son 42 contextos que se hablan por puertos.

Agregados que cuidan la consistencia

En UNNA, el negocio con sus sucursales es un agregado: todo cambio pasa por él y se guarda dentro de un bloqueo de fila en PostgreSQL. Así, dos ediciones al mismo tiempo no pueden borrar una sucursal recién creada.

packages/domain/src/ports/repository.port.tsVer en GitHub
  /**
   * Runs work holding a row lock on the business, so concurrent edits apply one after another over
   * fresh data; the save it hands out writes inside that lock. The business is null when missing.
   */
  withBusinessLock<T>(
    businessId: string,
    work: (business: Business | null, save: (business: Business) => Promise<void>) => Promise<T>,
  ): Promise<T>;

Value objects

Un nombre de negocio o un importe no son strings ni números sueltos. En UNNA el nombre del negocio se normaliza a Unicode NFC al crearse y el dinero se maneja en unidades mínimas, así nadie compara mal ni redondea mal en otra parte del código.

packages/domain/src/value-objects/business-name.tsVer en GitHub
public static create(value: string): BusinessName {
  if (!value || !value.trim()) {
    throw new Error('Este campo es obligatorio');
  }

  // Stored composed (NFC), so the same name typed with separate accent marks is equal.
  const trimmed = value.trim().normalize('NFC');
packages/domain/src/value-objects/money.tsVer en GitHub
public static fromCents(amount: number, currency: Currency = 'ARS'): Money {
  if (!Number.isInteger(amount)) {
    throw new Error('Monto en centavos debe ser un entero');
  }
Para qué

Para que el código hable como el negocio (sucursal, caja, comprobante, consentimiento) y para que las reglas importantes estén en un solo lugar que se puede testear, en vez de repartidas entre controladores y consultas.

04SRP: una sola razón para cambiar

Cada módulo tiene una responsabilidad. Si para describir una clase necesito decir "y", la divido. En mi stack eso se traduce así:

PiezaHaceNo hace
ControladorTraduce HTTP a un caso de usoDecidir permisos o reglas
Caso de uso / servicioAplica las reglas y la autorizaciónSaber de HTTP o de SQL
Repositorio / adaptadorGuarda y lee datos, llama a servicios externosAutorizar

En la práctica · UNNA

Hice un refactor con dos reglas medibles: una clase por archivo y ningún archivo de más de 400 líneas. El punto de venta se partió en hooks y componentes, y el cliente de la API en módulos chicos. Los cambios siguientes se revisaron más rápido, porque cada diff tocaba una sola responsabilidad.

En la práctica · Mr.Mauroo Studio

El panel de estadísticas del canal no sabe nada de la API de YouTube: depende de un puerto con una sola responsabilidad, leer los números del canal. La autenticación, los reintentos y el formato viven en el adaptador.

src/studio/ports/channel-stats-source.tsRepo privado
// The channel's numbers, read the way `mrm youtube stats` reads them.
export interface ChannelStatsSource {
  readonly handle: string;
  // An OAuth client is set; says nothing about the Keychain token.
  configured(): boolean;
  // Rejects with ChannelNotConnected when the owner must connect the channel.
  fetch(): Promise<ChannelStats>;
}

05DRY: una fuente para cada conocimiento

DRY no es "nunca repetir líneas": es que cada regla de negocio tenga una sola fuente. Extraigo cuando la misma lógica aparece en tres lugares y cambia al mismo ritmo; si dos duplicaciones evolucionan por separado, las dejo.

En la práctica · laburo

"Qué aviso sirve" (con email, de los últimos 10 días, en español, 100% remoto, de desarrollo y no senior) es una sola función que usan las fuentes de Telegram y de LinkedIn. Cuando decidí descartar los avisos que no mencionan mi stack, el cambio fue en un solo lugar y quedó cubierto por un test.

app/internal/sources/telegram.goRepo privado
// keepPost accepts a fresh Spanish post for a fully remote, non-senior
// dev role that carries a contact email.
func keepPost(title, text, email string, posted, now time.Time) bool {
	remote := remoteRe.MatchString(text) && (fullyRe.MatchString(text) || !onsiteRe.MatchString(text))
	return email != "" && core.IsFresh(posted, now) && remote && devTitleRe.MatchString(title) &&
		match.IsSpanish(text) && match.SeniorityFits(title, "")
}

En la práctica · Mr.Mauroo Studio

"Un storyboard aprueba con nota 8 o más" es una regla del negocio, así que existe una sola vez. La usan 12 archivos (el crítico, los defaults prohibidos, la aprobación del storyboard); cambiar el umbral es cambiar una línea.

src/critic/domain/critique.tsRepo privado
// It passes only when every score is >= PASS_SCORE and no banned default
// is left; otherwise the 3 worst are fixed first and the critique runs again.
export const PASS_SCORE = 8;
export const WORST_COUNT = 3;

06Cuándo no los uso

Estos principios cuestan: más archivos, más interfaces, más tests. Los aplico donde se pagan.

La idea de fondo es simple: la IA me da velocidad, y la arquitectura y el proceso me dan control. Las dos cosas juntas son las que me permiten entregar productos que se pueden mantener.