ES

← Portfolio · Article · Methodology

How I work: ODD, hexagonal architecture, DDD, SRP and DRY

I write software with AI agents every day. That doesn't mean the AI makes the decisions: it means I have to be stricter about process and architecture, because an agent writes fast and also makes mistakes fast.

Mauro José Ciappina · 10 min read · Real code: UNNA is public and every block links to its lines on GitHub; laburo and Mr.Mauroo Studio are private, and the healthtech work is under NDA

01ODD: my daily workflow

I work with ODD (Organic Driven Development), a workflow I run on top of Gentle Shell. Every request, small or large, goes through the same steps:

  1. Authorize and understand.I separate whether the request is a change or a question. If a product decision is missing, I ask a single question and wait for the answer.
  2. Explore before proposing.I read the code I'm going to touch and the project memory in Engram: past decisions, bugs already fixed, conventions.
  3. Size the work.If someone could finish the task with just the request and the diff, it's small and I do it directly. If not, it's large and gets a document.
  4. Write the feature document.In odd/tasks/: specs that quote the original request verbatim, one task per line with its commit, and a log with every decision and every piece of evidence.
  5. TDD, task by task.First the failing test, then the code. When tasks are independent, I split them across agents with allowed files that don't overlap.
  6. Verify according to risk, then close.If it touches security, data or contracts, an independent verifier runs. I close with small commits and a report of what was tested and what wasn't.

In practice · laburo

The laburo document ended with 27 specs, 27 tasks with their commits and 48 log entries. When I changed a rule halfway through ("only find applications to send the emails"), the request was added verbatim to the log, only that spec was rewritten and only that task was reopened.

In practice · Mr.Mauroo Studio

The video studio has the longest ODD document of all my projects: 23 specs, 171 tasks and 692 log entries. Every video goes through the same gates I define in the specs (brief, validated plan, storyboard, critic with a minimum score of 8, render), and the log records why each design decision was made.

02Hexagonal architecture

The domain, meaning the business rules, sits at the center and knows nothing about the outside world. Everything that is infrastructure (HTTP, database, Gmail, Mercado Pago, an LLM) comes in or goes out through a port: an interface defined by the domain. The concrete implementations are adapters that plug into that port.

Hexagonal architecture diagram The domain at the center, surrounded by ports. Outside, inbound adapters (HTTP, TUI) and outbound adapters (database, Gmail, LLM, Mercado Pago) connected to the ports. Domain rules · use cases HTTP / API TUI / CLI PostgreSQL Gmail API LLM Mercado Pago
The red lines are ports: the domain defines the interface and each adapter implements it.
Why

So I can test the rules without infrastructure and swap a provider without touching the rules. With AI agents this matters even more: I can give an agent a port and a test, and the change stays contained in one adapter.

In laburo the service doesn't know about Gmail or Telegram; it knows these interfaces:

app/internal/app/service.goPrivate repo
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 and the LinkedIn search through Exa are Source adapters. In the tests, each one is replaced by a five-line fake, which let me test rules like "never the same job twice" or "only postings with an email" with no network and without sending a single email.

In practice · UNNA

Electronic invoicing with ARCA is a domain port. In development and in the demo a simulator is plugged in; in production, the real ARCA client. Sequential receipt numbering lives in the domain and doesn't change with the adapter.

packages/domain/src/ports/fiscal.port.tsView on 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>;
}

Simulated adapter Real ARCA adapter

In practice · Mr.Mauroo Studio

Validating each video's plan is a domain port. Zod is just an adapter: if I switch schema libraries tomorrow, the plan rules and their tests stay untouched.

src/planning/ports/plan-parser.tsPrivate repo
// 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.tsPrivate repo
// plan.json text -> domain Plan through the LinkedIn Zod schema.
export class ZodPlanParser implements PlanParser {
  parse(text: string): ParsedPlan {
    return parseWith(LinkedinPlan, text);
  }
}

03DDD: the language of the business leads

Domain-Driven Design means modeling software with the language and the boundaries of the business. I use three concrete tools:

Bounded contexts

On the healthtech platform I split authentication, consent and clinical records into their own contexts inside a modular monolith. Each one has its own rules and data; they communicate through contracts, not shared tables.

In Mr.Mauroo Studio every part of the business is a context with its own domain/, application/, ports/ and infrastructure/: planning, critic, render, publishing, music, research… 42 contexts that talk to each other through ports.

Aggregates that protect consistency

In UNNA, the business with its branches is an aggregate: every change goes through it and is saved inside a row lock in PostgreSQL. That way, two concurrent edits can't delete a branch that was just created.

packages/domain/src/ports/repository.port.tsView on 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

A business name or an amount is not a loose string or number. In UNNA the business name is normalized to Unicode NFC when it is created, and money is handled in minor units, so nobody compares or rounds incorrectly somewhere else in the code.

packages/domain/src/value-objects/business-name.tsView on 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.tsView on GitHub
public static fromCents(amount: number, currency: Currency = 'ARS'): Money {
  if (!Number.isInteger(amount)) {
    throw new Error('Monto en centavos debe ser un entero');
  }
Why

So the code speaks like the business (branch, cash register, receipt, consent) and the important rules live in a single place that can be tested, instead of being scattered across controllers and queries.

04SRP: a single reason to change

Each module has one responsibility. If I need the word "and" to describe a class, I split it. In my stack that translates to:

PieceDoesDoesn't
ControllerTranslates HTTP into a use caseDecide permissions or rules
Use case / serviceApplies the rules and authorizationKnow about HTTP or SQL
Repository / adapterStores and reads data, calls external servicesAuthorize

In practice · UNNA

I did a refactor with two measurable rules: one class per file and no file over 400 lines. The point of sale was split into hooks and components, and the API client into small modules. The changes that followed were reviewed faster, because each diff touched a single responsibility.

In practice · Mr.Mauroo Studio

The channel stats dashboard knows nothing about the YouTube API: it depends on a port with a single responsibility, reading the channel's numbers. Authentication, retries and formatting live in the adapter.

src/studio/ports/channel-stats-source.tsPrivate repo
// 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: one source for each piece of knowledge

DRY isn't "never repeat a line": it means every business rule has a single source. I extract when the same logic shows up in three places and changes at the same pace; if two duplicates evolve separately, I leave them alone.

In practice · laburo

"Which posting is worth it" (with an email, from the last 10 days, in Spanish, 100% remote, a development role and not senior) is a single function used by both the Telegram and LinkedIn sources. When I decided to drop postings that don't mention my stack, the change happened in one place and was covered by a test.

app/internal/sources/telegram.goPrivate repo
// 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, "")
}

In practice · Mr.Mauroo Studio

"A storyboard passes with a score of 8 or more" is a business rule, so it exists exactly once. It's used by 12 files (the critic, the banned defaults, the storyboard approval); changing the threshold means changing one line.

src/critic/domain/critique.tsPrivate repo
// 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;

06When I don't use them

These principles have a cost: more files, more interfaces, more tests. I apply them where they pay off.

The underlying idea is simple: AI gives me speed, and architecture and process give me control. Together, they are what let me ship products that can be maintained.