Info PPAAI App

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

  1. Clean Architecture - Three distinct layers (presentation, application, infrastructure)
  2. Repository Pattern - Centralized data access with PolicyRepository
  3. Use Case Pattern - Business logic in dedicated use case classes
  4. Comprehensive Testing - Tests across all layers
  5. Type Safety - Full TypeScript coverage with Zod validation
  6. Error Handling - Try-catch blocks and validation
  7. 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 exports

Adding 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/controllers

Step 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/auth instead 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 null instead 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 protectedProcedure for authenticated endpoints
  • Remove unnecessary async - Don't use async if you're just returning a promise
    • ❌ Bad: .mutation(async ({ input }) => { return useCase.execute(input); })
    • ✅ Good: .mutation(({ input }) => { return useCase.execute(input); })

Async/Await Guidelines

  • No return await - Just return the promise directly
    • ❌ Bad: return await repository.findById(id);
    • ✅ Good: return repository.findById(id);
  • Exception: try-catch blocks - Use return await when 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, remove async

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 ...timestamps from schema-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: true in tsconfig
  • Avoid any - Use unknown instead of any
  • 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 protectedProcedure for 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 async without await
  • 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 async keyword if the function just returns a promise
  • Or add await if 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