Skip to Content
Apso is in public beta. Get started
Get startedCore conceptsThe extensions pattern

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.

Ownership boundaryRegenerate the foundation. Preserve the product.
No lock-in
Generated from .apsorc

Reproducible foundation

src/autogen/entities/src/autogen/controllers/src/autogen/dtos/migrations/

Change the contract, validate it, and generate the same framework structure again.

Written by your team

Preserved product logic

src/extensions/src/integrations/src/workflows/tests/

Keep approvals, billing rules, integrations, and other product decisions in normal framework code.

The repository, generated source, extension code, and deployment configuration remain available to inspect, change, and run elsewhere.

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 composition

The same rule applies in every generated stack: identify the generated directory, then place business rules, integrations, and custom routes outside it.

StackGenerated codeSuggested custom code
TypeScript and NestJSsrc/autogen/src/extensions/
Python and FastAPIapp/autogen/app/extensions/
Go and Ginautogen/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.

src/extensions/project-workflow.service.ts
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.

src/extensions/project-workflow.controller.ts
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.

src/extensions/extensions.module.ts
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 build

If 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

  1. Commit or review your current changes.
  2. Update .apsorc.
  3. Run the scaffold command for your stack.
  4. Inspect changes under the generated directory.
  5. Update extension imports or logic affected by the schema change.
  6. Run tests and start the service locally.
Last updated on