The extensions pattern
Apso gives you a generated API and the source code behind it. Keep generated CRUD code separate from the product-specific behavior you add afterward.
Reproducible foundation
src/autogen/entities/src/autogen/controllers/src/autogen/dtos/migrations/Change the contract, validate it, and generate the same framework structure again.
Preserved product logic
src/extensions/src/integrations/src/workflows/tests/Keep approvals, billing rules, integrations, and other product decisions in normal framework code.
Know the boundary
For a TypeScript service, Apso writes entity modules under src/autogen/. Running the scaffold command again can replace files in that directory. Files elsewhere in src/ remain yours.
src/
|-- autogen/ # Generated from .apsorc
| `-- Project/
| |-- Project.entity.ts
| |-- Project.service.ts
| `-- Project.module.ts
|-- extensions/ # Product-specific code
| |-- project-workflow.controller.ts
| |-- project-workflow.service.ts
| `-- extensions.module.ts
`-- app.module.rest.ts # Application compositionThe same rule applies in every generated stack: identify the generated directory, then place business rules, integrations, and custom routes outside it.
| Stack | Generated code | Suggested custom code |
|---|---|---|
| TypeScript and NestJS | src/autogen/ | src/extensions/ |
| Python and FastAPI | app/autogen/ | app/extensions/ |
| Go and Gin | autogen/ | extensions/ |
Add a product workflow
Suppose the generated Project resource has a status field. You can add an archive operation without changing its generated controller.
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Project } from '../autogen/Project/Project.entity';
@Injectable()
export class ProjectWorkflowService {
constructor(
@InjectRepository(Project)
private readonly projects: Repository<Project>,
) {}
async archive(id: string) {
const project = await this.projects.findOne({ where: { id } });
if (!project) {
throw new NotFoundException('Project not found');
}
project.status = 'Archived';
return this.projects.save(project);
}
}Expose that behavior through a route whose purpose is distinct from the generated CRUD routes.
import { Controller, Param, Patch } from '@nestjs/common';
import { ProjectWorkflowService } from './project-workflow.service';
@Controller('project-workflows')
export class ProjectWorkflowController {
constructor(private readonly workflows: ProjectWorkflowService) {}
@Patch(':id/archive')
archive(@Param('id') id: string) {
return this.workflows.archive(id);
}
}Register the entity repository, controller, and service in a normal NestJS module.
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Project } from '../autogen/Project/Project.entity';
import { ProjectWorkflowController } from './project-workflow.controller';
import { ProjectWorkflowService } from './project-workflow.service';
@Module({
imports: [TypeOrmModule.forFeature([Project])],
controllers: [ProjectWorkflowController],
providers: [ProjectWorkflowService],
})
export class ExtensionsModule {}Then add ExtensionsModule to the imports array in src/app.module.rest.ts. Registration is explicit, so a reader can see every custom module that starts with the application.
What belongs here
Use extensions for behavior that expresses how your application works:
- Multi-step workflows such as onboarding, checkout, or approvals
- Integrations with billing, email, search, or analytics providers
- Authorization checks beyond the generated resource policy
- Computed responses that combine multiple entities
- Webhook handlers and background job entry points
Put data shape changes in .apsorc. Regenerate after changing the schema, then keep application behavior in extension code.
Work safely with generated types
Custom modules can import generated entities, DTOs, and services. Those imports give you type checking as the schema evolves. After regeneration, run your application tests so schema changes that affect custom code fail locally.
apso generate --language typescript
npm test
npm run buildIf a custom workflow needs a field that does not exist, add the field to .apsorc first. Avoid editing the generated entity to make the compiler pass because the next scaffold will remove that edit.
Regeneration checklist
- Commit or review your current changes.
- Update
.apsorc. - Run the scaffold command for your stack.
- Inspect changes under the generated directory.
- Update extension imports or logic affected by the schema change.
- Run tests and start the service locally.
