Generating a single function with AI is easy — generating a coherent feature that spans a dozen files without breaking the rest of your codebase is where engineering judgment still matters most.
The Core Challenges of Multi-file Generation
When you ask an AI agent to generate a single file, the feedback loop is tight: you read the output, run it, and either accept or reject. When you ask it to generate a full feature across five or more files, the failure modes multiply. Each file is authored in a slightly different "context window snapshot," which means the agent may invent an interface in one file and use a subtly different version of that same interface in another.
The three most common coherence failures are:
- Interface drift — the
UserRepositoryinterface defined intypes.tsdeclaresfindById(id: string), but the service generated later callsgetUserById(id: number). - Import graph inconsistency — the controller imports from
../services/userbut the generated service file was written to../service/users(singular vs. plural, different path convention). - Implicit assumption leakage — the agent "remembers" a design decision made in step one but reverses it in step four without noticing, for example switching from async/await to callbacks midway through a module.
The practical solution is not to hope the agent stays consistent — it is to architect the generation order so that inconsistencies become immediately visible and type-checkable.
Learning tip: Think of multi-file generation as a dependency graph problem. Generate nodes with no dependencies first (types, interfaces, constants), then work outward toward nodes that consume them. Never generate a consumer before its contract exists.
Strategies for Ordering Generation
The most reliable ordering strategy mirrors how compilers resolve dependencies: start from the leaves of the dependency tree and work toward the entry points.
For a typical backend CRUD feature the practical order is:
- Domain types and interfaces (
types.ts,interfaces/) — no imports, no side effects, pure contracts - Repository layer — depends only on types; defines data access contracts
- Service layer — depends on repository interfaces, not implementations
- Controller/handler layer — depends on service interfaces
- Route/entrypoint wiring — depends on controllers
- Tests — generated last, when you have a complete picture of every public API
Each step gives you a concrete artifact to feed into the next prompt. Rather than giving the agent a vague instruction ("generate the full user module"), you feed it the already-generated types file and say "given these types, generate the repository."
This also means that TypeScript compilation becomes your free verification tool. After generating steps 1 through 3, run tsc --noEmit. Any interface mismatch surfaces immediately, before you have written a single line of business logic in step 4.
Learning tip: Keep each generation step as a separate commit. This gives you a clean rollback point if a later step introduces drift, and it lets you re-run only the failed step with a corrected prompt rather than regenerating everything.
Instructing Agents to Maintain Consistency Across Files
AI agents do not have persistent memory between separate prompts unless you explicitly provide it. That means consistency is your responsibility, not the agent's. Three techniques are reliable in practice:
Paste the contract. Before asking the agent to generate the service layer, paste the entire types file and the repository interface into the prompt. Do not summarize — paste verbatim. Summaries introduce paraphrase drift.
Lock naming conventions explicitly. If your codebase uses PascalCase for types and camelCase for function names, say so in the prompt header. If your repository methods follow the pattern findOne, findMany, create, update, delete, say so explicitly.
Use a generation brief. A generation brief is a short markdown block at the top of every prompt that describes the module you are building, the files already generated, and the exact file you are generating now. It takes thirty seconds to write and eliminates entire categories of hallucinated imports.
Learning tip: Treat each prompt as if the agent has complete amnesia about all previous prompts. Anything it needs to know must be pasted in. This mental model prevents the most common consistency bugs.
Using TypeScript and Type-checking as Verification
Type-checking after generation is not optional — it is the cheapest automated correctness signal available to you. The workflow is:
- Generate a batch of files (for example, types + repository interface).
- Run
tsc --noEmitimmediately. - If there are errors, paste them back into the agent with the instruction: "These are the TypeScript errors produced after generating this file. Fix them without changing the public interface."
- Repeat until clean.
- Move to the next batch.
This tight loop turns type errors into structured feedback rather than manual debugging sessions. The agent is generally very good at fixing type errors when you give it the exact compiler output.
For additional verification, ESLint with architectural rules (for example eslint-plugin-import with restricted paths) can catch import graph violations that TypeScript alone misses. If your project separates layers, configure import restrictions so that a repository cannot import from the controller layer — then run the linter after each generation step.
Learning tip: Create a simple shell alias
gencheckthat runstsc --noEmit && eslint --ext .ts src/so you can run verification with one keystroke after every generation step. Speed of feedback determines quality of output.
Decomposing Large Features into File-level Tasks
Before opening a prompt, write the file-level task list for the feature on paper or in a scratch file. For each file, note:
- What it imports (its dependencies)
- What it exports (its public surface)
- What behavior it must exhibit (one sentence)
This decomposition forces you to think through the design before the agent does, which means you catch design problems at the planning stage rather than at the code review stage. It also gives you a checklist to verify the agent's output against.
A practical task list for a CRUD user module might look like:
[ ] types.ts — exports User, CreateUserInput, UpdateUserInput
[ ] user.repository.interface.ts — exports IUserRepository with findById, findMany, create, update, delete
[ ] user.repository.ts — implements IUserRepository using Prisma
[ ] user.service.ts — depends on IUserRepository, implements business rules
[ ] user.controller.ts — depends on UserService, maps HTTP to service calls
[ ] user.routes.ts — wires controller to Express router
[ ] user.service.spec.ts — unit tests for service layer with mocked repository
[ ] user.controller.spec.ts — integration tests for controller with mocked service
With this list in hand, each generation prompt becomes a focused, single-file instruction rather than an open-ended request.
Learning tip: Share this task list with the agent in the first prompt of the session. Tell it "we will generate these files in this order, one at a time." It primes the agent's context and reduces the chance of it generating extra files you did not ask for.
Hands-On: Generating a Full CRUD Module
This exercise walks through generating a complete user CRUD module for a TypeScript/Express/Prisma backend. Each step includes the prompt to use.
Step 1 — Define the domain types
Create a blank file src/modules/user/types.ts and run the following prompt.
Generate the TypeScript domain types for a user management module.
Requirements:
- A `User` type representing the database entity with fields: id (string, UUID), email (string), displayName (string), role ('admin' | 'member' | 'viewer'), createdAt (Date), updatedAt (Date)
- A `CreateUserInput` type: pick email and displayName from User, add role as optional (defaults to 'member')
- An `UpdateUserInput` type: all fields from CreateUserInput made optional, no id or timestamps
- Export all types as named exports
Do not add any imports. Do not add any logic. Pure type definitions only.
File: src/modules/user/types.ts
Expected output: a clean types.ts with three exported types and no runtime code.
Step 2 — Generate the repository interface
Paste the contents of types.ts into the prompt.
Given the following TypeScript types:
[paste contents of types.ts here]
Generate a repository interface file for the user module.
Requirements:
- Interface name: IUserRepository
- Methods: findById(id: string): Promise<User | null>, findMany(filters?: { role?: User['role'] }): Promise<User[]>, create(input: CreateUserInput): Promise<User>, update(id: string, input: UpdateUserInput): Promise<User>, delete(id: string): Promise<void>
- All methods are async (return Promises)
- Export IUserRepository as a named export
- Import types from './types'
File: src/modules/user/user.repository.interface.ts
Step 3 — Generate the Prisma repository implementation
Paste both generated files into the next prompt.
Given these files:
[paste types.ts]
[paste user.repository.interface.ts]
Generate the Prisma implementation of IUserRepository.
Requirements:
- Class name: PrismaUserRepository
- Implements IUserRepository
- Constructor takes a PrismaClient instance via dependency injection
- Use prisma.user.findUnique for findById, prisma.user.findMany for findMany (apply role filter if provided), prisma.user.create for create (use crypto.randomUUID() for id), prisma.user.update for update, prisma.user.delete for delete
- Import PrismaClient from '@prisma/client'
- Handle the case where findById returns null (return null, do not throw)
File: src/modules/user/user.repository.ts
Step 4 — Generate the service layer
Given these files:
[paste types.ts]
[paste user.repository.interface.ts]
Generate the user service.
Requirements:
- Class name: UserService
- Constructor takes IUserRepository via dependency injection (do not import the concrete implementation)
- Methods: getById(id: string): Promise<User> — throws NotFoundError if null, list(filters?): Promise<User[]>, create(input: CreateUserInput): Promise<User>, update(id: string, input: UpdateUserInput): Promise<User> — throws NotFoundError if user does not exist, remove(id: string): Promise<void>
- Define a simple NotFoundError class in the same file extending Error
- No HTTP concerns (no request/response objects)
File: src/modules/user/user.service.ts
Step 5 — Generate the controller
Given these files:
[paste types.ts]
[paste user.service.ts]
Generate an Express controller for the user module.
Requirements:
- Class name: UserController
- Constructor takes UserService via dependency injection
- Methods: getById, list, create, update, remove — each is an Express RequestHandler
- Map NotFoundError to 404 JSON response: { error: 'User not found' }
- Map validation errors (missing required fields in body) to 400
- Successful responses: getById/create/update return 200 with { data: user }, list returns 200 with { data: users }, remove returns 204
File: src/modules/user/user.controller.ts
Step 6 — Generate the route wiring and verify
Given the UserController class from user.controller.ts, generate an Express router file.
Requirements:
- Create a userRouter using express.Router()
- Wire: GET / → controller.list, POST / → controller.create, GET /:id → controller.getById, PATCH /:id → controller.update, DELETE /:id → controller.remove
- Export userRouter as default export
- The router file should not contain any logic — only wiring
File: src/modules/user/user.routes.ts
After generating this file, run tsc --noEmit. If there are errors, paste the compiler output back with: "Fix these TypeScript errors in the files I generated. Do not change the public interface."
Step 7 — Generate unit tests for the service
Given the UserService class and IUserRepository interface, generate unit tests.
Requirements:
- Testing framework: Jest
- Use jest.fn() to mock IUserRepository
- Test cases: getById returns user when found, getById throws NotFoundError when not found, create calls repository.create and returns result, update throws NotFoundError when user does not exist, remove calls repository.delete
- Use describe/it blocks
- Do not test implementation details — only public method behavior
File: src/modules/user/user.service.spec.ts
Step 8 — Run the full type-check and review
Run tsc --noEmit && jest --testPathPattern=user. Review any failures, paste compiler or test output back to the agent with targeted fix prompts.
Key Takeaways
- Generate files in dependency order (types first, entry points last) to surface interface drift immediately rather than at integration time.
- Paste complete file contents — not summaries — into each prompt to prevent interface paraphrase drift.
- Run
tsc --noEmitafter every generation batch and feed compiler errors directly back to the agent as structured feedback. - Decompose features into a file-level task list before generating anything; this forces upfront design and gives you a verification checklist.
- Treat multi-file generation as a series of single-file generation steps, each grounded by the concrete artifacts already produced.