← 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.
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:
- 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.
- 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.
- 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.
- 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. - 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.
- 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.
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 privadotype 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>;
}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 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í:
| Pieza | Hace | No hace |
|---|---|---|
| Controlador | Traduce HTTP a un caso de uso | Decidir permisos o reglas |
| Caso de uso / servicio | Aplica las reglas y la autorización | Saber de HTTP o de SQL |
| Repositorio / adaptador | Guarda y lee datos, llama a servicios externos | Autorizar |
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.
- Un script de una sola vez o un prototipo para validar una idea no lleva puertos ni capas.
- Una tarea chica, que se entiende con el pedido y el diff, va directa: sin documento ni agentes.
- No extraigo una abstracción para dos usos que van a cambiar por motivos distintos.
- No escribo tests de documentación ni de configuración; ahí verifico de otra forma y lo dejo anotado.
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.