There's a failure mode that's becoming increasingly common as AI coding tools improve.
It goes like this: a developer opens Copilot or a chat interface, describes the feature they want, and accepts the generated code. The code works. The tests pass. The PR merges. Six months later, the codebase is a maze of poorly bounded modules, unclear ownership, and circular dependencies — all generated at high speed by a tool that had no context about the architectural intent.
I call this AI-accelerated technical debt. The debt accumulates faster than before because the *volume* of code you can produce has increased, but the *quality of the underlying design decisions* hasn't changed.
The solution isn't to use AI less. It's to use it differently.
The Rule I Follow
> Design the architecture first. Document the decisions. Then use AI to implement the design.
This is the core principle I apply on every project. The AI handles implementation — the scaffolding, the boilerplate, the repetitive patterns, the type signatures. I handle the architecture — the domain boundaries, the dependency direction, the data ownership, the failure modes.
The result: AI produces code that fits the design rather than code that *is* the design.
What "Architecture First" Actually Means in Practice
Before I write a single prompt or touch a code editor, I produce three things:
1. A Domain Map
I draw the bounded contexts — which domains exist, what each one owns, what it exposes to others, and what it never exposes.
For the Portfolio Nexus API, this looked like:
domains/
├── suggestions/ ← Owns: Suggestion entity, moderation flow, rate limiting policy
├── github-stats/ ← Owns: GitHub heatmap data, caching, proxy endpoint
├── admin/ ← Owns: Auth, JWT cookie, admin actions (calls suggestions service)
└── spider-web/ ← Owns: Config read, radar chart data endpointThe rule: a domain may call another domain's *service interface*. It may never import from another domain's *internal files* (repository, policy, DTO).
I write this down as a diagram before any code exists. This takes 20–40 minutes. It saves 20–40 hours of refactoring later.
2. Architectural Decision Records (ADRs)
For each non-obvious structural choice, I write a short ADR:
# ADR-001: Modular Monolith over Microservices
## Context
The API serves 4 endpoints across 3 domains. The team is 1 developer.
## Decision
Build as a modular monolith. Domain isolation enforced by ESLint boundaries
plugin, not by network boundaries.
## Rationale
A microservices split at this scale adds deployment complexity, network latency,
and distributed transaction problems with no meaningful benefit.
The monolith can be split later if load actually demands it.
## Consequences
All domains share a process. Scaling is vertical until further notice.This takes 10 minutes per decision. It's invaluable when I'm prompting AI three months later and need to explain "don't add a separate microservice for this."
3. A Dependency Direction Rule
Before generating any code, I define the allowed import graph:
Router → Controller → Service → Policy → Repository → Database
↑
(dependencies flow inward)
(domain → nothing external)Any generated code that violates this direction gets rejected immediately, regardless of whether it "works."
How I Actually Use AI to Implement
Once the design is documented, I use AI heavily — but with explicit context injection.
The Context Prompt Pattern
Every session starts with a context block:
You are implementing a specific domain module for a modular monolith Express.js API.
Architecture rules (non-negotiable):
- Domains import from other domains only through their index.ts public API
- Controllers are the ONLY files that import Express Request/Response/NextFunction
- Services contain business logic only — no raw DB queries, no HTTP objects
- Repositories contain data queries only — no business logic
- Policies are pure functions — no I/O, no async, no database calls
- All environment variables are read only from src/config/env.ts
- No 'any' types. No @ts-ignore.
Current domain: suggestions
Existing files: suggestions.service.ts, suggestions.policy.ts, suggestions.repository.ts
Task: implement the controller layerWithout this context, AI generates a controller that directly queries the database and puts business logic inline. With it, the generated code fits the architecture.
I Review Every Line
This is the non-negotiable part. I don't merge AI-generated code I haven't read.
This isn't skepticism about AI quality. It's professional responsibility. The code goes into a system I'm accountable for. I need to understand it so I can explain it, debug it, and modify it six months from now.
My review checklist for AI-generated code:
- [ ] Does it follow the dependency direction rule?
- [ ] Does it import only from permitted locations?
- [ ] Is there any
anytype hiding in here? - [ ] Are error cases handled, or does it silently swallow errors?
- [ ] Does it handle the edge cases I documented in the ADR?
- [ ] Would I be comfortable explaining every line of this to a client?
If something fails this review, I correct it. Sometimes I prompt again with a correction. Sometimes I just rewrite the relevant section.
The "Fix This" Pattern
When AI generates code that's close but wrong in a specific way, I use targeted correction prompts rather than regenerating from scratch:
This code directly queries the database from the controller:
[paste the bad code]
Move the database query into SuggestionsRepository.findById().
The controller should only call this.service.getById(id) and return the result.
Keep the error handling structure the same.This is faster than a full regeneration and produces more predictable output.
Where AI Genuinely Excels (And Where It Doesn't)
AI is exceptional at:
- TypeScript type signatures for well-defined data shapes
- Boilerplate that follows an established pattern (a 5th repository method when 4 exist)
- Zod schema definition when the fields are specified
- Test scaffolding (generate the test structure; I fill in the assertions)
- Writing migration SQL from a schema diff description
- Refactoring repetitive code to a consistent pattern once I've established what the pattern is
AI consistently struggles with:
- Domain boundary decisions — it doesn't know which domain should own a concept
- Cross-cutting concerns — it tends to handle them at the wrong layer
- Failure mode analysis — it optimizes for the happy path
- Long-term maintainability — it produces working code, not necessarily modifiable code
- Context from 3 months ago — you have to re-inject the architecture every session
The implication: use AI for *within*-layer implementation, not for *across*-layer design decisions.
A Concrete Example: The GitHub Stats Module
When I needed to build the GitHub contribution heatmap proxy, I designed it first:
Decision (5 minutes):
- Lives in
domains/github-stats/ - Public surface: one endpoint
GET /api/github/contributions - Returns cached data with a 1-hour TTL (in-memory Map, v1)
- Never exposes the GitHub token to the website
- Falls back to empty array if the GitHub API is unreachable
ADR (10 minutes):
# ADR-005: In-Memory Cache for GitHub Heatmap
Context: GitHub API has rate limits. Heatmap data doesn't change by the minute.
Decision: Cache in-memory Map with 3600s TTL.
Rationale: Redis adds deployment complexity. The data is non-critical —
an empty heatmap on cache miss is acceptable. Post-v1, replace with Redis.AI prompt (after design was locked):
Implement a GitHub stats service with these exact specifications:
Service: GitHubStatsService
- Method: getContributions() → Promise<ContributionDay[]>
- Uses in-memory Map cache with 3600 second TTL
- Falls back to empty array [] if GitHub GraphQL API returns an error
- GITHUB_TOKEN comes from env.GITHUB_TOKEN (already validated in env.ts)
- No console.log — use logger.info/warn/error from shared/core/logger
- Follows the service layer rules: no HTTP objects, no raw DB queries
Type: ContributionDay = { date: string; count: number; level: 0|1|2|3|4 }The generated service was ~85% correct. I corrected the error handling (it was swallowing errors silently) and added the proper logger calls. Total implementation time: 25 minutes including review and correction.
Without the design phase, I would have spent that 25 minutes debugging something that was structurally wrong.
The Bottom Line
AI is the best implementation accelerant I've encountered. It's also the most dangerous design accelerant I've encountered.
The discipline of architecture-first development — domain maps, ADRs, explicit dependency rules — doesn't become less important when AI is in the loop. It becomes *more* important, because the speed at which structurally wrong code can be generated has increased dramatically.
Design before you prompt. Document the decisions. Let AI fill the scaffold. Review every line.
That's the workflow. It's slower than pure AI generation and faster than pure manual development — and it produces codebases that survive contact with reality.
*The architectural patterns described here are implemented in the Portfolio Nexus — the full ADR set is available in the docs/ directory of the repository.*