Jordach MakayaOperations leader · Backend & systems engineeringCasablanca · FR / EN
Journal
Journal · TutorialTypeScriptNestJSAI coding agents

Designing Software for AI Maintainers: My Boxed Architecture

How I design a codebase that both humans and AI agents can maintain without having to mentally rebuild the entire repository on every change.

Author
Jordach Makaya
Reading time
45 min
Structure
37 chapters · 3 appendices
Language
English
Box · payment-reminder
contracts/public
src/private
SOUL.md
AGENT.md
MEMORY.md
tests/
hard gates
consumer → contract ← implementation

Systems do not share their internals. They collaborate through contracts.

Box · billing
BILLING_APIexported token
application/ · domain/ · infrastructure/
unit tests
integration
Anatomy of a BoxExplicit boundaries · Narrow contracts

TL;DR: A system of systems

I no longer design an application as one large set of files organized by convention. I design it as a system of systems: autonomous, closed, contractual boxes that are locally understandable and verifiable.

Each box has:

  • a precise responsibility;
  • a narrow public contract;
  • a private implementation;
  • its own tests;
  • a SOUL.md that explains what must survive;
  • an AGENT.md that defines the maintainer’s authority;
  • a MEMORY.md that maps what actually exists;
  • verifiable dependency boundaries;
  • hard gates that authorize or refuse DONE.

The most important piece is MEMORY.md.

This file is not optional documentation. It is an exhaustive, local, maintained map of the system: TypeScript files, functions, types, interfaces, classes, capabilities, contracts, entry points, dependencies, and — in the case of NestJS — providers and wiring.

Every maintainer is instructed to update it systematically, and a deterministic audit verifies that it still matches the filesystem and the real architecture.

My thesis is simple:

The more a codebase makes its boundaries and its state explicit, the less an agent has to guess them. The less it has to guess, the less maintenance depends on a huge context and fragile assumptions.

I consider this approach superior for the agentic maintenance of non-trivial systems, not because it contains more rules, but because it turns part of the architectural understanding into local, verifiable artifacts.

Video · How I Make AI-Generated Code MaintainableWatch on YouTube ↗
Part I · Why layered architecture fails with AI
Chapter 01

The problem is no longer just “writing good code”

Classic software engineering conventions remain necessary: typing, tests, SOLID, modularity, CI, code review.

But the arrival of coding agents introduces an additional problem.

An agent that receives:

"Change how payment reminders handle customers who already promised to pay."

does not automatically know:

  • which module owns the responsibility;
  • which abstractions already exist;
  • what is public or private;
  • what it is allowed to modify;
  • the invariants that previous developers wanted to preserve;
  • the reasons behind certain decisions;
  • the consumers that depend on a contract;
  • the files that must absolutely not be touched.

It has to reconstruct this knowledge.

In many repositories, the natural strategy becomes:

Task
  ↓
search repository
  ↓
open files
  ↓
follow imports
  ↓
read neighboring services
  ↓
inspect shared utilities
  ↓
infer architecture
  ↓
make a change

The problem is subtle: even if the final change touches four files, the agent may have inspected fifty.

Each additional file brings:

new concepts
+ implementation details
+ possible dependencies
+ alternative solutions
+ accidental opportunities to refactor
+ additional assumptions

The model’s context progressively becomes larger than the actual problem.

It is a form of maintenance entropy.

Chapter 02

My unit of architecture is not the file: it is the Box

A Box is not an arbitrary folder.

It is a capability important enough to have:

  • its own meaning;
  • its own responsibility;
  • a contract;
  • a lifecycle;
  • a private implementation;
  • tests;
  • an identifiable owner;
  • the ability to evolve independently.

Examples:

billing
customer-profile
payment-reminder
fraud-evaluation
verification-receipts
context-handoff
task-router
quality-gate

On the other hand:

parse-date
hash-string
normalize-name

generally do not justify their own Box.

They are internal details of a larger capability.

So the question is never:

“How many boxes can I create?”

The question is:

“Where are the stable boundaries of responsibility?”
Chapter 03

The contract matters more than the implementation

The central principle is this:

The public contract is the system’s promise to the rest of the architecture.

The inside can change.

The contract does not change silently.

Consumer ──▶ Contract ──▶ Implementation v1
                     └──▶ Implementation v2

I want to be able to:

  • change the algorithm;
  • change the storage;
  • replace a library;
  • rewrite an adapter;
  • optimize the code;
  • change the internal structure;

without requiring consumers to know about that change.

It is the difference between:

consumer → implementation detail

and:

consumer → contract ← implementation

In TypeScript:

export interface IInvoiceRepository {
  save(invoice: Invoice): Promise<void>;
  findById(id: InvoiceId): Promise<Invoice | null>;
}

The consumer depends on IInvoiceRepository.

It has no reason to know whether the implementation uses Prisma, Drizzle, PostgreSQL directly, or an in-memory store in tests.

Part II · Anatomy of a hermetic Box
Chapter 04

