DEV Community

Cover image for AGENTS.md + Claude Skills + project hooks: making AI agents follow your architecture
Felix Ezequiel
Felix Ezequiel

Posted on

AGENTS.md + Claude Skills + project hooks: making AI agents follow your architecture

Every TypeScript service I've started in the last three years opened with the same week:

  • Wire up the ORM. Decide between Prisma, MikroORM, Drizzle.
  • Write base classes for AggregateRoot, ValueObject, Entity. Make sure equality is by ID, not reference.
  • Set up an event store. Decide same-transaction or outbox.
  • Get the Unit of Work model right. Avoid the trap of leaking transactions into use cases.
  • Wire GraphQL alongside REST.
  • Write 200–400 tests just for the kernel.

A week. Sometimes two. Before feature #1.

And then the second problem hits: AI tools. Cursor, Claude Code, Copilot, Codex — they accelerate the inside of features, but they don't know your architecture. So you re-prompt them every conversation. "Don't import infrastructure into domain. Use case shouldn't call save(). Aggregates should auto-track." For every. Single. Session.

I built a template that addresses both.

The kernel

The base classes are in src/shared/domain/:

  • Identifier — UUID-based identity. Subclass per domain ID.
  • ValueObject<Props> — immutable via Object.defineProperty. Equality by attributes.
  • Entity<Id, Props> — identity + protected props.
  • AggregateRoot<Id, Props> — auto-tracking.
  • DomainEvent — name, aggregateId, occurredAt, causationId.

The auto-tracking is the part most people miss in DDD-on-TypeScript implementations. When an aggregate calls addDomainEvent(event), it auto-registers itself on a request-scoped AsyncLocalStorage tracker. The Unit of Work drains tracked aggregates on commit and persists them generically.

What that buys you: use cases never call repository.save().

// ❌ Don't
public async execute(cmd: CreateUserCommand): Promise<User> {
  const user = User.create(cmd.userId, cmd.name, cmd.email);
  await this.repository.save(user);
  return user;
}

// ✅ Do
public async execute(cmd: CreateUserCommand): Promise<User> {
  return User.create(cmd.userId, cmd.name, cmd.email);
}
Enter fullscreen mode Exit fullscreen mode

The MikroOrmUnitOfWork.commit() runs everything inside em.transactional(). Same-transaction event store: domain events are persisted to system_events IN THE SAME TRANSACTION as aggregate writes. No dual-write, no outbox poller. Replay is trivial because the event store contains exactly the events that committed.

The AI tooling — the differentiator

This is where the template stops being just a starter kit.

It ships coordinated artifacts for every agent in the wild:

  • AGENTS.md — generic instructions following the AGENTS.md convention. Read by Codex CLI, Aider, and any tool that adopts the spec.
  • CLAUDE.md — concise reference for Claude Code (and useful for human readers too).
  • .github/copilot-instructions.md — minimal mirror because Copilot Chat doesn't follow AGENTS.md yet.
  • Dedicated rule directories for tools that prefer their own conventions: .cursor/rules/, .clinerules/, .continue/rules/, .windsurf/rules/.
  • A scripts/sync-rules.mjs script that keeps all of those mirrors in sync with the canonical skill files — edit the skill, run the script, every agent picks up the new rule.

All point to a single source of truth: .claude/skills/ — 13 engineering skills in Ring format (YAML frontmatter + markdown body). Each skill declares its own activation rules:

---
name: skill:architecture-explainer
trigger: |
  - User asks "how does X work"
  - User asks "explain Y"
  - User asks "why Z"
---
Enter fullscreen mode Exit fullscreen mode

The frontmatter is machine-readable; the body is human-readable. Tools can build dependency graphs from sequence.before / sequence.after. Each skill is self-contained and portable.

Three skills exist specifically for buyer-side onboarding:

  • project-onboarding — guided tour of the template
  • architecture-explainer — answers "how does X work" with actual file references
  • module-walkthrough — step-by-step for adding a new bounded context, aggregate, use case, or domain event

A new dev opens the project, asks Cursor "how does the unit of work pattern work here", and the answer comes back with exact file paths and the rationale — including what alternative was rejected and why.

The hooks — what skills can't do

Skills are LLM-side. The LLM can skip them when it judges. The hooks are harness-side — they run deterministically.

