Development Guide
Best practices and guidelines for developing with the Info PPAAI App architecture
Development Guide
This guide provides practical instructions and best practices for developing features in the Info PPAAI App.
Reference Implementation
The Policy Module serves as the reference implementation demonstrating all architectural patterns and best practices. Use it as a template when building new features.
What the Policy Module Demonstrates
- ✅ Clean Architecture - Three distinct layers (presentation, application, infrastructure)
- ✅ Repository Pattern - Centralized data access with
PolicyRepository - ✅ Use Case Pattern - Business logic in dedicated use case classes
- ✅ Comprehensive Testing - Tests across all layers
- ✅ Type Safety - Full TypeScript coverage with Zod validation
- ✅ Error Handling - Try-catch blocks and validation
- ✅ Dependency Injection - Repository passed to module constructor
Module Location
packages/api/src/modules/policy/
├── application/ # Use cases (business logic)
│ ├── create-policy/
│ ├── delete-policy/
│ ├── get-policy/
│ ├── get-policy-by-slug/
│ └── add-organization-to-policy/
├── infrastructure/ # Repositories (data access)
│ └── repositories/
│ └── policy/
├── presentation/ # Controllers (API layer)
│ └── controllers/
│ └── policy-controller/
├── policy.module.ts # Dependency Injection container
└── index.ts # Public exportsAdding a New Feature Module
Follow these steps to create a new feature with clean architecture:
Step 1: Create Module Structure
Create the module directory and layer folders:
mkdir -p packages/api/src/modules/[feature]/application
mkdir -p packages/api/src/modules/[feature]/infrastructure/repositories
mkdir -p packages/api/src/modules/[feature]/presentation/controllersStep 2: Infrastructure Layer (Data Access)
Create the repository for database operations:
// infrastructure/repositories/[feature]-repository/[feature].repository.ts
import type { db } from "@info-ppaai-app/db";
import { myTable } from "@info-ppaai-app/db/schema";
import { eq } from "drizzle-orm";
export class FeatureRepository {
private db: typeof db;
constructor(db: typeof db) {
this.db = db;
}
async create(data: CreateFeatureData) {
const [item] = await this.db.insert(myTable).values(data).returning();
return item;
}
async findById(id: string) {
const [item] = await this.db
.select()
.from(myTable)
.where(eq(myTable.id, id))
.limit(1);
return item || null;
}
// Add more data access methods
}Write repository tests:
// [feature].repository.test.ts
import { describe, expect, it } from "bun:test";
describe("FeatureRepository", () => {
it("should create a new item", async () => {
// Arrange
const data = { name: "Test Item" };
// Act
const result = await repository.create(data);
// Assert
expect(result.name).toBe("Test Item");
});
});Step 3: Application Layer (Business Logic)
Create use cases for each operation:
// application/create-feature/create-feature.use-case.ts
export class CreateFeatureUseCase {
constructor(private repository: FeatureRepository) {}
async execute(input: CreateFeatureInput) {
// 1. Validate business rules
if (!input.name) {
throw new Error("Name is required");
}
// 2. Perform business logic
const item = await this.repository.create(input);
// 3. Return result
return item;
}
}Write use case tests:
// create-feature.use-case.test.ts
describe("CreateFeatureUseCase", () => {
it("should create feature successfully", async () => {
// Arrange
const mockRepository = {
create: async (data) => ({ id: "1", ...data }),
};
const useCase = new CreateFeatureUseCase(mockRepository);
// Act
const result = await useCase.execute({ name: "Test" });
// Assert
expect(result.name).toBe("Test");
});
});Step 4: Presentation Layer (API)
Create the tRPC router/controller using a factory function that accepts the module:
// presentation/controllers/feature-controller/feature.controller.ts
import { z } from "zod";
import { protectedProcedure, router } from "../../../../../index";
import type { FeatureModule } from "../../feature.module";
export const createFeatureRouter = (module: FeatureModule) => {
return router({
create: protectedProcedure
.input(
z.object({
name: z.string().min(1),
})
)
.mutation(({ ctx, input }) => {
return module.createFeatureUseCase.execute({
...input,
userId: ctx.session.user.id,
});
}),
list: protectedProcedure.query(({ ctx }) => {
return module.featureRepository.findByUserId(ctx.session.user.id);
}),
});
};Step 5: Module Definition
Create the module class to wire up dependencies:
// feature.module.ts
import { db } from "@info-ppaai-app/db";
import { CreateFeatureUseCase } from "./application/create-feature/create-feature.use-case";
import { FeatureRepository } from "./infrastructure/repositories/feature-repository/feature.repository";
export class FeatureModule {
public readonly featureRepository: FeatureRepository;
public readonly createFeatureUseCase: CreateFeatureUseCase;
constructor(dbInstance: typeof db = db) {
this.featureRepository = new FeatureRepository(dbInstance);
this.createFeatureUseCase = new CreateFeatureUseCase(this.featureRepository);
}
}Step 6: Module Index
Export public APIs from the module:
// index.ts
export * from "./infrastructure/repositories/feature-repository/feature.repository";
export { createFeatureRouter } from "./presentation/controllers/feature-controller/feature.controller";
export { FeatureModule } from "./feature.module";Step 7: Register Router
Instantiate the module and add the router to the main tRPC router:
// packages/api/src/routers/index.ts
import { createFeatureRouter, FeatureModule } from "../modules/feature";
// Instantiate the module once
const featureModule = new FeatureModule();
export const appRouter = router({
// ... existing routers
feature: createFeatureRouter(featureModule),
});Step 8: Frontend Integration
Create custom hooks for the feature:
// apps/web/src/hooks/use-features.ts
import { trpc } from "@/utils/trpc";
export function useFeatures() {
return trpc.feature.list.useQuery();
}
export function useCreateFeature() {
const utils = trpc.useUtils();
return trpc.feature.create.useMutation({
onSuccess: () => {
utils.feature.list.invalidate();
},
});
}Note: Organizations are now managed by Better Auth. For organization-related features, use the
authClient.organization.*methods from@info-ppaai-app/authinstead of custom tRPC routes.
Best Practices
Code Organization
- ✅ One use case per file - Keep use cases focused on a single responsibility
- ✅ Colocate tests - Place test files next to the code they test
- ✅ Named exports - Use named exports for better refactoring support
- ✅ Consistent naming - Follow the pattern:
[action]-[entity].use-case.ts
Repository Pattern
- ✅ Single responsibility - Each repository manages one entity
- ✅ Return null for not found - Use
nullinstead of throwing errors - ✅ Type safe methods - Use TypeScript interfaces for all parameters
- ✅ Async/await consistency - Always use async/await for database operations
Use Cases
- ✅ Pure business logic - Keep UI and infrastructure concerns out
- ✅ Single execute method - Each use case has one public
execute()method - ✅ Validation first - Validate inputs before processing
- ✅ Error handling - Use try-catch for expected errors
- ✅ Dependency injection - Pass repositories through constructor
Controllers/Routers
- ✅ Thin controllers - Delegate logic to use cases
- ✅ Zod validation - Validate all inputs with Zod schemas
- ✅ Auth middleware - Use
protectedProcedurefor authenticated endpoints - ✅ Remove unnecessary async - Don't use
asyncif you're just returning a promise- ❌ Bad:
.mutation(async ({ input }) => { return useCase.execute(input); }) - ✅ Good:
.mutation(({ input }) => { return useCase.execute(input); })
- ❌ Bad:
Async/Await Guidelines
- ✅ No return await - Just return the promise directly
- ❌ Bad:
return await repository.findById(id); - ✅ Good:
return repository.findById(id);
- ❌ Bad:
- ✅ Exception: try-catch blocks - Use
return awaitwhen you need to catch errors:try { return await repository.create(data); } catch (error) { // Handle error } - ✅ Remove async if no await - If a function doesn't use
await, removeasync
Testing
- ✅ AAA pattern - Arrange, Act, Assert structure
- ✅ Descriptive names - Test names should describe behavior: "should create user when valid input provided"
- ✅ Mock dependencies - Mock repositories in use case tests
- ✅ Test edge cases - Include tests for errors, null values, and edge cases
- ✅ Isolated tests - Each test should be independent
Database
- ✅ UUID primary keys - Use
uuid("id").primaryKey().defaultRandom() - ✅ Cascade deletes - Use
{ onDelete: "cascade" }for foreign keys - ✅ Timestamps - Include
...timestampsfromschema-utils - ✅ Nullable vs optional - Use
.nullable()for database nulls,.optional()for TypeScript - ✅ Indexes - Add indexes for frequently queried columns
Frontend
- ✅ Custom hooks - Abstract tRPC calls in custom hooks
- ✅ Cache invalidation - Invalidate queries after mutations
- ✅ Error handling - Handle loading and error states
- ✅ Optimistic updates - Use for better UX when appropriate
- ✅ SSR when needed - Use server components for initial data
TypeScript
- ✅ Strict mode - Keep
strict: truein tsconfig - ✅ Avoid any - Use
unknowninstead ofany - ✅ Explicit return types - Add return types to public methods
- ✅ Interface over type - Use interfaces for object shapes
- ✅ Zod for runtime - Use Zod for runtime validation and type inference
Error Handling
- ✅ Descriptive messages - Include context in error messages
- ✅ Try-catch placement - Catch at the boundary (controller level)
- ✅ Validation errors - Use Zod for input validation
- ✅ Log errors - Log errors with appropriate context
- ✅ User-friendly messages - Return clean error messages to frontend
Security
- ✅ Input validation - Validate all user inputs with Zod
- ✅ Auth checks - Use
protectedProcedurefor authenticated routes - ✅ Authorization - Check user permissions in use cases
- ✅ SQL injection - Use Drizzle ORM parameterized queries
- ✅ Sensitive data - Never log passwords or tokens
Common Patterns
Pagination
// Repository
async findPaginated(limit: number, offset: number) {
return db.select().from(table).limit(limit).offset(offset);
}
// Use case
async execute(input: { page: number; pageSize: number }) {
const offset = (input.page - 1) * input.pageSize;
return this.repository.findPaginated(input.pageSize, offset);
}Filtering
// Repository with filters
async findByFilters(filters: FilterOptions) {
const conditions = [];
if (filters.status) {
conditions.push(eq(table.status, filters.status));
}
if (filters.search) {
conditions.push(ilike(table.name, `%${filters.search}%`));
}
return db.select().from(table).where(and(...conditions));
}Transactions
async execute(input: ComplexInput) {
return db.transaction(async (tx) => {
const item1 = await tx.insert(table1).values(data1).returning();
const item2 = await tx.insert(table2).values(data2).returning();
return { item1, item2 };
});
}Soft Deletes
// Add deletedAt column
deletedAt: timestamp("deleted_at"),
// Repository
async softDelete(id: string) {
const [deleted] = await db
.update(table)
.set({ deletedAt: new Date() })
.where(eq(table.id, id))
.returning();
return deleted;
}
// Filter out deleted records
async findActive() {
return db.select().from(table).where(isNull(table.deletedAt));
}Code Review Checklist
Before submitting a PR, ensure:
- All layers (infrastructure, application, presentation) implemented
- Tests written for repository, use cases, and controllers
- Zod schemas for input validation
- Error handling in place
- TypeScript types/interfaces defined
- No
return await(except in try-catch) - No
asyncwithoutawait - Frontend hooks created for new endpoints
- Cache invalidation configured
- Documentation updated
- Database migration created (if schema changed)
- All tests passing
- Biome check passes
Troubleshooting
Common Issues
Problem: "This async function lacks an await expression"
- Remove the
asynckeyword if the function just returns a promise - Or add
awaitif you need to wait for the promise
Problem: Database type errors with Drizzle
- Ensure all required fields are provided in
.values() - Check that foreign keys match the referenced table's type
- Use
.returning()to get typed results
Problem: tRPC type errors
- Restart TypeScript server in VS Code
- Check that router is exported and imported correctly
- Ensure Zod schema matches the use case input type
Problem: React Query not updating
- Check cache invalidation is called after mutations
- Verify query keys match between query and invalidation
- Use React Query DevTools to inspect cache
Resources
- Policy Module - Reference implementation
- Better Auth Docs - Authentication and organization management
- Drizzle ORM Docs
- tRPC Docs
- Zod Docs
- React Query Docs