The local structure of a Box

A generic Box looks like this:

systems/<system-id>/
├── SOUL.md
├── AGENT.md
├── MEMORY.md
├── WIRING_PROTOCOL.md   # conditional
├── contracts/
├── schemas/
├── src/
└── tests/

The first three documents have different responsibilities.

ArtifactQuestion it answers
SOUL.mdWhy must this system stay this way?
AGENT.mdWhat is the maintainer responsible for, and where may it write?
MEMORY.mdWhat actually exists right now?

They must not duplicate each other.

Chapter 05

SOUL.md: what must survive

SOUL.md describes the system’s reason for being.

Example:

# System identity

System: payment-reminder

## Mission

Decide and execute authorized payment reminders for overdue invoices.

## Why this system is a closed box

Consumers must not know provider-specific delivery logic, retry internals,
or storage implementation.

## Public promise

Given a valid reminder request, the system produces an explicit delivery result.

## Survival invariants

- Never send a reminder without an authorized policy decision.
- Never expose vendor-specific clients in the public contract.
- Preserve idempotency for retried reminder jobs.

## Non-responsibilities

- Owning customer contact data.
- Calculating invoice balance.
- Deciding accounting policy.

This file plays an important role for agents.

Without it, an agent can produce a solution that is locally correct but architecturally destructive.

For example, it might “simplify” an indirection that existed precisely to prevent future coupling.

SOUL.md preserves the reason behind the boundary.

Chapter 06

AGENT.md: the maintainer’s authority

A maintainer does not own files.

It is responsible for the survival of the capability.

Example:

# Maintainer authority

system_id: payment-reminder

## Role

You maintain the payment-reminder bounded system.

Your responsibility is not to modify files.
Your responsibility is to preserve the survival of the system while
implementing authorized changes.

## Required reading

1. SOUL.md
2. MEMORY.md
3. public contracts
4. relevant local implementation
5. relevant local tests

## Write boundary

Allowed:

