The Extensions Pattern
Add custom business logic without modifying generated code.
The Problem
When you regenerate code after schema changes, APSO overwrites generated files. How do you keep your custom code?
The Solution: Extensions
APSO provides a dedicated extensions/ directory that is never overwritten. This is where all your custom business logic lives.
src/
├── modules/ # Generated (overwritten)
│ └── customer/
│ ├── customer.controller.ts
│ └── customer.service.ts
└── extensions/ # Your code (never touched)
├── services/
│ └── customer.service.extension.ts
├── controllers/
│ └── customer.controller.extension.ts
└── hooks/
└── customer.hooks.tsHow Extensions Work
Service Extensions
Add methods to generated services:
src/extensions/services/customer.service.extension.ts
import { Injectable } from '@nestjs/common';
import { CustomerService } from '../../modules/customer/customer.service';
@Injectable()
export class CustomerServiceExtension {
constructor(private readonly customerService: CustomerService) {}
/**
* Custom business logic: Calculate customer lifetime value
*/
async calculateLTV(customerId: string): Promise<number> {
const orders = await this.customerService.findOrders(customerId);
return orders.reduce((sum, order) => sum + order.total, 0);
}
/**
* Custom business logic: Send welcome email
*/
async onCustomerCreated(customer: Customer): Promise<void> {
await this.emailService.sendWelcome(customer.email, customer.name);
}
}Controller Extensions
Add custom endpoints:
src/extensions/controllers/customer.controller.extension.ts
import { Controller, Get, Param } from '@nestjs/common';
import { CustomerServiceExtension } from '../services/customer.service.extension';
@Controller('customers')
export class CustomerControllerExtension {
constructor(private readonly customerExt: CustomerServiceExtension) {}
@Get(':id/ltv')
async getCustomerLTV(@Param('id') id: string) {
const ltv = await this.customerExt.calculateLTV(id);
return { customerId: id, lifetimeValue: ltv };
}
@Post(':id/welcome-email')
async resendWelcomeEmail(@Param('id') id: string) {
const customer = await this.customerService.findOne(id);
await this.customerExt.onCustomerCreated(customer);
return { success: true };
}
}Lifecycle Hooks
React to CRUD operations:
src/extensions/hooks/customer.hooks.ts
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
@Injectable()
export class CustomerHooks {
@OnEvent('customer.created')
async handleCustomerCreated(payload: { customer: Customer }) {
// Send welcome email
// Create default settings
// Notify sales team
}
@OnEvent('customer.updated')
async handleCustomerUpdated(payload: { customer: Customer; changes: Partial<Customer> }) {
// Log changes
// Sync with CRM
}
@OnEvent('customer.deleted')
async handleCustomerDeleted(payload: { customerId: string }) {
// Clean up related data
// Notify relevant teams
}
}Registering Extensions
Extensions are auto-discovered if you follow the convention. Or register manually:
src/extensions/extensions.module.ts
import { Module } from '@nestjs/common';
import { CustomerServiceExtension } from './services/customer.service.extension';
import { CustomerControllerExtension } from './controllers/customer.controller.extension';
import { CustomerHooks } from './hooks/customer.hooks';
@Module({
providers: [
CustomerServiceExtension,
CustomerHooks,
],
controllers: [
CustomerControllerExtension,
],
exports: [
CustomerServiceExtension,
],
})
export class ExtensionsModule {}Common Extension Patterns
Business Rule Validation
// Validate before creating
async validateCustomerCreate(dto: CreateCustomerDto): Promise<void> {
const existing = await this.customerService.findByEmail(dto.email);
if (existing) {
throw new ConflictException('Email already registered');
}
}Third-Party Integrations
// Sync with Stripe after customer creation
@OnEvent('customer.created')
async syncToStripe(payload: { customer: Customer }) {
await this.stripe.customers.create({
email: payload.customer.email,
metadata: { apsoId: payload.customer.id }
});
}Computed Fields
// Add computed fields to responses
async enrichCustomerResponse(customer: Customer) {
return {
...customer,
orderCount: await this.getOrderCount(customer.id),
lifetimeValue: await this.calculateLTV(customer.id),
};
}Best Practices
Keep Extensions Focused
Each extension should handle one concern. Create multiple extension files rather than one large file.
- Name clearly —
*.extension.tsfor service/controller extensions - Use dependency injection — Inject generated services, don’t access repos directly
- Handle errors — Throw appropriate HTTP exceptions
- Test independently — Extensions should be unit-testable
Regeneration Safety
When you run apso server scaffold:
| Directory | Behavior |
|---|---|
src/modules/ | Overwritten |
src/entities/ | Overwritten |
src/extensions/ | Never touched |
migrations/ | New migrations added |
Next Steps
Last updated on