Nine hooks, pure Node ESM (.mjs). Zero runtime dependencies beyond Node — same bytecode runs on Windows, Mac, Linux, in CI, and inside any agent harness:

Hook What it catches
plainobject-checker.mjs ORM entity written without extends PlainObject (MikroORM's #1 footgun)
hexagonal-validator.mjs Domain or application code importing from infrastructure
tdd-checker.mjs Production code without a co-located .test.ts
readable-code-checker.mjs Magic numbers, nested ternaries, long functional chains
safe-refactoring-checker.mjs Direct edits on refactor/* branches
adr-detector.mjs Architectural keywords in prompts without ADR consultation
pr-template-validator.mjs gh pr create with body missing Summary or Test plan
doc-sync-tracker.mjs + doc-sync-checker.mjs Code changed in session but no docs updated

The plainobject-checker is my favorite. MikroORM uses property accessors on managed entities. If your ORM entity doesn't extends PlainObject from @mikro-orm/core, then em.upsert(SomeClass, plainInstance) re-uses identity-mapped proxies and returns proxy values from getters. Mappers see proxies, not primitives. Hydration breaks in subtle ways. I've debugged this footgun three separate times in three separate codebases. Now the hook just blocks the write.

Dual enforcement — works with every agent, not just Claude

The hooks live in .claude/hooks/, but the validation logic doesn't. Each hook is a thin adapter over a pure check function in .claude/hooks/checks/. Those same checks are imported by scripts/precommit.mjs, which runs on every git commit.

What that means in practice: the rules apply at two layers.

  1. Claude Code runtime — when Claude tries to edit/write a file, the hook fires before the change lands.
  2. Git pre-commit — at commit time, regardless of which agent (Cursor, Codex, Aider, Cline, Continue, Windsurf, Copilot) wrote the code. Even unattended commits.

The LLM can skip the runtime hook by routing through a tool that doesn't trigger it. Pre-commit doesn't care. The check runs the same way against the staged diff.

Trade-offs I made

I'd be lying if I said this template is for everyone. Honest list:

Same-transaction event store (no outbox). Outbox is "more correct" but requires a poller. For monoliths, same-tx gives 90% of the value at 10% of the complexity. The ADR is in the repo. If you're building a distributed system from day 1, outbox might be the right call.

MikroORM 7, not Prisma. Prisma's migration ergonomics are nicer. MikroORM's explicit schemas and transactional flush model fit the UoW design better. Schema-first vs code-first; pick your poison.

Hooks in pure Node ESM, not a shell script or external runtime. The hooks are .mjs files. The only requirement is Node — the same Node the project already uses. One toolchain to install, debug, and extend. Same bytecode runs on Windows, Mac, Linux, in CI, inside any agent. The trade-off: hooks track the project's Node version, which means Node 24+.

No integration test database setup ergonomics yet. Integration tests assume you have SQLite locally. Production is Postgres. The migration path is documented but not automated.

Who this is for

  • Senior backend devs starting a new TypeScript service who don't want to spend a week on plumbing
  • Teams adopting DDD for the first time who need a reference that compiles
  • Anyone using AI agents who's tired of re-prompting from scratch every session

Who it's NOT for:

  • People who want a CRUD scaffolder. Use Nest CLI, AdonisJS, or RedwoodJS.
  • People who hate opinions. This template has many.
  • Hobby projects. The kernel is overkill for a Discord bot.

How to use it

git clone <your-fork> my-service && cd my-service
nvm use
npm install
npm start
Enter fullscreen mode Exit fullscreen mode

REST on :3000, GraphQL on :4000. Migrations run automatically. Working CRUD on /users out of the box.

For the full walkthrough on adding your own bounded context, see docs/adding-a-bounded-context.md — or just open the repo with any AI agent and say "I want to add a Product module". The module-walkthrough skill takes it from there.

Closing

The template is $20 on Gumroad: zequiel56.gumroad.com/l/ddd-typescript-template

The patterns aren't proprietary. The Gumroad packaging exists because I want to validate willingness-to-pay for the bundle — kernel + AI tooling + future updates — and because pricing forces clearer thinking about what's actually valuable.

If you build something on top of it, I'd love to hear about it. Drop a comment.

Top comments (0)