Extending Generated Code Recipe
Advanced patterns for extending auto-generated code while maintaining regeneration safety.
Philosophy
TGraph Backend Generator follows a separation of concerns where:
.tg.*files = Auto-generated, safe to overwrite- Custom files = Your business logic, preserved forever
This recipe shows real-world patterns for extension.
Pattern 1: Service Extension with Business Logic
Scenario: E-commerce Order System
Generated Service:
// order.tg.service.ts (auto-generated)
@Injectable()
export class OrderTgService {
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateOrderTgDto): Promise<Order> {
return this.prisma.order.create({ data: dto });
}
async findAll(query: PaginationSearchDto): Promise<PaginatedResponse<Order>> {
// ... pagination logic
}
async update(id: string, dto: UpdateOrderTgDto): Promise<Order> {
return this.prisma.order.update({ where: { id }, data: dto });
}
}
Extended Service:
// order.service.ts (your custom service)
import { OrderTgService } from './order.tg.service';
import { EmailService } from '@/infrastructure/email/email.service';
import { PaymentService } from '@/infrastructure/payment/payment.service';
import { InventoryService } from '../inventory/inventory.service';
@Injectable()
export class OrderService extends OrderTgService {
constructor(
prisma: PrismaService,
private readonly emailService: EmailService,
private readonly paymentService: PaymentService,
private readonly inventoryService: InventoryService,
) {
super(prisma);
}
// Override create to add order processing workflow
async create(dto: CreateOrderTgDto): Promise<Order> {
// Start transaction
return this.prisma.$transaction(async (tx) => {
// 1. Check inventory
await this.inventoryService.checkAvailability(dto.items);
// 2. Create order
const order = await super.create(dto);
// 3. Reserve inventory
await this.inventoryService.reserve(order.id, dto.items);
// 4. Send confirmation email
await this.emailService.sendOrderConfirmation(order);
return order;
});
}
// Custom method: Process payment
async processPayment(orderId: string, paymentMethod: PaymentMethod): Promise<Order> {
const order = await this.findOne(orderId);
if (order.status !== OrderStatus.PENDING) {
throw new BadRequestException('Order cannot be paid');
}
// Process payment
const payment = await this.paymentService.charge({
amount: order.total,
method: paymentMethod,
orderId: order.id,
});
// Update order
return this.prisma.order.update({
where: { id: orderId },
data: {
status: OrderStatus.PAID,
paidAt: new Date(),
paymentId: payment.id,
},
});
}
// Custom method: Cancel order
async cancel(orderId: string, reason: string): Promise<Order> {
const order = await this.findOne(orderId);
if (order.status === OrderStatus.SHIPPED) {
throw new BadRequestException('Cannot cancel shipped order');
}
// Release inventory
await this.inventoryService.release(orderId);
// Refund if paid
if (order.status === OrderStatus.PAID) {
await this.paymentService.refund(order.paymentId);
}
// Update order
const updatedOrder = await this.prisma.order.update({
where: { id: orderId },
data: {
status: OrderStatus.CANCELLED,
cancelledAt: new Date(),
cancellationReason: reason,
},
});
// Send notification
await this.emailService.sendOrderCancellation(updatedOrder);
return updatedOrder;
}
// Custom method: Get user orders with stats
async getUserOrders(userId: string): Promise<{
orders: Order[];
stats: OrderStats;
}> {
const orders = await this.prisma.order.findMany({
where: { userId },
include: { items: true },
orderBy: { createdAt: 'desc' },
});
const stats = {
total: orders.length,
pending: orders.filter((o) => o.status === OrderStatus.PENDING).length,
paid: orders.filter((o) => o.status === OrderStatus.PAID).length,
shipped: orders.filter((o) => o.status === OrderStatus.SHIPPED).length,
totalSpent: orders.reduce((sum, o) => sum + o.total, 0),
};
return { orders, stats };
}
}
Pattern 2: Controller Extension with Custom Endpoints
Generated Controller:
// order.tg.controller.ts (auto-generated)
@Controller('tg-api/orders')
@UseGuards(JwtAuthGuard, AdminGuard)
export class OrderTgController {
constructor(private readonly orderService: OrderTgService) {}
@Get()
async findAll(@Query() query: PaginationSearchDto) {
return this.orderService.findAll(query);
}
@Post()
async create(@Body() dto: CreateOrderTgDto) {
return this.orderService.create(dto);
}
// ... other CRUD endpoints
}
Custom Controller:
// order.controller.ts (your custom controller)
import { OrderService } from './order.service';
@Controller('orders')
@UseGuards(JwtAuthGuard)
export class OrderController {
constructor(private readonly orderService: OrderService) {}
// User-facing endpoints (not admin-only)
@Get('my-orders')
async getMyOrders(@CurrentUser() user: User) {
return this.orderService.getUserOrders(user.id);
}
@Post()
async create(@CurrentUser() user: User, @Body() dto: CreateOrderDto) {
return this.orderService.create({
...dto,
userId: user.id,
});
}
@Put(':id/pay')
async pay(@Param('id') orderId: string, @Body() dto: PaymentDto, @CurrentUser() user: User) {
const order = await this.orderService.findOne(orderId);
// Verify ownership
if (order.userId !== user.id) {
throw new ForbiddenException('Not your order');
}
return this.orderService.processPayment(orderId, dto.paymentMethod);
}
@Put(':id/cancel')
async cancel(@Param('id') orderId: string, @Body() dto: CancelOrderDto, @CurrentUser() user: User) {
const order = await this.orderService.findOne(orderId);
// Verify ownership
if (order.userId !== user.id) {
throw new ForbiddenException('Not your order');
}
return this.orderService.cancel(orderId, dto.reason);
}
@Get(':id/track')
async track(@Param('id') orderId: string, @CurrentUser() user: User) {
const order = await this.orderService.findOne(orderId);
// Verify ownership
if (order.userId !== user.id) {
throw new ForbiddenException('Not your order');
}
return this.orderService.getTrackingInfo(orderId);
}
}
Now you have:
/tg-api/orders– Admin CRUD endpoints/orders– User-facing custom endpoints
Pattern 3: DTO Composition
Generated DTOs:
// create-order.tg.dto.ts (auto-generated)
export class CreateOrderTgDto {
@IsString()
@IsNotEmpty()
userId: string;
@IsNumber()
@IsNotEmpty()
total: number;
@IsEnum(OrderStatus)
@IsOptional()
status?: OrderStatus;
}
Custom DTOs:
// create-order.dto.ts (user-facing)
export class OrderItemDto {
@IsString()
@IsNotEmpty()
productId: string;
@IsInt()
@Min(1)
@Max(100)
quantity: number;
}
export class CreateOrderDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
@ArrayMinSize(1)
items: OrderItemDto[];
@IsString()
@IsNotEmpty()
shippingAddress: string;
@IsString()
@IsOptional()
notes?: string;
}
// payment.dto.ts
export class PaymentDto {
@IsEnum(PaymentMethod)
paymentMethod: PaymentMethod;
@IsString()
@IsOptional()
cardToken?: string;
}
// cancel-order.dto.ts
export class CancelOrderDto {
@IsString()
@MinLength(10)
@MaxLength(500)
reason: string;
}
Pattern 4: Event-Driven Architecture
Add events to your service:
// order.service.ts
import { EventEmitter2 } from '@nestjs/event-emitter';
@Injectable()
export class OrderService extends OrderTgService {
constructor(
prisma: PrismaService,
private readonly eventEmitter: EventEmitter2,
) {
super(prisma);
}
async create(dto: CreateOrderTgDto): Promise<Order> {
const order = await super.create(dto);
// Emit event
this.eventEmitter.emit('order.created', {
orderId: order.id,
userId: order.userId,
total: order.total,
});
return order;
}
async processPayment(orderId: string, paymentMethod: PaymentMethod): Promise<Order> {
const order = await this.paymentProcessing(orderId, paymentMethod);
// Emit event
this.eventEmitter.emit('order.paid', {
orderId: order.id,
userId: order.userId,
total: order.total,
paymentMethod,
});
return order;
}
}
// Listeners
@Injectable()
export class OrderEventsListener {
constructor(
private readonly emailService: EmailService,
private readonly analyticsService: AnalyticsService,
) {}
@OnEvent('order.created')
async handleOrderCreated(payload: OrderCreatedEvent) {
await this.emailService.sendOrderConfirmation(payload.orderId);
await this.analyticsService.trackOrder(payload);
}
@OnEvent('order.paid')
async handleOrderPaid(payload: OrderPaidEvent) {
await this.emailService.sendPaymentReceipt(payload.orderId);
await this.analyticsService.trackRevenue(payload);
}
}
Pattern 5: Interceptors and Middleware
Add cross-cutting concerns:
// logging.interceptor.ts
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
constructor(private readonly logger: LoggerService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
const { method, url, body } = req;
const now = Date.now();
return next.handle().pipe(
tap(() => {
this.logger.log({
method,
url,
duration: Date.now() - now,
body: JSON.stringify(body),
});
}),
);
}
}
// Use in controller
@Controller('orders')
@UseInterceptors(LoggingInterceptor)
export class OrderController {
// All endpoints will be logged
}
Pattern 6: Dashboard Customization
Generated Component:
// OrderList.tsx (auto-generated)
export const OrderList = () => (
<List>
<Datagrid rowClick="edit">
<TextField source="orderNumber" />
<NumberField source="total" />
<TextField source="status" />
<DateField source="createdAt" />
<EditButton />
</Datagrid>
</List>
);
Custom Component:
// OrderListCustom.tsx (your custom component)
import { Chip } from '@mui/material';
const OrderStatusField = ({ source }: { source: string }) => {
const record = useRecordContext();
const status = record?.[source];
const colors = {
PENDING: 'warning',
PAID: 'info',
SHIPPED: 'success',
DELIVERED: 'success',
CANCELLED: 'error',
};
return <Chip label={status} color={colors[status] || 'default'} size="small" />;
};
const OrderFilters = [
<TextInput source="q" label="Search" alwaysOn />,
<SelectInput
source="status"
choices={[
{ id: 'PENDING', name: 'Pending' },
{ id: 'PAID', name: 'Paid' },
{ id: 'SHIPPED', name: 'Shipped' },
{ id: 'DELIVERED', name: 'Delivered' },
{ id: 'CANCELLED', name: 'Cancelled' },
]}
/>,
<DateInput source="createdFrom" label="From" />,
<DateInput source="createdTo" label="To" />,
<NumberInput source="minTotal" label="Min Total" />,
];
const BulkActions = () => (
<>
<BulkExportButton />
<BulkUpdateButton data={{ status: 'SHIPPED' }} label="Mark as Shipped" mutationMode="pessimistic" />
</>
);
export const OrderListCustom = () => (
<List filters={OrderFilters} sort={{ field: 'createdAt', order: 'DESC' }}>
<Datagrid rowClick="show" bulkActionButtons={<BulkActions />}>
<TextField source="orderNumber" />
<ReferenceField source="userId" reference="users">
<TextField source="name" />
</ReferenceField>
<NumberField source="total" options={{ style: 'currency', currency: 'USD' }} />
<OrderStatusField source="status" />
<DateField source="createdAt" showTime />
<DateField source="paidAt" showTime />
<ShowButton />
<EditButton />
</Datagrid>
</List>
);
Update App.tsx:
<Resource
name="orders"
list={OrderListCustom} // Use custom component
edit={OrderEdit}
create={OrderCreate}
show={OrderShow}
/>
Pattern 7: Middleware Chain
Add request/response processing:
// order.module.ts
export class OrderModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware, RateLimitMiddleware, RequestIdMiddleware)
.forRoutes(OrderController, OrderTgController);
}
}
Pattern 8: Guards and Policies
Add authorization logic:
// order-ownership.guard.ts
@Injectable()
export class OrderOwnershipGuard implements CanActivate {
constructor(private readonly orderService: OrderService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user = request.user;
const orderId = request.params.id;
if (!orderId) return true; // Not a single-resource route
const order = await this.orderService.findOne(orderId);
// Admin can access all
if (user.role === 'ADMIN') return true;
// User can only access their own
return order.userId === user.id;
}
}
// Use in controller
@Controller('orders')
@UseGuards(JwtAuthGuard, OrderOwnershipGuard)
export class OrderController {
// All endpoints check ownership
}
Pattern 9: Response Transformation
Add response DTOs:
// order-response.dto.ts
export class OrderResponseDto {
id: string;
orderNumber: string;
total: number;
status: OrderStatus;
items: OrderItemResponseDto[];
user: UserSummaryDto;
createdAt: Date;
paidAt?: Date;
shippedAt?: Date;
static fromEntity(order: Order & { items: OrderItem[], user: User }): OrderResponseDto {
return {
id: order.id,
orderNumber: order.orderNumber,
total: order.total,
status: order.status,
items: order.items.map(OrderItemResponseDto.fromEntity),
user: UserSummaryDto.fromEntity(order.user),
createdAt: order.createdAt,
paidAt: order.paidAt,
shippedAt: order.shippedAt,
};
}
}
// Use in controller
@Get(':id')
async findOne(@Param('id') id: string): Promise<OrderResponseDto> {
const order = await this.orderService.findOne(id);
return OrderResponseDto.fromEntity(order);
}
Pattern 10: Testing Custom Extensions
// order.service.spec.ts
describe('OrderService', () => {
let service: OrderService;
let emailService: EmailService;
let paymentService: PaymentService;
let inventoryService: InventoryService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
OrderService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
{
provide: EmailService,
useValue: {
sendOrderConfirmation: jest.fn(),
sendOrderCancellation: jest.fn(),
},
},
{
provide: PaymentService,
useValue: {
charge: jest.fn(),
refund: jest.fn(),
},
},
{
provide: InventoryService,
useValue: {
checkAvailability: jest.fn(),
reserve: jest.fn(),
release: jest.fn(),
},
},
],
}).compile();
service = module.get<OrderService>(OrderService);
emailService = module.get<EmailService>(EmailService);
paymentService = module.get<PaymentService>(PaymentService);
inventoryService = module.get<InventoryService>(InventoryService);
});
describe('create', () => {
it('should create order with full workflow', async () => {
const dto = { userId: 'user1', items: [...], total: 100 };
jest.spyOn(inventoryService, 'checkAvailability').mockResolvedValue(true);
jest.spyOn(inventoryService, 'reserve').mockResolvedValue(undefined);
jest.spyOn(emailService, 'sendOrderConfirmation').mockResolvedValue(undefined);
const order = await service.create(dto);
expect(inventoryService.checkAvailability).toHaveBeenCalledWith(dto.items);
expect(inventoryService.reserve).toHaveBeenCalled();
expect(emailService.sendOrderConfirmation).toHaveBeenCalled();
expect(order).toBeDefined();
});
});
describe('cancel', () => {
it('should cancel order and refund', async () => {
const order = {
id: '1',
status: OrderStatus.PAID,
paymentId: 'pay123'
};
jest.spyOn(service, 'findOne').mockResolvedValue(order as any);
jest.spyOn(paymentService, 'refund').mockResolvedValue(undefined);
await service.cancel('1', 'Changed mind');
expect(paymentService.refund).toHaveBeenCalledWith('pay123');
expect(inventoryService.release).toHaveBeenCalledWith('1');
});
});
});
Best Practices
1. Never Edit .tg.* Files
Always extend, never modify generated files.
2. Use Clear Naming
OrderService(custom) extendsOrderTgService(generated)OrderController(custom) alongsideOrderTgController(generated)
3. Separate Concerns
- Generated = CRUD
- Custom = Business logic
4. Document Custom Logic
Add clear comments explaining why custom logic exists.
5. Test Custom Extensions
Focus tests on your custom logic, not generated CRUD.
Next Steps
- Custom Validation – Validation patterns
- File Uploads – Upload handling
- Customization Guide – More patterns