systems/payment-reminder/**

Forbidden without orchestration:

systems/<other-system>/**

## Contract rule

A public contract change is a migration, not routine maintenance.

This rule prevents a very common drift with agents:

fix local behavior
→ notice neighboring code
→ patch neighboring code
→ import helper
→ modify shared utility
→ widen diff
→ create coupling

A local task never implicitly grants the right to edit another Box.

Chapter 07

MEMORY.md: the most important piece for maintenance

This is the part of the architecture I value most for agentic coding.

MEMORY.md answers:

What actually exists in this Box?

It is not a summary.

It is not a conversation log.

It is not “read it if you have time” documentation.

It is an exhaustive, verifiable local map.

At a minimum, it must contain:

  • the short mission;
  • what the Box provides;
  • what it consumes;
  • the public API;
  • the contracts;
  • the entry points;
  • the TypeScript files;
  • their role;
  • the important functions;
  • the types and interfaces;
  • the invariants;
  • the dependencies;
  • the verification commands;
  • the active architectural decisions.

Example:

# System memory

system_id: payment-reminder

## Provides

- `PAYMENT_REMINDER_API` → `IPaymentReminderApi`

## Consumes

- `CUSTOMER_CONTACT_READER` → `ICustomerContactReader`
- `INVOICE_READER` → `IInvoiceReader`

## Public API

| Symbol | File | Role |
|---|---|---|
| IPaymentReminderApi | contracts/payment-reminder-api.ts | Public consumer contract |
| PAYMENT_REMINDER_API | contracts/tokens.ts | Runtime DI token |

## TypeScript symbol map

| File | Symbols | Visibility | Role |
|---|---|---|---|
| contracts/payment-reminder-api.ts | IPaymentReminderApi | public | Consumer contract |
| domain/reminder-policy.ts | ReminderPolicy, decideReminder | private | Deterministic reminder decision |
| application/send-reminder.ts | SendReminderService | private | Orchestrates delivery |
| infrastructure/email.adapter.ts | EmailReminderAdapter | private | Email delivery |
| tests/reminder-policy.test.ts | — | test | Policy behavior verification |

The goal is for a fresh maintainer to understand the real structure before digging deep into src/.

Chapter 08

Why MEMORY must be exhaustive

An incomplete MEMORY can be more dangerous than no MEMORY at all.

Suppose:

filesystem
├── evaluate-policy.ts
├── send-reminder.ts
└── security-check.ts

but:

MEMORY
├── evaluate-policy.ts
└── send-reminder.ts

An agent that uses the MEMORY to reduce context may conclude that security-check.ts does not exist.

It may then rebuild logic that is already present, or bypass a critical check.

So:

MEMORY.md
=
human-readable
+
machine-verifiable

The goal is not to have beautiful documentation.

The goal is to have a reliable representation of the current system.

Chapter 09

The mandatory MEMORY.md maintenance rule

Every Box contains an explicit instruction:

## Mandatory maintenance rule

This MEMORY.md is part of the system's maintained state.

Whenever you add, remove, rename, move or materially modify:

- a TypeScript file;
- a function;
- a type;
- an interface;
- a class;
- a capability;
- a public contract;
- a dependency;
- an entry point;

update the relevant MEMORY.md maps before the task can be considered complete.

Do not update MEMORY.md from intention.
Update it from the final verified implementation.

For NestJS, this list is extended to:

modules
controllers
providers
DI tokens
ports
adapters
guards
interceptors
pipes
filters
gateways
provider scopes
module exports
cross-box bindings

But I do not trust the instruction alone.

An LLM can forget.

The rule must be verified by a machine.

Chapter 10

The Memory Completeness Gate

The filesystem is compared to the MEMORY.

Filesystem / AST ──┐
                   ├──▶ Memory auditor ──match──────────────▶ PASS
MEMORY.md maps ────┘                   └──unmapped or stale──▶ FAIL

Example result:

export type MemoryAuditResult =
  | {
      readonly status: 'PASS';
      readonly systemId: string;
      readonly mappedFiles: number;
    }
  | {
      readonly status: 'FAIL';
      readonly systemId: string;
      readonly unmappedFiles: readonly string[];
      readonly staleFiles: readonly string[];
    };

Example error:

[MEMORY_FILE_UNMAPPED] TypeScript file is missing from system memory
  where : systems/payment-reminder/src/new-policy.ts
  cause : file exists but no matching MEMORY.md entry was found
  fix   : add the file and its architectural role to MEMORY.md

The task cannot be considered done as long as:

unmappedFiles.length > 0
OR
staleFiles.length > 0
Part III · Maintenance workflow and hard gates
Chapter 11

The maintenance workflow I want

Instead of:

TASK
 ↓
search the repo
 ↓
infer architecture
 ↓
modify code
 ↓
hope assumptions were correct

I want:

User task
 ↓
Impact resolver
 ↓
Resolve owning Box
 ↓
Load SOUL.md
 ↓
Load AGENT.md
 ↓
Load MEMORY.md
 ↓
Load public contracts
 ↓
Load relevant local source and tests
 ↓
Implement change
 ↓
Update MEMORY.md
 ↓
Run hard gates
 ├── PASS         → DONE authorized
 └── FAIL/BLOCKED → Correct / migrate / handoff

The difference looks simple, but it profoundly changes the role of context.

The agent no longer needs to start by discovering the world.

The system gives it a bounded cognitive surface.

Chapter 12

Example: going from 500 files to a local context

Imagine:

500 source files
25 bounded systems

The request:

"Change how repeated payment promises affect reminder escalation."

Without an explicit boundary, the agent may search for:

promise
reminder
invoice
customer
escalation
notification
email
payment

and quickly end up in:

src/
shared/
utils/
billing/
customer/
notifications/
cron/
workers/
tests/

With the Boxed Architecture:

TASK
 ↓
owner = payment-reminder
 ↓
load:
  SOUL.md
  AGENT.md
  MEMORY.md
  contracts/
  reminder-policy.ts
  send-reminder.ts
  relevant tests

The goal is not to guarantee “exactly eight files”.

The goal is for the size of the context to be determined by the Box’s responsibility, not by the size of the repository.

Chapter 13

Why I consider this superior for maintenance

I do not claim that a universally superior architecture exists.

I consider this approach superior when the problem is:

  • a codebase that is going to live;
  • several agents or developers;
  • frequent changes;
  • a need for handoff;
  • coupling risks;
  • domains complex enough that global exploration becomes expensive.

The main benefit is not initial creation speed.

It is locality of change.

13.1 Less mental reconstruction

The agent does not have to rediscover:

ownership
public surface
internal files
entry points
dependencies
local invariants
verification commands

on every task.

13.2 Smaller blast radius

A local request ideally produces a local diff.

13.3 More reliable handoff

The previous maintainer does not need to stay in the context.

A fresh agent can take over the Box from its artifacts.

13.4 Long-term maintenance

A change of AI model, provider or human developer must not destroy local knowledge.

13.5 Better substitutability

An adapter can be replaced as long as the contract holds.

13.6 Verifiable architecture

A boundary that only exists in a diagram is easy to violate.

A boundary tested by AST, dependency graph and write scope is harder to bypass by accident.

Chapter 14

But discipline is only useful if it is verifiable

An agent can say:

"Tests pass. Everything is done."

That is not sufficient proof.

My chain of authority is:

LLM claim
 ↓
Machine verification
 ↓
Evidence
 ↓
Gate receipt
 ↓
Aggregate decision
 ├── PASS         → DONE may be authorized
 └── FAIL/BLOCKED → State rejected

The principle is:

Soft reasoning prevents bad decisions. Hard gates prevent bad state.

The LLM can reason, propose, implement and diagnose.

But when a property can be verified deterministically through:

  • the filesystem;
  • Git;
  • the AST;
  • TypeScript;
  • a test runner;
  • a schema;
  • a dependency graph;

I prefer machine proof.

Chapter 15

The gates that protect maintenance

A local pipeline can include:

GateQuestion
BootstrapDid the maintainer load the right context?
Write scopeDid it write only inside its owner’s scope?
Memory completenessDoes the MEMORY represent the current system?
Dependency boundariesWere foreign internals imported?
Local testsIs local behavior still proven?
Contract compatibilityDid the public promise change?
Code styleAre the style invariants respected?
Value derivationWas a dynamic value hardcoded?
Test integrityWere tests weakened?
IntegrationIs the capability actually wired in?

The important point is that DONE is not a boolean declared by the agent.

It is a decision derived from the required evidence.

Chapter 16

Errors must be designed for the human AND for the agent

An error like:

Something went wrong

is almost useless.

I prefer:

[SYSTEM_BOUNDARY_VIOLATION] Private implementation imported across system boundary
  where : systems/orders/application/checkout.ts
  cause : imports systems/billing/infrastructure/prisma-invoice.repository.ts
  fix   : depend on the Billing public contract instead

A good error immediately answers:

WHAT happened?
WHERE?
WHY?
WHAT should I do next?

It reduces “stack archaeology” for a human as much as for an LLM.

Chapter 17

TypeScript: types should tell the story of the flow

I favor discriminated unions when several states are mutually exclusive.

Avoid:

export type SendReminderResult = {
  success: boolean;
  receipt?: ReminderReceipt;
  error?: string;
};

This structure allows inconsistent states.

Prefer:

export type SendReminderResult =
  | {
      readonly status: 'SENT';
      readonly receipt: ReminderReceipt;
    }
  | {
      readonly status: 'SKIPPED';
      readonly reason: 'PROMISE_TO_PAY_ACTIVE' | 'BALANCE_SETTLED';
    }
  | {
      readonly status: 'FAILED';
      readonly reason: ReminderFailureReason;
      readonly guidance: ActionableGuidance;
    };

The status is the discriminant.

The compiler then helps preserve exhaustiveness:

export function toReminderMetric(
  result: SendReminderResult,
): ReminderMetric {
  switch (result.status) {
    case 'SENT':
      return { outcome: 'delivery', value: 1 };
    case 'SKIPPED':
      return { outcome: 'policy_skip', value: 1 };
    case 'FAILED':
      return { outcome: 'failure', value: 1 };
  }
}
Chapter 18

Derived values are not independent facts

Another frequent source of bugs in generated code is the plausible but frozen value.

Wrong example:

const overdueInvoiceCount = 3;

if 3 actually depends on the current invoices.

The rule is:

Derived values are views of authoritative state, not independent facts.

Prefer:

export function countOverdueInvoices(
  invoices: readonly Invoice[],
  now: Date,
): number {
  return invoices.filter((invoice) => isOverdue(invoice, now)).length;
}

The same goes for:

  • a score;
  • a number of jobs;
  • a computed status;
  • a list of unblocked dependencies;
  • a hash;
  • a timestamp;
  • a path derived from configuration.

The test must prove the input → output relationship, not merely certify a frozen value.

Chapter 19

Full example: adding a second reminder channel

Let’s take a B2B SaaS case.

The payment-reminder Box currently sends emails.

The product request is:

"Add WhatsApp for some customers without breaking existing email reminders."

19.1 A quick but fragile implementation

An agent might naturally write:

@Injectable()
export class ReminderService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly whatsapp: WhatsAppClient,
    private readonly email: EmailClient,
  ) {}

  async remind(invoiceId: string): Promise<void> {
    const invoice = await this.prisma.invoice.findUnique({
      where: { id: invoiceId },
      include: { customer: true },
    });

    if (invoice?.customer.whatsappEnabled) {
      await this.whatsapp.send(invoice.customer.phone, 'Pay your invoice');
      return;
    }

    await this.email.send(invoice!.customer.email, 'Pay your invoice');
  }
}

Why I don’t want this solution:

  • the use case knows about storage;
  • the customer domain leaks into the reminder Box;
  • vendors are coupled to the use case;
  • the text is hardcoded;
  • results are implicit;
  • no port allows the delivery to be substituted;
  • tests have to mock too much infrastructure;
  • a future change to a third channel will widen the service even further.

19.2 Contracts

export type ReminderChannel = 'EMAIL' | 'WHATSAPP';

export type ReminderMessage = {
  readonly customerId: CustomerId;
  readonly channel: ReminderChannel;
  readonly destination: string;
  readonly content: string;
  readonly idempotencyKey: string;
};

export type ReminderDeliveryResult =
  | {
      readonly status: 'DELIVERED';
      readonly providerMessageId: string;
    }
  | {
      readonly status: 'FAILED';
      readonly reason: 'TEMPORARY_PROVIDER_FAILURE' | 'INVALID_DESTINATION';
    };

export interface IReminderDelivery {
  deliver(message: ReminderMessage): Promise<ReminderDeliveryResult>;
}

19.3 Pure policy

export type ReminderCandidate = {
  readonly customerId: CustomerId;
  readonly email: string | null;
  readonly whatsappNumber: string | null;
  readonly whatsappEnabled: boolean;
};

export function selectReminderChannel(
  candidate: ReminderCandidate,
): ReminderChannel | null {
  if (candidate.whatsappEnabled && candidate.whatsappNumber !== null) {
    return 'WHATSAPP';
  }

  if (candidate.email !== null) {
    return 'EMAIL';
  }

  return null;
}

19.4 Private adapters

@Injectable()
export class WhatsAppReminderAdapter implements IReminderDelivery {
  constructor(
    @Inject(WHATSAPP_CLIENT)
    private readonly client: IWhatsAppClient,
  ) {}

  async deliver(
    message: ReminderMessage,
  ): Promise<ReminderDeliveryResult> {
    const response = await this.client.send({
      to: message.destination,
      body: message.content,
      idempotencyKey: message.idempotencyKey,
    });

    return response.ok
      ? {
          status: 'DELIVERED',
          providerMessageId: response.messageId,
        }
      : {
          status: 'FAILED',
          reason: response.reason,
        };
  }
}

The email adapter can implement the same contract.

The choice of adapter can be made by an internal router or by separate ports, depending on the real needs of the domain.

I do not introduce a pattern just to “do architecture”: it must correspond to a variation that actually exists.

19.5 Updating the MEMORY

The maintainer adds:

| File | Symbols | Visibility | Role |
|---|---|---|---|
| infrastructure/whatsapp-reminder.adapter.ts | WhatsAppReminderAdapter | private | WhatsApp delivery adapter |
| domain/select-reminder-channel.ts | selectReminderChannel | private | Deterministic channel policy |

Then the Memory Gate verifies that the map is complete.

The outcome matters: the architecture and its map evolve together.

Part IV · NestJS and enforced boundaries
Chapter 20

NestJS: adapt the philosophy to the framework, don’t fight the framework

My generic TypeScript style favors factories and functional composition.

But NestJS explicitly relies on decorated classes for several fundamental primitives.

Forbidding all classes in a NestJS codebase would be dogmatic and counterproductive.

So I separate:

Architecture invariants
        │
        ├── framework-independent
        │   contracts
        │   ownership
        │   MEMORY
        │   boundaries
        │   gates
        │
        └── implementation idioms
            ├── pure TypeScript → functions/factories preferred
            └── NestJS → framework classes allowed

NestJS documents that its modules encapsulate their providers, and that exported providers form the surface accessible to consumer modules.

I use this property as a first layer, then add stricter architectural rules on top.

Chapter 21

A NestJS Box

systems/
└── billing/
    ├── SOUL.md
    ├── AGENT.md
    ├── MEMORY.md
    ├── contracts/
    │   ├── billing-api.contract.ts
    │   ├── billing.tokens.ts
    │   └── invoice-repository.port.ts
    ├── application/
    │   └── create-invoice.service.ts
    ├── domain/
    │   ├── invoice.ts
    │   └── money.ts
    ├── infrastructure/
    │   └── prisma-invoice.repository.ts
    ├── transport/
    │   └── billing.controller.ts
    ├── billing.module.ts
    └── tests/

The Nest module becomes the local composition root.

It wires.

It does not own the business logic.

Chapter 22

NestJS: interfaces + tokens

A TypeScript interface disappears at runtime.

For a cross-box dependency, I therefore use a type contract plus a runtime token.

export const BILLING_API = Symbol('billing.api');

export interface IBillingApi {
  createInvoice(input: CreateInvoiceInput): Promise<CreateInvoiceResult>;
}

Implementation:

@Injectable()
export class CreateInvoiceService implements IBillingApi {
  // ...
}

Wiring:

@Module({
  providers: [
    CreateInvoiceService,
    {
      provide: BILLING_API,
      useExisting: CreateInvoiceService,
    },
  ],
  exports: [BILLING_API],
})
export class BillingModule {}

Consumer:

@Injectable()
export class CheckoutService {
  constructor(
    @Inject(BILLING_API)
    private readonly billing: IBillingApi,
  ) {}
}

What the consumer never does:

import { CreateInvoiceService }
  from '../../billing/application/create-invoice.service';

and even less:

import { PrismaInvoiceRepository }
  from '../../billing/infrastructure/prisma-invoice.repository';

Nest’s ability to inject a class does not make that class architecturally public.

Chapter 23

NestJS: MEMORY.md also maps the wiring

A NestJS Box has more runtime metadata than a pure TypeScript package.

The MEMORY must therefore include:

## Nest module

| Module | File | Role |
|---|---|---|
| BillingModule | billing.module.ts | Local composition root |

## Controllers

| Controller | Route | Calls |
|---|---|---|
| BillingController | POST /invoices | BILLING_API |

## Providers

| Provider | Visibility | Role | Scope |
|---|---|---|---|
| CreateInvoiceService | private | Invoice issue use case | DEFAULT |
| PrismaInvoiceRepository | private | Persistence adapter | DEFAULT |

## DI tokens

| Token | Contract | Bound implementation | Visibility |
|---|---|---|---|
| BILLING_API | IBillingApi | CreateInvoiceService | public |
| INVOICE_REPOSITORY | IInvoiceRepository | PrismaInvoiceRepository | private |

This lets an agent quickly see:

what is public
what is private
what is injected
what implementation backs a port
what Nest exports actually expose
Chapter 24

NestJS: classes are allowed, coupling is not

The exact rule is:

Use classes where NestJS mechanics benefit from classes. Do not use NestJS as a reason to expose concrete implementations across Box boundaries.

Legitimate classes:

@Module()
@Controller()
@Injectable()
guards
interceptors
pipes
filters
gateways
strategies managed by the framework

A pure function remains preferable for a pure rule:

export function calculateLateFee(
  invoice: Invoice,
  policy: LateFeePolicy,
): Money {
  // deterministic domain rule
}

I don’t turn this into a LateFeeCalculationService just because Nest knows how to inject classes.

Chapter 25

Why I distrust forwardRef()

NestJS provides forwardRef() to resolve certain cycles.

I do not consider it a normal architectural strategy.

If I get:

BillingModule → OrdersModule
OrdersModule → BillingModule

I first look for:

  • a misplaced responsibility;
  • a missing contract;
  • an orchestration that belongs to a third capability;
  • an event/artifact that should decouple the workflow.

A forwardRef() may be necessary in some cases, but it then becomes a documented exception, not a habit.

Chapter 26

A boundary only really exists if it resists tooling

Saying:

"don't import Billing internals"

is a convention.

Making CI fail if:

systems/orders/**
→ imports
systems/billing/infrastructure/**

is a constraint.

I prefer to generate the rules from a registry when possible:

{
  "version": 1,
  "systems": [
    {
      "id": "billing",
      "root": "systems/billing",
      "publicRoots": [
        "systems/billing/contracts"
      ],
      "ownerRole": "billing-maintainer"
    }
  ]
}

The registry does not replace MEMORY.md.

It serves the harness to:

resolve owner
build write scope
build dependency policy
route task
identify public roots

The MEMORY remains the rich local map intended for the maintainer.

Chapter 27

Local maintenance vs migration

This is a critical distinction.

If I change the implementation while preserving the contract:

maintenance

If I change the public promise:

migration

Example:

export interface IBillingApi {
  createInvoice(input: CreateInvoiceInput): Promise<CreateInvoiceResult>;
}

If I replace Prisma with direct PostgreSQL: maintenance.

If I change:

createInvoice(...)

to force every consumer to provide a new mandatory public field: potentially a migration.

I want the agent to stop and handle that consequence explicitly instead of hiding it inside a “small refactor”.

Part V · Operating over time
Chapter 28

Cross-system: don’t turn the orchestrator into a super-agent

Some features genuinely touch several systems.

Example:

Orders
  ↓
Billing
  ↓
Notification

That does not mean I give a giant agent the right to modify the whole repository.

The work can be split:

orchestrator
   │
   ├── Billing migration / change
   │      ↓
   │   Billing maintainer
   │
   ├── Orders consumer update
   │      ↓
   │   Orders maintainer
   │
   └── integration verification

Each owner keeps its boundary.

The cross-system change becomes an orchestration of contracts and receipts.

Chapter 29

The hidden benefit: switching agents becomes less risky

Maintaining a project with agents raises a particular problem: context disappears.

The model that wrote a feature yesterday may not be the one that maintains it tomorrow.

It may be:

  • another run;
  • another model;
  • another provider;
  • a human;
  • a specialized reviewer.

I don’t want the understanding of the system to depend on the continuity of the conversation.

I want it to depend on artifacts versioned with the code.

Agent A
  ↓
changes code + updates local system memory
  ↓
commit
  ↓
Agent B
  ↓
reads SOUL + AGENT + MEMORY + contracts
  ↓
continues maintenance

It is a property of surviving a change of maintainer.

Chapter 30

What this architecture costs

It is not free.

It requires:

  • more discipline;
  • explicit contracts;
  • local artifacts;
  • gates;
  • a registry;
  • maintenance of MEMORY.md;
  • dependency boundaries;
  • a clear distinction between maintenance and migration.

For a 2,000-line application that will be thrown away in a week, I probably would not build all of this.

The cost becomes worthwhile when:

lifetime ↑
team/agents ↑
change frequency ↑
domain complexity ↑
risk ↑
repository size ↑

So I treat this architecture as an investment in the future cost of change.

Chapter 31

The risks of my own approach

A serious architecture must also describe its failure modes.

31.1 Too many boxes

If every function becomes a box, you replace a monolith with a bureaucracy of micro-systems.

Remedy: a box must represent an autonomous capability, not a helper.

31.2 Stale MEMORY.md

This is the most serious risk.

Remedy: deterministic audit + a freshness/completeness gate.

31.3 Overly abstract contracts

An interface created “just in case” adds complexity without benefit.

Remedy: introduce an abstraction when a real boundary or axis of variation exists.

31.4 Too many documents

SOUL.md, AGENT.md and MEMORY.md can end up repeating the same thing.

Remedy: distinct documentary responsibilities.

31.5 False sense of security

A well-documented box can still contain a bug.

Remedy: tests, evidence, hard gates, integration verification.

31.6 Framework leakage

In NestJS, it is easy to turn modules into a facade of coupled classes.

Remedy: treat exports as a real API and verify cross-box imports.

Chapter 32

What I measure to know whether it works

I don’t want to evaluate this architecture on its visual elegance.

I want to measure maintenance.

A few useful metrics:

files loaded per local task
cross-box files modified per local task
unauthorized write attempts
boundary violations
MEMORY drift incidents
contract migrations per change
median local change diff size
handoff success with a fresh agent
percentage of tasks completed without repo-wide exploration

The most interesting test is simple:

Can a fresh maintainer correctly modify an important Box without having to understand the entire repository?

If the answer is no, the cognitive boundary is not yet strong enough.

Chapter 33

My main quality criterion

A good architecture is not just one in which the current code works.

I am looking for an architecture in which:

change is local
contracts are visible
ownership is explicit
context is bounded
knowledge survives handoff
errors are actionable
proof is machine-verifiable

In other words:

The cost of understanding a local change should scale with the local system, not with the entire repository.

This is the core of how I think about maintenance with AI agents.

Part VI · Checklists and the final model
Chapter 34

Practical checklist: creating a new Box

Before creating a Box:

  • does it have a real, autonomous responsibility?
  • is there a useful contract boundary?
  • can it evolve internally without exposing its details?
  • does it have local tests?
  • can an owner be identified?

Then create:

SOUL.md
AGENT.md
MEMORY.md
contracts/
src or application/domain/infrastructure
tests/

Then define:

  • public roots;
  • write scope;
  • authorized consumers;
  • consumed dependencies;
  • verification commands;
  • memory audit;
  • dependency boundary gate.
Chapter 35

Practical checklist: maintaining a Box

Before coding:

  • resolve the owner;
  • read SOUL.md;
  • read AGENT.md;
  • read MEMORY.md;
  • read the public contracts;
  • load only the relevant local files.

During the change:

  • stay within the write scope;
  • preserve the contract if possible;
  • do not import foreign internals;
  • do not introduce a speculative abstraction;
  • derive values from their source of truth;
  • produce actionable errors.

Before DONE:

  • update MEMORY.md;
  • verify that it matches the filesystem;
  • run the local tests;
  • verify the boundaries;
  • verify contract compatibility;
  • verify the real integration;
  • obtain the required receipts.
Chapter 36

Example of a complete, compact NestJS MEMORY.md

# System memory

system_id: billing

## Mission

Own invoice issuance and invoice-level payment state.

## Provides

- BILLING_API → IBillingApi

## Consumes

- CUSTOMER_READER → ICustomerReader
- CLOCK → IClock

## Public API

| Token | Contract | File |
|---|---|---|
| BILLING_API | IBillingApi | contracts/billing-api.contract.ts |

## Nest module

| Module | File | Role |
|---|---|---|
| BillingModule | billing.module.ts | Local composition root |

## Controllers

| Controller | File | Route | Calls |
|---|---|---|---|
| BillingController | transport/billing.controller.ts | POST /invoices | BILLING_API |

## Providers

| Provider | File | Visibility | Role | Scope |
|---|---|---|---|---|
| CreateInvoiceService | application/create-invoice.service.ts | private | Invoice issuance | DEFAULT |
| PrismaInvoiceRepository | infrastructure/prisma-invoice.repository.ts | private | Persistence adapter | DEFAULT |

## DI tokens

| Token | Contract | Binding | Visibility |
|---|---|---|---|
| BILLING_API | IBillingApi | CreateInvoiceService | public |
| INVOICE_REPOSITORY | IInvoiceRepository | PrismaInvoiceRepository | private |

## TypeScript symbol map

| File | Symbols | Visibility | Role |
|---|---|---|---|
| contracts/billing-api.contract.ts | IBillingApi | public | Consumer contract |
| contracts/billing.tokens.ts | BILLING_API, INVOICE_REPOSITORY | mixed | Runtime tokens |
| application/create-invoice.service.ts | CreateInvoiceService | private | Use case orchestration |
| domain/invoice.ts | Invoice, createInvoice | private | Domain model |
| infrastructure/prisma-invoice.repository.ts | PrismaInvoiceRepository | private | Persistence |
| transport/billing.controller.ts | BillingController | private | HTTP adapter |
| billing.module.ts | BillingModule | entry | Nest wiring |

## Entry points

- Nest module: BillingModule
- Public token: BILLING_API

## Invariants

- No foreign system accesses invoice storage directly.
- No consumer imports infrastructure/**.
- HTTP DTOs never become domain contracts.

## Verification

- pnpm test -- billing
- G01 write scope
- G02 memory completeness
- G03 dependency boundaries
- G05 contract compatibility
- G13 integration

## Mandatory maintenance rule

Whenever a file, function, type, interface, class, provider, controller,
DI token, adapter, module export, entry point or cross-box dependency changes,
update this MEMORY.md from the final verified implementation before DONE.
Chapter 37

The final mental model

I summarize my architecture like this:

Repository
│
├── Box A
│   ├── public contract
│   ├── private implementation
│   ├── SOUL
│   ├── AGENT
│   ├── MEMORY
│   ├── tests
│   └── gates
│
├── Box B
│   ├── public contract
│   ├── private implementation
│   └── ...
│
└── Composition / orchestration

Systems do not collaborate by sharing their internals.

They collaborate through contracts.

Agents do not automatically receive the entire repository.

They receive a context bounded by ownership.

Documentation is not merely narrative.

An important part of it is auditable.

And DONE is not the model’s opinion.

It is an authorization derived from evidence.

Closing

Conclusion

I believe the next generation of software architecture will have to optimize two things at the same time:

  1. human understanding;
  2. maintainability by agents with limited, non-persistent context.

That does not mean writing code “for the LLM” at the expense of developers.

On the contrary, the properties I am looking for are just as useful to humans:

  • clear responsibilities;
  • narrow contracts;
  • less coupling;
  • actionable errors;
  • local changes;
  • reliable documentation;
  • explicit ownership;
  • reproducible verification.

The difference is that the agent makes the cost of ambiguity much more visible.

An implicit architecture can be gradually understood by a stable team over months.

A fresh agent has to rebuild it every time.

My approach therefore consists of turning as much of that tacit knowledge as possible into boundaries, contracts, local memory and proof.

More operational constraints can mean less behavioral entropy.

And for me, that is where the main advantage lies:

maintenance that is more local, more predictable and more transferable — even when the maintainer changes with every task.
Appendix A

Pure TypeScript vs NestJS

TopicPure TypeScriptNestJS
Compositionfactories/functions@Module() + DI + functions
Classesexceptionallowed for framework primitives
Public contractinterface/typeinterface/type + runtime token
Wiringexplicit factory compositionprovider registration
Private implementationmodule internalsnon-exported providers + internal folders
Cross-box dependencycontract importexported token + contract
Pure domain logicfunctions preferredfunctions still preferred
Circular dependencyforbiddenforwardRef() is not normal architecture
MEMORYfiles/functions/types/interfaces+ modules/controllers/providers/tokens/scopes/exports
Appendix B

Anti-patterns I want to detect

[SCOPE_FEATURE_CREEP]
[SCOPE_OPPORTUNISTIC_REFACTOR]
[SCOPE_HIDDEN_CONTRACT_CHANGE]
[TYPE_ANY_ESCAPE]
[TYPE_DUPLICATED_KNOWLEDGE]
[DERIVED_VALUE_HARDCODED]
[DUPLICATED_SOURCE_OF_TRUTH]
[FOREIGN_INTERNAL_IMPORT]
[FOREIGN_STORAGE_ACCESS]
[CROSS_SYSTEM_WRITE_BYPASS]
[LEAKY_PUBLIC_CONTRACT]
[GOD_INTERFACE]
[HIDDEN_GLOBAL_STATE]
[CIRCULAR_DEPENDENCY_INTRODUCED]
[PREMATURE_ABSTRACTION]
[SILENT_FAILURE_MASKED]
[FAIL_OPEN_VERIFICATION]
[TEST_EXPECTATION_WEAKENED]
[TEST_DISABLED]
[CAPABILITY_NOT_WIRED]
[UNVALIDATED_EXTERNAL_INPUT]
[NEST_GOD_SERVICE]
[NEST_EXPORT_EVERYTHING]
[NEST_CROSS_BOX_CONCRETE_INJECTION]
[NEST_FORWARD_REF_AS_ARCHITECTURE]
[NEST_HTTP_DOMAIN_COUPLING]
Appendix C

Pack references

The doctrine in this tutorial distills my own internal documentation set, split across these documents:

  • BOXED_SYSTEM_ARCHITECTURE.md
  • BOXED_SYSTEM_OWNERSHIP_RULES.md
  • BOXED_SYSTEM_OWNERSHIP_IMPLEMENTATION_PLAN.md
  • CODE_STYLE_RULES.md
  • FORBIDDEN_IMPLEMENTATION_PATTERNS.md
  • HARD_GATES.md
  • VALUE_DERIVATION_RULES.md

The translation of these principles to NestJS is formalized in:

  • NESTJS_BOXED_ARCHITECTURE_RULES.md

Verified framework references: